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 | |
| 11 | import { |
| 12 | getTenantSecret, |
| 13 | hasTenantSecret, |
| 14 | setTenantSecret, |
| 15 | } from '../tenant-secrets.js'; |
| 16 | import { |
| 17 | BRIVEN_ENGINE_SOCIAL_CATALOG, |
| 18 | type BrivenSocialProviderId, |
| 19 | type ProjectProviderSecrets, |
| 20 | } from './providers.js'; |
| 21 | import { mapProjectToAuthCore } from './project-map.js'; |
| 22 | |
| 23 | const SERVICE = 'auth' as const; |
| 24 | |
| 25 | function clientIdName(id: BrivenSocialProviderId): string { |
| 26 | return `briven_engine_${id}_client_id`; |
| 27 | } |
| 28 | function clientSecretName(id: BrivenSocialProviderId): string { |
| 29 | return `briven_engine_${id}_client_secret`; |
| 30 | } |
| 31 | /** Legacy names from pre-engine Auth product (still on some projects). */ |
| 32 | function legacyClientIdName(id: BrivenSocialProviderId): string { |
| 33 | return `${id}_client_id`; |
| 34 | } |
| 35 | function legacyClientSecretName(id: BrivenSocialProviderId): string { |
| 36 | return `${id}_client_secret`; |
| 37 | } |
| 38 | function extraName(id: BrivenSocialProviderId, key: string): string { |
| 39 | return `briven_engine_${id}_${key}`; |
| 40 | } |
| 41 | |
| 42 | async 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 | |
| 50 | async 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 | |
| 60 | async 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 | |
| 70 | async 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. */ |
| 81 | export 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 | |
| 92 | const DEFAULT_METHOD_FLAGS: BrivenEngineMethodFlags = { |
| 93 | emailPassword: true, |
| 94 | passwordlessEmail: true, |
| 95 | magicLink: true, |
| 96 | passwordlessSms: false, |
| 97 | passkeys: true, |
| 98 | mfa: false, |
| 99 | }; |
| 100 | |
| 101 | const METHOD_FLAGS_SECRET = 'briven_engine_method_flags'; |
| 102 | const BRANDING_SECRET = 'briven_engine_branding'; |
| 103 | |
| 104 | /** Login email / hosted UI look for one project. */ |
| 105 | export type BrivenEngineBranding = { |
| 106 | logoUrl: string | null; |
| 107 | primaryColor: string; |
| 108 | senderName: string; |
| 109 | }; |
| 110 | |
| 111 | export const DEFAULT_BRIVEN_ENGINE_BRANDING: BrivenEngineBranding = { |
| 112 | logoUrl: null, |
| 113 | primaryColor: '#FFFD74', |
| 114 | senderName: 'Briven Auth', |
| 115 | }; |
| 116 | |
| 117 | export 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 | |
| 158 | export 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 | |
| 171 | function 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 | |
| 193 | export 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 | |
| 219 | async 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 | */ |
| 244 | export 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 | */ |
| 374 | export 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 | */ |
| 401 | export 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 | */ |
| 443 | export 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 | */ |
| 483 | export 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 | |
| 516 | export 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 | } |