Refresh of the open-core distribution from the private tree. Included since the previous snapshot: - Mail module (ADR-158/159/160/169): composer, labels, scheduling, attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083 - Crawler-readable SSR for /blog and public spaces (ADR-190): blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages - Registration policy + referral/promo settings (ADR-183/188) - Message translation + language detection (ADR-185) - Reddit connector for the agent-tool surface Excised from the public distribution (unchanged policy): infrastructure topology and host config, internal ops scripts, DB cleanup snapshots, business documents, throwaway debug scripts, and two private product lines (SC-SIM simulator, personal one-off tools). Real host addresses are replaced with placeholders; credential-shaped literals are redacted. Frontend build verified green on this tree.
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
/**
|
|
* Core auth routes: /register, /login, /logout, /me, /refresh, /password
|
|
*/
|
|
import { registerUser, loginUser } from '../../../services/AuthService.js';
|
|
import { recordSignup } from '../../../services/SignupService.js';
|
|
import { applyPromoUnlock, isPromoEnabled } from '../../../services/starter-pack/StarterPackService.js';
|
|
import {
|
|
respondSuccess, respondError,
|
|
JWT_SECRET, REFRESH_COOKIE_NAME, ACCESS_TOKEN_TTL,
|
|
ACCESS_COOKIE_NAME,
|
|
getRefreshCookieOptions, getAccessCookieOptions,
|
|
createAccessToken, createRefreshToken,
|
|
requireAuth,
|
|
dbGet, dbRun, authLogger, jwt
|
|
} from './authShared.js';
|
|
|
|
/**
|
|
* @param {import('express').Router} router
|
|
*/
|
|
export default function registerCoreRoutes(router) {
|
|
|
|
/**
|
|
* @swagger
|
|
* /auth/register:
|
|
* post:
|
|
* tags: [Auth]
|
|
* summary: Register a new user
|
|
* description: Creates a new user account with email/password
|
|
* security: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - email
|
|
* - password
|
|
* - name
|
|
* properties:
|
|
* email:
|
|
* type: string
|
|
* format: email
|
|
* password:
|
|
* type: string
|
|
* minLength: 8
|
|
* name:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Registration successful
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* success:
|
|
* type: boolean
|
|
* data:
|
|
* type: object
|
|
* properties:
|
|
* user:
|
|
* $ref: '#/components/schemas/User'
|
|
* accessToken:
|
|
* type: string
|
|
* 400:
|
|
* description: Validation error
|
|
* 409:
|
|
* description: Email already exists
|
|
*/
|
|
// POST /api/v2/auth/register
|
|
router.post('/register', async (req, res) => {
|
|
try {
|
|
const { email, password, name, promo_code, signup_source, signup_referrer, user_agent, community, referral_code } = req.body;
|
|
|
|
// Validation
|
|
if (!email || !password || !name) {
|
|
return respondError(res, 400, 'VALIDATION_ERROR', 'Email, password, and name are required');
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
return respondError(res, 400, 'WEAK_PASSWORD', 'Password must be at least 8 characters long');
|
|
}
|
|
|
|
const promoNormalized = typeof promo_code === 'string' && promo_code.trim()
|
|
? promo_code.trim().toUpperCase().slice(0, 32)
|
|
: null;
|
|
|
|
// Register user (includes auto-create projects + ADR-0079 starter pack).
|
|
// ADR-183 Block A: `community: true` marks a street/community sign-up → viewer role.
|
|
// ADR-183 Block C: `referral_code` (if present) is the INVITER's code from the
|
|
// deep-link /start <code>; it is resolved to write-once `referred_by` inside
|
|
// registerUser. Unknown codes are ignored (best-effort attribution).
|
|
const user = await registerUser({
|
|
email, password, name,
|
|
communitySignup: community === true,
|
|
inviteCode: referral_code
|
|
});
|
|
|
|
// ADR-0079 P4: MASTERMIND / MESHOK promo unlocks the Tier-B coding agent pack for this user.
|
|
// Best-effort — never fail the request on unlock issues.
|
|
// ADR-183-A D6: promo is owner-gated. Skip the unlock server-side when the owner has
|
|
// switched promo off (defence-in-depth — not just the FE hiding the field). The flag
|
|
// read only happens when a promo code was actually submitted, keeping the hot path lean.
|
|
if (promoNormalized && await isPromoEnabled()) {
|
|
applyPromoUnlock(user.id, promoNormalized).catch((err) =>
|
|
authLogger.error({ err, userId: user.id }, 'applyPromoUnlock unhandled rejection')
|
|
);
|
|
}
|
|
|
|
// ADR-0070: mirror to Signups table 100045 + send welcome email (best-effort, fire-and-forget)
|
|
recordSignup({
|
|
user,
|
|
promoCode: promoNormalized,
|
|
signupSource: signup_source || 'godcrm.ai/register',
|
|
signupReferrer: signup_referrer || req.get('referer') || null,
|
|
userAgent: user_agent || req.get('user-agent') || null
|
|
}).catch((err) => authLogger.error({ err, userId: user.id }, 'recordSignup unhandled rejection'));
|
|
|
|
// Create tokens
|
|
const accessToken = createAccessToken(user);
|
|
const refreshToken = createRefreshToken(user);
|
|
|
|
// Set refresh token cookie + access_token cookie (ADR-0016 — for <img> auth on /uploads)
|
|
res.cookie(REFRESH_COOKIE_NAME, refreshToken, getRefreshCookieOptions());
|
|
res.cookie(ACCESS_COOKIE_NAME, accessToken, getAccessCookieOptions());
|
|
|
|
// Include refreshToken in body for mobile clients (they can't use httpOnly cookies).
|
|
return respondSuccess(res, {
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
// ADR-183 Block C: the newcomer's own code to hand out (their invite link).
|
|
referral_code: user.referral_code
|
|
},
|
|
accessToken,
|
|
refreshToken
|
|
}, 201);
|
|
|
|
} catch (error) {
|
|
authLogger.error({ err: error }, 'Registration error:', error);
|
|
|
|
if (error.message.includes('already exists')) {
|
|
return respondError(res, 409, 'USER_EXISTS', 'User with this email already exists');
|
|
}
|
|
|
|
// ADR-183 Block F4 (enforce) — signup_mode gate rejected this sign-up: the box
|
|
// is invite-only or closed and this request carried no valid inviter code.
|
|
if (error.code === 'REGISTRATION_NOT_ALLOWED') {
|
|
return respondError(res, 403, 'REGISTRATION_NOT_ALLOWED', error.message);
|
|
}
|
|
|
|
return respondError(res, 500, 'REGISTRATION_FAILED', 'Failed to register user', error.message);
|
|
}
|
|
});
|
|
|
|
// POST /api/v2/auth/login
|
|
router.post('/login', async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
// Validation
|
|
if (!email || !password) {
|
|
return respondError(res, 400, 'VALIDATION_ERROR', 'Email and password are required');
|
|
}
|
|
|
|
// Get IP and User-Agent for audit log
|
|
const ipAddress = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress;
|
|
const userAgent = req.headers['user-agent'];
|
|
|
|
// Login user
|
|
const result = await loginUser(email, password, ipAddress, userAgent);
|
|
|
|
if (!result.success) {
|
|
return respondError(res, 401, 'INVALID_CREDENTIALS', result.error);
|
|
}
|
|
|
|
// Create refresh token
|
|
const refreshToken = createRefreshToken(result.user);
|
|
|
|
// Set refresh token cookie + access_token cookie (ADR-0016 — for <img> auth on /uploads)
|
|
res.cookie(REFRESH_COOKIE_NAME, refreshToken, getRefreshCookieOptions());
|
|
res.cookie(ACCESS_COOKIE_NAME, result.token, getAccessCookieOptions());
|
|
|
|
// Include refreshToken in body for mobile clients (they can't use httpOnly cookies).
|
|
// Web clients still get the httpOnly cookie above for CSRF protection.
|
|
return respondSuccess(res, {
|
|
user: result.user,
|
|
accessToken: result.token,
|
|
refreshToken
|
|
});
|
|
|
|
} catch (error) {
|
|
authLogger.error({ err: error }, 'Login error:', error);
|
|
return respondError(res, 500, 'LOGIN_FAILED', 'Failed to login', error.message);
|
|
}
|
|
});
|
|
|
|
// POST /api/v2/auth/logout
|
|
router.post('/logout', (req, res) => {
|
|
// Clear refresh + access cookies (no auth required - logout should always work)
|
|
res.clearCookie(REFRESH_COOKIE_NAME, getRefreshCookieOptions());
|
|
res.clearCookie(ACCESS_COOKIE_NAME, getAccessCookieOptions());
|
|
|
|
return respondSuccess(res, { status: 'logged_out' });
|
|
});
|
|
|
|
// GET /api/v2/auth/me
|
|
router.get('/me', requireAuth, (req, res) => {
|
|
// Generate new access token to refresh session
|
|
const accessToken = jwt.sign(
|
|
{ id: req.user.id, email: req.user.email, role: req.user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: ACCESS_TOKEN_TTL }
|
|
);
|
|
|
|
// Refresh access_token cookie too (ADR-0016 — for <img> auth on /uploads)
|
|
res.cookie(ACCESS_COOKIE_NAME, accessToken, getAccessCookieOptions());
|
|
|
|
return respondSuccess(res, {
|
|
accessToken,
|
|
user: {
|
|
id: req.user.id,
|
|
email: req.user.email,
|
|
role: req.user.role
|
|
}
|
|
});
|
|
});
|
|
|
|
// POST /api/v2/auth/refresh
|
|
router.post('/refresh', async (req, res) => {
|
|
try {
|
|
const refreshToken = req.cookies[REFRESH_COOKIE_NAME] || req.body.refreshToken;
|
|
|
|
if (!refreshToken) {
|
|
return respondError(res, 401, 'NO_REFRESH_TOKEN', 'Refresh token is missing');
|
|
}
|
|
|
|
// Verify refresh token
|
|
const decoded = jwt.verify(refreshToken, JWT_SECRET);
|
|
|
|
if (decoded.type !== 'refresh') {
|
|
return respondError(res, 401, 'INVALID_TOKEN_TYPE', 'Invalid token type');
|
|
}
|
|
|
|
// Get user from database
|
|
const user = await dbGet('SELECT id, email, name, role FROM users WHERE id = ?', [decoded.id]);
|
|
|
|
if (!user) {
|
|
return respondError(res, 401, 'USER_NOT_FOUND', 'User not found');
|
|
}
|
|
|
|
// Create new access token
|
|
const accessToken = jwt.sign(
|
|
{ id: user.id, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: ACCESS_TOKEN_TTL }
|
|
);
|
|
|
|
// Rotate refresh token — issue a new one so mobile clients stay logged in
|
|
const newRefreshToken = createRefreshToken(user);
|
|
res.cookie(REFRESH_COOKIE_NAME, newRefreshToken, getRefreshCookieOptions());
|
|
// Refresh access_token cookie too (ADR-0016 — for <img> auth on /uploads)
|
|
res.cookie(ACCESS_COOKIE_NAME, accessToken, getAccessCookieOptions());
|
|
|
|
return respondSuccess(res, { accessToken, refreshToken: newRefreshToken, user });
|
|
|
|
} catch (error) {
|
|
authLogger.error({ err: error }, 'Token refresh error:', error);
|
|
return respondError(res, 401, 'INVALID_REFRESH_TOKEN', 'Refresh token is invalid or expired');
|
|
}
|
|
});
|
|
|
|
// PATCH /api/v2/auth/password - Change password
|
|
router.patch('/password', requireAuth, async (req, res) => {
|
|
try {
|
|
const { currentPassword, newPassword } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
// Validation
|
|
if (!currentPassword || !newPassword) {
|
|
return respondError(res, 400, 'VALIDATION_ERROR', 'Current password and new password are required');
|
|
}
|
|
|
|
if (newPassword.length < 8) {
|
|
return respondError(res, 400, 'WEAK_PASSWORD', 'New password must be at least 8 characters long');
|
|
}
|
|
|
|
// Import bcrypt and database
|
|
const bcrypt = await import('bcrypt');
|
|
const { dbGet, dbRun } = await import('../../../database/connection.js');
|
|
|
|
// Get user
|
|
const user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]);
|
|
|
|
if (!user) {
|
|
return respondError(res, 404, 'USER_NOT_FOUND', 'User not found');
|
|
}
|
|
|
|
// Verify current password
|
|
const isValidPassword = await bcrypt.default.compare(currentPassword, user.password_hash);
|
|
|
|
if (!isValidPassword) {
|
|
return respondError(res, 401, 'INVALID_PASSWORD', 'Current password is incorrect');
|
|
}
|
|
|
|
// Hash new password
|
|
const newPasswordHash = await bcrypt.default.hash(newPassword, 10);
|
|
|
|
// Update password
|
|
await dbRun(
|
|
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[newPasswordHash, userId]
|
|
);
|
|
|
|
return respondSuccess(res, { message: 'Password updated successfully' });
|
|
|
|
} catch (error) {
|
|
authLogger.error({ err: error }, 'Password change error:', error);
|
|
return respondError(res, 500, 'PASSWORD_CHANGE_FAILED', 'Failed to change password', error.message);
|
|
}
|
|
});
|
|
}
|