feat(api): complete unified v2 mobile surface

This commit is contained in:
zouantchaw
2026-03-13 17:02:24 +01:00
parent 817a39e305
commit b455455a49
39 changed files with 7726 additions and 506 deletions

View File

@@ -1,14 +1,29 @@
import express from 'express';
import { AppError } from '../lib/errors.js';
import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js';
import {
getSessionForActor,
parseClientSignIn,
parseClientSignUp,
parseStaffPhoneStart,
parseStaffPhoneVerify,
signInClient,
signOutActor,
signUpClient,
startStaffPhoneAuth,
verifyStaffPhoneAuth,
} from '../services/auth-service.js';
import { verifyFirebaseToken } from '../services/firebase-auth.js';
const defaultAuthService = {
parseClientSignIn,
parseClientSignUp,
parseStaffPhoneStart,
parseStaffPhoneVerify,
signInClient,
signOutActor,
signUpClient,
startStaffPhoneAuth,
verifyStaffPhoneAuth,
getSessionForActor,
};
@@ -31,7 +46,7 @@ async function requireAuth(req, _res, next) {
return next();
}
const decoded = await verifyFirebaseToken(token, { checkRevoked: true });
const decoded = await verifyFirebaseToken(token);
req.actor = {
uid: decoded.uid,
email: decoded.email || null,
@@ -77,6 +92,32 @@ export function createAuthRouter(options = {}) {
}
});
router.post('/staff/phone/start', async (req, res, next) => {
try {
const payload = authService.parseStaffPhoneStart(req.body);
const result = await authService.startStaffPhoneAuth(payload, { fetchImpl });
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/staff/phone/verify', async (req, res, next) => {
try {
const payload = authService.parseStaffPhoneVerify(req.body);
const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl });
return res.status(200).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.get('/session', requireAuth, async (req, res, next) => {
try {
const session = await authService.getSessionForActor(req.actor);

View File

@@ -14,10 +14,91 @@ const HOP_BY_HOP_HEADERS = new Set([
'upgrade',
]);
function resolveTargetBase(pathname) {
if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL;
if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL;
if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL;
const DIRECT_CORE_ALIASES = [
{ methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
{
methods: new Set(['POST']),
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
},
{
methods: new Set(['POST']),
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
},
{
methods: new Set(['POST']),
pattern: /^\/staff\/profile\/certificates$/,
targetPath: () => '/core/staff/certificates/upload',
},
{
methods: new Set(['DELETE']),
pattern: /^\/staff\/profile\/certificates\/([^/]+)$/,
targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`,
},
{ methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` },
];
function resolveTarget(pathname, method) {
const upperMethod = method.toUpperCase();
if (pathname.startsWith('/core')) {
return {
baseUrl: process.env.CORE_API_BASE_URL,
upstreamPath: pathname,
};
}
if (pathname.startsWith('/commands')) {
return {
baseUrl: process.env.COMMAND_API_BASE_URL,
upstreamPath: pathname,
};
}
if (pathname.startsWith('/query')) {
return {
baseUrl: process.env.QUERY_API_BASE_URL,
upstreamPath: pathname,
};
}
for (const alias of DIRECT_CORE_ALIASES) {
if (!alias.methods.has(upperMethod)) continue;
const match = pathname.match(alias.pattern);
if (!match) continue;
return {
baseUrl: process.env.CORE_API_BASE_URL,
upstreamPath: alias.targetPath(pathname, match),
};
}
if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
return {
baseUrl: process.env.QUERY_API_BASE_URL,
upstreamPath: `/query${pathname}`,
};
}
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
return {
baseUrl: process.env.COMMAND_API_BASE_URL,
upstreamPath: `/commands${pathname}`,
};
}
return null;
}
@@ -30,13 +111,13 @@ function copyHeaders(source, target) {
async function forwardRequest(req, res, next, fetchImpl) {
try {
const requestPath = new URL(req.originalUrl, 'http://localhost').pathname;
const baseUrl = resolveTargetBase(requestPath);
if (!baseUrl) {
throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404);
const requestUrl = new URL(req.originalUrl, 'http://localhost');
const target = resolveTarget(requestUrl.pathname, req.method);
if (!target?.baseUrl) {
throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404);
}
const url = new URL(req.originalUrl, baseUrl);
const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
@@ -69,7 +150,7 @@ export function createProxyRouter(options = {}) {
const router = Router();
const fetchImpl = options.fetchImpl || fetch;
router.use(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl));
router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl));
return router;
}

View File

@@ -2,7 +2,13 @@ import { z } from 'zod';
import { AppError } from '../lib/errors.js';
import { withTransaction } from './db.js';
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
import { deleteAccount, signInWithPassword, signUpWithPassword } from './identity-toolkit.js';
import {
deleteAccount,
sendVerificationCode,
signInWithPassword,
signInWithPhoneNumber,
signUpWithPassword,
} from './identity-toolkit.js';
import { loadActorContext } from './user-context.js';
const clientSignInSchema = z.object({
@@ -17,6 +23,30 @@ const clientSignUpSchema = z.object({
displayName: z.string().min(2).max(120).optional(),
});
const staffPhoneStartSchema = z.object({
phoneNumber: z.string().min(6).max(40),
recaptchaToken: z.string().min(1).optional(),
iosReceipt: z.string().min(1).optional(),
iosSecret: z.string().min(1).optional(),
captchaResponse: z.string().min(1).optional(),
playIntegrityToken: z.string().min(1).optional(),
safetyNetToken: z.string().min(1).optional(),
});
const staffPhoneVerifySchema = z.object({
mode: z.enum(['sign-in', 'sign-up']).optional(),
idToken: z.string().min(1).optional(),
sessionInfo: z.string().min(1).optional(),
code: z.string().min(1).optional(),
}).superRefine((value, ctx) => {
if (value.idToken) return;
if (value.sessionInfo && value.code) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide idToken or sessionInfo and code',
});
});
function slugify(input) {
return input
.toLowerCase()
@@ -40,9 +70,43 @@ function buildAuthEnvelope(authPayload, context) {
business: context.business,
vendor: context.vendor,
staff: context.staff,
requiresProfileSetup: !context.staff,
};
}
async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) {
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO users (id, email, display_name, phone, status, metadata)
VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb))
ON CONFLICT (id) DO UPDATE
SET email = COALESCE(EXCLUDED.email, users.email),
display_name = COALESCE(EXCLUDED.display_name, users.display_name),
phone = COALESCE(EXCLUDED.phone, users.phone),
metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb),
updated_at = NOW()
`,
[
decoded.uid,
decoded.email || fallbackProfile.email || null,
decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null,
decoded.phone_number || fallbackProfile.phoneNumber || null,
JSON.stringify({
provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null,
}),
]
);
});
}
async function hydrateAuthContext(authPayload, fallbackProfile = {}) {
const decoded = await verifyFirebaseToken(authPayload.idToken);
await upsertUserFromDecodedToken(decoded, fallbackProfile);
const context = await loadActorContext(decoded.uid);
return buildAuthEnvelope(authPayload, context);
}
export function parseClientSignIn(body) {
const parsed = clientSignInSchema.safeParse(body || {});
if (!parsed.success) {
@@ -63,6 +127,26 @@ export function parseClientSignUp(body) {
return parsed.data;
}
export function parseStaffPhoneStart(body) {
const parsed = staffPhoneStartSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export function parseStaffPhoneVerify(body) {
const parsed = staffPhoneVerifySchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export async function getSessionForActor(actor) {
return loadActorContext(actor.uid);
}
@@ -70,6 +154,7 @@ export async function getSessionForActor(actor) {
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
const authPayload = await signInWithPassword(payload, fetchImpl);
const decoded = await verifyFirebaseToken(authPayload.idToken);
await upsertUserFromDecodedToken(decoded, payload);
const context = await loadActorContext(decoded.uid);
if (!context.user || !context.business) {
@@ -151,6 +236,68 @@ export async function signUpClient(payload, { fetchImpl = fetch } = {}) {
}
}
function shouldUseClientSdkStaffFlow(payload) {
return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken;
}
export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
if (shouldUseClientSdkStaffFlow(payload)) {
return {
mode: 'CLIENT_FIREBASE_SDK',
provider: 'firebase-phone-auth',
phoneNumber: payload.phoneNumber,
nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.',
};
}
const authPayload = await sendVerificationCode(
{
phoneNumber: payload.phoneNumber,
recaptchaToken: payload.recaptchaToken,
iosReceipt: payload.iosReceipt,
iosSecret: payload.iosSecret,
captchaResponse: payload.captchaResponse,
playIntegrityToken: payload.playIntegrityToken,
safetyNetToken: payload.safetyNetToken,
},
fetchImpl
);
return {
mode: 'IDENTITY_TOOLKIT_SMS',
phoneNumber: payload.phoneNumber,
sessionInfo: authPayload.sessionInfo,
};
}
export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
if (payload.idToken) {
return hydrateAuthContext(
{
idToken: payload.idToken,
refreshToken: null,
expiresIn: 3600,
},
{
provider: 'firebase-phone-auth',
}
);
}
const authPayload = await signInWithPhoneNumber(
{
sessionInfo: payload.sessionInfo,
code: payload.code,
operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined,
},
fetchImpl
);
return hydrateAuthContext(authPayload, {
provider: 'firebase-phone-auth',
});
}
export async function signOutActor(actor) {
await revokeUserSessions(actor.uid);
return { signedOut: true };

View File

@@ -16,3 +16,8 @@ export async function revokeUserSessions(uid) {
ensureAdminApp();
await getAuth().revokeRefreshTokens(uid);
}
export async function createCustomToken(uid) {
ensureAdminApp();
return getAuth().createCustomToken(uid);
}

View File

@@ -56,6 +56,33 @@ export async function signUpWithPassword({ email, password }, fetchImpl = fetch)
);
}
export async function sendVerificationCode(payload, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:sendVerificationCode',
payload,
fetchImpl
);
}
export async function signInWithPhoneNumber(payload, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signInWithPhoneNumber',
payload,
fetchImpl
);
}
export async function signInWithCustomToken(payload, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signInWithCustomToken',
{
token: payload.token,
returnSecureToken: true,
},
fetchImpl
);
}
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:delete',