diff --git a/apps/web/src/app/api/auth/bootstrap/route.ts b/apps/web/src/app/api/auth/bootstrap/route.ts new file mode 100644 index 0000000..62ad71a --- /dev/null +++ b/apps/web/src/app/api/auth/bootstrap/route.ts @@ -0,0 +1,66 @@ +import type { NextRequest } from 'next/server' +import { ensureHostedUserBootstrap } from '@storage/firestore-metadata' +import { isHostedMode } from '@storage/hosted-mode' +import { writeServerAuditEntry, logSanitizedServerError } from '@storage/server-audit' +import { createSessionCookieHeader, verifyRequestIdentity } from '@/lib/auth' + +export const runtime = 'nodejs' + +export async function POST(req: NextRequest) { + if (!isHostedMode()) { + return new Response(JSON.stringify({ ok: true, mode: 'local' }), { + headers: { 'Content-Type': 'application/json' }, + }) + } + + const identity = await verifyRequestIdentity(req) + if (!identity) { + return new Response(JSON.stringify({ error: { code: 'unauthorized', message: 'Authentication required' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + + try { + const user = await ensureHostedUserBootstrap({ userId: identity.userId, email: identity.email }) + + await writeServerAuditEntry({ + event_type: 'auth.success', + success: true, + user_id: user.userId, + org_id: user.orgId, + metadata: { bootstrap: true, role: user.role }, + }) + + return new Response( + JSON.stringify({ + ok: true, + user: { + user_id: user.userId, + org_id: user.orgId, + role: user.role, + email: user.email, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': createSessionCookieHeader({ userId: user.userId, email: user.email }), + }, + }, + ) + } catch (error) { + logSanitizedServerError('auth.bootstrap', error) + await writeServerAuditEntry({ + event_type: 'auth.failed', + success: false, + user_id: identity.userId, + error_code: 'bootstrap_failed', + error_message: error instanceof Error ? error.message : String(error), + }) + return new Response(JSON.stringify({ error: { code: 'bootstrap_failed', message: 'Failed to bootstrap user account' } }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..b750d6d --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,232 @@ +import type { NextRequest } from 'next/server' +import crypto from 'node:crypto' +import { getHostedUserContext } from '@storage/firestore-metadata' +import { isHostedMode } from '@storage/hosted-mode' +import { writeServerAuditEntry } from '@storage/server-audit' + +export type AuthRole = 'org_owner' | 'clinician' | 'staff_viewer' + +export interface AuthContext { + userId: string + email?: string + orgId: string + role: AuthRole +} + +export interface VerifiedIdentity { + userId: string + email?: string +} + +interface TokenInfoResponse { + sub?: string + aud?: string + email?: string + exp?: string +} + +const tokenCache = new Map() +const SESSION_COOKIE_NAME = 'openscribe_session' +const SESSION_TTL_SECONDS = 15 * 60 + +function getBearerToken(req: NextRequest): string | null { + const value = req.headers.get('authorization') || req.headers.get('Authorization') + if (!value) return null + const [scheme, token] = value.split(' ') + if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null + return token.trim() +} + +function getCookieValue(req: NextRequest, name: string): string | null { + const cookieHeader = req.headers.get('cookie') + if (!cookieHeader) return null + const cookie = cookieHeader + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)) + if (!cookie) return null + return decodeURIComponent(cookie.slice(name.length + 1)) +} + +function getSessionSecret(): string { + const secret = process.env.AUTH_SESSION_SECRET || process.env.NEXTAUTH_SECRET + if (!secret) { + throw new Error('Missing AUTH_SESSION_SECRET for hosted auth session cookies.') + } + return secret +} + +function signSessionPayload(payloadBase64: string): string { + const secret = getSessionSecret() + return crypto + .createHmac('sha256', secret) + .update(payloadBase64) + .digest('base64url') +} + +function encodeSessionCookie(identity: VerifiedIdentity): string { + const payloadBase64 = Buffer.from( + JSON.stringify({ + sub: identity.userId, + email: identity.email || '', + exp: Date.now() + SESSION_TTL_SECONDS * 1000, + }), + 'utf8', + ).toString('base64url') + const signature = signSessionPayload(payloadBase64) + return `${payloadBase64}.${signature}` +} + +function decodeSessionCookie(raw: string): VerifiedIdentity | null { + const [payloadBase64, signature] = raw.split('.') + if (!payloadBase64 || !signature) return null + const expectedSig = signSessionPayload(payloadBase64) + const actual = Uint8Array.from(Buffer.from(signature)) + const expected = Uint8Array.from(Buffer.from(expectedSig)) + if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) { + return null + } + + const payloadRaw = Buffer.from(payloadBase64, 'base64url').toString('utf8') + const payload = JSON.parse(payloadRaw) as { sub?: string; email?: string; exp?: number } + if (!payload.sub || !payload.exp || payload.exp < Date.now()) { + return null + } + + return { userId: payload.sub, email: payload.email || undefined } +} + +async function verifyIdentityPlatformToken(idToken: string): Promise { + const cached = tokenCache.get(idToken) + if (cached && cached.expiresAt > Date.now()) { + return cached.payload + } + + const endpoint = `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(idToken)}` + const res = await fetch(endpoint) + if (!res.ok) { + throw new Error(`id_token verification failed: ${res.status}`) + } + + const payload = (await res.json()) as TokenInfoResponse + if (!payload.sub) { + throw new Error('id_token payload missing sub') + } + + const expectedAud = process.env.GCP_IDENTITY_PLATFORM_CLIENT_ID + if (expectedAud && payload.aud !== expectedAud) { + throw new Error('id_token audience mismatch') + } + + const expEpochMs = payload.exp ? Number(payload.exp) * 1000 : Date.now() + 60_000 + tokenCache.set(idToken, { + expiresAt: expEpochMs, + payload, + }) + + return payload +} + +export function createSessionCookieHeader(identity: VerifiedIdentity, secure = process.env.NODE_ENV === 'production'): string { + const value = encodeSessionCookie(identity) + return `${SESSION_COOKIE_NAME}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}; ${secure ? 'Secure; ' : ''}` +} + +export function clearSessionCookieHeader(secure = process.env.NODE_ENV === 'production'): string { + return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; ${secure ? 'Secure; ' : ''}` +} + +export async function verifyRequestIdentity(req: NextRequest): Promise { + if (!isHostedMode()) { + return { + userId: 'local-user', + email: undefined, + } + } + + const sessionValue = getCookieValue(req, SESSION_COOKIE_NAME) + if (sessionValue) { + try { + const identity = decodeSessionCookie(sessionValue) + if (identity) { + return identity + } + } catch { + // fallback to bearer token + } + } + + const token = getBearerToken(req) + if (!token) { + await writeServerAuditEntry({ event_type: 'auth.failed', success: false, error_code: 'missing_token' }) + return null + } + + try { + const payload = await verifyIdentityPlatformToken(token) + return { + userId: payload.sub as string, + email: payload.email, + } + } catch (error) { + await writeServerAuditEntry({ + event_type: 'auth.failed', + success: false, + error_code: 'verification_failed', + error_message: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +export async function requireAuth(req: NextRequest): Promise { + if (!isHostedMode()) { + return { + userId: 'local-user', + orgId: 'local-org', + role: 'org_owner', + } + } + + const identity = await verifyRequestIdentity(req) + if (!identity) { + return null + } + + try { + const context = await getHostedUserContext(identity.userId) + + if (!context?.orgId || !context.role) { + await writeServerAuditEntry({ + event_type: 'authz.denied', + success: false, + user_id: identity.userId, + error_code: 'missing_membership', + }) + return null + } + + await writeServerAuditEntry({ + event_type: 'auth.success', + success: true, + user_id: context.userId, + org_id: context.orgId, + metadata: { role: context.role }, + }) + + return { + userId: context.userId, + email: context.email, + orgId: context.orgId, + role: context.role, + } + } catch (error) { + await writeServerAuditEntry({ + event_type: 'auth.failed', + success: false, + error_code: 'auth_context_failed', + error_message: error instanceof Error ? error.message : String(error), + }) + return null + } +} diff --git a/packages/storage/src/__tests__/firestore-metadata.test.ts b/packages/storage/src/__tests__/firestore-metadata.test.ts new file mode 100644 index 0000000..62f0e75 --- /dev/null +++ b/packages/storage/src/__tests__/firestore-metadata.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ensureHostedUserBootstrap, getHostedUserContext } from '../firestore-metadata.js' + +type FirestoreDoc = Record + +const originalFetch = globalThis.fetch +const originalEnv = { ...process.env } + +function toFirestoreValue(value: unknown): Record { + if (typeof value === 'string') return { stringValue: value } + if (typeof value === 'boolean') return { booleanValue: value } + if (typeof value === 'number') return Number.isInteger(value) ? { integerValue: String(value) } : { doubleValue: value } + if (value === null || value === undefined) return { nullValue: null } + if (Array.isArray(value)) return { arrayValue: { values: value.map((v) => toFirestoreValue(v)) } } + if (typeof value === 'object') { + const fields: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + fields[key] = toFirestoreValue(nested) + } + return { mapValue: { fields } } + } + return { stringValue: String(value) } +} + +function fromFirestoreValue(value: unknown): unknown { + const typed = value as Record | undefined + if (!typed || typeof typed !== 'object') return undefined + if (typed.stringValue !== undefined) return typed.stringValue + if (typed.booleanValue !== undefined) return typed.booleanValue + if (typed.integerValue !== undefined) return Number(typed.integerValue) + if (typed.doubleValue !== undefined) return typed.doubleValue + if (typed.nullValue !== undefined) return null + const mapValue = typed.mapValue as { fields?: Record } | undefined + if (mapValue?.fields) { + const out: Record = {} + for (const [key, nested] of Object.entries(mapValue.fields)) { + out[key] = fromFirestoreValue(nested) + } + return out + } + const arrayValue = typed.arrayValue as { values?: unknown[] } | undefined + if (arrayValue?.values) return arrayValue.values.map(fromFirestoreValue) + return undefined +} + +function encodeDocument(fields: FirestoreDoc): Record { + return { + fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, toFirestoreValue(value)])), + } +} + +function decodeBodyFields(bodyText: string): FirestoreDoc { + const parsed = JSON.parse(bodyText) as { fields?: Record } + const out: FirestoreDoc = {} + for (const [key, value] of Object.entries(parsed.fields || {})) { + out[key] = fromFirestoreValue(value) + } + return out +} + +function setupFirestoreFetch(documents: Map): void { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const rawUrl = String(input) + const marker = '/documents/' + const idx = rawUrl.indexOf(marker) + if (idx < 0) { + return new Response(JSON.stringify({ error: 'bad request' }), { status: 400 }) + } + const path = rawUrl.slice(idx + marker.length).split('?')[0] + const method = (init?.method || 'GET').toUpperCase() + + if (method === 'GET') { + const doc = documents.get(path) + if (!doc) return new Response('{}', { status: 404 }) + return new Response(JSON.stringify(encodeDocument(doc)), { status: 200 }) + } + + if (method === 'PATCH') { + const current = documents.get(path) || {} + const bodyText = String(init?.body || '{}') + const patch = decodeBodyFields(bodyText) + documents.set(path, { ...current, ...patch }) + return new Response(JSON.stringify(encodeDocument(documents.get(path) || {})), { status: 200 }) + } + + return new Response(JSON.stringify({ error: 'unsupported method' }), { status: 405 }) + }) as typeof fetch +} + +test('getHostedUserContext returns null when membership is missing', async () => { + const docs = new Map() + docs.set('users/user-1', { + id: 'user-1', + email: 'clinician@example.com', + orgId: 'org-1', + role: 'org_owner', + }) + + process.env = { + ...originalEnv, + HOSTED_MODE: 'true', + GCP_PROJECT_ID: 'test-project', + GOOGLE_OAUTH_ACCESS_TOKEN: 'test-token', + } + setupFirestoreFetch(docs) + + const context = await getHostedUserContext('user-1') + assert.equal(context, null) +}) + +test('getHostedUserContext requires membership and uses membership role', async () => { + const docs = new Map() + docs.set('users/user-2', { + id: 'user-2', + email: 'clinician@example.com', + orgId: 'org-2', + role: 'org_owner', + }) + docs.set('memberships/user-2_org-2', { + id: 'user-2_org-2', + userId: 'user-2', + orgId: 'org-2', + role: 'staff_viewer', + }) + + process.env = { + ...originalEnv, + HOSTED_MODE: 'true', + GCP_PROJECT_ID: 'test-project', + GOOGLE_OAUTH_ACCESS_TOKEN: 'test-token', + } + setupFirestoreFetch(docs) + + const context = await getHostedUserContext('user-2') + assert.ok(context) + assert.equal(context?.orgId, 'org-2') + assert.equal(context?.role, 'staff_viewer') +}) + +test('ensureHostedUserBootstrap backfills missing membership for existing user', async () => { + const docs = new Map() + docs.set('users/user-3', { + id: 'user-3', + email: 'owner@example.com', + orgId: 'org-3', + role: 'org_owner', + }) + + process.env = { + ...originalEnv, + HOSTED_MODE: 'true', + GCP_PROJECT_ID: 'test-project', + GOOGLE_OAUTH_ACCESS_TOKEN: 'test-token', + } + setupFirestoreFetch(docs) + + const context = await ensureHostedUserBootstrap({ userId: 'user-3', email: 'owner@example.com' }) + assert.equal(context.orgId, 'org-3') + assert.equal(context.role, 'org_owner') + assert.ok(docs.get('memberships/user-3_org-3')) +}) + +test.after(() => { + process.env = originalEnv + globalThis.fetch = originalFetch +}) diff --git a/packages/storage/src/firestore-metadata.ts b/packages/storage/src/firestore-metadata.ts new file mode 100644 index 0000000..293f834 --- /dev/null +++ b/packages/storage/src/firestore-metadata.ts @@ -0,0 +1,233 @@ +import { isHostedMode } from './hosted-mode' + +export type OrgRole = 'org_owner' | 'clinician' | 'staff_viewer' + +export interface HostedUserContext { + userId: string + email?: string + orgId?: string + role?: OrgRole +} + +function membershipId(userId: string, orgId: string): string { + return `${userId}_${orgId}` +} + +function getProjectId(env: NodeJS.ProcessEnv = process.env): string { + const projectId = env.GCP_PROJECT_ID || env.GOOGLE_CLOUD_PROJECT || env.GCLOUD_PROJECT + if (!projectId) { + throw new Error('Missing GCP project id. Set GCP_PROJECT_ID or GOOGLE_CLOUD_PROJECT.') + } + return projectId +} + +async function getGoogleAccessToken(): Promise { + if (process.env.GOOGLE_OAUTH_ACCESS_TOKEN) { + return process.env.GOOGLE_OAUTH_ACCESS_TOKEN + } + + const metadataUrl = 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token' + const res = await fetch(metadataUrl, { + headers: { 'Metadata-Flavor': 'Google' }, + }) + + if (!res.ok) { + throw new Error(`Failed to fetch metadata token: ${res.status}`) + } + + const data = (await res.json()) as { access_token?: string } + if (!data.access_token) { + throw new Error('Metadata token response missing access_token') + } + + return data.access_token +} + +function firestoreBase(projectId: string): string { + return `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents` +} + +function toFirestoreValue(value: unknown): Record { + if (typeof value === 'string') return { stringValue: value } + if (typeof value === 'boolean') return { booleanValue: value } + if (typeof value === 'number') return Number.isInteger(value) ? { integerValue: String(value) } : { doubleValue: value } + if (value === null || value === undefined) return { nullValue: null } + if (Array.isArray(value)) return { arrayValue: { values: value.map((v) => toFirestoreValue(v)) } } + if (value instanceof Date) return { timestampValue: value.toISOString() } + if (typeof value === 'object') { + const fields: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + fields[k] = toFirestoreValue(v) + } + return { mapValue: { fields } } + } + return { stringValue: String(value) } +} + +function fromFirestoreValue(value: unknown): unknown { + const typed = value as Record | undefined + if (!typed || typeof typed !== 'object') return undefined + if (typed.stringValue !== undefined) return typed.stringValue + if (typed.booleanValue !== undefined) return typed.booleanValue + if (typed.integerValue !== undefined) return Number(typed.integerValue) + if (typed.doubleValue !== undefined) return typed.doubleValue + if (typed.timestampValue !== undefined) return typed.timestampValue + if (typed.nullValue !== undefined) return null + const arrayValue = typed.arrayValue as { values?: unknown[] } | undefined + if (arrayValue?.values) return arrayValue.values.map(fromFirestoreValue) + const mapValue = typed.mapValue as { fields?: Record } | undefined + if (mapValue?.fields) { + const out: Record = {} + for (const [k, v] of Object.entries(mapValue.fields)) { + out[k] = fromFirestoreValue(v) + } + return out + } + return undefined +} + +function decodeDocument(document: unknown): Record { + const fields = ((document as { fields?: Record } | undefined)?.fields) || {} + const out: Record = {} + for (const [k, v] of Object.entries(fields)) { + out[k] = fromFirestoreValue(v) + } + return out +} + +async function patchDocument(path: string, fields: Record): Promise { + const projectId = getProjectId() + const token = await getGoogleAccessToken() + const base = firestoreBase(projectId) + + const updateMask = Object.keys(fields).map((f) => `updateMask.fieldPaths=${encodeURIComponent(f)}`).join('&') + const query = updateMask ? `?${updateMask}` : '' + + const body = { + fields: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, toFirestoreValue(v)])), + } + + const res = await fetch(`${base}/${path}${query}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const errorBody = await res.text() + throw new Error(`Firestore patch failed (${res.status}): ${errorBody}`) + } +} + +async function getDocument(path: string): Promise | null> { + const projectId = getProjectId() + const token = await getGoogleAccessToken() + const base = firestoreBase(projectId) + + const res = await fetch(`${base}/${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + + if (res.status === 404) return null + if (!res.ok) { + const errorBody = await res.text() + throw new Error(`Firestore get failed (${res.status}): ${errorBody}`) + } + + const document = await res.json() + return decodeDocument(document) +} + +export async function ensureHostedUserBootstrap(user: { userId: string; email?: string }): Promise { + if (!isHostedMode()) { + return { userId: user.userId, email: user.email, orgId: 'local-org', role: 'org_owner' } + } + + const existing = await getDocument(`users/${user.userId}`) + if (existing?.orgId && existing?.role) { + const orgId = String(existing.orgId) + const role = String(existing.role) as OrgRole + const membershipDocId = membershipId(user.userId, orgId) + const membership = await getDocument(`memberships/${membershipDocId}`) + if (!membership?.role) { + const now = new Date().toISOString() + await patchDocument(`memberships/${membershipDocId}`, { + id: membershipDocId, + userId: user.userId, + orgId, + role, + createdAt: now, + updatedAt: now, + }) + } + + return { + userId: user.userId, + email: user.email, + orgId, + role, + } + } + + const orgId = crypto.randomUUID() + const now = new Date().toISOString() + + await patchDocument(`organizations/${orgId}`, { + id: orgId, + name: user.email ? `${user.email.split('@')[0]}'s Organization` : 'OpenScribe Organization', + createdAt: now, + updatedAt: now, + hostedMode: true, + }) + + await patchDocument(`users/${user.userId}`, { + id: user.userId, + email: user.email || '', + orgId, + role: 'org_owner', + createdAt: now, + updatedAt: now, + }) + + const membershipDocId = membershipId(user.userId, orgId) + await patchDocument(`memberships/${membershipDocId}`, { + id: membershipDocId, + userId: user.userId, + orgId, + role: 'org_owner', + createdAt: now, + updatedAt: now, + }) + + return { + userId: user.userId, + email: user.email, + orgId, + role: 'org_owner', + } +} + +export async function getHostedUserContext(userId: string): Promise { + if (!isHostedMode()) { + return { userId, orgId: 'local-org', role: 'org_owner' } + } + + const user = await getDocument(`users/${userId}`) + if (!user?.orgId) { + return null + } + + const orgId = String(user.orgId) + const membership = await getDocument(`memberships/${membershipId(userId, orgId)}`) + if (!membership?.role) return null + + return { + userId, + email: typeof user.email === 'string' ? user.email : undefined, + orgId, + role: String(membership.role) as OrgRole, + } +} diff --git a/packages/storage/src/hosted-mode.ts b/packages/storage/src/hosted-mode.ts new file mode 100644 index 0000000..4b69800 --- /dev/null +++ b/packages/storage/src/hosted-mode.ts @@ -0,0 +1,17 @@ +export function isHostedMode(env: NodeJS.ProcessEnv = process.env): boolean { + return String(env.HOSTED_MODE || '').toLowerCase() === 'true' +} + +export function allowUserApiKeys(env: NodeJS.ProcessEnv = process.env): boolean { + if (!isHostedMode(env)) return true + return String(env.ALLOW_USER_API_KEYS || '').toLowerCase() === 'true' +} + +export function persistServerPhi(env: NodeJS.ProcessEnv = process.env): boolean { + if (!isHostedMode(env)) return true + return String(env.PERSIST_SERVER_PHI || '').toLowerCase() === 'true' +} + +export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean { + return String(env.NODE_ENV || '').toLowerCase() === 'production' +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 88d6cd6..cfa1786 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -5,3 +5,6 @@ export * from "./preferences" export * from "./api-keys" export * from "./debug-logger" export * from "./audit-log" +export * from "./hosted-mode" +export * from "./server-audit" +export * from "./firestore-metadata" diff --git a/packages/storage/src/server-audit.ts b/packages/storage/src/server-audit.ts new file mode 100644 index 0000000..f12e50d --- /dev/null +++ b/packages/storage/src/server-audit.ts @@ -0,0 +1,80 @@ +import { isHostedMode } from './hosted-mode' + +export type ServerAuditEventType = + | 'auth.success' + | 'auth.failed' + | 'authz.denied' + | 'transcription.segment_uploaded' + | 'transcription.completed' + | 'transcription.failed' + | 'note.generation_started' + | 'note.generated' + | 'note.generation_failed' + | 'settings.api_key_configured' + +export interface ServerAuditEntry { + event_type: ServerAuditEventType + success: boolean + org_id?: string + user_id?: string + resource_id?: string + request_id?: string + error_code?: string + error_message?: string + metadata?: Record +} + +const PHI_KEY_PATTERNS = [/patient/i, /transcript/i, /note(_text)?/i, /audio/i, /visit_reason/i] + +export function sanitizeAuditMetadata(input?: Record): Record | undefined { + if (!input) return undefined + const out: Record = {} + for (const [key, value] of Object.entries(input)) { + if (PHI_KEY_PATTERNS.some((pattern) => pattern.test(key))) { + continue + } + if (typeof value === 'string' && value.length > 256) { + out[key] = `${value.slice(0, 256)}...[truncated]` + continue + } + out[key] = value + } + return out +} + +export function sanitizeAuditErrorMessage(message?: string): string | undefined { + if (!message) return undefined + return message + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[redacted-email]') + .slice(0, 512) +} + +export async function writeServerAuditEntry(entry: ServerAuditEntry): Promise { + const payload = { + severity: entry.success ? 'INFO' : 'WARNING', + component: 'openscribe-server-audit', + hosted_mode: isHostedMode(), + timestamp: new Date().toISOString(), + event_type: entry.event_type, + success: entry.success, + org_id: entry.org_id, + user_id: entry.user_id, + resource_id: entry.resource_id, + request_id: entry.request_id, + error_code: entry.error_code, + error_message: sanitizeAuditErrorMessage(entry.error_message), + metadata: sanitizeAuditMetadata(entry.metadata), + } + + // Structured JSON logging for Cloud Logging ingestion. + if (entry.success) { + console.info(JSON.stringify(payload)) + } else { + console.warn(JSON.stringify(payload)) + } +} + +export function logSanitizedServerError(context: string, error: unknown): void { + const message = error instanceof Error ? sanitizeAuditErrorMessage(error.message) : String(error) + console.error(`[${context}] ${message}`) +}