feat(api): add unified v2 gateway and mobile read slice
This commit is contained in:
157
backend/unified-api/src/services/auth-service.js
Normal file
157
backend/unified-api/src/services/auth-service.js
Normal file
@@ -0,0 +1,157 @@
|
||||
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 { 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(),
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutActor(actor) {
|
||||
await revokeUserSessions(actor.uid);
|
||||
return { signedOut: true };
|
||||
}
|
||||
87
backend/unified-api/src/services/db.js
Normal file
87
backend/unified-api/src/services/db.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
let pool;
|
||||
|
||||
function parseIntOrDefault(value, fallback) {
|
||||
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveDatabasePoolConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
const user = process.env.DB_USER;
|
||||
const password = process.env.DB_PASSWORD;
|
||||
const database = process.env.DB_NAME;
|
||||
const host = process.env.DB_HOST || (
|
||||
process.env.INSTANCE_CONNECTION_NAME
|
||||
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||
: ''
|
||||
);
|
||||
|
||||
if (!user || password == null || !database || !host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured() {
|
||||
return Boolean(resolveDatabasePoolConfig());
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const resolved = resolveDatabasePoolConfig();
|
||||
if (!resolved) {
|
||||
throw new Error('Database connection settings are required');
|
||||
}
|
||||
pool = new Pool(resolved);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query(text, params = []) {
|
||||
return getPool().query(text, params);
|
||||
}
|
||||
|
||||
export async function withTransaction(work) {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDatabaseHealth() {
|
||||
const result = await query('SELECT 1 AS ok');
|
||||
return result.rows[0]?.ok === 1;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
18
backend/unified-api/src/services/firebase-auth.js
Normal file
18
backend/unified-api/src/services/firebase-auth.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyFirebaseToken(token, { checkRevoked = false } = {}) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token, checkRevoked);
|
||||
}
|
||||
|
||||
export async function revokeUserSessions(uid) {
|
||||
ensureAdminApp();
|
||||
await getAuth().revokeRefreshTokens(uid);
|
||||
}
|
||||
65
backend/unified-api/src/services/identity-toolkit.js
Normal file
65
backend/unified-api/src/services/identity-toolkit.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
|
||||
const IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1';
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = process.env.FIREBASE_WEB_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new AppError('CONFIGURATION_ERROR', 'FIREBASE_WEB_API_KEY is required', 500);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function callIdentityToolkit(path, payload, fetchImpl = fetch) {
|
||||
const response = await fetchImpl(`${IDENTITY_TOOLKIT_BASE_URL}/${path}?key=${getApiKey()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new AppError(
|
||||
'AUTH_PROVIDER_ERROR',
|
||||
json?.error?.message || `Identity Toolkit request failed: ${path}`,
|
||||
response.status,
|
||||
{ provider: 'firebase-identity-toolkit', path }
|
||||
);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function signInWithPassword({ email, password }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signInWithPassword',
|
||||
{
|
||||
email,
|
||||
password,
|
||||
returnSecureToken: true,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function signUpWithPassword({ email, password }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signUp',
|
||||
{
|
||||
email,
|
||||
password,
|
||||
returnSecureToken: true,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:delete',
|
||||
{ idToken },
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
91
backend/unified-api/src/services/user-context.js
Normal file
91
backend/unified-api/src/services/user-context.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { query } from './db.js';
|
||||
|
||||
export async function loadActorContext(uid) {
|
||||
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT tm.id AS "membershipId",
|
||||
tm.tenant_id AS "tenantId",
|
||||
tm.base_role AS role,
|
||||
t.name AS "tenantName",
|
||||
t.slug AS "tenantSlug"
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.membership_status = 'ACTIVE'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT bm.id AS "membershipId",
|
||||
bm.business_id AS "businessId",
|
||||
bm.business_role AS role,
|
||||
b.business_name AS "businessName",
|
||||
b.slug AS "businessSlug",
|
||||
bm.tenant_id AS "tenantId"
|
||||
FROM business_memberships bm
|
||||
JOIN businesses b ON b.id = bm.business_id
|
||||
WHERE bm.user_id = $1
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
ORDER BY bm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT vm.id AS "membershipId",
|
||||
vm.vendor_id AS "vendorId",
|
||||
vm.vendor_role AS role,
|
||||
v.company_name AS "vendorName",
|
||||
v.slug AS "vendorSlug",
|
||||
vm.tenant_id AS "tenantId"
|
||||
FROM vendor_memberships vm
|
||||
JOIN vendors v ON v.id = vm.vendor_id
|
||||
WHERE vm.user_id = $1
|
||||
AND vm.membership_status = 'ACTIVE'
|
||||
ORDER BY vm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT s.id AS "staffId",
|
||||
s.tenant_id AS "tenantId",
|
||||
s.full_name AS "fullName",
|
||||
s.primary_role AS "primaryRole",
|
||||
s.onboarding_status AS "onboardingStatus",
|
||||
s.status,
|
||||
w.id AS "workforceId",
|
||||
w.vendor_id AS "vendorId",
|
||||
w.workforce_number AS "workforceNumber"
|
||||
FROM staffs s
|
||||
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
user: userResult.rows[0] || null,
|
||||
tenant: tenantResult.rows[0] || null,
|
||||
business: businessResult.rows[0] || null,
|
||||
vendor: vendorResult.rows[0] || null,
|
||||
staff: staffResult.rows[0] || null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user