import { z } from 'zod'; import { AppError } from '../lib/errors.js'; import { withTransaction } from './db.js'; import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js'; import { deleteAccount, sendVerificationCode, signInWithPassword, signInWithPhoneNumber, signUpWithPassword, } from './identity-toolkit.js'; import { loadActorContext } from './user-context.js'; const clientSignInSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); const clientSignUpSchema = z.object({ companyName: z.string().min(2).max(120), email: z.string().email(), password: z.string().min(8), 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() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 50); } function buildAuthEnvelope(authPayload, context) { return { sessionToken: authPayload.idToken, refreshToken: authPayload.refreshToken, expiresInSeconds: Number.parseInt(`${authPayload.expiresIn || 3600}`, 10), user: { id: context.user?.userId || authPayload.localId, email: context.user?.email || null, displayName: context.user?.displayName || null, phone: context.user?.phone || null, }, tenant: context.tenant, 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) { throw new AppError('VALIDATION_ERROR', 'Invalid client sign-in payload', 400, { issues: parsed.error.issues, }); } return parsed.data; } export function parseClientSignUp(body) { const parsed = clientSignUpSchema.safeParse(body || {}); if (!parsed.success) { throw new AppError('VALIDATION_ERROR', 'Invalid client sign-up payload', 400, { issues: parsed.error.issues, }); } 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); } 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) { throw new AppError('FORBIDDEN', 'Authenticated user does not have a client business membership', 403, { uid: decoded.uid, email: decoded.email || null, }); } return buildAuthEnvelope(authPayload, context); } export async function signUpClient(payload, { fetchImpl = fetch } = {}) { const authPayload = await signUpWithPassword(payload, fetchImpl); try { const decoded = await verifyFirebaseToken(authPayload.idToken); const defaultDisplayName = payload.displayName || payload.companyName; const tenantSlug = slugify(payload.companyName); const businessSlug = tenantSlug; await withTransaction(async (client) => { await client.query( ` INSERT INTO users (id, email, display_name, status, metadata) VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name, updated_at = NOW() `, [decoded.uid, payload.email, defaultDisplayName] ); const tenantResult = await client.query( ` INSERT INTO tenants (slug, name, status, metadata) VALUES ($1, $2, 'ACTIVE', '{"source":"unified-api-sign-up"}'::jsonb) RETURNING id, slug, name `, [tenantSlug, payload.companyName] ); const tenant = tenantResult.rows[0]; const businessResult = await client.query( ` INSERT INTO businesses ( tenant_id, slug, business_name, status, contact_name, contact_email, metadata ) VALUES ($1, $2, $3, 'ACTIVE', $4, $5, '{"source":"unified-api-sign-up"}'::jsonb) RETURNING id, slug, business_name `, [tenant.id, businessSlug, payload.companyName, defaultDisplayName, payload.email] ); const business = businessResult.rows[0]; await client.query( ` INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata) VALUES ($1, $2, 'ACTIVE', 'admin', '{"source":"sign-up"}'::jsonb) `, [tenant.id, decoded.uid] ); await client.query( ` INSERT INTO business_memberships (tenant_id, business_id, user_id, membership_status, business_role, metadata) VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"source":"sign-up"}'::jsonb) `, [tenant.id, business.id, decoded.uid] ); }); const context = await loadActorContext(decoded.uid); return buildAuthEnvelope(authPayload, context); } catch (error) { await deleteAccount({ idToken: authPayload.idToken }, fetchImpl).catch(() => null); throw error; } } 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 }; }