tenant-config-store.ts647 lines · main
1import { ValidationError } from '@briven/shared';
2import { z } from 'zod';
3
4import { runInProjectDatabase } from '../db/data-plane.js';
5import { log } from '../lib/logger.js';
6import { hasTenantSecret } from './tenant-secrets.js';
7
8/**
9 * Non-secret per-tenant auth config (BUILD_PLAN.md §6 Providers panel +
10 * §6 Branding panel + §6 Usage panel sender-domain field).
11 *
12 * Storage: customer's `_briven_meta` jsonb under key `auth_config`. Same
13 * table that `auth.enabled` flag lives in — keeps the per-project meta
14 * surface coherent.
15 *
16 * **Secrets do NOT live here.** OAuth client secrets, mittera API keys,
17 * webhook signing keys all go through `tenant-secret-store.ts` (HKDF +
18 * AES-256-GCM, control-plane `tenant_secrets` table). This file only
19 * holds the flags + the public bits (OAuth client ids are public-by-design
20 * per upstream provider docs).
21 */
22
23// ─── schema ─────────────────────────────────────────────────────────────
24
25const oauthProviderConfig = z.object({
26 enabled: z.boolean(),
27 /**
28 * Public client identifier from the upstream provider. Visible to the
29 * customer's end-users in the redirect URL — not a secret.
30 */
31 clientId: z.string().nullable(),
32});
33
34/**
35 * Generic / custom OIDC (OpenID Connect) provider configured by the customer.
36 * Same non-secret split as the built-in social providers: the public client id
37 * + endpoints live here (project DoltGres `_briven_meta`), the client secret
38 * rides the encrypted control-plane tenant-secret-store under the name
39 * `oidc_<id>_client_secret` (service 'auth').
40 *
41 * The provider can be configured EITHER via an OIDC `issuer` (the engine fetches
42 * `<issuer>/.well-known/openid-configuration`) OR via the three explicit
43 * endpoints. Either path is gated on enabled + clientId + a stored secret before
44 * it reaches the Better Auth `genericOAuth` plugin (mirrors the konnos gate).
45 */
46const customOidcProviderConfig = z.object({
47 /** URL-safe slug; also the Better Auth `providerId` + the `provider` query value. */
48 id: z
49 .string()
50 .min(1)
51 .max(40)
52 .regex(/^[a-z0-9-]+$/, 'id must be a slug of lowercase letters, digits and hyphens'),
53 /** Human label shown on the hosted-pages + SDK button (e.g. "Acme SSO"). */
54 displayName: z.string().min(1).max(64),
55 enabled: z.boolean(),
56 /** Public OAuth client id from the upstream provider — not a secret. */
57 clientId: z.string().nullable(),
58 /**
59 * OIDC issuer base URL. When set, the engine discovers the
60 * authorization/token/userinfo endpoints from
61 * `<issuer>/.well-known/openid-configuration`. Mutually-sufficient with the
62 * three explicit endpoints below — supply one or the other.
63 */
64 issuer: z.string().url().nullable(),
65 authorizationUrl: z.string().url().nullable(),
66 tokenUrl: z.string().url().nullable(),
67 userinfoUrl: z.string().url().nullable(),
68 /** Space-separated OAuth scopes. Defaults to the standard OIDC trio. */
69 scopes: z.string().min(1).default('openid profile email'),
70 /** Whether to use PKCE on the authorization code exchange. Defaults on. */
71 pkce: z.boolean().optional(),
72});
73
74export type CustomOidcProvider = z.infer<typeof customOidcProviderConfig>;
75
76const authConfigSchema = z.object({
77 providers: z.object({
78 emailPassword: z.object({ enabled: z.boolean() }),
79 magicLink: z.object({
80 enabled: z.boolean(),
81 expiryMinutes: z.number().int().min(1).max(60),
82 }),
83 emailOtp: z.object({
84 enabled: z.boolean(),
85 codeLength: z.number().int().min(4).max(8),
86 expiryMinutes: z.number().int().min(1).max(30),
87 }),
88 passkey: z.object({ enabled: z.boolean() }),
89 google: oauthProviderConfig,
90 github: oauthProviderConfig,
91 discord: oauthProviderConfig,
92 microsoft: oauthProviderConfig,
93 apple: oauthProviderConfig,
94 twitter: oauthProviderConfig,
95 linkedin: oauthProviderConfig,
96 gitlab: oauthProviderConfig,
97 bitbucket: oauthProviderConfig,
98 dropbox: oauthProviderConfig,
99 facebook: oauthProviderConfig,
100 spotify: oauthProviderConfig,
101 // Generic OIDC/OAuth provider (Forgejo at code.konnos.org). Same
102 // {enabled, clientId} shape as the built-in social providers — the
103 // public client id is non-secret; the secret rides the encrypted
104 // tenant-secret-store like the others.
105 konnos: oauthProviderConfig,
106 }),
107 twoFactor: z.object({
108 enabled: z.boolean(),
109 /** Issuer name shown in the authenticator app (defaults to project name). */
110 issuer: z.string().max(64).nullable(),
111 /** When true, all users MUST enroll MFA (TOTP or passkey) before signing in. */
112 required: z.boolean().default(false),
113 }),
114 /**
115 * Custom JWT claims added to every token issued by the jwt plugin.
116 * Keys are claim names; values are static strings. For dynamic claims
117 * (e.g. org_role), the customer should use a webhook + their own
118 * signing layer; this surface is for simple static claims only.
119 */
120 jwtClaims: z.record(z.string().min(1).max(64), z.string().max(256)).default({}),
121 branding: z.object({
122 /** Customer-uploaded logo URL. Null means use the briven default mark. */
123 logoUrl: z.string().url().nullable(),
124 /**
125 * Primary accent color. Must be a 6-digit hex (with leading `#`) so
126 * the dashboard's WCAG-AA contrast check can normalise it. Defaults
127 * to briven green per BRAND.md.
128 */
129 primaryColor: z
130 .string()
131 .regex(/^#[0-9a-fA-F]{6}$/, 'primaryColor must be a 6-digit hex like #00e87a'),
132 /**
133 * mittera-verified sender domain (e.g. `mail.customerapp.com`). Null
134 * until the customer completes the verification wizard; mail then
135 * falls back to `noreply@auth.briven.tech`.
136 */
137 senderDomain: z
138 .string()
139 // RFC-1035-ish domain match — single conservative pass; mittera
140 // does the authoritative DNS validation downstream.
141 .regex(
142 /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i,
143 'senderDomain must be a valid DNS domain',
144 )
145 .nullable(),
146 /** Display name in the From: header. */
147 senderName: z.string().min(1).max(64),
148 }),
149 /**
150 * Customer-defined generic OIDC providers (in addition to the built-in
151 * social providers above). Optional so configs written before this field
152 * existed still parse — `getAuthConfig` callers treat a missing array as `[]`.
153 */
154 customOidc: z.array(customOidcProviderConfig).optional(),
155 /** Customer-owned auth subdomain (e.g. auth.murphus.eu). Null means use the default hosted pages. */
156 customAuthDomain: z
157 .string()
158 .regex(
159 /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i,
160 'customAuthDomain must be a valid DNS domain',
161 )
162 .nullable(),
163 /**
164 * Session management settings (Phase 2 — BUILD_PLAN.md).
165 */
166 session: z
167 .object({
168 /** Maximum session lifetime in days (5 min to 10 years). Default 30. */
169 maxLifetimeDays: z.number().int().min(1).max(3650).default(30),
170 /** Refresh session token if older than this many days. Default 7. */
171 updateAgeDays: z.number().int().min(1).max(365).default(7),
172 /** Invalidate session after this many minutes of inactivity. 0 = disabled. */
173 inactivityTimeoutMinutes: z.number().int().min(0).max(1440).default(0),
174 })
175 .default({ maxLifetimeDays: 30, updateAgeDays: 7, inactivityTimeoutMinutes: 0 }),
176 /**
177 * Security hardening settings (Phase 1 — BUILD_PLAN.md).
178 * Controls sign-up gating, email restrictions, and abuse prevention.
179 */
180 security: z
181 .object({
182 /** Who may sign up: public (anyone), restricted (invite/enterprise only), waitlist (admin approval). */
183 signUpMode: z.enum(['public', 'restricted', 'waitlist']).default('public'),
184 /** Only allow sign-ups from these email domains. Empty = no restriction. */
185 allowedEmailDomains: z.array(z.string().min(1).max(128)).default([]),
186 /** Block sign-ups from these email domains. Checked before allowedEmailDomains. */
187 blockedEmailDomains: z.array(z.string().min(1).max(128)).default([]),
188 /** Reject known disposable email domains. */
189 blockDisposableEmails: z.boolean().default(false),
190 /** Reject email addresses with subaddress tags (+tag, #tag). */
191 blockEmailSubaddresses: z.boolean().default(false),
192 /** Check passwords against Have I Been Pwned breach database on sign-up. */
193 breachDetection: z.object({ enabled: z.boolean() }).default({ enabled: false }),
194 /** Rate limiting configuration for auth endpoints. */
195 rateLimiting: z
196 .object({
197 enabled: z.boolean().default(true),
198 maxAttemptsPerIp: z.number().int().min(1).max(1000).default(100),
199 windowMinutes: z.number().int().min(1).max(1440).default(15),
200 })
201 .default({ enabled: true, maxAttemptsPerIp: 100, windowMinutes: 15 }),
202 })
203 .default({
204 signUpMode: 'public',
205 allowedEmailDomains: [],
206 blockedEmailDomains: [],
207 blockDisposableEmails: false,
208 blockEmailSubaddresses: false,
209 breachDetection: { enabled: false },
210 rateLimiting: { enabled: true, maxAttemptsPerIp: 100, windowMinutes: 15 },
211 }),
212 /**
213 * Cloudflare Turnstile bot protection (Phase 1 — BUILD_PLAN.md).
214 * The site key is public (rendered in frontend); the secret is global env.
215 */
216 turnstile: z
217 .object({
218 enabled: z.boolean().default(false),
219 siteKey: z.string().nullable().default(null),
220 })
221 .default({ enabled: false, siteKey: null }),
222 /**
223 * Log retention settings (Phase 6.3).
224 * Controls how long audit logs and app logs are retained.
225 */
226 retention: z
227 .object({
228 /** Audit log retention in days. 7 | 30 | 90. Default 30. */
229 auditLogDays: z.number().int().min(7).max(90).default(30),
230 /** App log retention in days. 7 | 30 | 90. Default 7. */
231 appLogDays: z.number().int().min(7).max(90).default(7),
232 })
233 .default({ auditLogDays: 30, appLogDays: 7 }),
234 /**
235 * Localization settings (Phase 6.8).
236 * Default locale for hosted pages and email templates.
237 */
238 locale: z
239 .string()
240 .regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'locale must be a valid BCP 47 language tag')
241 .default('en'),
242});
243
244export type AuthConfig = z.infer<typeof authConfigSchema>;
245
246/**
247 * Hard-coded defaults — used by `getAuthConfig` when a project has no
248 * `auth_config` row yet (e.g. first dashboard load after Enable Auth).
249 * Frozen so accidental mutation throws.
250 *
251 * Explicit literal (no `.default()` chains in the schema) because zod 4
252 * tightened the type of `.default()` to the output shape, which made
253 * field-level defaulting on nested objects fight typecheck. Trade-off:
254 * callers must always pass a full config to `parse`; partial patches
255 * route through `mergeAuthConfig` which seeds from this constant.
256 */
257function deepFreeze<T>(obj: T): T {
258 if (obj !== null && typeof obj === 'object' && !Object.isFrozen(obj)) {
259 for (const v of Object.values(obj as Record<string, unknown>)) deepFreeze(v);
260 Object.freeze(obj);
261 }
262 return obj;
263}
264
265/**
266 * Starter pack after Enable Auth — **passwordless ON by default** (Clerk-simple).
267 * Email+password stays on. OAuth stays off until secrets exist.
268 * Turning providers off is still a dashboard/API choice; new projects must
269 * work with magic link / OTP / passkey without a second "toggle providers" step
270 * (fleet pain 2026-07 — agents and owners re-toggled forever while defaults
271 * left passwordless OFF).
272 */
273export const DEFAULT_AUTH_CONFIG: AuthConfig = deepFreeze({
274 providers: {
275 emailPassword: { enabled: true },
276 magicLink: { enabled: true, expiryMinutes: 15 },
277 emailOtp: { enabled: true, codeLength: 6, expiryMinutes: 5 },
278 passkey: { enabled: true },
279 google: { enabled: false, clientId: null },
280 github: { enabled: false, clientId: null },
281 discord: { enabled: false, clientId: null },
282 microsoft: { enabled: false, clientId: null },
283 apple: { enabled: false, clientId: null },
284 twitter: { enabled: false, clientId: null },
285 linkedin: { enabled: false, clientId: null },
286 gitlab: { enabled: false, clientId: null },
287 bitbucket: { enabled: false, clientId: null },
288 dropbox: { enabled: false, clientId: null },
289 facebook: { enabled: false, clientId: null },
290 spotify: { enabled: false, clientId: null },
291 konnos: { enabled: false, clientId: null },
292 },
293 twoFactor: { enabled: false, issuer: null, required: false },
294 jwtClaims: {},
295 session: { maxLifetimeDays: 30, updateAgeDays: 7, inactivityTimeoutMinutes: 0 },
296 branding: {
297 logoUrl: null,
298 primaryColor: '#00e87a',
299 senderDomain: null,
300 senderName: 'briven auth',
301 },
302 customOidc: [],
303 customAuthDomain: null,
304 security: {
305 signUpMode: 'public',
306 allowedEmailDomains: [],
307 blockedEmailDomains: [],
308 blockDisposableEmails: false,
309 blockEmailSubaddresses: false,
310 breachDetection: { enabled: false },
311 rateLimiting: { enabled: true, maxAttemptsPerIp: 100, windowMinutes: 15 },
312 },
313 turnstile: { enabled: false, siteKey: null },
314 retention: { auditLogDays: 30, appLogDays: 7 },
315 locale: 'en',
316}) as AuthConfig;
317
318// ─── pure helpers (unit-testable without postgres) ───────────────────────
319
320/**
321 * Deep-merge a partial patch over the current config, then re-validate
322 * via zod so writes can't smuggle invalid shapes through. Unknown keys
323 * are stripped by zod's default `.strip` mode.
324 */
325export function mergeAuthConfig(current: AuthConfig, patch: unknown): AuthConfig {
326 if (patch === null || typeof patch !== 'object') {
327 throw new ValidationError('auth config patch must be an object');
328 }
329 const merged = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>);
330 const parsed = authConfigSchema.safeParse(merged);
331 if (!parsed.success) {
332 throw new ValidationError('auth config patch failed validation', {
333 issues: parsed.error.issues,
334 });
335 }
336 return parsed.data;
337}
338
339function deepMerge(
340 base: Record<string, unknown>,
341 patch: Record<string, unknown>,
342): Record<string, unknown> {
343 const out: Record<string, unknown> = { ...base };
344 for (const [k, v] of Object.entries(patch)) {
345 if (v === undefined) continue;
346 const existing = out[k];
347 if (
348 v !== null &&
349 typeof v === 'object' &&
350 !Array.isArray(v) &&
351 existing !== null &&
352 typeof existing === 'object' &&
353 !Array.isArray(existing)
354 ) {
355 out[k] = deepMerge(
356 existing as Record<string, unknown>,
357 v as Record<string, unknown>,
358 );
359 } else {
360 out[k] = v;
361 }
362 }
363 return out;
364}
365
366// ─── postgres-backed accessors ──────────────────────────────────────────
367
368const META_KEY = 'auth_config';
369
370/**
371 * Read the current auth config for a project. Returns the defaults if no
372 * row exists yet (project enabled auth but hasn't visited the providers
373 * panel).
374 *
375 * Reads run inside a `runInProjectDatabase` transaction against the
376 * project's own DoltGres database. Single-statement read.
377 */
378/**
379 * DoltGres (via node-postgres) can hand a `jsonb` column back as a raw JSON
380 * *string* rather than a parsed object. Normalise both shapes so a read never
381 * silently fails on a perfectly-valid stored value.
382 */
383function normaliseJsonb(raw: unknown): unknown {
384 if (typeof raw !== 'string') return raw;
385 try {
386 return JSON.parse(raw);
387 } catch {
388 return raw;
389 }
390}
391
392export async function getAuthConfig(projectId: string): Promise<AuthConfig> {
393 const rows = await runInProjectDatabase<{ value: unknown }[]>(projectId, async (tx) =>
394 (await tx.unsafe(
395 `SELECT value FROM "_briven_meta" WHERE key = $1 LIMIT 1`,
396 [META_KEY],
397 )) as { value: unknown }[],
398 );
399 if (rows.length === 0) return DEFAULT_AUTH_CONFIG;
400 const parsed = authConfigSchema.safeParse(normaliseJsonb(rows[0]!.value));
401 if (!parsed.success) {
402 // A stored row exists but doesn't match the current schema (older shape,
403 // or unparseable). Fall back to defaults so the read never throws — but
404 // LOG it, because this silent fallback is exactly what hid a real bug
405 // where valid saved config was read back as a string and discarded.
406 log.warn('auth_config_parse_failed_fallback_default', {
407 projectId,
408 issues: parsed.error.issues.slice(0, 3),
409 });
410 return DEFAULT_AUTH_CONFIG;
411 }
412 return parsed.data;
413}
414
415/**
416 * Apply a partial patch to the auth config and persist. Returns the new
417 * full config. Idempotent — calling with an empty patch is a no-op write
418 * that still validates the stored shape.
419 */
420export async function updateAuthConfig(
421 projectId: string,
422 patch: unknown,
423): Promise<AuthConfig> {
424 const current = await getAuthConfig(projectId);
425 const next = mergeAuthConfig(current, patch);
426 await runInProjectDatabase(projectId, async (tx) => {
427 // DoltGres upsert WITHOUT `ON CONFLICT` (its `excluded` pseudo-table is
428 // unsupported and ON CONFLICT behaviour has been unreliable): probe for an
429 // existing row, then UPDATE or INSERT. Atomic within the BEGIN/COMMIT.
430 const existing = (await tx.unsafe(
431 `SELECT 1 FROM "_briven_meta" WHERE key = $1 LIMIT 1`,
432 [META_KEY],
433 )) as unknown[];
434 if (existing.length > 0) {
435 await tx.unsafe(
436 `UPDATE "_briven_meta" SET value = $2::jsonb WHERE key = $1`,
437 [META_KEY, JSON.stringify(next)],
438 );
439 } else {
440 await tx.unsafe(
441 `INSERT INTO "_briven_meta" (key, value) VALUES ($1, $2::jsonb)`,
442 [META_KEY, JSON.stringify(next)],
443 );
444 }
445 });
446 return next;
447}
448
449/**
450 * Read the `_briven_meta.auth_enabled` flag for a project. Set to true by
451 * `POST /v1/projects/:id/auth/enable`; absent or `false` until then. The
452 * dashboard's Overview page uses this to decide between the Enable CTA
453 * and the configured state.
454 */
455export async function isAuthEnabled(projectId: string): Promise<boolean> {
456 const rows = await runInProjectDatabase<{ value: unknown }[]>(projectId, async (tx) =>
457 (await tx.unsafe(
458 `SELECT value FROM "_briven_meta" WHERE key = 'auth_enabled' LIMIT 1`,
459 )) as { value: unknown }[],
460 );
461 if (rows.length === 0) return false;
462 return normaliseJsonb(rows[0]!.value) === true;
463}
464
465// ─── enabled-providers signal (render-gating) ────────────────────────────
466
467/**
468 * Built-in social provider keys, konnos-first (our own product leads). These
469 * map 1:1 to the secret name convention `<key>_client_secret` and the Better
470 * Auth `provider` value used to start a sign-in.
471 */
472export const SOCIAL_PROVIDER_KEYS = [
473 'konnos',
474 'google',
475 'github',
476 'discord',
477 'microsoft',
478 'apple',
479 'twitter',
480 'linkedin',
481 'gitlab',
482 'bitbucket',
483 'dropbox',
484 'facebook',
485 'spotify',
486] as const;
487
488/** One social provider key — the `<key>` half of `<key>_client_secret`. */
489export type SocialProviderKey = (typeof SOCIAL_PROVIDER_KEYS)[number];
490
491/** Type guard: is `v` one of the built-in social provider keys? */
492export function isSocialProviderKey(v: unknown): v is SocialProviderKey {
493 return typeof v === 'string' && (SOCIAL_PROVIDER_KEYS as readonly string[]).includes(v);
494}
495
496/**
497 * Does a custom-OIDC entry have a usable endpoint set — an issuer OR all three
498 * explicit endpoints? Mirrors the gate `buildGenericOAuthConfigs` applies in
499 * the pool, so the "enabled" signal never lists a provider the engine would
500 * skip.
501 */
502function oidcHasEndpoints(o: CustomOidcProvider): boolean {
503 return Boolean(o.issuer) || Boolean(o.authorizationUrl && o.tokenUrl && o.userinfoUrl);
504}
505
506/**
507 * Pure gate: which providers are fully configured given a secret-presence
508 * probe. The SINGLE source of truth for "is this provider live?" — the exact
509 * same `enabled && clientId && secret-present` rule `createAuthInstance` wires
510 * with (custom-OIDC additionally needs an endpoint set). `hasSecret` is the
511 * `<name>` half of the (projectId,'auth',name) triple so this stays sync +
512 * unit-testable with a plain map.
513 *
514 * Returns provider keys for built-ins and the slug `id` for custom-OIDC,
515 * konnos-first then the rest, then custom-OIDC in declaration order.
516 */
517export function computeEnabledProviders(
518 config: AuthConfig,
519 hasSecret: (name: string) => boolean,
520): string[] {
521 const out: string[] = [];
522 for (const key of SOCIAL_PROVIDER_KEYS) {
523 const c = config.providers[key];
524 if (c.enabled && c.clientId && hasSecret(`${key}_client_secret`)) out.push(key);
525 }
526 for (const o of config.customOidc ?? []) {
527 if (o.enabled && o.clientId && oidcHasEndpoints(o) && hasSecret(`oidc_${o.id}_client_secret`)) {
528 out.push(o.id);
529 }
530 }
531 return out;
532}
533
534/**
535 * Async wrapper over `computeEnabledProviders` that resolves secret presence
536 * from the encrypted control-plane store. Batches the presence probes (never
537 * decrypts) and feeds the result set into the one shared gate. Pass `preloaded`
538 * to reuse a config already in hand and skip the extra meta read.
539 */
540export async function listEnabledProviders(
541 projectId: string,
542 preloaded?: AuthConfig,
543): Promise<string[]> {
544 const config = preloaded ?? (await getAuthConfig(projectId));
545 const names = [
546 ...SOCIAL_PROVIDER_KEYS.map((k) => `${k}_client_secret`),
547 ...(config.customOidc ?? []).map((o) => `oidc_${o.id}_client_secret`),
548 ];
549 const present = await Promise.all(
550 names.map((name) => hasTenantSecret(projectId, 'auth', name)),
551 );
552 const set = new Set(names.filter((_, i) => present[i]));
553 return computeEnabledProviders(config, (name) => set.has(name));
554}
555
556/**
557 * Shape of the public, UNAUTHENTICATED branding/config payload. Carries only
558 * non-secret presentation + the enabled-provider signal — NEVER a clientId,
559 * secret, toggle, or endpoint. The hosted pages + SDK read this to decide which
560 * OAuth buttons to render.
561 */
562export interface AuthBrandingPublicPayload {
563 primaryColor: string;
564 senderName: string;
565 /** Enabled provider keys / custom-OIDC slugs — the `provider` start value. */
566 socialProviders: string[];
567 /** Display labels for the enabled custom-OIDC entries (built-ins self-label). */
568 customOidc: Array<{ id: string; displayName: string }>;
569 /** Turnstile bot-protection config (public site key only). */
570 turnstile: {
571 enabled: boolean;
572 siteKey: string | null;
573 };
574}
575
576/**
577 * Build the public branding/config payload from a config + its already-computed
578 * enabled list. Deliberately projects ONLY safe fields so a clientId/secret can
579 * never leak through this unauthenticated surface.
580 */
581export function buildAuthBrandingPublicPayload(
582 config: AuthConfig,
583 enabledProviders: string[],
584): AuthBrandingPublicPayload {
585 const enabled = new Set(enabledProviders);
586 return {
587 primaryColor: config.branding.primaryColor,
588 senderName: config.branding.senderName,
589 socialProviders: enabledProviders,
590 customOidc: (config.customOidc ?? [])
591 .filter((o) => enabled.has(o.id))
592 .map((o) => ({ id: o.id, displayName: o.displayName })),
593 turnstile: {
594 enabled: config.turnstile.enabled,
595 siteKey: config.turnstile.siteKey,
596 },
597 };
598}
599
600// ─── custom-OIDC CRUD ────────────────────────────────────────────────────
601
602/**
603 * Validate + upsert a single custom-OIDC provider (keyed by `id`) into the
604 * project's config, persisting via `updateAuthConfig`. Replacing the whole
605 * `customOidc` array is intentional — `deepMerge` treats arrays as wholesale
606 * replacements, so we recompute the array here. Returns the new full config.
607 * Throws `ValidationError` on a malformed entry (bad slug, missing displayName…).
608 */
609export async function upsertCustomOidcProvider(
610 projectId: string,
611 entry: unknown,
612): Promise<AuthConfig> {
613 const parsed = customOidcProviderConfig.safeParse(entry);
614 if (!parsed.success) {
615 throw new ValidationError('custom OIDC provider failed validation', {
616 issues: parsed.error.issues,
617 });
618 }
619 const e = parsed.data;
620 const current = await getAuthConfig(projectId);
621 const list = (current.customOidc ?? []).filter((o) => o.id !== e.id);
622 return updateAuthConfig(projectId, { customOidc: [...list, e] });
623}
624
625/**
626 * Remove a custom-OIDC provider by `id`. Idempotent — removing an absent id is
627 * a no-op write that still re-validates the stored shape. The encrypted secret
628 * (`oidc_<id>_client_secret`) is left in place (harmless: nothing references it
629 * once the entry is gone, and the gate requires the entry to exist).
630 */
631export async function removeCustomOidcProvider(
632 projectId: string,
633 id: string,
634): Promise<AuthConfig> {
635 const current = await getAuthConfig(projectId);
636 const list = (current.customOidc ?? []).filter((o) => o.id !== id);
637 return updateAuthConfig(projectId, { customOidc: list });
638}
639
640/**
641 * Visible-for-tests: the raw zod schema. Lets test files exercise
642 * validation without going through the postgres path.
643 */
644export const __authConfigSchema = authConfigSchema;
645
646/** Visible-for-tests: the custom-OIDC element schema. */
647export const __customOidcSchema = customOidcProviderConfig;