mcp-auth-bridge.ts837 lines · main
| 1 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 5 | import { env } from '../env.js'; |
| 6 | import { log } from '../lib/logger.js'; |
| 7 | import { resolveFallbackFromAddress, resolveFromAddress } from './auth-mailer.js'; |
| 8 | import { |
| 9 | ensureTenantAuthSchema, |
| 10 | renderAuthProvisioningSql, |
| 11 | } from './auth-provisioning.js'; |
| 12 | import { createAuthSdkKey } from './auth-sdk-keys.js'; |
| 13 | import { invalidateAuthInstance } from './auth-tenant-pool.js'; |
| 14 | import { |
| 15 | getAuthConfig, |
| 16 | isAuthEnabled, |
| 17 | updateAuthConfig, |
| 18 | type AuthConfig, |
| 19 | } from './tenant-config-store.js'; |
| 20 | |
| 21 | /** |
| 22 | * MCP auth bridge — reception desk + agent write tools for THIS project only. |
| 23 | * |
| 24 | * READ (every MCP scope): |
| 25 | * auth_config_get, sender_domain_status, auth_docs_ask |
| 26 | * |
| 27 | * WRITE (read-write / admin MCP keys only — 2026-07-21): |
| 28 | * auth_enable_passwordless — turn on magic link + email OTP + passkey |
| 29 | * auth_mint_public_key — mint pk_briven_auth_… (plaintext once) |
| 30 | * |
| 31 | * Contract: |
| 32 | * - One-project scope from the MCP key binding. |
| 33 | * - Writes only passwordless toggles + mint public key (not OAuth secrets). |
| 34 | * - Vendor-silent prose for email infra. |
| 35 | */ |
| 36 | |
| 37 | const DOCS = { |
| 38 | auth: 'https://docs.briven.tech/auth', |
| 39 | senderDomain: 'https://docs.briven.tech/auth#sender-domain', |
| 40 | keys: 'https://docs.briven.tech/auth#keys', |
| 41 | flows: 'https://docs.briven.tech/auth#flows', |
| 42 | security: 'https://docs.briven.tech/auth#security', |
| 43 | verifyTokens: 'https://docs.briven.tech/auth#verify-tokens', |
| 44 | twoFactor: 'https://docs.briven.tech/auth#two-factor', |
| 45 | testingTokens: 'https://docs.briven.tech/auth#testing-tokens', |
| 46 | setup: 'https://docs.briven.tech/auth#setup', |
| 47 | agents: 'https://docs.briven.tech/auth#agents', |
| 48 | } as const; |
| 49 | |
| 50 | /* ── auth_config_get ─────────────────────────────────────────────────── */ |
| 51 | |
| 52 | /** |
| 53 | * Sanitize the stored auth config for MCP exposure: OAuth client ids are |
| 54 | * publishable but still replaced with a set/unset boolean — an agent needs |
| 55 | * to know WHETHER a provider is wired, never the value, and this keeps the |
| 56 | * tool output safe to paste anywhere. |
| 57 | */ |
| 58 | export function sanitizeAuthConfig(config: AuthConfig): { |
| 59 | providers: Record<string, Record<string, unknown>>; |
| 60 | branding: { |
| 61 | logoUrl: string | null; |
| 62 | primaryColor: string; |
| 63 | senderName: string; |
| 64 | senderDomain: string | null; |
| 65 | }; |
| 66 | } { |
| 67 | const providers: Record<string, Record<string, unknown>> = {}; |
| 68 | for (const [name, raw] of Object.entries(config.providers)) { |
| 69 | const p = raw as Record<string, unknown>; |
| 70 | const out: Record<string, unknown> = {}; |
| 71 | for (const [k, v] of Object.entries(p)) { |
| 72 | if (k === 'clientId') out.clientIdSet = typeof v === 'string' && v.length > 0; |
| 73 | else out[k] = v; |
| 74 | } |
| 75 | providers[name] = out; |
| 76 | } |
| 77 | return { |
| 78 | providers, |
| 79 | branding: { |
| 80 | logoUrl: config.branding.logoUrl, |
| 81 | primaryColor: config.branding.primaryColor, |
| 82 | senderName: config.branding.senderName, |
| 83 | senderDomain: config.branding.senderDomain, |
| 84 | }, |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | /* ── sender_domain_status ────────────────────────────────────────────── */ |
| 89 | |
| 90 | interface ProviderDnsRecord { |
| 91 | type: string; |
| 92 | name: string; |
| 93 | value: string; |
| 94 | ttl: string; |
| 95 | priority?: string | null; |
| 96 | status: string; |
| 97 | recommended?: boolean; |
| 98 | } |
| 99 | |
| 100 | type DomainStatusResult = |
| 101 | | { |
| 102 | available: true; |
| 103 | registered: boolean; |
| 104 | status: string | null; |
| 105 | verified: boolean; |
| 106 | dnsRecords: ProviderDnsRecord[]; |
| 107 | } |
| 108 | | { available: false; reason: string }; |
| 109 | |
| 110 | /** |
| 111 | * Ask briven's email infrastructure for the verification state of one |
| 112 | * sending domain. Fail-soft: any transport problem becomes |
| 113 | * `{ available: false }` — this tool must never take the MCP request down. |
| 114 | */ |
| 115 | async function fetchDomainStatus(domainName: string): Promise<DomainStatusResult> { |
| 116 | if (!env.BRIVEN_MITTERA_API_URL || !env.BRIVEN_MITTERA_API_KEY) { |
| 117 | return { |
| 118 | available: false, |
| 119 | reason: |
| 120 | 'email-infrastructure status API is not configured on this deployment; ' + |
| 121 | 'sends still work (fallback sender), only live verification status is unavailable.', |
| 122 | }; |
| 123 | } |
| 124 | try { |
| 125 | const url = `${env.BRIVEN_MITTERA_API_URL.replace(/\/$/, '')}/api/v1/domains`; |
| 126 | const res = await fetch(url, { |
| 127 | headers: { authorization: `Bearer ${env.BRIVEN_MITTERA_API_KEY}` }, |
| 128 | signal: AbortSignal.timeout(8_000), |
| 129 | }); |
| 130 | if (!res.ok) { |
| 131 | log.warn('mcp_sender_domain_status_upstream_error', { status: res.status }); |
| 132 | return { available: false, reason: `status API answered ${res.status}` }; |
| 133 | } |
| 134 | const domains = (await res.json()) as Array<{ |
| 135 | name?: string; |
| 136 | status?: string; |
| 137 | dnsRecords?: ProviderDnsRecord[]; |
| 138 | }>; |
| 139 | const match = Array.isArray(domains) |
| 140 | ? domains.find((d) => d.name?.toLowerCase() === domainName.toLowerCase()) |
| 141 | : undefined; |
| 142 | if (!match) { |
| 143 | return { available: true, registered: false, status: null, verified: false, dnsRecords: [] }; |
| 144 | } |
| 145 | return { |
| 146 | available: true, |
| 147 | registered: true, |
| 148 | status: match.status ?? null, |
| 149 | verified: match.status === 'SUCCESS', |
| 150 | dnsRecords: match.dnsRecords ?? [], |
| 151 | }; |
| 152 | } catch (err) { |
| 153 | log.warn('mcp_sender_domain_status_fetch_failed', { |
| 154 | error: err instanceof Error ? err.message : String(err), |
| 155 | }); |
| 156 | return { available: false, reason: 'status API unreachable (timeout or network)' }; |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /* ── auth_docs_ask — curated guidance corpus ─────────────────────────── */ |
| 161 | |
| 162 | export interface AuthGuidanceEntry { |
| 163 | readonly id: string; |
| 164 | readonly topic: string; |
| 165 | readonly keywords: readonly string[]; |
| 166 | /** The briven-side facts. */ |
| 167 | readonly answer: string; |
| 168 | /** What the REQUESTING agent does in its own project with those facts. */ |
| 169 | readonly applyInYourProject: readonly string[]; |
| 170 | readonly docs: string; |
| 171 | } |
| 172 | |
| 173 | export const AUTH_GUIDANCE: readonly AuthGuidanceEntry[] = [ |
| 174 | { |
| 175 | id: 'briven-engine-fdi', |
| 176 | topic: 'briven-engine live auth API (replaces auth-tenant)', |
| 177 | keywords: [ |
| 178 | 'briven-engine', 'auth-core', 'fdi', 'auth-tenant', '410', 'engine', |
| 179 | 'doltgres', 'signinup', 'session', 'me', 'proxy', 'first-party', |
| 180 | ], |
| 181 | answer: |
| 182 | 'production login is briven-engine on Doltgres (database briven_engine). HTTP base: ' + |
| 183 | 'https://api.briven.tech/v1/auth-core. Passwordless FDI: POST /v1/auth-core/fdi/signinup/code ' + |
| 184 | 'and POST /v1/auth-core/fdi/signinup/code/consume. Session: GET /v1/auth-core/session/me. ' + |
| 185 | 'Social: authorisationurl + signinup under /v1/auth-core/fdi/. WebAuthn under /v1/auth-core/fdi/webauthn/*. ' + |
| 186 | 'Always send x-briven-project-id and x-briven-engine: briven-engine. ' + |
| 187 | 'The old Better Auth surface /v1/auth-tenant/* is retired and returns 410 Gone.', |
| 188 | applyInYourProject: [ |
| 189 | 'prefer a first-party proxy on your app (e.g. /api/auth/*) so sAccessToken cookies are same-site on your domain.', |
| 190 | 'do not call /v1/auth-tenant/* in new code; rewrite any leftover clients to auth-core FDI.', |
| 191 | 'after consume, mint your own app session if you need SSR without calling Briven every request.', |
| 192 | ], |
| 193 | docs: DOCS.auth, |
| 194 | }, |
| 195 | { |
| 196 | id: 'sender-domain-setup', |
| 197 | topic: 'brand auth emails as your own domain', |
| 198 | keywords: ['sender', 'domain', 'brand', 'from', 'address', 'noreply', 'custom', 'email'], |
| 199 | answer: |
| 200 | 'auth emails send from noreply@briven.tech by default. to send as noreply@yourdomain, ' + |
| 201 | 'the project owner sets sender name + sender domain (ROOT domain, never an auth.* ' + |
| 202 | 'subdomain) under dashboard → auth → branding. the domain then needs DNS verification ' + |
| 203 | '(SPF + DKIM records briven provides). until verified, sends automatically use the ' + |
| 204 | 'briven fallback — a set-but-unverified domain can never break sign-in.', |
| 205 | applyInYourProject: [ |
| 206 | 'no code change is needed in your app for the sender switch — it happens platform-side the moment the domain verifies.', |
| 207 | 'call sender_domain_status to get the exact DNS records and their live status; add the missing ones at your domain host.', |
| 208 | 'domain registration with briven email infrastructure is platform-side: if sender_domain_status reports registered:false, request registration via an owner handoff — do not try to modify the briven platform yourself.', |
| 209 | ], |
| 210 | docs: DOCS.senderDomain, |
| 211 | }, |
| 212 | { |
| 213 | id: 'emails-from-fallback', |
| 214 | topic: 'why do my emails come from noreply@briven.tech?', |
| 215 | keywords: ['fallback', 'briven.tech', 'noreply', 'still', 'wrong', 'sender', 'why'], |
| 216 | answer: |
| 217 | 'that is the safe fallback: your sender domain is either empty or not verified yet. ' + |
| 218 | 'briven tries your custom domain first and falls back automatically when the email ' + |
| 219 | 'infrastructure rejects it, so sign-in keeps working during DNS setup.', |
| 220 | applyInYourProject: [ |
| 221 | 'nothing is broken — treat fallback sends as fully functional.', |
| 222 | 'to move to noreply@yourdomain: run sender_domain_status, add the DNS records it lists, wait for verification (rechecked periodically), then re-run it to confirm verified:true.', |
| 223 | ], |
| 224 | docs: DOCS.senderDomain, |
| 225 | }, |
| 226 | { |
| 227 | id: 'email-not-arriving', |
| 228 | topic: 'sign-in emails not arriving', |
| 229 | keywords: ['not', 'arriving', 'missing', 'spam', 'inbox', 'delivery', 'receive', 'email'], |
| 230 | answer: |
| 231 | 'first check spam. then dashboard → auth → usage for delivery status. a recipient who ' + |
| 232 | 'previously hard-bounced or complained is suppressed and will not be re-sent. note the ' + |
| 233 | 'auth SDK returns ok:true when the API answered at all — it is not delivery proof. ' + |
| 234 | 'an unverified custom senderDomain falls back to noreply@briven.tech automatically — that is success, not a failure.', |
| 235 | applyInYourProject: [ |
| 236 | 'verify the send really returned HTTP 200 by probing POST /v1/auth-core/fdi/signinup/code ' + |
| 237 | 'with headers x-briven-project-id + x-briven-engine: briven-engine and body ' + |
| 238 | '{ email, flowType: "USER_INPUT_CODE" | "MAGIC_LINK" } — expect status:"OK" and delivery.ok. ' + |
| 239 | 'Old /v1/auth-tenant/* returns 410 Gone; do not probe those paths.', |
| 240 | 'surface a "check your spam folder" hint in your sign-in UI after a magic-link or OTP send.', |
| 241 | ], |
| 242 | docs: DOCS.flows, |
| 243 | }, |
| 244 | { |
| 245 | id: 'magic-otp-500', |
| 246 | topic: 'magic link or email OTP returns HTTP 500 empty body', |
| 247 | keywords: [ |
| 248 | '500', 'empty', 'body', 'magic', 'otp', 'send', 'fail', 'failed', 'error', |
| 249 | 'server', 'broken', 'schema', 'templates', |
| 250 | ], |
| 251 | answer: |
| 252 | 'live login uses briven-engine (Doltgres) under /v1/auth-core/*, not the retired auth-tenant surface. ' + |
| 253 | 'HTTP 500 on FDI usually means engine not ready (503 notReady), misconfigured delivery, or a real platform bug — ' + |
| 254 | 'capture x-request-id. Unverified senderDomain is NOT a 500 cause (fallback From is used). ' + |
| 255 | 'CORS allow-origin on a 500 means Allowed Domains already worked — do NOT close with "add domains".', |
| 256 | applyInYourProject: [ |
| 257 | 're-probe POST https://api.briven.tech/v1/auth-core/fdi/signinup/code with x-briven-project-id, ' + |
| 258 | 'x-briven-engine: briven-engine, body { email, flowType: "USER_INPUT_CODE" }; expect 200 { status: "OK", engine: "briven-engine" }.', |
| 259 | 'if still 500: capture x-request-id + origin + path and file a Briven platform handoff — do not invent Clerk or a side mailer.', |
| 260 | 'if HTTP 200 but no inbox: check spam; From may be branded via fallback until sender domain verifies.', |
| 261 | ], |
| 262 | docs: DOCS.flows, |
| 263 | }, |
| 264 | { |
| 265 | id: 'magic-link-integration', |
| 266 | topic: 'integrate magic-link sign-in', |
| 267 | keywords: ['magic', 'link', 'sign', 'in', 'login', 'passwordless', 'integrate', 'sdk'], |
| 268 | answer: |
| 269 | 'live engine path (Option B / briven-engine): POST /v1/auth-core/fdi/signinup/code with ' + |
| 270 | '{ email, flowType: "MAGIC_LINK", magicLinkBaseUrl: "https://your.app/auth/consume" }. ' + |
| 271 | 'Engine emails a link with preAuthSessionId + linkCode + deviceId; your page POSTs ' + |
| 272 | '/v1/auth-core/fdi/signinup/code/consume with those fields. Prefer a first-party proxy ' + |
| 273 | '(your.app/api/auth/* → api.briven.tech) so session cookies (sAccessToken) sit on YOUR domain (Safari-safe). ' + |
| 274 | 'Headers: x-briven-project-id, x-briven-engine: briven-engine. /v1/auth-tenant/* is retired (410).', |
| 275 | applyInYourProject: [ |
| 276 | 'register every origin under auth → allowed domains.', |
| 277 | 'proxy FDI through your domain and rewrite Set-Cookie to first-party Path=/; SameSite=Lax.', |
| 278 | 'after consume, call GET /v1/auth-core/session/me (or your proxy /api/auth/session/me) with credentials:include.', |
| 279 | 'treat HTTP 200 + status:OK as send accepted, not inbox proof.', |
| 280 | ], |
| 281 | docs: DOCS.flows, |
| 282 | }, |
| 283 | { |
| 284 | id: 'email-otp-integration', |
| 285 | topic: 'integrate email OTP sign-in', |
| 286 | keywords: ['otp', 'code', 'one', 'time', 'email', 'verification', 'sign-in'], |
| 287 | answer: |
| 288 | 'briven-engine OTP: POST /v1/auth-core/fdi/signinup/code with { email, flowType: "USER_INPUT_CODE" } ' + |
| 289 | '→ response preAuthSessionId + deviceId (store client-side). User enters 6-digit code → ' + |
| 290 | 'POST /v1/auth-core/fdi/signinup/code/consume with { preAuthSessionId, deviceId, userInputCode }. ' + |
| 291 | 'Success sets sAccessToken + sRefreshToken cookies. Prefer first-party proxy so cookies land on your app host. ' + |
| 292 | 'Do not use /v1/auth-tenant/email-otp/* (410).', |
| 293 | applyInYourProject: [ |
| 294 | 'never invent a local OTP table — engine stores hashed codes in briven_engine.', |
| 295 | 'same Allowed Domains + first-party proxy rules as magic link.', |
| 296 | 'session check: GET /v1/auth-core/session/me with credentials:include and x-briven-project-id.', |
| 297 | ], |
| 298 | docs: DOCS.flows, |
| 299 | }, |
| 300 | { |
| 301 | id: 'keys', |
| 302 | topic: 'which key goes where', |
| 303 | keywords: ['key', 'keys', 'pk', 'brk', 'publishable', 'secret', 'api'], |
| 304 | answer: |
| 305 | 'pk_briven_auth_* = browser-safe publishable auth key (end-user sign-in surface only). ' + |
| 306 | 'brk_* = server data keys, never in client code. pk_briven_mcp_* = this MCP connection, ' + |
| 307 | 'bound to one project and scope.', |
| 308 | applyInYourProject: [ |
| 309 | 'ship pk_briven_auth_* in your client bundle freely; keep brk_* server-side only (env var, never committed).', |
| 310 | 'if a brk_ key ever appeared in chat/logs/git, rotate it in dashboard → api keys.', |
| 311 | ], |
| 312 | docs: DOCS.keys, |
| 313 | }, |
| 314 | { |
| 315 | id: 'providers-config', |
| 316 | topic: 'enable or configure sign-in providers', |
| 317 | keywords: [ |
| 318 | 'provider', 'providers', 'enable', 'otp', 'passkey', 'oauth', 'google', 'github', |
| 319 | 'toggle', 'konnos', 'clientid', 'secret', |
| 320 | ], |
| 321 | answer: |
| 322 | 'ALWAYS call auth_config_get first. Magic link / email OTP / passkey / password only need ' + |
| 323 | 'enabled:true (no secrets). OAuth (google, github, konnos, …) needs enabled:true AND clientIdSet:true ' + |
| 324 | 'AND a stored client secret — toggle alone does NOT activate the button. ' + |
| 325 | 'If magic/OTP/passkey are OFF and your MCP key is read-write or admin: call auth_enable_passwordless once ' + |
| 326 | '(or PATCH /v1/projects/:id/auth/config with Bearer brk_ admin key). ' + |
| 327 | 'If they already show enabled:true, do NOT re-nag the owner — wire the app.', |
| 328 | applyInYourProject: [ |
| 329 | 'write-scope MCP: auth_enable_passwordless then auth_mint_public_key if you lack pk_briven_auth_….', |
| 330 | 'HTTP (admin brk_): PATCH /v1/projects/{id}/auth/config with providers.magicLink/emailOtp/passkey enabled true.', |
| 331 | 'offer UI only for providers with enabled:true; for OAuth also require clientIdSet:true.', |
| 332 | 'never invent Clerk/Firebase Auth.', |
| 333 | ], |
| 334 | docs: DOCS.auth, |
| 335 | }, |
| 336 | { |
| 337 | id: 'passkey-webauthn', |
| 338 | topic: 'passkey / Face ID / WebAuthn routes and integration', |
| 339 | keywords: [ |
| 340 | 'passkey', 'passkeys', 'webauthn', 'face', 'touch', 'biometric', '404', |
| 341 | 'generate', 'authenticate', 'register', 'options', 'fido', |
| 342 | ], |
| 343 | answer: |
| 344 | 'when passkey is enabled, briven-engine WebAuthn is live under /v1/auth-core/fdi/webauthn/*: ' + |
| 345 | 'POST …/webauthn/signin/options, POST …/webauthn/signin/finish, ' + |
| 346 | 'POST …/webauthn/register/options (needs session), POST …/webauthn/register/finish, ' + |
| 347 | 'GET …/webauthn/credentials, DELETE …/webauthn/credentials/:id. ' + |
| 348 | 'Face ID/Touch ID is the device — NOT a "wait for platform update". ' + |
| 349 | 'Old Better Auth /v1/auth-tenant/passkey/* paths are retired (410). ' + |
| 350 | 'rpID should be a parent domain of your host. Allowed Domains must include the app origin.', |
| 351 | applyInYourProject: [ |
| 352 | 'use the FDI webauthn routes above (or a first-party proxy to them); do not call retired auth-tenant passkey paths.', |
| 353 | 'register passkey only after a normal session (magic link/OTP/password); then sign-in with Face ID.', |
| 354 | 'if options return OK but browser fails: check HTTPS, Allowed Domains, and that rpId is a parent of your host.', |
| 355 | ], |
| 356 | docs: DOCS.auth, |
| 357 | }, |
| 358 | { |
| 359 | id: 'verify-tokens-jwt-jwks', |
| 360 | topic: 'verify a session locally with tokens (JWT + JWKS)', |
| 361 | keywords: ['jwt', 'jwks', 'token', 'tokens', 'verify', 'verifiable', 'verification', 'locally', 'stateless', 'bearer'], |
| 362 | answer: |
| 363 | 'preferred live path: session cookie sAccessToken (value is the engine session handle) plus ' + |
| 364 | 'GET /v1/auth-core/session/me with credentials:include and x-briven-project-id. ' + |
| 365 | 'Many apps mint their OWN first-party signed cookie after me succeeds (e.g. konnos_session) so SSR ' + |
| 366 | 'does not depend on Briven every request. Retired auth-tenant /token and /jwks return 410 — do not wire new apps to them.', |
| 367 | applyInYourProject: [ |
| 368 | 'after OTP/magic/OAuth consume, call GET /v1/auth-core/session/me (via first-party proxy if possible).', |
| 369 | 'optionally mint your app session cookie from that identity (HMAC JWT or similar) for SSR.', |
| 370 | 'do not depend on GET /v1/auth-tenant/token or /jwks for new work.', |
| 371 | ], |
| 372 | docs: DOCS.verifyTokens, |
| 373 | }, |
| 374 | { |
| 375 | id: 'sessions-cookies', |
| 376 | topic: 'sessions and cross-domain cookies', |
| 377 | keywords: ['session', 'cookie', 'stick', 'logged', 'out', 'cross', 'domain', 'cors'], |
| 378 | answer: |
| 379 | 'briven-engine sets HttpOnly sAccessToken + sRefreshToken on successful consume/sign-in. ' + |
| 380 | 'If the browser talks to api.briven.tech directly, those cookies are third-party on your site and Safari may block them. ' + |
| 381 | 'Production pattern: proxy /api/auth/* on YOUR origin to /v1/auth-core/fdi/* (+ session/me), rewrite Set-Cookie to first-party on your host.', |
| 382 | applyInYourProject: [ |
| 383 | 'implement a same-origin auth proxy; strip upstream Domain, set Path=/ and SameSite=Lax.', |
| 384 | 'gate protected pages on your own session cookie or server-side session/me with the first-party Briven cookies.', |
| 385 | 'never log user emails or IPs from auth traffic.', |
| 386 | ], |
| 387 | docs: DOCS.security, |
| 388 | }, |
| 389 | { |
| 390 | id: 'two-factor-backup-codes', |
| 391 | topic: 'two-factor and lost-phone backup codes', |
| 392 | keywords: [ |
| 393 | 'two', |
| 394 | 'factor', |
| 395 | '2fa', |
| 396 | 'mfa', |
| 397 | 'totp', |
| 398 | 'backup', |
| 399 | 'recovery', |
| 400 | 'code', |
| 401 | 'codes', |
| 402 | 'authenticator', |
| 403 | 'lost', |
| 404 | 'phone', |
| 405 | ], |
| 406 | answer: |
| 407 | 'when 2FA is on, password sign-in may return twoFactorRequired. finish with ' + |
| 408 | 'auth.twoFactor.verify (TOTP app code) or auth.twoFactor.verifyBackupCode (single-use ' + |
| 409 | 'recovery codes for lost phones). enroll with enable → verify → generateBackupCodes ' + |
| 410 | 'and show the codes once. hosted path: /auth/<projectId>/two-factor.', |
| 411 | applyInYourProject: [ |
| 412 | 'after signIn.email, if result.ok && "twoFactorRequired" in result, show TwoFactorChallenge or redirect to the hosted two-factor page.', |
| 413 | 'never store backup codes in your database — only the user should keep them offline.', |
| 414 | 'use auth.twoFactor.verify (not a made-up /two-factor/verify path) — the live endpoint is verify-totp under the hood.', |
| 415 | ], |
| 416 | docs: DOCS.twoFactor, |
| 417 | }, |
| 418 | { |
| 419 | id: 'testing-tokens-e2e', |
| 420 | topic: 'testing tokens for e2e / ci', |
| 421 | keywords: ['test', 'testing', 'token', 'e2e', 'ci', 'playwright', 'bypass', 'mfa'], |
| 422 | answer: |
| 423 | 'mint a short-lived briven_test_… token as a project admin (dashboard or POST ' + |
| 424 | '/v1/projects/:id/auth/test-tokens). exchange it with auth.signIn.testToken(raw) to get ' + |
| 425 | 'a real session without MFA/rate-limit friction. raw token is shown once; revoke after CI.', |
| 426 | applyInYourProject: [ |
| 427 | 'store the raw token only in CI secrets (BRIVEN_AUTH_TEST_TOKEN), never in git.', |
| 428 | 'call createBrivenAuth({ projectId, publicKey }) then auth.signIn.testToken(process.env.BRIVEN_AUTH_TEST_TOKEN!).', |
| 429 | 'do not use testing tokens for real end users in production flows.', |
| 430 | ], |
| 431 | docs: DOCS.testingTokens, |
| 432 | }, |
| 433 | { |
| 434 | id: 'password-policy', |
| 435 | topic: 'password policy and force reset', |
| 436 | keywords: [ |
| 437 | 'password', |
| 438 | 'policy', |
| 439 | 'weak', |
| 440 | 'length', |
| 441 | 'reuse', |
| 442 | 'expired', |
| 443 | 'force', |
| 444 | 'reset', |
| 445 | 'complexity', |
| 446 | ], |
| 447 | answer: |
| 448 | 'per-project password policy covers min length, character classes, max age, and reuse. ' + |
| 449 | 'admins can force a password change on next sign-in. weak passwords are rejected on ' + |
| 450 | 'sign-up/reset/change; expired or force-reset users cannot open a session until they reset.', |
| 451 | applyInYourProject: [ |
| 452 | 'read policy via dashboard or GET /v1/projects/:id/auth/password-policy before showing signup rules in your UI.', |
| 453 | 'surface the server error message (code weak_password) next to the password field — do not invent rules that disagree with the project policy.', |
| 454 | ], |
| 455 | docs: DOCS.security, |
| 456 | }, |
| 457 | { |
| 458 | id: 'new-device-sessions', |
| 459 | topic: 'new device alerts and session revoke', |
| 460 | keywords: ['device', 'new', 'session', 'sessions', 'revoke', 'alert', 'fingerprint'], |
| 461 | answer: |
| 462 | 'on sign-in from an unseen browser fingerprint, briven can email a new-device notice ' + |
| 463 | '(no raw IPs stored). admins can list sessions/devices for a user and revoke a ' + |
| 464 | 'specific session. end users can list/revoke via the SDK sessions helpers.', |
| 465 | applyInYourProject: [ |
| 466 | 'offer a "sessions" settings page using auth.sessions.list() / auth.sessions.revoke(sessionId).', |
| 467 | 'tell users to check spam if they do not see new-device mail; branding/sender domain still applies.', |
| 468 | ], |
| 469 | docs: DOCS.security, |
| 470 | }, |
| 471 | { |
| 472 | id: 'scaffold-setup', |
| 473 | topic: 'scaffold briven auth into a next app', |
| 474 | keywords: [ |
| 475 | 'scaffold', 'setup', 'middleware', 'next', 'briven auth scaffold', 'pilot', |
| 476 | 'connect', 'link', |
| 477 | ], |
| 478 | answer: |
| 479 | 'wire the folder first: briven setup <name> for a NEW cloud project, or briven connect p_… ' + |
| 480 | 'for an EXISTING one (never setup --project). then briven auth scaffold + install @briven/auth. ' + |
| 481 | 'that writes middleware.ts, lib/auth.ts, and .env.local seeds. paste pk_briven_auth_…, then ' + |
| 482 | 'hostedPageURL or BrivenSignIn. prove with AUTH-GO-LIVE-CHECKLIST.md.', |
| 483 | applyInYourProject: [ |
| 484 | 'briven setup OR briven connect, then briven auth scaffold — do not invent setup --project.', |
| 485 | 'set NEXT_PUBLIC_BRIVEN_API_ORIGIN, NEXT_PUBLIC_BRIVEN_PROJECT_ID, NEXT_PUBLIC_BRIVEN_AUTH_KEY, BRIVEN_AUTH_PUBLIC_KEY (same pk value for middleware).', |
| 486 | 'copy examples/auth-pilot/ for a minimal hosted sign-in button pattern.', |
| 487 | ], |
| 488 | docs: DOCS.setup, |
| 489 | }, |
| 490 | { |
| 491 | id: 'allowed-domains-cors', |
| 492 | topic: 'allowed domains / CORS / origin rejected', |
| 493 | keywords: [ |
| 494 | 'allowed', 'domains', 'domain', 'cors', 'origin', 'access-control', 'forbidden', |
| 495 | 'app', 'domains', |
| 496 | ], |
| 497 | answer: |
| 498 | 'every browser Origin that calls /v1/auth-core/* (or your first-party auth proxy) must be listed under the project ' + |
| 499 | 'Auth → Allowed Domains (exact scheme+host, e.g. https://konnos.org and ' + |
| 500 | 'http://localhost:3000 separately). when CORS returns access-control-allow-origin for your ' + |
| 501 | 'origin, domains are fine — a later 500 is not a domains problem. /v1/auth-tenant/* is retired (410).', |
| 502 | applyInYourProject: [ |
| 503 | 'add every production + local origin the human uses; retest with Origin header matching the list.', |
| 504 | 'do not tell the owner to "re-add domains" if the failing response already shows the correct allow-origin.', |
| 505 | ], |
| 506 | docs: DOCS.auth, |
| 507 | }, |
| 508 | ] as const; |
| 509 | |
| 510 | export function tokeniseQuestion(text: string): string[] { |
| 511 | return text |
| 512 | .toLowerCase() |
| 513 | .split(/[^a-z0-9]+/) |
| 514 | .filter((t) => t.length > 1); |
| 515 | } |
| 516 | |
| 517 | /** |
| 518 | * Score guidance entries against a free-form question (same word-overlap |
| 519 | * approach as the public docs search). Exported for tests. |
| 520 | */ |
| 521 | export function matchAuthGuidance(question: string, limit = 3): AuthGuidanceEntry[] { |
| 522 | const tokens = new Set(tokeniseQuestion(question)); |
| 523 | const scored = AUTH_GUIDANCE.map((entry) => { |
| 524 | let score = 0; |
| 525 | for (const k of entry.keywords) if (tokens.has(k)) score += 2; |
| 526 | for (const t of tokeniseQuestion(entry.topic)) if (tokens.has(t)) score += 1; |
| 527 | return { entry, score }; |
| 528 | }) |
| 529 | .filter((s) => s.score > 0) |
| 530 | .sort((a, b) => b.score - a.score); |
| 531 | return scored.slice(0, limit).map((s) => s.entry); |
| 532 | } |
| 533 | |
| 534 | /* ── registration ────────────────────────────────────────────────────── */ |
| 535 | |
| 536 | /** |
| 537 | * Register auth-bridge tools. Read tools: every MCP scope. Write tools: |
| 538 | * only when `opts.allowWrites` (read-write / admin MCP keys). |
| 539 | */ |
| 540 | export function registerAuthBridgeTools( |
| 541 | server: McpServer, |
| 542 | ctx: { projectId: string }, |
| 543 | auditCall: (tool: string, metadata: Record<string, unknown>) => Promise<void>, |
| 544 | jsonResult: (payload: unknown) => { content: { type: 'text'; text: string }[] }, |
| 545 | opts?: { allowWrites?: boolean }, |
| 546 | ): void { |
| 547 | server.registerTool( |
| 548 | 'auth_config_get', |
| 549 | { |
| 550 | title: 'Auth config (read-only)', |
| 551 | description: |
| 552 | 'Read YOUR project\'s auth configuration: which sign-in providers are enabled ' + |
| 553 | '(with settings), and the email branding (sender name, sender domain, colors). ' + |
| 554 | 'Secrets and OAuth client ids are never returned. ' + |
| 555 | 'To turn on magic/OTP/passkey with a write-scope MCP key, use auth_enable_passwordless. ' + |
| 556 | 'To mint pk_briven_auth_… use auth_mint_public_key.', |
| 557 | annotations: { readOnlyHint: true }, |
| 558 | }, |
| 559 | async () => { |
| 560 | await auditCall('auth_config_get', {}); |
| 561 | const config = await getAuthConfig(ctx.projectId); |
| 562 | const sanitized = sanitizeAuthConfig(config); |
| 563 | const enabled = await isAuthEnabled(ctx.projectId).catch(() => false); |
| 564 | return jsonResult({ |
| 565 | authEnabled: enabled, |
| 566 | config: sanitized, |
| 567 | guidance: { |
| 568 | summary: |
| 569 | 'build your sign-in UI from this live config. with a read-write/admin MCP key ' + |
| 570 | 'call auth_enable_passwordless to turn on magic+OTP+passkey, and auth_mint_public_key ' + |
| 571 | 'for a browser pk_briven_auth_… key.', |
| 572 | applyInYourProject: [ |
| 573 | 'if magicLink/emailOtp/passkey show enabled:true, do NOT re-ask the owner to toggle them — wire the app.', |
| 574 | 'if they are false and your MCP key is write-scope: call auth_enable_passwordless once, then auth_config_get again.', |
| 575 | 'OAuth (google/konnos/…): needs enabled:true AND clientIdSet:true AND a secret in the dashboard; toggle alone is not enough.', |
| 576 | 'live engine (briven-engine): POST /v1/auth-core/fdi/signinup/code for OTP or MAGIC_LINK; POST …/signinup/code/consume to finish; GET /v1/auth-core/session/me for session.', |
| 577 | 'headers on every engine call: x-briven-project-id + x-briven-engine: briven-engine. Prefer first-party /api/auth proxy for cookies.', |
| 578 | 'passkey: POST /v1/auth-core/fdi/webauthn/signin/options then …/signin/finish (register paths under webauthn/register/*).', |
| 579 | 'retired: /v1/auth-tenant/* returns 410 — do not wire new apps to it.', |
| 580 | 'every Origin must be under Auth → Allowed Domains; if CORS already allows your origin, do not blame domains for other errors.', |
| 581 | 'keys: pk_briven_auth_… in the browser only; never brk_.', |
| 582 | ], |
| 583 | docs: DOCS.auth, |
| 584 | }, |
| 585 | }); |
| 586 | }, |
| 587 | ); |
| 588 | |
| 589 | server.registerTool( |
| 590 | 'sender_domain_status', |
| 591 | { |
| 592 | title: 'Sender domain verification status', |
| 593 | description: |
| 594 | 'Check YOUR project\'s email sender domain: is one set, is it verified with ' + |
| 595 | 'briven\'s email infrastructure, and exactly which DNS records (with live ' + |
| 596 | 'per-record status) still need to be added at the domain host. Also explains ' + |
| 597 | 'which From: address your auth emails use today.', |
| 598 | annotations: { readOnlyHint: true }, |
| 599 | }, |
| 600 | async () => { |
| 601 | await auditCall('sender_domain_status', {}); |
| 602 | const config = await getAuthConfig(ctx.projectId); |
| 603 | const senderDomain = config.branding.senderDomain; |
| 604 | |
| 605 | if (!senderDomain) { |
| 606 | return jsonResult({ |
| 607 | senderDomain: null, |
| 608 | fromAddressToday: resolveFallbackFromAddress(config), |
| 609 | verification: null, |
| 610 | guidance: { |
| 611 | summary: |
| 612 | 'no sender domain is set — auth emails send from the briven fallback, which ' + |
| 613 | 'is fully functional. set a domain only when you want branded From: addresses.', |
| 614 | applyInYourProject: [ |
| 615 | 'nothing required. to brand emails, the project owner fills sender domain (root domain, e.g. yourapp.com) under dashboard → auth → branding, then re-runs this tool for the DNS records.', |
| 616 | ], |
| 617 | docs: DOCS.senderDomain, |
| 618 | }, |
| 619 | }); |
| 620 | } |
| 621 | |
| 622 | const status = await fetchDomainStatus(senderDomain); |
| 623 | const verified = status.available === true && status.verified; |
| 624 | const pendingRecords = |
| 625 | status.available === true |
| 626 | ? status.dnsRecords.filter((r) => r.status !== 'SUCCESS') |
| 627 | : []; |
| 628 | |
| 629 | return jsonResult({ |
| 630 | senderDomain, |
| 631 | fromAddressToday: verified ? resolveFromAddress(config) : resolveFallbackFromAddress(config), |
| 632 | fromAddressWhenVerified: resolveFromAddress(config), |
| 633 | verification: status, |
| 634 | guidance: { |
| 635 | summary: verified |
| 636 | ? 'domain verified — auth emails send as your branded From: address. nothing to do.' |
| 637 | : status.available && !status.registered |
| 638 | ? 'the domain is set in branding but not yet registered with briven\'s email ' + |
| 639 | 'infrastructure. registration is platform-side: request it via an owner ' + |
| 640 | 'handoff. until then emails send from the briven fallback — sign-in keeps working.' |
| 641 | : 'the domain is set but not fully verified. add the DNS records listed under ' + |
| 642 | 'verification.dnsRecords (those not marked SUCCESS) at the domain host, then ' + |
| 643 | 're-run this tool. until verified, emails send from the briven fallback — ' + |
| 644 | 'sign-in keeps working.', |
| 645 | applyInYourProject: [ |
| 646 | 'do NOT clear the sender domain to "fix" email — the automatic fallback already keeps sign-in working.', |
| 647 | pendingRecords.length > 0 |
| 648 | ? `add these ${pendingRecords.length} DNS record(s) at the ${senderDomain} DNS host exactly as given, then allow up to an hour for propagation.` |
| 649 | : 'no DNS action derivable right now — see verification.available/summary for the reason.', |
| 650 | 'never modify the briven platform from your project session; platform-side steps go through an owner handoff.', |
| 651 | ], |
| 652 | docs: DOCS.senderDomain, |
| 653 | }, |
| 654 | }); |
| 655 | }, |
| 656 | ); |
| 657 | |
| 658 | server.registerTool( |
| 659 | 'auth_docs_ask', |
| 660 | { |
| 661 | title: 'Ask about briven auth (guidance)', |
| 662 | description: |
| 663 | 'Ask a free-form question about briven auth / sign-in emails / sender domains / ' + |
| 664 | 'keys / sessions. Returns curated guidance: the briven-side facts, concrete ' + |
| 665 | '"apply in your project" steps for YOUR codebase, and the official docs page. ' + |
| 666 | 'Read-only; grounded in briven\'s documented behavior, not speculation.', |
| 667 | inputSchema: { |
| 668 | question: z.string().min(3).max(500).describe('Your question in plain words'), |
| 669 | }, |
| 670 | annotations: { readOnlyHint: true }, |
| 671 | }, |
| 672 | async ({ question }) => { |
| 673 | await auditCall('auth_docs_ask', { length: question.length }); |
| 674 | const matches = matchAuthGuidance(question); |
| 675 | if (matches.length === 0) { |
| 676 | return jsonResult({ |
| 677 | matches: [], |
| 678 | guidance: { |
| 679 | summary: |
| 680 | 'no curated answer matched. read the auth docs page — it covers install, keys, ' + |
| 681 | 'flows, sender domain, and security — or rephrase with words like "sender ' + |
| 682 | 'domain", "magic link", "keys", "session".', |
| 683 | docs: DOCS.auth, |
| 684 | }, |
| 685 | }); |
| 686 | } |
| 687 | return jsonResult({ |
| 688 | matches: matches.map((m) => ({ |
| 689 | topic: m.topic, |
| 690 | answer: m.answer, |
| 691 | applyInYourProject: m.applyInYourProject, |
| 692 | docs: m.docs, |
| 693 | })), |
| 694 | note: |
| 695 | 'answers are curated and cite the official docs. for live state, combine with ' + |
| 696 | 'auth_config_get and sender_domain_status. write-scope keys may call ' + |
| 697 | 'auth_enable_passwordless and auth_mint_public_key.', |
| 698 | }); |
| 699 | }, |
| 700 | ); |
| 701 | |
| 702 | if (opts?.allowWrites) { |
| 703 | server.registerTool( |
| 704 | 'auth_enable_passwordless', |
| 705 | { |
| 706 | title: 'Enable magic link + email OTP + passkey', |
| 707 | description: |
| 708 | 'Enable passwordless sign-in for THIS project: magic link, email OTP, and passkey. ' + |
| 709 | 'Also ensures Auth is provisioned (tables + auth_enabled). Does NOT set OAuth secrets. ' + |
| 710 | 'Write-scope MCP keys only. Call auth_config_get after to verify.', |
| 711 | annotations: { readOnlyHint: false }, |
| 712 | }, |
| 713 | async () => { |
| 714 | await auditCall('auth_enable_passwordless', {}); |
| 715 | const projectId = ctx.projectId; |
| 716 | // Provision tables if needed (idempotent). |
| 717 | const statements = renderAuthProvisioningSql(); |
| 718 | await runInProjectDatabase(projectId, async (tx) => { |
| 719 | for (const stmt of statements) { |
| 720 | await tx.unsafe(stmt); |
| 721 | } |
| 722 | const txClient = { |
| 723 | query: async (sql: string, params?: unknown[]) => { |
| 724 | const rows = await tx.unsafe(sql, (params ?? []) as never); |
| 725 | return { rows: Array.isArray(rows) ? rows : [] }; |
| 726 | }, |
| 727 | }; |
| 728 | await ensureTenantAuthSchema(txClient); |
| 729 | await tx.unsafe( |
| 730 | `INSERT INTO "_briven_meta" (key, value) |
| 731 | VALUES ('auth_enabled', 'true'::jsonb) |
| 732 | ON CONFLICT (key) DO NOTHING`, |
| 733 | ); |
| 734 | await tx.unsafe( |
| 735 | `UPDATE "_briven_meta" SET value = 'true'::jsonb WHERE key = 'auth_enabled'`, |
| 736 | ); |
| 737 | }); |
| 738 | const next = await updateAuthConfig(projectId, { |
| 739 | providers: { |
| 740 | magicLink: { enabled: true }, |
| 741 | emailOtp: { enabled: true }, |
| 742 | passkey: { enabled: true }, |
| 743 | }, |
| 744 | }); |
| 745 | await invalidateAuthInstance(projectId); |
| 746 | return jsonResult({ |
| 747 | ok: true, |
| 748 | authEnabled: true, |
| 749 | providers: { |
| 750 | magicLink: next.providers.magicLink, |
| 751 | emailOtp: next.providers.emailOtp, |
| 752 | passkey: next.providers.passkey, |
| 753 | }, |
| 754 | guidance: { |
| 755 | summary: |
| 756 | 'passwordless providers are ON. mint a browser key with auth_mint_public_key if ' + |
| 757 | 'you do not already have pk_briven_auth_…, register Allowed Domains, then wire ' + |
| 758 | 'briven-engine FDI (POST /v1/auth-core/fdi/signinup/code + consume) preferably via a first-party proxy.', |
| 759 | applyInYourProject: [ |
| 760 | 'set NEXT_PUBLIC_BRIVEN_AUTH_KEY / BRIVEN_AUTH_PUBLIC_KEY to a read-write pk_briven_auth_… key when using publishable-key flows.', |
| 761 | 'OTP/magic: POST /v1/auth-core/fdi/signinup/code then …/code/consume; session: GET /v1/auth-core/session/me.', |
| 762 | 'passkey: POST /v1/auth-core/fdi/webauthn/signin/options then …/signin/finish.', |
| 763 | 'do not call retired /v1/auth-tenant/* (410).', |
| 764 | ], |
| 765 | docs: DOCS.auth, |
| 766 | }, |
| 767 | }); |
| 768 | }, |
| 769 | ); |
| 770 | |
| 771 | server.registerTool( |
| 772 | 'auth_mint_public_key', |
| 773 | { |
| 774 | title: 'Mint pk_briven_auth_ public key', |
| 775 | description: |
| 776 | 'Create a browser-safe Auth public key (pk_briven_auth_…) for THIS project. ' + |
| 777 | 'Returns the plaintext ONCE — store it in the app env immediately. Write-scope only. ' + |
| 778 | 'Default scope is read-write (sign-in surface).', |
| 779 | inputSchema: { |
| 780 | name: z |
| 781 | .string() |
| 782 | .min(1) |
| 783 | .max(64) |
| 784 | .default('mcp-minted') |
| 785 | .describe('Label for the key in the dashboard'), |
| 786 | scope: z |
| 787 | .enum(['read', 'read-write', 'admin']) |
| 788 | .default('read-write') |
| 789 | .describe('Auth SDK key scope'), |
| 790 | }, |
| 791 | annotations: { readOnlyHint: false }, |
| 792 | }, |
| 793 | async ({ name, scope }) => { |
| 794 | await auditCall('auth_mint_public_key', { name, scope }); |
| 795 | const created = await createAuthSdkKey({ |
| 796 | projectId: ctx.projectId, |
| 797 | createdBy: `mcp:${ctx.projectId}`, |
| 798 | name, |
| 799 | scope, |
| 800 | }); |
| 801 | return jsonResult({ |
| 802 | ok: true, |
| 803 | key: { |
| 804 | id: created.record.id, |
| 805 | name: created.record.name, |
| 806 | prefix: created.record.prefix, |
| 807 | suffix: created.record.suffix, |
| 808 | scope: created.record.scope, |
| 809 | createdAt: created.record.createdAt.toISOString(), |
| 810 | }, |
| 811 | plaintext: created.plaintext, |
| 812 | guidance: { |
| 813 | summary: |
| 814 | 'copy plaintext into NEXT_PUBLIC_BRIVEN_AUTH_KEY and BRIVEN_AUTH_PUBLIC_KEY now — it is not shown again.', |
| 815 | applyInYourProject: [ |
| 816 | 'never put brk_ or MCP keys in the browser; only this pk_briven_auth_… value.', |
| 817 | ], |
| 818 | docs: DOCS.keys, |
| 819 | }, |
| 820 | }); |
| 821 | }, |
| 822 | ); |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | /** Read tools — every MCP scope. */ |
| 827 | export const AUTH_BRIDGE_TOOLS = [ |
| 828 | 'auth_config_get', |
| 829 | 'sender_domain_status', |
| 830 | 'auth_docs_ask', |
| 831 | ] as const; |
| 832 | |
| 833 | /** Write tools — read-write / admin MCP keys only. */ |
| 834 | export const AUTH_BRIDGE_WRITE_TOOLS = [ |
| 835 | 'auth_enable_passwordless', |
| 836 | 'auth_mint_public_key', |
| 837 | ] as const; |