@agent-native/core 0.31.2 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/client/AssistantChat.d.ts.map +1 -1
  2. package/dist/client/AssistantChat.js +7 -0
  3. package/dist/client/AssistantChat.js.map +1 -1
  4. package/dist/client/MultiTabAssistantChat.js +5 -5
  5. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  6. package/dist/client/use-chat-threads.d.ts.map +1 -1
  7. package/dist/client/use-chat-threads.js +8 -1
  8. package/dist/client/use-chat-threads.js.map +1 -1
  9. package/dist/deploy/build.d.ts +2 -1
  10. package/dist/deploy/build.d.ts.map +1 -1
  11. package/dist/deploy/build.js +104 -42
  12. package/dist/deploy/build.js.map +1 -1
  13. package/dist/deploy/immutable-assets.d.ts.map +1 -1
  14. package/dist/deploy/immutable-assets.js +5 -0
  15. package/dist/deploy/immutable-assets.js.map +1 -1
  16. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  17. package/dist/server/core-routes-plugin.js +1719 -1685
  18. package/dist/server/core-routes-plugin.js.map +1 -1
  19. package/dist/server/framework-request-handler.js +8 -6
  20. package/dist/server/framework-request-handler.js.map +1 -1
  21. package/dist/server/index.d.ts +1 -1
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +1 -1
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/request-context.d.ts +8 -0
  26. package/dist/server/request-context.d.ts.map +1 -1
  27. package/dist/server/request-context.js +25 -4
  28. package/dist/server/request-context.js.map +1 -1
  29. package/dist/server/ssr-handler.d.ts +1 -1
  30. package/dist/server/ssr-handler.d.ts.map +1 -1
  31. package/dist/server/ssr-handler.js +54 -15
  32. package/dist/server/ssr-handler.js.map +1 -1
  33. package/dist/shared/cache-control.d.ts +6 -0
  34. package/dist/shared/cache-control.d.ts.map +1 -1
  35. package/dist/shared/cache-control.js +6 -0
  36. package/dist/shared/cache-control.js.map +1 -1
  37. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { getH3App, awaitBootstrap, markDefaultPluginProvided, } from "./framework-request-handler.js";
1
+ import { getH3App, awaitBootstrap, markDefaultPluginProvided, trackPluginInit, } from "./framework-request-handler.js";
2
2
  import { getAllowedCorsOrigin, readCorsAllowedOrigins, } from "./cors-origins.js";
3
3
  import { defineEventHandler, setResponseStatus, setResponseHeader, getMethod, getHeader, getCookie, setCookie, deleteCookie, getRequestURL, } from "h3";
4
4
  import path from "node:path";
@@ -47,6 +47,7 @@ import { isEnvVarWriteAllowed } from "./env-var-writes.js";
47
47
  import { llmConnectionTrackingProperties } from "../shared/llm-connection.js";
48
48
  import { mountBrowserSessionRoutes } from "../browser-sessions/routes.js";
49
49
  import { mountDbAdminRoutes } from "../db-admin/routes.js";
50
+ import { DEFAULT_SSR_CACHE_CONTROL, EMPTY_SPECULATION_RULES, } from "../shared/cache-control.js";
50
51
  /**
51
52
  * The base path prefix for all framework-level routes.
52
53
  * All agent-native core routes live under this namespace to avoid
@@ -251,292 +252,343 @@ export function createCoreRoutesPlugin(options = {}) {
251
252
  markDefaultPluginProvided(nitroApp, "core-routes");
252
253
  // No-op when called from inside the bootstrap (auto-mount path).
253
254
  // Otherwise wait so other default plugins finish mounting first.
254
- await awaitBootstrap(nitroApp);
255
- // Restore env vars from the settings table. On serverless, .env
256
- // writes don't persist across invocations the DB is the durable
257
- // store. Only set keys that are currently empty so explicit env
258
- // vars (Netlify dashboard, process-level) always win.
259
- //
260
- // GATED: only rehydrate into `process.env` on local-dev SQLite (or
261
- // with the explicit single-tenant opt-in). On a shared-DB hosted
262
- // multi-tenant deploy the `persisted-env-vars` row is deployment-wide
263
- // global state — pushing user-supplied values into `process.env` from
264
- // it would let any one tenant's writes (or a stale dev seed) leak
265
- // into every other tenant's process. The opt-out scrub of legacy
266
- // BUILDER_* values still runs unconditionally so existing rows on
267
- // multi-tenant deploys self-heal, but new env-var writes never land
268
- // in `process.env` outside the allowed contexts.
255
+ let resolveInit = () => { };
256
+ let rejectInit = () => { };
257
+ const initPromise = new Promise((resolve, reject) => {
258
+ resolveInit = resolve;
259
+ rejectInit = reject;
260
+ });
261
+ trackPluginInit(nitroApp, initPromise, {
262
+ paths: [FRAMEWORK_ROUTE_PREFIX, "/.well-known"],
263
+ });
269
264
  try {
270
- const persisted = (await getSetting("persisted-env-vars"));
271
- if (persisted) {
272
- const builderKeys = new Set(BUILDER_ENV_KEYS);
273
- const writesAllowed = isEnvVarWriteAllowed();
274
- let scrubbed = 0;
275
- for (const [k, v] of Object.entries(persisted)) {
276
- if (builderKeys.has(k)) {
277
- scrubbed++;
278
- continue;
279
- }
280
- if (writesAllowed && typeof v === "string" && !process.env[k]) {
281
- process.env[k] = v;
282
- }
283
- }
284
- if (scrubbed > 0) {
285
- try {
286
- const cleaned = {};
287
- for (const [k, v] of Object.entries(persisted)) {
288
- if (!builderKeys.has(k))
289
- cleaned[k] = v;
265
+ await awaitBootstrap(nitroApp);
266
+ // Restore env vars from the settings table. On serverless, .env
267
+ // writes don't persist across invocations — the DB is the durable
268
+ // store. Only set keys that are currently empty so explicit env
269
+ // vars (Netlify dashboard, process-level) always win.
270
+ //
271
+ // GATED: only rehydrate into `process.env` on local-dev SQLite (or
272
+ // with the explicit single-tenant opt-in). On a shared-DB hosted
273
+ // multi-tenant deploy the `persisted-env-vars` row is deployment-wide
274
+ // global state — pushing user-supplied values into `process.env` from
275
+ // it would let any one tenant's writes (or a stale dev seed) leak
276
+ // into every other tenant's process. The opt-out scrub of legacy
277
+ // BUILDER_* values still runs unconditionally so existing rows on
278
+ // multi-tenant deploys self-heal, but new env-var writes never land
279
+ // in `process.env` outside the allowed contexts.
280
+ try {
281
+ const persisted = (await getSetting("persisted-env-vars"));
282
+ if (persisted) {
283
+ const builderKeys = new Set(BUILDER_ENV_KEYS);
284
+ const writesAllowed = isEnvVarWriteAllowed();
285
+ let scrubbed = 0;
286
+ for (const [k, v] of Object.entries(persisted)) {
287
+ if (builderKeys.has(k)) {
288
+ scrubbed++;
289
+ continue;
290
+ }
291
+ if (writesAllowed && typeof v === "string" && !process.env[k]) {
292
+ process.env[k] = v;
290
293
  }
291
- await putSetting("persisted-env-vars", cleaned);
292
- console.warn(`[core] Removed ${scrubbed} legacy BUILDER_* key(s) from persisted-env-vars (cross-tenant leak fix).`);
293
294
  }
294
- catch {
295
- // Couldn't rewrite the row — the skip-on-rehydrate above
296
- // is the load-bearing protection. We'll try again next boot.
295
+ if (scrubbed > 0) {
296
+ try {
297
+ const cleaned = {};
298
+ for (const [k, v] of Object.entries(persisted)) {
299
+ if (!builderKeys.has(k))
300
+ cleaned[k] = v;
301
+ }
302
+ await putSetting("persisted-env-vars", cleaned);
303
+ console.warn(`[core] Removed ${scrubbed} legacy BUILDER_* key(s) from persisted-env-vars (cross-tenant leak fix).`);
304
+ }
305
+ catch {
306
+ // Couldn't rewrite the row — the skip-on-rehydrate above
307
+ // is the load-bearing protection. We'll try again next boot.
308
+ }
297
309
  }
298
310
  }
299
311
  }
300
- }
301
- catch {
302
- // DB not ready yet — skip
303
- }
304
- // Honor Builder disconnect. Nitro's dev env-runner preserves
305
- // `process.env` across `.env` file reloads inside the same worker, so
306
- // deleting BUILDER_PRIVATE_KEY in the disconnect handler can bleed
307
- // back through an env-runner restart. We persist a
308
- // `builder-disconnected` flag in SQL and scrub BUILDER_* on every
309
- // plugin init while the flag is set. The flag is cleared by the
310
- // Builder cli-auth callback when the user re-connects.
311
- try {
312
- const disconnected = (await getSetting("builder-disconnected"));
313
- if (disconnected) {
314
- for (const key of BUILDER_ENV_KEYS) {
315
- delete process.env[key];
316
- }
317
- }
318
- }
319
- catch {
320
- // DB not ready — skip; the disconnect flag will be enforced on the
321
- // next plugin boot once the settings table is reachable.
322
- }
323
- // Register framework-level secrets (OPENAI_API_KEY for composer voice
324
- // transcription, etc.). Each registration is guarded so templates that
325
- // already registered the same key win.
326
- registerFrameworkSecrets();
327
- registerBuiltinProviders();
328
- registerBuiltinNotificationChannels();
329
- try {
330
- const { createObservabilityHandler } = await import("../observability/routes.js");
331
- const { ensureObservabilityTables } = await import("../observability/store.js");
332
- ensureObservabilityTables().catch(() => { });
333
- getH3App(nitroApp).use(`${FRAMEWORK_ROUTE_PREFIX}/observability`, createObservabilityHandler());
334
- }
335
- catch {
336
- // Observability module not available — skip
337
- }
338
- const P = FRAMEWORK_ROUTE_PREFIX;
339
- // Security response headers — emitted on every framework response.
340
- // Mounted before route handlers so 4xx/5xx error pages also carry the
341
- // headers. Routes that need to relax a specific header (e.g. the tools
342
- // /render route allowing same-origin framing) override via setResponseHeader.
343
- const { createSecurityHeadersMiddleware } = await import("./security-headers.js");
344
- getH3App(nitroApp).use(createSecurityHeadersMiddleware());
345
- // CORS for framework routes. Desktop tray apps (Tauri/Electron) run on
346
- // their own dev origin (e.g. localhost:1420) and make credentialed
347
- // requests against the template's server at a different port. We echo
348
- // the exact origin + Allow-Credentials so same-site localhost ports
349
- // can cross-send cookies.
350
- const allowlist = readCorsAllowedOrigins();
351
- getH3App(nitroApp).use(defineEventHandler((event) => {
352
- const pathname = stripAppBasePath(event.url?.pathname ??
353
- String(event.node?.req?.url ?? event.path ?? "/").split("?")[0]);
354
- if (!pathname.startsWith(P) && !pathname.startsWith("/api/"))
355
- return;
356
- const readRequestHeader = (name) => {
357
- const lower = name.toLowerCase();
358
- const raw = event.node?.req?.headers?.[lower] ??
359
- event.node?.req?.headers?.[name];
360
- if (Array.isArray(raw))
361
- return raw[0];
362
- if (typeof raw === "string")
363
- return raw;
364
- return getHeader(event, name) ?? undefined;
365
- };
366
- const origin = readRequestHeader("origin");
367
- const method = getMethod(event);
368
- const requestedHeaders = readRequestHeader("access-control-request-headers");
369
- const requestedHeaderNames = String(requestedHeaders ?? "")
370
- .toLowerCase()
371
- .split(",")
372
- .map((header) => header.trim());
373
- const mcpEmbedCorsRequest = isMcpEmbedCorsOrigin(origin) &&
374
- (requestedHeaderNames.includes(EMBED_TARGET_HEADER.toLowerCase()) ||
375
- requestedHeaderNames.includes(EMBED_TRANSPLANT_HEADER) ||
376
- Boolean(readRequestHeader(EMBED_TARGET_HEADER)) ||
377
- Boolean(readRequestHeader(EMBED_TRANSPLANT_HEADER)) ||
378
- Boolean(readRequestHeader("authorization")));
379
- // Decide whether this origin is allowed. We never fall back to the
380
- // first allowlist entry — that previously echoed `Access-Control-
381
- // Allow-Origin: <unrelated-allowed-origin>` for disallowed callers,
382
- // which is permissive enough that some clients followed through.
383
- const allowedOrigin = mcpEmbedCorsRequest
384
- ? origin
385
- : getAllowedCorsOrigin(origin, {
386
- allowedOrigins: allowlist,
387
- allowAnyOriginWhenNoAllowlist: false,
388
- allowLocalhostWhenNoAllowlist: true,
389
- });
390
- // Reject preflights from disallowed cross-origin callers BEFORE
391
- // returning 204. Previously the OPTIONS short-circuit returned 204
392
- // with no ACAO header, which the browser then treats as a CORS
393
- // failure — but also short-circuited any further checks. Now we
394
- // explicitly 403 disallowed cross-origin preflights.
395
- if (method === "OPTIONS") {
396
- if (origin && !allowedOrigin) {
397
- setResponseStatus(event, 403);
398
- return "";
399
- }
400
- if (allowedOrigin) {
401
- setResponseHeader(event, "Access-Control-Allow-Origin", allowedOrigin);
402
- setResponseHeader(event, "Vary", "Origin");
403
- if (shouldAllowMcpEmbedCredentials(allowedOrigin)) {
404
- setResponseHeader(event, "Access-Control-Allow-Credentials", "true");
312
+ catch {
313
+ // DB not ready yet — skip
314
+ }
315
+ // Honor Builder disconnect. Nitro's dev env-runner preserves
316
+ // `process.env` across `.env` file reloads inside the same worker, so
317
+ // deleting BUILDER_PRIVATE_KEY in the disconnect handler can bleed
318
+ // back through an env-runner restart. We persist a
319
+ // `builder-disconnected` flag in SQL and scrub BUILDER_* on every
320
+ // plugin init while the flag is set. The flag is cleared by the
321
+ // Builder cli-auth callback when the user re-connects.
322
+ try {
323
+ const disconnected = (await getSetting("builder-disconnected"));
324
+ if (disconnected) {
325
+ for (const key of BUILDER_ENV_KEYS) {
326
+ delete process.env[key];
405
327
  }
406
- setResponseHeader(event, "Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS");
407
- setResponseHeader(event, "Access-Control-Allow-Headers", MCP_EMBED_CORS_ALLOW_HEADERS);
408
328
  }
409
- setResponseStatus(event, 204);
410
- return "";
411
- }
412
- // Non-preflight requests: only set CORS response headers when we
413
- // have an allowed origin. Same-origin / no-origin requests fall
414
- // through without explicit CORS headers (browser treats them as
415
- // same-origin by default).
416
- if (!allowedOrigin)
417
- return;
418
- setResponseHeader(event, "Access-Control-Allow-Origin", allowedOrigin);
419
- setResponseHeader(event, "Vary", "Origin");
420
- if (shouldAllowMcpEmbedCredentials(allowedOrigin)) {
421
- setResponseHeader(event, "Access-Control-Allow-Credentials", "true");
422
329
  }
423
- setResponseHeader(event, "Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS");
424
- setResponseHeader(event, "Access-Control-Allow-Headers", MCP_EMBED_CORS_ALLOW_HEADERS);
425
- }));
426
- // Defense-in-depth CSRF check for state-changing /_agent-native/* routes.
427
- // Mounted AFTER the CORS layer so disallowed-origin OPTIONS preflights
428
- // 403 first (rather than being rejected on a stale cookie heuristic).
429
- // See `csrf.ts` for the threat model and allowlist.
430
- const { createCsrfMiddleware } = await import("./csrf.js");
431
- getH3App(nitroApp).use(createCsrfMiddleware(P));
432
- // Demo-mode status — read by the client fetch interceptor and the
433
- // Demo mode settings toggle. `forced` reflects the DEMO_MODE env (a
434
- // hosted demo deployment); `enabled` ORs that with the per-user
435
- // application_state toggle. No request-context dependency: the env case
436
- // needs nothing, and the per-user case resolves the session straight
437
- // off the request cookie via getSession(event).
438
- getH3App(nitroApp).use(`${P}/demo/status`, defineEventHandler(async (event) => {
439
- const { isDemoModeForced } = await import("../demo/config.js");
440
- const forced = isDemoModeForced();
441
- if (forced)
442
- return { enabled: true, forced: true };
330
+ catch {
331
+ // DB not ready — skip; the disconnect flag will be enforced on the
332
+ // next plugin boot once the settings table is reachable.
333
+ }
334
+ // Register framework-level secrets (OPENAI_API_KEY for composer voice
335
+ // transcription, etc.). Each registration is guarded so templates that
336
+ // already registered the same key win.
337
+ registerFrameworkSecrets();
338
+ registerBuiltinProviders();
339
+ registerBuiltinNotificationChannels();
443
340
  try {
444
- const session = await getSession(event);
445
- const email = session?.email;
446
- if (!email)
447
- return { enabled: false, forced: false };
448
- const { appStateGet } = await import("../application-state/store.js");
449
- const state = await appStateGet(email, "demo-mode");
450
- return {
451
- enabled: state?.enabled === true,
452
- forced: false,
453
- };
341
+ const { createObservabilityHandler } = await import("../observability/routes.js");
342
+ const { ensureObservabilityTables } = await import("../observability/store.js");
343
+ ensureObservabilityTables().catch(() => { });
344
+ getH3App(nitroApp).use(`${FRAMEWORK_ROUTE_PREFIX}/observability`, createObservabilityHandler());
454
345
  }
455
346
  catch {
456
- return { enabled: false, forced: false };
457
- }
458
- }));
459
- // Polling
460
- getH3App(nitroApp).use(`${P}/poll`, createPollHandler());
461
- // SSE
462
- if (!options.disableSSE) {
463
- const sseRoute = options.sseRoute ?? `${P}/events`;
464
- getH3App(nitroApp).use(sseRoute, createPollEventsHandler());
465
- }
466
- // Ping
467
- if (!options.disablePing) {
468
- getH3App(nitroApp).use(`${P}/ping`, defineEventHandler(() => ({
469
- message: process.env.PING_MESSAGE ?? "pong",
470
- })));
471
- }
472
- mountBrowserSessionRoutes(nitroApp, { routePrefix: P });
473
- // Dev-mode DB admin (Supabase-Studio-like). Mounted unconditionally; every
474
- // handler self-gates on dev + localhost (the authoritative gate lives in
475
- // db-admin/routes.ts), so on a deployed / production app it always 403s.
476
- mountDbAdminRoutes(nitroApp, { routePrefix: P });
477
- const resolveBuilderOwnerContext = async (event, mode) => resolveBuilderOwnerContextForRequest(event, { anonymousOwner: options.anonymousOwner }, mode);
478
- getH3App(nitroApp).use(`${P}/builder/status`, defineEventHandler(async (event) => {
479
- const envStatus = getBuilderBrowserStatusForEvent(event);
480
- const ownerContext = await resolveBuilderOwnerContext(event);
481
- const userEmail = ownerContext.email;
482
- const withConnectToken = (status) => {
483
- if (!userEmail)
484
- return status;
485
- const previewOrigin = getBuilderBrowserOriginForEvent(event);
486
- const callbackOrigin = getBuilderCliAuthCallbackOriginForEvent(event);
487
- const statusWithConnectToken = {
488
- ...status,
489
- connectUrl: appendBuilderConnectToken(status.connectUrl, userEmail),
490
- };
491
- // Direct cli-auth only works when the callback lands on the same
492
- // deployment that minted the signed state. Builder/Fusion previews
493
- // often need a gateway callback origin; in that case use the
494
- // /builder/connect trampoline so it can write the pending-connect
495
- // row that the gateway callback validates against.
496
- if (previewOrigin.replace(/\/+$/, "") !==
497
- callbackOrigin.replace(/\/+$/, "")) {
498
- return statusWithConnectToken;
499
- }
500
- const cliAuthUrl = buildBuilderCliAuthUrl(callbackOrigin, signBuilderCallbackState(userEmail), { previewOrigin });
501
- return {
502
- ...statusWithConnectToken,
503
- cliAuthUrl,
347
+ // Observability module not available skip
348
+ }
349
+ const P = FRAMEWORK_ROUTE_PREFIX;
350
+ // Security response headers — emitted on every framework response.
351
+ // Mounted before route handlers so 4xx/5xx error pages also carry the
352
+ // headers. Routes that need to relax a specific header (e.g. the tools
353
+ // /render route allowing same-origin framing) override via setResponseHeader.
354
+ const { createSecurityHeadersMiddleware } = await import("./security-headers.js");
355
+ getH3App(nitroApp).use(createSecurityHeadersMiddleware());
356
+ // CORS for framework routes. Desktop tray apps (Tauri/Electron) run on
357
+ // their own dev origin (e.g. localhost:1420) and make credentialed
358
+ // requests against the template's server at a different port. We echo
359
+ // the exact origin + Allow-Credentials so same-site localhost ports
360
+ // can cross-send cookies.
361
+ const allowlist = readCorsAllowedOrigins();
362
+ getH3App(nitroApp).use(defineEventHandler((event) => {
363
+ const pathname = stripAppBasePath(event.url?.pathname ??
364
+ String(event.node?.req?.url ?? event.path ?? "/").split("?")[0]);
365
+ if (!pathname.startsWith(P) && !pathname.startsWith("/api/"))
366
+ return;
367
+ const readRequestHeader = (name) => {
368
+ const lower = name.toLowerCase();
369
+ const raw = event.node?.req?.headers?.[lower] ??
370
+ event.node?.req?.headers?.[name];
371
+ if (Array.isArray(raw))
372
+ return raw[0];
373
+ if (typeof raw === "string")
374
+ return raw;
375
+ return getHeader(event, name) ?? undefined;
504
376
  };
505
- };
506
- // Pass the user's active orgId so status reads can fall back to
507
- // org-scoped credentials and branch project IDs. Without it, an
508
- // admin's org-scope OAuth result is invisible to every other org
509
- // member's status poller and the UI would show "not connected" forever
510
- // even though the chat actually resolves the org-shared credential.
511
- let orgId = null;
512
- if (!ownerContext.anonymous) {
377
+ const origin = readRequestHeader("origin");
378
+ const method = getMethod(event);
379
+ const requestedHeaders = readRequestHeader("access-control-request-headers");
380
+ const requestedHeaderNames = String(requestedHeaders ?? "")
381
+ .toLowerCase()
382
+ .split(",")
383
+ .map((header) => header.trim());
384
+ const mcpEmbedCorsRequest = isMcpEmbedCorsOrigin(origin) &&
385
+ (requestedHeaderNames.includes(EMBED_TARGET_HEADER.toLowerCase()) ||
386
+ requestedHeaderNames.includes(EMBED_TRANSPLANT_HEADER) ||
387
+ Boolean(readRequestHeader(EMBED_TARGET_HEADER)) ||
388
+ Boolean(readRequestHeader(EMBED_TRANSPLANT_HEADER)) ||
389
+ Boolean(readRequestHeader("authorization")));
390
+ // Decide whether this origin is allowed. We never fall back to the
391
+ // first allowlist entry — that previously echoed `Access-Control-
392
+ // Allow-Origin: <unrelated-allowed-origin>` for disallowed callers,
393
+ // which is permissive enough that some clients followed through.
394
+ const allowedOrigin = mcpEmbedCorsRequest
395
+ ? origin
396
+ : getAllowedCorsOrigin(origin, {
397
+ allowedOrigins: allowlist,
398
+ allowAnyOriginWhenNoAllowlist: false,
399
+ allowLocalhostWhenNoAllowlist: true,
400
+ });
401
+ // Reject preflights from disallowed cross-origin callers BEFORE
402
+ // returning 204. Previously the OPTIONS short-circuit returned 204
403
+ // with no ACAO header, which the browser then treats as a CORS
404
+ // failure — but also short-circuited any further checks. Now we
405
+ // explicitly 403 disallowed cross-origin preflights.
406
+ if (method === "OPTIONS") {
407
+ if (origin && !allowedOrigin) {
408
+ setResponseStatus(event, 403);
409
+ return "";
410
+ }
411
+ if (allowedOrigin) {
412
+ setResponseHeader(event, "Access-Control-Allow-Origin", allowedOrigin);
413
+ setResponseHeader(event, "Vary", "Origin");
414
+ if (shouldAllowMcpEmbedCredentials(allowedOrigin)) {
415
+ setResponseHeader(event, "Access-Control-Allow-Credentials", "true");
416
+ }
417
+ setResponseHeader(event, "Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS");
418
+ setResponseHeader(event, "Access-Control-Allow-Headers", MCP_EMBED_CORS_ALLOW_HEADERS);
419
+ }
420
+ setResponseStatus(event, 204);
421
+ return "";
422
+ }
423
+ // Non-preflight requests: only set CORS response headers when we
424
+ // have an allowed origin. Same-origin / no-origin requests fall
425
+ // through without explicit CORS headers (browser treats them as
426
+ // same-origin by default).
427
+ if (!allowedOrigin)
428
+ return;
429
+ setResponseHeader(event, "Access-Control-Allow-Origin", allowedOrigin);
430
+ setResponseHeader(event, "Vary", "Origin");
431
+ if (shouldAllowMcpEmbedCredentials(allowedOrigin)) {
432
+ setResponseHeader(event, "Access-Control-Allow-Credentials", "true");
433
+ }
434
+ setResponseHeader(event, "Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS");
435
+ setResponseHeader(event, "Access-Control-Allow-Headers", MCP_EMBED_CORS_ALLOW_HEADERS);
436
+ }));
437
+ // Defense-in-depth CSRF check for state-changing /_agent-native/* routes.
438
+ // Mounted AFTER the CORS layer so disallowed-origin OPTIONS preflights
439
+ // 403 first (rather than being rejected on a stale cookie heuristic).
440
+ // See `csrf.ts` for the threat model and allowlist.
441
+ const { createCsrfMiddleware } = await import("./csrf.js");
442
+ getH3App(nitroApp).use(createCsrfMiddleware(P));
443
+ // Demo-mode status — read by the client fetch interceptor and the
444
+ // Demo mode settings toggle. `forced` reflects the DEMO_MODE env (a
445
+ // hosted demo deployment); `enabled` ORs that with the per-user
446
+ // application_state toggle. No request-context dependency: the env case
447
+ // needs nothing, and the per-user case resolves the session straight
448
+ // off the request cookie via getSession(event).
449
+ getH3App(nitroApp).use(`${P}/demo/status`, defineEventHandler(async (event) => {
450
+ const { isDemoModeForced } = await import("../demo/config.js");
451
+ const forced = isDemoModeForced();
452
+ if (forced)
453
+ return { enabled: true, forced: true };
513
454
  try {
514
- const { getOrgContext } = await import("../org/context.js");
515
- const orgCtx = await getOrgContext(event);
516
- orgId = orgCtx.orgId ?? null;
455
+ const session = await getSession(event);
456
+ const email = session?.email;
457
+ if (!email)
458
+ return { enabled: false, forced: false };
459
+ const { appStateGet } = await import("../application-state/store.js");
460
+ const state = await appStateGet(email, "demo-mode");
461
+ return {
462
+ enabled: state?.enabled === true,
463
+ forced: false,
464
+ };
517
465
  }
518
466
  catch {
519
- /* org module not present in this template — keep userEmail-only */
467
+ return { enabled: false, forced: false };
520
468
  }
