@agent-native/core 0.101.7 → 0.101.10

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 (153) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +22 -0
  3. package/corpus/core/docs/content/deployment.mdx +9 -7
  4. package/corpus/core/docs/content/locales/ar-SA/deployment.mdx +9 -7
  5. package/corpus/core/docs/content/locales/de-DE/deployment.mdx +9 -7
  6. package/corpus/core/docs/content/locales/es-ES/deployment.mdx +9 -7
  7. package/corpus/core/docs/content/locales/fr-FR/deployment.mdx +9 -7
  8. package/corpus/core/docs/content/locales/hi-IN/deployment.mdx +9 -7
  9. package/corpus/core/docs/content/locales/ja-JP/deployment.mdx +9 -7
  10. package/corpus/core/docs/content/locales/ko-KR/deployment.mdx +9 -7
  11. package/corpus/core/docs/content/locales/pt-BR/deployment.mdx +9 -7
  12. package/corpus/core/docs/content/locales/zh-CN/deployment.mdx +9 -7
  13. package/corpus/core/docs/content/locales/zh-TW/deployment.mdx +9 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  16. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  17. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  18. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  19. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  20. package/corpus/core/src/server/auth.ts +25 -1
  21. package/corpus/core/src/server/builder-browser.ts +353 -1
  22. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  23. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  24. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  25. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  26. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  27. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  28. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  29. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  30. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  31. package/corpus/templates/content/AGENTS.md +3 -2
  32. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  33. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  34. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  35. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  36. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  37. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  38. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  39. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  40. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  41. package/corpus/templates/content/actions/_database-source-utils.ts +1041 -154
  42. package/corpus/templates/content/actions/_database-utils.ts +89 -12
  43. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  44. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  45. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  46. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  47. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  48. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  49. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  50. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  51. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  52. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  53. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  54. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +27 -15
  55. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  56. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  57. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2353 -576
  58. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  59. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  60. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  61. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  62. package/corpus/templates/content/app/global.css +18 -1
  63. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  64. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  65. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -0
  66. package/corpus/templates/content/app/i18n-data.ts +287 -0
  67. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  68. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  69. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  70. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  71. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  72. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  73. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  74. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  75. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  76. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  77. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  78. package/corpus/templates/content/parity/matrix.md +27 -25
  79. package/corpus/templates/content/parity/matrix.ts +50 -2
  80. package/corpus/templates/content/server/db/schema.ts +47 -0
  81. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  82. package/corpus/templates/content/server/plugins/db.ts +53 -0
  83. package/corpus/templates/content/shared/api.ts +70 -1
  84. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  85. package/corpus/templates/content/shared/nfm.ts +3 -3
  86. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  87. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  88. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  89. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  90. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  91. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  92. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  93. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  94. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  95. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  96. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  97. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  98. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  99. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  100. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  101. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  102. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  103. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  104. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  105. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  106. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  107. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  108. package/dist/client/review/ReviewCommentComposer.js +3 -3
  109. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  110. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  111. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  112. package/dist/client/review/ReviewThreadPanel.js +6 -4
  113. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  114. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  115. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  116. package/dist/client/settings/SettingsPanel.js +11 -4
  117. package/dist/client/settings/SettingsPanel.js.map +1 -1
  118. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  119. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  120. package/dist/client/settings/useBuilderStatus.js +19 -6
  121. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  122. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  123. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  124. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  125. package/dist/collab/awareness.d.ts +2 -2
  126. package/dist/collab/awareness.d.ts.map +1 -1
  127. package/dist/observability/routes.d.ts +5 -5
  128. package/dist/resources/handlers.d.ts +1 -1
  129. package/dist/secrets/routes.d.ts +9 -9
  130. package/dist/server/auth.d.ts +1 -0
  131. package/dist/server/auth.d.ts.map +1 -1
  132. package/dist/server/auth.js +19 -2
  133. package/dist/server/auth.js.map +1 -1
  134. package/dist/server/builder-browser.d.ts +78 -0
  135. package/dist/server/builder-browser.d.ts.map +1 -1
  136. package/dist/server/builder-browser.js +246 -1
  137. package/dist/server/builder-browser.js.map +1 -1
  138. package/dist/server/core-routes-plugin.d.ts +41 -0
  139. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  140. package/dist/server/core-routes-plugin.js +260 -14
  141. package/dist/server/core-routes-plugin.js.map +1 -1
  142. package/docs/content/deployment.mdx +9 -7
  143. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  144. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  145. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  146. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  147. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  148. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  149. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  150. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  151. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  152. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  153. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import {
2
+ assertBodySize,
2
3
  defineEventHandler,
3
4
  setResponseStatus,
4
5
  setResponseHeader,
@@ -9,6 +10,7 @@ import {
9
10
  deleteCookie,
10
11
  getRequestURL,
11
12
  getRequestIP,
13
+ readRawBody,
12
14
  } from "h3";
13
15
  import type { H3Event } from "h3";
14
16
  import { readMultipartFormData } from "h3";
@@ -53,6 +55,7 @@ import {
53
55
  runDatabaseSchemaHealthCheck,
54
56
  type DatabaseSchemaHealthResult,
55
57
  } from "../db/runtime-diagnostics.js";
58
+ import { ssrfSafeFetch } from "../extensions/url-safety.js";
56
59
  import {
57
60
  uploadFile,
58
61
  getActiveFileUploadProviderForRequest,
@@ -107,12 +110,17 @@ import {
107
110
  BUILDER_CONNECT_OWNER_COOKIE,
108
111
  BUILDER_ENV_KEYS,
109
112
  BUILDER_OPENER_PARAM,
113
+ BUILDER_RELAY_FLOW_HEADER,
114
+ BUILDER_RELAY_SIGNATURE_HEADER,
115
+ BUILDER_RELAY_STATE_PARAM,
116
+ BUILDER_RELAY_TIMESTAMP_HEADER,
110
117
  BUILDER_STATE_PARAM,
111
118
  appendBuilderConnectToken,
112
119
  builderConnectTrackingProperties,
113
120
  buildBuilderCliAuthUrl,
114
121
  createBuilderBrowserCallbackErrorPage,
115
122
  createBuilderBrowserCallbackPage,
123
+ createBuilderRelayRequest,
116
124
  getBuilderConnectTrackingParams,
117
125
  getBuilderCliAuthCallbackOriginForEvent,
118
126
  getBuilderBrowserOriginForEvent,
@@ -122,10 +130,15 @@ import {
122
130
  resolveSafePreviewUrl,
123
131
  runBuilderAgent,
124
132
  signBuilderCallbackState,
133
+ signBuilderPreviewRelayState,
134
+ verifyBuilderRelayRequest,
135
+ verifyBuilderPreviewRelayStateForCallback,
125
136
  verifyBuilderConnectTokenAndGetOwner,
126
137
  verifyBuilderCallbackStateAndGetOwner,
127
138
  signBuilderConnectToken,
128
139
  type BuilderConnectTrackingParams,
140
+ type BuilderRelayCredentials,
141
+ type BuilderPreviewRelayState,
129
142
  } from "./builder-browser.js";
130
143
  import { captureError } from "./capture-error.js";
131
144
  import {
@@ -336,6 +349,21 @@ export function resolveFrameworkSseRoutes(sseRoute?: string): string[] {
336
349
  );
337
350
  }
338
351
 
352
+ export const BUILDER_STATUS_ROUTE_SUFFIXES = [
353
+ "/builder/status",
354
+ "/connection-status/builder",
355
+ ] as const;
356
+
357
+ export function mountBuilderStatusRouteAliases<T>(
358
+ mount: (path: string, handler: T) => void,
359
+ prefix: string,
360
+ handler: T,
361
+ ): void {
362
+ for (const routeSuffix of BUILDER_STATUS_ROUTE_SUFFIXES) {
363
+ mount(`${prefix}${routeSuffix}`, handler);
364
+ }
365
+ }
366
+
339
367
  registerBuiltinEngines();
340
368
 
341
369
  function cleanBuilderWaitlistText(
@@ -848,6 +876,141 @@ export function getFrameworkRouteRequestUrl(event: H3Event): URL {
848
876
  return url;
849
877
  }
850
878
 
879
+ export interface BuilderRelayPendingRecord {
880
+ ownerEmail: string;
881
+ orgId: string | null;
882
+ role: string | null;
883
+ targetOrigin: string;
884
+ basePath: string;
885
+ expiresAt: number;
886
+ tracking?: BuilderConnectTrackingParams;
887
+ }
888
+
889
+ export interface ConsumeBuilderRelayDependencies {
890
+ getPending: (key: string) => Promise<Record<string, unknown> | null>;
891
+ deletePending: (key: string) => Promise<boolean>;
892
+ writeCredentials: (
893
+ ownerEmail: string,
894
+ credentials: BuilderRelayCredentials,
895
+ scope: { orgId: string | null; role: string | null },
896
+ ) => Promise<unknown>;
897
+ }
898
+
899
+ function builderRelayPendingKey(flowId: string): string {
900
+ return `builder-pending-relay:${flowId}`;
901
+ }
902
+
903
+ function parseBuilderRelayPendingRecord(
904
+ value: Record<string, unknown> | null,
905
+ ): BuilderRelayPendingRecord | null {
906
+ if (
907
+ !value ||
908
+ typeof value.ownerEmail !== "string" ||
909
+ typeof value.targetOrigin !== "string" ||
910
+ typeof value.basePath !== "string" ||
911
+ typeof value.expiresAt !== "number"
912
+ ) {
913
+ return null;
914
+ }
915
+ return {
916
+ ownerEmail: value.ownerEmail,
917
+ orgId: typeof value.orgId === "string" ? value.orgId : null,
918
+ role: typeof value.role === "string" ? value.role : null,
919
+ targetOrigin: value.targetOrigin,
920
+ basePath: value.basePath,
921
+ expiresAt: value.expiresAt,
922
+ tracking:
923
+ value.tracking && typeof value.tracking === "object"
924
+ ? (value.tracking as BuilderConnectTrackingParams)
925
+ : undefined,
926
+ };
927
+ }
928
+
929
+ /**
930
+ * Authenticated one-shot receiver for the second hop of Builder preview auth.
931
+ * Owner and org scope always come from the preview's pending record; the
932
+ * corporate callback cannot choose them in its POST body.
933
+ */
934
+ export async function consumeBuilderRelayRequest(
935
+ input: {
936
+ rawBody: string;
937
+ timestamp: string | null | undefined;
938
+ flowId: string | null | undefined;
939
+ signature: string | null | undefined;
940
+ requestOrigin: string;
941
+ requestBasePath: string;
942
+ now?: number;
943
+ },
944
+ dependencies: ConsumeBuilderRelayDependencies,
945
+ ): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
946
+ if (input.rawBody.length > 64 * 1024) {
947
+ return {
948
+ ok: false,
949
+ status: 413,
950
+ error: "Builder relay request is too large",
951
+ };
952
+ }
953
+ let verified: ReturnType<typeof verifyBuilderRelayRequest>;
954
+ try {
955
+ verified = verifyBuilderRelayRequest({
956
+ body: input.rawBody,
957
+ timestamp: input.timestamp,
958
+ flowId: input.flowId,
959
+ signature: input.signature,
960
+ requestOrigin: input.requestOrigin,
961
+ requestBasePath: input.requestBasePath,
962
+ now: input.now,
963
+ });
964
+ } catch {
965
+ return { ok: false, status: 503, error: "Builder relay is not configured" };
966
+ }
967
+ if (!verified) {
968
+ return { ok: false, status: 401, error: "Invalid Builder relay request" };
969
+ }
970
+ const pendingKey = builderRelayPendingKey(verified.payload.flowId);
971
+ const pending = parseBuilderRelayPendingRecord(
972
+ await dependencies.getPending(pendingKey).catch(() => null),
973
+ );
974
+ const now = input.now ?? Date.now();
975
+ if (
976
+ !pending ||
977
+ pending.expiresAt < now ||
978
+ pending.ownerEmail !== verified.payload.ownerEmail ||
979
+ pending.targetOrigin !== verified.payload.targetOrigin ||
980
+ pending.basePath !== verified.payload.basePath
981
+ ) {
982
+ return { ok: false, status: 403, error: "No active Builder relay flow" };
983
+ }
984
+
985
+ // A successful delete, not merely a resolved promise, is the one-shot gate.
986
+ // It happens before credential persistence so replay is impossible even if
987
+ // the downstream write fails and the human has to start a fresh flow.
988
+ const consumed = await dependencies
989
+ .deletePending(pendingKey)
990
+ .catch(() => false);
991
+ if (consumed !== true) {
992
+ return {
993
+ ok: false,
994
+ status: 409,
995
+ error: "Builder relay flow was already consumed",
996
+ };
997
+ }
998
+
999
+ await dependencies.writeCredentials(
1000
+ pending.ownerEmail,
1001
+ verified.body.credentials,
1002
+ { orgId: pending.orgId, role: pending.role },
1003
+ );
1004
+ return { ok: true };
1005
+ }
1006
+
1007
+ export async function readBuilderRelayRequestBody(
1008
+ event: H3Event,
1009
+ ): Promise<string> {
1010
+ await assertBodySize(event, 64 * 1024);
1011
+ return (await readRawBody(event, "utf8")) ?? "";
1012
+ }
1013
+
851
1014
  function redactValues(text: string, values: Array<string | null | undefined>) {
852
1015
  let out = text;
853
1016
  for (const value of values) {
@@ -1436,128 +1599,79 @@ export function createCoreRoutesPlugin(
1436
1599
  mode,
1437
1600
  );
1438
1601
 
1439
- getH3App(nitroApp).use(
1440
- `${P}/builder/status`,
1441
- defineEventHandler(async (event) => {
1442
- const envStatus = getBuilderBrowserStatusForEvent(event);
1443
- const ownerContext = await resolveBuilderOwnerContext(event);
1444
- const userEmail = ownerContext.email;
1445
- const withConnectToken = <T extends { connectUrl: string }>(
1446
- status: T,
1447
- ): T & { cliAuthUrl?: string } => {
1448
- if (!userEmail) return status;
1449
- const previewOrigin = getBuilderBrowserOriginForEvent(event);
1450
- const callbackOrigin =
1451
- getBuilderCliAuthCallbackOriginForEvent(event);
1452
- const statusWithConnectToken = {
1453
- ...status,
1454
- connectUrl: appendBuilderConnectToken(
1455
- status.connectUrl,
1456
- userEmail,
1457
- ),
1458
- } as T & { cliAuthUrl?: string };
1459
- // Direct cli-auth only works when the callback lands on the same
1460
- // deployment that minted the signed state. Builder/Fusion previews
1461
- // often need a gateway callback origin; in that case use the
1462
- // /builder/connect trampoline so it can write the pending-connect
1463
- // row that the gateway callback validates against.
1464
- if (
1465
- previewOrigin.replace(/\/+$/, "") !==
1466
- callbackOrigin.replace(/\/+$/, "")
1467
- ) {
1468
- return statusWithConnectToken;
1469
- }
1470
- const cliAuthUrl = buildBuilderCliAuthUrl(
1471
- callbackOrigin,
1472
- signBuilderCallbackState(userEmail),
1473
- { previewOrigin },
1474
- );
1475
- return {
1476
- ...statusWithConnectToken,
1477
- cliAuthUrl,
1478
- };
1602
+ const builderStatusHandler = defineEventHandler(async (event) => {
1603
+ const envStatus = getBuilderBrowserStatusForEvent(event);
1604
+ const ownerContext = await resolveBuilderOwnerContext(event);
1605
+ const userEmail = ownerContext.email;
1606
+ const withConnectToken = <T extends { connectUrl: string }>(
1607
+ status: T,
1608
+ ): T & { cliAuthUrl?: string } => {
1609
+ if (!userEmail) return status;
1610
+ const previewOrigin = getBuilderBrowserOriginForEvent(event);
1611
+ const callbackOrigin = getBuilderCliAuthCallbackOriginForEvent(event);
1612
+ const statusWithConnectToken = {
1613
+ ...status,
1614
+ connectUrl: appendBuilderConnectToken(status.connectUrl, userEmail),
1615
+ } as T & { cliAuthUrl?: string };
1616
+ // Direct cli-auth only works when the callback lands on the same
1617
+ // deployment that minted the signed state. Builder/Fusion previews
1618
+ // often need a gateway callback origin; in that case use the
1619
+ // /builder/connect trampoline so it can write the pending-connect
1620
+ // row that the gateway callback validates against.
1621
+ if (
1622
+ previewOrigin.replace(/\/+$/, "") !==
1623
+ callbackOrigin.replace(/\/+$/, "")
1624
+ ) {
1625
+ return statusWithConnectToken;
1626
+ }
1627
+ const cliAuthUrl = buildBuilderCliAuthUrl(
1628
+ callbackOrigin,
1629
+ signBuilderCallbackState(userEmail),
1630
+ { previewOrigin },
1631
+ );
1632
+ return {
1633
+ ...statusWithConnectToken,
1634
+ cliAuthUrl,
1479
1635
  };
1636
+ };
1480
1637
 
1481
- // Pass the user's active orgId so status reads can fall back to
1482
- // org-scoped credentials and branch project IDs. Without it, an
1483
- // admin's org-scope OAuth result is invisible to every other org
1484
- // member's status poller and the UI would show "not connected" forever
1485
- // even though the chat actually resolves the org-shared credential.
1486
- let orgId: string | null = null;
1487
- if (!ownerContext.anonymous) {
1488
- try {
1489
- const { getOrgContext } = await import("../org/context.js");
1490
- const orgCtx = await getOrgContext(event);
1491
- orgId = orgCtx.orgId ?? null;
1492
- } catch {
1493
- /* org module not present in this template — keep userEmail-only */
1494
- }
1638
+ // Pass the user's active orgId so status reads can fall back to
1639
+ // org-scoped credentials and branch project IDs. Without it, an
1640
+ // admin's org-scope OAuth result is invisible to every other org
1641
+ // member's status poller and the UI would show "not connected" forever
1642
+ // even though the chat actually resolves the org-shared credential.
1643
+ let orgId: string | null = null;
1644
+ if (!ownerContext.anonymous) {
1645
+ try {
1646
+ const { getOrgContext } = await import("../org/context.js");
1647
+ const orgCtx = await getOrgContext(event);
1648
+ orgId = orgCtx.orgId ?? null;
1649
+ } catch {
1650
+ /* org module not present in this template — keep userEmail-only */
1495
1651
  }
1652
+ }
1496
1653
 
1497
- return runWithRequestContext(
1498
- { userEmail, orgId: orgId ?? undefined },
1499
- async () => {
1500
- const projectId = await resolveBuilderBranchProjectId();
1501
- const requestStatus = {
1502
- ...envStatus,
1503
- builderEnabled: !!projectId,
1504
- branchProjectIdConfigured: !!projectId,
1505
- branchProjectId: projectId || undefined,
1506
- };
1507
-
1508
- // Surface a recent OAuth callback failure before reporting a
1509
- // deployment fallback as "connected"; otherwise a failed personal
1510
- // connect attempt on a deploy that also has BUILDER_PRIVATE_KEY set
1511
- // looks successful even though the user's credentials were not saved.
1512
- try {
1513
- if (userEmail) {
1514
- const errKey = `builder-connect-error:${userEmail}`;
1515
- const errRow = await getSetting(errKey);
1516
- if (errRow && typeof errRow.message === "string") {
1517
- await deleteSetting(errKey).catch(() => {});
1518
- return withConnectToken({
1519
- ...requestStatus,
1520
- configured: false,
1521
- privateKeyConfigured: false,
1522
- publicKeyConfigured: false,
1523
- userId: undefined,
1524
- orgName: undefined,
1525
- orgKind: undefined,
1526
- subscription: undefined,
1527
- subscriptionLevel: undefined,
1528
- subscriptionName: undefined,
1529
- isEnterprise: undefined,
1530
- isFreeAccount: undefined,
1531
- connectError: {
1532
- message: errRow.message as string,
1533
- at:
1534
- typeof errRow.at === "number"
1535
- ? (errRow.at as number)
1536
- : Date.now(),
1537
- },
1538
- });
1539
- }
1540
- }
1541
- } catch {
1542
- // settings store unavailable — fall through
1543
- }
1654
+ return runWithRequestContext(
1655
+ { userEmail, orgId: orgId ?? undefined },
1656
+ async () => {
1657
+ const projectId = await resolveBuilderBranchProjectId();
1658
+ const requestStatus = {
1659
+ ...envStatus,
1660
+ builderEnabled: !!projectId,
1661
+ branchProjectIdConfigured: !!projectId,
1662
+ branchProjectId: projectId || undefined,
1663
+ };
1544
1664
 
1545
- // Read request-scoped Builder credentials first; deploy env is only
1546
- // the fallback. This keeps a root/local BUILDER_PRIVATE_KEY from
1547
- // blocking a user from connecting their own Builder account.
1548
- try {
1549
- const {
1550
- resolveBuilderCredentials,
1551
- resolveBuilderCredentialSource,
1552
- getBuilderCredentialAuthFailure,
1553
- } = await import("./credential-provider.js");
1554
- const [creds, credentialSource] = await Promise.all([
1555
- resolveBuilderCredentials(),
1556
- resolveBuilderCredentialSource(),
1557
- ]);
1558
- const authFailure =
1559
- await getBuilderCredentialAuthFailure(creds);
1560
- if (authFailure) {
1665
+ // Surface a recent OAuth callback failure before reporting a
1666
+ // deployment fallback as "connected"; otherwise a failed personal
1667
+ // connect attempt on a deploy that also has BUILDER_PRIVATE_KEY set
1668
+ // looks successful even though the user's credentials were not saved.
1669
+ try {
1670
+ if (userEmail) {
1671
+ const errKey = `builder-connect-error:${userEmail}`;
1672
+ const errRow = await getSetting(errKey);
1673
+ if (errRow && typeof errRow.message === "string") {
1674
+ await deleteSetting(errKey).catch(() => {});
1561
1675
  return withConnectToken({
1562
1676
  ...requestStatus,
1563
1677
  configured: false,
@@ -1571,113 +1685,157 @@ export function createCoreRoutesPlugin(
1571
1685
  subscriptionName: undefined,
1572
1686
  isEnterprise: undefined,
1573
1687
  isFreeAccount: undefined,
1574
- credentialSource: credentialSource ?? undefined,
1575
- // Surface durable credential rejection separately from
1576
- // one-shot cli-auth callback failures. The reconnect UI keeps
1577
- // polling through authError while the user chooses a new
1578
- // Builder space; connectError means the active callback itself
1579
- // failed and should stop the flow.
1580
- authError: {
1581
- message: authFailure.message,
1582
- at: authFailure.at,
1688
+ connectError: {
1689
+ message: errRow.message as string,
1690
+ at:
1691
+ typeof errRow.at === "number"
1692
+ ? (errRow.at as number)
1693
+ : Date.now(),
1583
1694
  },
1584
1695
  });
1585
1696
  }
1586
- if (creds.privateKey && creds.publicKey) {
1587
- // Best-effort: surface the real space name(s) from Builder's
1588
- // Admin API. Stay NON-BLOCKING return whatever is cached now
1589
- // and refresh in the background for the next poll. Falls back
1590
- // to orgName until the cache warms.
1591
- let spaces: Array<{ id: string; name: string }> | undefined;
1592
- try {
1593
- const { getCachedBuilderSpaces, listBuilderSpaces } =
1594
- await import("./builder-space.js");
1595
- const privateKey = creds.privateKey;
1596
- const cachedSpaces = getCachedBuilderSpaces(privateKey);
1597
- if (cachedSpaces && cachedSpaces.length > 0) {
1598
- spaces = cachedSpaces;
1599
- }
1600
- if (!cachedSpaces) {
1601
- // Warm the cache without blocking this response.
1602
- void listBuilderSpaces(privateKey).catch(() => {});
1603
- }
1604
- } catch {
1605
- // Admin API helper unavailable — leave spaces undefined.
1697
+ }
1698
+ } catch {
1699
+ // settings store unavailablefall through
1700
+ }
1701
+
1702
+ // Read request-scoped Builder credentials first; deploy env is only
1703
+ // the fallback. This keeps a root/local BUILDER_PRIVATE_KEY from
1704
+ // blocking a user from connecting their own Builder account.
1705
+ try {
1706
+ const {
1707
+ resolveBuilderCredentials,
1708
+ resolveBuilderCredentialSource,
1709
+ getBuilderCredentialAuthFailure,
1710
+ } = await import("./credential-provider.js");
1711
+ const [creds, credentialSource] = await Promise.all([
1712
+ resolveBuilderCredentials(),
1713
+ resolveBuilderCredentialSource(),
1714
+ ]);
1715
+ const authFailure = await getBuilderCredentialAuthFailure(creds);
1716
+ if (authFailure) {
1717
+ return withConnectToken({
1718
+ ...requestStatus,
1719
+ configured: false,
1720
+ privateKeyConfigured: false,
1721
+ publicKeyConfigured: false,
1722
+ userId: undefined,
1723
+ orgName: undefined,
1724
+ orgKind: undefined,
1725
+ subscription: undefined,
1726
+ subscriptionLevel: undefined,
1727
+ subscriptionName: undefined,
1728
+ isEnterprise: undefined,
1729
+ isFreeAccount: undefined,
1730
+ credentialSource: credentialSource ?? undefined,
1731
+ // Surface durable credential rejection separately from
1732
+ // one-shot cli-auth callback failures. The reconnect UI keeps
1733
+ // polling through authError while the user chooses a new
1734
+ // Builder space; connectError means the active callback itself
1735
+ // failed and should stop the flow.
1736
+ authError: {
1737
+ message: authFailure.message,
1738
+ at: authFailure.at,
1739
+ },
1740
+ });
1741
+ }
1742
+ if (creds.privateKey && creds.publicKey) {
1743
+ // Best-effort: surface the real space name(s) from Builder's
1744
+ // Admin API. Stay NON-BLOCKING — return whatever is cached now
1745
+ // and refresh in the background for the next poll. Falls back
1746
+ // to orgName until the cache warms.
1747
+ let spaces: Array<{ id: string; name: string }> | undefined;
1748
+ try {
1749
+ const { getCachedBuilderSpaces, listBuilderSpaces } =
1750
+ await import("./builder-space.js");
1751
+ const privateKey = creds.privateKey;
1752
+ const cachedSpaces = getCachedBuilderSpaces(privateKey);
1753
+ if (cachedSpaces && cachedSpaces.length > 0) {
1754
+ spaces = cachedSpaces;
1606
1755
  }
1607
- return withConnectToken({
1608
- ...requestStatus,
1609
- configured: true,
1610
- privateKeyConfigured: true,
1611
- publicKeyConfigured: !!creds.publicKey,
1612
- userId: creds.userId || envStatus.userId,
1613
- orgName: creds.orgName || envStatus.orgName,
1614
- spaces,
1615
- orgKind: creds.orgKind || envStatus.orgKind,
1616
- subscription:
1617
- creds.subscription || envStatus.subscription || undefined,
1618
- subscriptionLevel:
1619
- creds.subscriptionLevel ||
1620
- envStatus.subscriptionLevel ||
1621
- undefined,
1622
- subscriptionName:
1623
- creds.subscriptionName ||
1624
- envStatus.subscriptionName ||
1625
- undefined,
1626
- isEnterprise:
1627
- creds.isEnterprise ?? envStatus.isEnterprise ?? undefined,
1628
- isFreeAccount:
1629
- creds.isFreeAccount ??
1630
- envStatus.isFreeAccount ??
1631
- undefined,
1632
- credentialSource: credentialSource ?? undefined,
1633
- });
1756
+ if (!cachedSpaces) {
1757
+ // Warm the cache without blocking this response.
1758
+ void listBuilderSpaces(privateKey).catch(() => {});
1759
+ }
1760
+ } catch {
1761
+ // Admin API helper unavailable — leave spaces undefined.
1634
1762
  }
1635
- } catch {
1636
- // Secrets table not ready — fall through to env status
1763
+ return withConnectToken({
1764
+ ...requestStatus,
1765
+ configured: true,
1766
+ privateKeyConfigured: true,
1767
+ publicKeyConfigured: !!creds.publicKey,
1768
+ userId: creds.userId || envStatus.userId,
1769
+ orgName: creds.orgName || envStatus.orgName,
1770
+ spaces,
1771
+ orgKind: creds.orgKind || envStatus.orgKind,
1772
+ subscription:
1773
+ creds.subscription || envStatus.subscription || undefined,
1774
+ subscriptionLevel:
1775
+ creds.subscriptionLevel ||
1776
+ envStatus.subscriptionLevel ||
1777
+ undefined,
1778
+ subscriptionName:
1779
+ creds.subscriptionName ||
1780
+ envStatus.subscriptionName ||
1781
+ undefined,
1782
+ isEnterprise:
1783
+ creds.isEnterprise ?? envStatus.isEnterprise ?? undefined,
1784
+ isFreeAccount:
1785
+ creds.isFreeAccount ?? envStatus.isFreeAccount ?? undefined,
1786
+ credentialSource: credentialSource ?? undefined,
1787
+ });
1637
1788
  }
1789
+ } catch {
1790
+ // Secrets table not ready — fall through to env status
1791
+ }
1638
1792
 
1639
- // Honor legacy disconnect flag for existing deployments.
1640
- try {
1641
- const disconnected = await getSetting("builder-disconnected");
1642
- if (disconnected) {
1643
- return withConnectToken({
1644
- ...requestStatus,
1645
- configured: false,
1646
- privateKeyConfigured: false,
1647
- publicKeyConfigured: false,
1648
- userId: undefined,
1649
- orgName: undefined,
1650
- orgKind: undefined,
1651
- subscription: undefined,
1652
- subscriptionLevel: undefined,
1653
- subscriptionName: undefined,
1654
- isEnterprise: undefined,
1655
- isFreeAccount: undefined,
1656
- });
1657
- }
1658
- } catch {
1659
- // DB not reachable
1793
+ // Honor legacy disconnect flag for existing deployments.
1794
+ try {
1795
+ const disconnected = await getSetting("builder-disconnected");
1796
+ if (disconnected) {
1797
+ return withConnectToken({
1798
+ ...requestStatus,
1799
+ configured: false,
1800
+ privateKeyConfigured: false,
1801
+ publicKeyConfigured: false,
1802
+ userId: undefined,
1803
+ orgName: undefined,
1804
+ orgKind: undefined,
1805
+ subscription: undefined,
1806
+ subscriptionLevel: undefined,
1807
+ subscriptionName: undefined,
1808
+ isEnterprise: undefined,
1809
+ isFreeAccount: undefined,
1810
+ });
1660
1811
  }
1661
- // No env, no per-user creds → not configured. Both authenticated
1662
- // and unauthenticated callers see "not connected" so they can
1663
- // run through the OAuth flow.
1664
- return withConnectToken({
1665
- ...requestStatus,
1666
- configured: false,
1667
- privateKeyConfigured: false,
1668
- publicKeyConfigured: false,
1669
- userId: undefined,
1670
- orgName: undefined,
1671
- orgKind: undefined,
1672
- subscription: undefined,
1673
- subscriptionLevel: undefined,
1674
- subscriptionName: undefined,
1675
- isEnterprise: undefined,
1676
- isFreeAccount: undefined,
1677
- });
1678
- },
1679
- );
1680
- }),
1812
+ } catch {
1813
+ // DB not reachable
1814
+ }
1815
+ // No env, no per-user creds → not configured. Both authenticated
1816
+ // and unauthenticated callers see "not connected" so they can
1817
+ // run through the OAuth flow.
1818
+ return withConnectToken({
1819
+ ...requestStatus,
1820
+ configured: false,
1821
+ privateKeyConfigured: false,
1822
+ publicKeyConfigured: false,
1823
+ userId: undefined,
1824
+ orgName: undefined,
1825
+ orgKind: undefined,
1826
+ subscription: undefined,
1827
+ subscriptionLevel: undefined,
1828
+ subscriptionName: undefined,
1829
+ isEnterprise: undefined,
1830
+ isFreeAccount: undefined,
1831
+ });
1832
+ },
1833
+ );
1834
+ });
1835
+ mountBuilderStatusRouteAliases(
1836
+ (path, handler) => getH3App(nitroApp).use(path, handler),
1837
+ P,
1838
+ builderStatusHandler,
1681
1839
  );
1682
1840
 
1683
1841
  // How long a pending-connect row is valid. Must be long enough for
@@ -1827,15 +1985,77 @@ export function createCoreRoutesPlugin(
1827
1985
  // No prior error row — fine
1828
1986
  }
1829
1987
 
1988
+ const previewOrigin = getBuilderBrowserOriginForEvent(event).replace(
1989
+ /\/+$/,
1990
+ "",
1991
+ );
1992
+ const callbackOrigin = getBuilderCliAuthCallbackOriginForEvent(
1993
+ event,
1994
+ ).replace(/\/+$/, "");
1995
+ let relay:
1996
+ | { state: string; payload: BuilderPreviewRelayState }
1997
+ | undefined;
1998
+ if (previewOrigin !== callbackOrigin) {
1999
+ try {
2000
+ relay = signBuilderPreviewRelayState({
2001
+ ownerEmail,
2002
+ targetOrigin: previewOrigin,
2003
+ basePath: getAppBasePath(),
2004
+ });
2005
+ } catch (err) {
2006
+ const msg =
2007
+ err instanceof Error
2008
+ ? err.message
2009
+ : "Builder preview relay is not configured.";
2010
+ setResponseStatus(event, 503);
2011
+ setResponseHeader(
2012
+ event,
2013
+ "Content-Type",
2014
+ "text/html; charset=utf-8",
2015
+ );
2016
+ return createBuilderBrowserCallbackErrorPage(msg, {
2017
+ title: "Builder preview connection isn't configured",
2018
+ body: "This preview needs its secure Builder callback relay configured before authorization can start.",
2019
+ closeHint:
2020
+ "Close this popup and ask the preview owner to finish Builder relay setup.",
2021
+ parentOrigin: previewOrigin,
2022
+ });
2023
+ }
2024
+ }
2025
+
2026
+ let pendingOrgId: string | null = null;
2027
+ let pendingRole: string | null = null;
2028
+ if (!ownerContext.anonymous) {
2029
+ try {
2030
+ const orgContext = await getOrgContext(event);
2031
+ pendingOrgId = orgContext.orgId ?? null;
2032
+ pendingRole = orgContext.role ?? null;
2033
+ } catch {
2034
+ // The pending owner remains user-scoped when org context is absent.
2035
+ }
2036
+ }
2037
+
1830
2038
  // Store a short-lived pending row. If the DB is unavailable we
1831
2039
  // surface a popup-renderable error page that signals the parent
1832
2040
  // via BroadcastChannel, rather than letting the popup show raw
1833
2041
  // JSON and the parent poll for 5 minutes.
1834
2042
  try {
1835
- await putSetting(`builder-pending-connect:${ownerEmail}`, {
1836
- expiresAt: Date.now() + BUILDER_CONNECT_PENDING_TTL_MS,
1837
- tracking: connectTracking,
1838
- });
2043
+ if (relay) {
2044
+ await putSetting(builderRelayPendingKey(relay.payload.flowId), {
2045
+ ownerEmail,
2046
+ orgId: pendingOrgId,
2047
+ role: pendingRole,
2048
+ targetOrigin: relay.payload.targetOrigin,
2049
+ basePath: relay.payload.basePath,
2050
+ expiresAt: relay.payload.exp,
2051
+ tracking: connectTracking,
2052
+ });
2053
+ } else {
2054
+ await putSetting(`builder-pending-connect:${ownerEmail}`, {
2055
+ expiresAt: Date.now() + BUILDER_CONNECT_PENDING_TTL_MS,
2056
+ tracking: connectTracking,
2057
+ });
2058
+ }
1839
2059
  } catch (err) {
1840
2060
  await trackBuilderLifecycle(
1841
2061
  event,
@@ -1886,10 +2106,11 @@ export function createCoreRoutesPlugin(
1886
2106
  // clients, but still send it to Builder immediately and include signed
1887
2107
  // callback state so the callback does not depend on popup cookies.
1888
2108
  const cliAuthUrl = buildBuilderCliAuthUrl(
1889
- getBuilderCliAuthCallbackOriginForEvent(event),
2109
+ callbackOrigin,
1890
2110
  signBuilderCallbackState(ownerEmail),
1891
2111
  {
1892
- previewOrigin: getBuilderBrowserOriginForEvent(event),
2112
+ previewOrigin,
2113
+ relayState: relay?.state,
1893
2114
  tracking: connectTracking,
1894
2115
  },
1895
2116
  );
@@ -2067,6 +2288,51 @@ export function createCoreRoutesPlugin(
2067
2288
  }),
2068
2289
  );
2069
2290
 
2291
+ getH3App(nitroApp).use(
2292
+ `${P}/builder/relay`,
2293
+ defineEventHandler(async (event: H3Event) => {
2294
+ if (getMethod(event) !== "POST") {
2295
+ setResponseStatus(event, 405);
2296
+ return { error: "Method not allowed" };
2297
+ }
2298
+ const rawBody = await readBuilderRelayRequestBody(event);
2299
+ const result = await consumeBuilderRelayRequest(
2300
+ {
2301
+ rawBody,
2302
+ timestamp: getHeader(event, BUILDER_RELAY_TIMESTAMP_HEADER),
2303
+ flowId: getHeader(event, BUILDER_RELAY_FLOW_HEADER),
2304
+ signature: getHeader(event, BUILDER_RELAY_SIGNATURE_HEADER),
2305
+ requestOrigin: getFrameworkRouteRequestUrl(event).origin,
2306
+ requestBasePath: getAppBasePath(),
2307
+ },
2308
+ {
2309
+ getPending: getSetting,
2310
+ deletePending: deleteSetting,
2311
+ writeCredentials: async (ownerEmail, credentials, scope) => {
2312
+ const { writeBuilderCredentials } =
2313
+ await import("./credential-provider.js");
2314
+ await writeBuilderCredentials(ownerEmail, credentials, scope);
2315
+ await Promise.all([
2316
+ deleteSetting("builder-disconnected").catch(() => false),
2317
+ deleteSetting(`builder-connect-error:${ownerEmail}`).catch(
2318
+ () => false,
2319
+ ),
2320
+ ]);
2321
+ },
2322
+ },
2323
+ ).catch(() => ({
2324
+ ok: false as const,
2325
+ status: 500,
2326
+ error: "Builder relay credential persistence failed",
2327
+ }));
2328
+ if (!result.ok) {
2329
+ setResponseStatus(event, result.status);
2330
+ return { error: result.error };
2331
+ }
2332
+ return { ok: true };
2333
+ }),
2334
+ );
2335
+
2070
2336
  getH3App(nitroApp).use(
2071
2337
  `${P}/builder/callback`,
2072
2338
  defineEventHandler(async (event: H3Event) => {
@@ -2074,6 +2340,119 @@ export function createCoreRoutesPlugin(
2074
2340
  setResponseStatus(event, 405);
2075
2341
  return { error: "Method not allowed" };
2076
2342
  }
2343
+ // Builder's provider contract puts credentials on this first-hop
2344
+ // URL. Keep the response out of caches and suppress referrer
2345
+ // propagation even though the second hop carries secrets only in
2346
+ // its authenticated POST body.
2347
+ setResponseHeader(event, "Cache-Control", "no-store");
2348
+ setResponseHeader(event, "Referrer-Policy", "no-referrer");
2349
+
2350
+ const requestUrl = getFrameworkRouteRequestUrl(event);
2351
+ const relayStateRaw = requestUrl.searchParams.get(
2352
+ BUILDER_RELAY_STATE_PARAM,
2353
+ );
2354
+ if (relayStateRaw) {
2355
+ let relayPayload: BuilderPreviewRelayState | null = null;
2356
+ try {
2357
+ relayPayload =
2358
+ verifyBuilderPreviewRelayStateForCallback(relayStateRaw);
2359
+ } catch {
2360
+ // A preview relay must fail closed when its dedicated shared
2361
+ // secret is absent on the corporate callback deployment.
2362
+ }
2363
+ if (!relayPayload) {
2364
+ setResponseStatus(event, 403);
2365
+ setResponseHeader(
2366
+ event,
2367
+ "Content-Type",
2368
+ "text/html; charset=utf-8",
2369
+ );
2370
+ return createBuilderBrowserCallbackErrorPage(
2371
+ "Builder preview relay state is invalid or expired.",
2372
+ );
2373
+ }
2374
+
2375
+ const privateKey = requestUrl.searchParams.get("p-key");
2376
+ const publicKey = requestUrl.searchParams.get("api-key");
2377
+ if (!privateKey || !publicKey) {
2378
+ setResponseStatus(event, 400);
2379
+ setResponseHeader(
2380
+ event,
2381
+ "Content-Type",
2382
+ "text/html; charset=utf-8",
2383
+ );
2384
+ return createBuilderBrowserCallbackErrorPage(
2385
+ "Builder didn't return credentials. Restart the connect flow from settings.",
2386
+ { parentOrigin: relayPayload.targetOrigin },
2387
+ );
2388
+ }
2389
+
2390
+ const credentials: BuilderRelayCredentials = {
2391
+ privateKey,
2392
+ publicKey,
2393
+ userId: requestUrl.searchParams.get("user-id"),
2394
+ orgName: requestUrl.searchParams.get("org-name"),
2395
+ orgKind: requestUrl.searchParams.get("kind"),
2396
+ subscription: requestUrl.searchParams.get("subscription"),
2397
+ subscriptionLevel:
2398
+ requestUrl.searchParams.get("subscription-level"),
2399
+ subscriptionName:
2400
+ requestUrl.searchParams.get("subscription-name"),
2401
+ isEnterprise: parseBuilderCallbackBoolean(
2402
+ requestUrl.searchParams.get("is-enterprise"),
2403
+ ),
2404
+ isFreeAccount: parseBuilderCallbackBoolean(
2405
+ requestUrl.searchParams.get("is-free-account"),
2406
+ ),
2407
+ };
2408
+
2409
+ try {
2410
+ const relayRequest = createBuilderRelayRequest(
2411
+ relayStateRaw,
2412
+ credentials,
2413
+ );
2414
+ const response = await ssrfSafeFetch(
2415
+ relayRequest.url,
2416
+ {
2417
+ method: "POST",
2418
+ headers: relayRequest.headers,
2419
+ body: relayRequest.body,
2420
+ },
2421
+ { maxRedirects: 0, httpsOnly: true },
2422
+ );
2423
+ if (!response.ok) {
2424
+ throw new Error(
2425
+ `Preview relay rejected the callback (${response.status}).`,
2426
+ );
2427
+ }
2428
+ } catch (err) {
2429
+ const message =
2430
+ err instanceof Error
2431
+ ? err.message
2432
+ : "Builder preview relay failed.";
2433
+ // Never log the first-hop URL or relay body: both contain
2434
+ // credentials. The popup gets a bounded, credential-free error.
2435
+ setResponseStatus(event, 502);
2436
+ setResponseHeader(
2437
+ event,
2438
+ "Content-Type",
2439
+ "text/html; charset=utf-8",
2440
+ );
2441
+ return createBuilderBrowserCallbackErrorPage(message, {
2442
+ parentOrigin: relayPayload.targetOrigin,
2443
+ });
2444
+ }
2445
+
2446
+ setResponseHeader(
2447
+ event,
2448
+ "Content-Type",
2449
+ "text/html; charset=utf-8",
2450
+ );
2451
+ return createBuilderBrowserCallbackPage(
2452
+ `${relayPayload.targetOrigin}${relayPayload.basePath || "/"}`,
2453
+ { parentOrigin: relayPayload.targetOrigin },
2454
+ );
2455
+ }
2077
2456
 
2078
2457
  // A real session or a template-approved anonymous owner is required;
2079
2458
  // the pending-row check below (combined with the same-origin gate on
@@ -2105,7 +2484,6 @@ export function createCoreRoutesPlugin(
2105
2484
  }
2106
2485
  clearBuilderConnectOwnerCookie(event);
2107
2486
 
2108
- const requestUrl = getFrameworkRouteRequestUrl(event);
2109
2487
  let connectTracking = getBuilderConnectTrackingParams(
2110
2488
  requestUrl.searchParams,
2111
2489
  );