setup.ts339 lines · main
1/**
2 * Golden-path Auth setup for one project — status + one-click finish.
3 */
4
5import { createAuthSdkKey, listAuthSdkKeysForProject } from '../auth-sdk-keys.js';
6import {
7 addBrivenEngineAppOrigins,
8 getBrivenEngineAppOrigins,
9 getBrivenEngineProjectConfig,
10 setBrivenEngineMethodFlags,
11 type BrivenEngineMethodFlags,
12} from './project-config.js';
13import { enableBrivenEngineAuth, isBrivenEngineAuthEnabled } from './workspace.js';
14import { recordBrivenEngineAudit } from './audit.js';
15import { env } from '../../env.js';
16
17const STARTER_METHODS: BrivenEngineMethodFlags = {
18 emailPassword: true,
19 passwordlessEmail: true,
20 magicLink: true,
21 passwordlessSms: false,
22 passkeys: true,
23 mfa: false,
24};
25
26export type SetupStepId =
27 | 'auth_on'
28 | 'core_methods'
29 | 'public_key'
30 | 'app_origin'
31 | 'proxy';
32
33export type SetupStep = {
34 id: SetupStepId;
35 label: string;
36 ok: boolean;
37 detail: string;
38 href?: string;
39};
40
41export type SetupStatus = {
42 engine: 'briven-engine';
43 projectId: string;
44 complete: boolean;
45 steps: SetupStep[];
46 appOrigins: string[];
47 methods: BrivenEngineMethodFlags;
48 activeKeyCount: number;
49 apiOrigin: string;
50 /** Snippet for first-party proxy (Next.js style). */
51 proxySnippet: string;
52};
53
54function coreMethodsOn(m: BrivenEngineMethodFlags): boolean {
55 return (
56 m.emailPassword === true &&
57 m.passwordlessEmail === true &&
58 m.magicLink === true &&
59 m.passkeys === true
60 );
61}
62
63/**
64 * True when a proxy response clearly came from briven-engine (even on 404 —
65 * FDI returns auth_core_fdi_partial + engine for unknown paths). Hard 404 HTML
66 * from Next with no engine body does NOT count.
67 */
68function bodyLooksLikeBrivenAuth(text: string): boolean {
69 return (
70 text.includes('briven-engine') ||
71 text.includes('auth_core') ||
72 text.includes('auth_core_fdi') ||
73 text.includes('"status":"BAD_REQUEST"') ||
74 text.includes('"status": "BAD_REQUEST"')
75 );
76}
77
78/**
79 * Probe whether the app host has a first-party auth proxy.
80 * Soft check — network failures = not ok, not a hard error.
81 *
82 * Real apps (e.g. Mavi) mount FDI at `/api/auth/*` → `/v1/auth-core/fdi/*`.
83 * Probing only `/api/auth/v1/auth-core/info` was wrong: that path 404s on FDI
84 * even when the proxy works, so the checklist never turned green.
85 */
86async function probeProxy(origin: string): Promise<{
87 ok: boolean;
88 detail: string;
89}> {
90 const base = origin.replace(/\/$/, '');
91 // Prefer the FDI path real apps use; keep legacy candidates as fallback.
92 const candidates: Array<{ url: string; method: 'GET' | 'POST'; body?: string }> = [
93 {
94 url: `${base}/api/auth/signinup/code`,
95 method: 'POST',
96 body: '{}',
97 },
98 { url: `${base}/api/auth/session`, method: 'GET' },
99 { url: `${base}/api/auth/v1/auth-core/info`, method: 'GET' },
100 { url: `${base}/api/auth/v1/auth-core/ready`, method: 'GET' },
101 ];
102 for (const c of candidates) {
103 try {
104 const ctrl = new AbortController();
105 const t = setTimeout(() => ctrl.abort(), 4000);
106 const res = await fetch(c.url, {
107 method: c.method,
108 signal: ctrl.signal,
109 redirect: 'manual',
110 headers: {
111 accept: 'application/json',
112 ...(c.method === 'POST'
113 ? {
114 'content-type': 'application/json',
115 rid: 'passwordless',
116 'fdi-version': '1.19',
117 }
118 : {}),
119 },
120 body: c.method === 'POST' ? (c.body ?? '{}') : undefined,
121 });
122 clearTimeout(t);
123 if (res.status < 200 || res.status >= 600) continue;
124 const text = await res.text().catch(() => '');
125 // Engine JSON = proxy is live (status can be 200/400/404 FDI partial).
126 if (bodyLooksLikeBrivenAuth(text)) {
127 return {
128 ok: true,
129 detail: `proxy answered at ${c.url.replace(base, '')} (${res.status})`,
130 };
131 }
132 // Non-404 without engine body: path exists, may still be wiring.
133 if (res.status !== 404 && res.status < 500) {
134 return {
135 ok: true,
136 detail: `proxy path reachable (${res.status}) — finish wiring if login fails`,
137 };
138 }
139 } catch {
140 // try next
141 }
142 }
143 return {
144 ok: false,
145 detail:
146 'add /api/auth proxy on your app so cookies stay on your domain (see snippet below)',
147 };
148}
149
150export async function getAuthSetupStatus(
151 projectId: string,
152): Promise<SetupStatus> {
153 const authOn = await isBrivenEngineAuthEnabled(projectId);
154 const config = authOn
155 ? await getBrivenEngineProjectConfig(projectId)
156 : null;
157 const methods = config?.methods ?? STARTER_METHODS;
158 const appOrigins =
159 config?.appOrigins ?? (await getBrivenEngineAppOrigins(projectId));
160 let activeKeyCount = 0;
161 try {
162 const keys = await listAuthSdkKeysForProject(projectId);
163 activeKeyCount = keys.filter((k) => !k.revokedAt).length;
164 } catch {
165 activeKeyCount = 0;
166 }
167
168 const methodsOk = coreMethodsOn(methods);
169 const originOk = appOrigins.length > 0;
170
171 let proxyOk = false;
172 let proxyDetail =
173 'add an app origin first, then we check for /api/auth on your site';
174 if (originOk) {
175 const prod = appOrigins.find(
176 (o) => !o.includes('localhost') && !o.includes('127.0.0.1'),
177 );
178 const probeTarget = prod ?? appOrigins[0]!;
179 if (probeTarget.includes('localhost') || probeTarget.includes('127.0.0.1')) {
180 // Cannot probe operator laptop from France API — treat origin as enough for local.
181 proxyOk = true;
182 proxyDetail =
183 'local origin only — run the proxy snippet on localhost when you start the app';
184 } else {
185 const probe = await probeProxy(probeTarget);
186 proxyOk = probe.ok;
187 proxyDetail = probe.detail;
188 }
189 }
190
191 const steps: SetupStep[] = [
192 {
193 id: 'auth_on',
194 label: 'Auth on',
195 ok: authOn,
196 detail: authOn
197 ? 'tenant ready on briven-engine'
198 : 'turn Auth on for this project',
199 },
200 {
201 id: 'core_methods',
202 label: 'Core sign-in methods',
203 ok: methodsOk,
204 detail: methodsOk
205 ? 'password · magic link · email OTP · passkeys'
206 : 'enable password, magic link, email OTP, passkeys',
207 href: `/dashboard/auth/${projectId}/providers`,
208 },
209 {
210 id: 'public_key',
211 label: 'Browser public key',
212 ok: activeKeyCount > 0,
213 detail:
214 activeKeyCount > 0
215 ? `${activeKeyCount} key(s) ready`
216 : 'mint a pk_briven_auth_… key for the app',
217 href: `/dashboard/auth/${projectId}/keys`,
218 },
219 {
220 id: 'app_origin',
221 label: 'App domain / origin',
222 ok: originOk,
223 detail: originOk
224 ? appOrigins.join(', ')
225 : 'add http://localhost:3000 and your live app URL',
226 },
227 {
228 id: 'proxy',
229 label: 'First-party proxy',
230 ok: proxyOk,
231 detail: proxyDetail,
232 },
233 ];
234
235 const complete = steps.every((s) => s.ok);
236 const proxySnippet = `// App route or middleware: browser → YOUR /api/auth/* → Briven FDI
237// (Mavi-style) destination: ${env.BRIVEN_API_ORIGIN}/v1/auth-core/fdi/:path*
238// Browser calls same-origin /api/auth/... so cookies stay on YOUR domain.
239
240// next.config rewrite example:
241async rewrites() {
242 return [
243 {
244 source: '/api/auth/:path*',
245 destination: '${env.BRIVEN_API_ORIGIN}/v1/auth-core/fdi/:path*',
246 },
247 ];
248}`;
249
250 return {
251 engine: 'briven-engine',
252 projectId,
253 complete,
254 steps,
255 appOrigins,
256 methods,
257 activeKeyCount,
258 apiOrigin: env.BRIVEN_API_ORIGIN,
259 proxySnippet,
260 };
261}
262
263export type SetupFinishResult = {
264 ok: true;
265 engine: 'briven-engine';
266 projectId: string;
267 status: SetupStatus;
268 /** Shown once if a new key was minted. */
269 mintedKeyPlaintext: string | null;
270 actions: string[];
271};
272
273/**
274 * One-click safe defaults: enable Auth, starter methods, localhost origin,
275 * mint a browser key if none exists. Optional production origin from body.
276 */
277export async function finishAuthSetup(
278 projectId: string,
279 opts: {
280 userId: string;
281 productionOrigin?: string | null;
282 },
283): Promise<SetupFinishResult> {
284 const actions: string[] = [];
285
286 const enable = await enableBrivenEngineAuth(projectId);
287 if (enable.ok) {
288 actions.push(enable.created ? 'enabled Auth' : 'Auth already on');
289 } else {
290 throw new Error(enable.message ?? 'could not enable Auth');
291 }
292
293 await setBrivenEngineMethodFlags(projectId, STARTER_METHODS, opts.userId);
294 actions.push('core methods on (password, magic, OTP, passkeys)');
295
296 const originsToAdd = ['http://localhost:3000'];
297 if (opts.productionOrigin?.trim()) {
298 originsToAdd.push(opts.productionOrigin.trim());
299 }
300 const { appOrigins } = await addBrivenEngineAppOrigins(
301 projectId,
302 originsToAdd,
303 opts.userId,
304 );
305 actions.push(`app origins: ${appOrigins.join(', ') || 'none'}`);
306
307 let mintedKeyPlaintext: string | null = null;
308 const keys = await listAuthSdkKeysForProject(projectId);
309 const active = keys.filter((k) => !k.revokedAt);
310 if (active.length === 0) {
311 const created = await createAuthSdkKey({
312 projectId,
313 createdBy: opts.userId,
314 name: 'browser',
315 scope: 'read-write',
316 });
317 mintedKeyPlaintext = created.plaintext;
318 actions.push('minted browser public key (copy once)');
319 } else {
320 actions.push('public key already present');
321 }
322
323 void recordBrivenEngineAudit({
324 action: 'setup.finish',
325 projectId,
326 userId: opts.userId,
327 metadata: { actions },
328 });
329
330 const status = await getAuthSetupStatus(projectId);
331 return {
332 ok: true,
333 engine: 'briven-engine',
334 projectId,
335 status,
336 mintedKeyPlaintext,
337 actions,
338 };
339}