project-config.ts538 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 getTenantSecret,
13 hasTenantSecret,
14 setTenantSecret,
15} from '../tenant-secrets.js';
16import {
17 BRIVEN_ENGINE_SOCIAL_CATALOG,
18 type BrivenSocialProviderId,
19 type ProjectProviderSecrets,
20} from './providers.js';
21import { mapProjectToAuthCore } from './project-map.js';
22
23const SERVICE = 'auth' as const;
24
25function clientIdName(id: BrivenSocialProviderId): string {
26 return `briven_engine_${id}_client_id`;
27}
28function clientSecretName(id: BrivenSocialProviderId): string {
29 return `briven_engine_${id}_client_secret`;
30}
31/** Legacy names from pre-engine Auth product (still on some projects). */
32function legacyClientIdName(id: BrivenSocialProviderId): string {
33 return `${id}_client_id`;
34}
35function legacyClientSecretName(id: BrivenSocialProviderId): string {
36 return `${id}_client_secret`;
37}
38function extraName(id: BrivenSocialProviderId, key: string): string {
39 return `briven_engine_${id}_${key}`;
40}
41
42async function hasProviderClientId(
43 projectId: string,
44 id: BrivenSocialProviderId,
45): Promise<boolean> {
46 if (await hasTenantSecret(projectId, SERVICE, clientIdName(id))) return true;
47 return hasTenantSecret(projectId, SERVICE, legacyClientIdName(id));
48}
49
50async function hasProviderClientSecret(
51 projectId: string,
52 id: BrivenSocialProviderId,
53): Promise<boolean> {
54 if (await hasTenantSecret(projectId, SERVICE, clientSecretName(id))) {
55 return true;
56 }
57 return hasTenantSecret(projectId, SERVICE, legacyClientSecretName(id));
58}
59
60async function readProviderClientId(
61 projectId: string,
62 id: BrivenSocialProviderId,
63): Promise<string | null> {
64 return (
65 (await getTenantSecret(projectId, SERVICE, clientIdName(id))) ??
66 (await getTenantSecret(projectId, SERVICE, legacyClientIdName(id)))
67 );
68}
69
70async function readProviderClientSecret(
71 projectId: string,
72 id: BrivenSocialProviderId,
73): Promise<string | null> {
74 return (
75 (await getTenantSecret(projectId, SERVICE, clientSecretName(id))) ??
76 (await getTenantSecret(projectId, SERVICE, legacyClientSecretName(id)))
77 );
78}
79
80/** Per-project which sign-in methods are turned on for the app. */
81export type BrivenEngineMethodFlags = {
82 emailPassword: boolean;
83 /** Email OTP codes */
84 passwordlessEmail: boolean;
85 /** Magic link in email */
86 magicLink: boolean;
87 passwordlessSms: boolean;
88 passkeys: boolean;
89 mfa: boolean;
90};
91
92const DEFAULT_METHOD_FLAGS: BrivenEngineMethodFlags = {
93 emailPassword: true,
94 passwordlessEmail: true,
95 magicLink: true,
96 passwordlessSms: false,
97 passkeys: true,
98 mfa: false,
99};
100
101const METHOD_FLAGS_SECRET = 'briven_engine_method_flags';
102const BRANDING_SECRET = 'briven_engine_branding';
103
104/** Login email / hosted UI look for one project. */
105export type BrivenEngineBranding = {
106 logoUrl: string | null;
107 primaryColor: string;
108 senderName: string;
109};
110
111export const DEFAULT_BRIVEN_ENGINE_BRANDING: BrivenEngineBranding = {
112 logoUrl: null,
113 primaryColor: '#FFFD74',
114 senderName: 'Briven Auth',
115};
116
117export type BrivenEngineProjectConfig = {
118 engine: 'briven-engine';
119 projectId: string;
120 appId: string;
121 tenantId: string;
122 providers: Array<{
123 thirdPartyId: BrivenSocialProviderId;
124 name: string;
125 configured: boolean;
126 hasClientId: boolean;
127 hasClientSecret: boolean;
128 help?: string;
129 callbackHint?: string;
130 }>;
131 delivery: {
132 sms: { configured: boolean; provider: string | null };
133 email: { configured: boolean; provider: string | null };
134 };
135 branding: BrivenEngineBranding;
136 /** Legacy boolean bag (kept for older UI). */
137 recipes: {
138 emailPassword: boolean;
139 passwordless: boolean;
140 passwordlessSms: boolean;
141 thirdParty: boolean;
142 webauthn: boolean;
143 mfa: boolean;
144 };
145 /** Which methods this project wants on (operator choice). */
146 methods: BrivenEngineMethodFlags;
147 /** Flat list for chips / overview. */
148 methodChips: Array<{
149 id: string;
150 label: string;
151 kind: 'core' | 'oauth';
152 enabled: boolean;
153 configured: boolean;
154 hrefSuffix: string;
155 }>;
156};
157
158export async function getBrivenEngineBranding(
159 projectId: string,
160): Promise<BrivenEngineBranding> {
161 try {
162 const raw = await getTenantSecret(projectId, SERVICE, BRANDING_SECRET);
163 if (!raw) return { ...DEFAULT_BRIVEN_ENGINE_BRANDING };
164 const parsed = JSON.parse(raw) as Partial<BrivenEngineBranding>;
165 return normalizeBranding(parsed);
166 } catch {
167 return { ...DEFAULT_BRIVEN_ENGINE_BRANDING };
168 }
169}
170
171function normalizeBranding(
172 input: Partial<BrivenEngineBranding> | null | undefined,
173): BrivenEngineBranding {
174 const color =
175 typeof input?.primaryColor === 'string' &&
176 /^#[0-9A-Fa-f]{6}$/.test(input.primaryColor.trim())
177 ? input.primaryColor.trim()
178 : DEFAULT_BRIVEN_ENGINE_BRANDING.primaryColor;
179 const name =
180 typeof input?.senderName === 'string' && input.senderName.trim()
181 ? input.senderName.trim().slice(0, 80)
182 : DEFAULT_BRIVEN_ENGINE_BRANDING.senderName;
183 let logoUrl: string | null = null;
184 if (typeof input?.logoUrl === 'string' && input.logoUrl.trim()) {
185 const u = input.logoUrl.trim();
186 if (u.startsWith('https://') || u.startsWith('http://localhost')) {
187 logoUrl = u.slice(0, 500);
188 }
189 }
190 return { logoUrl, primaryColor: color, senderName: name };
191}
192
193export async function setBrivenEngineBranding(
194 projectId: string,
195 input: Partial<BrivenEngineBranding>,
196 createdBy?: string | null,
197): Promise<{ ok: true; engine: 'briven-engine'; branding: BrivenEngineBranding }> {
198 const current = await getBrivenEngineBranding(projectId);
199 const next = normalizeBranding({
200 logoUrl:
201 input.logoUrl === undefined
202 ? current.logoUrl
203 : input.logoUrl === null || input.logoUrl === ''
204 ? null
205 : input.logoUrl,
206 primaryColor: input.primaryColor ?? current.primaryColor,
207 senderName: input.senderName ?? current.senderName,
208 });
209 await setTenantSecret(
210 projectId,
211 SERVICE,
212 BRANDING_SECRET,
213 JSON.stringify(next),
214 createdBy ?? null,
215 );
216 return { ok: true, engine: 'briven-engine', branding: next };
217}
218
219async function loadMethodFlags(
220 projectId: string,
221): Promise<BrivenEngineMethodFlags> {
222 try {
223 const raw = await getTenantSecret(projectId, SERVICE, METHOD_FLAGS_SECRET);
224 if (!raw) return { ...DEFAULT_METHOD_FLAGS };
225 const parsed = JSON.parse(raw) as Partial<BrivenEngineMethodFlags>;
226 return {
227 emailPassword: parsed.emailPassword ?? DEFAULT_METHOD_FLAGS.emailPassword,
228 passwordlessEmail:
229 parsed.passwordlessEmail ?? DEFAULT_METHOD_FLAGS.passwordlessEmail,
230 magicLink: parsed.magicLink ?? DEFAULT_METHOD_FLAGS.magicLink,
231 passwordlessSms:
232 parsed.passwordlessSms ?? DEFAULT_METHOD_FLAGS.passwordlessSms,
233 passkeys: parsed.passkeys ?? DEFAULT_METHOD_FLAGS.passkeys,
234 mfa: parsed.mfa ?? DEFAULT_METHOD_FLAGS.mfa,
235 };
236 } catch {
237 return { ...DEFAULT_METHOD_FLAGS };
238 }
239}
240
241/**
242 * Public config view (no secret values).
243 */
244export async function getBrivenEngineProjectConfig(
245 projectId: string,
246): Promise<BrivenEngineProjectConfig> {
247 const map = mapProjectToAuthCore(projectId);
248 const methods = await loadMethodFlags(projectId);
249 const branding = await getBrivenEngineBranding(projectId);
250
251 const providers = await Promise.all(
252 BRIVEN_ENGINE_SOCIAL_CATALOG.map(async (p) => {
253 const hasClientId = await hasProviderClientId(
254 projectId,
255 p.thirdPartyId,
256 );
257 const hasClientSecret = await hasProviderClientSecret(
258 projectId,
259 p.thirdPartyId,
260 );
261 return {
262 thirdPartyId: p.thirdPartyId,
263 name: p.name,
264 configured: hasClientId && hasClientSecret,
265 hasClientId,
266 hasClientSecret,
267 help: p.help,
268 callbackHint: p.callbackHint,
269 };
270 }),
271 );
272
273 const smsSid = await hasTenantSecret(projectId, SERVICE, 'briven_engine_sms_account_sid');
274 const smsToken = await hasTenantSecret(projectId, SERVICE, 'briven_engine_sms_auth_token');
275 const smsFrom = await hasTenantSecret(projectId, SERVICE, 'briven_engine_sms_from');
276 /** Ready only when Twilio-compatible triple is present (SID + token + from). */
277 const smsConfigured = smsSid && smsToken && smsFrom;
278 const emailHost = await hasTenantSecret(projectId, SERVICE, 'briven_engine_smtp_host');
279
280 const anyOauthConfigured = providers.some((p) => p.configured);
281
282 const methodChips: BrivenEngineProjectConfig['methodChips'] = [
283 {
284 id: 'emailPassword',
285 label: 'email + password',
286 kind: 'core',
287 enabled: methods.emailPassword,
288 configured: true,
289 hrefSuffix: 'providers?method=emailPassword',
290 },
291 {
292 id: 'passwordless-email',
293 label: 'passwordless-email',
294 kind: 'core',
295 enabled: methods.passwordlessEmail,
296 configured: true,
297 hrefSuffix: 'providers?method=passwordlessEmail',
298 },
299 {
300 id: 'magic-link',
301 label: 'magic-link',
302 kind: 'core',
303 enabled: methods.magicLink,
304 configured: true,
305 hrefSuffix: 'providers?method=magicLink',
306 },
307 {
308 id: 'passwordless-sms',
309 label: 'passwordless-sms',
310 kind: 'core',
311 enabled: methods.passwordlessSms,
312 configured: smsConfigured,
313 hrefSuffix: 'providers?method=passwordlessSms',
314 },
315 {
316 id: 'passkeys',
317 label: 'passkeys',
318 kind: 'core',
319 enabled: methods.passkeys,
320 configured: true,
321 hrefSuffix: 'providers?method=passkeys',
322 },
323 {
324 id: 'mfa',
325 label: 'mfa (TOTP)',
326 kind: 'core',
327 enabled: methods.mfa,
328 configured: true,
329 hrefSuffix: 'security',
330 },
331 ...providers.map((p) => ({
332 id: p.thirdPartyId,
333 label: p.name,
334 kind: 'oauth' as const,
335 enabled: p.configured,
336 configured: p.configured,
337 hrefSuffix: `providers?provider=${p.thirdPartyId}`,
338 })),
339 ];
340
341 return {
342 engine: 'briven-engine',
343 projectId,
344 appId: map.appId,
345 tenantId: map.tenantId,
346 providers,
347 delivery: {
348 sms: {
349 configured: smsConfigured,
350 provider: smsConfigured ? 'twilio-compatible' : null,
351 },
352 email: {
353 configured: emailHost,
354 provider: emailHost ? 'smtp' : null,
355 },
356 },
357 branding,
358 methods,
359 methodChips,
360 recipes: {
361 emailPassword: methods.emailPassword,
362 passwordless: methods.passwordlessEmail || methods.magicLink,
363 passwordlessSms: methods.passwordlessSms,
364 thirdParty: anyOauthConfigured,
365 webauthn: methods.passkeys,
366 mfa: methods.mfa,
367 },
368 };
369}
370
371/**
372 * Save which sign-in methods this project wants on.
373 */
374export async function setBrivenEngineMethodFlags(
375 projectId: string,
376 flags: Partial<BrivenEngineMethodFlags>,
377 createdBy?: string | null,
378): Promise<{ ok: true; engine: 'briven-engine'; methods: BrivenEngineMethodFlags }> {
379 const current = await loadMethodFlags(projectId);
380 const next: BrivenEngineMethodFlags = {
381 emailPassword: flags.emailPassword ?? current.emailPassword,
382 passwordlessEmail: flags.passwordlessEmail ?? current.passwordlessEmail,
383 magicLink: flags.magicLink ?? current.magicLink,
384 passwordlessSms: flags.passwordlessSms ?? current.passwordlessSms,
385 passkeys: flags.passkeys ?? current.passkeys,
386 mfa: flags.mfa ?? current.mfa,
387 };
388 await setTenantSecret(
389 projectId,
390 SERVICE,
391 METHOD_FLAGS_SECRET,
392 JSON.stringify(next),
393 createdBy ?? null,
394 );
395 return { ok: true, engine: 'briven-engine', methods: next };
396}
397
398/**
399 * Save social provider credentials for a project (encrypted at rest).
400 */
401export async function setBrivenEngineProviderSecrets(
402 projectId: string,
403 input: {
404 thirdPartyId: BrivenSocialProviderId;
405 clientId: string;
406 clientSecret: string;
407 additionalConfig?: Record<string, string>;
408 createdBy?: string;
409 },
410): Promise<{ ok: true; engine: 'briven-engine' }> {
411 await setTenantSecret(
412 projectId,
413 SERVICE,
414 clientIdName(input.thirdPartyId),
415 input.clientId,
416 input.createdBy ?? null,
417 );
418 await setTenantSecret(
419 projectId,
420 SERVICE,
421 clientSecretName(input.thirdPartyId),
422 input.clientSecret,
423 input.createdBy ?? null,
424 );
425 if (input.additionalConfig) {
426 for (const [k, v] of Object.entries(input.additionalConfig)) {
427 if (!v) continue;
428 await setTenantSecret(
429 projectId,
430 SERVICE,
431 extraName(input.thirdPartyId, k),
432 v,
433 input.createdBy ?? null,
434 );
435 }
436 }
437 return { ok: true, engine: 'briven-engine' };
438}
439
440/**
441 * Load decrypted provider secrets for recipe wiring (server-side only).
442 */
443export async function loadProjectProviderSecrets(
444 projectId: string,
445): Promise<ProjectProviderSecrets[]> {
446 const out: ProjectProviderSecrets[] = [];
447 for (const p of BRIVEN_ENGINE_SOCIAL_CATALOG) {
448 const clientId = await readProviderClientId(projectId, p.thirdPartyId);
449 const clientSecret = await readProviderClientSecret(
450 projectId,
451 p.thirdPartyId,
452 );
453 if (!clientId || !clientSecret) continue;
454 const additionalConfig: Record<string, string> = {};
455 if (p.thirdPartyId === 'apple') {
456 const keyId = await getTenantSecret(
457 projectId,
458 SERVICE,
459 extraName('apple', 'keyId'),
460 );
461 const teamId = await getTenantSecret(
462 projectId,
463 SERVICE,
464 extraName('apple', 'teamId'),
465 );
466 if (keyId) additionalConfig.keyId = keyId;
467 if (teamId) additionalConfig.teamId = teamId;
468 }
469 out.push({
470 thirdPartyId: p.thirdPartyId,
471 clientId,
472 clientSecret,
473 additionalConfig:
474 Object.keys(additionalConfig).length > 0 ? additionalConfig : undefined,
475 });
476 }
477 return out;
478}
479
480/**
481 * SMS provider secrets (Twilio-compatible shape).
482 */
483export async function setBrivenEngineSmsSecrets(
484 projectId: string,
485 input: {
486 accountSid: string;
487 authToken: string;
488 fromNumber: string;
489 createdBy?: string;
490 },
491): Promise<{ ok: true; engine: 'briven-engine' }> {
492 await setTenantSecret(
493 projectId,
494 SERVICE,
495 'briven_engine_sms_account_sid',
496 input.accountSid,
497 input.createdBy ?? null,
498 );
499 await setTenantSecret(
500 projectId,
501 SERVICE,
502 'briven_engine_sms_auth_token',
503 input.authToken,
504 input.createdBy ?? null,
505 );
506 await setTenantSecret(
507 projectId,
508 SERVICE,
509 'briven_engine_sms_from',
510 input.fromNumber,
511 input.createdBy ?? null,
512 );
513 return { ok: true, engine: 'briven-engine' };
514}
515
516export async function getBrivenEngineSmsSecrets(projectId: string): Promise<{
517 accountSid: string;
518 authToken: string;
519 fromNumber: string;
520} | null> {
521 const accountSid = await getTenantSecret(
522 projectId,
523 SERVICE,
524 'briven_engine_sms_account_sid',
525 );
526 const authToken = await getTenantSecret(
527 projectId,
528 SERVICE,
529 'briven_engine_sms_auth_token',
530 );
531 const fromNumber = await getTenantSecret(
532 projectId,
533 SERVICE,
534 'briven_engine_sms_from',
535 );
536 if (!accountSid || !authToken || !fromNumber) return null;
537 return { accountSid, authToken, fromNumber };
538}