auth-service.ts4256 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { ValidationError } from '@briven/shared';
5
6import { runInProjectDatabase } from '../db/data-plane.js';
7import { env } from '../env.js';
8import { log } from '../lib/logger.js';
9import { runWithRequestContext } from '../lib/request-context.js';
10import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
11import { requireAuthTeamAdmin } from '../middleware/auth-team.js';
12import { audit, hashIp } from '../services/audit.js';
13import {
14 ensureTenantAuthSchema,
15 renderAuthProvisioningSql,
16} from '../services/auth-provisioning.js';
17import { getAuthInstance, invalidateAuthInstance } from '../services/auth-tenant-pool.js';
18import { listAuditEntries } from '../services/auth-audit.js';
19import { getAuthAnalyticsOverview, getAuthMauStats, getProviderBreakdown } from '../services/auth-mau.js';
20import {
21 importAuthUsers,
22 parseImportCsv,
23 type ImportRow,
24} from '../services/auth-import.js';
25import {
26 createAuthSdkKey,
27 isAssignableSdkKeyScope,
28 listAuthSdkKeysForProject,
29 resolveAuthSdkKey,
30 revealAuthSdkKey,
31 revokeAuthSdkKey,
32} from '../services/auth-sdk-keys.js';
33import { getProjectUserDetail, listProjectUsers } from '../services/auth-users.js';
34import {
35 brandingLogoPublicUrl,
36 deleteBrandingLogo,
37 getBrandingLogo,
38 isStorageConfigured,
39 putBrandingLogo,
40 validateLogoUpload,
41} from '../services/auth-branding-logo.js';
42import { eq } from 'drizzle-orm';
43import { getDb } from '../db/client.js';
44import { users, projects } from '../db/schema.js';
45import { isSuperadminEmail } from '../lib/superadmin.js';
46import {
47 AppDomainLimitExceeded,
48 addOrigin,
49 listOrigins,
50 originsForProject,
51 removeOrigin,
52} from '../services/auth-origin-allowlist.js';
53import {
54 SOCIAL_PROVIDER_KEYS,
55 getAuthConfig,
56 isAuthEnabled,
57 isSocialProviderKey,
58 updateAuthConfig,
59} from '../services/tenant-config-store.js';
60import { hasTenantSecret, setTenantSecret } from '../services/tenant-secrets.js';
61import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
62import {
63 banUser,
64 checkSignUpGate,
65 listWaitlist,
66 approveWaitlistEntry,
67 rejectWaitlistEntry,
68 suspendUser,
69 unbanUser,
70 unsuspendUser,
71} from '../services/auth-security.js';
72import { checkIpRateLimit, checkEmailRateLimit } from '../services/auth-rate-limit.js';
73import { checkPasswordBreach } from '../services/auth-breach-detection.js';
74import { verifyTurnstileToken } from '../services/auth-turnstile.js';
75import {
76 getUserMetadata,
77 getUserPublicMetadata,
78 setUserMetadata,
79 deleteUserMetadata,
80} from '../services/auth-user-metadata.js';
81import {
82 listUserEmails,
83 addUserEmail,
84 verifyUserEmail,
85 setPrimaryEmail,
86 removeUserEmail,
87} from '../services/auth-user-emails.js';
88import {
89 createSigninToken,
90 exchangeSigninToken,
91 SigninTokenError,
92} from '../services/auth-signin-tokens.js';
93import {
94 acceptInvite,
95 addOrgMember,
96 createOrg,
97 createOrgInvite,
98 deleteOrg,
99 getInviteByToken,
100 getOrg,
101 getSessionActiveOrg,
102 getUserOrgRole,
103 hasPermission,
104 listOrgDomains,
105 listOrgMembers,
106 listOrgRoles,
107 listOrgsForUser,
108 listPendingInvites,
109 listMembershipRequests,
110 addOrgDomain,
111 createMembershipRequest,
112 createOrgRole,
113 deleteOrgRole,
114 removeOrgDomain,
115 removeOrgMember,
116 resolveMembershipRequest,
117 revokeInvite,
118 setOrgDomainAutoJoin,
119 setSessionActiveOrg,
120 updateMemberRole,
121 updateOrg,
122 updateOrgRole,
123 verifyOrgDomain,
124} from '../services/auth-orgs.js';
125import {
126 createSsoConnection,
127 createSsoSession,
128 deleteSsoConnection,
129 exchangeOidcCode,
130 findConnectionByDomain,
131 findOrCreateSsoUser,
132 generateOidcAuthUrl,
133 generateSamlAuthnRequest,
134 generateSamlMetadata,
135 getSsoConnection,
136 listSsoConnections,
137 revokeAllSessionsForConnection,
138 updateSsoConnection,
139 validateSamlResponse,
140} from '../services/auth-sso.js';
141import { listUserAccounts, unlinkUserAccount } from '../services/auth-account-linking.js';
142import {
143 addAuthTeamMember,
144 findUserByEmail,
145 listAuthTeamMembers,
146 removeAuthTeamMember,
147} from '../services/auth-team-seats.js';
148import {
149 createImpersonationSession,
150 getActiveImpersonation,
151 stopImpersonationSession,
152} from '../services/auth-impersonate.js';
153import { listAppLogs, purgeOldAppLogs, purgeOldAuditLogs } from '../services/auth-app-logs.js';
154import { bulkBanUsers, bulkDeleteUsers, bulkInviteUsers } from '../services/auth-bulk-ops.js';
155import { getComplianceSettings, setComplianceSettings } from '../services/auth-compliance.js';
156import {
157 buildEnterpriseSalesPack,
158 signGdprDpa,
159 signHipaaBaa,
160} from '../services/auth-enterprise-pack.js';
161import {
162 deleteScimRoleMap,
163 listScimRoleMaps,
164 upsertScimRoleMap,
165} from '../services/auth-scim-role-maps.js';
166import {
167 createJwtTemplate,
168 deleteJwtTemplate,
169 generateJwtToken,
170 getCustomJwks,
171 listJwtTemplates,
172} from '../services/auth-jwt-templates.js';
173import {
174 generateAvatarPresign,
175 getAvatarImage,
176 updateUserAvatar,
177} from '../services/auth-user-avatar.js';
178import {
179 createUsername,
180 deleteUsername,
181 getUsernameByUserId,
182 resolveUsernameToEmail,
183 validateUsername,
184} from '../services/auth-usernames.js';
185import {
186 createTestToken,
187 exchangeTestToken,
188 listTestTokens,
189 revokeTestToken,
190} from '../services/auth-test-tokens.js';
191import {
192 deactivateEmailTemplate,
193 listEmailTemplates,
194 setEmailTemplate,
195 type EmailTemplateName,
196 EMAIL_TEMPLATE_NAMES,
197} from '../services/auth-email-templates.js';
198import {
199 assertPasswordNotReused,
200 forcePasswordReset,
201 getPasswordPolicy,
202 setPasswordPolicy,
203 validatePassword,
204} from '../services/auth-password-policy.js';
205import { exportUserData } from '../services/auth-gdpr-export.js';
206
207/**
208 * briven auth service router (BUILD_PLAN.md §4).
209 *
210 * Mounted by `apps/api/src/index.ts` only when `BRIVEN_AUTH_ENABLED=true`.
211 * The kill-switch is intentional — if a customer-facing auth bug surfaces
212 * in production, an operator can disable the service via Dokploy env
213 * without redeploying (ARCHITECTURE.md §9).
214 *
215 * Three URL prefixes own distinct surfaces:
216 * - `/v1/auth-service/*` → operational endpoints (health, ready, metrics)
217 * - `/v1/projects/:id/auth/*` → admin endpoints (dashboard-driven; tenant
218 * resolution via path param, project-auth middleware gates access)
219 * - `/v1/auth-tenant/*` → customer-end-user surface (SDK + hosted pages;
220 * tenant resolution via `x-briven-project-id` header or hosted-pages
221 * subdomain at the edge)
222 *
223 * Why three prefixes? Control-plane Better Auth already owns `/v1/auth/*`
224 * for the briven.tech dashboard login (`apps/api/src/lib/auth.ts`).
225 * Customer-tenant Better Auth instances claim `/v1/auth-tenant/*` so the
226 * two engines don't collide in Hono routing.
227 */
228export const authServiceRouter = new Hono<AppEnv>();
229
230/**
231 * Resolve an actor id for audit + createdBy when the caller may be either
232 * a dashboard session user OR a project API key (brk_). Without this, every
233 * "if (!actor) 401" after requireProjectAuth blocks agents that use brk_
234 * even though they already passed admin role via requireProjectRole.
235 */
236function resolveAuthActorId(c: {
237 get: (k: 'user' | 'apiKeyId') => { id: string } | string | null | undefined;
238}): string | null {
239 const user = c.get('user') as { id: string } | null | undefined;
240 if (user && typeof user === 'object' && typeof user.id === 'string') return user.id;
241 const keyId = c.get('apiKeyId');
242 return typeof keyId === 'string' && keyId.length > 0 ? keyId : null;
243}
244
245// ─── operational ────────────────────────────────────────────────────────
246
247/**
248 * Health = process is alive + the service kill-switch is on. Mirrors the
249 * shape of `routes/health.ts` so the same monitoring stack scrapes it
250 * without bespoke parsing.
251 */
252authServiceRouter.get('/v1/auth-service/health', (c) =>
253 c.json({
254 status: 'ok',
255 service: 'auth',
256 env: env.BRIVEN_ENV,
257 }),
258);
259
260/**
261 * Ready = dependencies reachable. The master key must be configured for
262 * per-tenant decrypt; the data plane URL must be configured for per-tenant
263 * postgres pools.
264 */
265authServiceRouter.get('/v1/auth-service/ready', (c) => {
266 const masterKeyConfigured = Boolean(process.env.BRIVEN_AUTH_MASTER_KEY);
267 const dataPlaneConfigured = Boolean(env.BRIVEN_DATA_PLANE_URL);
268 const ready = masterKeyConfigured && dataPlaneConfigured;
269 return c.json(
270 {
271 status: ready ? 'ready' : 'degraded',
272 service: 'auth',
273 checks: {
274 masterKey: masterKeyConfigured ? 'configured' : 'missing',
275 dataPlane: dataPlaneConfigured ? 'configured' : 'missing',
276 },
277 },
278 ready ? 200 : 503,
279 );
280});
281
282/**
283 * Resolve a custom auth domain (e.g. auth.murphus.eu) to a project id.
284 * Public and unauthenticated — called by the web-app edge proxy before
285 * any auth context exists. Cached aggressively by the caller.
286 */
287authServiceRouter.get('/v1/auth-service/resolve-domain', async (c) => {
288 const domain = c.req.query('domain');
289 if (!domain) {
290 return c.json({ code: 'validation_failed', message: 'missing domain query param' }, 400);
291 }
292
293 const db = getDb();
294 const [row] = await db
295 .select({ id: projects.id, authDomain: projects.authDomain })
296 .from(projects)
297 .where(eq(projects.authDomain, domain))
298 .limit(1);
299
300 if (!row) {
301 return c.json({ code: 'not_found', message: 'no project found for this auth domain' }, 404);
302 }
303
304 return c.json({ projectId: row.id, authDomain: row.authDomain });
305});
306
307// ─── admin (dashboard-driven) ───────────────────────────────────────────
308
309/**
310 * Serve a project's branding logo. World-readable on purpose: hosted login
311 * pages (and any embedder) load it via a plain <img src>, so it must work
312 * without a session or api key. Registered BEFORE the requireProjectAuth()
313 * group middleware below so the auth middleware never runs for this GET.
314 * The object stays PRIVATE in MinIO; we proxy the bytes with the stored
315 * content-type. nosniff + a locked-down CSP keep a customer SVG image-only.
316 */
317authServiceRouter.get('/v1/projects/:id/auth/branding/logo', async (c) => {
318 const projectId = c.req.param('id');
319 if (!projectId) {
320 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
321 }
322 if (!isStorageConfigured()) {
323 return c.json({ code: 'storage_not_configured' }, 503);
324 }
325 const obj = await getBrandingLogo(projectId);
326 if (!obj) {
327 return c.json({ code: 'not_found' }, 404);
328 }
329 return new Response(obj.bytes, {
330 status: 200,
331 headers: {
332 'content-type': obj.contentType,
333 'cache-control': 'public, max-age=300',
334 'x-content-type-options': 'nosniff',
335 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
336 },
337 });
338});
339
340authServiceRouter.use('/v1/projects/:id/auth/*', requireProjectAuth());
341authServiceRouter.use('/v1/projects/:id/auth/*', requireAuthTeamAdmin());
342
343/**
344 * Upload (or replace) the branding logo. Multipart form-data, field `file`.
345 * Stores the image PRIVATELY in MinIO at a stable key, then points
346 * `branding.logoUrl` at the public serve route above (cache-busted).
347 * Admin-gated like the branding config PATCH.
348 */
349authServiceRouter.post(
350 '/v1/projects/:id/auth/branding/logo',
351 requireProjectRole('admin'),
352 async (c) => {
353 const projectId = c.req.param('id');
354 if (!projectId) {
355 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
356 }
357 const actor = c.get('user');
358 if (!actor) return c.json({ code: 'unauthorized' }, 401);
359 if (!isStorageConfigured()) {
360 return c.json({ code: 'storage_not_configured' }, 503);
361 }
362
363 let file: File | null = null;
364 try {
365 const body = await c.req.parseBody();
366 const f = body.file;
367 if (f instanceof File) file = f;
368 } catch {
369 return c.json({ code: 'validation_failed', message: 'expected multipart form-data' }, 400);
370 }
371 if (!file) {
372 return c.json({ code: 'validation_failed', message: 'missing `file` form field' }, 400);
373 }
374
375 try {
376 // Browsers usually set image/jpeg for .jpg/.jpeg; empty type → sniff name.
377 let contentType = file.type || '';
378 if (!contentType && file.name) {
379 const lower = file.name.toLowerCase();
380 if (lower.endsWith('.png')) contentType = 'image/png';
381 else if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) contentType = 'image/jpeg';
382 else if (lower.endsWith('.webp')) contentType = 'image/webp';
383 else if (lower.endsWith('.svg')) contentType = 'image/svg+xml';
384 }
385 validateLogoUpload({ contentType, size: file.size });
386 const bytes = new Uint8Array(await file.arrayBuffer());
387 await putBrandingLogo({ projectId, bytes, contentType });
388 const logoUrl = brandingLogoPublicUrl(projectId);
389 // Logo is upload-only — stored as a stable public CDN URL we generate.
390 // Operators never paste an external logo URL in the UI.
391 await updateAuthConfig(projectId, { branding: { logoUrl } });
392 // Keep briven-engine branding in lockstep (dashboard Auth → branding).
393 try {
394 const { setBrivenEngineBranding } = await import(
395 '../services/auth-core/project-config.js'
396 );
397 await setBrivenEngineBranding(projectId, { logoUrl }, actor.id);
398 } catch {
399 // Non-fatal: tenant config is still updated.
400 }
401 // Drop the cached Better Auth instance so hosted pages rebuild with
402 // the new logo (mirrors the config PATCH path).
403 await invalidateAuthInstance(projectId);
404 await audit({
405 actorId: actor.id,
406 projectId,
407 action: 'auth.branding.logo.uploaded',
408 ipHash: hashIp(
409 c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null,
410 ),
411 userAgent: c.req.header('user-agent') ?? null,
412 metadata: { contentType: file.type, sizeBytes: file.size },
413 });
414 return c.json({ logoUrl });
415 } catch (err) {
416 if (err instanceof ValidationError) {
417 return c.json({ code: 'validation_failed', message: err.message }, 400);
418 }
419 log.error('briven_auth_branding_logo_upload_failed', {
420 projectId,
421 message: err instanceof Error ? err.message : String(err),
422 });
423 return c.json({ code: 'logo_upload_failed' }, 500);
424 }
425 },
426);
427
428/**
429 * Remove the branding logo: delete the object + null out `branding.logoUrl`.
430 * Idempotent — a missing object is a no-op. Admin-gated like the upload.
431 */
432authServiceRouter.delete(
433 '/v1/projects/:id/auth/branding/logo',
434 requireProjectRole('admin'),
435 async (c) => {
436 const projectId = c.req.param('id');
437 if (!projectId) {
438 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
439 }
440 const actor = c.get('user');
441 if (!actor) return c.json({ code: 'unauthorized' }, 401);
442 if (!isStorageConfigured()) {
443 return c.json({ code: 'storage_not_configured' }, 503);
444 }
445
446 try {
447 await deleteBrandingLogo(projectId);
448 await updateAuthConfig(projectId, { branding: { logoUrl: null } });
449 try {
450 const { setBrivenEngineBranding } = await import(
451 '../services/auth-core/project-config.js'
452 );
453 await setBrivenEngineBranding(projectId, { logoUrl: null }, actor.id);
454 } catch {
455 // Non-fatal.
456 }
457 await invalidateAuthInstance(projectId);
458 await audit({
459 actorId: actor.id,
460 projectId,
461 action: 'auth.branding.logo.removed',
462 ipHash: hashIp(
463 c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null,
464 ),
465 userAgent: c.req.header('user-agent') ?? null,
466 metadata: {},
467 });
468 return c.json({ ok: true });
469 } catch (err) {
470 if (err instanceof ValidationError) {
471 return c.json({ code: 'validation_failed', message: err.message }, 400);
472 }
473 log.error('briven_auth_branding_logo_remove_failed', {
474 projectId,
475 message: err instanceof Error ? err.message : String(err),
476 });
477 return c.json({ code: 'logo_remove_failed' }, 500);
478 }
479 },
480);
481
482/**
483 * Provision the customer's auth schema. Idempotent — re-running on an
484 * already-enabled project is a no-op because every DDL statement uses
485 * `IF NOT EXISTS`. Owner / admin tier only (CLAUDE.md §5.4 says admin
486 * actions are gated; the auth tables hold session tokens and account
487 * data so this is the strictest gate available without 2FA step-up).
488 */
489authServiceRouter.post(
490 '/v1/projects/:id/auth/enable',
491 requireProjectRole('admin'),
492 async (c) => {
493 const projectId = c.req.param('id');
494 if (!projectId) {
495 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
496 }
497
498 const actorId = resolveAuthActorId(c);
499 if (!actorId) {
500 return c.json({ code: 'unauthorized' }, 401);
501 }
502
503 const statements = renderAuthProvisioningSql();
504 try {
505 await runInProjectDatabase(projectId, async (tx) => {
506 for (const stmt of statements) {
507 await tx.unsafe(stmt);
508 }
509 // Self-heal columns CREATE IF NOT EXISTS cannot add (Doltgres has no
510 // ADD COLUMN IF NOT EXISTS). Keeps re-Enable Auth and old tenants safe.
511 const txClient = {
512 query: async (sql: string, params?: unknown[]) => {
513 const rows = await tx.unsafe(sql, (params ?? []) as never);
514 return { rows: Array.isArray(rows) ? rows : [] };
515 },
516 };
517 await ensureTenantAuthSchema(txClient);
518 // Flip the meta flag so other code paths can probe "is auth on?"
519 // without inspecting pg_tables. DoltGres lacks `ON CONFLICT ... DO
520 // UPDATE` (no `excluded` pseudo-table), so emulate the upsert: insert
521 // if absent, then unconditionally update. Both run inside the same
522 // transaction, so the pair stays atomic and idempotent.
523 await tx.unsafe(
524 `INSERT INTO "_briven_meta" (key, value)
525 VALUES ('auth_enabled', 'true'::jsonb)
526 ON CONFLICT (key) DO NOTHING`,
527 );
528 await tx.unsafe(
529 `UPDATE "_briven_meta" SET value = 'true'::jsonb WHERE key = 'auth_enabled'`,
530 );
531 });
532 } catch (err) {
533 log.error('briven_auth_enable_failed', {
534 projectId,
535 message: err instanceof Error ? err.message : String(err),
536 });
537 return c.json(
538 {
539 code: 'provisioning_failed',
540 message: 'auth provisioning failed; check api logs',
541 },
542 500,
543 );
544 }
545
546 // Clerk-simple starter pack: persist passwordless ON so first login works
547 // without a second "toggle providers" step. Defaults alone only apply when
548 // no config row exists; write explicitly so re-enable also heals OFF fleets.
549 try {
550 await updateAuthConfig(projectId, {
551 providers: {
552 emailPassword: { enabled: true },
553 magicLink: { enabled: true, expiryMinutes: 15 },
554 emailOtp: { enabled: true, codeLength: 6, expiryMinutes: 5 },
555 passkey: { enabled: true },
556 },
557 });
558 await invalidateAuthInstance(projectId);
559 } catch (err) {
560 log.warn('briven_auth_enable_starter_pack_failed', {
561 projectId,
562 message: err instanceof Error ? err.message : String(err),
563 });
564 }
565
566 // Dev-friendly guest list so localhost sign-in works without a dashboard hop.
567 try {
568 await addOrigin({
569 projectId,
570 origin: 'http://localhost:3000',
571 isWildcard: false,
572 createdBy: actorId,
573 unlimited: false,
574 });
575 } catch {
576 // already present or cap — non-fatal
577 }
578
579 const cfIp = c.req.header('cf-connecting-ip') ?? null;
580 await audit({
581 actorId,
582 projectId,
583 action: 'auth.enable',
584 ipHash: cfIp ? hashIp(cfIp) : null,
585 userAgent: c.req.header('user-agent') ?? null,
586 metadata: {
587 statements: statements.length,
588 via: c.get('apiKeyId') ? 'api_key' : 'session',
589 starterPack: ['emailPassword', 'magicLink', 'emailOtp', 'passkey'],
590 },
591 });
592
593 log.info('briven_auth_enabled', { projectId, actorId });
594
595 return c.json({
596 ok: true,
597 tables: statements.filter((s) => s.startsWith('CREATE TABLE')).length,
598 // Public API host (valid TLS). Per-project *.auth.briven.tech only when
599 // wildcard cert + router are live; until then magic-link emails must not
600 // use the broken Traefik-default host (2026-07-21).
601 authUrl: env.BRIVEN_API_ORIGIN,
602 basePath: '/v1/auth-tenant',
603 // What is live after this call — agents must not re-ask the owner to toggle.
604 providers: {
605 emailPassword: true,
606 magicLink: true,
607 emailOtp: true,
608 passkey: true,
609 },
610 next: [
611 'mint pk_briven_auth_… (dashboard Auth → API keys, or MCP auth_mint_public_key)',
612 'add production Origin under Auth → Allowed Domains (localhost:3000 pre-seeded)',
613 'wire @briven/auth or briven auth scaffold — only offer UI for enabled providers',
614 ],
615 });
616 },
617);
618
619/**
620 * Read the project's current auth config (BUILD_PLAN.md §4 admin endpoint).
621 * Returns the validated config blob — secrets are NOT part of this surface;
622 * OAuth client secrets live in the encrypted tenant-secret-store and are
623 * write-only post first save (BUILD_PLAN.md §6 Providers panel).
624 */
625authServiceRouter.get(
626 '/v1/projects/:id/auth/config',
627 requireProjectRole('admin'),
628 async (c) => {
629 const projectId = c.req.param('id');
630 if (!projectId) {
631 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
632 }
633 const [enabled, config] = await Promise.all([
634 isAuthEnabled(projectId),
635 getAuthConfig(projectId),
636 ]);
637 // Surface secret PRESENCE only (booleans) so the UI can render a
638 // "secret set ✓" indicator. Never the ciphertext or plaintext —
639 // `hasTenantSecret` is a pure existence probe and does not decrypt.
640 const presence = await Promise.all(
641 SOCIAL_PROVIDER_KEYS.map((key) =>
642 hasTenantSecret(projectId, 'auth', `${key}_client_secret`),
643 ),
644 );
645 const secretSet = Object.fromEntries(
646 SOCIAL_PROVIDER_KEYS.map((key, i) => [key, presence[i]]),
647 ) as Record<(typeof SOCIAL_PROVIDER_KEYS)[number], boolean>;
648 return c.json({ enabled, config, secretSet });
649 },
650);
651
652/**
653 * Patch the project's auth config. Body shape: a partial `AuthConfig`.
654 * Server-side merge + zod validation lives in `tenant-config-store.ts`.
655 * Bad fields → 400 with zod's issue list; good fields → 200 with the new
656 * full config.
657 *
658 * After every successful write, `invalidateAuthInstance(projectId)` flushes
659 * the cached Better Auth instance so the next request rebuilds with the
660 * new config (provider toggles, email expiry, sender domain, etc).
661 */
662authServiceRouter.patch(
663 '/v1/projects/:id/auth/config',
664 requireProjectRole('admin'),
665 async (c) => {
666 const projectId = c.req.param('id');
667 if (!projectId) {
668 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
669 }
670 const actorId = resolveAuthActorId(c);
671 if (!actorId) return c.json({ code: 'unauthorized' }, 401);
672
673 const body = await c.req.json().catch(() => null);
674 if (body === null) {
675 return c.json({ code: 'validation_failed', message: 'body must be JSON' }, 400);
676 }
677
678 let next;
679 try {
680 next = await updateAuthConfig(projectId, body);
681 } catch (err) {
682 if (err instanceof ValidationError) {
683 return c.json(
684 {
685 code: 'validation_failed',
686 message: err.message,
687 context: (err as ValidationError & { context?: unknown }).context,
688 },
689 400,
690 );
691 }
692 log.error('briven_auth_config_update_failed', {
693 projectId,
694 message: err instanceof Error ? err.message : String(err),
695 });
696 return c.json({ code: 'config_update_failed' }, 500);
697 }
698
699 // Sync customAuthDomain to the control-plane projects table so the
700 // edge proxy can resolve auth.murphus.eu → projectId without querying
701 // every tenant database.
702 const domainPatch = (body as Record<string, unknown>)?.customAuthDomain;
703 if (domainPatch !== undefined) {
704 const db = getDb();
705 await db
706 .update(projects)
707 .set({ authDomain: typeof domainPatch === 'string' ? domainPatch : null })
708 .where(eq(projects.id, projectId));
709 }
710
711 // Drop the cached instance so the very next sign-in / session call
712 // rebuilds with the new config. Per ARCHITECTURE.md §3 the eviction
713 // path also closes the per-project postgres pool — the freshly
714 // created replacement opens a new one.
715 await invalidateAuthInstance(projectId);
716
717 const cfIp = c.req.header('cf-connecting-ip') ?? null;
718 await audit({
719 actorId,
720 projectId,
721 action: 'auth.config.updated',
722 ipHash: cfIp ? hashIp(cfIp) : null,
723 userAgent: c.req.header('user-agent') ?? null,
724 // Don't log the full patch — provider toggles + branding may include
725 // client ids that are public but still noisy. Just count the keys
726 // touched so operators can correlate "who patched what when".
727 metadata: {
728 keys: Object.keys(body as Record<string, unknown>),
729 via: c.get('apiKeyId') ? 'api_key' : 'session',
730 },
731 });
732
733 return c.json({ config: next });
734 },
735);
736
737/** Max accepted client-secret length. Real OAuth secrets are well under this
738 * (Google ~24, GitHub ~40, Microsoft ~40); the cap just rejects garbage. */
739const MAX_CLIENT_SECRET_LEN = 500;
740
741/**
742 * Set (or replace) one built-in social provider's OAuth **client secret**
743 * (BUILD_PLAN.md §6 Providers panel). The public client id travels through
744 * the plain config PATCH above; the secret travels HERE, into the encrypted
745 * tenant-secret store, so it never lands in the config blob or an audit log.
746 *
747 * Admin-gated exactly like the config PATCH. Write-only by design: the value
748 * is never returned by this or any other endpoint — the UI only ever learns
749 * presence via `secretSet` on the config GET.
750 *
751 * On success the cached Better Auth instance is evicted so the next sign-in
752 * rebuilds with the now-complete (client id + secret) provider.
753 */
754authServiceRouter.put(
755 '/v1/projects/:id/auth/providers/:provider/secret',
756 requireProjectRole('admin'),
757 async (c) => {
758 const projectId = c.req.param('id');
759 if (!projectId) {
760 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
761 }
762 const provider = c.req.param('provider');
763 if (!isSocialProviderKey(provider)) {
764 return c.json({ code: 'validation_failed', message: 'unknown provider' }, 400);
765 }
766 const actor = c.get('user');
767 if (!actor) return c.json({ code: 'unauthorized' }, 401);
768
769 const body = (await c.req.json().catch(() => null)) as { secret?: unknown } | null;
770 if (body === null) {
771 return c.json({ code: 'validation_failed', message: 'body must be JSON' }, 400);
772 }
773 const secret = body.secret;
774 if (typeof secret !== 'string' || secret.length === 0) {
775 return c.json(
776 { code: 'validation_failed', message: 'secret must be a non-empty string' },
777 400,
778 );
779 }
780 if (secret.length > MAX_CLIENT_SECRET_LEN) {
781 return c.json(
782 { code: 'validation_failed', message: 'secret too long' },
783 400,
784 );
785 }
786
787 await setTenantSecret(projectId, 'auth', `${provider}_client_secret`, secret, actor.id);
788
789 // Evict the cached instance so the next sign-in rebuilds with the
790 // freshly-complete provider (client id + secret both present now).
791 await invalidateAuthInstance(projectId);
792
793 const cfIp = c.req.header('cf-connecting-ip') ?? null;
794 await audit({
795 actorId: actor.id,
796 projectId,
797 action: 'auth.provider.secret.set',
798 ipHash: cfIp ? hashIp(cfIp) : null,
799 userAgent: c.req.header('user-agent') ?? null,
800 // Record WHICH provider was rotated — NEVER the secret value or length.
801 metadata: { provider },
802 });
803
804 return c.json({ ok: true });
805 },
806);
807
808/**
809 * Allowed app domains — the browser guest list. Each project registers the
810 * origins its own app is served from so briven auth trusts login requests from
811 * that site (consumed by the CORS gate + CSRF check + each tenant's Better Auth
812 * trustedOrigins). Admin-gated; capped per project unless the caller is the
813 * platform founder/superadmin.
814 */
815authServiceRouter.get(
816 '/v1/projects/:id/auth/allowed-domains',
817 requireProjectRole('admin'),
818 async (c) => {
819 const projectId = c.req.param('id');
820 if (!projectId) {
821 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
822 }
823 const domains = await listOrigins(projectId);
824 return c.json({ domains });
825 },
826);
827
828authServiceRouter.post(
829 '/v1/projects/:id/auth/allowed-domains',
830 requireProjectRole('admin'),
831 async (c) => {
832 const projectId = c.req.param('id');
833 if (!projectId) {
834 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
835 }
836 const actorId = resolveAuthActorId(c);
837 if (!actorId) return c.json({ code: 'unauthorized' }, 401);
838
839 const body = (await c.req.json().catch(() => null)) as
840 | { origin?: unknown; isWildcard?: unknown }
841 | null;
842 const origin = typeof body?.origin === 'string' ? body.origin : '';
843 const isWildcard = body?.isWildcard === true;
844 if (!origin) {
845 return c.json({ code: 'validation_failed', message: 'missing `origin`' }, 400);
846 }
847
848 // Founder/superadmin (isAdmin + env allowlist) has no per-project cap.
849 // API-key callers never get unlimited (no user row).
850 let unlimited = false;
851 const user = c.get('user') as { id: string } | null;
852 if (user?.id) {
853 const [urow] = await getDb()
854 .select({ email: users.email, isAdmin: users.isAdmin })
855 .from(users)
856 .where(eq(users.id, user.id))
857 .limit(1);
858 unlimited = Boolean(urow?.isAdmin) && isSuperadminEmail(urow?.email);
859 }
860
861 try {
862 const domain = await addOrigin({
863 projectId,
864 origin,
865 isWildcard,
866 createdBy: actorId,
867 unlimited,
868 });
869 await invalidateAuthInstance(projectId);
870 await audit({
871 actorId,
872 projectId,
873 action: 'auth.allowed_domain.added',
874 ipHash: hashIp(
875 c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null,
876 ),
877 userAgent: c.req.header('user-agent') ?? null,
878 metadata: {
879 origin: domain.origin,
880 isWildcard: domain.isWildcard,
881 via: c.get('apiKeyId') ? 'api_key' : 'session',
882 },
883 });
884 return c.json({ domain });
885 } catch (err) {
886 if (err instanceof AppDomainLimitExceeded) {
887 return c.json({ code: err.code, message: err.message }, 402);
888 }
889 if (err instanceof ValidationError) {
890 return c.json({ code: 'validation_failed', message: err.message }, 400);
891 }
892 log.error('briven_auth_allowed_domain_add_failed', {
893 projectId,
894 message: err instanceof Error ? err.message : String(err),
895 });
896 return c.json({ code: 'allowed_domain_add_failed' }, 500);
897 }
898 },
899);
900
901authServiceRouter.delete(
902 '/v1/projects/:id/auth/allowed-domains/:originId',
903 requireProjectRole('admin'),
904 async (c) => {
905 const projectId = c.req.param('id');
906 const originId = c.req.param('originId');
907 if (!projectId || !originId) {
908 return c.json({ code: 'validation_failed', message: 'missing :id/:originId' }, 400);
909 }
910 const actor = c.get('user');
911 if (!actor) return c.json({ code: 'unauthorized' }, 401);
912
913 const removed = await removeOrigin(projectId, originId);
914 if (!removed) return c.json({ code: 'not_found' }, 404);
915 await invalidateAuthInstance(projectId);
916 await audit({
917 actorId: actor.id,
918 projectId,
919 action: 'auth.allowed_domain.removed',
920 ipHash: hashIp(
921 c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null,
922 ),
923 userAgent: c.req.header('user-agent') ?? null,
924 metadata: { originId },
925 });
926 return c.json({ ok: true });
927 },
928);
929
930/**
931 * Paginated list of users with hard redaction (no email, no IP, no full
932 * name). BUILD_PLAN.md §4 admin-list response shape. Cursor pagination
933 * for stable order on growing tables.
934 *
935 * Query params:
936 * ?limit=50 — page size (1..200, default 50)
937 * ?cursor=<opaque> — next cursor from the previous response
938 */
939authServiceRouter.get(
940 '/v1/projects/:id/auth/users',
941 requireProjectRole('admin'),
942 async (c) => {
943 const projectId = c.req.param('id');
944 if (!projectId) {
945 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
946 }
947 const limitRaw = c.req.query('limit');
948 const limit = limitRaw ? Number(limitRaw) : undefined;
949 const cursor = c.req.query('cursor') ?? null;
950
951 try {
952 const result = await listProjectUsers(projectId, {
953 limit: Number.isFinite(limit) ? limit : undefined,
954 cursor,
955 });
956 return c.json(result);
957 } catch (err) {
958 if (err instanceof ValidationError) {
959 return c.json(
960 {
961 code: 'validation_failed',
962 message: err.message,
963 },
964 400,
965 );
966 }
967 throw err;
968 }
969 },
970);
971
972/**
973 * Single-user detail view: sessions, linked accounts, recent audit. Same
974 * redaction rules as the list view — no raw email, no raw IP. Returns
975 * 404 when the user id is not present in this project's schema.
976 */
977authServiceRouter.get(
978 '/v1/projects/:id/auth/users/:userId',
979 requireProjectRole('admin'),
980 async (c) => {
981 const projectId = c.req.param('id');
982 const userId = c.req.param('userId');
983 if (!projectId || !userId) {
984 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
985 }
986 try {
987 const detail = await getProjectUserDetail(projectId, userId);
988 if (!detail) return c.json({ code: 'not_found' }, 404);
989 return c.json({ user: detail });
990 } catch (err) {
991 if (err instanceof ValidationError) {
992 return c.json({ code: 'validation_failed', message: err.message }, 400);
993 }
994 throw err;
995 }
996 },
997);
998
999// ─── Account linking (Gap Fix #4) ─────────────────────────────────────────
1000
1001authServiceRouter.get(
1002 '/v1/projects/:id/auth/users/:userId/accounts',
1003 requireProjectRole('admin'),
1004 async (c) => {
1005 const projectId = c.req.param('id');
1006 const userId = c.req.param('userId');
1007 if (!projectId || !userId) {
1008 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1009 }
1010 const accounts = await listUserAccounts(projectId, userId);
1011 return c.json({ accounts });
1012 },
1013);
1014
1015/**
1016 * Admin unlink one linked account (OAuth / credential row) from a user.
1017 * Refuses to remove the only remaining sign-in method.
1018 */
1019authServiceRouter.delete(
1020 '/v1/projects/:id/auth/users/:userId/accounts/:accountId',
1021 requireProjectRole('admin'),
1022 async (c) => {
1023 const projectId = c.req.param('id');
1024 const userId = c.req.param('userId');
1025 const accountId = c.req.param('accountId');
1026 if (!projectId || !userId || !accountId) {
1027 return c.json({ code: 'validation_failed', message: 'missing param' }, 400);
1028 }
1029 const actor = c.get('user');
1030 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1031 try {
1032 await unlinkUserAccount(projectId, userId, accountId);
1033 await audit({
1034 actorId: actor.id,
1035 projectId,
1036 action: 'auth.account.unlinked',
1037 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1038 userAgent: c.req.header('user-agent') ?? null,
1039 metadata: { userId, accountId },
1040 });
1041 return c.json({ ok: true });
1042 } catch (err) {
1043 if (err instanceof ValidationError) {
1044 return c.json({ code: 'validation_failed', message: err.message }, 400);
1045 }
1046 if ((err as { code?: string }).code === 'not_found') {
1047 return c.json({ code: 'not_found' }, 404);
1048 }
1049 throw err;
1050 }
1051 },
1052);
1053
1054/**
1055 * Admin: force password change on next sign-in.
1056 */
1057authServiceRouter.post(
1058 '/v1/projects/:id/auth/users/:userId/force-password-reset',
1059 requireProjectRole('admin'),
1060 async (c) => {
1061 const projectId = c.req.param('id');
1062 const userId = c.req.param('userId');
1063 if (!projectId || !userId) {
1064 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1065 }
1066 const actor = c.get('user');
1067 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1068 const body = (await c.req.json().catch(() => ({}))) as { reason?: string };
1069 await forcePasswordReset(projectId, userId, body.reason);
1070 await audit({
1071 actorId: actor.id,
1072 projectId,
1073 action: 'auth.password.force_reset',
1074 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1075 userAgent: c.req.header('user-agent') ?? null,
1076 metadata: { userId, reason: body.reason },
1077 });
1078 return c.json({ ok: true });
1079 },
1080);
1081
1082/**
1083 * Audit log read endpoint. Cursor pagination + optional action / user
1084 * filters. IP hashes are surfaced as 8-char hints only (CLAUDE.md §5.1).
1085 */
1086authServiceRouter.get(
1087 '/v1/projects/:id/auth/audit-log',
1088 requireProjectRole('admin'),
1089 async (c) => {
1090 const projectId = c.req.param('id');
1091 if (!projectId) {
1092 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1093 }
1094 const limitRaw = c.req.query('limit');
1095 const limit = limitRaw ? Number(limitRaw) : undefined;
1096 const cursor = c.req.query('cursor') ?? null;
1097 const action = c.req.query('action') ?? null;
1098 const userId = c.req.query('userId') ?? null;
1099
1100 try {
1101 const result = await listAuditEntries(projectId, {
1102 limit: Number.isFinite(limit) ? limit : undefined,
1103 cursor,
1104 action,
1105 userId,
1106 });
1107 return c.json(result);
1108 } catch (err) {
1109 if (err instanceof ValidationError) {
1110 return c.json({ code: 'validation_failed', message: err.message }, 400);
1111 }
1112 throw err;
1113 }
1114 },
1115);
1116
1117/**
1118 * Bulk import users. Accepts either:
1119 * - content-type: text/csv → parsed via parseImportCsv (header row required;
1120 * cols `email,name,emailVerified,passwordHash` in any order)
1121 * - content-type: application/json → `{ rows: ImportRow[] }`
1122 *
1123 * Hash compat: bcrypt + argon2id accepted (BUILD_PLAN.md §10). Returns
1124 * per-row errors so a partial CSV can be fixed + retried — the inserts
1125 * run inside a single tx, so an error short-circuits the whole batch.
1126 */
1127authServiceRouter.post(
1128 '/v1/projects/:id/auth/import',
1129 requireProjectRole('admin'),
1130 async (c) => {
1131 const projectId = c.req.param('id');
1132 if (!projectId) {
1133 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1134 }
1135 const actor = c.get('user');
1136 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1137
1138 let rows: ImportRow[] = [];
1139 const contentType = c.req.header('content-type') ?? '';
1140 try {
1141 if (contentType.startsWith('text/csv')) {
1142 const text = await c.req.text();
1143 rows = parseImportCsv(text);
1144 } else {
1145 const body = (await c.req.json().catch(() => null)) as
1146 | { rows?: unknown }
1147 | null;
1148 if (!body || !Array.isArray(body.rows)) {
1149 return c.json(
1150 { code: 'validation_failed', message: 'expected { rows: [...] }' },
1151 400,
1152 );
1153 }
1154 rows = body.rows as ImportRow[];
1155 }
1156 } catch (err) {
1157 return c.json(
1158 {
1159 code: 'validation_failed',
1160 message: err instanceof Error ? err.message : 'malformed body',
1161 },
1162 400,
1163 );
1164 }
1165
1166 try {
1167 const result = await importAuthUsers(projectId, rows);
1168 await audit({
1169 actorId: actor.id,
1170 projectId,
1171 action: 'briven_auth.users.imported',
1172 ipHash: hashIp(
1173 c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null,
1174 ),
1175 userAgent: c.req.header('user-agent') ?? null,
1176 metadata: {
1177 inserted: result.inserted,
1178 skipped: result.skipped,
1179 errored: result.errors.length,
1180 },
1181 });
1182 return c.json(result);
1183 } catch (err) {
1184 if (err instanceof ValidationError) {
1185 return c.json({ code: 'validation_failed', message: err.message }, 400);
1186 }
1187 throw err;
1188 }
1189 },
1190);
1191
1192/**
1193 * Auth MAU + ceiling for the auth → usage panel. Cheap read against
1194 * `_briven_auth_sessions`; no caching yet — page load frequency is low.
1195 */
1196authServiceRouter.get(
1197 '/v1/projects/:id/auth/mau',
1198 requireProjectRole('admin'),
1199 async (c) => {
1200 const projectId = c.req.param('id');
1201 if (!projectId) {
1202 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1203 }
1204 const stats = await getAuthMauStats(projectId);
1205 return c.json(stats);
1206 },
1207);
1208
1209/**
1210 * Auth analytics overview — DAU, new signups, total users, active sessions.
1211 */
1212authServiceRouter.get(
1213 '/v1/projects/:id/auth/analytics/overview',
1214 requireProjectRole('admin'),
1215 async (c) => {
1216 const projectId = c.req.param('id');
1217 if (!projectId) {
1218 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1219 }
1220 const overview = await getAuthAnalyticsOverview(projectId);
1221 return c.json(overview);
1222 },
1223);
1224
1225/**
1226 * Auth provider breakdown — how users sign in (email, OAuth, passkey, etc).
1227 */
1228authServiceRouter.get(
1229 '/v1/projects/:id/auth/analytics/providers',
1230 requireProjectRole('admin'),
1231 async (c) => {
1232 const projectId = c.req.param('id');
1233 if (!projectId) {
1234 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1235 }
1236 const breakdown = await getProviderBreakdown(projectId);
1237 return c.json(breakdown);
1238 },
1239);
1240
1241/**
1242 * SDK keys — list. Returns masked rows; the plaintext is never persisted
1243 * after `POST` so it cannot reappear here.
1244 */
1245authServiceRouter.get(
1246 '/v1/projects/:id/auth/api-keys',
1247 requireProjectRole('admin'),
1248 async (c) => {
1249 const projectId = c.req.param('id');
1250 if (!projectId) {
1251 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1252 }
1253 const items = await listAuthSdkKeysForProject(projectId);
1254 return c.json({
1255 items: items.map((k) => ({
1256 id: k.id,
1257 name: k.name,
1258 prefix: k.prefix,
1259 suffix: k.suffix,
1260 scope: k.scope,
1261 createdAt: k.createdAt.toISOString(),
1262 lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
1263 expiresAt: k.expiresAt ? k.expiresAt.toISOString() : null,
1264 revokedAt: k.revokedAt ? k.revokedAt.toISOString() : null,
1265 })),
1266 });
1267 },
1268);
1269
1270/**
1271 * SDK keys — create. Returns the plaintext exactly once; the caller is
1272 * responsible for surfacing it to the operator and never persisting it
1273 * server-side anywhere outside this response.
1274 */
1275authServiceRouter.post(
1276 '/v1/projects/:id/auth/api-keys',
1277 requireProjectRole('admin'),
1278 async (c) => {
1279 const projectId = c.req.param('id');
1280 if (!projectId) {
1281 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1282 }
1283 const actorId = resolveAuthActorId(c);
1284 if (!actorId) {
1285 return c.json({ code: 'unauthorized' }, 401);
1286 }
1287 const body = (await c.req.json().catch(() => null)) as
1288 | { name?: unknown; scope?: unknown }
1289 | null;
1290 if (!body || typeof body.name !== 'string') {
1291 return c.json({ code: 'validation_failed', message: 'name required' }, 400);
1292 }
1293 const scopeRaw = typeof body.scope === 'string' ? body.scope : 'read';
1294 if (!isAssignableSdkKeyScope(scopeRaw)) {
1295 return c.json(
1296 {
1297 code: 'validation_failed',
1298 message: 'scope must be read | read-write | admin',
1299 },
1300 400,
1301 );
1302 }
1303 try {
1304 const created = await createAuthSdkKey({
1305 projectId,
1306 createdBy: actorId,
1307 name: body.name,
1308 scope: scopeRaw,
1309 });
1310 await audit({
1311 actorId,
1312 projectId,
1313 action: 'briven_auth.api_key.created',
1314 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1315 userAgent: c.req.header('user-agent') ?? null,
1316 metadata: {
1317 keyId: created.record.id,
1318 scope: scopeRaw,
1319 via: c.get('apiKeyId') ? 'api_key' : 'session',
1320 },
1321 });
1322 return c.json(
1323 {
1324 key: {
1325 id: created.record.id,
1326 name: created.record.name,
1327 prefix: created.record.prefix,
1328 suffix: created.record.suffix,
1329 scope: created.record.scope,
1330 createdAt: created.record.createdAt.toISOString(),
1331 },
1332 plaintext: created.plaintext,
1333 },
1334 201,
1335 );
1336 } catch (err) {
1337 if (err instanceof ValidationError) {
1338 return c.json({ code: 'validation_failed', message: err.message }, 400);
1339 }
1340 throw err;
1341 }
1342 },
1343);
1344
1345/**
1346 * SDK keys — revoke. Idempotent; revoked keys remain in the list with a
1347 * `revokedAt` timestamp so audit history doesn't lose them.
1348 */
1349authServiceRouter.delete(
1350 '/v1/projects/:id/auth/api-keys/:keyId',
1351 requireProjectRole('admin'),
1352 async (c) => {
1353 const projectId = c.req.param('id');
1354 const keyId = c.req.param('keyId');
1355 if (!projectId || !keyId) {
1356 return c.json({ code: 'validation_failed', message: 'missing :id or :keyId' }, 400);
1357 }
1358 const actor = c.get('user');
1359 if (!actor) {
1360 return c.json({ code: 'unauthorized' }, 401);
1361 }
1362 try {
1363 await revokeAuthSdkKey(projectId, keyId);
1364 await audit({
1365 actorId: actor.id,
1366 projectId,
1367 action: 'briven_auth.api_key.revoked',
1368 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1369 userAgent: c.req.header('user-agent') ?? null,
1370 metadata: { keyId },
1371 });
1372 return c.json({ ok: true });
1373 } catch (err) {
1374 if ((err as { code?: string }).code === 'not_found') {
1375 return c.json({ code: 'not_found' }, 404);
1376 }
1377 throw err;
1378 }
1379 },
1380);
1381
1382/**
1383 * SDK keys — reveal (copy again). Decrypts the AES-GCM ciphertext stored at
1384 * create time and returns the plaintext once more. Always writes an audit
1385 * row on success. Revoked / pre-0039 keys return 404 key_not_revealable.
1386 */
1387authServiceRouter.post(
1388 '/v1/projects/:id/auth/api-keys/:keyId/reveal',
1389 requireProjectRole('admin'),
1390 async (c) => {
1391 const projectId = c.req.param('id');
1392 const keyId = c.req.param('keyId');
1393 if (!projectId || !keyId) {
1394 return c.json({ code: 'validation_failed', message: 'missing :id or :keyId' }, 400);
1395 }
1396 const actor = c.get('user');
1397 if (!actor) {
1398 return c.json({ code: 'unauthorized' }, 401);
1399 }
1400 try {
1401 const revealed = await revealAuthSdkKey(projectId, keyId);
1402 await audit({
1403 actorId: actor.id,
1404 projectId,
1405 action: 'briven_auth.api_key.revealed',
1406 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1407 userAgent: c.req.header('user-agent') ?? null,
1408 metadata: { keyId },
1409 });
1410 return c.json({ plaintext: revealed.plaintext });
1411 } catch (err) {
1412 const code = (err as { code?: string }).code;
1413 if (code === 'not_found' || code === 'key_not_revealable') {
1414 return c.json({ code: code ?? 'not_found' }, 404);
1415 }
1416 throw err;
1417 }
1418 },
1419);
1420
1421// ─── user moderation (ban / suspend) ─────────────────────────────────────
1422
1423authServiceRouter.post(
1424 '/v1/projects/:id/auth/users/:userId/ban',
1425 requireProjectRole('admin'),
1426 async (c) => {
1427 const projectId = c.req.param('id');
1428 const userId = c.req.param('userId');
1429 if (!projectId || !userId) {
1430 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1431 }
1432 const actor = c.get('user');
1433 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1434 const body = (await c.req.json().catch(() => ({}))) as { reason?: string; expiresAt?: string };
1435 await banUser(projectId, userId, {
1436 reason: body.reason,
1437 expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
1438 });
1439 await invalidateAuthInstance(projectId);
1440 await audit({
1441 actorId: actor.id,
1442 projectId,
1443 action: 'auth.user.banned',
1444 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1445 userAgent: c.req.header('user-agent') ?? null,
1446 metadata: { userId, reason: body.reason },
1447 });
1448 return c.json({ ok: true });
1449 },
1450);
1451
1452authServiceRouter.post(
1453 '/v1/projects/:id/auth/users/:userId/unban',
1454 requireProjectRole('admin'),
1455 async (c) => {
1456 const projectId = c.req.param('id');
1457 const userId = c.req.param('userId');
1458 if (!projectId || !userId) {
1459 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1460 }
1461 const actor = c.get('user');
1462 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1463 await unbanUser(projectId, userId);
1464 await invalidateAuthInstance(projectId);
1465 await audit({
1466 actorId: actor.id,
1467 projectId,
1468 action: 'auth.user.unbanned',
1469 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1470 userAgent: c.req.header('user-agent') ?? null,
1471 metadata: { userId },
1472 });
1473 return c.json({ ok: true });
1474 },
1475);
1476
1477authServiceRouter.post(
1478 '/v1/projects/:id/auth/users/:userId/suspend',
1479 requireProjectRole('admin'),
1480 async (c) => {
1481 const projectId = c.req.param('id');
1482 const userId = c.req.param('userId');
1483 if (!projectId || !userId) {
1484 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1485 }
1486 const actor = c.get('user');
1487 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1488 const body = (await c.req.json().catch(() => ({}))) as { reason?: string };
1489 await suspendUser(projectId, userId, { reason: body.reason });
1490 await invalidateAuthInstance(projectId);
1491 await audit({
1492 actorId: actor.id,
1493 projectId,
1494 action: 'auth.user.suspended',
1495 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1496 userAgent: c.req.header('user-agent') ?? null,
1497 metadata: { userId, reason: body.reason },
1498 });
1499 return c.json({ ok: true });
1500 },
1501);
1502
1503authServiceRouter.post(
1504 '/v1/projects/:id/auth/users/:userId/unsuspend',
1505 requireProjectRole('admin'),
1506 async (c) => {
1507 const projectId = c.req.param('id');
1508 const userId = c.req.param('userId');
1509 if (!projectId || !userId) {
1510 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1511 }
1512 const actor = c.get('user');
1513 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1514 await unsuspendUser(projectId, userId);
1515 await invalidateAuthInstance(projectId);
1516 await audit({
1517 actorId: actor.id,
1518 projectId,
1519 action: 'auth.user.unsuspended',
1520 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1521 userAgent: c.req.header('user-agent') ?? null,
1522 metadata: { userId },
1523 });
1524 return c.json({ ok: true });
1525 },
1526);
1527
1528/**
1529 * Admin list a user's live sessions (no tokens — id + device hint only).
1530 */
1531authServiceRouter.get(
1532 '/v1/projects/:id/auth/users/:userId/sessions',
1533 requireProjectRole('admin'),
1534 async (c) => {
1535 const projectId = c.req.param('id');
1536 const userId = c.req.param('userId');
1537 if (!projectId || !userId) {
1538 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1539 }
1540 const { listSessionsForUser } = await import('../services/auth-device-tracking.js');
1541 const sessions = await listSessionsForUser(projectId, userId);
1542 return c.json({ items: sessions });
1543 },
1544);
1545
1546/**
1547 * Admin list a user's known devices (fingerprint + human hint).
1548 */
1549authServiceRouter.get(
1550 '/v1/projects/:id/auth/users/:userId/devices',
1551 requireProjectRole('admin'),
1552 async (c) => {
1553 const projectId = c.req.param('id');
1554 const userId = c.req.param('userId');
1555 if (!projectId || !userId) {
1556 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1557 }
1558 const { listDevicesForUser } = await import('../services/auth-device-tracking.js');
1559 const devices = await listDevicesForUser(projectId, userId);
1560 return c.json({ items: devices });
1561 },
1562);
1563
1564/**
1565 * Admin revoke a specific user session.
1566 */
1567authServiceRouter.post(
1568 '/v1/projects/:id/auth/users/:userId/sessions/:sessionId/revoke',
1569 requireProjectRole('admin'),
1570 async (c) => {
1571 const projectId = c.req.param('id');
1572 const userId = c.req.param('userId');
1573 const sessionId = c.req.param('sessionId');
1574 if (!projectId || !userId || !sessionId) {
1575 return c.json({ code: 'validation_failed', message: 'missing :id, :userId, or :sessionId' }, 400);
1576 }
1577 const actor = c.get('user');
1578 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1579
1580 await runInProjectDatabase(projectId, async (tx) => {
1581 // Verify the session belongs to the specified user before deleting.
1582 const rows = (await tx.unsafe(
1583 `SELECT id FROM "_briven_auth_sessions" WHERE id = $1 AND user_id = $2 LIMIT 1`,
1584 [sessionId, userId] as never,
1585 )) as Array<{ id: string }>;
1586 if (rows.length === 0) {
1587 throw new ValidationError('session not found for this user');
1588 }
1589 await tx.unsafe(
1590 `DELETE FROM "_briven_auth_sessions" WHERE id = $1`,
1591 [sessionId] as never,
1592 );
1593 await tx.unsafe(
1594 `DELETE FROM "_briven_auth_session_activity" WHERE session_id = $1`,
1595 [sessionId] as never,
1596 );
1597 await tx.unsafe(
1598 `DELETE FROM "_briven_auth_sso_sessions" WHERE session_id = $1`,
1599 [sessionId] as never,
1600 );
1601 });
1602
1603 await invalidateAuthInstance(projectId);
1604 await audit({
1605 actorId: actor.id,
1606 projectId,
1607 action: 'auth.session.revoked',
1608 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1609 userAgent: c.req.header('user-agent') ?? null,
1610 metadata: { userId, sessionId },
1611 });
1612 return c.json({ ok: true });
1613 },
1614);
1615
1616// ─── Phase 6.4 — Bulk Operations ──────────────────────────────────────────
1617
1618const bulkBanSchema = z.object({
1619 userIds: z.array(z.string().min(1)).min(1).max(100),
1620 reason: z.string().max(500).optional(),
1621});
1622
1623authServiceRouter.post(
1624 '/v1/projects/:id/auth/users/bulk-ban',
1625 requireProjectRole('admin'),
1626 async (c) => {
1627 const projectId = c.req.param('id');
1628 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
1629
1630 const actor = c.get('user');
1631 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1632
1633 const body = await c.req.json().catch(() => null);
1634 const parsed = bulkBanSchema.safeParse(body);
1635 if (!parsed.success) {
1636 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1637 }
1638
1639 const result = await bulkBanUsers(projectId, parsed.data.userIds, parsed.data.reason);
1640 await invalidateAuthInstance(projectId);
1641 await audit({
1642 actorId: actor.id,
1643 projectId,
1644 action: 'auth.user.bulk_banned',
1645 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1646 userAgent: c.req.header('user-agent') ?? null,
1647 metadata: { count: result.succeeded, failed: result.failed },
1648 });
1649 return c.json(result);
1650 },
1651);
1652
1653const bulkDeleteSchema = z.object({
1654 userIds: z.array(z.string().min(1)).min(1).max(100),
1655});
1656
1657authServiceRouter.post(
1658 '/v1/projects/:id/auth/users/bulk-delete',
1659 requireProjectRole('admin'),
1660 async (c) => {
1661 const projectId = c.req.param('id');
1662 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
1663
1664 const actor = c.get('user');
1665 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1666
1667 const body = await c.req.json().catch(() => null);
1668 const parsed = bulkDeleteSchema.safeParse(body);
1669 if (!parsed.success) {
1670 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1671 }
1672
1673 const result = await bulkDeleteUsers(projectId, parsed.data.userIds);
1674 await invalidateAuthInstance(projectId);
1675 await audit({
1676 actorId: actor.id,
1677 projectId,
1678 action: 'auth.user.bulk_deleted',
1679 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1680 userAgent: c.req.header('user-agent') ?? null,
1681 metadata: { count: result.succeeded, failed: result.failed },
1682 });
1683 return c.json(result);
1684 },
1685);
1686
1687const bulkInviteSchema = z.object({
1688 orgId: z.string().min(1),
1689 emails: z.array(z.string().email()).min(1).max(100),
1690 role: z.enum(['admin', 'member']).optional(),
1691});
1692
1693authServiceRouter.post(
1694 '/v1/projects/:id/auth/orgs/bulk-invite',
1695 requireProjectRole('admin'),
1696 async (c) => {
1697 const projectId = c.req.param('id');
1698 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
1699
1700 const actor = c.get('user');
1701 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1702
1703 const body = await c.req.json().catch(() => null);
1704 const parsed = bulkInviteSchema.safeParse(body);
1705 if (!parsed.success) {
1706 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1707 }
1708
1709 const result = await bulkInviteUsers(projectId, {
1710 orgId: parsed.data.orgId,
1711 emails: parsed.data.emails,
1712 role: parsed.data.role,
1713 invitedBy: actor.id,
1714 });
1715 await audit({
1716 actorId: actor.id,
1717 projectId,
1718 action: 'auth.org.bulk_invited',
1719 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1720 userAgent: c.req.header('user-agent') ?? null,
1721 metadata: { orgId: parsed.data.orgId, count: result.succeeded, failed: result.failed },
1722 });
1723 return c.json(result);
1724 },
1725);
1726
1727// ─── waitlist management ─────────────────────────────────────────────────
1728
1729authServiceRouter.get(
1730 '/v1/projects/:id/auth/waitlist',
1731 requireProjectRole('admin'),
1732 async (c) => {
1733 const projectId = c.req.param('id');
1734 if (!projectId) {
1735 return c.json({ code: 'validation_failed', message: 'missing :id' }, 400);
1736 }
1737 const status = c.req.query('status') ?? undefined;
1738 const limitRaw = c.req.query('limit');
1739 const limit = limitRaw ? Number(limitRaw) : undefined;
1740 const cursor = c.req.query('cursor') ?? null;
1741 const result = await listWaitlist(projectId, { status, limit, cursor });
1742 return c.json(result);
1743 },
1744);
1745
1746authServiceRouter.post(
1747 '/v1/projects/:id/auth/waitlist/:entryId/approve',
1748 requireProjectRole('admin'),
1749 async (c) => {
1750 const projectId = c.req.param('id');
1751 const entryId = c.req.param('entryId');
1752 if (!projectId || !entryId) {
1753 return c.json({ code: 'validation_failed', message: 'missing :id or :entryId' }, 400);
1754 }
1755 const actor = c.get('user');
1756 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1757 await approveWaitlistEntry(projectId, entryId, actor.id);
1758 await audit({
1759 actorId: actor.id,
1760 projectId,
1761 action: 'auth.waitlist.approved',
1762 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1763 userAgent: c.req.header('user-agent') ?? null,
1764 metadata: { entryId },
1765 });
1766 return c.json({ ok: true });
1767 },
1768);
1769
1770authServiceRouter.post(
1771 '/v1/projects/:id/auth/waitlist/:entryId/reject',
1772 requireProjectRole('admin'),
1773 async (c) => {
1774 const projectId = c.req.param('id');
1775 const entryId = c.req.param('entryId');
1776 if (!projectId || !entryId) {
1777 return c.json({ code: 'validation_failed', message: 'missing :id or :entryId' }, 400);
1778 }
1779 const actor = c.get('user');
1780 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1781 const body = (await c.req.json().catch(() => ({}))) as { reason?: string };
1782 await rejectWaitlistEntry(projectId, entryId, { reason: body.reason });
1783 await audit({
1784 actorId: actor.id,
1785 projectId,
1786 action: 'auth.waitlist.rejected',
1787 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1788 userAgent: c.req.header('user-agent') ?? null,
1789 metadata: { entryId, reason: body.reason },
1790 });
1791 return c.json({ ok: true });
1792 },
1793);
1794
1795// ─── user metadata (admin) ───────────────────────────────────────────────
1796
1797authServiceRouter.get(
1798 '/v1/projects/:id/auth/users/:userId/metadata',
1799 requireProjectRole('admin'),
1800 async (c) => {
1801 const projectId = c.req.param('id');
1802 const userId = c.req.param('userId');
1803 if (!projectId || !userId) {
1804 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1805 }
1806 const meta = await getUserMetadata(projectId, userId);
1807 return c.json({ metadata: meta });
1808 },
1809);
1810
1811authServiceRouter.patch(
1812 '/v1/projects/:id/auth/users/:userId/metadata',
1813 requireProjectRole('admin'),
1814 async (c) => {
1815 const projectId = c.req.param('id');
1816 const userId = c.req.param('userId');
1817 if (!projectId || !userId) {
1818 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1819 }
1820 const actor = c.get('user');
1821 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1822 const body = (await c.req.json().catch(() => ({}))) as {
1823 publicMetadata?: Record<string, unknown>;
1824 privateMetadata?: Record<string, unknown>;
1825 };
1826 const meta = await setUserMetadata(projectId, userId, {
1827 publicMetadata: body.publicMetadata,
1828 privateMetadata: body.privateMetadata,
1829 });
1830 await audit({
1831 actorId: actor.id,
1832 projectId,
1833 action: 'auth.user.metadata.updated',
1834 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1835 userAgent: c.req.header('user-agent') ?? null,
1836 metadata: { userId },
1837 });
1838 return c.json({ metadata: meta });
1839 },
1840);
1841
1842authServiceRouter.delete(
1843 '/v1/projects/:id/auth/users/:userId/metadata',
1844 requireProjectRole('admin'),
1845 async (c) => {
1846 const projectId = c.req.param('id');
1847 const userId = c.req.param('userId');
1848 if (!projectId || !userId) {
1849 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1850 }
1851 const actor = c.get('user');
1852 if (!actor) return c.json({ code: 'unauthorized' }, 401);
1853 await deleteUserMetadata(projectId, userId);
1854 await audit({
1855 actorId: actor.id,
1856 projectId,
1857 action: 'auth.user.metadata.deleted',
1858 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
1859 userAgent: c.req.header('user-agent') ?? null,
1860 metadata: { userId },
1861 });
1862 return c.json({ ok: true });
1863 },
1864);
1865
1866// ─── user emails (admin) ─────────────────────────────────────────────────
1867
1868authServiceRouter.get(
1869 '/v1/projects/:id/auth/users/:userId/emails',
1870 requireProjectRole('admin'),
1871 async (c) => {
1872 const projectId = c.req.param('id');
1873 const userId = c.req.param('userId');
1874 if (!projectId || !userId) {
1875 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1876 }
1877 const emails = await listUserEmails(projectId, userId);
1878 return c.json({ emails });
1879 },
1880);
1881
1882authServiceRouter.post(
1883 '/v1/projects/:id/auth/users/:userId/emails',
1884 requireProjectRole('admin'),
1885 async (c) => {
1886 const projectId = c.req.param('id');
1887 const userId = c.req.param('userId');
1888 if (!projectId || !userId) {
1889 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1890 }
1891 const body = (await c.req.json().catch(() => ({}))) as { email?: string };
1892 if (!body.email || typeof body.email !== 'string') {
1893 return c.json({ code: 'validation_failed', message: 'email required' }, 400);
1894 }
1895 const email = await addUserEmail(projectId, userId, body.email);
1896 return c.json({ email }, 201);
1897 },
1898);
1899
1900authServiceRouter.post(
1901 '/v1/projects/:id/auth/users/:userId/emails/:emailId/verify',
1902 requireProjectRole('admin'),
1903 async (c) => {
1904 const projectId = c.req.param('id');
1905 const userId = c.req.param('userId');
1906 const emailId = c.req.param('emailId');
1907 if (!projectId || !userId || !emailId) {
1908 return c.json({ code: 'validation_failed', message: 'missing param' }, 400);
1909 }
1910 await verifyUserEmail(projectId, userId, emailId);
1911 return c.json({ ok: true });
1912 },
1913);
1914
1915authServiceRouter.post(
1916 '/v1/projects/:id/auth/users/:userId/emails/:emailId/primary',
1917 requireProjectRole('admin'),
1918 async (c) => {
1919 const projectId = c.req.param('id');
1920 const userId = c.req.param('userId');
1921 const emailId = c.req.param('emailId');
1922 if (!projectId || !userId || !emailId) {
1923 return c.json({ code: 'validation_failed', message: 'missing param' }, 400);
1924 }
1925 await setPrimaryEmail(projectId, userId, emailId);
1926 return c.json({ ok: true });
1927 },
1928);
1929
1930authServiceRouter.delete(
1931 '/v1/projects/:id/auth/users/:userId/emails/:emailId',
1932 requireProjectRole('admin'),
1933 async (c) => {
1934 const projectId = c.req.param('id');
1935 const userId = c.req.param('userId');
1936 const emailId = c.req.param('emailId');
1937 if (!projectId || !userId || !emailId) {
1938 return c.json({ code: 'validation_failed', message: 'missing param' }, 400);
1939 }
1940 await removeUserEmail(projectId, userId, emailId);
1941 return c.json({ ok: true });
1942 },
1943);
1944
1945// ─── Password Policy (Gap Fix #13) ────────────────────────────────────────
1946
1947authServiceRouter.get(
1948 '/v1/projects/:id/auth/password-policy',
1949 requireProjectRole('admin'),
1950 async (c) => {
1951 const projectId = c.req.param('id');
1952 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
1953 const policy = await getPasswordPolicy(projectId);
1954 return c.json({ policy });
1955 },
1956);
1957
1958authServiceRouter.put(
1959 '/v1/projects/:id/auth/password-policy',
1960 requireProjectRole('admin'),
1961 async (c) => {
1962 const projectId = c.req.param('id');
1963 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
1964 const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
1965 const policy = await setPasswordPolicy(projectId, {
1966 minLength: typeof body.minLength === 'number' ? body.minLength : undefined,
1967 requireUppercase: typeof body.requireUppercase === 'boolean' ? body.requireUppercase : undefined,
1968 requireLowercase: typeof body.requireLowercase === 'boolean' ? body.requireLowercase : undefined,
1969 requireNumber: typeof body.requireNumber === 'boolean' ? body.requireNumber : undefined,
1970 requireSpecial: typeof body.requireSpecial === 'boolean' ? body.requireSpecial : undefined,
1971 maxAgeDays: typeof body.maxAgeDays === 'number' ? body.maxAgeDays : null,
1972 preventReuse: typeof body.preventReuse === 'number' ? body.preventReuse : undefined,
1973 });
1974 return c.json({ policy });
1975 },
1976);
1977
1978// ─── GDPR Data Export (Gap Fix #15) ───────────────────────────────────────
1979
1980authServiceRouter.get(
1981 '/v1/projects/:id/auth/users/:userId/export',
1982 requireProjectRole('admin'),
1983 async (c) => {
1984 const projectId = c.req.param('id');
1985 const userId = c.req.param('userId');
1986 if (!projectId || !userId) {
1987 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
1988 }
1989 const data = await exportUserData(projectId, userId);
1990 return c.json({ data });
1991 },
1992);
1993
1994// ─── customer-end-user surface (Better Auth handler bridge) ─────────────
1995
1996/**
1997 * Tenant resolver. The customer's SDK passes the tenant id via the
1998 * `x-briven-project-id` header on every request; the hosted-pages
1999 * deployment resolves the tenant from the subdomain at the edge and
2000 * sets the same header before forwarding to the api.
2001 *
2002 * Fallback: browser-navigation endpoints (magic-link verify, email
2003 * verification, OAuth callback) arrive as a plain link click that can't
2004 * carry that header, so we also accept a `briven_project_id` query param
2005 * (stamped into the link by tagTenantUrl()). Header wins when both exist.
2006 *
2007 * Missing / malformed on both → 400 with a stable error code so the SDK
2008 * can surface a clear message; the SDK init logs `projectId required`
2009 * when this fires.
2010 */
2011function resolveTenant(c: {
2012 req: { header: (k: string) => string | undefined; url: string };
2013}): string | null {
2014 // Same identifier regex as projects.ts — a malformed id must never reach
2015 // schemaNameFor() and produce a bogus schema name.
2016 const VALID = /^p_[a-zA-Z0-9_]{6,64}$/;
2017 // 1. Header — how the SDK (and the hosted-pages edge) pass the tenant on
2018 // every programmatic request.
2019 const header = c.req.header('x-briven-project-id');
2020 if (header && VALID.test(header)) return header;
2021 // 2. Query-param fallback — browser-navigation endpoints (magic-link verify,
2022 // email verification, OAuth callback) are reached by a plain link click,
2023 // which cannot carry a custom header. The tenant id is stamped into the
2024 // link by tagTenantUrl() (auth-tenant-pool.ts). `p_…` is a public
2025 // identifier; the one-time token in the same URL stays the real credential.
2026 try {
2027 const q = new URL(c.req.url).searchParams.get('briven_project_id');
2028 if (q && VALID.test(q)) return q;
2029 } catch {
2030 /* malformed request URL — fall through to unresolved */
2031 }
2032 return null;
2033}
2034
2035/**
2036 * Validate the SDK key sent as `Authorization: Bearer <publicKey>`.
2037 * Returns `null` when the key is valid or when no Authorization header
2038 * is present (browser-navigation flows such as email links cannot carry
2039 * custom headers). Returns a `Response` when the key is invalid, expired,
2040 * mismatched, or has insufficient scope for the HTTP method.
2041 */
2042async function enforceSdkKeyScope(
2043 c: {
2044 req: { header: (k: string) => string | undefined; method: string };
2045 json: (obj: unknown, status?: number) => Response;
2046 },
2047 projectId: string,
2048): Promise<Response | null> {
2049 const authHeader = c.req.header('authorization');
2050 if (!authHeader) return null; // Browser flows — no key to validate.
2051
2052 const match = authHeader.match(/^Bearer\s+(.+)$/i);
2053 if (!match) {
2054 return c.json(
2055 { code: 'invalid_auth_header', message: 'Authorization header must be Bearer <token>' },
2056 401,
2057 );
2058 }
2059
2060 const resolved = await resolveAuthSdkKey(match[1]!);
2061 if (!resolved) {
2062 return c.json(
2063 { code: 'invalid_sdk_key', message: 'SDK key is invalid, revoked, or expired' },
2064 401,
2065 );
2066 }
2067
2068 if (resolved.projectId !== projectId) {
2069 return c.json(
2070 { code: 'sdk_key_mismatch', message: 'SDK key does not belong to this project' },
2071 403,
2072 );
2073 }
2074
2075 const { sdkKeyAllowsMethod } = await import('../services/auth-hardening.js');
2076 if (!sdkKeyAllowsMethod(resolved.scope, c.req.method)) {
2077 return c.json(
2078 { code: 'insufficient_scope', message: 'read key cannot modify state' },
2079 403,
2080 );
2081 }
2082
2083 return null; // Valid key with sufficient scope.
2084}
2085
2086/**
2087 * Validate a SAML/OIDC RelayState (or redirectTo) against a project's
2088 * registered app origins. Prevents open-redirect attacks via the IdP
2089 * response. Pure origin rules live in auth-hardening.sanitizeRelayState.
2090 */
2091async function validateRelayState(
2092 relayState: string,
2093 projectId: string,
2094): Promise<string> {
2095 const { sanitizeRelayState } = await import('../services/auth-hardening.js');
2096 const allowed = await originsForProject(projectId);
2097 const allAllowed = [...allowed, env.BRIVEN_WEB_ORIGIN, env.BRIVEN_API_ORIGIN].filter(
2098 (x): x is string => typeof x === 'string' && x.length > 0,
2099 );
2100 return sanitizeRelayState(relayState, allAllowed);
2101}
2102
2103/**
2104 * Callback/redirect normalization for the tenant-auth bridge.
2105 *
2106 * WHY this exists (proven-by-trace bug):
2107 * 1. The @briven/auth SDK POSTs `{ email, redirectTo }` to endpoints like
2108 * /v1/auth-tenant/sign-in/magic-link — but Better Auth only reads
2109 * `body.callbackURL`, so `redirectTo` is silently ignored. After the
2110 * user clicks the email link, Better Auth redirects to its default "/",
2111 * resolved against its baseURL (api.briven.tech) instead of the tenant
2112 * app. We seed `callbackURL` from `redirectTo` here so the intent the
2113 * SDK expressed actually reaches Better Auth.
2114 * 2. A RELATIVE callbackURL ("/dashboard") also resolves against
2115 * api.briven.tech, not the calling app. The SDK's fetch always carries
2116 * an Origin header, so we absolutize relative paths against it
2117 * (Origin "https://code.konnos.org" + "/dashboard" →
2118 * "https://code.konnos.org/dashboard").
2119 *
2120 * Security boundary: we do NOT validate the resulting absolute URL here.
2121 * Better Auth's trustedOrigins originCheck still validates every absolute
2122 * callbackURL against the project's registered app domains downstream —
2123 * that check is the security boundary, and this function must neither
2124 * bypass nor duplicate it. Protocol-relative "//evil.com" is left alone
2125 * (it is not a same-app relative path), and a malformed/missing Origin
2126 * means we forward the body untouched. Never throws.
2127 */
2128const TENANT_CALLBACK_FIELDS = ['callbackURL', 'newUserCallbackURL', 'errorCallbackURL'] as const;
2129
2130export function normalizeTenantCallbacks(
2131 body: Record<string, unknown>,
2132 origin: string | null,
2133): Record<string, unknown> {
2134 try {
2135 const out: Record<string, unknown> = { ...body };
2136
2137 // Bridge the SDK's vocabulary: seed callbackURL (and only callbackURL)
2138 // from redirectTo when the caller didn't set callbackURL explicitly.
2139 if (out.callbackURL === undefined && typeof out.redirectTo === 'string') {
2140 out.callbackURL = out.redirectTo;
2141 }
2142
2143 // Absolutize relative paths against the calling app's Origin. Only a
2144 // valid http(s) origin qualifies; otherwise leave everything untouched.
2145 let originUrl: URL | null = null;
2146 if (origin) {
2147 try {
2148 const parsed = new URL(origin);
2149 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
2150 originUrl = parsed;
2151 }
2152 } catch {
2153 /* malformed Origin header — do not rewrite anything */
2154 }
2155 }
2156 if (originUrl) {
2157 for (const field of TENANT_CALLBACK_FIELDS) {
2158 const value = out[field];
2159 // "/path" is app-relative; "//host" is protocol-relative (a foreign
2160 // host, not a path) and must be left for originCheck to reject.
2161 if (typeof value === 'string' && value.startsWith('/') && !value.startsWith('//')) {
2162 out[field] = new URL(value, originUrl).toString();
2163 }
2164 }
2165 }
2166 return out;
2167 } catch {
2168 // Normalization must never break an auth request.
2169 return body;
2170 }
2171}
2172
2173
2174
2175// ─── tenant session helper ────────────────────────────────────────────────
2176
2177/**
2178 * Resolve the current user id from the tenant session cookie.
2179 * Makes an internal sub-request to the project's Better Auth instance
2180 * so the exact same session validation runs (cookie parsing, token
2181 * verification, expiry checks).
2182 */
2183// eslint-disable-next-line @typescript-eslint/no-explicit-any
2184async function getTenantUserId(c: any, projectId: string): Promise<string | null> {
2185 try {
2186 const instance = await getAuthInstance(projectId);
2187 const url = new URL(c.req.url);
2188 const sessionReq = new Request(`${url.origin}/v1/auth-tenant/get-session?briven_project_id=${projectId}`, {
2189 method: 'GET',
2190 headers: {
2191 cookie: c.req.header('cookie') ?? '',
2192 'x-briven-project-id': projectId,
2193 },
2194 });
2195 const response = await instance.betterAuth.handler(sessionReq);
2196 if (!response.ok) return null;
2197 const body = (await response.json()) as { user?: { id?: string } } | null;
2198 return body?.user?.id ?? null;
2199 } catch {
2200 return null;
2201 }
2202}
2203
2204interface TenantSession {
2205 userId: string;
2206 sessionId: string;
2207}
2208
2209// eslint-disable-next-line @typescript-eslint/no-explicit-any
2210async function getTenantSession(c: any, projectId: string): Promise<TenantSession | null> {
2211 try {
2212 const instance = await getAuthInstance(projectId);
2213 const url = new URL(c.req.url);
2214 const sessionReq = new Request(`${url.origin}/v1/auth-tenant/get-session?briven_project_id=${projectId}`, {
2215 method: 'GET',
2216 headers: {
2217 cookie: c.req.header('cookie') ?? '',
2218 'x-briven-project-id': projectId,
2219 },
2220 });
2221 const response = await instance.betterAuth.handler(sessionReq);
2222 if (!response.ok) return null;
2223 const body = (await response.json()) as {
2224 user?: { id?: string };
2225 session?: { id?: string };
2226 } | null;
2227 const userId = body?.user?.id;
2228 const sessionId = body?.session?.id;
2229 if (!userId || !sessionId) return null;
2230 return { userId, sessionId };
2231 } catch {
2232 return null;
2233 }
2234}
2235
2236// ─── organizations (customer-facing) ─────────────────────────────────────
2237
2238authServiceRouter.get('/v1/auth-tenant/orgs', async (c) => {
2239 const projectId = resolveTenant(c);
2240 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2241 const userId = await getTenantUserId(c, projectId);
2242 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2243 const orgs = await listOrgsForUser(projectId, userId);
2244 return c.json({ orgs });
2245});
2246
2247authServiceRouter.post('/v1/auth-tenant/orgs', async (c) => {
2248 const projectId = resolveTenant(c);
2249 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2250 const userId = await getTenantUserId(c, projectId);
2251 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2252 const body = await c.req.json().catch(() => ({}));
2253 try {
2254 const org = await createOrg(projectId, userId, body as { name: string; slug: string; logo?: string });
2255 return c.json({ org });
2256 } catch (err) {
2257 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2258 log.error('org_create_failed', { projectId, message: err instanceof Error ? err.message : String(err) });
2259 return c.json({ code: 'org_create_failed' }, 500);
2260 }
2261});
2262
2263authServiceRouter.get('/v1/auth-tenant/orgs/:id', async (c) => {
2264 const projectId = resolveTenant(c);
2265 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2266 const userId = await getTenantUserId(c, projectId);
2267 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2268 const org = await getOrg(projectId, c.req.param('id'));
2269 if (!org) return c.json({ code: 'not_found' }, 404);
2270 return c.json({ org });
2271});
2272
2273authServiceRouter.patch('/v1/auth-tenant/orgs/:id', async (c) => {
2274 const projectId = resolveTenant(c);
2275 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2276 const userId = await getTenantUserId(c, projectId);
2277 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2278 const orgId = c.req.param('id');
2279 if (!(await hasPermission(projectId, orgId, userId, 'org:update'))) {
2280 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2281 }
2282 const body = await c.req.json().catch(() => ({}));
2283 try {
2284 const org = await updateOrg(projectId, orgId, body as { name?: string; logo?: string | null; slug?: string });
2285 return c.json({ org });
2286 } catch (err) {
2287 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2288 return c.json({ code: 'org_update_failed' }, 500);
2289 }
2290});
2291
2292authServiceRouter.delete('/v1/auth-tenant/orgs/:id', async (c) => {
2293 const projectId = resolveTenant(c);
2294 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2295 const userId = await getTenantUserId(c, projectId);
2296 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2297 const orgId = c.req.param('id');
2298 if (!(await hasPermission(projectId, orgId, userId, 'org:delete'))) {
2299 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2300 }
2301 await deleteOrg(projectId, orgId);
2302 return c.json({ ok: true });
2303});
2304
2305// members
2306authServiceRouter.get('/v1/auth-tenant/orgs/:id/members', async (c) => {
2307 const projectId = resolveTenant(c);
2308 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2309 const userId = await getTenantUserId(c, projectId);
2310 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2311 const orgId = c.req.param('id');
2312 const members = await listOrgMembers(projectId, orgId);
2313 return c.json({ members });
2314});
2315
2316authServiceRouter.post('/v1/auth-tenant/orgs/:id/members', async (c) => {
2317 const projectId = resolveTenant(c);
2318 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2319 const userId = await getTenantUserId(c, projectId);
2320 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2321 const orgId = c.req.param('id');
2322 if (!(await hasPermission(projectId, orgId, userId, 'member:add'))) {
2323 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2324 }
2325 const body = await c.req.json().catch(() => ({}));
2326 try {
2327 const member = await addOrgMember(projectId, orgId, body.userId, body.role ?? 'member');
2328 return c.json({ member });
2329 } catch (err) {
2330 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2331 return c.json({ code: 'member_add_failed' }, 500);
2332 }
2333});
2334
2335authServiceRouter.patch('/v1/auth-tenant/orgs/:id/members/:userId', async (c) => {
2336 const projectId = resolveTenant(c);
2337 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2338 const userId = await getTenantUserId(c, projectId);
2339 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2340 const orgId = c.req.param('id');
2341 if (!(await hasPermission(projectId, orgId, userId, 'member:update_role'))) {
2342 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2343 }
2344 const targetUserId = c.req.param('userId');
2345 const body = await c.req.json().catch(() => ({}));
2346 try {
2347 const member = await updateMemberRole(projectId, orgId, targetUserId, body.role);
2348 return c.json({ member });
2349 } catch (err) {
2350 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2351 return c.json({ code: 'member_update_failed' }, 500);
2352 }
2353});
2354
2355authServiceRouter.delete('/v1/auth-tenant/orgs/:id/members/:userId', async (c) => {
2356 const projectId = resolveTenant(c);
2357 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2358 const userId = await getTenantUserId(c, projectId);
2359 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2360 const orgId = c.req.param('id');
2361 if (!(await hasPermission(projectId, orgId, userId, 'member:remove'))) {
2362 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2363 }
2364 await removeOrgMember(projectId, orgId, c.req.param('userId'));
2365 return c.json({ ok: true });
2366});
2367
2368// invites
2369authServiceRouter.get('/v1/auth-tenant/orgs/:id/invites', async (c) => {
2370 const projectId = resolveTenant(c);
2371 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2372 const userId = await getTenantUserId(c, projectId);
2373 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2374 const orgId = c.req.param('id');
2375 if (!(await hasPermission(projectId, orgId, userId, 'invite:list'))) {
2376 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2377 }
2378 const invites = await listPendingInvites(projectId, orgId);
2379 return c.json({ invites });
2380});
2381
2382authServiceRouter.post('/v1/auth-tenant/orgs/:id/invites', async (c) => {
2383 const projectId = resolveTenant(c);
2384 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2385 const userId = await getTenantUserId(c, projectId);
2386 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2387 const orgId = c.req.param('id');
2388 if (!(await hasPermission(projectId, orgId, userId, 'invite:create'))) {
2389 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2390 }
2391 const body = await c.req.json().catch(() => ({}));
2392 try {
2393 const invite = await createOrgInvite(projectId, orgId, userId, { email: body.email, role: body.role });
2394 return c.json({ invite });
2395 } catch (err) {
2396 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2397 return c.json({ code: 'invite_create_failed' }, 500);
2398 }
2399});
2400
2401authServiceRouter.delete('/v1/auth-tenant/orgs/:id/invites/:inviteId', async (c) => {
2402 const projectId = resolveTenant(c);
2403 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2404 const userId = await getTenantUserId(c, projectId);
2405 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2406 const orgId = c.req.param('id');
2407 if (!(await hasPermission(projectId, orgId, userId, 'invite:revoke'))) {
2408 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2409 }
2410 await revokeInvite(projectId, c.req.param('inviteId'));
2411 return c.json({ ok: true });
2412});
2413
2414authServiceRouter.get('/v1/auth-tenant/invites/:token', async (c) => {
2415 const projectId = resolveTenant(c);
2416 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2417 const invite = await getInviteByToken(projectId, c.req.param('token'));
2418 if (!invite) return c.json({ code: 'not_found' }, 404);
2419 return c.json({ invite });
2420});
2421
2422authServiceRouter.post('/v1/auth-tenant/invites/accept', async (c) => {
2423 const projectId = resolveTenant(c);
2424 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2425 const userId = await getTenantUserId(c, projectId);
2426 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2427 const body = await c.req.json().catch(() => ({}));
2428 try {
2429 const result = await acceptInvite(projectId, body.token, userId);
2430 return c.json({ ok: true, orgId: result.orgId });
2431 } catch (err) {
2432 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2433 return c.json({ code: 'invite_accept_failed' }, 500);
2434 }
2435});
2436
2437// ─── Phase 4 — Custom Roles ───────────────────────────────────────────────
2438
2439authServiceRouter.get('/v1/auth-tenant/orgs/:id/roles', async (c) => {
2440 const projectId = resolveTenant(c);
2441 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2442 const userId = await getTenantUserId(c, projectId);
2443 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2444 const orgId = c.req.param('id');
2445 const roles = await listOrgRoles(projectId, orgId);
2446 return c.json({ roles });
2447});
2448
2449authServiceRouter.post('/v1/auth-tenant/orgs/:id/roles', async (c) => {
2450 const projectId = resolveTenant(c);
2451 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2452 const userId = await getTenantUserId(c, projectId);
2453 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2454 const orgId = c.req.param('id');
2455 if (!(await hasPermission(projectId, orgId, userId, 'member:update_role'))) {
2456 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2457 }
2458 const body = await c.req.json().catch(() => ({}));
2459 try {
2460 const role = await createOrgRole(projectId, orgId, { name: body.name, permissions: body.permissions ?? [] });
2461 return c.json({ role });
2462 } catch (err) {
2463 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2464 return c.json({ code: 'role_create_failed' }, 500);
2465 }
2466});
2467
2468authServiceRouter.patch('/v1/auth-tenant/orgs/:id/roles/:roleId', async (c) => {
2469 const projectId = resolveTenant(c);
2470 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2471 const userId = await getTenantUserId(c, projectId);
2472 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2473 const orgId = c.req.param('id');
2474 if (!(await hasPermission(projectId, orgId, userId, 'member:update_role'))) {
2475 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2476 }
2477 const body = await c.req.json().catch(() => ({}));
2478 try {
2479 const role = await updateOrgRole(projectId, orgId, c.req.param('roleId'), {
2480 name: body.name,
2481 permissions: body.permissions,
2482 });
2483 return c.json({ role });
2484 } catch (err) {
2485 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2486 return c.json({ code: 'role_update_failed' }, 500);
2487 }
2488});
2489
2490authServiceRouter.delete('/v1/auth-tenant/orgs/:id/roles/:roleId', async (c) => {
2491 const projectId = resolveTenant(c);
2492 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2493 const userId = await getTenantUserId(c, projectId);
2494 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2495 const orgId = c.req.param('id');
2496 if (!(await hasPermission(projectId, orgId, userId, 'member:update_role'))) {
2497 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2498 }
2499 try {
2500 await deleteOrgRole(projectId, orgId, c.req.param('roleId'));
2501 return c.json({ ok: true });
2502 } catch (err) {
2503 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2504 return c.json({ code: 'role_delete_failed' }, 500);
2505 }
2506});
2507
2508// ─── Phase 4 — Domain Verification ────────────────────────────────────────
2509
2510authServiceRouter.get('/v1/auth-tenant/orgs/:id/domains', async (c) => {
2511 const projectId = resolveTenant(c);
2512 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2513 const userId = await getTenantUserId(c, projectId);
2514 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2515 const orgId = c.req.param('id');
2516 if (!(await hasPermission(projectId, orgId, userId, 'domain:manage'))) {
2517 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2518 }
2519 const domains = await listOrgDomains(projectId, orgId);
2520 return c.json({ domains });
2521});
2522
2523authServiceRouter.post('/v1/auth-tenant/orgs/:id/domains', async (c) => {
2524 const projectId = resolveTenant(c);
2525 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2526 const userId = await getTenantUserId(c, projectId);
2527 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2528 const orgId = c.req.param('id');
2529 if (!(await hasPermission(projectId, orgId, userId, 'domain:manage'))) {
2530 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2531 }
2532 const body = await c.req.json().catch(() => ({}));
2533 try {
2534 const domain = await addOrgDomain(projectId, orgId, body.domain);
2535 return c.json({ domain });
2536 } catch (err) {
2537 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2538 return c.json({ code: 'domain_add_failed' }, 500);
2539 }
2540});
2541
2542authServiceRouter.post('/v1/auth-tenant/orgs/:id/domains/:domainId/verify', async (c) => {
2543 const projectId = resolveTenant(c);
2544 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2545 const userId = await getTenantUserId(c, projectId);
2546 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2547 const orgId = c.req.param('id');
2548 if (!(await hasPermission(projectId, orgId, userId, 'domain:manage'))) {
2549 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2550 }
2551 try {
2552 const domain = await verifyOrgDomain(projectId, orgId, c.req.param('domainId'));
2553 return c.json({ domain });
2554 } catch (err) {
2555 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2556 return c.json({ code: 'domain_verify_failed' }, 500);
2557 }
2558});
2559
2560authServiceRouter.patch('/v1/auth-tenant/orgs/:id/domains/:domainId/auto-join', async (c) => {
2561 const projectId = resolveTenant(c);
2562 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2563 const userId = await getTenantUserId(c, projectId);
2564 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2565 const orgId = c.req.param('id');
2566 if (!(await hasPermission(projectId, orgId, userId, 'domain:manage'))) {
2567 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2568 }
2569 const body = await c.req.json().catch(() => ({}));
2570 try {
2571 const domain = await setOrgDomainAutoJoin(projectId, orgId, c.req.param('domainId'), Boolean(body.enabled));
2572 return c.json({ domain });
2573 } catch (err) {
2574 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2575 return c.json({ code: 'domain_update_failed' }, 500);
2576 }
2577});
2578
2579authServiceRouter.delete('/v1/auth-tenant/orgs/:id/domains/:domainId', async (c) => {
2580 const projectId = resolveTenant(c);
2581 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2582 const userId = await getTenantUserId(c, projectId);
2583 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2584 const orgId = c.req.param('id');
2585 if (!(await hasPermission(projectId, orgId, userId, 'domain:manage'))) {
2586 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2587 }
2588 await removeOrgDomain(projectId, orgId, c.req.param('domainId'));
2589 return c.json({ ok: true });
2590});
2591
2592// ─── Phase 4 — Membership Requests ────────────────────────────────────────
2593
2594authServiceRouter.post('/v1/auth-tenant/orgs/:id/membership-requests', async (c) => {
2595 const projectId = resolveTenant(c);
2596 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2597 const userId = await getTenantUserId(c, projectId);
2598 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2599 const orgId = c.req.param('id');
2600 const body = await c.req.json().catch(() => ({}));
2601 try {
2602 const request = await createMembershipRequest(projectId, orgId, userId, body.message);
2603 return c.json({ request });
2604 } catch (err) {
2605 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2606 return c.json({ code: 'request_create_failed' }, 500);
2607 }
2608});
2609
2610authServiceRouter.get('/v1/auth-tenant/orgs/:id/membership-requests', async (c) => {
2611 const projectId = resolveTenant(c);
2612 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2613 const userId = await getTenantUserId(c, projectId);
2614 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2615 const orgId = c.req.param('id');
2616 if (!(await hasPermission(projectId, orgId, userId, 'request:approve'))) {
2617 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2618 }
2619 const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined;
2620 const requests = await listMembershipRequests(projectId, orgId, status);
2621 return c.json({ requests });
2622});
2623
2624authServiceRouter.post('/v1/auth-tenant/orgs/:id/membership-requests/:requestId/resolve', async (c) => {
2625 const projectId = resolveTenant(c);
2626 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2627 const userId = await getTenantUserId(c, projectId);
2628 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2629 const orgId = c.req.param('id');
2630 if (!(await hasPermission(projectId, orgId, userId, 'request:approve'))) {
2631 return c.json({ code: 'forbidden', message: 'insufficient permissions' }, 403);
2632 }
2633 const body = await c.req.json().catch(() => ({}));
2634 try {
2635 const request = await resolveMembershipRequest(projectId, orgId, c.req.param('requestId'), userId, body.decision);
2636 return c.json({ request });
2637 } catch (err) {
2638 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2639 return c.json({ code: 'request_resolve_failed' }, 500);
2640 }
2641});
2642
2643// ─── Phase 4 — Active Organization ────────────────────────────────────────
2644
2645authServiceRouter.post('/v1/auth-tenant/orgs/:id/set-active', async (c) => {
2646 const projectId = resolveTenant(c);
2647 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2648 const session = await getTenantSession(c, projectId);
2649 if (!session) return c.json({ code: 'unauthenticated' }, 401);
2650 const orgId = c.req.param('id');
2651 // Verify the user is actually a member of this org
2652 const role = await getUserOrgRole(projectId, orgId, session.userId);
2653 if (!role) return c.json({ code: 'forbidden', message: 'not a member of this org' }, 403);
2654 await setSessionActiveOrg(projectId, session.sessionId, orgId);
2655 return c.json({ ok: true });
2656});
2657
2658authServiceRouter.get('/v1/auth-tenant/orgs/active', async (c) => {
2659 const projectId = resolveTenant(c);
2660 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2661 const session = await getTenantSession(c, projectId);
2662 if (!session) return c.json({ code: 'unauthenticated' }, 401);
2663 const activeOrgId = await getSessionActiveOrg(projectId, session.sessionId);
2664 if (!activeOrgId) return c.json({ activeOrg: null });
2665 const org = await getOrg(projectId, activeOrgId);
2666 return c.json({ activeOrg: org });
2667});
2668
2669// ─── user metadata (customer-facing) ─────────────────────────────────────
2670
2671authServiceRouter.get('/v1/auth-tenant/user/metadata', async (c) => {
2672 const projectId = resolveTenant(c);
2673 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2674 const userId = await getTenantUserId(c, projectId);
2675 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2676 const meta = await getUserPublicMetadata(projectId, userId);
2677 return c.json({ publicMetadata: meta });
2678});
2679
2680authServiceRouter.patch('/v1/auth-tenant/user/metadata', async (c) => {
2681 const projectId = resolveTenant(c);
2682 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2683 const userId = await getTenantUserId(c, projectId);
2684 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2685 const body = (await c.req.json().catch(() => ({}))) as {
2686 publicMetadata?: Record<string, unknown>;
2687 };
2688 const meta = await setUserMetadata(
2689 projectId,
2690 userId,
2691 { publicMetadata: body.publicMetadata },
2692 { merge: true },
2693 );
2694 return c.json({ publicMetadata: meta.publicMetadata });
2695});
2696
2697// ─── user emails (customer-facing) ───────────────────────────────────────
2698
2699authServiceRouter.get('/v1/auth-tenant/user/emails', async (c) => {
2700 const projectId = resolveTenant(c);
2701 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2702 const userId = await getTenantUserId(c, projectId);
2703 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2704 const emails = await listUserEmails(projectId, userId);
2705 return c.json({ emails });
2706});
2707
2708authServiceRouter.post('/v1/auth-tenant/user/emails', async (c) => {
2709 const projectId = resolveTenant(c);
2710 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2711 const userId = await getTenantUserId(c, projectId);
2712 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2713 const body = (await c.req.json().catch(() => ({}))) as { email?: string };
2714 if (!body.email || typeof body.email !== 'string') {
2715 return c.json({ code: 'validation_failed', message: 'email required' }, 400);
2716 }
2717 try {
2718 const email = await addUserEmail(projectId, userId, body.email);
2719 return c.json({ email }, 201);
2720 } catch (err) {
2721 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
2722 return c.json({ code: 'email_add_failed' }, 500);
2723 }
2724});
2725
2726authServiceRouter.delete('/v1/auth-tenant/user/emails/:emailId', async (c) => {
2727 const projectId = resolveTenant(c);
2728 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2729 const userId = await getTenantUserId(c, projectId);
2730 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2731 await removeUserEmail(projectId, userId, c.req.param('emailId'));
2732 return c.json({ ok: true });
2733});
2734
2735// ─── user avatar (Phase 7.2 — customer-facing) ────────────────────────────
2736
2737authServiceRouter.post('/v1/auth-tenant/user/avatar/presign', async (c) => {
2738 const projectId = resolveTenant(c);
2739 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2740 const userId = await getTenantUserId(c, projectId);
2741 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2742
2743 if (!isStorageConfigured()) {
2744 return c.json({ code: 'storage_not_configured' }, 503);
2745 }
2746
2747 const body = (await c.req.json().catch(() => ({}))) as { contentType?: string };
2748 if (!body.contentType) {
2749 return c.json({ code: 'validation_failed', message: 'contentType required' }, 400);
2750 }
2751
2752 try {
2753 const result = generateAvatarPresign(projectId, userId, body.contentType);
2754 return c.json(result);
2755 } catch (err) {
2756 return c.json(
2757 { code: 'validation_failed', message: err instanceof Error ? err.message : 'invalid content type' },
2758 400,
2759 );
2760 }
2761});
2762
2763authServiceRouter.patch('/v1/auth-tenant/user/avatar', async (c) => {
2764 const projectId = resolveTenant(c);
2765 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2766 const userId = await getTenantUserId(c, projectId);
2767 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2768
2769 const body = (await c.req.json().catch(() => ({}))) as { imageUrl?: string | null };
2770 await updateUserAvatar(projectId, userId, body.imageUrl ?? null);
2771 return c.json({ ok: true });
2772});
2773
2774authServiceRouter.get('/v1/auth-tenant/user/avatar/serve', async (c) => {
2775 const q = new URL(c.req.url).searchParams;
2776 const projectId = q.get('p');
2777 const userId = q.get('u');
2778 const fileId = q.get('f');
2779 if (!projectId || !userId || !fileId) {
2780 return c.json({ code: 'validation_failed' }, 400);
2781 }
2782
2783 try {
2784 const img = await getAvatarImage(projectId, userId, fileId);
2785 if (!img) return c.body(null, 404);
2786 c.header('content-type', img.contentType);
2787 c.header('cache-control', 'public, max-age=86400');
2788 return c.body(Buffer.from(img.bytes));
2789 } catch {
2790 return c.json({ code: 'avatar_fetch_failed' }, 500);
2791 }
2792});
2793
2794// ─── username authentication (Phase 7.3 — customer-facing) ────────────────
2795
2796authServiceRouter.post('/v1/auth-tenant/username/sign-in', async (c) => {
2797 const projectId = resolveTenant(c);
2798 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2799
2800 const body = (await c.req.json().catch(() => ({}))) as { username?: string; password?: string };
2801 if (!body.username || !body.password) {
2802 return c.json({ code: 'validation_failed', message: 'username and password required' }, 400);
2803 }
2804
2805 const resolved = await resolveUsernameToEmail(projectId, body.username);
2806 if (!resolved) {
2807 return c.json({ code: 'invalid_credentials' }, 401);
2808 }
2809
2810 const instance = await getAuthInstance(projectId);
2811 const signInUrl = new URL(c.req.url);
2812 signInUrl.pathname = '/v1/auth-tenant/sign-in/email';
2813
2814 const signInReq = new Request(signInUrl.toString(), {
2815 method: 'POST',
2816 headers: {
2817 'content-type': 'application/json',
2818 'x-briven-project-id': projectId,
2819 },
2820 body: JSON.stringify({ email: resolved.email, password: body.password }),
2821 });
2822
2823 const response = await instance.betterAuth.handler(signInReq);
2824 return await withActionableOriginError(response, projectId);
2825});
2826
2827authServiceRouter.post('/v1/auth-tenant/username', async (c) => {
2828 const projectId = resolveTenant(c);
2829 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2830 const userId = await getTenantUserId(c, projectId);
2831 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2832
2833 const body = (await c.req.json().catch(() => ({}))) as { username?: string };
2834 if (!body.username) {
2835 return c.json({ code: 'validation_failed', message: 'username required' }, 400);
2836 }
2837
2838 try {
2839 validateUsername(body.username);
2840 await createUsername(projectId, userId, body.username);
2841 return c.json({ ok: true });
2842 } catch (err) {
2843 if (err instanceof ValidationError) {
2844 return c.json({ code: 'validation_failed', message: err.message }, 400);
2845 }
2846 return c.json({ code: 'username_taken', message: 'username already taken' }, 409);
2847 }
2848});
2849
2850authServiceRouter.get('/v1/auth-tenant/username', async (c) => {
2851 const projectId = resolveTenant(c);
2852 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2853 const userId = await getTenantUserId(c, projectId);
2854 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2855
2856 const username = await getUsernameByUserId(projectId, userId);
2857 return c.json({ username });
2858});
2859
2860authServiceRouter.delete('/v1/auth-tenant/username', async (c) => {
2861 const projectId = resolveTenant(c);
2862 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2863 const userId = await getTenantUserId(c, projectId);
2864 if (!userId) return c.json({ code: 'unauthenticated' }, 401);
2865
2866 await deleteUsername(projectId, userId);
2867 return c.json({ ok: true });
2868});
2869
2870// ─── test token exchange (Phase 7.4 — customer-facing) ────────────────────
2871
2872authServiceRouter.post('/v1/auth-tenant/test-token', async (c) => {
2873 const projectId = resolveTenant(c);
2874 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2875
2876 const body = (await c.req.json().catch(() => ({}))) as { token?: string };
2877 if (!body.token || typeof body.token !== 'string') {
2878 return c.json({ code: 'validation_failed', message: 'token required' }, 400);
2879 }
2880
2881 const result = await exchangeTestToken(projectId, body.token);
2882 if (!result) {
2883 return c.json({ code: 'invalid_token' }, 401);
2884 }
2885
2886 const isProd = env.BRIVEN_ENV === 'production';
2887 const cookieValue = `${SESSION_COOKIE_NAME}=${encodeURIComponent(result.sessionToken)}; Path=/; HttpOnly; SameSite=${isProd ? 'None' : 'Lax'}${isProd ? '; Secure' : ''}; Max-Age=604800`;
2888 c.header('set-cookie', cookieValue);
2889
2890 return c.json({ ok: true, expiresAt: result.expiresAt.toISOString() });
2891});
2892
2893// ─── sign-in tokens (customer-facing) ─────────────────────────────────────
2894
2895authServiceRouter.post('/v1/auth-tenant/sign-in/token', async (c) => {
2896 const projectId = resolveTenant(c);
2897 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2898 const body = (await c.req.json().catch(() => ({}))) as { token?: string };
2899 if (!body.token || typeof body.token !== 'string') {
2900 return c.json({ code: 'validation_failed', message: 'token required' }, 400);
2901 }
2902 try {
2903 const result = await exchangeSigninToken(projectId, body.token, {
2904 userAgent: c.req.header('user-agent') ?? null,
2905 });
2906 // Set the session cookie so the client is authenticated on the next request.
2907 // Cookie attributes mirror Better Auth's defaults (cookiePrefix: 'briven-auth').
2908 const isProd = env.BRIVEN_ENV === 'production';
2909 const cookieValue = `${SESSION_COOKIE_NAME}=${encodeURIComponent(result.sessionToken)}; Path=/; HttpOnly; SameSite=${isProd ? 'None' : 'Lax'}${isProd ? '; Secure' : ''}; Max-Age=604800`;
2910 c.header('set-cookie', cookieValue);
2911 return c.json({ ok: true, expiresAt: result.expiresAt.toISOString() });
2912 } catch (err) {
2913 if (err instanceof SigninTokenError) {
2914 const status = err.code === 'token_already_used' ? 410 : 401;
2915 return c.json({ code: err.code, message: err.message }, status);
2916 }
2917 log.error('briven_auth_signin_token_exchange_failed', {
2918 projectId,
2919 message: err instanceof Error ? err.message : String(err),
2920 });
2921 return c.json({ code: 'token_exchange_failed' }, 500);
2922 }
2923});
2924
2925// ─── JWT token generation (Phase 7.1) ─────────────────────────────────────
2926
2927authServiceRouter.post('/v1/auth-tenant/jwt/token', async (c) => {
2928 const projectId = resolveTenant(c);
2929 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2930
2931 const body = (await c.req.json().catch(() => ({}))) as { template?: string };
2932 const cookieHeader = c.req.header('cookie') ?? '';
2933 const match = cookieHeader.match(/briven_auth_session_token=([^;]+)/);
2934 const sessionToken = match?.[1] ?? null;
2935
2936 if (!sessionToken) {
2937 return c.json({ code: 'unauthenticated' }, 401);
2938 }
2939
2940 const result = await generateJwtToken(projectId, sessionToken, body.template);
2941 if ('error' in result) {
2942 return c.json({ code: result.error }, 400);
2943 }
2944
2945 return c.json({ token: result.token, expiresAt: result.expiresAt.toISOString() });
2946});
2947
2948authServiceRouter.get('/v1/auth-tenant/jwt/jwks', async (c) => {
2949 const projectId = resolveTenant(c);
2950 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
2951 const jwks = await getCustomJwks(projectId);
2952 return c.json(jwks);
2953});
2954
2955// ─── sign-in tokens (admin) ────────────────────────────────────────────────
2956
2957authServiceRouter.post(
2958 '/v1/projects/:id/auth/users/:userId/signin-token',
2959 requireProjectRole('admin'),
2960 async (c) => {
2961 const projectId = c.req.param('id');
2962 const userId = c.req.param('userId');
2963 if (!projectId || !userId) {
2964 return c.json({ code: 'validation_failed', message: 'missing :id or :userId' }, 400);
2965 }
2966 const actor = c.get('user');
2967 if (!actor) return c.json({ code: 'unauthorized' }, 401);
2968 const body = (await c.req.json().catch(() => ({}))) as { ttlMinutes?: number };
2969 try {
2970 const created = await createSigninToken(projectId, userId, {
2971 ttlMinutes: typeof body.ttlMinutes === 'number' ? body.ttlMinutes : undefined,
2972 });
2973 await audit({
2974 actorId: actor.id,
2975 projectId,
2976 action: 'auth.user.signin_token.created',
2977 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
2978 userAgent: c.req.header('user-agent') ?? null,
2979 metadata: { userId },
2980 });
2981 return c.json({ token: created.token, expiresAt: created.expiresAt.toISOString() });
2982 } catch (err) {
2983 log.error('briven_auth_signin_token_create_failed', {
2984 projectId,
2985 userId,
2986 message: err instanceof Error ? err.message : String(err),
2987 });
2988 return c.json({ code: 'token_create_failed' }, 500);
2989 }
2990 },
2991);
2992
2993// ─── Phase 5 — Enterprise SSO (admin) ─────────────────────────────────────
2994
2995authServiceRouter.get(
2996 '/v1/projects/:id/auth/sso/connections',
2997 requireProjectRole('admin'),
2998 async (c) => {
2999 const projectId = c.req.param('id');
3000 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3001 const connections = await listSsoConnections(projectId);
3002 return c.json({ connections });
3003 },
3004);
3005
3006authServiceRouter.post(
3007 '/v1/projects/:id/auth/sso/connections',
3008 requireProjectRole('admin'),
3009 async (c) => {
3010 const projectId = c.req.param('id');
3011 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3012 const body = await c.req.json().catch(() => ({}));
3013 try {
3014 const connection = await createSsoConnection(projectId, {
3015 name: body.name,
3016 providerType: body.providerType,
3017 config: body.config ?? {},
3018 domains: body.domains,
3019 jitEnabled: body.jitEnabled,
3020 });
3021 return c.json({ connection });
3022 } catch (err) {
3023 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
3024 log.error('sso_connection_create_failed', { projectId, message: err instanceof Error ? err.message : String(err) });
3025 return c.json({ code: 'sso_connection_create_failed' }, 500);
3026 }
3027 },
3028);
3029
3030authServiceRouter.patch(
3031 '/v1/projects/:id/auth/sso/connections/:connectionId',
3032 requireProjectRole('admin'),
3033 async (c) => {
3034 const projectId = c.req.param('id');
3035 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3036 const body = await c.req.json().catch(() => ({}));
3037 try {
3038 const connection = await updateSsoConnection(projectId, c.req.param('connectionId'), {
3039 name: body.name,
3040 config: body.config,
3041 domains: body.domains,
3042 jitEnabled: body.jitEnabled,
3043 });
3044 return c.json({ connection });
3045 } catch (err) {
3046 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
3047 return c.json({ code: 'sso_connection_update_failed' }, 500);
3048 }
3049 },
3050);
3051
3052authServiceRouter.delete(
3053 '/v1/projects/:id/auth/sso/connections/:connectionId',
3054 requireProjectRole('admin'),
3055 async (c) => {
3056 const projectId = c.req.param('id');
3057 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3058 await deleteSsoConnection(projectId, c.req.param('connectionId'));
3059 return c.json({ ok: true });
3060 },
3061);
3062
3063authServiceRouter.post(
3064 '/v1/projects/:id/auth/sso/connections/:connectionId/revoke-sessions',
3065 requireProjectRole('admin'),
3066 async (c) => {
3067 const projectId = c.req.param('id');
3068 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3069 const count = await revokeAllSessionsForConnection(projectId, c.req.param('connectionId'));
3070 return c.json({ revoked: count });
3071 },
3072);
3073
3074// ─── Phase 5 — Enterprise SSO (customer-facing) ───────────────────────────
3075
3076authServiceRouter.get('/v1/auth-tenant/sso/connections', async (c) => {
3077 const projectId = resolveTenant(c);
3078 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3079 const connections = await listSsoConnections(projectId);
3080 // Strip config from public response — it may contain certs/secrets.
3081 return c.json({
3082 connections: connections.map((c) => ({
3083 id: c.id,
3084 name: c.name,
3085 providerType: c.providerType,
3086 domains: c.domains,
3087 })),
3088 });
3089});
3090
3091authServiceRouter.get('/v1/auth-tenant/sso/domain/:domain', async (c) => {
3092 const projectId = resolveTenant(c);
3093 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3094 const connection = await findConnectionByDomain(projectId, c.req.param('domain'));
3095 if (!connection) return c.json({ code: 'not_found' }, 404);
3096 return c.json({
3097 connection: {
3098 id: connection.id,
3099 name: connection.name,
3100 providerType: connection.providerType,
3101 domains: connection.domains,
3102 },
3103 });
3104});
3105
3106// SAML
3107authServiceRouter.get('/v1/auth-tenant/sso/saml/:connectionId/metadata', async (c) => {
3108 const projectId = resolveTenant(c);
3109 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3110 try {
3111 const metadata = await generateSamlMetadata(projectId, c.req.param('connectionId'));
3112 return c.text(metadata, 200, { 'content-type': 'application/xml' });
3113 } catch (err) {
3114 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
3115 return c.json({ code: 'metadata_generation_failed' }, 500);
3116 }
3117});
3118
3119authServiceRouter.get('/v1/auth-tenant/sso/saml/:connectionId', async (c) => {
3120 const projectId = resolveTenant(c);
3121 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3122 const relayState = await validateRelayState(c.req.query('redirectTo') ?? '/', projectId);
3123 try {
3124 const { redirectUrl } = await generateSamlAuthnRequest(projectId, c.req.param('connectionId'), relayState);
3125 return c.redirect(redirectUrl);
3126 } catch (err) {
3127 if (err instanceof ValidationError) return c.json({ code: 'validation_failed', message: err.message }, 400);
3128 return c.json({ code: 'saml_request_failed' }, 500);
3129 }
3130});
3131
3132authServiceRouter.post('/v1/auth-tenant/sso/saml/:connectionId/acs', async (c) => {
3133 const projectId = resolveTenant(c);
3134 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3135
3136 const connectionId = c.req.param('connectionId');
3137 const body = await c.req.parseBody();
3138 const samlResponse = body.SAMLResponse as string;
3139 if (!samlResponse) {
3140 return c.json({ code: 'validation_failed', message: 'SAMLResponse is required' }, 400);
3141 }
3142
3143 try {
3144 const assertion = await validateSamlResponse(projectId, connectionId, samlResponse);
3145 const conn = await getSsoConnection(projectId, connectionId);
3146 if (!conn) throw new ValidationError('connection not found');
3147
3148 // JIT provisioning + session creation.
3149 const user = await findOrCreateSsoUser(projectId, assertion.email, assertion.name, conn.jitEnabled);
3150 const { sessionToken, expiresAt } = await createSsoSession(projectId, user.id, connectionId, {
3151 userAgent: c.req.header('user-agent') ?? null,
3152 });
3153
3154 // Set session cookie.
3155 const isProduction = env.BRIVEN_ENV === 'production';
3156 const cookieValue = `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionToken)}; Path=/; HttpOnly; SameSite=${isProduction ? 'None' : 'Lax'}${isProduction ? '; Secure' : ''}; Expires=${expiresAt.toUTCString()}`;
3157 c.header('set-cookie', cookieValue);
3158
3159 // Redirect to the app's callback URL (from RelayState) or default.
3160 const relayState = await validateRelayState((body.RelayState as string) || '/', projectId);
3161 return c.redirect(relayState);
3162 } catch (err) {
3163 if (err instanceof ValidationError) {
3164 return c.json({ code: 'validation_failed', message: err.message }, 400);
3165 }
3166 log.error('saml_acs_failed', {
3167 projectId,
3168 connectionId,
3169 message: err instanceof Error ? err.message : String(err),
3170 });
3171 return c.json({ code: 'saml_acs_failed' }, 500);
3172 }
3173});
3174
3175// ─── OIDC Enterprise (Gap Fix #3) ─────────────────────────────────────────
3176
3177authServiceRouter.get('/v1/auth-tenant/sso/oidc/:connectionId', async (c) => {
3178 const projectId = resolveTenant(c);
3179 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3180 const connectionId = c.req.param('connectionId');
3181 const redirectTo = await validateRelayState(c.req.query('redirectTo') ?? '/', projectId);
3182
3183 try {
3184 const { redirectUrl } = await generateOidcAuthUrl(projectId, connectionId, { redirectTo });
3185 return c.redirect(redirectUrl);
3186 } catch (err) {
3187 if (err instanceof ValidationError) {
3188 return c.json({ code: 'validation_failed', message: err.message }, 400);
3189 }
3190 log.error('oidc_start_failed', {
3191 projectId,
3192 connectionId,
3193 message: err instanceof Error ? err.message : String(err),
3194 });
3195 return c.json({ code: 'oidc_start_failed' }, 500);
3196 }
3197});
3198
3199authServiceRouter.get('/v1/auth-tenant/sso/oidc/:connectionId/callback', async (c) => {
3200 const projectId = resolveTenant(c);
3201 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3202 const connectionId = c.req.param('connectionId');
3203 const code = c.req.query('code');
3204 const state = c.req.query('state');
3205
3206 if (!code || !state) {
3207 return c.json({ code: 'validation_failed', message: 'code and state are required' }, 400);
3208 }
3209
3210 try {
3211 const userinfo = await exchangeOidcCode(projectId, connectionId, code, state);
3212 const conn = await getSsoConnection(projectId, connectionId);
3213 if (!conn) throw new ValidationError('connection not found');
3214
3215 const user = await findOrCreateSsoUser(projectId, userinfo.email, userinfo.name, conn.jitEnabled);
3216 const { sessionToken, expiresAt } = await createSsoSession(projectId, user.id, connectionId, {
3217 userAgent: c.req.header('user-agent') ?? null,
3218 });
3219
3220 const isProduction = env.BRIVEN_ENV === 'production';
3221 const cookieValue = `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionToken)}; Path=/; HttpOnly; SameSite=${isProduction ? 'None' : 'Lax'}${isProduction ? '; Secure' : ''}; Expires=${expiresAt.toUTCString()}`;
3222 c.header('set-cookie', cookieValue);
3223
3224 // Prefer redirect stored at OIDC start (validated); fallback to query RelayState.
3225 const preferred =
3226 userinfo.redirectTo && userinfo.redirectTo.length > 0
3227 ? userinfo.redirectTo
3228 : c.req.query('RelayState') || '/';
3229 const relayState = await validateRelayState(preferred, projectId);
3230 return c.redirect(relayState);
3231 } catch (err) {
3232 if (err instanceof ValidationError) {
3233 return c.json({ code: 'validation_failed', message: err.message }, 400);
3234 }
3235 log.error('oidc_callback_failed', {
3236 projectId,
3237 connectionId,
3238 message: err instanceof Error ? err.message : String(err),
3239 });
3240 return c.json({ code: 'oidc_callback_failed' }, 500);
3241 }
3242});
3243
3244// ─── Catch-all bridge ─────────────────────────────────────────────────────
3245
3246// ─── session activity helpers ─────────────────────────────────────────────
3247
3248const SESSION_COOKIE_NAME = 'briven-auth.session_token';
3249
3250function extractSessionToken(cookieHeader: string | undefined): string | undefined {
3251 if (!cookieHeader) return undefined;
3252 for (const part of cookieHeader.split(';')) {
3253 const [name, value] = part.trim().split('=');
3254 if (name === SESSION_COOKIE_NAME && value) {
3255 return decodeURIComponent(value);
3256 }
3257 }
3258 return undefined;
3259}
3260
3261/**
3262 * Check whether a session has exceeded the inactivity timeout.
3263 * Returns { active: true } when there is no session cookie, no activity
3264 * record, or the session is still within the timeout window.
3265 */
3266async function checkSessionActivity(
3267 projectId: string,
3268 cookieHeader: string | undefined,
3269 timeoutMinutes: number,
3270): Promise<{ active: boolean; reason?: string }> {
3271 if (timeoutMinutes <= 0) return { active: true };
3272 const token = extractSessionToken(cookieHeader);
3273 if (!token) return { active: true };
3274
3275 try {
3276 const rows = await runInProjectDatabase<
3277 Array<{ last_active_at: Date | null }>
3278 >(projectId, async (tx) =>
3279 tx.unsafe(
3280 `SELECT a.last_active_at
3281 FROM "_briven_auth_session_activity" a
3282 JOIN "_briven_auth_sessions" s ON a.session_id = s.id
3283 WHERE s.token = $1
3284 LIMIT 1`,
3285 [token] as never,
3286 ) as never,
3287 );
3288 const row = rows[0];
3289 if (!row || !row.last_active_at) return { active: true };
3290
3291 const inactiveMs = Date.now() - new Date(row.last_active_at).getTime();
3292 const timeoutMs = timeoutMinutes * 60 * 1000;
3293 if (inactiveMs > timeoutMs) {
3294 return { active: false, reason: 'session expired due to inactivity' };
3295 }
3296 return { active: true };
3297 } catch {
3298 // Fail-open: if the query errors, allow the request.
3299 return { active: true };
3300 }
3301}
3302
3303/**
3304 * Touch (update) the session activity timestamp. Fire-and-forget — never
3305 * blocks the request path.
3306 */
3307async function touchSessionActivity(
3308 projectId: string,
3309 cookieHeader: string | undefined,
3310): Promise<void> {
3311 const token = extractSessionToken(cookieHeader);
3312 if (!token) return;
3313
3314 try {
3315 await runInProjectDatabase(projectId, async (tx) => {
3316 await tx.unsafe(
3317 `UPDATE "_briven_auth_session_activity" a
3318 SET last_active_at = now(), updated_at = now()
3319 FROM "_briven_auth_sessions" s
3320 WHERE a.session_id = s.id AND s.token = $1`,
3321 [token] as never,
3322 );
3323 });
3324 } catch {
3325 // Swallow — activity tracking must never break requests.
3326 }
3327}
3328
3329/**
3330 * Security-aware request processing for the tenant-auth bridge.
3331 *
3332 * For eligible JSON POSTs (sign-in, sign-up, magic-link, OTP, password reset):
3333 * 1. Parse the body
3334 * 2. Apply rate limiting by email
3335 * 3. Verify Turnstile token when enabled
3336 * 4. Check email allowlist/blocklist for sign-up
3337 * 5. Check waitlist mode for sign-up
3338 * 6. Check password breach for sign-up / password reset
3339 * 7. Check session inactivity timeout
3340 * 8. Normalize callbacks (existing behavior)
3341 *
3342 * Returns either a Response (when a security check fails) or a modified
3343 * Request (when all checks pass) to be forwarded to Better Auth.
3344 */
3345async function processTenantRequest(
3346 raw: Request,
3347 projectId: string,
3348 config: Awaited<ReturnType<typeof getAuthConfig>>,
3349 clientIp: string,
3350): Promise<Response | Request> {
3351 const path = new URL(raw.url).pathname;
3352
3353 // ── rate limiting by email (only for JSON POSTs to auth endpoints) ──
3354 const isAuthPost =
3355 raw.method === 'POST' &&
3356 (path.includes('/sign-up') ||
3357 path.includes('/sign-in') ||
3358 path.includes('/forget-password') ||
3359 path.includes('/reset-password') ||
3360 path.includes('/send-verification-email'));
3361
3362 if (isAuthPost && config.security.rateLimiting.enabled) {
3363 const ipLimit = await checkIpRateLimit(projectId, clientIp, {
3364 maxAttempts: config.security.rateLimiting.maxAttemptsPerIp,
3365 windowMinutes: config.security.rateLimiting.windowMinutes,
3366 });
3367 if (!ipLimit.allowed) {
3368 return new Response(
3369 JSON.stringify({
3370 code: 'rate_limited',
3371 message: 'too many requests from this IP address',
3372 retryAfter: ipLimit.retryAfterSeconds,
3373 }),
3374 {
3375 status: 429,
3376 headers: {
3377 'content-type': 'application/json',
3378 'retry-after': String(ipLimit.retryAfterSeconds),
3379 },
3380 },
3381 );
3382 }
3383 }
3384
3385 // Only parse JSON for the specific endpoints we need to inspect.
3386 const eligible =
3387 raw.method === 'POST' &&
3388 (path.includes('/sign-in/') ||
3389 path.includes('/sign-up') ||
3390 path.includes('/forget-password') ||
3391 path.includes('/reset-password') ||
3392 path.includes('/send-verification-email'));
3393
3394 if (!eligible) return raw;
3395
3396 const contentType = raw.headers.get('content-type') ?? '';
3397 if (!contentType.toLowerCase().includes('application/json')) return raw;
3398
3399 let parsed: unknown;
3400 try {
3401 parsed = await raw.clone().json();
3402 } catch {
3403 return raw;
3404 }
3405 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return raw;
3406 const body = parsed as Record<string, unknown>;
3407
3408 const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : undefined;
3409
3410 // ── email rate limiting ──
3411 if (isAuthPost && email && config.security.rateLimiting.enabled) {
3412 const emailLimit = await checkEmailRateLimit(projectId, email, {
3413 maxAttempts: 5,
3414 windowMinutes: 15,
3415 });
3416 if (!emailLimit.allowed) {
3417 return new Response(
3418 JSON.stringify({
3419 code: 'rate_limited',
3420 message: 'too many requests for this email address',
3421 retryAfter: emailLimit.retryAfterSeconds,
3422 }),
3423 {
3424 status: 429,
3425 headers: {
3426 'content-type': 'application/json',
3427 'retry-after': String(emailLimit.retryAfterSeconds),
3428 },
3429 },
3430 );
3431 }
3432 }
3433
3434 // ── turnstile verification ──
3435 if (config.turnstile.enabled && config.turnstile.siteKey) {
3436 const turnstileToken =
3437 typeof body.turnstileToken === 'string' ? body.turnstileToken : undefined;
3438 if (!turnstileToken) {
3439 return new Response(
3440 JSON.stringify({
3441 code: 'turnstile_required',
3442 message: 'bot protection verification is required',
3443 }),
3444 { status: 400, headers: { 'content-type': 'application/json' } },
3445 );
3446 }
3447 const turnstile = await verifyTurnstileToken(turnstileToken);
3448 if (!turnstile.success) {
3449 return new Response(
3450 JSON.stringify({
3451 code: 'turnstile_failed',
3452 message: turnstile.message ?? 'bot protection verification failed',
3453 }),
3454 { status: 400, headers: { 'content-type': 'application/json' } },
3455 );
3456 }
3457 }
3458
3459 // ── sign-up gate (email allowlist / blocklist / waitlist) ──
3460 const isSignUp = path.includes('/sign-up');
3461 if (isSignUp && email) {
3462 const gate = await checkSignUpGate(projectId, email, {
3463 signUpMode: config.security.signUpMode,
3464 allowedDomains: config.security.allowedEmailDomains,
3465 blockedDomains: config.security.blockedEmailDomains,
3466 blockDisposable: config.security.blockDisposableEmails,
3467 blockSubaddresses: config.security.blockEmailSubaddresses,
3468 });
3469 if (!gate.allowed) {
3470 return new Response(
3471 JSON.stringify({
3472 code: 'sign_up_not_allowed',
3473 message: gate.reason ?? 'sign-up is not allowed',
3474 }),
3475 { status: 403, headers: { 'content-type': 'application/json' } },
3476 );
3477 }
3478 }
3479
3480 // ── password breach detection ──
3481 const password = typeof body.password === 'string' ? body.password : undefined;
3482 const newPassword = typeof body.newPassword === 'string' ? body.newPassword : undefined;
3483 const passwordToCheck = password ?? newPassword;
3484 if (passwordToCheck && config.security.breachDetection.enabled) {
3485 const breach = await checkPasswordBreach(passwordToCheck);
3486 if (breach.breached) {
3487 return new Response(
3488 JSON.stringify({
3489 code: 'password_breached',
3490 message:
3491 'this password has been found in a data breach. please choose a different password.',
3492 }),
3493 { status: 400, headers: { 'content-type': 'application/json' } },
3494 );
3495 }
3496 }
3497
3498 // ── password policy enforcement (complexity + reuse) ──
3499 const isPasswordChange =
3500 path.includes('/sign-up') ||
3501 path.includes('/reset-password') ||
3502 path.includes('/change-password');
3503 if (isPasswordChange && passwordToCheck) {
3504 try {
3505 const policy = await getPasswordPolicy(projectId);
3506 validatePassword(passwordToCheck, policy);
3507 // Reuse check needs a user id when available (change/reset for known user).
3508 const bodyUserId =
3509 typeof body.userId === 'string'
3510 ? body.userId
3511 : typeof (body as { user?: { id?: string } }).user?.id === 'string'
3512 ? (body as { user: { id: string } }).user.id
3513 : null;
3514 if (bodyUserId && policy.preventReuse > 0) {
3515 await assertPasswordNotReused(projectId, bodyUserId, passwordToCheck, policy);
3516 }
3517 } catch (err) {
3518 if (err instanceof ValidationError) {
3519 return new Response(
3520 JSON.stringify({ code: 'weak_password', message: err.message }),
3521 { status: 400, headers: { 'content-type': 'application/json' } },
3522 );
3523 }
3524 // Unexpected error — swallow and let Better Auth handle it.
3525 }
3526 }
3527
3528 // ── callback normalization (existing behavior) ──
3529 const normalized = normalizeTenantCallbacks(body, raw.headers.get('origin'));
3530 const headers = new Headers(raw.headers);
3531 headers.delete('content-length');
3532 return new Request(raw.url, {
3533 method: raw.method,
3534 headers,
3535 body: JSON.stringify(normalized),
3536 });
3537}
3538
3539/**
3540 * Catch-all bridge. Better Auth ships its own `handler(request)` method
3541 * that routes every endpoint Better Auth registered (sign-in, sign-up,
3542 * OAuth callback, magic-link consume, session, etc). We pull the per-
3543 * tenant instance from the pool, hand the raw Request off, and return
3544 * Better Auth's Response untouched.
3545 *
3546 * Methods covered: GET, POST, PATCH, DELETE, OPTIONS — Better Auth's
3547 * internal route table includes all of them, so the bridge mounts `.all`.
3548 */
3549authServiceRouter.all('/v1/auth-tenant/*', async (c) => {
3550 const projectId = resolveTenant(c);
3551 if (!projectId) {
3552 return c.json(
3553 {
3554 code: 'tenant_unresolved',
3555 message:
3556 'missing or malformed tenant id (x-briven-project-id header or briven_project_id query param)',
3557 },
3558 400,
3559 );
3560 }
3561
3562 // Enforce SDK key scope when an Authorization header is present.
3563 const keyError = await enforceSdkKeyScope(c, projectId);
3564 if (keyError) return keyError;
3565
3566 // Raw visitor IP for the control-plane sign-up geo capture. Better Auth's
3567 // user.create hook can't read the HTTP request, so we stash the IP in an
3568 // AsyncLocalStorage context around the handler call; the hook reads it back
3569 // via getRequestContext(). Take the first comma-separated x-forwarded-for
3570 // value (the original client, before proxy appends). Independent of Better
3571 // Auth's own disableIpTracking — this is control-plane analytics only.
3572 const ip =
3573 c.req.header('cf-connecting-ip') ??
3574 c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
3575 null;
3576
3577 const clientIp = ip ?? 'unknown';
3578
3579 try {
3580 const instance = await getAuthInstance(projectId);
3581 const config = await getAuthConfig(projectId);
3582
3583 // Session inactivity timeout — checked on EVERY authenticated request,
3584 // not just the eligible POSTs inside processTenantRequest.
3585 if (config.session.inactivityTimeoutMinutes > 0) {
3586 const activity = await checkSessionActivity(
3587 projectId,
3588 c.req.header('cookie') ?? undefined,
3589 config.session.inactivityTimeoutMinutes,
3590 );
3591 if (!activity.active) {
3592 return c.json(
3593 { code: 'session_inactive', message: activity.reason ?? 'session expired due to inactivity' },
3594 401,
3595 );
3596 }
3597 }
3598
3599 // Security checks + callback rewriting in one pass.
3600 const processed = await processTenantRequest(c.req.raw, projectId, config, clientIp);
3601 if (processed instanceof Response) {
3602 // A security check failed — return the error response directly.
3603 return processed;
3604 }
3605
3606 const response = await runWithRequestContext({ ip, projectId, userAgent: c.req.header('user-agent') }, () =>
3607 instance.betterAuth.handler(processed),
3608 );
3609
3610 // Touch session activity on successful responses so idle tracking
3611 // stays fresh. Fire-and-forget — never blocks the response.
3612 if (config.session.inactivityTimeoutMinutes > 0 && response.status < 400) {
3613 void touchSessionActivity(projectId, c.req.header('cookie') ?? undefined);
3614 }
3615
3616 return await withActionableOriginError(response, projectId);
3617 } catch (err) {
3618 log.error('briven_auth_tenant_bridge_failed', {
3619 projectId,
3620 path: new URL(c.req.url).pathname,
3621 message: err instanceof Error ? err.message : String(err),
3622 });
3623 return c.json({ code: 'auth_internal_error' }, 500);
3624 }
3625});
3626
3627// ─── Phase 6.1 — Auth Dashboard Team Seats ────────────────────────────────
3628
3629/**
3630 * List auth team members for a project. Owners and auth team admins can read.
3631 */
3632authServiceRouter.get('/v1/projects/:id/auth/team', async (c) => {
3633 const projectId = c.req.param('id');
3634 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3635 const members = await listAuthTeamMembers(projectId);
3636 return c.json({ members });
3637});
3638
3639const inviteTeamMemberSchema = z.object({
3640 email: z.string().email(),
3641 role: z.enum(['admin', 'viewer']).default('viewer'),
3642});
3643
3644/**
3645 * Invite a user to the auth dashboard team by email. Owner-only.
3646 * If the user exists they are added immediately; otherwise the caller
3647 * should invite them to the project first.
3648 */
3649authServiceRouter.post(
3650 '/v1/projects/:id/auth/team',
3651 requireProjectRole('owner'),
3652 async (c) => {
3653 const projectId = c.req.param('id');
3654 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3655
3656 const actor = c.get('user');
3657 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3658
3659 const body = await c.req.json().catch(() => null);
3660 const parsed = inviteTeamMemberSchema.safeParse(body);
3661 if (!parsed.success) {
3662 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
3663 }
3664
3665 const target = await findUserByEmail(parsed.data.email);
3666 if (!target) {
3667 return c.json(
3668 { code: 'user_not_found', message: 'no registered user with this email; invite them to the project first' },
3669 404,
3670 );
3671 }
3672
3673 const added = await addAuthTeamMember({
3674 projectId,
3675 userId: target.id,
3676 role: parsed.data.role,
3677 invitedBy: actor.id,
3678 });
3679
3680 await audit({
3681 actorId: actor.id,
3682 projectId,
3683 action: 'auth.team.invite',
3684 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
3685 userAgent: c.req.header('user-agent') ?? null,
3686 metadata: { invitedUserId: target.id, role: parsed.data.role },
3687 });
3688
3689 return c.json({ member: added }, 201);
3690 },
3691);
3692
3693/**
3694 * Remove a user from the auth dashboard team. Owner-only.
3695 */
3696authServiceRouter.delete(
3697 '/v1/projects/:id/auth/team/:userId',
3698 requireProjectRole('owner'),
3699 async (c) => {
3700 const projectId = c.req.param('id');
3701 const userId = c.req.param('userId');
3702 if (!projectId || !userId) return c.json({ code: 'validation_failed' }, 400);
3703
3704 const actor = c.get('user');
3705 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3706
3707 await removeAuthTeamMember(projectId, userId);
3708
3709 await audit({
3710 actorId: actor.id,
3711 projectId,
3712 action: 'auth.team.remove',
3713 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
3714 userAgent: c.req.header('user-agent') ?? null,
3715 metadata: { removedUserId: userId },
3716 });
3717
3718 return c.json({ ok: true });
3719 },
3720);
3721
3722// ─── Phase 6.2 — User Impersonation ───────────────────────────────────────
3723
3724/**
3725 * Start impersonating a user. Auth team admins can create a short-lived
3726 * session for a target user. Returns a session token the dashboard can set
3727 * as a cookie to act on the user's behalf.
3728 */
3729authServiceRouter.post(
3730 '/v1/projects/:id/auth/impersonate',
3731 requireProjectRole('admin'),
3732 async (c) => {
3733 const projectId = c.req.param('id');
3734 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3735
3736 const actor = c.get('user');
3737 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3738
3739 const body = await c.req.json().catch(() => null);
3740 const targetUserId = body && typeof body === 'object' ? (body as Record<string, unknown>).userId : null;
3741 if (typeof targetUserId !== 'string') {
3742 return c.json({ code: 'validation_failed', message: 'missing userId' }, 400);
3743 }
3744
3745 const { sessionToken, expiresAt } = await createImpersonationSession(
3746 projectId,
3747 targetUserId,
3748 actor.id,
3749 );
3750
3751 await audit({
3752 actorId: actor.id,
3753 projectId,
3754 action: 'auth.impersonate.start',
3755 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
3756 userAgent: c.req.header('user-agent') ?? null,
3757 metadata: { targetUserId },
3758 });
3759
3760 return c.json({ sessionToken, expiresAt: expiresAt.toISOString() });
3761 },
3762);
3763
3764/**
3765 * Stop impersonating — revokes the impersonation session and records
3766 * the stop event in the tenant audit log.
3767 */
3768authServiceRouter.post(
3769 '/v1/projects/:id/auth/impersonate/stop',
3770 requireProjectRole('admin'),
3771 async (c) => {
3772 const projectId = c.req.param('id');
3773 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3774
3775 const actor = c.get('user');
3776 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3777
3778 const body = await c.req.json().catch(() => null);
3779 const sessionToken = body && typeof body === 'object' ? (body as Record<string, unknown>).sessionToken : null;
3780 if (typeof sessionToken !== 'string') {
3781 return c.json({ code: 'validation_failed', message: 'missing sessionToken' }, 400);
3782 }
3783
3784 await stopImpersonationSession(projectId, sessionToken, actor.id);
3785
3786 await audit({
3787 actorId: actor.id,
3788 projectId,
3789 action: 'auth.impersonate.stop',
3790 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
3791 userAgent: c.req.header('user-agent') ?? null,
3792 metadata: {},
3793 });
3794
3795 return c.json({ ok: true });
3796 },
3797);
3798
3799/**
3800 * Check whether the current session is an active impersonation session.
3801 * Customer-facing — called by the SDK to show an impersonation banner.
3802 */
3803authServiceRouter.get('/v1/auth-tenant/impersonation', async (c) => {
3804 const projectId = resolveTenant(c);
3805 if (!projectId) return c.json({ code: 'tenant_unresolved' }, 400);
3806
3807 const cookieHeader = c.req.header('cookie') ?? '';
3808 const match = cookieHeader.match(/briven_auth_session_token=([^;]+)/);
3809 const sessionToken = match?.[1] ?? null;
3810 if (!sessionToken) {
3811 return c.json({ impersonating: false });
3812 }
3813
3814 const active = await getActiveImpersonation(projectId, sessionToken);
3815 if (!active) {
3816 return c.json({ impersonating: false });
3817 }
3818
3819 return c.json({
3820 impersonating: true,
3821 impersonatedBy: active.impersonatedBy,
3822 targetUserId: active.targetUserId,
3823 });
3824});
3825
3826// ─── Phase 6.3 — Application Logs ─────────────────────────────────────────
3827
3828authServiceRouter.get(
3829 '/v1/projects/:id/auth/app-logs',
3830 requireProjectRole('admin'),
3831 async (c) => {
3832 const projectId = c.req.param('id');
3833 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3834
3835 const level = c.req.query('level') as 'error' | 'warn' | 'info' | undefined;
3836 const action = c.req.query('action') ?? undefined;
3837 const cursor = c.req.query('cursor') ?? null;
3838 const limitRaw = c.req.query('limit');
3839 const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
3840
3841 const result = await listAppLogs(projectId, {
3842 level,
3843 action,
3844 cursor,
3845 limit: Number.isFinite(limit!) ? limit : undefined,
3846 });
3847 return c.json(result);
3848 },
3849);
3850
3851/**
3852 * Admin trigger to purge old logs immediately. Normally the janitor
3853 * handles this on schedule; this endpoint is for manual cleanup or
3854 * testing retention changes.
3855 */
3856authServiceRouter.post(
3857 '/v1/projects/:id/auth/app-logs/purge',
3858 requireProjectRole('owner'),
3859 async (c) => {
3860 const projectId = c.req.param('id');
3861 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3862
3863 const config = await getAuthConfig(projectId);
3864 const [appResult, auditResult] = await Promise.all([
3865 purgeOldAppLogs(projectId, config.retention.appLogDays),
3866 purgeOldAuditLogs(projectId, config.retention.auditLogDays),
3867 ]);
3868
3869 return c.json({
3870 appLogsDeleted: appResult.deleted,
3871 auditLogsDeleted: auditResult.deleted,
3872 retention: config.retention,
3873 });
3874 },
3875);
3876
3877// ─── Phase 6.6 — Compliance Groundwork ────────────────────────────────────
3878
3879authServiceRouter.get(
3880 '/v1/projects/:id/auth/compliance',
3881 requireProjectRole('admin'),
3882 async (c) => {
3883 const projectId = c.req.param('id');
3884 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3885 const settings = await getComplianceSettings(projectId);
3886 return c.json({ compliance: settings });
3887 },
3888);
3889
3890authServiceRouter.patch(
3891 '/v1/projects/:id/auth/compliance',
3892 requireProjectRole('owner'),
3893 async (c) => {
3894 const projectId = c.req.param('id');
3895 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3896
3897 const actor = c.get('user');
3898 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3899
3900 const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
3901 const patch: Partial<{
3902 soc2ControlsUrl: string | null;
3903 hipaaBaaSignedAt: string | null;
3904 hipaaBaaSignedBy: string | null;
3905 gdprDpaSignedAt: string | null;
3906 gdprDpaSignedBy: string | null;
3907 encryptionAtRestEnabled: boolean;
3908 }> = {};
3909
3910 if (body.soc2ControlsUrl !== undefined) patch.soc2ControlsUrl = typeof body.soc2ControlsUrl === 'string' ? body.soc2ControlsUrl : null;
3911 if (body.hipaaBaaSignedAt !== undefined) patch.hipaaBaaSignedAt = typeof body.hipaaBaaSignedAt === 'string' ? body.hipaaBaaSignedAt : null;
3912 if (body.hipaaBaaSignedBy !== undefined) patch.hipaaBaaSignedBy = typeof body.hipaaBaaSignedBy === 'string' ? body.hipaaBaaSignedBy : null;
3913 if (body.gdprDpaSignedAt !== undefined) patch.gdprDpaSignedAt = typeof body.gdprDpaSignedAt === 'string' ? body.gdprDpaSignedAt : null;
3914 if (body.gdprDpaSignedBy !== undefined) patch.gdprDpaSignedBy = typeof body.gdprDpaSignedBy === 'string' ? body.gdprDpaSignedBy : null;
3915 if (body.encryptionAtRestEnabled !== undefined) patch.encryptionAtRestEnabled = Boolean(body.encryptionAtRestEnabled);
3916
3917 const settings = await setComplianceSettings(projectId, patch);
3918 await audit({
3919 actorId: actor.id,
3920 projectId,
3921 action: 'auth.compliance.update',
3922 ipHash: hashIp(c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? null),
3923 userAgent: c.req.header('user-agent') ?? null,
3924 metadata: { fields: Object.keys(patch) },
3925 });
3926 return c.json({ compliance: settings });
3927 },
3928);
3929
3930/** Full enterprise sales kit (DPA/BAA/retention templates + project status). */
3931authServiceRouter.get(
3932 '/v1/projects/:id/auth/compliance/pack',
3933 requireProjectRole('admin'),
3934 async (c) => {
3935 const projectId = c.req.param('id');
3936 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3937 const pack = await buildEnterpriseSalesPack(projectId, env.BRIVEN_API_ORIGIN);
3938 return c.json(pack);
3939 },
3940);
3941
3942authServiceRouter.post(
3943 '/v1/projects/:id/auth/compliance/sign-dpa',
3944 requireProjectRole('owner'),
3945 async (c) => {
3946 const projectId = c.req.param('id');
3947 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3948 const actor = c.get('user');
3949 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3950 const body = (await c.req.json().catch(() => ({}))) as { signedBy?: string };
3951 const signedBy = typeof body.signedBy === 'string' && body.signedBy.trim()
3952 ? body.signedBy.trim()
3953 : actor.email ?? actor.id;
3954 const compliance = await signGdprDpa(projectId, signedBy);
3955 await audit({
3956 actorId: actor.id,
3957 projectId,
3958 action: 'auth.compliance.dpa_signed',
3959 metadata: { signedBy },
3960 });
3961 return c.json({ compliance });
3962 },
3963);
3964
3965authServiceRouter.post(
3966 '/v1/projects/:id/auth/compliance/sign-baa',
3967 requireProjectRole('owner'),
3968 async (c) => {
3969 const projectId = c.req.param('id');
3970 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3971 const actor = c.get('user');
3972 if (!actor) return c.json({ code: 'unauthorized' }, 401);
3973 const body = (await c.req.json().catch(() => ({}))) as { signedBy?: string };
3974 const signedBy = typeof body.signedBy === 'string' && body.signedBy.trim()
3975 ? body.signedBy.trim()
3976 : actor.email ?? actor.id;
3977 const compliance = await signHipaaBaa(projectId, signedBy);
3978 await audit({
3979 actorId: actor.id,
3980 projectId,
3981 action: 'auth.compliance.baa_signed',
3982 metadata: { signedBy },
3983 });
3984 return c.json({ compliance });
3985 },
3986);
3987
3988// ─── SCIM group → org role maps (Phase 9.2) ───────────────────────────────
3989
3990authServiceRouter.get(
3991 '/v1/projects/:id/auth/scim/role-maps',
3992 requireProjectRole('admin'),
3993 async (c) => {
3994 const projectId = c.req.param('id');
3995 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
3996 const items = await listScimRoleMaps(projectId);
3997 return c.json({ items });
3998 },
3999);
4000
4001authServiceRouter.put(
4002 '/v1/projects/:id/auth/scim/role-maps',
4003 requireProjectRole('admin'),
4004 async (c) => {
4005 const projectId = c.req.param('id');
4006 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4007 const actor = c.get('user');
4008 if (!actor) return c.json({ code: 'unauthorized' }, 401);
4009 const body = (await c.req.json().catch(() => null)) as {
4010 displayName?: string;
4011 orgId?: string;
4012 role?: string;
4013 } | null;
4014 if (!body?.displayName || !body?.orgId) {
4015 return c.json({ code: 'validation_failed', message: 'displayName and orgId required' }, 400);
4016 }
4017 try {
4018 const item = await upsertScimRoleMap(projectId, {
4019 displayName: body.displayName,
4020 orgId: body.orgId,
4021 role: body.role,
4022 });
4023 await audit({
4024 actorId: actor.id,
4025 projectId,
4026 action: 'briven_auth.scim_role_map.upsert',
4027 metadata: { mapId: item.id, orgId: item.orgId },
4028 });
4029 return c.json({ item });
4030 } catch (err) {
4031 return c.json(
4032 { code: 'validation_failed', message: err instanceof Error ? err.message : 'failed' },
4033 400,
4034 );
4035 }
4036 },
4037);
4038
4039authServiceRouter.delete(
4040 '/v1/projects/:id/auth/scim/role-maps/:mapId',
4041 requireProjectRole('admin'),
4042 async (c) => {
4043 const projectId = c.req.param('id');
4044 const mapId = c.req.param('mapId');
4045 const actor = c.get('user');
4046 if (!actor) return c.json({ code: 'unauthorized' }, 401);
4047 try {
4048 await deleteScimRoleMap(projectId, mapId);
4049 await audit({
4050 actorId: actor.id,
4051 projectId,
4052 action: 'briven_auth.scim_role_map.deleted',
4053 metadata: { mapId },
4054 });
4055 return c.json({ ok: true });
4056 } catch {
4057 return c.json({ code: 'not_found' }, 404);
4058 }
4059 },
4060);
4061
4062// ─── Phase 7.1 — JWT Templates ────────────────────────────────────────────
4063
4064authServiceRouter.get(
4065 '/v1/projects/:id/auth/jwt/templates',
4066 requireProjectRole('admin'),
4067 async (c) => {
4068 const projectId = c.req.param('id');
4069 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4070 const templates = await listJwtTemplates(projectId);
4071 return c.json({ templates });
4072 },
4073);
4074
4075authServiceRouter.post(
4076 '/v1/projects/:id/auth/jwt/templates',
4077 requireProjectRole('admin'),
4078 async (c) => {
4079 const projectId = c.req.param('id');
4080 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4081 const body = (await c.req.json().catch(() => ({}))) as {
4082 name?: string;
4083 claims?: Record<string, unknown>;
4084 };
4085 if (!body.name || typeof body.name !== 'string' || body.name.length < 1 || body.name.length > 64) {
4086 return c.json({ code: 'validation_failed', message: 'name must be 1-64 characters' }, 400);
4087 }
4088 if (!body.claims || typeof body.claims !== 'object') {
4089 return c.json({ code: 'validation_failed', message: 'claims must be an object' }, 400);
4090 }
4091 await createJwtTemplate(projectId, body.name, body.claims);
4092 return c.json({ ok: true });
4093 },
4094);
4095
4096authServiceRouter.delete(
4097 '/v1/projects/:id/auth/jwt/templates/:name',
4098 requireProjectRole('admin'),
4099 async (c) => {
4100 const projectId = c.req.param('id');
4101 const name = c.req.param('name');
4102 if (!projectId || !name) return c.json({ code: 'validation_failed' }, 400);
4103 await deleteJwtTemplate(projectId, name);
4104 return c.json({ ok: true });
4105 },
4106);
4107
4108// ─── Phase 7.4 — Testing Tokens ───────────────────────────────────────────
4109
4110authServiceRouter.get(
4111 '/v1/projects/:id/auth/test-tokens',
4112 requireProjectRole('admin'),
4113 async (c) => {
4114 const projectId = c.req.param('id');
4115 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4116 const tokens = await listTestTokens(projectId);
4117 return c.json({ tokens });
4118 },
4119);
4120
4121authServiceRouter.post(
4122 '/v1/projects/:id/auth/test-tokens',
4123 requireProjectRole('admin'),
4124 async (c) => {
4125 const projectId = c.req.param('id');
4126 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4127 const body = (await c.req.json().catch(() => ({}))) as { userId?: string; name?: string };
4128 if (!body.userId) {
4129 return c.json({ code: 'validation_failed', message: 'userId required' }, 400);
4130 }
4131 const token = await createTestToken(projectId, body.userId, body.name);
4132 return c.json({ token: token.token, expiresAt: token.expiresAt.toISOString() });
4133 },
4134);
4135
4136authServiceRouter.delete(
4137 '/v1/projects/:id/auth/test-tokens/:tokenId',
4138 requireProjectRole('admin'),
4139 async (c) => {
4140 const projectId = c.req.param('id');
4141 const tokenId = c.req.param('tokenId');
4142 if (!projectId || !tokenId) return c.json({ code: 'validation_failed' }, 400);
4143 await revokeTestToken(projectId, tokenId);
4144 return c.json({ ok: true });
4145 },
4146);
4147
4148// ─── Phase 7.5 — Email Template Customization ─────────────────────────────
4149
4150authServiceRouter.get(
4151 '/v1/projects/:id/auth/email-templates',
4152 requireProjectRole('admin'),
4153 async (c) => {
4154 const projectId = c.req.param('id');
4155 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4156 const templates = await listEmailTemplates(projectId);
4157 return c.json({ templates });
4158 },
4159);
4160
4161authServiceRouter.put(
4162 '/v1/projects/:id/auth/email-templates',
4163 requireProjectRole('admin'),
4164 async (c) => {
4165 const projectId = c.req.param('id');
4166 if (!projectId) return c.json({ code: 'validation_failed' }, 400);
4167 const body = (await c.req.json().catch(() => ({}))) as {
4168 name?: string;
4169 subject?: string;
4170 html?: string;
4171 text?: string | null;
4172 };
4173 if (!body.name || !EMAIL_TEMPLATE_NAMES.includes(body.name as EmailTemplateName)) {
4174 return c.json({ code: 'validation_failed', message: `name must be one of ${EMAIL_TEMPLATE_NAMES.join(', ')}` }, 400);
4175 }
4176 if (!body.subject || typeof body.subject !== 'string') {
4177 return c.json({ code: 'validation_failed', message: 'subject required' }, 400);
4178 }
4179 if (!body.html || typeof body.html !== 'string') {
4180 return c.json({ code: 'validation_failed', message: 'html required' }, 400);
4181 }
4182 await setEmailTemplate(projectId, {
4183 name: body.name as EmailTemplateName,
4184 subject: body.subject,
4185 html: body.html,
4186 text: body.text,
4187 });
4188 return c.json({ ok: true });
4189 },
4190);
4191
4192authServiceRouter.delete(
4193 '/v1/projects/:id/auth/email-templates/:name',
4194 requireProjectRole('admin'),
4195 async (c) => {
4196 const projectId = c.req.param('id');
4197 const name = c.req.param('name');
4198 if (!projectId || !name) return c.json({ code: 'validation_failed' }, 400);
4199 if (!EMAIL_TEMPLATE_NAMES.includes(name as EmailTemplateName)) {
4200 return c.json({ code: 'validation_failed', message: `name must be one of ${EMAIL_TEMPLATE_NAMES.join(', ')}` }, 400);
4201 }
4202 await deactivateEmailTemplate(projectId, name as EmailTemplateName);
4203 return c.json({ ok: true });
4204 },
4205);
4206
4207/**
4208 * Turn Better Auth's raw `INVALID_ORIGIN` into an actionable error that
4209 * tells the developer exactly what to configure. We only inspect JSON error
4210 * responses; success responses and non-JSON bodies pass through untouched.
4211 */
4212async function withActionableOriginError(
4213 response: Response,
4214 projectId: string,
4215): Promise<Response> {
4216 // Only intercept 4xx JSON errors. Better Auth uses 400 for INVALID_ORIGIN.
4217 if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) {
4218 return response;
4219 }
4220 if (response.status < 400 || response.status >= 500) {
4221 return response;
4222 }
4223
4224 let body: unknown;
4225 try {
4226 body = await response.clone().json();
4227 } catch {
4228 return response;
4229 }
4230
4231 const isInvalidOrigin =
4232 body &&
4233 typeof body === 'object' &&
4234 ('INVALID_ORIGIN' === (body as { code?: string }).code ||
4235 'INVALID_ORIGIN' === (body as { error?: { code?: string } }).error?.code ||
4236 ((body as { message?: string }).message?.toUpperCase().includes('INVALID_ORIGIN') ??
4237 false));
4238
4239 if (!isInvalidOrigin) return response;
4240
4241 const actionable = {
4242 code: 'INVALID_ORIGIN',
4243 message: `This app origin is not allowed for project ${projectId}. Add it in the Briven dashboard → Auth → Allowed Domains, or via POST /v1/projects/${projectId}/auth/allowed-domains.`,
4244 docs: 'https://docs.briven.tech/auth/allowed-domains',
4245 };
4246
4247 const jsonBody = JSON.stringify(actionable);
4248 const headers = new Headers(response.headers);
4249 headers.set('content-length', String(Buffer.byteLength(jsonBody)));
4250
4251 return new Response(jsonBody, {
4252 status: response.status,
4253 statusText: response.statusText,
4254 headers,
4255 });
4256}