project-config.ts1036 lines · main
1/**
2 * briven-engine per-project config (Path A).
3 *
4 * Stores social client secrets + SMS/email provider secrets via tenant_secrets
5 * (service = 'auth'). Recipe feature flags live in memory/env until a dedicated
6 * config row is migrated (local build — no deploy).
7 *
8 * Product brand: briven-engine only.
9 */
10
11import {
12 deleteTenantSecret,
13 getTenantSecret,
14 hasTenantSecret,
15 setTenantSecret,
16} from '../tenant-secrets.js';
17import {
18 BRIVEN_ENGINE_SOCIAL_CATALOG,
19 type BrivenSocialProviderId,
20 type ProjectProviderSecrets,
21} from './providers.js';
22import { mapProjectToAuthCore } from './project-map.js';
23
24const SERVICE = 'auth' as const;
25
26function clientIdName(id: BrivenSocialProviderId): string {
27 return `briven_engine_${id}_client_id`;
28}
29function clientSecretName(id: BrivenSocialProviderId): string {
30 return `briven_engine_${id}_client_secret`;
31}
32/** Legacy names from pre-engine Auth product (still on some projects). */
33function legacyClientIdName(id: BrivenSocialProviderId): string {
34 return `${id}_client_id`;
35}
36function legacyClientSecretName(id: BrivenSocialProviderId): string {
37 return `${id}_client_secret`;
38}
39function extraName(id: BrivenSocialProviderId, key: string): string {
40 return `briven_engine_${id}_${key}`;
41}
42
43async function readProviderClientId(
44 projectId: string,
45 id: BrivenSocialProviderId,
46): Promise<string | null> {
47 return (
48 (await getTenantSecret(projectId, SERVICE, clientIdName(id))) ??
49 (await getTenantSecret(projectId, SERVICE, legacyClientIdName(id)))
50 );
51}
52
53async function readProviderClientSecret(
54 projectId: string,
55 id: BrivenSocialProviderId,
56): Promise<string | null> {
57 return (
58 (await getTenantSecret(projectId, SERVICE, clientSecretName(id))) ??
59 (await getTenantSecret(projectId, SERVICE, legacyClientSecretName(id)))
60 );
61}
62
63/**
64 * "Configured" means we can actually open the secret with the current master
65 * key — not merely that a ciphertext row exists (stale after key rotation).
66 */
67async function hasProviderClientId(
68 projectId: string,
69 id: BrivenSocialProviderId,
70): Promise<boolean> {
71 const v = await readProviderClientId(projectId, id);
72 return Boolean(v && v.length > 0);
73}
74
75async function hasProviderClientSecret(
76 projectId: string,
77 id: BrivenSocialProviderId,
78): Promise<boolean> {
79 const v = await readProviderClientSecret(projectId, id);
80 return Boolean(v && v.length > 0);
81}
82
83/** Per-project which sign-in methods are turned on for the app. */
84export type BrivenEngineMethodFlags = {
85 emailPassword: boolean;
86 /** Email OTP codes */
87 passwordlessEmail: boolean;
88 /** Magic link in email */
89 magicLink: boolean;
90 passwordlessSms: boolean;
91 passkeys: boolean;
92 mfa: boolean;
93};
94
95const DEFAULT_METHOD_FLAGS: BrivenEngineMethodFlags = {
96 emailPassword: true,
97 passwordlessEmail: true,
98 magicLink: true,
99 passwordlessSms: false,
100 passkeys: true,
101 mfa: false,
102};
103
104const METHOD_FLAGS_SECRET = 'briven_engine_method_flags';
105const BRANDING_SECRET = 'briven_engine_branding';
106/** App origins allowed for CORS / passkey rpId / magic-link return (JSON string[]). */
107const APP_ORIGINS_SECRET = 'briven_engine_app_origins';
108/** Custom OIDC ID-token claims (JSON object of string keys → string|number|boolean). */
109const JWT_CLAIMS_SECRET = 'briven_engine_jwt_claims';
110/** When true, email/password sign-in also accepts metadata.username. */
111const USERNAME_LOGIN_SECRET = 'briven_engine_username_login';
112
113/** Login email / hosted UI look for one project. */
114export type BrivenEngineBranding = {
115 logoUrl: string | null;
116 primaryColor: string;
117 /**
118 * Display name in the mailbox From: line, e.g. `Pando` →
119 * `Pando <noreply@pando.so>`. SuperTokens-style per-app sender name.
120 */
121 senderName: string;
122 /**
123 * Domain for From: address, e.g. `pando.so` → `noreply@pando.so`.
124 * Must be authorized on your mail provider (SPF/DKIM). Null = use
125 * platform domain but still the project `senderName` as display name.
126 */
127 senderDomain: string | null;
128 /**
129 * Local part before @ (default `noreply`). Only used with senderDomain.
130 * e.g. `hello` + `pando.so` → `hello@pando.so`.
131 */
132 senderLocalPart: string | null;
133 /**
134 * Full From email override (takes precedence over local@domain).
135 * e.g. `auth@pando.so`. Must still be authorized on the mail provider.
136 */
137 senderEmail: string | null;
138 /**
139 * Public brand site shown in the email footer as `{name} · {brandUrl}`.
140 * e.g. `https://mavi.app` or `briven.tech`. Null = show name only.
141 */
142 brandUrl: string | null;
143 /** Optional short line under the email body (support / legal). */
144 footerNote: string | null;
145 /**
146 * Custom email footer (3 optional lines). Operators pick text + which
147 * lines to show — no hard-coded Flanders/flndrn copy.
148 *
149 * Line 1: made with ♥ {footerLoveName} by {footerOrgName}
150 * Line 2: {footerTagline}
151 * Line 3: {footerOrgName}, {footerCity}, {footerCountry}
152 */
153 footerLoveName: string | null;
154 footerOrgName: string | null;
155 footerTagline: string | null;
156 footerCity: string | null;
157 footerCountry: string | null;
158 footerShowLove: boolean;
159 footerShowTagline: boolean;
160 footerShowAddress: boolean;
161};
162
163export const DEFAULT_BRIVEN_ENGINE_BRANDING: BrivenEngineBranding = {
164 logoUrl: null,
165 primaryColor: '#FFFD74',
166 senderName: 'Briven Auth',
167 senderDomain: null,
168 senderLocalPart: null,
169 senderEmail: null,
170 brandUrl: null,
171 footerNote: null,
172 footerLoveName: null,
173 footerOrgName: null,
174 footerTagline: null,
175 footerCity: null,
176 footerCountry: null,
177 footerShowLove: false,
178 footerShowTagline: false,
179 footerShowAddress: false,
180};
181
182const DOMAIN_RE =
183 /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
184const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
185const LOCAL_PART_RE = /^[a-z0-9][a-z0-9._+-]{0,63}$/i;
186
187/**
188 * Build RFC-ish From: for project Auth emails.
189 * SuperTokens model: each app sets its own from name + address via email delivery config.
190 *
191 * senderEmail set → `Name <senderEmail>`
192 * senderDomain set → `Name <local@senderDomain>` (local defaults noreply)
193 * only senderName → `Name <noreply@BRIVEN_DOMAIN>` (display fixed; domain platform)
194 * nothing useful → null (caller uses platform default)
195 */
196export function buildAuthEmailFromHeader(
197 b: Pick<
198 BrivenEngineBranding,
199 'senderName' | 'senderDomain' | 'senderLocalPart' | 'senderEmail'
200 >,
201 platformDomain = 'briven.tech',
202): string | null {
203 const name = (b.senderName ?? '').trim().slice(0, 80) || 'Briven Auth';
204 let address: string | null = null;
205
206 const full = (b.senderEmail ?? '').trim().toLowerCase();
207 if (full && EMAIL_RE.test(full) && full.length <= 200) {
208 address = full;
209 } else {
210 const domain = (b.senderDomain ?? '').trim().toLowerCase().replace(/^@/, '');
211 if (domain && DOMAIN_RE.test(domain)) {
212 let local = (b.senderLocalPart ?? 'noreply').trim().toLowerCase();
213 if (!local || !LOCAL_PART_RE.test(local)) local = 'noreply';
214 address = `${local}@${domain}`;
215 } else if (name && name.toLowerCase() !== 'briven auth') {
216 // At least show the project brand name with platform mailbox.
217 const pd = platformDomain.replace(/^@/, '').toLowerCase() || 'briven.tech';
218 address = `noreply@${pd}`;
219 }
220 }
221 if (!address) return null;
222
223 const needsQuote = /[\s",;:<>@()\\[\]]/.test(name);
224 const display = needsQuote
225 ? `"${name.replace(/\\/g, '').replace(/"/g, '')}"`
226 : name;
227 return `${display} <${address}>`;
228}
229
230/** Plain footer lines for email HTML/text (empty strings filtered out). */
231export function buildAuthEmailFooterLines(
232 b: BrivenEngineBranding,
233): string[] {
234 const lines: string[] = [];
235 const org = (b.footerOrgName ?? '').trim();
236 const love = (b.footerLoveName ?? '').trim();
237 const tag = (b.footerTagline ?? '').trim();
238 const city = (b.footerCity ?? '').trim();
239 const country = (b.footerCountry ?? '').trim();
240
241 if (b.footerShowLove) {
242 // "made with ♥ {name} by {organization}"
243 if (love && org) lines.push(`made with ♥ ${love} by ${org}`);
244 else if (love) lines.push(`made with ♥ ${love}`);
245 else if (org) lines.push(`made with ♥ by ${org}`);
246 }
247 if (b.footerShowTagline && tag) {
248 lines.push(tag);
249 }
250 if (b.footerShowAddress) {
251 const parts = [org, city, country].filter(Boolean);
252 if (parts.length) lines.push(parts.join(', '));
253 }
254 return lines;
255}
256
257export type BrivenEngineProjectConfig = {
258 engine: 'briven-engine';
259 projectId: string;
260 appId: string;
261 tenantId: string;
262 providers: Array<{
263 thirdPartyId: BrivenSocialProviderId;
264 name: string;
265 configured: boolean;
266 hasClientId: boolean;
267 hasClientSecret: boolean;
268 help?: string;
269 callbackHint?: string;
270 }>;
271 delivery: {
272 sms: { configured: boolean; provider: string | null };
273 email: { configured: boolean; provider: string | null };
274 };
275 branding: BrivenEngineBranding;
276 /** Legacy boolean bag (kept for older UI). */
277 recipes: {
278 emailPassword: boolean;
279 passwordless: boolean;
280 passwordlessSms: boolean;
281 thirdParty: boolean;
282 webauthn: boolean;
283 mfa: boolean;
284 };
285 /** Which methods this project wants on (operator choice). */
286 methods: BrivenEngineMethodFlags;
287 /** Flat list for chips / overview. */
288 methodChips: Array<{
289 id: string;
290 label: string;
291 kind: 'core' | 'oauth';
292 enabled: boolean;
293 configured: boolean;
294 hrefSuffix: string;
295 }>;
296 /**
297 * App website origins for this project (e.g. http://localhost:3000,
298 * https://pay.example.com). Used by golden-path setup + CORS/passkey.
299 */
300 appOrigins: string[];
301 /** Extra claims merged into OIDC ID tokens (string keys only). */
302 jwtClaims: Record<string, string | number | boolean>;
303 /** Allow sign-in with username (stored in user metadata) as well as email. */
304 usernameLogin: boolean;
305 /**
306 * Bot protection for app login (Turnstile). When required=true, FDI expects
307 * turnstileToken on sign-up / sign-in. siteKey is public for the widget.
308 */
309 captcha: {
310 required: boolean;
311 siteKey: string | null;
312 provider: 'turnstile' | null;
313 };
314};
315
316export async function getBrivenEngineBranding(
317 projectId: string,
318): Promise<BrivenEngineBranding> {
319 try {
320 const raw = await getTenantSecret(projectId, SERVICE, BRANDING_SECRET);
321 if (!raw) return { ...DEFAULT_BRIVEN_ENGINE_BRANDING };
322 const parsed = JSON.parse(raw) as Partial<BrivenEngineBranding>;
323 return normalizeBranding(parsed);
324 } catch {
325 return { ...DEFAULT_BRIVEN_ENGINE_BRANDING };
326 }
327}
328
329function normalizeBranding(
330 input: Partial<BrivenEngineBranding> | null | undefined,
331): BrivenEngineBranding {
332 const color =
333 typeof input?.primaryColor === 'string' &&
334 /^#[0-9A-Fa-f]{6}$/.test(input.primaryColor.trim())
335 ? input.primaryColor.trim()
336 : DEFAULT_BRIVEN_ENGINE_BRANDING.primaryColor;
337 const name =
338 typeof input?.senderName === 'string' && input.senderName.trim()
339 ? input.senderName.trim().slice(0, 80)
340 : DEFAULT_BRIVEN_ENGINE_BRANDING.senderName;
341 // Logo is upload-only. The only valid logoUrl is our public CDN route
342 // (…/auth/branding/logo). Free-form external URLs are rejected.
343 let logoUrl: string | null = null;
344 if (typeof input?.logoUrl === 'string' && input.logoUrl.trim()) {
345 const u = input.logoUrl.trim().slice(0, 500);
346 if (
347 (u.startsWith('https://') || u.startsWith('http://localhost')) &&
348 /\/v1\/projects\/[^/]+\/auth\/branding\/logo(?:\?|$)/.test(u)
349 ) {
350 logoUrl = u;
351 }
352 }
353 let brandUrl: string | null = null;
354 if (typeof input?.brandUrl === 'string' && input.brandUrl.trim()) {
355 const raw = input.brandUrl.trim().slice(0, 200);
356 // Accept bare domains (briven.tech) or full https URLs.
357 if (/^https?:\/\//i.test(raw)) {
358 try {
359 const u = new URL(raw);
360 if (u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost')) {
361 brandUrl = u.toString().replace(/\/$/, '');
362 }
363 } catch {
364 brandUrl = null;
365 }
366 } else if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[\w./-]*)?$/i.test(raw)) {
367 brandUrl = raw.replace(/\/$/, '');
368 }
369 }
370 let footerNote: string | null = null;
371 if (typeof input?.footerNote === 'string' && input.footerNote.trim()) {
372 footerNote = input.footerNote.trim().slice(0, 200);
373 }
374
375 const strOrNull = (v: unknown, max: number): string | null => {
376 if (typeof v !== 'string' || !v.trim()) return null;
377 return v.trim().slice(0, max);
378 };
379 const boolOr = (v: unknown, fallback: boolean): boolean =>
380 typeof v === 'boolean' ? v : fallback;
381
382 let senderDomain: string | null = null;
383 if (typeof input?.senderDomain === 'string' && input.senderDomain.trim()) {
384 const d = input.senderDomain
385 .trim()
386 .toLowerCase()
387 .replace(/^@/, '')
388 .replace(/^https?:\/\//, '')
389 .split('/')[0]!
390 .slice(0, 200);
391 if (DOMAIN_RE.test(d)) senderDomain = d;
392 }
393
394 let senderLocalPart: string | null = null;
395 if (typeof input?.senderLocalPart === 'string' && input.senderLocalPart.trim()) {
396 const lp = input.senderLocalPart.trim().toLowerCase().slice(0, 64);
397 if (LOCAL_PART_RE.test(lp)) senderLocalPart = lp;
398 }
399
400 let senderEmail: string | null = null;
401 if (typeof input?.senderEmail === 'string' && input.senderEmail.trim()) {
402 const em = input.senderEmail.trim().toLowerCase().slice(0, 200);
403 if (EMAIL_RE.test(em)) senderEmail = em;
404 }
405
406 return {
407 logoUrl,
408 primaryColor: color,
409 senderName: name,
410 senderDomain,
411 senderLocalPart,
412 senderEmail,
413 brandUrl,
414 footerNote,
415 footerLoveName: strOrNull(input?.footerLoveName, 80),
416 footerOrgName: strOrNull(input?.footerOrgName, 120),
417 footerTagline: strOrNull(input?.footerTagline, 200),
418 footerCity: strOrNull(input?.footerCity, 80),
419 footerCountry: strOrNull(input?.footerCountry, 80),
420 footerShowLove: boolOr(
421 input?.footerShowLove,
422 DEFAULT_BRIVEN_ENGINE_BRANDING.footerShowLove,
423 ),
424 footerShowTagline: boolOr(
425 input?.footerShowTagline,
426 DEFAULT_BRIVEN_ENGINE_BRANDING.footerShowTagline,
427 ),
428 footerShowAddress: boolOr(
429 input?.footerShowAddress,
430 DEFAULT_BRIVEN_ENGINE_BRANDING.footerShowAddress,
431 ),
432 };
433}
434
435export async function setBrivenEngineBranding(
436 projectId: string,
437 input: Partial<BrivenEngineBranding>,
438 createdBy?: string | null,
439): Promise<{ ok: true; engine: 'briven-engine'; branding: BrivenEngineBranding }> {
440 const current = await getBrivenEngineBranding(projectId);
441 const pickStr = (
442 next: string | null | undefined,
443 cur: string | null,
444 ): string | null => {
445 if (next === undefined) return cur;
446 if (next === null || next === '') return null;
447 return next;
448 };
449 const pickBool = (next: boolean | undefined, cur: boolean): boolean =>
450 next === undefined ? cur : next;
451
452 const next = normalizeBranding({
453 logoUrl:
454 input.logoUrl === undefined
455 ? current.logoUrl
456 : input.logoUrl === null || input.logoUrl === ''
457 ? null
458 : input.logoUrl,
459 primaryColor: input.primaryColor ?? current.primaryColor,
460 senderName: input.senderName ?? current.senderName,
461 senderDomain:
462 input.senderDomain === undefined
463 ? current.senderDomain
464 : input.senderDomain === null || input.senderDomain === ''
465 ? null
466 : input.senderDomain,
467 senderLocalPart:
468 input.senderLocalPart === undefined
469 ? current.senderLocalPart
470 : input.senderLocalPart === null || input.senderLocalPart === ''
471 ? null
472 : input.senderLocalPart,
473 senderEmail:
474 input.senderEmail === undefined
475 ? current.senderEmail
476 : input.senderEmail === null || input.senderEmail === ''
477 ? null
478 : input.senderEmail,
479 brandUrl:
480 input.brandUrl === undefined
481 ? current.brandUrl
482 : input.brandUrl === null || input.brandUrl === ''
483 ? null
484 : input.brandUrl,
485 footerNote:
486 input.footerNote === undefined
487 ? current.footerNote
488 : input.footerNote === null || input.footerNote === ''
489 ? null
490 : input.footerNote,
491 footerLoveName: pickStr(input.footerLoveName, current.footerLoveName),
492 footerOrgName: pickStr(input.footerOrgName, current.footerOrgName),
493 footerTagline: pickStr(input.footerTagline, current.footerTagline),
494 footerCity: pickStr(input.footerCity, current.footerCity),
495 footerCountry: pickStr(input.footerCountry, current.footerCountry),
496 footerShowLove: pickBool(input.footerShowLove, current.footerShowLove),
497 footerShowTagline: pickBool(
498 input.footerShowTagline,
499 current.footerShowTagline,
500 ),
501 footerShowAddress: pickBool(
502 input.footerShowAddress,
503 current.footerShowAddress,
504 ),
505 });
506 await setTenantSecret(
507 projectId,
508 SERVICE,
509 BRANDING_SECRET,
510 JSON.stringify(next),
511 createdBy ?? null,
512 );
513 return { ok: true, engine: 'briven-engine', branding: next };
514}
515
516async function loadMethodFlags(
517 projectId: string,
518): Promise<BrivenEngineMethodFlags> {
519 try {
520 const raw = await getTenantSecret(projectId, SERVICE, METHOD_FLAGS_SECRET);
521 if (!raw) return { ...DEFAULT_METHOD_FLAGS };
522 const parsed = JSON.parse(raw) as Partial<BrivenEngineMethodFlags>;
523 return {
524 emailPassword: parsed.emailPassword ?? DEFAULT_METHOD_FLAGS.emailPassword,
525 passwordlessEmail:
526 parsed.passwordlessEmail ?? DEFAULT_METHOD_FLAGS.passwordlessEmail,
527 magicLink: parsed.magicLink ?? DEFAULT_METHOD_FLAGS.magicLink,
528 passwordlessSms:
529 parsed.passwordlessSms ?? DEFAULT_METHOD_FLAGS.passwordlessSms,
530 passkeys: parsed.passkeys ?? DEFAULT_METHOD_FLAGS.passkeys,
531 mfa: parsed.mfa ?? DEFAULT_METHOD_FLAGS.mfa,
532 };
533 } catch {
534 return { ...DEFAULT_METHOD_FLAGS };
535 }
536}
537
538/** Public read of method flags for FDI recipe gates. */
539export async function getBrivenEngineMethodFlags(
540 projectId: string,
541): Promise<BrivenEngineMethodFlags> {
542 return loadMethodFlags(projectId);
543}
544
545function normalizeOrigin(raw: string): string | null {
546 const t = raw.trim().replace(/\/$/, '');
547 if (!t) return null;
548 try {
549 const u = new URL(t.includes('://') ? t : `https://${t}`);
550 if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
551 if (u.protocol === 'http:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') {
552 return null;
553 }
554 return `${u.protocol}//${u.host}`;
555 } catch {
556 return null;
557 }
558}
559
560export async function getBrivenEngineAppOrigins(
561 projectId: string,
562): Promise<string[]> {
563 try {
564 const raw = await getTenantSecret(projectId, SERVICE, APP_ORIGINS_SECRET);
565 if (!raw) return [];
566 const parsed = JSON.parse(raw) as unknown;
567 if (!Array.isArray(parsed)) return [];
568 const out: string[] = [];
569 for (const item of parsed) {
570 if (typeof item !== 'string') continue;
571 const o = normalizeOrigin(item);
572 if (o && !out.includes(o)) out.push(o);
573 }
574 return out;
575 } catch {
576 return [];
577 }
578}
579
580export async function setBrivenEngineAppOrigins(
581 projectId: string,
582 origins: string[],
583 createdBy?: string | null,
584): Promise<{ ok: true; engine: 'briven-engine'; appOrigins: string[] }> {
585 const next: string[] = [];
586 for (const item of origins) {
587 const o = normalizeOrigin(item);
588 if (o && !next.includes(o)) next.push(o);
589 }
590 await setTenantSecret(
591 projectId,
592 SERVICE,
593 APP_ORIGINS_SECRET,
594 JSON.stringify(next),
595 createdBy ?? null,
596 );
597 return { ok: true, engine: 'briven-engine', appOrigins: next };
598}
599
600/** Append origins without removing existing ones. */
601export async function addBrivenEngineAppOrigins(
602 projectId: string,
603 origins: string[],
604 createdBy?: string | null,
605): Promise<{ ok: true; engine: 'briven-engine'; appOrigins: string[] }> {
606 const current = await getBrivenEngineAppOrigins(projectId);
607 return setBrivenEngineAppOrigins(
608 projectId,
609 [...current, ...origins],
610 createdBy,
611 );
612}
613
614/**
615 * Public config view (no secret values).
616 */
617export async function getBrivenEngineProjectConfig(
618 projectId: string,
619): Promise<BrivenEngineProjectConfig> {
620 const map = mapProjectToAuthCore(projectId);
621 const methods = await loadMethodFlags(projectId);
622 const branding = await getBrivenEngineBranding(projectId);
623
624 const providers = await Promise.all(
625 BRIVEN_ENGINE_SOCIAL_CATALOG.map(async (p) => {
626 const hasClientId = await hasProviderClientId(
627 projectId,
628 p.thirdPartyId,
629 );
630 const hasClientSecret = await hasProviderClientSecret(
631 projectId,
632 p.thirdPartyId,
633 );
634 return {
635 thirdPartyId: p.thirdPartyId,
636 name: p.name,
637 configured: hasClientId && hasClientSecret,
638 hasClientId,
639 hasClientSecret,
640 help: p.help,
641 callbackHint: p.callbackHint,
642 };
643 }),
644 );
645
646 const smsSid = await hasTenantSecret(projectId, SERVICE, 'briven_engine_sms_account_sid');
647 const smsToken = await hasTenantSecret(projectId, SERVICE, 'briven_engine_sms_auth_token');
648 const smsFrom = await hasTenantSecret(projectId, SERVICE, 'briven_engine_sms_from');
649 /** Ready only when Twilio-compatible triple is present (SID + token + from). */
650 const smsConfigured = smsSid && smsToken && smsFrom;
651 const emailHost = await hasTenantSecret(projectId, SERVICE, 'briven_engine_smtp_host');
652
653 const anyOauthConfigured = providers.some((p) => p.configured);
654
655 const methodChips: BrivenEngineProjectConfig['methodChips'] = [
656 {
657 id: 'emailPassword',
658 label: 'email + password',
659 kind: 'core',
660 enabled: methods.emailPassword,
661 configured: true,
662 hrefSuffix: 'providers?method=emailPassword',
663 },
664 {
665 id: 'passwordless-email',
666 label: 'passwordless-email',
667 kind: 'core',
668 enabled: methods.passwordlessEmail,
669 configured: true,
670 hrefSuffix: 'providers?method=passwordlessEmail',
671 },
672 {
673 id: 'magic-link',
674 label: 'magic-link',
675 kind: 'core',
676 enabled: methods.magicLink,
677 configured: true,
678 hrefSuffix: 'providers?method=magicLink',
679 },
680 {
681 id: 'passwordless-sms',
682 label: 'passwordless-sms',
683 kind: 'core',
684 enabled: methods.passwordlessSms,
685 configured: smsConfigured,
686 hrefSuffix: 'providers?method=passwordlessSms',
687 },
688 {
689 id: 'passkeys',
690 label: 'passkeys',
691 kind: 'core',
692 enabled: methods.passkeys,
693 configured: true,
694 hrefSuffix: 'providers?method=passkeys',
695 },
696 {
697 id: 'mfa',
698 label: 'mfa (TOTP)',
699 kind: 'core',
700 enabled: methods.mfa,
701 configured: true,
702 hrefSuffix: 'security',
703 },
704 ...providers.map((p) => ({
705 id: p.thirdPartyId,
706 label: p.name,
707 kind: 'oauth' as const,
708 enabled: p.configured,
709 configured: p.configured,
710 hrefSuffix: `providers?provider=${p.thirdPartyId}`,
711 })),
712 ];
713
714 const appOrigins = await getBrivenEngineAppOrigins(projectId);
715 const jwtClaims = await getBrivenEngineJwtClaims(projectId);
716 const usernameLogin = await getBrivenEngineUsernameLogin(projectId);
717
718 // Platform Turnstile (not per-project secret store): apps read siteKey for widget.
719 let captchaRequired = false;
720 let captchaSiteKey: string | null = null;
721 try {
722 const { env } = await import('../../env.js');
723 captchaRequired = Boolean(env.BRIVEN_TURNSTILE_SECRET_KEY);
724 captchaSiteKey = env.BRIVEN_TURNSTILE_SITE_KEY ?? null;
725 } catch {
726 captchaRequired = false;
727 captchaSiteKey = null;
728 }
729
730 return {
731 engine: 'briven-engine',
732 projectId,
733 appId: map.appId,
734 tenantId: map.tenantId,
735 providers,
736 delivery: {
737 sms: {
738 configured: smsConfigured,
739 provider: smsConfigured ? 'twilio-compatible' : null,
740 },
741 email: {
742 configured: emailHost,
743 provider: emailHost ? 'smtp' : null,
744 },
745 },
746 branding,
747 methods,
748 methodChips,
749 appOrigins,
750 jwtClaims,
751 usernameLogin,
752 captcha: {
753 required: captchaRequired,
754 siteKey: captchaSiteKey,
755 provider: captchaRequired ? 'turnstile' : null,
756 },
757 recipes: {
758 emailPassword: methods.emailPassword,
759 passwordless: methods.passwordlessEmail || methods.magicLink,
760 passwordlessSms: methods.passwordlessSms,
761 thirdParty: anyOauthConfigured,
762 webauthn: methods.passkeys,
763 mfa: methods.mfa,
764 },
765 };
766}
767
768/** Custom claims for OIDC ID tokens (project-wide template). */
769export async function getBrivenEngineJwtClaims(
770 projectId: string,
771): Promise<Record<string, string | number | boolean>> {
772 try {
773 const raw = await getTenantSecret(projectId, SERVICE, JWT_CLAIMS_SECRET);
774 if (!raw) return {};
775 const parsed = JSON.parse(raw) as Record<string, unknown>;
776 const out: Record<string, string | number | boolean> = {};
777 for (const [k, v] of Object.entries(parsed)) {
778 if (!/^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$/.test(k)) continue;
779 if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
780 out[k] = v;
781 }
782 }
783 return out;
784 } catch {
785 return {};
786 }
787}
788
789export async function setBrivenEngineJwtClaims(
790 projectId: string,
791 claims: Record<string, string | number | boolean>,
792 createdBy?: string | null,
793): Promise<{ ok: true; engine: 'briven-engine'; jwtClaims: Record<string, string | number | boolean> }> {
794 const cleaned = await getBrivenEngineJwtClaims(projectId);
795 // replace with validated input
796 const next: Record<string, string | number | boolean> = {};
797 for (const [k, v] of Object.entries(claims ?? {})) {
798 if (!/^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$/.test(k)) continue;
799 if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
800 next[k] = v;
801 }
802 }
803 await setTenantSecret(
804 projectId,
805 SERVICE,
806 JWT_CLAIMS_SECRET,
807 JSON.stringify(next),
808 createdBy ?? null,
809 );
810 void cleaned;
811 return { ok: true, engine: 'briven-engine', jwtClaims: next };
812}
813
814export async function getBrivenEngineUsernameLogin(projectId: string): Promise<boolean> {
815 try {
816 const raw = await getTenantSecret(projectId, SERVICE, USERNAME_LOGIN_SECRET);
817 return raw === 'true' || raw === '1';
818 } catch {
819 return false;
820 }
821}
822
823export async function setBrivenEngineUsernameLogin(
824 projectId: string,
825 enabled: boolean,
826 createdBy?: string | null,
827): Promise<{ ok: true; engine: 'briven-engine'; usernameLogin: boolean }> {
828 await setTenantSecret(
829 projectId,
830 SERVICE,
831 USERNAME_LOGIN_SECRET,
832 enabled ? 'true' : 'false',
833 createdBy ?? null,
834 );
835 return { ok: true, engine: 'briven-engine', usernameLogin: enabled };
836}
837
838/**
839 * Save which sign-in methods this project wants on.
840 */
841export async function setBrivenEngineMethodFlags(
842 projectId: string,
843 flags: Partial<BrivenEngineMethodFlags>,
844 createdBy?: string | null,
845): Promise<{ ok: true; engine: 'briven-engine'; methods: BrivenEngineMethodFlags }> {
846 const current = await loadMethodFlags(projectId);
847 const next: BrivenEngineMethodFlags = {
848 emailPassword: flags.emailPassword ?? current.emailPassword,
849 passwordlessEmail: flags.passwordlessEmail ?? current.passwordlessEmail,
850 magicLink: flags.magicLink ?? current.magicLink,
851 passwordlessSms: flags.passwordlessSms ?? current.passwordlessSms,
852 passkeys: flags.passkeys ?? current.passkeys,
853 mfa: flags.mfa ?? current.mfa,
854 };
855 await setTenantSecret(
856 projectId,
857 SERVICE,
858 METHOD_FLAGS_SECRET,
859 JSON.stringify(next),
860 createdBy ?? null,
861 );
862 return { ok: true, engine: 'briven-engine', methods: next };
863}
864
865/**
866 * Save social provider credentials for a project (encrypted at rest).
867 */
868export async function setBrivenEngineProviderSecrets(
869 projectId: string,
870 input: {
871 thirdPartyId: BrivenSocialProviderId;
872 clientId: string;
873 clientSecret: string;
874 additionalConfig?: Record<string, string>;
875 createdBy?: string;
876 },
877): Promise<{ ok: true; engine: 'briven-engine' }> {
878 await setTenantSecret(
879 projectId,
880 SERVICE,
881 clientIdName(input.thirdPartyId),
882 input.clientId,
883 input.createdBy ?? null,
884 );
885 await setTenantSecret(
886 projectId,
887 SERVICE,
888 clientSecretName(input.thirdPartyId),
889 input.clientSecret,
890 input.createdBy ?? null,
891 );
892 if (input.additionalConfig) {
893 for (const [k, v] of Object.entries(input.additionalConfig)) {
894 if (!v) continue;
895 await setTenantSecret(
896 projectId,
897 SERVICE,
898 extraName(input.thirdPartyId, k),
899 v,
900 input.createdBy ?? null,
901 );
902 }
903 }
904 // Prove we can open what we just wrote (catches master-key / encrypt bugs early).
905 const idOk = await readProviderClientId(projectId, input.thirdPartyId);
906 const secretOk = await readProviderClientSecret(projectId, input.thirdPartyId);
907 if (!idOk || !secretOk || idOk !== input.clientId || secretOk !== input.clientSecret) {
908 throw new Error(
909 'OAuth secrets saved but could not be re-read. Check BRIVEN_AUTH_MASTER_KEY on the API, then paste client id + secret again.',
910 );
911 }
912 return { ok: true, engine: 'briven-engine' };
913}
914
915/**
916 * Revoke OAuth provider settings for a project: delete client id, secret,
917 * and any extra keys (e.g. Apple). UI goes back to empty / not configured.
918 */
919export async function clearBrivenEngineProviderSecrets(
920 projectId: string,
921 thirdPartyId: BrivenSocialProviderId,
922): Promise<{ ok: true; engine: 'briven-engine'; thirdPartyId: BrivenSocialProviderId }> {
923 const names = [
924 clientIdName(thirdPartyId),
925 clientSecretName(thirdPartyId),
926 legacyClientIdName(thirdPartyId),
927 legacyClientSecretName(thirdPartyId),
928 ];
929 if (thirdPartyId === 'apple') {
930 names.push(extraName('apple', 'keyId'), extraName('apple', 'teamId'));
931 }
932 for (const name of names) {
933 await deleteTenantSecret(projectId, SERVICE, name);
934 }
935 return { ok: true, engine: 'briven-engine', thirdPartyId };
936}
937
938/**
939 * Load decrypted provider secrets for recipe wiring (server-side only).
940 */
941export async function loadProjectProviderSecrets(
942 projectId: string,
943): Promise<ProjectProviderSecrets[]> {
944 const out: ProjectProviderSecrets[] = [];
945 for (const p of BRIVEN_ENGINE_SOCIAL_CATALOG) {
946 const clientId = await readProviderClientId(projectId, p.thirdPartyId);
947 const clientSecret = await readProviderClientSecret(
948 projectId,
949 p.thirdPartyId,
950 );
951 if (!clientId || !clientSecret) continue;
952 const additionalConfig: Record<string, string> = {};
953 if (p.thirdPartyId === 'apple') {
954 const keyId = await getTenantSecret(
955 projectId,
956 SERVICE,
957 extraName('apple', 'keyId'),
958 );
959 const teamId = await getTenantSecret(
960 projectId,
961 SERVICE,
962 extraName('apple', 'teamId'),
963 );
964 if (keyId) additionalConfig.keyId = keyId;
965 if (teamId) additionalConfig.teamId = teamId;
966 }
967 out.push({
968 thirdPartyId: p.thirdPartyId,
969 clientId,
970 clientSecret,
971 additionalConfig:
972 Object.keys(additionalConfig).length > 0 ? additionalConfig : undefined,
973 });
974 }
975 return out;
976}
977
978/**
979 * SMS provider secrets (Twilio-compatible shape).
980 */
981export async function setBrivenEngineSmsSecrets(
982 projectId: string,
983 input: {
984 accountSid: string;
985 authToken: string;
986 fromNumber: string;
987 createdBy?: string;
988 },
989): Promise<{ ok: true; engine: 'briven-engine' }> {
990 await setTenantSecret(
991 projectId,
992 SERVICE,
993 'briven_engine_sms_account_sid',
994 input.accountSid,
995 input.createdBy ?? null,
996 );
997 await setTenantSecret(
998 projectId,
999 SERVICE,
1000 'briven_engine_sms_auth_token',
1001 input.authToken,
1002 input.createdBy ?? null,
1003 );
1004 await setTenantSecret(
1005 projectId,
1006 SERVICE,
1007 'briven_engine_sms_from',
1008 input.fromNumber,
1009 input.createdBy ?? null,
1010 );
1011 return { ok: true, engine: 'briven-engine' };
1012}
1013
1014export async function getBrivenEngineSmsSecrets(projectId: string): Promise<{
1015 accountSid: string;
1016 authToken: string;
1017 fromNumber: string;
1018} | null> {
1019 const accountSid = await getTenantSecret(
1020 projectId,
1021 SERVICE,
1022 'briven_engine_sms_account_sid',
1023 );
1024 const authToken = await getTenantSecret(
1025 projectId,
1026 SERVICE,
1027 'briven_engine_sms_auth_token',
1028 );
1029 const fromNumber = await getTenantSecret(
1030 projectId,
1031 SERVICE,
1032 'briven_engine_sms_from',
1033 );
1034 if (!accountSid || !authToken || !fromNumber) return null;
1035 return { accountSid, authToken, fromNumber };
1036}