import { authenticate } from '../../plugins/auth.js' import { getCustomerById, markCustomerUspSeen, updateCustomerDisplayName, } from '../../services/customer.service.js' import { completeCustomerPhoneSignIn, signInWithGoogle, signInWithApple, } from '../../services/auth.service.js' import { requestOtp, verifyOtp } from '../../services/otp.service.js' import { verifyAccessToken } from '../../services/token.service.js' import { UserType } from '../../constants.js' const extractDeviceInfo = (request) => ({ user_agent: request.headers['user-agent'] || null, ip: request.ip || null, }) // Phase 4 §2.1 — Resolve the trusted anonymous customer id for the merge path. // Source of truth is a verified Bearer JWT. The body field was deliberately // dropped: a tamper-able UUID in the body lets anyone who learns a victim's // anon id stamp `account_belongs_to` on it, which would mis-route their // transactions during reconciliation. The JWT is HS256-signed with // AUTH_JWT_SECRET — un-forgeable. // // Rules: // - Bearer valid + customer + customer.is_anonymous → return that customer id. // - Bearer valid + customer but NOT anonymous (already verified) → null // - Bearer absent or invalid → null const resolveAnonymousCustomerId = async ({ bearer }) => { if (!bearer || !bearer.startsWith('Bearer ')) return null try { const claims = verifyAccessToken(bearer.slice('Bearer '.length)) if (claims.userType !== UserType.CUSTOMER) return null const customer = await getCustomerById(claims.userId) return customer?.is_anonymous ? customer.id : null } catch { return null } } const sendAuthError = (reply, err) => { if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error') return reply.code(err.statusCode || 500).send({ success: false, error: { code: err.code || 'INTERNAL', message: err.message, ...(err.details && { details: err.details }), }, }) } export const clientAuthRoutes = async (app) => { // --- Phone OTP --- app.post('/otp/request', async (request, reply) => { const { phone, channel } = request.body || {} try { const result = await requestOtp({ phone, userType: UserType.CUSTOMER, ipAddress: request.ip, channel, }) return reply.send({ success: true, data: result }) } catch (err) { return sendAuthError(reply, err) } }) app.post('/otp/verify', async (request, reply) => { const { otp_request_id, code } = request.body || {} try { const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code }) if (user_type !== UserType.CUSTOMER) { return reply.code(400).send({ success: false, error: { code: 'WRONG_FLOW', message: 'This OTP was issued for a different user type' }, }) } // Phase 4 §2.1 — the anon prefix used to drive the // account_belongs_to merge is derived ONLY from the verified Bearer // JWT. Clients that need the merge must send their anonymous session's // access_token as `Authorization: Bearer …`. The body field is no // longer accepted. const anonymousCustomerId = await resolveAnonymousCustomerId({ bearer: request.headers.authorization, }) const { tokens, profile } = await completeCustomerPhoneSignIn({ phone, anonymousCustomerId, deviceInfo: extractDeviceInfo(request), }) return reply.send({ success: true, data: { ...tokens, profile } }) } catch (err) { return sendAuthError(reply, err) } }) // --- Google --- app.post('/google', async (request, reply) => { const { id_token } = request.body || {} if (!id_token) { return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'id_token is required' }, }) } try { // Phase 4 §2.1 — anon prefix is derived ONLY from the Bearer JWT; // body field `anonymous_customer_id` is not accepted. const anonymousCustomerId = await resolveAnonymousCustomerId({ bearer: request.headers.authorization, }) const { tokens, profile } = await signInWithGoogle({ idToken: id_token, anonymousCustomerId, deviceInfo: extractDeviceInfo(request), }) return reply.send({ success: true, data: { ...tokens, profile } }) } catch (err) { return sendAuthError(reply, err) } }) // --- Apple --- app.post('/apple', async (request, reply) => { const { id_token } = request.body || {} if (!id_token) { return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'id_token is required' }, }) } try { // Phase 4 §2.1 — same Bearer-only contract as /otp/verify and /google. const anonymousCustomerId = await resolveAnonymousCustomerId({ bearer: request.headers.authorization, }) const { tokens, profile } = await signInWithApple({ idToken: id_token, anonymousCustomerId, deviceInfo: extractDeviceInfo(request), }) return reply.send({ success: true, data: { ...tokens, profile } }) } catch (err) { return sendAuthError(reply, err) } }) // --- Current user profile --- app.get('/me', { preHandler: authenticate }, async (request, reply) => { if (request.auth.userType !== UserType.CUSTOMER) { return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Customer account required' }, }) } const customer = await getCustomerById(request.auth.userId) if (!customer) { return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Customer account not found' }, }) } return reply.send({ success: true, data: customer }) }) // --- Phase 4: mark USP screen as seen (one-time gate, idempotent) --- app.post('/usp-seen', { preHandler: authenticate }, async (request, reply) => { if (request.auth.userType !== UserType.CUSTOMER) { return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Customer account required' }, }) } const updated = await markCustomerUspSeen(request.auth.userId) if (!updated) { return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Customer account not found' }, }) } return reply.send({ success: true, data: updated }) }) // --- Update display name --- app.patch('/profile', { preHandler: authenticate }, async (request, reply) => { if (request.auth.userType !== UserType.CUSTOMER) { return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Customer account required' }, }) } const { display_name } = request.body || {} if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) { return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'display_name is required' }, }) } const updated = await updateCustomerDisplayName(request.auth.userId, display_name.trim()) return reply.send({ success: true, data: updated }) }) }