mcp-auth-bridge.ts837 lines · main
1import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2import { z } from 'zod';
3
4import { runInProjectDatabase } from '../db/data-plane.js';
5import { env } from '../env.js';
6import { log } from '../lib/logger.js';
7import { resolveFallbackFromAddress, resolveFromAddress } from './auth-mailer.js';
8import {
9 ensureTenantAuthSchema,
10 renderAuthProvisioningSql,
11} from './auth-provisioning.js';
12import { createAuthSdkKey } from './auth-sdk-keys.js';
13import { invalidateAuthInstance } from './auth-tenant-pool.js';
14import {
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
37const 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 */
58export 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
90interface 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
100type 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 */
115async 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
162export 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
173export 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
510export 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 */
521export 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 */
540export 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. */
827export 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. */
834export const AUTH_BRIDGE_WRITE_TOOLS = [
835 'auth_enable_passwordless',
836 'auth_mint_public_key',
837] as const;