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