305 lines
9.6 KiB
JavaScript
305 lines
9.6 KiB
JavaScript
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 };
|
|
}
|