feat(api): complete unified v2 mobile surface
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -16,3 +16,8 @@ export async function revokeUserSessions(uid) {
|
||||
ensureAdminApp();
|
||||
await getAuth().revokeRefreshTokens(uid);
|
||||
}
|
||||
|
||||
export async function createCustomToken(uid) {
|
||||
ensureAdminApp();
|
||||
return getAuth().createCustomToken(uid);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user