521
- }
522
- return runWithRequestContext({ userEmail, orgId }, async () => {
523
- const projectId = await resolveBuilderBranchProjectId();
524
- const requestStatus = {
525
- ...envStatus,
526
- builderEnabled: !!projectId,
527
- branchProjectIdConfigured: !!projectId,
528
- branchProjectId: projectId || undefined,
469
+ }));
470
+ // Polling
471
+ getH3App(nitroApp).use(`${P}/poll`, createPollHandler());
472
+ // SSE
473
+ if (!options.disableSSE) {
474
+ const sseRoute = options.sseRoute ?? `${P}/events`;
475
+ getH3App(nitroApp).use(sseRoute, createPollEventsHandler());
476
+ }
477
+ // Ping
478
+ if (!options.disablePing) {
479
+ getH3App(nitroApp).use(`${P}/ping`, defineEventHandler(() => ({
480
+ message: process.env.PING_MESSAGE ?? "pong",
481
+ })));
482
+ }
483
+ getH3App(nitroApp).use(`${P}/speculation-rules.json`, defineEventHandler((event) => {
484
+ // `createH3SSRHandler` points the Speculation-Rules response header
485
+ // here to prevent Cloudflare Speed Brain from injecting its own
486
+ // edge prefetch rules. Keep this route public and side-effect free:
487
+ // browsers may request it while parsing any SSR HTML document.
488
+ setResponseHeader(event, "content-type", "application/speculationrules+json; charset=utf-8");
489
+ setResponseHeader(event, "cache-control", DEFAULT_SSR_CACHE_CONTROL);
490
+ return EMPTY_SPECULATION_RULES;
491
+ }));
492
+ mountBrowserSessionRoutes(nitroApp, { routePrefix: P });
493
+ // Dev-mode DB admin (Supabase-Studio-like). Mounted unconditionally; every
494
+ // handler self-gates on dev + localhost (the authoritative gate lives in
495
+ // db-admin/routes.ts), so on a deployed / production app it always 403s.
496
+ mountDbAdminRoutes(nitroApp, { routePrefix: P });
497
+ const resolveBuilderOwnerContext = async (event, mode) => resolveBuilderOwnerContextForRequest(event, { anonymousOwner: options.anonymousOwner }, mode);
498
+ getH3App(nitroApp).use(`${P}/builder/status`, defineEventHandler(async (event) => {
499
+ const envStatus = getBuilderBrowserStatusForEvent(event);
500
+ const ownerContext = await resolveBuilderOwnerContext(event);
501
+ const userEmail = ownerContext.email;
502
+ const withConnectToken = (status) => {
503
+ if (!userEmail)
504
+ return status;
505
+ const previewOrigin = getBuilderBrowserOriginForEvent(event);
506
+ const callbackOrigin = getBuilderCliAuthCallbackOriginForEvent(event);
507
+ const statusWithConnectToken = {
508
+ ...status,
509
+ connectUrl: appendBuilderConnectToken(status.connectUrl, userEmail),
510
+ };
511
+ // Direct cli-auth only works when the callback lands on the same
512
+ // deployment that minted the signed state. Builder/Fusion previews
513
+ // often need a gateway callback origin; in that case use the
514
+ // /builder/connect trampoline so it can write the pending-connect
515
+ // row that the gateway callback validates against.
516
+ if (previewOrigin.replace(/\/+$/, "") !==
517
+ callbackOrigin.replace(/\/+$/, "")) {
518
+ return statusWithConnectToken;
519
+ }
520
+ const cliAuthUrl = buildBuilderCliAuthUrl(callbackOrigin, signBuilderCallbackState(userEmail), { previewOrigin });
521
+ return {
522
+ ...statusWithConnectToken,
523
+ cliAuthUrl,
524
+ };
529
525
  };
530
- // Surface a recent OAuth callback failure before reporting a
531
- // deployment fallback as "connected"; otherwise a failed personal
532
- // connect attempt on a deploy that also has BUILDER_PRIVATE_KEY set
533
- // looks successful even though the user's credentials were not saved.
534
- try {
535
- if (userEmail) {
536
- const errKey = `builder-connect-error:${userEmail}`;
537
- const errRow = await getSetting(errKey);
538
- if (errRow && typeof errRow.message === "string") {
539
- await deleteSetting(errKey).catch(() => { });
526
+ // Pass the user's active orgId so status reads can fall back to
527
+ // org-scoped credentials and branch project IDs. Without it, an
528
+ // admin's org-scope OAuth result is invisible to every other org
529
+ // member's status poller and the UI would show "not connected" forever
530
+ // even though the chat actually resolves the org-shared credential.
531
+ let orgId = null;
532
+ if (!ownerContext.anonymous) {
533
+ try {
534
+ const { getOrgContext } = await import("../org/context.js");
535
+ const orgCtx = await getOrgContext(event);
536
+ orgId = orgCtx.orgId ?? null;
537
+ }
538
+ catch {
539
+ /* org module not present in this template — keep userEmail-only */
540
+ }
541
+ }
542
+ return runWithRequestContext({ userEmail, orgId }, async () => {
543
+ const projectId = await resolveBuilderBranchProjectId();
544
+ const requestStatus = {
545
+ ...envStatus,
546
+ builderEnabled: !!projectId,
547
+ branchProjectIdConfigured: !!projectId,
548
+ branchProjectId: projectId || undefined,
549
+ };
550
+ // Surface a recent OAuth callback failure before reporting a
551
+ // deployment fallback as "connected"; otherwise a failed personal
552
+ // connect attempt on a deploy that also has BUILDER_PRIVATE_KEY set
553
+ // looks successful even though the user's credentials were not saved.
554
+ try {
555
+ if (userEmail) {
556
+ const errKey = `builder-connect-error:${userEmail}`;
557
+ const errRow = await getSetting(errKey);
558
+ if (errRow && typeof errRow.message === "string") {
559
+ await deleteSetting(errKey).catch(() => { });
560
+ return withConnectToken({
561
+ ...requestStatus,
562
+ configured: false,
563
+ privateKeyConfigured: false,
564
+ publicKeyConfigured: false,
565
+ userId: undefined,
566
+ orgName: undefined,
567
+ orgKind: undefined,
568
+ connectError: {
569
+ message: errRow.message,
570
+ at: typeof errRow.at === "number"
571
+ ? errRow.at
572
+ : Date.now(),
573
+ },
574
+ });
575
+ }
576
+ }
577
+ }
578
+ catch {
579
+ // settings store unavailable — fall through
580
+ }
581
+ // Read request-scoped Builder credentials first; deploy env is only
582
+ // the fallback. This keeps a root/local BUILDER_PRIVATE_KEY from
583
+ // blocking a user from connecting their own Builder account.
584
+ try {
585
+ const { resolveBuilderCredentials, resolveBuilderCredentialSource, getBuilderCredentialAuthFailure, } = await import("./credential-provider.js");
586
+ const [creds, credentialSource] = await Promise.all([
587
+ resolveBuilderCredentials(),
588
+ resolveBuilderCredentialSource(),
589
+ ]);
590
+ const authFailure = await getBuilderCredentialAuthFailure(creds);
591
+ if (authFailure) {
540
592
  return withConnectToken({
541
593
  ...requestStatus,
542
594
  configured: false,
@@ -545,1551 +597,1533 @@ export function createCoreRoutesPlugin(options = {}) {
545
597
  userId: undefined,
546
598
  orgName: undefined,
547
599
  orgKind: undefined,
548
- connectError: {
549
- message: errRow.message,
550
- at: typeof errRow.at === "number"
551
- ? errRow.at
552
- : Date.now(),
600
+ credentialSource: credentialSource ?? undefined,
601
+ // Surface durable credential rejection separately from
602
+ // one-shot cli-auth callback failures. The reconnect UI keeps
603
+ // polling through authError while the user chooses a new
604
+ // Builder space; connectError means the active callback itself
605
+ // failed and should stop the flow.
606
+ authError: {
607
+ message: authFailure.message,
608
+ at: authFailure.at,
553
609
  },
554
610
  });
555
611
  }
612
+ if (creds.privateKey && creds.publicKey) {
613
+ return withConnectToken({
614
+ ...requestStatus,
615
+ configured: true,
616
+ privateKeyConfigured: true,
617
+ publicKeyConfigured: !!creds.publicKey,
618
+ userId: creds.userId || envStatus.userId,
619
+ orgName: creds.orgName || envStatus.orgName,
620
+ orgKind: creds.orgKind || envStatus.orgKind,
621
+ credentialSource: credentialSource ?? undefined,
622
+ });
623
+ }
556
624
  }
557
- }
558
- catch {
559
- // settings store unavailable — fall through
560
- }
561
- // Read request-scoped Builder credentials first; deploy env is only
562
- // the fallback. This keeps a root/local BUILDER_PRIVATE_KEY from
563
- // blocking a user from connecting their own Builder account.
564
- try {
565
- const { resolveBuilderCredentials, resolveBuilderCredentialSource, getBuilderCredentialAuthFailure, } = await import("./credential-provider.js");
566
- const [creds, credentialSource] = await Promise.all([
567
- resolveBuilderCredentials(),
568
- resolveBuilderCredentialSource(),
569
- ]);
570
- const authFailure = await getBuilderCredentialAuthFailure(creds);
571
- if (authFailure) {
572
- return withConnectToken({
573
- ...requestStatus,
574
- configured: false,
575
- privateKeyConfigured: false,
576
- publicKeyConfigured: false,
577
- userId: undefined,
578
- orgName: undefined,
579
- orgKind: undefined,
580
- credentialSource: credentialSource ?? undefined,
581
- // Surface durable credential rejection separately from
582
- // one-shot cli-auth callback failures. The reconnect UI keeps
583
- // polling through authError while the user chooses a new
584
- // Builder space; connectError means the active callback itself
585
- // failed and should stop the flow.
586
- authError: {
587
- message: authFailure.message,
588
- at: authFailure.at,
589
- },
590
- });
625
+ catch {
626
+ // Secrets table not ready — fall through to env status
591
627
  }
592
- if (creds.privateKey && creds.publicKey) {
593
- return withConnectToken({
594
- ...requestStatus,
595
- configured: true,
596
- privateKeyConfigured: true,
597
- publicKeyConfigured: !!creds.publicKey,
598
- userId: creds.userId || envStatus.userId,
599
- orgName: creds.orgName || envStatus.orgName,
600
- orgKind: creds.orgKind || envStatus.orgKind,
601
- credentialSource: credentialSource ?? undefined,
602
- });
628
+ // Honor legacy disconnect flag for existing deployments.
629
+ try {
630
+ const disconnected = await getSetting("builder-disconnected");
631
+ if (disconnected) {
632
+ return withConnectToken({
633
+ ...requestStatus,
634
+ configured: false,
635
+ privateKeyConfigured: false,
636
+ publicKeyConfigured: false,
637
+ userId: undefined,
638
+ orgName: undefined,
639
+ orgKind: undefined,
640
+ });
641
+ }
642
+ }
643
+ catch {
644
+ // DB not reachable
645
+ }
646
+ // No env, no per-user creds → not configured. Both authenticated
647
+ // and unauthenticated callers see "not connected" so they can
648
+ // run through the OAuth flow.
649
+ return withConnectToken({
650
+ ...requestStatus,
651
+ configured: false,
652
+ privateKeyConfigured: false,
653
+ publicKeyConfigured: false,
654
+ userId: undefined,
655
+ orgName: undefined,
656
+ orgKind: undefined,
657
+ });
658
+ });
659
+ }));
660
+ // How long a pending-connect row is valid. Must be long enough for
661
+ // the user to complete the Builder CLI-auth flow, but short enough
662
+ // that a stale row from an abandoned attempt doesn't accept a new
663
+ // callback minutes later.
664
+ const BUILDER_CONNECT_PENDING_TTL_MS = 10 * 60 * 1000; // 10 min
665
+ // Decide whether a /builder/connect navigation originated from this
666
+ // app's own UI (allowed) or from a foreign origin (cross-site CSRF
667
+ // attempt — rejected). Sec-Fetch-Site is the modern signal:
668
+ // - "same-origin": user clicked Connect from our own pages — allow
669
+ // - "none": typed in URL bar / bookmark / browser extension — allow
670
+ // - "same-site" / "cross-site" / missing-but-with-foreign-Origin
671
+ // all map to reject.
672
+ // For older browsers without Sec-Fetch-* we fall back to Origin and
673
+ // then Referer, comparing against the request's resolved origin.
674
+ function isSameOriginConnect(event) {
675
+ const fetchSite = getHeader(event, "sec-fetch-site");
676
+ if (fetchSite === "same-origin" || fetchSite === "none")
677
+ return true;
678
+ if (fetchSite)
679
+ return false; // browser told us it's cross-site/same-site
680
+ const expected = getBuilderBrowserOriginForEvent(event).replace(/\/+$/, "");
681
+ const origin = getHeader(event, "origin");
682
+ if (origin)
683
+ return origin.replace(/\/+$/, "") === expected;
684
+ const referer = getHeader(event, "referer");
685
+ if (referer) {
686
+ try {
687
+ return new URL(referer).origin === expected;
688
+ }
689
+ catch {
690
+ return false;
603
691
  }
604
692
  }
605
- catch {
606
- // Secrets table not ready fall through to env status
693
+ // No Sec-Fetch-Site, no Origin, no Referer — pre-2020 browser
694
+ // making a top-level navigation. Allow; cookies are still
695
+ // session-bound so the worst case degrades to the prior behavior.
696
+ return true;
697
+ }
698
+ // Lightweight 302 to the Builder CLI-auth URL. Lets clients do
699
+ // `window.open('/_agent-native/builder/connect', '_blank')` synchronously
700
+ // inside a click handler, avoiding the popup-blocker downgrade that
701
+ // happens when an await sits before window.open.
702
+ //
703
+ // CSRF protection here is layered because session cookies are
704
+ // SameSite=None;Secure (so the editor iframe can ride along) — that
705
+ // means a session cookie alone does NOT prevent cross-origin
706
+ // window.open from initiating a connect flow on the victim's behalf:
707
+ // 1. Signed connect token from /builder/status — proves the opener
708
+ // could read same-origin JSON, which cross-site attackers cannot.
709
+ // This covers local/embedded browsers that conservatively label a
710
+ // legitimate popup navigation as same-site/cross-site.
711
+ // 2. Sec-Fetch-Site header fallback — modern browsers stamp every
712
+ // request with the navigation context. We allow `same-origin` or
713
+ // `none` (typed/bookmark/extension); cross-site / same-site without
714
+ // a valid connect token are rejected.
715
+ // 3. Pending row keyed by session email + bound nonce — the callback
716
+ // requires both a valid session and a one-time row that this
717
+ // handler wrote during the same flow. Without the same-origin
718
+ // gate or connect token above, an attacker could prime the row from
719
+ // cross-site and then trick the victim into hitting a callback URL
720
+ // with attacker-controlled p-key/api-key, hijacking the victim's
721
+ // account.
722
+ getH3App(nitroApp).use(`${P}/builder/connect`, defineEventHandler(async (event) => {
723
+ const ownerContext = await resolveBuilderOwnerContext(event, "connect");
724
+ const ownerEmail = ownerContext.email;
725
+ if (!ownerEmail) {
726
+ setResponseStatus(event, 401);
727
+ return { error: "Authentication required" };
607
728
  }
608
- // Honor legacy disconnect flag for existing deployments.
729
+ const requestUrl = new URL(`${event.url?.pathname || "/"}${event.url?.search || ""}`, getBuilderBrowserOriginForEvent(event));
730
+ const connectToken = requestUrl.searchParams.get(BUILDER_CONNECT_PARAM);
731
+ const connectTokenOwner = verifyBuilderConnectTokenAndGetOwner(connectToken);
732
+ const connectTracking = getBuilderConnectTrackingParams(requestUrl.searchParams);
733
+ // The token must both be well-formed AND minted for the current
734
+ // session owner. Without the owner check, an attacker holding any
735
+ // valid signed token could trick a victim into hitting this route
736
+ // with that token to bypass the cross-origin gate.
737
+ const hasValidConnectToken = Boolean(connectTokenOwner) && connectTokenOwner === ownerEmail;
738
+ // Same-origin gate. Sec-Fetch-Site remains the fast path; the signed
739
+ // connect token is the compatibility path for legitimate embedded or
740
+ // local desktop popups stamped as same-site/cross-site by the browser.
741
+ if (!isSameOriginConnect(event) && !hasValidConnectToken) {
742
+ const crossOriginMessage = connectToken
743
+ ? "This Builder connect link is expired or belongs to a different deployment. Close this popup and click Connect account again."
744
+ : "Builder connect opened without a fresh signed link. Close this popup and click Connect account again.";
745
+ await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
746
+ ...builderConnectTrackingProperties(connectTracking),
747
+ reason: "cross_origin",
748
+ stage: "connect",
749
+ has_connect_token: Boolean(connectToken),
750
+ has_valid_connect_token: false,
751
+ connect_token_owner_matches_context: false,
752
+ sec_fetch_site: getHeader(event, "sec-fetch-site") ?? null,
753
+ });
754
+ await putSetting(`builder-connect-error:${ownerEmail}`, {
755
+ message: crossOriginMessage,
756
+ at: Date.now(),
757
+ }).catch(() => { });
758
+ console.warn("[builder-connect] rejected cross-origin connect", {
759
+ hasConnectToken: Boolean(connectToken),
760
+ secFetchSite: getHeader(event, "sec-fetch-site") ?? null,
761
+ origin: getHeader(event, "origin") ?? null,
762
+ referer: getHeader(event, "referer") ?? null,
763
+ });
764
+ setResponseStatus(event, 403);
765
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
766
+ return createBuilderBrowserCallbackErrorPage(crossOriginMessage, {
767
+ title: "Couldn't start Builder connection",
768
+ body: "The connect popup did not include a valid signed link for this app.",
769
+ closeHint: "Close this popup, refresh the app, and try Connect account again.",
770
+ parentOrigin: getBuilderBrowserOriginForEvent(event),
771
+ });
772
+ }
773
+ // Clear any prior failure row from a previous attempt — otherwise
774
+ // useBuilderStatus polling sees the stale error and aborts the
775
+ // new attempt before it can complete.
609
776
  try {
610
- const disconnected = await getSetting("builder-disconnected");
611
- if (disconnected) {
612
- return withConnectToken({
613
- ...requestStatus,
614
- configured: false,
615
- privateKeyConfigured: false,
616
- publicKeyConfigured: false,
617
- userId: undefined,
618
- orgName: undefined,
619
- orgKind: undefined,
620
- });
621
- }
777
+ await deleteSetting(`builder-connect-error:${ownerEmail}`);
622
778
  }
623
779
  catch {
624
- // DB not reachable
625
- }
626
- // No env, no per-user creds not configured. Both authenticated
627
- // and unauthenticated callers see "not connected" so they can
628
- // run through the OAuth flow.
629
- return withConnectToken({
630
- ...requestStatus,
631
- configured: false,
632
- privateKeyConfigured: false,
633
- publicKeyConfigured: false,
634
- userId: undefined,
635
- orgName: undefined,
636
- orgKind: undefined,
637
- });
638
- });
639
- }));
640
- // How long a pending-connect row is valid. Must be long enough for
641
- // the user to complete the Builder CLI-auth flow, but short enough
642
- // that a stale row from an abandoned attempt doesn't accept a new
643
- // callback minutes later.
644
- const BUILDER_CONNECT_PENDING_TTL_MS = 10 * 60 * 1000; // 10 min
645
- // Decide whether a /builder/connect navigation originated from this
646
- // app's own UI (allowed) or from a foreign origin (cross-site CSRF
647
- // attempt — rejected). Sec-Fetch-Site is the modern signal:
648
- // - "same-origin": user clicked Connect from our own pages — allow
649
- // - "none": typed in URL bar / bookmark / browser extension — allow
650
- // - "same-site" / "cross-site" / missing-but-with-foreign-Origin
651
- // all map to reject.
652
- // For older browsers without Sec-Fetch-* we fall back to Origin and
653
- // then Referer, comparing against the request's resolved origin.
654
- function isSameOriginConnect(event) {
655
- const fetchSite = getHeader(event, "sec-fetch-site");
656
- if (fetchSite === "same-origin" || fetchSite === "none")
657
- return true;
658
- if (fetchSite)
659
- return false; // browser told us it's cross-site/same-site
660
- const expected = getBuilderBrowserOriginForEvent(event).replace(/\/+$/, "");
661
- const origin = getHeader(event, "origin");
662
- if (origin)
663
- return origin.replace(/\/+$/, "") === expected;
664
- const referer = getHeader(event, "referer");
665
- if (referer) {
780
+ // No prior error row — fine
781
+ }
782
+ // Store a short-lived pending row. If the DB is unavailable we
783
+ // surface a popup-renderable error page that signals the parent
784
+ // via BroadcastChannel, rather than letting the popup show raw
785
+ // JSON and the parent poll for 5 minutes.
666
786
  try {
667
- return new URL(referer).origin === expected;
787
+ await putSetting(`builder-pending-connect:${ownerEmail}`, {
788
+ expiresAt: Date.now() + BUILDER_CONNECT_PENDING_TTL_MS,
789
+ tracking: connectTracking,
790
+ });
668
791
  }
669
- catch {
670
- return false;
792
+ catch (err) {
793
+ await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
794
+ ...builderConnectTrackingProperties(connectTracking),
795
+ reason: "pending_storage_unavailable",
796
+ stage: "connect",
797
+ });
798
+ const msg = "Could not initiate Builder connect — storage unavailable. Try again.";
799
+ console.error("[builder] Could not store pending-connect state:", err?.message ?? err);
800
+ // Best-effort: also write the error row so the parent's
801
+ // /builder/status poll picks it up if BroadcastChannel doesn't.
802
+ await putSetting(`builder-connect-error:${ownerEmail}`, {
803
+ message: msg,
804
+ at: Date.now(),
805
+ }).catch(() => { });
806
+ setResponseStatus(event, 503);
807
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
808
+ return createBuilderBrowserCallbackErrorPage(msg, {
809
+ parentOrigin: getBuilderBrowserOriginForEvent(event),
810
+ });
671
811
  }
672
- }
673
- // No Sec-Fetch-Site, no Origin, no Referer — pre-2020 browser
674
- // making a top-level navigation. Allow; cookies are still
675
- // session-bound so the worst case degrades to the prior behavior.
676
- return true;
677
- }
678
- // Lightweight 302 to the Builder CLI-auth URL. Lets clients do
679
- // `window.open('/_agent-native/builder/connect', '_blank')` synchronously
680
- // inside a click handler, avoiding the popup-blocker downgrade that
681
- // happens when an await sits before window.open.
682
- //
683
- // CSRF protection here is layered because session cookies are
684
- // SameSite=None;Secure (so the editor iframe can ride along) — that
685
- // means a session cookie alone does NOT prevent cross-origin
686
- // window.open from initiating a connect flow on the victim's behalf:
687
- // 1. Signed connect token from /builder/status — proves the opener
688
- // could read same-origin JSON, which cross-site attackers cannot.
689
- // This covers local/embedded browsers that conservatively label a
690
- // legitimate popup navigation as same-site/cross-site.
691
- // 2. Sec-Fetch-Site header fallback — modern browsers stamp every
692
- // request with the navigation context. We allow `same-origin` or
693
- // `none` (typed/bookmark/extension); cross-site / same-site without
694
- // a valid connect token are rejected.
695
- // 3. Pending row keyed by session email + bound nonce — the callback
696
- // requires both a valid session and a one-time row that this
697
- // handler wrote during the same flow. Without the same-origin
698
- // gate or connect token above, an attacker could prime the row from
699
- // cross-site and then trick the victim into hitting a callback URL
700
- // with attacker-controlled p-key/api-key, hijacking the victim's
701
- // account.
702
- getH3App(nitroApp).use(`${P}/builder/connect`, defineEventHandler(async (event) => {
703
- const ownerContext = await resolveBuilderOwnerContext(event, "connect");
704
- const ownerEmail = ownerContext.email;
705
- if (!ownerEmail) {
706
- setResponseStatus(event, 401);
707
- return { error: "Authentication required" };
708
- }
709
- const requestUrl = new URL(`${event.url?.pathname || "/"}${event.url?.search || ""}`, getBuilderBrowserOriginForEvent(event));
710
- const connectToken = requestUrl.searchParams.get(BUILDER_CONNECT_PARAM);
711
- const connectTokenOwner = verifyBuilderConnectTokenAndGetOwner(connectToken);
712
- const connectTracking = getBuilderConnectTrackingParams(requestUrl.searchParams);
713
- // The token must both be well-formed AND minted for the current
714
- // session owner. Without the owner check, an attacker holding any
715
- // valid signed token could trick a victim into hitting this route
716
- // with that token to bypass the cross-origin gate.
717
- const hasValidConnectToken = Boolean(connectTokenOwner) && connectTokenOwner === ownerEmail;
718
- // Same-origin gate. Sec-Fetch-Site remains the fast path; the signed
719
- // connect token is the compatibility path for legitimate embedded or
720
- // local desktop popups stamped as same-site/cross-site by the browser.
721
- if (!isSameOriginConnect(event) && !hasValidConnectToken) {
722
- const crossOriginMessage = connectToken
723
- ? "This Builder connect link is expired or belongs to a different deployment. Close this popup and click Connect account again."
724
- : "Builder connect opened without a fresh signed link. Close this popup and click Connect account again.";
725
- await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
812
+ await trackBuilderLifecycle(event, "builder connect started", ownerEmail, {
726
813
  ...builderConnectTrackingProperties(connectTracking),
727
- reason: "cross_origin",
728
814
  stage: "connect",
729
- has_connect_token: Boolean(connectToken),
730
- has_valid_connect_token: false,
731
- connect_token_owner_matches_context: false,
732
- sec_fetch_site: getHeader(event, "sec-fetch-site") ?? null,
733
- });
734
- await putSetting(`builder-connect-error:${ownerEmail}`, {
735
- message: crossOriginMessage,
736
- at: Date.now(),
737
- }).catch(() => { });
738
- console.warn("[builder-connect] rejected cross-origin connect", {
739
- hasConnectToken: Boolean(connectToken),
740
- secFetchSite: getHeader(event, "sec-fetch-site") ?? null,
741
- origin: getHeader(event, "origin") ?? null,
742
- referer: getHeader(event, "referer") ?? null,
743
- });
744
- setResponseStatus(event, 403);
745
- setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
746
- return createBuilderBrowserCallbackErrorPage(crossOriginMessage, {
747
- title: "Couldn't start Builder connection",
748
- body: "The connect popup did not include a valid signed link for this app.",
749
- closeHint: "Close this popup, refresh the app, and try Connect account again.",
750
- parentOrigin: getBuilderBrowserOriginForEvent(event),
815
+ connect_token_owner_matches_context: !connectTokenOwner || connectTokenOwner === ownerEmail,
751
816
  });
752
- }
753
- // Clear any prior failure row from a previous attempt otherwise
754
- // useBuilderStatus polling sees the stale error and aborts the
755
- // new attempt before it can complete.
756
- try {
757
- await deleteSetting(`builder-connect-error:${ownerEmail}`);
758
- }
759
- catch {
760
- // No prior error row — fine
761
- }
762
- // Store a short-lived pending row. If the DB is unavailable we
763
- // surface a popup-renderable error page that signals the parent
764
- // via BroadcastChannel, rather than letting the popup show raw
765
- // JSON and the parent poll for 5 minutes.
766
- try {
767
- await putSetting(`builder-pending-connect:${ownerEmail}`, {
768
- expiresAt: Date.now() + BUILDER_CONNECT_PENDING_TTL_MS,
817
+ setBuilderConnectOwnerCookie(event, ownerEmail);
818
+ // The primary UI now opens the signed Builder /cli-auth URL directly
819
+ // from /builder/status. Keep this legacy trampoline working for older
820
+ // clients, but still send it to Builder immediately and include signed
821
+ // callback state so the callback does not depend on popup cookies.
822
+ const cliAuthUrl = buildBuilderCliAuthUrl(getBuilderCliAuthCallbackOriginForEvent(event), signBuilderCallbackState(ownerEmail), {
823
+ previewOrigin: getBuilderBrowserOriginForEvent(event),
769
824
  tracking: connectTracking,
770
825
  });
771
- }
772
- catch (err) {
773
- await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
774
- ...builderConnectTrackingProperties(connectTracking),
775
- reason: "pending_storage_unavailable",
776
- stage: "connect",
826
+ setResponseStatus(event, 302);
827
+ setResponseHeader(event, "Location", cliAuthUrl);
828
+ return "";
829
+ }));
830
+ getH3App(nitroApp).use(`${P}/builder/run`, defineEventHandler(async (event) => {
831
+ if (getMethod(event) !== "POST") {
832
+ setResponseStatus(event, 405);
833
+ return { error: "Method not allowed" };
834
+ }
835
+ const body = await readBody(event).catch(() => ({}));
836
+ const prompt = typeof body?.prompt === "string" ? body.prompt : "";
837
+ if (!prompt.trim()) {
838
+ setResponseStatus(event, 400);
839
+ return { error: "prompt is required" };
840
+ }
841
+ const session = await getSession(event).catch(() => null);
842
+ if (!session?.email) {
843
+ setResponseStatus(event, 401);
844
+ return { error: "Authentication required" };
845
+ }
846
+ const userEmail = session.email;
847
+ let orgId = null;
848
+ try {
849
+ const orgCtx = await getOrgContext(event);
850
+ orgId = orgCtx.orgId ?? null;
851
+ }
852
+ catch {
853
+ /* org module not present in this template — keep userEmail-only */
854
+ }
855
+ // Wrap in runWithRequestContext so resolveBuilderCredential() inside
856
+ // runBuilderAgent() resolves per-user app_secrets rather than falling
857
+ // through to process.env — the same pattern the /builder/status endpoint
858
+ // uses. Without this, per-user Builder keys stored in app_secrets are
859
+ // invisible to the run path and the call throws "Builder keys are not
860
+ // configured" even though the status endpoint correctly reports configured=true.
861
+ return runWithRequestContext({ userEmail, orgId }, async () => {
862
+ const projectId = await resolveBuilderBranchProjectId();
863
+ if (!projectId) {
864
+ setResponseStatus(event, 403);
865
+ return {
866
+ error: "Builder branch creation is not available for this organization yet.",
867
+ };
868
+ }
869
+ const { resolveBuilderCredential: resolveBuilderCred } = await import("./credential-provider.js");
870
+ const builderUserId = (await resolveBuilderCred("BUILDER_USER_ID")) || undefined;
871
+ // Server-controlled projectId — don't let clients target arbitrary
872
+ // Builder projects with our private key. When this feature graduates
873
+ // past the hardcoded preview, the projectId will come from
874
+ // workspace/org config, still resolved server-side.
875
+ try {
876
+ const result = await runBuilderAgent({
877
+ prompt,
878
+ projectId,
879
+ branchName: typeof body?.branchName === "string"
880
+ ? body.branchName
881
+ : undefined,
882
+ userEmail,
883
+ userId: builderUserId,
884
+ });
885
+ return result;
886
+ }
887
+ catch (e) {
888
+ setResponseStatus(event, 500);
889
+ return {
890
+ error: e instanceof Error ? e.message : "Builder run failed",
891
+ };
892
+ }
777
893
  });
778
- const msg = "Could not initiate Builder connect — storage unavailable. Try again.";
779
- console.error("[builder] Could not store pending-connect state:", err?.message ?? err);
780
- // Best-effort: also write the error row so the parent's
781
- // /builder/status poll picks it up if BroadcastChannel doesn't.
782
- await putSetting(`builder-connect-error:${ownerEmail}`, {
783
- message: msg,
784
- at: Date.now(),
785
- }).catch(() => { });
786
- setResponseStatus(event, 503);
787
- setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
788
- return createBuilderBrowserCallbackErrorPage(msg, {
789
- parentOrigin: getBuilderBrowserOriginForEvent(event),
894
+ }));
895
+ // Branch-creation waitlist signup. Used by ConnectBuilderCard when the
896
+ // current request has no Builder branch project configured — instead of
897
+ // the raw 403 from /builder/run, the card surfaces a waitlist CTA that
898
+ // POSTs here. Recorded as a tracking event so PostHog/Mixpanel/etc.
899
+ // capture demand without us standing up new storage.
900
+ getH3App(nitroApp).use(`${P}/builder/branch-waitlist`, defineEventHandler(async (event) => {
901
+ if (getMethod(event) !== "POST") {
902
+ setResponseStatus(event, 405);
903
+ return { error: "Method not allowed" };
904
+ }
905
+ const session = await getSession(event).catch(() => null);
906
+ if (!session?.email) {
907
+ setResponseStatus(event, 401);
908
+ return { error: "Authentication required" };
909
+ }
910
+ await trackBuilderLifecycle(event, "builder branch waitlist joined", session.email, {
911
+ stage: "waitlist",
790
912
  });
791
- }
792
- await trackBuilderLifecycle(event, "builder connect started", ownerEmail, {
793
- ...builderConnectTrackingProperties(connectTracking),
794
- stage: "connect",
795
- connect_token_owner_matches_context: !connectTokenOwner || connectTokenOwner === ownerEmail,
796
- });
797
- setBuilderConnectOwnerCookie(event, ownerEmail);
798
- // The primary UI now opens the signed Builder /cli-auth URL directly
799
- // from /builder/status. Keep this legacy trampoline working for older
800
- // clients, but still send it to Builder immediately and include signed
801
- // callback state so the callback does not depend on popup cookies.
802
- const cliAuthUrl = buildBuilderCliAuthUrl(getBuilderCliAuthCallbackOriginForEvent(event), signBuilderCallbackState(ownerEmail), {
803
- previewOrigin: getBuilderBrowserOriginForEvent(event),
804
- tracking: connectTracking,
805
- });
806
- setResponseStatus(event, 302);
807
- setResponseHeader(event, "Location", cliAuthUrl);
808
- return "";
809
- }));
810
- getH3App(nitroApp).use(`${P}/builder/run`, defineEventHandler(async (event) => {
811
- if (getMethod(event) !== "POST") {
812
- setResponseStatus(event, 405);
813
- return { error: "Method not allowed" };
814
- }
815
- const body = await readBody(event).catch(() => ({}));
816
- const prompt = typeof body?.prompt === "string" ? body.prompt : "";
817
- if (!prompt.trim()) {
818
- setResponseStatus(event, 400);
819
- return { error: "prompt is required" };
820
- }
821
- const session = await getSession(event).catch(() => null);
822
- if (!session?.email) {
823
- setResponseStatus(event, 401);
824
- return { error: "Authentication required" };
825
- }
826
- const userEmail = session.email;
827
- let orgId = null;
828
- try {
829
- const orgCtx = await getOrgContext(event);
830
- orgId = orgCtx.orgId ?? null;
831
- }
832
- catch {
833
- /* org module not present in this template — keep userEmail-only */
834
- }
835
- // Wrap in runWithRequestContext so resolveBuilderCredential() inside
836
- // runBuilderAgent() resolves per-user app_secrets rather than falling
837
- // through to process.env — the same pattern the /builder/status endpoint
838
- // uses. Without this, per-user Builder keys stored in app_secrets are
839
- // invisible to the run path and the call throws "Builder keys are not
840
- // configured" even though the status endpoint correctly reports configured=true.
841
- return runWithRequestContext({ userEmail, orgId }, async () => {
842
- const projectId = await resolveBuilderBranchProjectId();
843
- if (!projectId) {
844
- setResponseStatus(event, 403);
845
- return {
846
- error: "Builder branch creation is not available for this organization yet.",
847
- };
913
+ return { ok: true };
914
+ }));
915
+ getH3App(nitroApp).use(`${P}/builder/callback`, defineEventHandler(async (event) => {
916
+ if (getMethod(event) !== "GET") {
917
+ setResponseStatus(event, 405);
918
+ return { error: "Method not allowed" };
848
919
  }
849
- const { resolveBuilderCredential: resolveBuilderCred } = await import("./credential-provider.js");
850
- const builderUserId = (await resolveBuilderCred("BUILDER_USER_ID")) || undefined;
851
- // Server-controlled projectId don't let clients target arbitrary
852
- // Builder projects with our private key. When this feature graduates
853
- // past the hardcoded preview, the projectId will come from
854
- // workspace/org config, still resolved server-side.
920
+ // A real session or a template-approved anonymous owner is required;
921
+ // the pending-row check below (combined with the same-origin gate on
922
+ // /builder/connect) blocks CSRF and callback replay.
923
+ const ownerContext = await resolveBuilderOwnerContext(event, "callback");
924
+ const ownerEmail = ownerContext.email;
925
+ // Diagnostic: log the resolver's inputs for debugging "No active
926
+ // connect flow found" reports. Reveals session-vs-state owner
927
+ // mismatches and missing/forged _an_state without leaking the
928
+ // signed token itself.
855
929
  try {
856
- const result = await runBuilderAgent({
857
- prompt,
858
- projectId,
859
- branchName: typeof body?.branchName === "string"
860
- ? body.branchName
861
- : undefined,
862
- userEmail,
863
- userId: builderUserId,
864
- });
865
- return result;
930
+ const debugSearch = new URLSearchParams((event.url?.search || "").replace(/^\?/, ""));
931
+ const stateRaw = debugSearch.get(BUILDER_STATE_PARAM);
932
+ const stateOwnerProbe = verifyBuilderCallbackStateAndGetOwner(stateRaw);
933
+ const session = await getSession(event).catch(() => null);
934
+ console.log(`[builder-callback] resolved-owner=${ownerEmail ?? "(none)"} session-email=${session?.email ?? "(none)"} state-owner=${stateOwnerProbe ?? "(none)"} state-present=${Boolean(stateRaw)} anon=${ownerContext.anonymous} host=${getHeader(event, "host") ?? "(none)"} sec-fetch-site=${getHeader(event, "sec-fetch-site") ?? "(none)"} origin=${getHeader(event, "origin") ?? "(none)"} referer=${getHeader(event, "referer") ?? "(none)"}`);
866
935
  }
867
- catch (e) {
868
- setResponseStatus(event, 500);
869
- return {
870
- error: e instanceof Error ? e.message : "Builder run failed",
871
- };
936
+ catch {
937
+ // Diagnostic logging is best-effort; do not break the callback.
872
938
  }
873
- });
874
- }));
875
- // Branch-creation waitlist signup. Used by ConnectBuilderCard when the
876
- // current request has no Builder branch project configured — instead of
877
- // the raw 403 from /builder/run, the card surfaces a waitlist CTA that
878
- // POSTs here. Recorded as a tracking event so PostHog/Mixpanel/etc.
879
- // capture demand without us standing up new storage.
880
- getH3App(nitroApp).use(`${P}/builder/branch-waitlist`, defineEventHandler(async (event) => {
881
- if (getMethod(event) !== "POST") {
882
- setResponseStatus(event, 405);
883
- return { error: "Method not allowed" };
884
- }
885
- const session = await getSession(event).catch(() => null);
886
- if (!session?.email) {
887
- setResponseStatus(event, 401);
888
- return { error: "Authentication required" };
889
- }
890
- await trackBuilderLifecycle(event, "builder branch waitlist joined", session.email, {
891
- stage: "waitlist",
892
- });
893
- return { ok: true };
894
- }));
895
- getH3App(nitroApp).use(`${P}/builder/callback`, defineEventHandler(async (event) => {
896
- if (getMethod(event) !== "GET") {
897
- setResponseStatus(event, 405);
898
- return { error: "Method not allowed" };
899
- }
900
- // A real session or a template-approved anonymous owner is required;
901
- // the pending-row check below (combined with the same-origin gate on
902
- // /builder/connect) blocks CSRF and callback replay.
903
- const ownerContext = await resolveBuilderOwnerContext(event, "callback");
904
- const ownerEmail = ownerContext.email;
905
- // Diagnostic: log the resolver's inputs for debugging "No active
906
- // connect flow found" reports. Reveals session-vs-state owner
907
- // mismatches and missing/forged _an_state without leaking the
908
- // signed token itself.
909
- try {
910
- const debugSearch = new URLSearchParams((event.url?.search || "").replace(/^\?/, ""));
911
- const stateRaw = debugSearch.get(BUILDER_STATE_PARAM);
912
- const stateOwnerProbe = verifyBuilderCallbackStateAndGetOwner(stateRaw);
913
- const session = await getSession(event).catch(() => null);
914
- console.log(`[builder-callback] resolved-owner=${ownerEmail ?? "(none)"} session-email=${session?.email ?? "(none)"} state-owner=${stateOwnerProbe ?? "(none)"} state-present=${Boolean(stateRaw)} anon=${ownerContext.anonymous} host=${getHeader(event, "host") ?? "(none)"} sec-fetch-site=${getHeader(event, "sec-fetch-site") ?? "(none)"} origin=${getHeader(event, "origin") ?? "(none)"} referer=${getHeader(event, "referer") ?? "(none)"}`);
915
- }
916
- catch {
917
- // Diagnostic logging is best-effort; do not break the callback.
918
- }
919
- if (!ownerEmail) {
920
- setResponseStatus(event, 401);
921
- return { error: "Authentication required" };
922
- }
923
- clearBuilderConnectOwnerCookie(event);
924
- const requestUrl = new URL(`${event.url?.pathname || "/"}${event.url?.search || ""}`, getOrigin(event));
925
- let connectTracking = getBuilderConnectTrackingParams(requestUrl.searchParams);
926
- // postMessage from the callback success/error pages must target the
927
- // original preview opener, not the callback server. On the fallback
928
- // path the callback is served from the env-configured gateway while
929
- // the opener lives on the preview origin. Three sources of opener
930
- // origin, in priority order:
931
- // 1. `_an_opener` — written into the callback URL's query by
932
- // buildBuilderCliAuthUrl when cli-auth's allow-list forced
933
- // preview_url onto the gateway. Survives Builder's redirect
934
- // verbatim (Builder preserves redirect_url's query string).
935
- // 2. `preview-url` — Builder echoes the top-level preview_url back
936
- // as a query param on the callback. Reflects the gateway on
937
- // the fallback path, but matches the opener on the happy path.
938
- // 3. The event's own origin — last-resort fallback.
939
- const openerOriginFromQuery = requestUrl.searchParams.get(BUILDER_OPENER_PARAM);
940
- const callbackParentOrigin = resolveSafePreviewUrl(openerOriginFromQuery, event) ||
941
- resolveSafePreviewUrl(requestUrl.searchParams.get("preview-url"), event) ||
942
- getBuilderBrowserOriginForEvent(event);
943
- const callbackStateOwner = verifyBuilderCallbackStateAndGetOwner(requestUrl.searchParams.get(BUILDER_STATE_PARAM));
944
- const hasValidCallbackState = callbackStateOwner === ownerEmail;
945
- // Verify either:
946
- // 1. the signed callback state embedded in redirect_url by
947
- // /builder/status (primary flow), or
948
- // 2. the server-side pending-connect row written by the legacy
949
- // /builder/connect trampoline.
950
- //
951
- // For the pending-row path, delete must succeed before we proceed;
952
- // otherwise a DB blip
953
- // leaves the row in place and the same callback URL can be
954
- // replayed against the same session for up to 10 minutes (the
955
- // TTL window). Treat a delete failure as a hard failure: the
956
- // user retries, the next /builder/connect call rewrites the
957
- // pending row.
958
- let pendingValid = hasValidCallbackState;
959
- let pendingError = null;
960
- try {
961
- const pending = (await getSetting(`builder-pending-connect:${ownerEmail}`));
962
- if (pending?.tracking) {
963
- connectTracking = {
964
- signupSource: connectTracking.signupSource ?? pending.tracking.signupSource,
965
- agentNativeFlow: connectTracking.agentNativeFlow ??
966
- pending.tracking.agentNativeFlow,
967
- agentNativeConnectSource: connectTracking.agentNativeConnectSource ??
968
- pending.tracking.agentNativeConnectSource,
969
- };
939
+ if (!ownerEmail) {
940
+ setResponseStatus(event, 401);
941
+ return { error: "Authentication required" };
970
942
  }
971
- if (pending &&
972
- typeof pending.expiresAt === "number" &&
973
- Date.now() < pending.expiresAt) {
974
- try {
975
- await deleteSetting(`builder-pending-connect:${ownerEmail}`);
976
- pendingValid = true;
943
+ clearBuilderConnectOwnerCookie(event);
944
+ const requestUrl = new URL(`${event.url?.pathname || "/"}${event.url?.search || ""}`, getOrigin(event));
945
+ let connectTracking = getBuilderConnectTrackingParams(requestUrl.searchParams);
946
+ // postMessage from the callback success/error pages must target the
947
+ // original preview opener, not the callback server. On the fallback
948
+ // path the callback is served from the env-configured gateway while
949
+ // the opener lives on the preview origin. Three sources of opener
950
+ // origin, in priority order:
951
+ // 1. `_an_opener` — written into the callback URL's query by
952
+ // buildBuilderCliAuthUrl when cli-auth's allow-list forced
953
+ // preview_url onto the gateway. Survives Builder's redirect
954
+ // verbatim (Builder preserves redirect_url's query string).
955
+ // 2. `preview-url` — Builder echoes the top-level preview_url back
956
+ // as a query param on the callback. Reflects the gateway on
957
+ // the fallback path, but matches the opener on the happy path.
958
+ // 3. The event's own origin — last-resort fallback.
959
+ const openerOriginFromQuery = requestUrl.searchParams.get(BUILDER_OPENER_PARAM);
960
+ const callbackParentOrigin = resolveSafePreviewUrl(openerOriginFromQuery, event) ||
961
+ resolveSafePreviewUrl(requestUrl.searchParams.get("preview-url"), event) ||
962
+ getBuilderBrowserOriginForEvent(event);
963
+ const callbackStateOwner = verifyBuilderCallbackStateAndGetOwner(requestUrl.searchParams.get(BUILDER_STATE_PARAM));
964
+ const hasValidCallbackState = callbackStateOwner === ownerEmail;
965
+ // Verify either:
966
+ // 1. the signed callback state embedded in redirect_url by
967
+ // /builder/status (primary flow), or
968
+ // 2. the server-side pending-connect row written by the legacy
969
+ // /builder/connect trampoline.
970
+ //
971
+ // For the pending-row path, delete must succeed before we proceed;
972
+ // otherwise a DB blip
973
+ // leaves the row in place and the same callback URL can be
974
+ // replayed against the same session for up to 10 minutes (the
975
+ // TTL window). Treat a delete failure as a hard failure: the
976
+ // user retries, the next /builder/connect call rewrites the
977
+ // pending row.
978
+ let pendingValid = hasValidCallbackState;
979
+ let pendingError = null;
980
+ try {
981
+ const pending = (await getSetting(`builder-pending-connect:${ownerEmail}`));
982
+ if (pending?.tracking) {
983
+ connectTracking = {
984
+ signupSource: connectTracking.signupSource ?? pending.tracking.signupSource,
985
+ agentNativeFlow: connectTracking.agentNativeFlow ??
986
+ pending.tracking.agentNativeFlow,
987
+ agentNativeConnectSource: connectTracking.agentNativeConnectSource ??
988
+ pending.tracking.agentNativeConnectSource,
989
+ };
977
990
  }
978
- catch (err) {
979
- if (!hasValidCallbackState) {
980
- pendingError =
981
- "Could not consume pending-connect token (storage error). Please retry.";
982
- console.error("[builder] deleteSetting failed for pending-connect — refusing to proceed (replay risk):", err?.message ?? err);
991
+ if (pending &&
992
+ typeof pending.expiresAt === "number" &&
993
+ Date.now() < pending.expiresAt) {
994
+ try {
995
+ await deleteSetting(`builder-pending-connect:${ownerEmail}`);
996
+ pendingValid = true;
997
+ }
998
+ catch (err) {
999
+ if (!hasValidCallbackState) {
1000
+ pendingError =
1001
+ "Could not consume pending-connect token (storage error). Please retry.";
1002
+ console.error("[builder] deleteSetting failed for pending-connect — refusing to proceed (replay risk):", err?.message ?? err);
1003
+ }
983
1004
  }
984
1005
  }
985
1006
  }
986
- }
987
- catch {
988
- // DB temporarily unavailable — treat as missing.
989
- }
990
- if (pendingError) {
991
- await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
992
- ...builderConnectTrackingProperties(connectTracking),
993
- reason: "pending_consume_storage_error",
994
- stage: "callback",
995
- });
996
- // Best-effort signal to the parent's poll loop, then render the
997
- // popup-friendly error page so the BroadcastChannel notify fires.
998
- await putSetting(`builder-connect-error:${ownerEmail}`, {
999
- message: pendingError,
1000
- at: Date.now(),
1001
- }).catch(() => { });
1002
- setResponseStatus(event, 503);
1003
- setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1004
- return createBuilderBrowserCallbackErrorPage(pendingError, {
1005
- parentOrigin: callbackParentOrigin,
1006
- });
1007
- }
1008
- if (!pendingValid) {
1009
- // Diagnostic: log the exact reason pendingValid is false so we can
1010
- // distinguish "state didn't validate" from "no pending row" in
1011
- // production "No active connect flow found" reports.
1012
- console.warn(`[builder-callback] pending-invalid owner=${ownerEmail} has-state-param=${Boolean(requestUrl.searchParams.get(BUILDER_STATE_PARAM))} state-validated=${hasValidCallbackState} pending-error=${pendingError ?? "(none)"}`);
1013
- await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1014
- ...builderConnectTrackingProperties(connectTracking),
1015
- reason: hasValidCallbackState
1016
- ? "callback_state_unexpectedly_rejected"
1017
- : "missing_pending_connect",
1018
- stage: "callback",
1019
- has_callback_state: Boolean(requestUrl.searchParams.get(BUILDER_STATE_PARAM)),
1020
- });
1021
- const msg = "No active connect flow found. Restart the Builder connect flow from Settings.";
1022
- // Write an error signal so the polling loop in the parent tab
1023
- // terminates quickly instead of waiting 5 minutes for the timeout.
1024
- try {
1007
+ catch {
1008
+ // DB temporarily unavailable — treat as missing.
1009
+ }
1010
+ if (pendingError) {
1011
+ await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1012
+ ...builderConnectTrackingProperties(connectTracking),
1013
+ reason: "pending_consume_storage_error",
1014
+ stage: "callback",
1015
+ });
1016
+ // Best-effort signal to the parent's poll loop, then render the
1017
+ // popup-friendly error page so the BroadcastChannel notify fires.
1018
+ await putSetting(`builder-connect-error:${ownerEmail}`, {
1019
+ message: pendingError,
1020
+ at: Date.now(),
1021
+ }).catch(() => { });
1022
+ setResponseStatus(event, 503);
1023
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1024
+ return createBuilderBrowserCallbackErrorPage(pendingError, {
1025
+ parentOrigin: callbackParentOrigin,
1026
+ });
1027
+ }
1028
+ if (!pendingValid) {
1029
+ // Diagnostic: log the exact reason pendingValid is false so we can
1030
+ // distinguish "state didn't validate" from "no pending row" in
1031
+ // production "No active connect flow found" reports.
1032
+ console.warn(`[builder-callback] pending-invalid owner=${ownerEmail} has-state-param=${Boolean(requestUrl.searchParams.get(BUILDER_STATE_PARAM))} state-validated=${hasValidCallbackState} pending-error=${pendingError ?? "(none)"}`);
1033
+ await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1034
+ ...builderConnectTrackingProperties(connectTracking),
1035
+ reason: hasValidCallbackState
1036
+ ? "callback_state_unexpectedly_rejected"
1037
+ : "missing_pending_connect",
1038
+ stage: "callback",
1039
+ has_callback_state: Boolean(requestUrl.searchParams.get(BUILDER_STATE_PARAM)),
1040
+ });
1041
+ const msg = "No active connect flow found. Restart the Builder connect flow from Settings.";
1042
+ // Write an error signal so the polling loop in the parent tab
1043
+ // terminates quickly instead of waiting 5 minutes for the timeout.
1044
+ try {
1045
+ await putSetting(`builder-connect-error:${ownerEmail}`, {
1046
+ message: msg,
1047
+ at: Date.now(),
1048
+ });
1049
+ }
1050
+ catch {
1051
+ // DB unavailable — parent will time out naturally.
1052
+ }
1053
+ setResponseStatus(event, 403);
1054
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1055
+ return createBuilderBrowserCallbackErrorPage(msg, {
1056
+ parentOrigin: callbackParentOrigin,
1057
+ });
1058
+ }
1059
+ const privateKey = requestUrl.searchParams.get("p-key");
1060
+ const publicKey = requestUrl.searchParams.get("api-key");
1061
+ if (!privateKey || !publicKey) {
1062
+ await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1063
+ ...builderConnectTrackingProperties(connectTracking),
1064
+ reason: "missing_credentials",
1065
+ stage: "callback",
1066
+ });
1067
+ // Render the popup-friendly error page (and write a status row)
1068
+ // instead of bare JSON, so the parent tab's poll loop terminates
1069
+ // immediately via BroadcastChannel rather than hanging until the
1070
+ // 5-minute timeout.
1071
+ const msg = "Builder didn't return credentials. Restart the connect flow from settings.";
1025
1072
  await putSetting(`builder-connect-error:${ownerEmail}`, {
1026
1073
  message: msg,
1027
1074
  at: Date.now(),
1075
+ }).catch(() => { });
1076
+ setResponseStatus(event, 400);
1077
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1078
+ return createBuilderBrowserCallbackErrorPage(msg, {
1079
+ parentOrigin: callbackParentOrigin,
1080
+ });
1081
+ }
1082
+ const userId = requestUrl.searchParams.get("user-id");
1083
+ const orgName = requestUrl.searchParams.get("org-name");
1084
+ const orgKind = requestUrl.searchParams.get("kind");
1085
+ // Store per-user in app_secrets so each user's Builder connection
1086
+ // is independent. No more shared env vars that the last connector
1087
+ // overwrites.
1088
+ //
1089
+ // Failure handling: a silent catch here (returning the success page
1090
+ // anyway) was Midhun's bug on 2026-04-28 — popup said "yay", parent
1091
+ // window polled `/builder/status` for 5 minutes seeing
1092
+ // configured:false, never got a real error. Now we surface the
1093
+ // failure two ways: (a) a settings row that the next /builder/status
1094
+ // poll picks up, and (b) postMessage from the error page itself,
1095
+ // wired into the popup HTML, so the parent stops polling immediately.
1096
+ let writeError = null;
1097
+ try {
1098
+ const { writeBuilderCredentials } = await import("./credential-provider.js");
1099
+ // Resolve the user's active org / role so the credentials land
1100
+ // at org scope when an owner/admin is connecting (everyone in
1101
+ // the org auto-resolves them on next chat call). Members and
1102
+ // users with no active org silently fall back to user scope.
1103
+ // Failure to read org context is non-fatal — we just keep the
1104
+ // legacy per-user behaviour for that connection.
1105
+ let orgId = null;
1106
+ let role = null;
1107
+ if (!ownerContext.anonymous) {
1108
+ try {
1109
+ const { getOrgContext } = await import("../org/context.js");
1110
+ const orgCtx = await getOrgContext(event);
1111
+ orgId = orgCtx.orgId ?? null;
1112
+ role = orgCtx.role ?? null;
1113
+ }
1114
+ catch {
1115
+ /* org module not present in this template — keep user scope */
1116
+ }
1117
+ }
1118
+ const target = await writeBuilderCredentials(ownerEmail, { privateKey, publicKey, userId, orgName, orgKind }, { orgId, role });
1119
+ console.log(`[builder-connect] wrote credentials email=${ownerEmail} requestOrgId=${orgId ?? "(none)"} role=${role ?? "(none)"} scope=${target.scope} scopeId=${target.scopeId}`);
1120
+ }
1121
+ catch (err) {
1122
+ writeError = err?.message ?? String(err);
1123
+ console.error("[builder] Failed to persist Builder credentials:", writeError);
1124
+ }
1125
+ if (writeError) {
1126
+ await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1127
+ ...builderConnectTrackingProperties(connectTracking),
1128
+ reason: "credential_write_failed",
1129
+ stage: "callback",
1130
+ });
1131
+ // Best-effort signal to /builder/status. If putSetting also fails
1132
+ // (entire DB unreachable) the popup's postMessage still notifies
1133
+ // the parent. If both fail the parent times out at 5min as today.
1134
+ try {
1135
+ await putSetting(`builder-connect-error:${ownerEmail}`, {
1136
+ message: writeError,
1137
+ at: Date.now(),
1138
+ });
1139
+ }
1140
+ catch (settingsErr) {
1141
+ console.error("[builder] Couldn't even record connect-error to settings:", settingsErr?.message ?? settingsErr);
1142
+ }
1143
+ setResponseStatus(event, 500);
1144
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1145
+ return createBuilderBrowserCallbackErrorPage(writeError, {
1146
+ parentOrigin: callbackParentOrigin,
1028
1147
  });
1029
1148
  }
1149
+ // Clear any legacy disconnect flag and any prior connect-error row
1150
+ // (so a successful retry doesn't surface the previous failure).
1151
+ try {
1152
+ await deleteSetting("builder-disconnected");
1153
+ }
1030
1154
  catch {
1031
- // DB unavailableparent will time out naturally.
1155
+ // DB not ready proceed
1032
1156
  }
1033
- setResponseStatus(event, 403);
1034
- setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1035
- return createBuilderBrowserCallbackErrorPage(msg, {
1036
- parentOrigin: callbackParentOrigin,
1157
+ try {
1158
+ await deleteSetting(`builder-connect-error:${ownerEmail}`);
1159
+ }
1160
+ catch {
1161
+ // No prior error row — fine
1162
+ }
1163
+ const previewUrl = resolveBuilderCallbackReturnUrl({
1164
+ event,
1165
+ openerOrigin: openerOriginFromQuery,
1166
+ previewUrl: requestUrl.searchParams.get("preview-url"),
1037
1167
  });
1038
- }
1039
- const privateKey = requestUrl.searchParams.get("p-key");
1040
- const publicKey = requestUrl.searchParams.get("api-key");
1041
- if (!privateKey || !publicKey) {
1042
- await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1168
+ await trackBuilderLifecycle(event, "builder connect succeeded", ownerEmail, {
1043
1169
  ...builderConnectTrackingProperties(connectTracking),
1044
- reason: "missing_credentials",
1045
1170
  stage: "callback",
1171
+ has_preview_url: Boolean(previewUrl),
1172
+ org_kind: orgKind || undefined,
1046
1173
  });
1047
- // Render the popup-friendly error page (and write a status row)
1048
- // instead of bare JSON, so the parent tab's poll loop terminates
1049
- // immediately via BroadcastChannel rather than hanging until the
1050
- // 5-minute timeout.
1051
- const msg = "Builder didn't return credentials. Restart the connect flow from settings.";
1052
- await putSetting(`builder-connect-error:${ownerEmail}`, {
1053
- message: msg,
1054
- at: Date.now(),
1055
- }).catch(() => { });
1056
- setResponseStatus(event, 400);
1057
1174
  setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1058
- return createBuilderBrowserCallbackErrorPage(msg, {
1175
+ // The parent (opener) is the original preview surface that started the
1176
+ // connect flow, NOT the callback server's own origin — when the
1177
+ // env-configured gateway is used as the callback fallback (because
1178
+ // Builder rejects the preview host), the callback server and the
1179
+ // opener live on different origins, and postMessage to the gateway
1180
+ // origin would be dropped by the preview opener. callbackParentOrigin
1181
+ // is the precomputed best-available opener origin (`_an_opener` →
1182
+ // `preview-url` → event origin).
1183
+ return createBuilderBrowserCallbackPage(previewUrl, {
1059
1184
  parentOrigin: callbackParentOrigin,
1060
1185
  });
1061
- }
1062
- const userId = requestUrl.searchParams.get("user-id");
1063
- const orgName = requestUrl.searchParams.get("org-name");
1064
- const orgKind = requestUrl.searchParams.get("kind");
1065
- // Store per-user in app_secrets so each user's Builder connection
1066
- // is independent. No more shared env vars that the last connector
1067
- // overwrites.
1068
- //
1069
- // Failure handling: a silent catch here (returning the success page
1070
- // anyway) was Midhun's bug on 2026-04-28 — popup said "yay", parent
1071
- // window polled `/builder/status` for 5 minutes seeing
1072
- // configured:false, never got a real error. Now we surface the
1073
- // failure two ways: (a) a settings row that the next /builder/status
1074
- // poll picks up, and (b) postMessage from the error page itself,
1075
- // wired into the popup HTML, so the parent stops polling immediately.
1076
- let writeError = null;
1077
- try {
1078
- const { writeBuilderCredentials } = await import("./credential-provider.js");
1079
- // Resolve the user's active org / role so the credentials land
1080
- // at org scope when an owner/admin is connecting (everyone in
1081
- // the org auto-resolves them on next chat call). Members and
1082
- // users with no active org silently fall back to user scope.
1083
- // Failure to read org context is non-fatal — we just keep the
1084
- // legacy per-user behaviour for that connection.
1186
+ }));
1187
+ // POST /_agent-native/builder/disconnect — revoke the user's per-user
1188
+ // or org-scoped Builder credentials in app_secrets. Deploy-level env
1189
+ // credentials are never mutated here; if env is configured it remains as
1190
+ // the fallback after request-scoped credentials are removed.
1191
+ getH3App(nitroApp).use(`${P}/builder/disconnect`, defineEventHandler(async (event) => {
1192
+ if (getMethod(event) !== "POST") {
1193
+ setResponseStatus(event, 405);
1194
+ return { error: "Method not allowed" };
1195
+ }
1196
+ const session = await getSession(event).catch(() => null);
1197
+ if (!session?.email) {
1198
+ setResponseStatus(event, 401);
1199
+ return { error: "unauthorized" };
1200
+ }
1201
+ const { deleteBuilderCredentials } = await import("./credential-provider.js");
1202
+ // Mirror the connect-side scope decision so disconnect undoes
1203
+ // exactly what connect wrote: owner/admin connections land at
1204
+ // org scope and tear down at org scope; member or no-org
1205
+ // connections stay user-scoped on both ends. Symmetric, so a
1206
+ // single Disconnect press always reverses what the same user's
1207
+ // Connect press did.
1085
1208
  let orgId = null;
1086
1209
  let role = null;
1087
- if (!ownerContext.anonymous) {
1088
- try {
1089
- const { getOrgContext } = await import("../org/context.js");
1090
- const orgCtx = await getOrgContext(event);
1091
- orgId = orgCtx.orgId ?? null;
1092
- role = orgCtx.role ?? null;
1093
- }
1094
- catch {
1095
- /* org module not present in this template — keep user scope */
1096
- }
1097
- }
1098
- const target = await writeBuilderCredentials(ownerEmail, { privateKey, publicKey, userId, orgName, orgKind }, { orgId, role });
1099
- console.log(`[builder-connect] wrote credentials email=${ownerEmail} requestOrgId=${orgId ?? "(none)"} role=${role ?? "(none)"} scope=${target.scope} scopeId=${target.scopeId}`);
1100
- }
1101
- catch (err) {
1102
- writeError = err?.message ?? String(err);
1103
- console.error("[builder] Failed to persist Builder credentials:", writeError);
1104
- }
1105
- if (writeError) {
1106
- await trackBuilderLifecycle(event, "builder connect failed", ownerEmail, {
1107
- ...builderConnectTrackingProperties(connectTracking),
1108
- reason: "credential_write_failed",
1109
- stage: "callback",
1110
- });
1111
- // Best-effort signal to /builder/status. If putSetting also fails
1112
- // (entire DB unreachable) the popup's postMessage still notifies
1113
- // the parent. If both fail the parent times out at 5min as today.
1114
1210
  try {
1115
- await putSetting(`builder-connect-error:${ownerEmail}`, {
1116
- message: writeError,
1117
- at: Date.now(),
1118
- });
1211
+ const { getOrgContext } = await import("../org/context.js");
1212
+ const orgCtx = await getOrgContext(event);
1213
+ orgId = orgCtx.orgId ?? null;
1214
+ role = orgCtx.role ?? null;
1119
1215
  }
1120
- catch (settingsErr) {
1121
- console.error("[builder] Couldn't even record connect-error to settings:", settingsErr?.message ?? settingsErr);
1216
+ catch {
1217
+ /* org module not present keep user scope */
1122
1218
  }
1123
- setResponseStatus(event, 500);
1124
- setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1125
- return createBuilderBrowserCallbackErrorPage(writeError, {
1126
- parentOrigin: callbackParentOrigin,
1127
- });
1128
- }
1129
- // Clear any legacy disconnect flag and any prior connect-error row
1130
- // (so a successful retry doesn't surface the previous failure).
1131
- try {
1132
- await deleteSetting("builder-disconnected");
1133
- }
1134
- catch {
1135
- // DB not ready — proceed
1136
- }
1137
- try {
1138
- await deleteSetting(`builder-connect-error:${ownerEmail}`);
1139
- }
1140
- catch {
1141
- // No prior error row — fine
1142
- }
1143
- const previewUrl = resolveBuilderCallbackReturnUrl({
1144
- event,
1145
- openerOrigin: openerOriginFromQuery,
1146
- previewUrl: requestUrl.searchParams.get("preview-url"),
1147
- });
1148
- await trackBuilderLifecycle(event, "builder connect succeeded", ownerEmail, {
1149
- ...builderConnectTrackingProperties(connectTracking),
1150
- stage: "callback",
1151
- has_preview_url: Boolean(previewUrl),
1152
- org_kind: orgKind || undefined,
1153
- });
1154
- setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
1155
- // The parent (opener) is the original preview surface that started the
1156
- // connect flow, NOT the callback server's own origin — when the
1157
- // env-configured gateway is used as the callback fallback (because
1158
- // Builder rejects the preview host), the callback server and the
1159
- // opener live on different origins, and postMessage to the gateway
1160
- // origin would be dropped by the preview opener. callbackParentOrigin
1161
- // is the precomputed best-available opener origin (`_an_opener` →
1162
- // `preview-url` → event origin).
1163
- return createBuilderBrowserCallbackPage(previewUrl, {
1164
- parentOrigin: callbackParentOrigin,
1165
- });
1166
- }));
1167
- // POST /_agent-native/builder/disconnect — revoke the user's per-user
1168
- // or org-scoped Builder credentials in app_secrets. Deploy-level env
1169
- // credentials are never mutated here; if env is configured it remains as
1170
- // the fallback after request-scoped credentials are removed.
1171
- getH3App(nitroApp).use(`${P}/builder/disconnect`, defineEventHandler(async (event) => {
1172
- if (getMethod(event) !== "POST") {
1173
- setResponseStatus(event, 405);
1174
- return { error: "Method not allowed" };
1175
- }
1176
- const session = await getSession(event).catch(() => null);
1177
- if (!session?.email) {
1178
- setResponseStatus(event, 401);
1179
- return { error: "unauthorized" };
1180
- }
1181
- const { deleteBuilderCredentials } = await import("./credential-provider.js");
1182
- // Mirror the connect-side scope decision so disconnect undoes
1183
- // exactly what connect wrote: owner/admin connections land at
1184
- // org scope and tear down at org scope; member or no-org
1185
- // connections stay user-scoped on both ends. Symmetric, so a
1186
- // single Disconnect press always reverses what the same user's
1187
- // Connect press did.
1188
- let orgId = null;
1189
- let role = null;
1190
- try {
1191
- const { getOrgContext } = await import("../org/context.js");
1192
- const orgCtx = await getOrgContext(event);
1193
- orgId = orgCtx.orgId ?? null;
1194
- role = orgCtx.role ?? null;
1195
- }
1196
- catch {
1197
- /* org module not present — keep user scope */
1198
- }
1199
- try {
1200
- await deleteBuilderCredentials(session.email, { orgId, role });
1201
- }
1202
- catch (err) {
1203
- await trackBuilderLifecycle(event, "builder disconnect failed", session.email, {
1204
- reason: "credential_delete_failed",
1205
- });
1206
- setResponseStatus(event, 500);
1207
- return {
1208
- ok: false,
1209
- error: "Could not remove Builder credentials — your connection is unchanged. Please retry.",
1210
- cause: err instanceof Error ? err.message : String(err),
1211
- };
1212
- }
1213
- await trackBuilderLifecycle(event, "builder disconnect succeeded", session.email);
1214
- return { ok: true };
1215
- }));
1216
- // Proxy to Builder's agents-run API for background code changes.
1217
- getH3App(nitroApp).use(`${P}/builder/agents-run`, defineEventHandler(async (event) => {
1218
- if (getMethod(event) !== "POST") {
1219
- setResponseStatus(event, 405);
1220
- return { error: "Method not allowed" };
1221
- }
1222
- const session = await getSession(event).catch(() => null);
1223
- if (!session?.email) {
1224
- setResponseStatus(event, 401);
1225
- return { error: "unauthorized" };
1226
- }
1227
- return runWithRequestContext({ userEmail: session.email, orgId: session.orgId ?? undefined }, async () => {
1228
- const { resolveBuilderCredentials: resolveCreds } = await import("./credential-provider.js");
1229
- const creds = await resolveCreds();
1230
- if (!creds.privateKey || !creds.publicKey) {
1231
- setResponseStatus(event, 400);
1219
+ try {
1220
+ await deleteBuilderCredentials(session.email, { orgId, role });
1221
+ }
1222
+ catch (err) {
1223
+ await trackBuilderLifecycle(event, "builder disconnect failed", session.email, {
1224
+ reason: "credential_delete_failed",
1225
+ });
1226
+ setResponseStatus(event, 500);
1232
1227
  return {
1233
- error: "Builder not connected. Connect Builder in Setup to use background agent.",
1228
+ ok: false,
1229
+ error: "Could not remove Builder credentials — your connection is unchanged. Please retry.",
1230
+ cause: err instanceof Error ? err.message : String(err),
1234
1231
  };
1235
1232
  }
1236
- const body = (await readBody(event));
1237
- if (!body?.userMessage) {
1238
- setResponseStatus(event, 400);
1239
- return { error: "userMessage is required" };
1233
+ await trackBuilderLifecycle(event, "builder disconnect succeeded", session.email);
1234
+ return { ok: true };
1235
+ }));
1236
+ // Proxy to Builder's agents-run API for background code changes.
1237
+ getH3App(nitroApp).use(`${P}/builder/agents-run`, defineEventHandler(async (event) => {
1238
+ if (getMethod(event) !== "POST") {
1239
+ setResponseStatus(event, 405);
1240
+ return { error: "Method not allowed" };
1240
1241
  }
1241
- const apiHost = process.env.BUILDER_API_HOST || "https://api.builder.io";
1242
- try {
1243
- const res = await fetch(`${apiHost}/agents/run?apiKey=${encodeURIComponent(creds.publicKey)}`, {
1244
- method: "POST",
1245
- headers: {
1246
- "Content-Type": "application/json",
1247
- Authorization: `Bearer ${creds.privateKey}`,
1248
- },
1249
- body: JSON.stringify({
1250
- userMessage: {
1251
- userPrompt: body.userMessage,
1242
+ const session = await getSession(event).catch(() => null);
1243
+ if (!session?.email) {
1244
+ setResponseStatus(event, 401);
1245
+ return { error: "unauthorized" };
1246
+ }
1247
+ return runWithRequestContext({ userEmail: session.email, orgId: session.orgId ?? undefined }, async () => {
1248
+ const { resolveBuilderCredentials: resolveCreds } = await import("./credential-provider.js");
1249
+ const creds = await resolveCreds();
1250
+ if (!creds.privateKey || !creds.publicKey) {
1251
+ setResponseStatus(event, 400);
1252
+ return {
1253
+ error: "Builder not connected. Connect Builder in Setup to use background agent.",
1254
+ };
1255
+ }
1256
+ const body = (await readBody(event));
1257
+ if (!body?.userMessage) {
1258
+ setResponseStatus(event, 400);
1259
+ return { error: "userMessage is required" };
1260
+ }
1261
+ const apiHost = process.env.BUILDER_API_HOST || "https://api.builder.io";
1262
+ try {
1263
+ const res = await fetch(`${apiHost}/agents/run?apiKey=${encodeURIComponent(creds.publicKey)}`, {
1264
+ method: "POST",
1265
+ headers: {
1266
+ "Content-Type": "application/json",
1267
+ Authorization: `Bearer ${creds.privateKey}`,
1252
1268
  },
1253
- branchName: body.branchName,
1254
- }),
1255
- });
1256
- if (!res.ok) {
1257
- const err = await res.text().catch(() => "Unknown error");
1258
- setResponseStatus(event, res.status);
1269
+ body: JSON.stringify({
1270
+ userMessage: {
1271
+ userPrompt: body.userMessage,
1272
+ },
1273
+ branchName: body.branchName,
1274
+ }),
1275
+ });
1276
+ if (!res.ok) {
1277
+ const err = await res.text().catch(() => "Unknown error");
1278
+ setResponseStatus(event, res.status);
1279
+ return {
1280
+ error: redactValues(err, [
1281
+ creds.privateKey,
1282
+ creds.publicKey,
1283
+ ]),
1284
+ };
1285
+ }
1286
+ return await res.json();
1287
+ }
1288
+ catch (err) {
1289
+ setResponseStatus(event, 500);
1259
1290
  return {
1260
- error: redactValues(err, [creds.privateKey, creds.publicKey]),
1291
+ error: redactValues(err?.message || "Failed to reach Builder agents-run API", [creds.privateKey, creds.publicKey]),
1261
1292
  };
1262
1293
  }
1263
- return await res.json();
1264
- }
1265
- catch (err) {
1266
- setResponseStatus(event, 500);
1267
- return {
1268
- error: redactValues(err?.message || "Failed to reach Builder agents-run API", [creds.privateKey, creds.publicKey]),
1269
- };
1270
- }
1271
- });
1272
- }));
1273
- // Env key management — framework keys are always included
1274
- const frameworkEnvKeys = [
1275
- { key: "ENABLE_BUILDER", label: "Enable Builder.io features" },
1294
+ });
1295
+ }));
1296
+ // Env key management — framework keys are always included
1297
+ const frameworkEnvKeys = [
1298
+ { key: "ENABLE_BUILDER", label: "Enable Builder.io features" },
1299
+ {
1300
+ key: "AGENT_ENGINE_PREFER_BYO_KEY",
1301
+ label: "Prefer BYO LLM key over Builder gateway (default: false — gateway wins)",
1302
+ },
1303
+ ...Object.values(PROVIDER_ENV_META).map(({ envVar, label }) => ({
1304
+ key: envVar,
1305
+ label,
1306
+ })),
1307
+ ];
1276
1308
  {
1277
- key: "AGENT_ENGINE_PREFER_BYO_KEY",
1278
- label: "Prefer BYO LLM key over Builder gateway (default: false — gateway wins)",
1279
- },
1280
- ...Object.values(PROVIDER_ENV_META).map(({ envVar, label }) => ({
1281
- key: envVar,
1282
- label,
1283
- })),
1284
- ];
1285
- {
1286
- const envKeys = [...frameworkEnvKeys, ...(options.envKeys ?? [])];
1287
- // Onboarding form fields are resolved per-request so late-registered
1288
- // steps (and template overrides) are picked up without a restart.
1289
- // Builder CLI auth writes scoped Builder credentials through the
1290
- // credential provider, never through the deploy-global env sink.
1291
- const collectOnboardingKeys = () => {
1292
- const keys = new Set();
1293
- for (const step of listOnboardingSteps()) {
1294
- for (const method of step.methods) {
1295
- if (method.kind === "form") {
1296
- for (const field of method.payload.fields) {
1297
- if (field?.key)
1298
- keys.add(field.key);
1309
+ const envKeys = [...frameworkEnvKeys, ...(options.envKeys ?? [])];
1310
+ // Onboarding form fields are resolved per-request so late-registered
1311
+ // steps (and template overrides) are picked up without a restart.
1312
+ // Builder CLI auth writes scoped Builder credentials through the
1313
+ // credential provider, never through the deploy-global env sink.
1314
+ const collectOnboardingKeys = () => {
1315
+ const keys = new Set();
1316
+ for (const step of listOnboardingSteps()) {
1317
+ for (const method of step.methods) {
1318
+ if (method.kind === "form") {
1319
+ for (const field of method.payload.fields) {
1320
+ if (field?.key)
1321
+ keys.add(field.key);
1322
+ }
1299
1323
  }
1300
1324
  }
1301
1325
  }
1302
- }
1303
- return keys;
1304
- };
1305
- getH3App(nitroApp).use(`${P}/env-status`, defineEventHandler(async (event) => {
1306
- const session = await getSession(event).catch(() => null);
1307
- const userEmail = session?.email;
1308
- let orgId;
1309
- if (userEmail) {
1310
- try {
1311
- const orgCtx = await getOrgContext(event);
1312
- orgId = orgCtx.orgId ?? undefined;
1326
+ return keys;
1327
+ };
1328
+ getH3App(nitroApp).use(`${P}/env-status`, defineEventHandler(async (event) => {
1329
+ const session = await getSession(event).catch(() => null);
1330
+ const userEmail = session?.email;
1331
+ let orgId;
1332
+ if (userEmail) {
1333
+ try {
1334
+ const orgCtx = await getOrgContext(event);
1335
+ orgId = orgCtx.orgId ?? undefined;
1336
+ }
1337
+ catch {
1338
+ /* org module not present in this template */
1339
+ }
1340
+ }
1341
+ const canUseDeployEnv = await runWithRequestContext({ userEmail, orgId }, () => canUseDeployCredentialFallbackForRequest());
1342
+ return envKeys.map((cfg) => {
1343
+ const isProviderKey = PROVIDER_ENV_VAR_KEYS.has(cfg.key);
1344
+ return {
1345
+ key: cfg.key,
1346
+ label: cfg.label,
1347
+ required: cfg.required ?? false,
1348
+ configured: !!process.env[cfg.key] && (!isProviderKey || canUseDeployEnv),
1349
+ ...(cfg.helpText ? { helpText: cfg.helpText } : {}),
1350
+ };
1351
+ });
1352
+ }));
1353
+ getH3App(nitroApp).use(`${P}/env-vars`, defineEventHandler(async (event) => {
1354
+ if (getMethod(event) !== "POST") {
1355
+ setResponseStatus(event, 405);
1356
+ return { error: "Method not allowed" };
1357
+ }
1358
+ // Env vars are deployment-wide globals, not per-tenant. On any
1359
+ // shared-DB multi-tenant deploy, allowing authenticated users to
1360
+ // write here lets one tenant overwrite Stripe / OpenAI / Sentry
1361
+ // keys for every other tenant. Disable the endpoint outside of
1362
+ // local-dev SQLite or an explicit single-tenant opt-in, and
1363
+ // direct callers to the per-org credential store instead.
1364
+ if (!isEnvVarWriteAllowed()) {
1365
+ setResponseStatus(event, 403);
1366
+ return {
1367
+ error: "env-vars endpoint disabled on multi-tenant deployments. Use saveCredential(key, value, { userEmail, orgId, scope: 'org' }) to store per-org credentials.",
1368
+ };
1369
+ }
1370
+ const body = await readBody(event);
1371
+ const { vars } = body;
1372
+ if (!Array.isArray(vars) || vars.length === 0) {
1373
+ setResponseStatus(event, 400);
1374
+ return { error: "vars array required" };
1375
+ }
1376
+ const allowedKeys = new Set([
1377
+ ...envKeys.map((k) => k.key),
1378
+ ...collectOnboardingKeys(),
1379
+ ]);
1380
+ const blockedEnvVarWriteKeys = new Set(BUILDER_ENV_KEYS);
1381
+ const isWritableEnvKey = (key) => allowedKeys.has(key) && !blockedEnvVarWriteKeys.has(key);
1382
+ const filtered = vars.filter((v) => typeof v.key === "string" &&
1383
+ isWritableEnvKey(v.key) &&
1384
+ typeof v.value === "string" &&
1385
+ v.value.trim().length > 0);
1386
+ if (filtered.length === 0) {
1387
+ setResponseStatus(event, 400);
1388
+ const rejectedEmpty = vars.some((v) => typeof v.key === "string" &&
1389
+ isWritableEnvKey(v.key) &&
1390
+ (typeof v.value !== "string" || v.value.trim().length === 0));
1391
+ return {
1392
+ error: rejectedEmpty
1393
+ ? "Env values must be non-empty — refusing to clear a saved key"
1394
+ : "No recognized env keys in request",
1395
+ };
1396
+ }
1397
+ // Write to .env file. When inside a workspace, write to the
1398
+ // workspace root .env so keys are shared across every app. The
1399
+ // per-app .env still wins at load time if it also defines a key.
1400
+ try {
1401
+ const scope = body?.scope ?? "auto";
1402
+ const workspaceRoot = findWorkspaceRoot(process.cwd());
1403
+ const envPath = scope === "app"
1404
+ ? path.join(process.cwd(), ".env")
1405
+ : workspaceRoot
1406
+ ? path.join(workspaceRoot, ".env")
1407
+ : path.join(process.cwd(), ".env");
1408
+ await upsertEnvFile(envPath, filtered);
1409
+ }
1410
+ catch {
1411
+ // Edge runtime — skip file write
1412
+ }
1413
+ // Update process.env immediately
1414
+ for (const { key, value } of filtered) {
1415
+ process.env[key] = value;
1416
+ }
1417
+ // Persist to settings table for serverless cold-start recovery.
1418
+ try {
1419
+ const envMap = {};
1420
+ for (const { key, value } of filtered)
1421
+ envMap[key] = value;
1422
+ const existing = (await getSetting("persisted-env-vars")) ?? {};
1423
+ await putSetting("persisted-env-vars", {
1424
+ ...existing,
1425
+ ...envMap,
1426
+ });
1427
+ }
1428
+ catch {
1429
+ // DB not ready yet — skip
1430
+ }
1431
+ return { saved: filtered.map((v) => v.key) };
1432
+ }));
1433
+ }
1434
+ // GET /_agent-native/agent-engine/status — reports whether an engine
1435
+ // is configured (settings row, settings+env, or auto-detected from env).
1436
+ // The agent-chat UI uses this to skip the onboarding gate for providers
1437
+ // not in the env-status list (OpenRouter, Groq, Ollama, …).
1438
+ getH3App(nitroApp).use(`${P}/agent-engine/status`, defineEventHandler(async (event) => {
1439
+ try {
1440
+ const session = await getSession(event).catch(() => null);
1441
+ const userEmail = session?.email;
1442
+ let orgId;
1443
+ if (userEmail) {
1444
+ try {
1445
+ const orgCtx = await getOrgContext(event);
1446
+ orgId = orgCtx.orgId ?? undefined;
1447
+ }
1448
+ catch {
1449
+ /* org module not present in this template */
1450
+ }
1451
+ }
1452
+ const stored = (await getSetting("agent-engine"));
1453
+ if (isAgentEngineSettingConfigured(stored)) {
1454
+ const engine = stored.engine;
1455
+ const entry = getAgentEngineEntry(engine);
1456
+ return {
1457
+ configured: true,
1458
+ engine,
1459
+ model: stored.model ?? entry?.defaultModel ?? DEFAULT_MODEL,
1460
+ source: "settings",
1461
+ };
1462
+ }
1463
+ const envEntry = process.env.AGENT_ENGINE
1464
+ ? getAgentEngineEntry(process.env.AGENT_ENGINE)
1465
+ : undefined;
1466
+ if (envEntry) {
1467
+ if (!isAgentEnginePackageInstalled(envEntry)) {
1468
+ return { configured: false };
1469
+ }
1470
+ return {
1471
+ configured: true,
1472
+ engine: envEntry.name,
1473
+ model: envEntry.defaultModel ?? DEFAULT_MODEL,
1474
+ source: "env",
1475
+ envVar: "AGENT_ENGINE",
1476
+ };
1477
+ }
1478
+ // Per-user app_secrets — a user who connected Builder (or pasted
1479
+ // their own provider key) may not have any deploy-level env vars
1480
+ // set, so check their per-user secret store before reporting "no
1481
+ // engine configured" and re-showing the onboarding gate.
1482
+ const detectedFromUser = await runWithRequestContext({ userEmail, orgId }, () => detectEngineFromUserSecrets());
1483
+ if (detectedFromUser?.name === "builder") {
1484
+ return {
1485
+ configured: true,
1486
+ engine: detectedFromUser.name,
1487
+ model: detectedFromUser.defaultModel ?? DEFAULT_MODEL,
1488
+ source: "app_secrets",
1489
+ envVar: detectedFromUser.requiredEnvVars[0],
1490
+ };
1491
+ }
1492
+ if (stored && typeof stored.engine === "string") {
1493
+ const entry = getAgentEngineEntry(stored.engine);
1494
+ if (entry &&
1495
+ (await runWithRequestContext({ userEmail, orgId }, () => isStoredEngineUsableForRequest(stored, entry)))) {
1496
+ return {
1497
+ configured: true,
1498
+ engine: stored.engine,
1499
+ model: stored.model ?? entry.defaultModel ?? DEFAULT_MODEL,
1500
+ source: "env",
1501
+ envVar: entry.requiredEnvVars[0],
1502
+ };
1503
+ }
1504
+ }
1505
+ if (detectedFromUser) {
1506
+ return {
1507
+ configured: true,
1508
+ engine: detectedFromUser.name,
1509
+ model: detectedFromUser.defaultModel ?? DEFAULT_MODEL,
1510
+ source: "app_secrets",
1511
+ envVar: detectedFromUser.requiredEnvVars[0],
1512
+ };
1313
1513
  }
1314
- catch {
1315
- /* org module not present in this template */
1514
+ const canUseDeployEnv = await runWithRequestContext({ userEmail, orgId }, () => canUseDeployCredentialFallbackForRequest());
1515
+ const detected = canUseDeployEnv ? detectEngineFromEnv() : null;
1516
+ if (detected) {
1517
+ return {
1518
+ configured: true,
1519
+ engine: detected.name,
1520
+ model: detected.defaultModel ?? DEFAULT_MODEL,
1521
+ source: "env",
1522
+ envVar: detected.requiredEnvVars[0],
1523
+ };
1316
1524
  }
1317
1525
  }
1318
- const canUseDeployEnv = await runWithRequestContext({ userEmail, orgId }, () => canUseDeployCredentialFallbackForRequest());
1319
- return envKeys.map((cfg) => {
1320
- const isProviderKey = PROVIDER_ENV_VAR_KEYS.has(cfg.key);
1321
- return {
1322
- key: cfg.key,
1323
- label: cfg.label,
1324
- required: cfg.required ?? false,
1325
- configured: !!process.env[cfg.key] && (!isProviderKey || canUseDeployEnv),
1326
- ...(cfg.helpText ? { helpText: cfg.helpText } : {}),
1327
- };
1328
- });
1526
+ catch { }
1527
+ return { configured: false };
1329
1528
  }));
1330
- getH3App(nitroApp).use(`${P}/env-vars`, defineEventHandler(async (event) => {
1529
+ // POST /_agent-native/agent-engine/disconnect clear the agent-engine
1530
+ // setting. Env vars are left alone so the next chat turn falls back to
1531
+ // resolveEngine's env/default resolution.
1532
+ getH3App(nitroApp).use(`${P}/agent-engine/disconnect`, defineEventHandler(async (event) => {
1331
1533
  if (getMethod(event) !== "POST") {
1332
1534
  setResponseStatus(event, 405);
1333
1535
  return { error: "Method not allowed" };
1334
1536
  }
1335
- // Env vars are deployment-wide globals, not per-tenant. On any
1336
- // shared-DB multi-tenant deploy, allowing authenticated users to
1337
- // write here lets one tenant overwrite Stripe / OpenAI / Sentry
1338
- // keys for every other tenant. Disable the endpoint outside of
1339
- // local-dev SQLite or an explicit single-tenant opt-in, and
1340
- // direct callers to the per-org credential store instead.
1341
- if (!isEnvVarWriteAllowed()) {
1342
- setResponseStatus(event, 403);
1343
- return {
1344
- error: "env-vars endpoint disabled on multi-tenant deployments. Use saveCredential(key, value, { userEmail, orgId, scope: 'org' }) to store per-org credentials.",
1345
- };
1537
+ const session = await getSession(event).catch(() => null);
1538
+ if (!session?.email) {
1539
+ setResponseStatus(event, 401);
1540
+ return { error: "unauthorized" };
1346
1541
  }
1347
- const body = await readBody(event);
1348
- const { vars } = body;
1349
- if (!Array.isArray(vars) || vars.length === 0) {
1350
- setResponseStatus(event, 400);
1351
- return { error: "vars array required" };
1542
+ try {
1543
+ await deleteSetting("agent-engine");
1544
+ return { ok: true };
1352
1545
  }
1353
- const allowedKeys = new Set([
1354
- ...envKeys.map((k) => k.key),
1355
- ...collectOnboardingKeys(),
1356
- ]);
1357
- const blockedEnvVarWriteKeys = new Set(BUILDER_ENV_KEYS);
1358
- const isWritableEnvKey = (key) => allowedKeys.has(key) && !blockedEnvVarWriteKeys.has(key);
1359
- const filtered = vars.filter((v) => typeof v.key === "string" &&
1360
- isWritableEnvKey(v.key) &&
1361
- typeof v.value === "string" &&
1362
- v.value.trim().length > 0);
1363
- if (filtered.length === 0) {
1364
- setResponseStatus(event, 400);
1365
- const rejectedEmpty = vars.some((v) => typeof v.key === "string" &&
1366
- isWritableEnvKey(v.key) &&
1367
- (typeof v.value !== "string" || v.value.trim().length === 0));
1546
+ catch (err) {
1547
+ setResponseStatus(event, 500);
1368
1548
  return {
1369
- error: rejectedEmpty
1370
- ? "Env values must be non-empty refusing to clear a saved key"
1371
- : "No recognized env keys in request",
1549
+ ok: false,
1550
+ error: err instanceof Error ? err.message : String(err),
1372
1551
  };
1373
1552
  }
1374
- // Write to .env file. When inside a workspace, write to the
1375
- // workspace root .env so keys are shared across every app. The
1376
- // per-app .env still wins at load time if it also defines a key.
1377
- try {
1378
- const scope = body?.scope ?? "auto";
1379
- const workspaceRoot = findWorkspaceRoot(process.cwd());
1380
- const envPath = scope === "app"
1381
- ? path.join(process.cwd(), ".env")
1382
- : workspaceRoot
1383
- ? path.join(workspaceRoot, ".env")
1384
- : path.join(process.cwd(), ".env");
1385
- await upsertEnvFile(envPath, filtered);
1386
- }
1387
- catch {
1388
- // Edge runtime — skip file write
1389
- }
1390
- // Update process.env immediately
1391
- for (const { key, value } of filtered) {
1392
- process.env[key] = value;
1393
- }
1394
- // Persist to settings table for serverless cold-start recovery.
1395
- try {
1396
- const envMap = {};
1397
- for (const { key, value } of filtered)
1398
- envMap[key] = value;
1399
- const existing = (await getSetting("persisted-env-vars")) ?? {};
1400
- await putSetting("persisted-env-vars", { ...existing, ...envMap });
1401
- }
1402
- catch {
1403
- // DB not ready yet — skip
1404
- }
1405
- return { saved: filtered.map((v) => v.key) };
1406
1553
  }));
1407
- }
1408
- // GET /_agent-native/agent-engine/status reports whether an engine
1409
- // is configured (settings row, settings+env, or auto-detected from env).
1410
- // The agent-chat UI uses this to skip the onboarding gate for providers
1411
- // not in the env-status list (OpenRouter, Groq, Ollama, …).
1412
- getH3App(nitroApp).use(`${P}/agent-engine/status`, defineEventHandler(async (event) => {
1413
- try {
1554
+ // GET/PUT/DELETE /_agent-native/agent-loop-settings — org/user-scoped
1555
+ // ceiling for tool-calling loop iterations before the agent asks whether
1556
+ // it should keep going.
1557
+ getH3App(nitroApp).use(`${P}/agent-loop-settings`, defineEventHandler(async (event) => {
1414
1558
  const session = await getSession(event).catch(() => null);
1415
- const userEmail = session?.email;
1416
- let orgId;
1417
- if (userEmail) {
1418
- try {
1419
- const orgCtx = await getOrgContext(event);
1420
- orgId = orgCtx.orgId ?? undefined;
1421
- }
1422
- catch {
1423
- /* org module not present in this template */
1424
- }
1559
+ if (!session?.email) {
1560
+ setResponseStatus(event, 401);
1561
+ return { error: "unauthorized" };
1425
1562
  }
1426
- const stored = (await getSetting("agent-engine"));
1427
- if (isAgentEngineSettingConfigured(stored)) {
1428
- const engine = stored.engine;
1429
- const entry = getAgentEngineEntry(engine);
1430
- return {
1431
- configured: true,
1432
- engine,
1433
- model: stored.model ?? entry?.defaultModel ?? DEFAULT_MODEL,
1434
- source: "settings",
1435
- };
1563
+ const orgCtx = await getOrgContext(event).catch(() => null);
1564
+ const orgId = orgCtx?.orgId ?? session.orgId ?? null;
1565
+ const ctx = { userEmail: session.email, orgId };
1566
+ const canUpdate = await canUpdateAgentLoopSettings(session.email, orgId);
1567
+ const withContext = async () => ({
1568
+ ...(await readAgentLoopSettings(ctx)),
1569
+ canUpdate,
1570
+ orgId,
1571
+ orgName: orgCtx?.orgName ?? null,
1572
+ role: orgCtx?.role ?? null,
1573
+ });
1574
+ const method = getMethod(event);
1575
+ if (method === "GET") {
1576
+ return withContext();
1436
1577
  }
1437
- const envEntry = process.env.AGENT_ENGINE
1438
- ? getAgentEngineEntry(process.env.AGENT_ENGINE)
1439
- : undefined;
1440
- if (envEntry) {
1441
- if (!isAgentEnginePackageInstalled(envEntry)) {
1442
- return { configured: false };
1578
+ if (method === "PUT") {
1579
+ if (!canUpdate) {
1580
+ setResponseStatus(event, 403);
1581
+ return {
1582
+ error: orgId
1583
+ ? "Only organization owners and admins can change the agent step limit."
1584
+ : "You cannot change the agent step limit.",
1585
+ };
1443
1586
  }
1587
+ const body = await readBody(event).catch(() => ({}));
1588
+ const validation = validateMaxIterationsInput(body?.maxIterations);
1589
+ if (validation.ok === false) {
1590
+ setResponseStatus(event, 400);
1591
+ return { error: validation.error };
1592
+ }
1593
+ const updated = await writeAgentLoopSettings(ctx, validation.value);
1444
1594
  return {
1445
- configured: true,
1446
- engine: envEntry.name,
1447
- model: envEntry.defaultModel ?? DEFAULT_MODEL,
1448
- source: "env",
1449
- envVar: "AGENT_ENGINE",
1450
- };
1451
- }
1452
- // Per-user app_secrets — a user who connected Builder (or pasted
1453
- // their own provider key) may not have any deploy-level env vars
1454
- // set, so check their per-user secret store before reporting "no
1455
- // engine configured" and re-showing the onboarding gate.
1456
- const detectedFromUser = await runWithRequestContext({ userEmail, orgId }, () => detectEngineFromUserSecrets());
1457
- if (detectedFromUser?.name === "builder") {
1458
- return {
1459
- configured: true,
1460
- engine: detectedFromUser.name,
1461
- model: detectedFromUser.defaultModel ?? DEFAULT_MODEL,
1462
- source: "app_secrets",
1463
- envVar: detectedFromUser.requiredEnvVars[0],
1595
+ ...updated,
1596
+ canUpdate,
1597
+ orgId,
1598
+ orgName: orgCtx?.orgName ?? null,
1599
+ role: orgCtx?.role ?? null,
1464
1600
  };
1465
1601
  }
1466
- if (stored && typeof stored.engine === "string") {
1467
- const entry = getAgentEngineEntry(stored.engine);
1468
- if (entry &&
1469
- (await runWithRequestContext({ userEmail, orgId }, () => isStoredEngineUsableForRequest(stored, entry)))) {
1602
+ if (method === "DELETE") {
1603
+ if (!canUpdate) {
1604
+ setResponseStatus(event, 403);
1470
1605
  return {
1471
- configured: true,
1472
- engine: stored.engine,
1473
- model: stored.model ?? entry.defaultModel ?? DEFAULT_MODEL,
1474
- source: "env",
1475
- envVar: entry.requiredEnvVars[0],
1606
+ error: orgId
1607
+ ? "Only organization owners and admins can reset the agent step limit."
1608
+ : "You cannot reset the agent step limit.",
1476
1609
  };
1477
1610
  }
1478
- }
1479
- if (detectedFromUser) {
1480
- return {
1481
- configured: true,
1482
- engine: detectedFromUser.name,
1483
- model: detectedFromUser.defaultModel ?? DEFAULT_MODEL,
1484
- source: "app_secrets",
1485
- envVar: detectedFromUser.requiredEnvVars[0],
1486
- };
1487
- }
1488
- const canUseDeployEnv = await runWithRequestContext({ userEmail, orgId }, () => canUseDeployCredentialFallbackForRequest());
1489
- const detected = canUseDeployEnv ? detectEngineFromEnv() : null;
1490
- if (detected) {
1611
+ const updated = await resetAgentLoopSettings(ctx);
1491
1612
  return {
1492
- configured: true,
1493
- engine: detected.name,
1494
- model: detected.defaultModel ?? DEFAULT_MODEL,
1495
- source: "env",
1496
- envVar: detected.requiredEnvVars[0],
1613
+ ...updated,
1614
+ canUpdate,
1615
+ orgId,
1616
+ orgName: orgCtx?.orgName ?? null,
1617
+ role: orgCtx?.role ?? null,
1497
1618
  };
1498
1619
  }
1499
- }
1500
- catch { }
1501
- return { configured: false };
1502
- }));
1503
- // POST /_agent-native/agent-engine/disconnect — clear the agent-engine
1504
- // setting. Env vars are left alone so the next chat turn falls back to
1505
- // resolveEngine's env/default resolution.
1506
- getH3App(nitroApp).use(`${P}/agent-engine/disconnect`, defineEventHandler(async (event) => {
1507
- if (getMethod(event) !== "POST") {
1508
1620
  setResponseStatus(event, 405);
1509
1621
  return { error: "Method not allowed" };
1510
- }
1511
- const session = await getSession(event).catch(() => null);
1512
- if (!session?.email) {
1513
- setResponseStatus(event, 401);
1514
- return { error: "unauthorized" };
1515
- }
1516
- try {
1517
- await deleteSetting("agent-engine");
1518
- return { ok: true };
1519
- }
1520
- catch (err) {
1521
- setResponseStatus(event, 500);
1622
+ }));
1623
+ // ─── Usage & cost summary ────────────────────────────────────────
1624
+ // GET /_agent-native/usage?sinceDays=30
1625
+ // Returns spend broken down by label, model, app, and day for the
1626
+ // current user. Powers the Usage section in the agent settings panel.
1627
+ getH3App(nitroApp).use(`${P}/usage`, defineEventHandler(async (event) => {
1628
+ const session = await getSession(event).catch(() => null);
1629
+ if (!session?.email) {
1630
+ setResponseStatus(event, 401);
1631
+ return { error: "unauthorized" };
1632
+ }
1633
+ const sinceDaysParam = new URL(`${event.url?.pathname || "/"}${event.url?.search || ""}`, "http://x").searchParams.get("sinceDays");
1634
+ const sinceDays = Math.max(1, Math.min(365, Number(sinceDaysParam) || 30));
1635
+ const { getUsageSummary, usageBillingForEngine } = await import("../usage/store.js");
1636
+ const [summary, engineName] = await Promise.all([
1637
+ getUsageSummary({
1638
+ ownerEmail: session.email,
1639
+ sinceMs: Date.now() - sinceDays * 86_400_000,
1640
+ }),
1641
+ detectUsageEngineName(event, session.email),
1642
+ ]);
1522
1643
  return {
1523
- ok: false,
1524
- error: err instanceof Error ? err.message : String(err),
1644
+ ...summary,
1645
+ billing: usageBillingForEngine(engineName),
1525
1646
  };
1526
- }
1527
- }));
1528
- // GET/PUT/DELETE /_agent-native/agent-loop-settingsorg/user-scoped
1529
- // ceiling for tool-calling loop iterations before the agent asks whether
1530
- // it should keep going.
1531
- getH3App(nitroApp).use(`${P}/agent-loop-settings`, defineEventHandler(async (event) => {
1532
- const session = await getSession(event).catch(() => null);
1533
- if (!session?.email) {
1534
- setResponseStatus(event, 401);
1535
- return { error: "unauthorized" };
1536
- }
1537
- const orgCtx = await getOrgContext(event).catch(() => null);
1538
- const orgId = orgCtx?.orgId ?? session.orgId ?? null;
1539
- const ctx = { userEmail: session.email, orgId };
1540
- const canUpdate = await canUpdateAgentLoopSettings(session.email, orgId);
1541
- const withContext = async () => ({
1542
- ...(await readAgentLoopSettings(ctx)),
1543
- canUpdate,
1544
- orgId,
1545
- orgName: orgCtx?.orgName ?? null,
1546
- role: orgCtx?.role ?? null,
1547
- });
1548
- const method = getMethod(event);
1549
- if (method === "GET") {
1550
- return withContext();
1551
- }
1552
- if (method === "PUT") {
1553
- if (!canUpdate) {
1554
- setResponseStatus(event, 403);
1555
- return {
1556
- error: orgId
1557
- ? "Only organization owners and admins can change the agent step limit."
1558
- : "You cannot change the agent step limit.",
1559
- };
1647
+ }));
1648
+ // ─── File upload primitive ──────────────────────────────────────
1649
+ // GET /_agent-native/file-upload/statusreport active provider
1650
+ // POST /_agent-native/file-upload — upload a file, return { url }
1651
+ getH3App(nitroApp).use(`${P}/file-upload/status`, defineEventHandler(async (event) => {
1652
+ const active = getActiveFileUploadProvider();
1653
+ // resolveBuilderPrivateKey() reads per-user credentials from app_secrets
1654
+ // (DB), which requires request context (AsyncLocalStorage) to know which
1655
+ // user to scope by. Without runWithRequestContext() the ALS store is empty
1656
+ // and it falls back to process.env only — missing OAuth-connected users.
1657
+ const session = await getSession(event).catch(() => null);
1658
+ const userEmail = session?.email;
1659
+ let builderConfigured = !!process.env.BUILDER_PRIVATE_KEY;
1660
+ try {
1661
+ const { resolveBuilderPrivateKey } = await import("./credential-provider.js");
1662
+ const resolve = () => resolveBuilderPrivateKey().then((k) => !!k);
1663
+ builderConfigured = userEmail
1664
+ ? await runWithRequestContext({ userEmail }, resolve)
1665
+ : await resolve();
1560
1666
  }
1561
- const body = await readBody(event).catch(() => ({}));
1562
- const validation = validateMaxIterationsInput(body?.maxIterations);
1563
- if (validation.ok === false) {
1564
- setResponseStatus(event, 400);
1565
- return { error: validation.error };
1667
+ catch {
1668
+ // fall back to env check above
1566
1669
  }
1567
- const updated = await writeAgentLoopSettings(ctx, validation.value);
1670
+ // When the builder builtin is selected via env var, its sync
1671
+ // isConfigured() doesn't reflect per-user OAuth credentials. Use the
1672
+ // async builderConfigured check so the status accurately represents
1673
+ // whether this specific user can actually upload (thread 7 fix).
1674
+ const isBuilderEnvActive = active?.id === "builder";
1675
+ const configured = isBuilderEnvActive
1676
+ ? builderConfigured
1677
+ : !!active || builderConfigured;
1678
+ const activeProvider = isBuilderEnvActive
1679
+ ? builderConfigured
1680
+ ? { id: "builder", name: "Builder.io" }
1681
+ : null
1682
+ : active
1683
+ ? { id: active.id, name: active.name }
1684
+ : builderConfigured
1685
+ ? { id: "builder", name: "Builder.io" }
1686
+ : null;
1568
1687
  return {
1569
- ...updated,
1570
- canUpdate,
1571
- orgId,
1572
- orgName: orgCtx?.orgName ?? null,
1573
- role: orgCtx?.role ?? null,
1688
+ configured,
1689
+ activeProvider,
1690
+ providers: listFileUploadProviders().map((p) => ({
1691
+ id: p.id,
1692
+ name: p.name,
1693
+ configured: p.isConfigured(),
1694
+ })),
1695
+ builderConfigured,
1574
1696
  };
1575
- }
1576
- if (method === "DELETE") {
1577
- if (!canUpdate) {
1578
- setResponseStatus(event, 403);
1579
- return {
1580
- error: orgId
1581
- ? "Only organization owners and admins can reset the agent step limit."
1582
- : "You cannot reset the agent step limit.",
1583
- };
1697
+ }));
1698
+ getH3App(nitroApp).use(`${P}/file-upload`, defineEventHandler(async (event) => {
1699
+ if (getMethod(event) !== "POST") {
1700
+ setResponseStatus(event, 405);
1701
+ return { error: "Method not allowed" };
1584
1702
  }
1585
- const updated = await resetAgentLoopSettings(ctx);
1703
+ const parts = await readMultipartFormData(event);
1704
+ const filePart = parts?.find((p) => p.name === "file");
1705
+ if (!filePart?.data) {
1706
+ setResponseStatus(event, 400);
1707
+ return { error: "No file uploaded" };
1708
+ }
1709
+ const session = await getSession(event);
1710
+ if (!session?.email) {
1711
+ setResponseStatus(event, 401);
1712
+ return { error: "Unauthorized" };
1713
+ }
1714
+ const userEmail = session.email;
1715
+ const result = await runWithRequestContext({ userEmail }, () => uploadFile({
1716
+ data: filePart.data,
1717
+ filename: filePart.filename,
1718
+ mimeType: filePart.type,
1719
+ ownerEmail: userEmail,
1720
+ }));
1721
+ if (result) {
1722
+ setResponseStatus(event, 201);
1723
+ return result;
1724
+ }
1725
+ setResponseStatus(event, 503);
1586
1726
  return {
1587
- ...updated,
1588
- canUpdate,
1589
- orgId,
1590
- orgName: orgCtx?.orgName ?? null,
1591
- role: orgCtx?.role ?? null,
1727
+ error: "No file upload provider configured. Connect Builder.io in Settings → File uploads, or register a provider.",
1592
1728
  };
1593
- }
1594
- setResponseStatus(event, 405);
1595
- return { error: "Method not allowed" };
1596
- }));
1597
- // ─── Usage & cost summary ────────────────────────────────────────
1598
- // GET /_agent-native/usage?sinceDays=30
1599
- // Returns spend broken down by label, model, app, and day for the
1600
- // current user. Powers the Usage section in the agent settings panel.
1601
- getH3App(nitroApp).use(`${P}/usage`, defineEventHandler(async (event) => {
1602
- const session = await getSession(event).catch(() => null);
1603
- if (!session?.email) {
1604
- setResponseStatus(event, 401);
1605
- return { error: "unauthorized" };
1606
- }
1607
- const sinceDaysParam = new URL(`${event.url?.pathname || "/"}${event.url?.search || ""}`, "http://x").searchParams.get("sinceDays");
1608
- const sinceDays = Math.max(1, Math.min(365, Number(sinceDaysParam) || 30));
1609
- const { getUsageSummary, usageBillingForEngine } = await import("../usage/store.js");
1610
- const [summary, engineName] = await Promise.all([
1611
- getUsageSummary({
1612
- ownerEmail: session.email,
1613
- sinceMs: Date.now() - sinceDays * 86_400_000,
1614
- }),
1615
- detectUsageEngineName(event, session.email),
1616
- ]);
1617
- return {
1618
- ...summary,
1619
- billing: usageBillingForEngine(engineName),
1620
- };
1621
- }));
1622
- // ─── File upload primitive ──────────────────────────────────────
1623
- // GET /_agent-native/file-upload/statusreport active provider
1624
- // POST /_agent-native/file-upload — upload a file, return { url }
1625
- getH3App(nitroApp).use(`${P}/file-upload/status`, defineEventHandler(async (event) => {
1626
- const active = getActiveFileUploadProvider();
1627
- // resolveBuilderPrivateKey() reads per-user credentials from app_secrets
1628
- // (DB), which requires request context (AsyncLocalStorage) to know which
1629
- // user to scope by. Without runWithRequestContext() the ALS store is empty
1630
- // and it falls back to process.env only — missing OAuth-connected users.
1631
- const session = await getSession(event).catch(() => null);
1632
- const userEmail = session?.email;
1633
- let builderConfigured = !!process.env.BUILDER_PRIVATE_KEY;
1729
+ }));
1730
+ // ─── Voice transcription (Whisper) ───────────────────────────────
1731
+ // POST /_agent-native/transcribe-voice multipart audio → text
1732
+ getH3App(nitroApp).use(`${P}/transcribe-voice`, createTranscribeVoiceHandler());
1733
+ // ─── Google realtime transcription session bridge ───────────────
1734
+ // POST /_agent-native/transcribe-stream/session — resolve the user's
1735
+ // Google service-account credential server-side, mint an opaque managed
1736
+ // streaming session in ai-services, and return the websocket URL.
1737
+ getH3App(nitroApp).use(`${P}/transcribe-stream/session`, createGoogleRealtimeSessionHandler());
1738
+ // ─── Voice provider status ───────────────────────────────────────
1739
+ // GET /_agent-native/voice-providers/status — which providers are
1740
+ // configured for the current user (powers the Settings UI pills).
1741
+ getH3App(nitroApp).use(`${P}/voice-providers/status`, createVoiceProvidersStatusHandler());
1742
+ // ─── Ad-hoc secrets (user-created keys) ────────────────────────────
1743
+ // Must mount before the generic /secrets handler to avoid shadowing.
1744
+ const adHocSecretHandler = createAdHocSecretHandler();
1745
+ getH3App(nitroApp).use(`${P}/secrets/adhoc`, adHocSecretHandler);
1746
+ // ─── Secrets registry ────────────────────────────────────────────
1747
+ // GET /_agent-native/secrets — list registered secrets + status
1748
+ // POST /_agent-native/secrets/:key — write a secret value
1749
+ // DELETE /_agent-native/secrets/:key — remove a secret value
1750
+ // POST /_agent-native/secrets/:key/test — re-run the validator
1751
+ const listSecretsHandler = createListSecretsHandler();
1752
+ const writeSecretHandler = createWriteSecretHandler();
1753
+ const testSecretHandler = createTestSecretHandler();
1754
+ getH3App(nitroApp).use(`${P}/secrets`, defineEventHandler(async (event) => {
1755
+ const pathname = (event.url?.pathname || "")
1756
+ .replace(/^\/+/, "")
1757
+ .replace(/\/+$/, "");
1758
+ const parts = pathname ? pathname.split("/") : [];
1759
+ // Collection root list handler.
1760
+ if (parts.length === 0) {
1761
+ return listSecretsHandler(event);
1762
+ }
1763
+ // /:key/test re-validate stored value.
1764
+ if (parts.length === 2 && parts[1] === "test") {
1765
+ return testSecretHandler(event);
1766
+ }
1767
+ // /:key write / delete a specific secret.
1768
+ if (parts.length === 1) {
1769
+ return writeSecretHandler(event);
1770
+ }
1771
+ setResponseStatus(event, 404);
1772
+ return { error: "Not found" };
1773
+ }));
1774
+ // ─── Notifications inbox ──────────────────────────────────────────
1775
+ // GET /_agent-native/notifications[?unread&limit&before]
1776
+ // GET /_agent-native/notifications/count
1777
+ // POST /_agent-native/notifications/:id/read
1778
+ // POST /_agent-native/notifications/read-all
1779
+ // DELETE /_agent-native/notifications/:id
1780
+ getH3App(nitroApp).use(`${P}/notifications`, createNotificationsHandler());
1781
+ // ─── Extensions (sandboxed mini-app runtime + proxy) ────────────────
1634
1782
  try {
1635
- const { resolveBuilderPrivateKey } = await import("./credential-provider.js");
1636
- const resolve = () => resolveBuilderPrivateKey().then((k) => !!k);
1637
- builderConfigured = userEmail
1638
- ? await runWithRequestContext({ userEmail }, resolve)
1639
- : await resolve();
1783
+ const { ensureExtensionsTables, registerExtensionsShareable } = await import("../extensions/store.js");
1784
+ const { createExtensionsHandler } = await import("../extensions/routes.js");
1785
+ ensureExtensionsTables().catch(() => { });
1786
+ registerExtensionsShareable();
1787
+ const extensionsHandler = createExtensionsHandler();
1788
+ getH3App(nitroApp).use(`${P}/extensions`, extensionsHandler);
1789
+ // Legacy alias — the previous public API was /_agent-native/tools/*.
1790
+ // Mounted in addition to /extensions/* so any deployed iframes mid-flight
1791
+ // (or external integrations bookmarked the old path) keep working.
1792
+ getH3App(nitroApp).use(`${P}/tools`, extensionsHandler);
1793
+ // Extension-point slots — sub-system of extensions.
1794
+ const { ensureSlotTables } = await import("../extensions/slots/store.js");
1795
+ const { createSlotsHandler } = await import("../extensions/slots/routes.js");
1796
+ ensureSlotTables().catch(() => { });
1797
+ getH3App(nitroApp).use(`${P}/slots`, createSlotsHandler());
1640
1798
  }
1641
1799
  catch {
1642
- // fall back to env check above
1643
- }
1644
- // When the builder builtin is selected via env var, its sync
1645
- // isConfigured() doesn't reflect per-user OAuth credentials. Use the
1646
- // async builderConfigured check so the status accurately represents
1647
- // whether this specific user can actually upload (thread 7 fix).
1648
- const isBuilderEnvActive = active?.id === "builder";
1649
- const configured = isBuilderEnvActive
1650
- ? builderConfigured
1651
- : !!active || builderConfigured;
1652
- const activeProvider = isBuilderEnvActive
1653
- ? builderConfigured
1654
- ? { id: "builder", name: "Builder.io" }
1655
- : null
1656
- : active
1657
- ? { id: active.id, name: active.name }
1658
- : builderConfigured
1659
- ? { id: "builder", name: "Builder.io" }
1660
- : null;
1661
- return {
1662
- configured,
1663
- activeProvider,
1664
- providers: listFileUploadProviders().map((p) => ({
1665
- id: p.id,
1666
- name: p.name,
1667
- configured: p.isConfigured(),
1668
- })),
1669
- builderConfigured,
1670
- };
1671
- }));
1672
- getH3App(nitroApp).use(`${P}/file-upload`, defineEventHandler(async (event) => {
1673
- if (getMethod(event) !== "POST") {
1674
- setResponseStatus(event, 405);
1675
- return { error: "Method not allowed" };
1676
- }
1677
- const parts = await readMultipartFormData(event);
1678
- const filePart = parts?.find((p) => p.name === "file");
1679
- if (!filePart?.data) {
1680
- setResponseStatus(event, 400);
1681
- return { error: "No file uploaded" };
1682
- }
1683
- const session = await getSession(event);
1684
- if (!session?.email) {
1685
- setResponseStatus(event, 401);
1686
- return { error: "Unauthorized" };
1800
+ // Extensions module not available skip
1687
1801
  }
1688
- const userEmail = session.email;
1689
- const result = await runWithRequestContext({ userEmail }, () => uploadFile({
1690
- data: filePart.data,
1691
- filename: filePart.filename,
1692
- mimeType: filePart.type,
1693
- ownerEmail: userEmail,
1802
+ // ─── Page-level legacy redirect: /tools → /extensions ──────────────
1803
+ // Catches direct browser navigation / bookmarks for the old page route
1804
+ // (`/tools`, `/tools/:id`) and 302s to the renamed equivalent under
1805
+ // `/extensions`. The framework API alias above (`/_agent-native/tools/*`)
1806
+ // is intentionally untouched — it stays mounted in parallel.
1807
+ //
1808
+ // Mounted with no path so the helper can do its own base-path stripping
1809
+ // (h3 mount-matching only allows base-path stripping for `/_agent-native`
1810
+ // and `/.well-known`). Returns undefined to fall through for anything
1811
+ // that isn't a `/tools` page navigation.
1812
+ getH3App(nitroApp).use(defineEventHandler((event) => {
1813
+ const method = getMethod(event);
1814
+ if (method !== "GET" && method !== "HEAD")
1815
+ return;
1816
+ const rawPath = event.url?.pathname ??
1817
+ String(event.node?.req?.url ?? event.path ?? "/").split("?")[0];
1818
+ const search = event.url?.search ?? "";
1819
+ const target = resolveLegacyToolsRedirect(rawPath, search);
1820
+ if (!target)
1821
+ return;
1822
+ setResponseStatus(event, 302);
1823
+ setResponseHeader(event, "Location", target);
1824
+ return "";
1694
1825
  }));
1695
- if (result) {
1696
- setResponseStatus(event, 201);
1697
- return result;
1698
- }
1699
- setResponseStatus(event, 503);
1700
- return {
1701
- error: "No file upload provider configured. Connect Builder.io in Settings → File uploads, or register a provider.",
1702
- };
1703
- }));
1704
- // ─── Voice transcription (Whisper) ───────────────────────────────
1705
- // POST /_agent-native/transcribe-voice multipart audio text
1706
- getH3App(nitroApp).use(`${P}/transcribe-voice`, createTranscribeVoiceHandler());
1707
- // ─── Google realtime transcription session bridge ───────────────
1708
- // POST /_agent-native/transcribe-stream/session — resolve the user's
1709
- // Google service-account credential server-side, mint an opaque managed
1710
- // streaming session in ai-services, and return the websocket URL.
1711
- getH3App(nitroApp).use(`${P}/transcribe-stream/session`, createGoogleRealtimeSessionHandler());
1712
- // ─── Voice provider status ───────────────────────────────────────
1713
- // GET /_agent-native/voice-providers/status which providers are
1714
- // configured for the current user (powers the Settings UI pills).
1715
- getH3App(nitroApp).use(`${P}/voice-providers/status`, createVoiceProvidersStatusHandler());
1716
- // ─── Ad-hoc secrets (user-created keys) ────────────────────────────
1717
- // Must mount before the generic /secrets handler to avoid shadowing.
1718
- const adHocSecretHandler = createAdHocSecretHandler();
1719
- getH3App(nitroApp).use(`${P}/secrets/adhoc`, adHocSecretHandler);
1720
- // ─── Secrets registry ────────────────────────────────────────────
1721
- // GET /_agent-native/secrets — list registered secrets + status
1722
- // POST /_agent-native/secrets/:key — write a secret value
1723
- // DELETE /_agent-native/secrets/:key — remove a secret value
1724
- // POST /_agent-native/secrets/:key/test — re-run the validator
1725
- const listSecretsHandler = createListSecretsHandler();
1726
- const writeSecretHandler = createWriteSecretHandler();
1727
- const testSecretHandler = createTestSecretHandler();
1728
- getH3App(nitroApp).use(`${P}/secrets`, defineEventHandler(async (event) => {
1729
- const pathname = (event.url?.pathname || "")
1730
- .replace(/^\/+/, "")
1731
- .replace(/\/+$/, "");
1732
- const parts = pathname ? pathname.split("/") : [];
1733
- // Collection root — list handler.
1734
- if (parts.length === 0) {
1735
- return listSecretsHandler(event);
1736
- }
1737
- // /:key/test — re-validate stored value.
1738
- if (parts.length === 2 && parts[1] === "test") {
1739
- return testSecretHandler(event);
1740
- }
1741
- // /:key — write / delete a specific secret.
1742
- if (parts.length === 1) {
1743
- return writeSecretHandler(event);
1744
- }
1745
- setResponseStatus(event, 404);
1746
- return { error: "Not found" };
1747
- }));
1748
- // ─── Notifications inbox ──────────────────────────────────────────
1749
- // GET /_agent-native/notifications[?unread&limit&before]
1750
- // GET /_agent-native/notifications/count
1751
- // POST /_agent-native/notifications/:id/read
1752
- // POST /_agent-native/notifications/read-all
1753
- // DELETE /_agent-native/notifications/:id
1754
- getH3App(nitroApp).use(`${P}/notifications`, createNotificationsHandler());
1755
- // ─── Extensions (sandboxed mini-app runtime + proxy) ────────────────
1756
- try {
1757
- const { ensureExtensionsTables, registerExtensionsShareable } = await import("../extensions/store.js");
1758
- const { createExtensionsHandler } = await import("../extensions/routes.js");
1759
- ensureExtensionsTables().catch(() => { });
1760
- registerExtensionsShareable();
1761
- const extensionsHandler = createExtensionsHandler();
1762
- getH3App(nitroApp).use(`${P}/extensions`, extensionsHandler);
1763
- // Legacy alias — the previous public API was /_agent-native/tools/*.
1764
- // Mounted in addition to /extensions/* so any deployed iframes mid-flight
1765
- // (or external integrations bookmarked the old path) keep working.
1766
- getH3App(nitroApp).use(`${P}/tools`, extensionsHandler);
1767
- // Extension-point slots — sub-system of extensions.
1768
- const { ensureSlotTables } = await import("../extensions/slots/store.js");
1769
- const { createSlotsHandler } = await import("../extensions/slots/routes.js");
1770
- ensureSlotTables().catch(() => { });
1771
- getH3App(nitroApp).use(`${P}/slots`, createSlotsHandler());
1772
- }
1773
- catch {
1774
- // Extensions module not available — skip
1775
- }
1776
- // ─── Page-level legacy redirect: /tools → /extensions ──────────────
1777
- // Catches direct browser navigation / bookmarks for the old page route
1778
- // (`/tools`, `/tools/:id`) and 302s to the renamed equivalent under
1779
- // `/extensions`. The framework API alias above (`/_agent-native/tools/*`)
1780
- // is intentionally untouched — it stays mounted in parallel.
1781
- //
1782
- // Mounted with no path so the helper can do its own base-path stripping
1783
- // (h3 mount-matching only allows base-path stripping for `/_agent-native`
1784
- // and `/.well-known`). Returns undefined to fall through for anything
1785
- // that isn't a `/tools` page navigation.
1786
- getH3App(nitroApp).use(defineEventHandler((event) => {
1787
- const method = getMethod(event);
1788
- if (method !== "GET" && method !== "HEAD")
1789
- return;
1790
- const rawPath = event.url?.pathname ??
1791
- String(event.node?.req?.url ?? event.path ?? "/").split("?")[0];
1792
- const search = event.url?.search ?? "";
1793
- const target = resolveLegacyToolsRedirect(rawPath, search);
1794
- if (!target)
1795
- return;
1796
- setResponseStatus(event, 302);
1797
- setResponseHeader(event, "Location", target);
1798
- return "";
1799
- }));
1800
- // ─── Agent run progress ───────────────────────────────────────────
1801
- // GET /_agent-native/runs[?active&limit]
1802
- // GET /_agent-native/runs/:id
1803
- // DELETE /_agent-native/runs/:id
1804
- getH3App(nitroApp).use(`${P}/runs`, createProgressHandler());
1805
- // ─── Automations API ──────────────────────────────────────────────
1806
- // GET /_agent-native/automations — list all automations (parsed triggers)
1807
- // POST /_agent-native/automations/fire-test — emit test.event.fired
1808
- getH3App(nitroApp).use(`${P}/automations`, defineEventHandler(async (event) => {
1809
- const method = getMethod(event);
1810
- const pathname = (event.path || event.url?.pathname || "")
1811
- .split("?")[0]
1812
- .replace(/^\/+/, "")
1813
- .replace(/\/+$/, "");
1814
- // Auth check applies to every method. Without this, any anonymous
1815
- // caller could `POST /fire-test` to emit unowned events that fan
1816
- // out across every tenant's matching trigger (the dispatcher
1817
- // short-circuits its owner check when `eventMeta.owner` is
1818
- // undefined). See audit 12 / fire-test finding.
1819
- const session = await getSession(event).catch(() => null);
1820
- if (!session?.email) {
1821
- setResponseStatus(event, 401);
1822
- return { error: "Unauthenticated" };
1823
- }
1824
- if ((pathname === "fire-test" || pathname.endsWith("/fire-test")) &&
1825
- method === "POST") {
1826
- try {
1827
- const { emit } = await import("../event-bus/index.js");
1828
- const body = (await readBody(event).catch(() => ({})));
1829
- // Scope the test event to the current user so only their
1830
- // automations fire, not those owned by other tenants.
1831
- emit("test.event.fired", { data: body.data ?? {} }, {
1832
- owner: session.email,
1833
- });
1834
- return { ok: true };
1826
+ // ─── Agent run progress ───────────────────────────────────────────
1827
+ // GET /_agent-native/runs[?active&limit]
1828
+ // GET /_agent-native/runs/:id
1829
+ // DELETE /_agent-native/runs/:id
1830
+ getH3App(nitroApp).use(`${P}/runs`, createProgressHandler());
1831
+ // ─── Automations API ──────────────────────────────────────────────
1832
+ // GET /_agent-native/automations list all automations (parsed triggers)
1833
+ // POST /_agent-native/automations/fire-test — emit test.event.fired
1834
+ getH3App(nitroApp).use(`${P}/automations`, defineEventHandler(async (event) => {
1835
+ const method = getMethod(event);
1836
+ const pathname = (event.path || event.url?.pathname || "")
1837
+ .split("?")[0]
1838
+ .replace(/^\/+/, "")
1839
+ .replace(/\/+$/, "");
1840
+ // Auth check applies to every method. Without this, any anonymous
1841
+ // caller could `POST /fire-test` to emit unowned events that fan
1842
+ // out across every tenant's matching trigger (the dispatcher
1843
+ // short-circuits its owner check when `eventMeta.owner` is
1844
+ // undefined). See audit 12 / fire-test finding.
1845
+ const session = await getSession(event).catch(() => null);
1846
+ if (!session?.email) {
1847
+ setResponseStatus(event, 401);
1848
+ return { error: "Unauthenticated" };
1835
1849
  }
1836
- catch (err) {
1837
- setResponseStatus(event, 500);
1838
- return { error: err?.message ?? "Failed to emit test event" };
1850
+ if ((pathname === "fire-test" || pathname.endsWith("/fire-test")) &&
1851
+ method === "POST") {
1852
+ try {
1853
+ const { emit } = await import("../event-bus/index.js");
1854
+ const body = (await readBody(event).catch(() => ({})));
1855
+ // Scope the test event to the current user so only their
1856
+ // automations fire, not those owned by other tenants.
1857
+ emit("test.event.fired", { data: body.data ?? {} }, {
1858
+ owner: session.email,
1859
+ });
1860
+ return { ok: true };
1861
+ }
1862
+ catch (err) {
1863
+ setResponseStatus(event, 500);
1864
+ return { error: err?.message ?? "Failed to emit test event" };
1865
+ }
1839
1866
  }
1840
- }
1841
- if (method !== "GET") {
1842
- setResponseStatus(event, 405);
1843
- return { error: "Method not allowed" };
1844
- }
1845
- try {
1846
- const owner = session.email;
1847
- const { resourceListAllOwners, SHARED_OWNER } = await import("../resources/store.js");
1848
- const allResources = await resourceListAllOwners("jobs/");
1849
- const resources = allResources.filter((r) => r.owner === owner || r.owner === SHARED_OWNER);
1850
- const FRONT_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
1851
- const automations = resources
1852
- .filter((r) => r.path.endsWith(".md") && !r.path.endsWith(".keep"))
1853
- .map((r) => {
1854
- const match = r.content.match(FRONT_RE);
1855
- if (!match)
1867
+ if (method !== "GET") {
1868
+ setResponseStatus(event, 405);
1869
+ return { error: "Method not allowed" };
1870
+ }
1871
+ try {
1872
+ const owner = session.email;
1873
+ const { resourceListAllOwners, SHARED_OWNER } = await import("../resources/store.js");
1874
+ const allResources = await resourceListAllOwners("jobs/");
1875
+ const resources = allResources.filter((r) => r.owner === owner || r.owner === SHARED_OWNER);
1876
+ const FRONT_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
1877
+ const automations = resources
1878
+ .filter((r) => r.path.endsWith(".md") && !r.path.endsWith(".keep"))
1879
+ .map((r) => {
1880
+ const match = r.content.match(FRONT_RE);
1881
+ if (!match)
1882
+ return {
1883
+ id: r.id,
1884
+ name: r.path.replace(/^jobs\//, "").replace(/\.md$/, ""),
1885
+ path: r.path,
1886
+ owner: r.owner,
1887
+ triggerType: "schedule",
1888
+ enabled: false,
1889
+ mode: "agentic",
1890
+ body: r.content,
1891
+ };
1892
+ const yaml = match[1];
1893
+ const body = match[2].trim();
1894
+ const meta = {};
1895
+ for (const line of yaml.split("\n")) {
1896
+ const ci = line.indexOf(":");
1897
+ if (ci === -1)
1898
+ continue;
1899
+ const k = line.slice(0, ci).trim();
1900
+ let v = line.slice(ci + 1).trim();
1901
+ if ((v.startsWith('"') && v.endsWith('"')) ||
1902
+ (v.startsWith("'") && v.endsWith("'")))
1903
+ v = v.slice(1, -1);
1904
+ meta[k] = v;
1905
+ }
1856
1906
  return {
1857
1907
  id: r.id,
1858
1908
  name: r.path.replace(/^jobs\//, "").replace(/\.md$/, ""),
1859
1909
  path: r.path,
1860
1910
  owner: r.owner,
1861
- triggerType: "schedule",
1862
- enabled: false,
1863
- mode: "agentic",
1864
- body: r.content,
1911
+ triggerType: meta.triggerType || "schedule",
1912
+ event: meta.event,
1913
+ schedule: meta.schedule,
1914
+ condition: meta.condition,
1915
+ mode: meta.mode || "agentic",
1916
+ domain: meta.domain,
1917
+ enabled: meta.enabled !== "false",
1918
+ lastStatus: meta.lastStatus,
1919
+ lastRun: meta.lastRun,
1920
+ lastError: meta.lastError,
1921
+ createdBy: meta.createdBy,
1922
+ body,
1865
1923
  };
1866
- const yaml = match[1];
1867
- const body = match[2].trim();
1868
- const meta = {};
1869
- for (const line of yaml.split("\n")) {
1870
- const ci = line.indexOf(":");
1871
- if (ci === -1)
1872
- continue;
1873
- const k = line.slice(0, ci).trim();
1874
- let v = line.slice(ci + 1).trim();
1875
- if ((v.startsWith('"') && v.endsWith('"')) ||
1876
- (v.startsWith("'") && v.endsWith("'")))
1877
- v = v.slice(1, -1);
1878
- meta[k] = v;
1879
- }
1880
- return {
1881
- id: r.id,
1882
- name: r.path.replace(/^jobs\//, "").replace(/\.md$/, ""),
1883
- path: r.path,
1884
- owner: r.owner,
1885
- triggerType: meta.triggerType || "schedule",
1886
- event: meta.event,
1887
- schedule: meta.schedule,
1888
- condition: meta.condition,
1889
- mode: meta.mode || "agentic",
1890
- domain: meta.domain,
1891
- enabled: meta.enabled !== "false",
1892
- lastStatus: meta.lastStatus,
1893
- lastRun: meta.lastRun,
1894
- lastError: meta.lastError,
1895
- createdBy: meta.createdBy,
1896
- body,
1897
- };
1898
- });
1899
- return automations;
1900
- }
1901
- catch (err) {
1902
- setResponseStatus(event, 500);
1903
- return { error: err?.message ?? "Failed to list automations" };
1904
- }
1905
- }));
1906
- // ─── Application State CRUD ──────────────────────────────────────
1907
- // Auto-mounted so templates don't need boilerplate route files.
1908
- // ─── User-scoped settings store ────────────────────────────────────
1909
- // GET /_agent-native/settings/:key — read current user's value
1910
- // PUT /_agent-native/settings/:key — write current user's value
1911
- // DELETE /_agent-native/settings/:key — clear current user's value
1912
- //
1913
- // Keys are auto-prefixed with `u:<email>:` so each user gets their
1914
- // own row — no leakage between sessions sharing the same DB.
1915
- getH3App(nitroApp).use(`${P}/settings`, defineEventHandler(async (event) => {
1916
- const rawKey = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] || "";
1917
- const key = rawKey.replace(/[^a-zA-Z0-9_-]/g, "");
1918
- if (!key) {
1919
- setResponseStatus(event, 404);
1920
- return { error: "Settings key required" };
1921
- }
1922
- const session = await getSession(event);
1923
- if (!session?.email) {
1924
- setResponseStatus(event, 401);
1925
- return { error: "unauthorized" };
1926
- }
1927
- const method = getMethod(event);
1928
- const requestSource = event.node?.req?.headers?.["x-request-source"] || undefined;
1929
- if (method === "GET") {
1930
- const value = await getUserSetting(session.email, key);
1931
- if (!value) {
1932
- setResponseStatus(event, 404);
1933
- return { error: `No setting for ${key}` };
1924
+ });
1925
+ return automations;
1934
1926
  }
1935
- return value;
1936
- }
1937
- if (method === "PUT") {
1938
- const body = await readBody(event);
1939
- await putUserSetting(session.email, key, body, { requestSource });
1940
- return body;
1941
- }
1942
- if (method === "DELETE") {
1943
- await deleteUserSetting(session.email, key, { requestSource });
1944
- return { ok: true };
1945
- }
1946
- setResponseStatus(event, 405);
1947
- return { error: "Method not allowed" };
1948
- }));
1949
- // ─── Avatar routes ──────────────────────────────────────────────────
1950
- // GET /_agent-native/avatar/:email fetch any user's avatar (public)
1951
- // PUT /_agent-native/avatar — update current user's avatar (auth required)
1952
- getH3App(nitroApp).use(`${P}/avatar`, defineEventHandler(async (event) => {
1953
- const method = getMethod(event);
1954
- const emailParam = (event.url?.pathname || "")
1955
- .replace(/^\/+/, "")
1956
- .split("/")[0];
1957
- if (method === "GET") {
1958
- if (!emailParam) {
1959
- setResponseStatus(event, 400);
1960
- return { error: "email required" };
1927
+ catch (err) {
1928
+ setResponseStatus(event, 500);
1929
+ return { error: err?.message ?? "Failed to list automations" };
1930
+ }
1931
+ }));
1932
+ // ─── Application State CRUD ──────────────────────────────────────
1933
+ // Auto-mounted so templates don't need boilerplate route files.
1934
+ // ─── User-scoped settings store ────────────────────────────────────
1935
+ // GET /_agent-native/settings/:key read current user's value
1936
+ // PUT /_agent-native/settings/:key — write current user's value
1937
+ // DELETE /_agent-native/settings/:key — clear current user's value
1938
+ //
1939
+ // Keys are auto-prefixed with `u:<email>:` so each user gets their
1940
+ // own row — no leakage between sessions sharing the same DB.
1941
+ getH3App(nitroApp).use(`${P}/settings`, defineEventHandler(async (event) => {
1942
+ const rawKey = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] || "";
1943
+ const key = rawKey.replace(/[^a-zA-Z0-9_-]/g, "");
1944
+ if (!key) {
1945
+ setResponseStatus(event, 404);
1946
+ return { error: "Settings key required" };
1961
1947
  }
1962
- const data = await getSetting(`avatar:${decodeURIComponent(emailParam)}`);
1963
- return { image: data?.image ?? null };
1964
- }
1965
- if (method === "PUT") {
1966
1948
  const session = await getSession(event);
1967
1949
  if (!session?.email) {
1968
1950
  setResponseStatus(event, 401);
1969
1951
  return { error: "unauthorized" };
1970
1952
  }
1971
- const body = await readBody(event);
1972
- const { image } = body;
1973
- if (!image || !image.startsWith("data:image/")) {
1974
- setResponseStatus(event, 400);
1975
- return { error: "image (data URL) required" };
1976
- }
1977
- await putSetting(`avatar:${session.email}`, { image });
1978
- return { ok: true };
1979
- }
1980
- setResponseStatus(event, 405);
1981
- return { error: "Method not allowed" };
1982
- }));
1983
- if (!options.disableMcpConnect) {
1984
- getH3App(nitroApp).use("/.well-known/oauth-protected-resource", defineEventHandler((event) => handleMcpOAuthProtectedResourceMetadata(event)));
1985
- getH3App(nitroApp).use("/.well-known/oauth-authorization-server", defineEventHandler((event) => handleMcpOAuthAuthorizationServerMetadata(event)));
1986
- getH3App(nitroApp).use("/.well-known/openid-configuration", defineEventHandler((event) => handleMcpOAuthAuthorizationServerMetadata(event)));
1987
- getH3App(nitroApp).use(`${P}/mcp/oauth`, defineEventHandler(async (event) => {
1988
- const subpath = event.url?.pathname || "";
1989
- return handleMcpOAuth(event, subpath, {
1990
- appId: options.mcpConnectAppId,
1991
- appName: options.mcpConnectAppName ?? getAppName(),
1992
- });
1993
- }));
1994
- // Frictionless external-agent connection. A logged-in user mints a
1995
- // per-user, scoped, revocable MCP bearer token here — via the browser
1996
- // Connect page or the OAuth-style device-code flow a CLI drives — so
1997
- // they never copy a shared deployment secret. The handler resolves the
1998
- // browser session itself and serves its own login form (like /open)
1999
- // for the page + unauth device endpoints; the /token, /device/authorize,
2000
- // /tokens, /tokens/revoke subpaths require a session and 401 without it.
2001
- // The auth guard bypasses ONLY the page + device/start + device/poll
2002
- // (see createAuthGuardFn in auth.ts).
2003
- const mcpConnectOpts = {
2004
- appId: options.mcpConnectAppId,
2005
- appName: options.mcpConnectAppName ?? getAppName(),
2006
- };
2007
- getH3App(nitroApp).use(`${P}/mcp/connect`, defineEventHandler(async (event) => {
2008
- // The framework strips the mount prefix from event.url.pathname,
2009
- // so what remains is the subpath after `/connect` (e.g. `/token`,
2010
- // `/device/start`, or `` for the page itself).
2011
- const subpath = event.url?.pathname || "";
2012
- return handleMcpConnect(event, subpath, mcpConnectOpts);
2013
- }));
2014
- }
2015
- // Cross-app SSO ("Sign in with Agent-Native") — CLIENT side. Mounted
2016
- // ONLY when `AGENT_NATIVE_IDENTITY_HUB_URL` is set, so an unset env var
2017
- // means the route is never even registered: zero new surface, existing
2018
- // auth byte-for-byte unchanged. `/login` 302s to the identity hub;
2019
- // `/callback` verifies the hub-issued A2A-signed identity JWT and JIT-
2020
- // links the verified email into this app's local Better Auth store. The
2021
- // handler 404s if disabled (defence in depth). The auth guard bypasses
2022
- // these two exact paths under the same env gate.
2023
- if (isIdentitySsoEnabled()) {
2024
- getH3App(nitroApp).use(`${P}/identity`, defineEventHandler(async (event) => {
2025
- // Framework strips the mount prefix; what remains is the subpath
2026
- // after `/identity` (e.g. `/login`, `/callback`).
2027
- const subpath = event.url?.pathname || "";
2028
- return handleIdentitySso(event, subpath);
2029
- }));
2030
- }
2031
- if (!options.disableOpenRoute) {
2032
- // Stable deep-link route. External agents (MCP/A2A) surface
2033
- // `/_agent-native/open?app=…&view=…&<recordId>=…` links; this resolves
2034
- // the browser session, writes the one-shot `navigate` app-state command
2035
- // the UI already drains, and 302s to the rendered SPA view. The auth
2036
- // guard bypasses this exact path so it can serve its own login form.
2037
- getH3App(nitroApp).use(`${P}/open`, createOpenRouteHandler({ resolveOpenPath: options.resolveOpenPath }));
2038
- }
2039
- if (!options.disableEmbedRoute) {
2040
- // One-time ticket launcher for MCP Apps that embed the full React app.
2041
- // The ticket is minted by an authenticated MCP tool call and exchanged
2042
- // here for a short-lived browser session cookie + bearer fallback.
2043
- getH3App(nitroApp).use(`${P}/embed/start`, createEmbedStartRouteHandler({ getExistingSession: getSession }));
2044
- }
2045
- if (!options.disableAppState) {
2046
- // Compose draft routes (more specific path, mounted first so the
2047
- // generic app-state matcher below doesn't shadow them). The framework
2048
- // strips the mount prefix from event.url.pathname before calling us,
2049
- // so we just see e.g. `/abc-123` (id) or `/` (collection root).
2050
- getH3App(nitroApp).use(`${P}/application-state/compose`, defineEventHandler(async (event) => {
2051
- const id = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] || "";
2052
- if (event.context) {
2053
- event.context.params = { ...event.context.params, id };
2054
- }
2055
1953
  const method = getMethod(event);
2056
- if (!id) {
2057
- if (method === "GET")
2058
- return listComposeDrafts(event);
2059
- if (method === "DELETE")
2060
- return deleteAllComposeDrafts(event);
1954
+ const requestSource = event.node?.req?.headers?.["x-request-source"] || undefined;
1955
+ if (method === "GET") {
1956
+ const value = await getUserSetting(session.email, key);
1957
+ if (!value) {
1958
+ setResponseStatus(event, 404);
1959
+ return { error: `No setting for ${key}` };
1960
+ }
1961
+ return value;
2061
1962
  }
2062
- else {
2063
- if (method === "GET")
2064
- return getComposeDraft(event);
2065
- if (method === "PUT")
2066
- return putComposeDraft(event);
2067
- if (method === "DELETE")
2068
- return deleteComposeDraft(event);
1963
+ if (method === "PUT") {
1964
+ const body = await readBody(event);
1965
+ await putUserSetting(session.email, key, body, { requestSource });
1966
+ return body;
1967
+ }
1968
+ if (method === "DELETE") {
1969
+ await deleteUserSetting(session.email, key, { requestSource });
1970
+ return { ok: true };
2069
1971
  }
2070
1972
  setResponseStatus(event, 405);
2071
1973
  return { error: "Method not allowed" };
2072
1974
  }));
2073
- // Generic application state — match `/application-state/:key` only
2074
- // (NOT `/application-state/compose/...` which the handler above owns).
2075
- getH3App(nitroApp).use(`${P}/application-state`, defineEventHandler(async (event) => {
2076
- const key = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] || "";
2077
- // Skip — compose handler above already handled it
2078
- if (key === "compose" || key === "")
2079
- return;
2080
- if (event.context) {
2081
- event.context.params = { ...event.context.params, key };
2082
- }
1975
+ // ─── Avatar routes ──────────────────────────────────────────────────
1976
+ // GET /_agent-native/avatar/:email fetch any user's avatar (public)
1977
+ // PUT /_agent-native/avatar — update current user's avatar (auth required)
1978
+ getH3App(nitroApp).use(`${P}/avatar`, defineEventHandler(async (event) => {
2083
1979
  const method = getMethod(event);
2084
- if (method === "GET")
2085
- return getState(event);
2086
- if (method === "PUT")
2087
- return putState(event);
2088
- if (method === "DELETE")
2089
- return deleteState(event);
1980
+ const emailParam = (event.url?.pathname || "")
1981
+ .replace(/^\/+/, "")
1982
+ .split("/")[0];
1983
+ if (method === "GET") {
1984
+ if (!emailParam) {
1985
+ setResponseStatus(event, 400);
1986
+ return { error: "email required" };
1987
+ }
1988
+ const data = await getSetting(`avatar:${decodeURIComponent(emailParam)}`);
1989
+ return { image: data?.image ?? null };
1990
+ }
1991
+ if (method === "PUT") {
1992
+ const session = await getSession(event);
1993
+ if (!session?.email) {
1994
+ setResponseStatus(event, 401);
1995
+ return { error: "unauthorized" };
1996
+ }
1997
+ const body = await readBody(event);
1998
+ const { image } = body;
1999
+ if (!image || !image.startsWith("data:image/")) {
2000
+ setResponseStatus(event, 400);
2001
+ return { error: "image (data URL) required" };
2002
+ }
2003
+ await putSetting(`avatar:${session.email}`, { image });
2004
+ return { ok: true };
2005
+ }
2090
2006
  setResponseStatus(event, 405);
2091
2007
  return { error: "Method not allowed" };
2092
2008
  }));
2009
+ if (!options.disableMcpConnect) {
2010
+ getH3App(nitroApp).use("/.well-known/oauth-protected-resource", defineEventHandler((event) => handleMcpOAuthProtectedResourceMetadata(event)));
2011
+ getH3App(nitroApp).use("/.well-known/oauth-authorization-server", defineEventHandler((event) => handleMcpOAuthAuthorizationServerMetadata(event)));
2012
+ getH3App(nitroApp).use("/.well-known/openid-configuration", defineEventHandler((event) => handleMcpOAuthAuthorizationServerMetadata(event)));
2013
+ getH3App(nitroApp).use(`${P}/mcp/oauth`, defineEventHandler(async (event) => {
2014
+ const subpath = event.url?.pathname || "";
2015
+ return handleMcpOAuth(event, subpath, {
2016
+ appId: options.mcpConnectAppId,
2017
+ appName: options.mcpConnectAppName ?? getAppName(),
2018
+ });
2019
+ }));
2020
+ // Frictionless external-agent connection. A logged-in user mints a
2021
+ // per-user, scoped, revocable MCP bearer token here — via the browser
2022
+ // Connect page or the OAuth-style device-code flow a CLI drives — so
2023
+ // they never copy a shared deployment secret. The handler resolves the
2024
+ // browser session itself and serves its own login form (like /open)
2025
+ // for the page + unauth device endpoints; the /token, /device/authorize,
2026
+ // /tokens, /tokens/revoke subpaths require a session and 401 without it.
2027
+ // The auth guard bypasses ONLY the page + device/start + device/poll
2028
+ // (see createAuthGuardFn in auth.ts).
2029
+ const mcpConnectOpts = {
2030
+ appId: options.mcpConnectAppId,
2031
+ appName: options.mcpConnectAppName ?? getAppName(),
2032
+ };
2033
+ getH3App(nitroApp).use(`${P}/mcp/connect`, defineEventHandler(async (event) => {
2034
+ // The framework strips the mount prefix from event.url.pathname,
2035
+ // so what remains is the subpath after `/connect` (e.g. `/token`,
2036
+ // `/device/start`, or `` for the page itself).
2037
+ const subpath = event.url?.pathname || "";
2038
+ return handleMcpConnect(event, subpath, mcpConnectOpts);
2039
+ }));
2040
+ }
2041
+ // Cross-app SSO ("Sign in with Agent-Native") — CLIENT side. Mounted
2042
+ // ONLY when `AGENT_NATIVE_IDENTITY_HUB_URL` is set, so an unset env var
2043
+ // means the route is never even registered: zero new surface, existing
2044
+ // auth byte-for-byte unchanged. `/login` 302s to the identity hub;
2045
+ // `/callback` verifies the hub-issued A2A-signed identity JWT and JIT-
2046
+ // links the verified email into this app's local Better Auth store. The
2047
+ // handler 404s if disabled (defence in depth). The auth guard bypasses
2048
+ // these two exact paths under the same env gate.
2049
+ if (isIdentitySsoEnabled()) {
2050
+ getH3App(nitroApp).use(`${P}/identity`, defineEventHandler(async (event) => {
2051
+ // Framework strips the mount prefix; what remains is the subpath
2052
+ // after `/identity` (e.g. `/login`, `/callback`).
2053
+ const subpath = event.url?.pathname || "";
2054
+ return handleIdentitySso(event, subpath);
2055
+ }));
2056
+ }
2057
+ if (!options.disableOpenRoute) {
2058
+ // Stable deep-link route. External agents (MCP/A2A) surface
2059
+ // `/_agent-native/open?app=…&view=…&<recordId>=…` links; this resolves
2060
+ // the browser session, writes the one-shot `navigate` app-state command
2061
+ // the UI already drains, and 302s to the rendered SPA view. The auth
2062
+ // guard bypasses this exact path so it can serve its own login form.
2063
+ getH3App(nitroApp).use(`${P}/open`, createOpenRouteHandler({ resolveOpenPath: options.resolveOpenPath }));
2064
+ }
2065
+ if (!options.disableEmbedRoute) {
2066
+ // One-time ticket launcher for MCP Apps that embed the full React app.
2067
+ // The ticket is minted by an authenticated MCP tool call and exchanged
2068
+ // here for a short-lived browser session cookie + bearer fallback.
2069
+ getH3App(nitroApp).use(`${P}/embed/start`, createEmbedStartRouteHandler({ getExistingSession: getSession }));
2070
+ }
2071
+ if (!options.disableAppState) {
2072
+ // Compose draft routes (more specific path, mounted first so the
2073
+ // generic app-state matcher below doesn't shadow them). The framework
2074
+ // strips the mount prefix from event.url.pathname before calling us,
2075
+ // so we just see e.g. `/abc-123` (id) or `/` (collection root).
2076
+ getH3App(nitroApp).use(`${P}/application-state/compose`, defineEventHandler(async (event) => {
2077
+ const id = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] ||
2078
+ "";
2079
+ if (event.context) {
2080
+ event.context.params = { ...event.context.params, id };
2081
+ }
2082
+ const method = getMethod(event);
2083
+ if (!id) {
2084
+ if (method === "GET")
2085
+ return listComposeDrafts(event);
2086
+ if (method === "DELETE")
2087
+ return deleteAllComposeDrafts(event);
2088
+ }
2089
+ else {
2090
+ if (method === "GET")
2091
+ return getComposeDraft(event);
2092
+ if (method === "PUT")
2093
+ return putComposeDraft(event);
2094
+ if (method === "DELETE")
2095
+ return deleteComposeDraft(event);
2096
+ }
2097
+ setResponseStatus(event, 405);
2098
+ return { error: "Method not allowed" };
2099
+ }));
2100
+ // Generic application state — match `/application-state/:key` only
2101
+ // (NOT `/application-state/compose/...` which the handler above owns).
2102
+ getH3App(nitroApp).use(`${P}/application-state`, defineEventHandler(async (event) => {
2103
+ const key = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] ||
2104
+ "";
2105
+ // Skip — compose handler above already handled it
2106
+ if (key === "compose" || key === "")
2107
+ return;
2108
+ if (event.context) {
2109
+ event.context.params = { ...event.context.params, key };
2110
+ }
2111
+ const method = getMethod(event);
2112
+ if (method === "GET")
2113
+ return getState(event);
2114
+ if (method === "PUT")
2115
+ return putState(event);
2116
+ if (method === "DELETE")
2117
+ return deleteState(event);
2118
+ setResponseStatus(event, 405);
2119
+ return { error: "Method not allowed" };
2120
+ }));
2121
+ }
2122
+ resolveInit();
2123
+ }
2124
+ catch (error) {
2125
+ rejectInit(error);
2126
+ throw error;
2093
2127
  }
2094
2128
  };
2095
2129
  }