@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
@@ -45,6 +45,7 @@ import {
45
45
  TooltipTrigger,
46
46
  } from "../components/ui/tooltip.js";
47
47
  import { TeamPage } from "../org/TeamPage.js";
48
+ import { BuilderConnectCard } from "../setup-connections/BuilderConnectCard.js";
48
49
  import { callAction } from "../use-action.js";
49
50
  import { uploadAvatar, useAvatarUrl } from "../use-avatar.js";
50
51
  import { useDevMode } from "../use-dev-mode.js";
@@ -2578,6 +2579,7 @@ interface SettingsPanelContentProps extends SettingsPanelProps {
2578
2579
  showCapabilityStrip?: boolean;
2579
2580
  className?: string;
2580
2581
  surface?: SettingsSurface;
2582
+ builderConnectionOwnedExternally?: boolean;
2581
2583
  }
2582
2584
 
2583
2585
  function SettingsPanelContent({
@@ -2591,8 +2593,11 @@ function SettingsPanelContent({
2591
2593
  showCapabilityStrip = true,
2592
2594
  className,
2593
2595
  surface = "sidebar",
2596
+ builderConnectionOwnedExternally = false,
2594
2597
  }: SettingsPanelContentProps) {
2595
- const { status: builder, loading: builderLoading } = useBuilderStatus();
2598
+ const { status: builder, loading: builderLoading } = useBuilderStatus({
2599
+ enabled: !builderConnectionOwnedExternally,
2600
+ });
2596
2601
  const connected = builder?.configured ?? false;
2597
2602
  const connectUrl = builder?.cliAuthUrl ?? builder?.connectUrl;
2598
2603
  const orgName = builder?.orgName;
@@ -2600,6 +2605,7 @@ function SettingsPanelContent({
2600
2605
  const credentialSource = builder?.credentialSource;
2601
2606
  const builderBranchesAvailable = !!builder?.builderEnabled;
2602
2607
  const builderFlow = useBuilderConnectFlow({
2608
+ enabled: !builderConnectionOwnedExternally,
2603
2609
  popupUrl: connectUrl,
2604
2610
  trackingSource: "settings_panel_builder_card",
2605
2611
  });
@@ -2965,20 +2971,22 @@ function SettingsPanelContent({
2965
2971
  icon={<IconBrowser size={14} />}
2966
2972
  title="Browser Automation"
2967
2973
  subtitle="Let agents control a real browser for web tasks."
2968
- connected={connected}
2974
+ connected={builderConnectionOwnedExternally ? undefined : connected}
2969
2975
  open={openSection === "browser"}
2970
2976
  onToggle={() => toggle("browser")}
2971
2977
  >
2972
- <UseBuilderCard
2973
- builderFlow={builderFlow}
2974
- connectUrl={connectUrl}
2975
- connected={connected}
2976
- orgName={orgName}
2977
- envManaged={envManaged}
2978
- credentialSource={credentialSource}
2979
- trackingSource="browser_settings"
2980
- trackingFlow="browser_automation"
2981
- />
2978
+ {!builderConnectionOwnedExternally ? (
2979
+ <UseBuilderCard
2980
+ builderFlow={builderFlow}
2981
+ connectUrl={connectUrl}
2982
+ connected={connected}
2983
+ orgName={orgName}
2984
+ envManaged={envManaged}
2985
+ credentialSource={credentialSource}
2986
+ trackingSource="browser_settings"
2987
+ trackingFlow="browser_automation"
2988
+ />
2989
+ ) : null}
2982
2990
  </SettingsSection>
2983
2991
  )}
2984
2992
 
@@ -3057,6 +3065,26 @@ export function SettingsPanel(props: SettingsPanelProps) {
3057
3065
  return <SettingsPanelContent {...props} />;
3058
3066
  }
3059
3067
 
3068
+ export function ConnectionsSettingsContent({
3069
+ settingsPanelProps,
3070
+ }: {
3071
+ settingsPanelProps: SettingsPanelProps;
3072
+ }) {
3073
+ return (
3074
+ <div className="mx-auto w-full max-w-2xl space-y-4">
3075
+ <BuilderConnectCard trackingSource="settings_connections" />
3076
+ <SettingsPanelContent
3077
+ {...settingsPanelProps}
3078
+ surface="page"
3079
+ sections={CONNECTION_SETTINGS_SECTIONS}
3080
+ showCapabilityStrip={false}
3081
+ className="w-full"
3082
+ builderConnectionOwnedExternally
3083
+ />
3084
+ </div>
3085
+ );
3086
+ }
3087
+
3060
3088
  export function useAgentSettingsTabs(): SettingsTabItem[] {
3061
3089
  const { isDevMode, canToggle, setDevMode } = useDevMode();
3062
3090
  const baseProps = useMemo<SettingsPanelProps>(
@@ -3102,15 +3130,7 @@ export function useAgentSettingsTabs(): SettingsTabItem[] {
3102
3130
  ...connections,
3103
3131
  icon: IconPlugConnected,
3104
3132
  group: "agent",
3105
- content: (
3106
- <SettingsPanelContent
3107
- {...baseProps}
3108
- surface="page"
3109
- sections={CONNECTION_SETTINGS_SECTIONS}
3110
- showCapabilityStrip={false}
3111
- className="mx-auto w-full max-w-2xl"
3112
- />
3113
- ),
3133
+ content: <ConnectionsSettingsContent settingsPanelProps={baseProps} />,
3114
3134
  },
3115
3135
  {
3116
3136
  ...organization,
@@ -53,10 +53,14 @@ export interface BuilderStatus {
53
53
  }
54
54
 
55
55
  /**
56
- * Fetches Builder connection status from /_agent-native/builder/status.
56
+ * Fetches Builder connection status from the neutral connection-status route.
57
+ * The legacy /_agent-native/builder/status route remains available for older
58
+ * clients.
57
59
  * Re-fetches on window focus to detect post-redirect state changes.
58
60
  */
59
- export function useBuilderStatus() {
61
+ export function useBuilderStatus({
62
+ enabled = true,
63
+ }: { enabled?: boolean } = {}) {
60
64
  const [status, setStatus] = useState<BuilderStatus | null>(null);
61
65
  const [loading, setLoading] = useState(true);
62
66
  const [error, setError] = useState<string | null>(null);
@@ -64,6 +68,7 @@ export function useBuilderStatus() {
64
68
  const lastGoodStatusRef = useRef<BuilderStatus | null>(null);
65
69
 
66
70
  const fetchStatus = useCallback(async () => {
71
+ if (!enabled) return;
67
72
  const keepLastGoodStatus = (message: string) => {
68
73
  const lastGoodStatus = lastGoodStatusRef.current;
69
74
  setStatus(lastGoodStatus);
@@ -72,7 +77,9 @@ export function useBuilderStatus() {
72
77
  };
73
78
 
74
79
  try {
75
- const res = await fetch(agentNativePath("/_agent-native/builder/status"));
80
+ const res = await fetch(
81
+ agentNativePath("/_agent-native/connection-status/builder"),
82
+ );
76
83
  if (!res.ok) {
77
84
  keepLastGoodStatus(`Builder status unavailable (${res.status})`);
78
85
  return;
@@ -91,9 +98,18 @@ export function useBuilderStatus() {
91
98
  } finally {
92
99
  setLoading(false);
93
100
  }
94
- }, []);
101
+ }, [enabled]);
95
102
 
96
103
  useEffect(() => {
104
+ if (!enabled) {
105
+ setStatus(null);
106
+ setLoading(false);
107
+ setError(null);
108
+ setStale(false);
109
+ lastGoodStatusRef.current = null;
110
+ return;
111
+ }
112
+ setLoading(true);
97
113
  fetchStatus();
98
114
 
99
115
  function onFocus() {
@@ -115,7 +131,7 @@ export function useBuilderStatus() {
115
131
  fetchStatus,
116
132
  );
117
133
  };
118
- }, [fetchStatus]);
134
+ }, [enabled, fetchStatus]);
119
135
 
120
136
  return { status, loading, error, stale, refetch: fetchStatus };
121
137
  }
@@ -123,7 +139,7 @@ export function useBuilderStatus() {
123
139
  // ─── useBuilderConnectFlow ──────────────────────────────────────────────────
124
140
  //
125
141
  // Shared state machine for the "open Builder CLI-auth popup + poll
126
- // /builder/status until credentials land" interaction. Replaces three
142
+ // /connection-status/builder until credentials land" interaction. Replaces three
127
143
  // near-duplicate inline implementations: `BuilderCliAuthMethod` in
128
144
  // OnboardingPanel, `ConnectBuilderCard`, and `BuilderConnectCta` in
129
145
  // AssistantChat. Each consumer supplies its own popup URL / completion
@@ -158,7 +174,7 @@ export interface BuilderConnectStartOptions {
158
174
 
159
175
  export interface BuilderConnectFlow {
160
176
  configured: boolean;
161
- /** True after at least one successful `/builder/status` response. */
177
+ /** True after at least one successful Builder connection-status response. */
162
178
  statusResolved: boolean;
163
179
  /**
164
180
  * True when the deploy has BUILDER_PRIVATE_KEY set as a fallback. Connect
@@ -176,7 +192,7 @@ export interface BuilderConnectFlow {
176
192
  connecting: boolean;
177
193
  error: string | null;
178
194
  /**
179
- * True once the first `/builder/status` fetch has completed (successfully
195
+ * True once the first Builder connection-status fetch has completed (successfully
180
196
  * or not). Consumers that accept an `initialConfigured` prop (e.g. agent
181
197
  * tool-call results rendered with server-side state) should treat
182
198
  * `configured`/`orgName` as authoritative only once this flips true —
@@ -579,7 +595,10 @@ export function useBuilderConnectFlow(
579
595
  const origin = getCallbackOrigin() || window.location.origin;
580
596
  try {
581
597
  const r = await fetch(
582
- new URL(agentNativePath("/_agent-native/builder/status"), origin).href,
598
+ new URL(
599
+ agentNativePath("/_agent-native/connection-status/builder"),
600
+ origin,
601
+ ).href,
583
602
  );
584
603
  if (!r.ok) return null;
585
604
  return (await r.json()) as {
@@ -30,9 +30,7 @@ export function BuilderConnectCard({
30
30
  ? "Checking"
31
31
  : flow.configured
32
32
  ? connectedLabel
33
- : flow.builderEnabled
34
- ? "Ready to connect"
35
- : "Project not configured";
33
+ : "Ready to connect";
36
34
 
37
35
  return (
38
36
  <section
@@ -73,10 +71,7 @@ export function BuilderConnectCard({
73
71
  <button
74
72
  type="button"
75
73
  onClick={() => flow.start()}
76
- disabled={
77
- flow.connecting ||
78
- (!flow.builderEnabled && flow.hasFetchedStatus)
79
- }
74
+ disabled={flow.connecting}
80
75
  className="mt-3 inline-flex h-8 items-center gap-2 rounded-md border border-border bg-foreground px-3 text-xs font-medium text-background transition-colors hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
81
76
  >
82
77
  {flow.connecting ? (
@@ -104,9 +104,12 @@ import type { BetterAuthConfig } from "./better-auth-instance.js";
104
104
  import {
105
105
  BUILDER_CONNECT_OWNER_COOKIE,
106
106
  BUILDER_CONNECT_PARAM,
107
+ BUILDER_RELAY_PATH,
108
+ BUILDER_RELAY_STATE_PARAM,
107
109
  BUILDER_STATE_PARAM,
108
110
  verifyBuilderCallbackStateAndGetOwner,
109
111
  verifyBuilderConnectTokenAndGetOwner,
112
+ verifyBuilderPreviewRelayStateForCallback,
110
113
  } from "./builder-browser.js";
111
114
  import { resolveAuthCookieNamespace } from "./cookie-namespace.js";
112
115
  import {
@@ -1522,7 +1525,15 @@ function verifiedBuilderConnectOwnerFromUrl(url: string): string | null {
1522
1525
  return verifyBuilderConnectTokenAndGetOwner(token);
1523
1526
  }
1524
1527
 
1525
- function shouldBypassAuthForBuilderConnect(event: H3Event, p: string): boolean {
1528
+ export function shouldBypassAuthForBuilderConnect(
1529
+ event: H3Event,
1530
+ p: string,
1531
+ ): boolean {
1532
+ // The preview-safe second hop is authenticated by its timestamped HMAC and
1533
+ // one-shot pending row. It cannot carry a browser session from the corporate
1534
+ // callback deployment, so let the route perform that stronger check itself.
1535
+ if (p === BUILDER_RELAY_PATH) return true;
1536
+
1526
1537
  if (p === "/_agent-native/builder/connect") {
1527
1538
  const url = event.node?.req?.url ?? event.path ?? "/";
1528
1539
  return Boolean(verifiedBuilderConnectOwnerFromUrl(url));
@@ -1537,6 +1548,19 @@ function shouldBypassAuthForBuilderConnect(event: H3Event, p: string): boolean {
1537
1548
  BUILDER_STATE_PARAM,
1538
1549
  )
1539
1550
  : null;
1551
+ const relayState =
1552
+ queryStart >= 0
1553
+ ? new URLSearchParams(url.slice(queryStart + 1)).get(
1554
+ BUILDER_RELAY_STATE_PARAM,
1555
+ )
1556
+ : null;
1557
+ if (relayState) {
1558
+ try {
1559
+ if (verifyBuilderPreviewRelayStateForCallback(relayState)) return true;
1560
+ } catch {
1561
+ // Dedicated relay secret missing: let the auth guard fail closed.
1562
+ }
1563
+ }
1540
1564
  // The signed `_an_state` authenticates this specific Builder callback
1541
1565
  // flow back to our app. A stale localhost session cookie can otherwise
1542
1566
  // make the global guard reject the callback before the handler gets to
@@ -1,4 +1,9 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import {
2
+ createHash,
3
+ createHmac,
4
+ randomBytes,
5
+ timingSafeEqual,
6
+ } from "node:crypto";
2
7
 
3
8
  import type { H3Event } from "h3";
4
9
  import { getHeader } from "h3";
@@ -15,6 +20,349 @@ const BUILDER_BROWSER_HOST = "agent-native-browser";
15
20
  const BUILDER_BROWSER_CLIENT_ID = "Agent Native Browser";
16
21
 
17
22
  export const BUILDER_CALLBACK_PATH = "/_agent-native/builder/callback";
23
+ export const BUILDER_RELAY_PATH = "/_agent-native/builder/relay";
24
+ export const BUILDER_RELAY_STATE_PARAM = "_an_relay";
25
+ export const BUILDER_RELAY_SECRET_ENV = "AGENT_NATIVE_BUILDER_RELAY_SECRET";
26
+ export const BUILDER_RELAY_TARGET_ORIGINS_ENV =
27
+ "AGENT_NATIVE_BUILDER_RELAY_TARGET_ORIGINS";
28
+ export const BUILDER_RELAY_TIMESTAMP_HEADER = "x-agent-native-relay-timestamp";
29
+ export const BUILDER_RELAY_FLOW_HEADER = "x-agent-native-relay-flow";
30
+ export const BUILDER_RELAY_SIGNATURE_HEADER = "x-agent-native-relay-signature";
31
+
32
+ const BUILDER_RELAY_PURPOSE = "builder-preview-callback-relay";
33
+ const BUILDER_RELAY_STATE_VERSION = 1;
34
+ const BUILDER_RELAY_TTL_MS = 10 * 60 * 1000;
35
+ const BUILDER_RELAY_REQUEST_SKEW_MS = 2 * 60 * 1000;
36
+
37
+ export interface BuilderPreviewRelayState {
38
+ v: 1;
39
+ purpose: typeof BUILDER_RELAY_PURPOSE;
40
+ flowId: string;
41
+ ownerEmail: string;
42
+ targetOrigin: string;
43
+ basePath: string;
44
+ iat: number;
45
+ exp: number;
46
+ }
47
+
48
+ export interface BuilderRelayCredentials {
49
+ privateKey: string;
50
+ publicKey: string;
51
+ userId: string | null;
52
+ orgName: string | null;
53
+ orgKind: string | null;
54
+ subscription: string | null;
55
+ subscriptionLevel: string | null;
56
+ subscriptionName: string | null;
57
+ isEnterprise: boolean | null;
58
+ isFreeAccount: boolean | null;
59
+ }
60
+
61
+ export interface BuilderRelayRequestBody {
62
+ relayState: string;
63
+ credentials: BuilderRelayCredentials;
64
+ }
65
+
66
+ function builderRelaySecret(): string {
67
+ const secret = process.env[BUILDER_RELAY_SECRET_ENV]?.trim();
68
+ if (!secret) {
69
+ throw new Error(
70
+ `${BUILDER_RELAY_SECRET_ENV} is required for Builder preview authorization relay.`,
71
+ );
72
+ }
73
+ if (secret.length < 32) {
74
+ throw new Error(
75
+ `${BUILDER_RELAY_SECRET_ENV} must be at least 32 characters long.`,
76
+ );
77
+ }
78
+ return secret;
79
+ }
80
+
81
+ function builderRelayMac(value: string): string {
82
+ return createHmac("sha256", builderRelaySecret())
83
+ .update(value)
84
+ .digest("base64url");
85
+ }
86
+
87
+ function safeEqualText(expected: string, actual: string): boolean {
88
+ const expectedBuffer = Buffer.from(expected);
89
+ const actualBuffer = Buffer.from(actual);
90
+ return (
91
+ expectedBuffer.length === actualBuffer.length &&
92
+ timingSafeEqual(expectedBuffer, actualBuffer)
93
+ );
94
+ }
95
+
96
+ function normalizeBuilderRelayBasePath(value: string): string | null {
97
+ if (!value) return "";
98
+ if (!value.startsWith("/") || value.includes("?") || value.includes("#")) {
99
+ return null;
100
+ }
101
+ const normalized = value.replace(/\/+$/, "");
102
+ if (normalized.split("/").some((part) => part === "." || part === "..")) {
103
+ return null;
104
+ }
105
+ return normalized;
106
+ }
107
+
108
+ export function isSafeBuilderRelayTargetOrigin(value: string): boolean {
109
+ try {
110
+ const url = new URL(value);
111
+ if (
112
+ url.origin !== value ||
113
+ url.username ||
114
+ url.password ||
115
+ url.hostname.includes("*")
116
+ )
117
+ return false;
118
+ const hostname = url.hostname.toLowerCase();
119
+ const loopback =
120
+ hostname === "localhost" ||
121
+ hostname === "127.0.0.1" ||
122
+ hostname === "::1" ||
123
+ hostname === "[::1]";
124
+ if (loopback)
125
+ return url.protocol === "http:" && process.env.NODE_ENV !== "production";
126
+ if (url.protocol !== "https:") return false;
127
+ return (
128
+ hostname.endsWith(".netlify.app") ||
129
+ hostname.endsWith(".vercel.app") ||
130
+ hostname === "agent-native.com" ||
131
+ hostname.endsWith(".agent-native.com") ||
132
+ hostname.endsWith(".builder.io") ||
133
+ hostname.endsWith(".builderio.xyz") ||
134
+ hostname.endsWith(".builderio.dev") ||
135
+ hostname.endsWith(".builder.codes")
136
+ );
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ export function isTrustedBuilderRelayTargetOrigin(value: string): boolean {
143
+ if (!isSafeBuilderRelayTargetOrigin(value)) return false;
144
+ const hostname = new URL(value).hostname.toLowerCase();
145
+ if (
146
+ hostname.endsWith(".netlify.app") &&
147
+ !/^[a-f0-9]{24}--[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.netlify\.app$/.test(
148
+ hostname,
149
+ )
150
+ ) {
151
+ return false;
152
+ }
153
+ const configured = process.env[BUILDER_RELAY_TARGET_ORIGINS_ENV];
154
+ if (!configured) return false;
155
+ return configured
156
+ .split(",")
157
+ .map((origin) => origin.trim())
158
+ .filter(Boolean)
159
+ .some((origin) => origin === value && !origin.includes("*"));
160
+ }
161
+
162
+ export function signBuilderPreviewRelayState(input: {
163
+ ownerEmail: string;
164
+ targetOrigin: string;
165
+ basePath?: string;
166
+ flowId?: string;
167
+ now?: number;
168
+ }): { state: string; payload: BuilderPreviewRelayState } {
169
+ if (!isSafeBuilderRelayTargetOrigin(input.targetOrigin)) {
170
+ throw new Error(
171
+ "Builder relay target origin is not an approved preview origin.",
172
+ );
173
+ }
174
+ const basePath = normalizeBuilderRelayBasePath(input.basePath ?? "");
175
+ if (basePath === null) throw new Error("Builder relay base path is invalid.");
176
+ const now = input.now ?? Date.now();
177
+ const payload: BuilderPreviewRelayState = {
178
+ v: BUILDER_RELAY_STATE_VERSION,
179
+ purpose: BUILDER_RELAY_PURPOSE,
180
+ flowId: input.flowId ?? randomBytes(24).toString("base64url"),
181
+ ownerEmail: input.ownerEmail,
182
+ targetOrigin: input.targetOrigin,
183
+ basePath,
184
+ iat: now,
185
+ exp: now + BUILDER_RELAY_TTL_MS,
186
+ };
187
+ const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString(
188
+ "base64url",
189
+ );
190
+ return { state: `${encoded}.${builderRelayMac(encoded)}`, payload };
191
+ }
192
+
193
+ export function verifyBuilderPreviewRelayState(
194
+ state: string | null | undefined,
195
+ options: { now?: number } = {},
196
+ ): BuilderPreviewRelayState | null {
197
+ if (!state) return null;
198
+ const parts = state.split(".");
199
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
200
+ if (!safeEqualText(builderRelayMac(parts[0]), parts[1])) return null;
201
+ let value: unknown;
202
+ try {
203
+ value = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
204
+ } catch {
205
+ return null;
206
+ }
207
+ if (!value || typeof value !== "object") return null;
208
+ const payload = value as Partial<BuilderPreviewRelayState>;
209
+ const now = options.now ?? Date.now();
210
+ if (
211
+ payload.v !== BUILDER_RELAY_STATE_VERSION ||
212
+ payload.purpose !== BUILDER_RELAY_PURPOSE ||
213
+ typeof payload.flowId !== "string" ||
214
+ !/^[A-Za-z0-9_-]{24,128}$/.test(payload.flowId) ||
215
+ typeof payload.ownerEmail !== "string" ||
216
+ !payload.ownerEmail.includes("@") ||
217
+ typeof payload.targetOrigin !== "string" ||
218
+ !isSafeBuilderRelayTargetOrigin(payload.targetOrigin) ||
219
+ typeof payload.basePath !== "string" ||
220
+ normalizeBuilderRelayBasePath(payload.basePath) !== payload.basePath ||
221
+ typeof payload.iat !== "number" ||
222
+ typeof payload.exp !== "number" ||
223
+ payload.exp <= payload.iat ||
224
+ payload.exp - payload.iat > BUILDER_RELAY_TTL_MS ||
225
+ payload.iat > now + BUILDER_RELAY_REQUEST_SKEW_MS ||
226
+ payload.exp < now
227
+ ) {
228
+ return null;
229
+ }
230
+ return payload as BuilderPreviewRelayState;
231
+ }
232
+
233
+ /**
234
+ * Corporate callback trust check. Preview-side state verification and relay
235
+ * receipt deliberately do not require this callback-only allowlist.
236
+ */
237
+ export function verifyBuilderPreviewRelayStateForCallback(
238
+ state: string | null | undefined,
239
+ options: { now?: number } = {},
240
+ ): BuilderPreviewRelayState | null {
241
+ const payload = verifyBuilderPreviewRelayState(state, options);
242
+ return payload && isTrustedBuilderRelayTargetOrigin(payload.targetOrigin)
243
+ ? payload
244
+ : null;
245
+ }
246
+
247
+ export function getBuilderPreviewRelayUrl(
248
+ payload: BuilderPreviewRelayState,
249
+ ): string {
250
+ return `${payload.targetOrigin}${payload.basePath}${BUILDER_RELAY_PATH}`;
251
+ }
252
+
253
+ function builderRelayBodyDigest(body: string): string {
254
+ return createHash("sha256").update(body).digest("base64url");
255
+ }
256
+
257
+ function builderRelayRequestSignature(
258
+ timestamp: number,
259
+ flowId: string,
260
+ body: string,
261
+ ): string {
262
+ return builderRelayMac(
263
+ `v1.${timestamp}.${flowId}.${builderRelayBodyDigest(body)}`,
264
+ );
265
+ }
266
+
267
+ export function createBuilderRelayRequest(
268
+ relayState: string,
269
+ credentials: BuilderRelayCredentials,
270
+ options: { now?: number } = {},
271
+ ): { body: string; headers: Record<string, string>; url: string } {
272
+ const payload = verifyBuilderPreviewRelayState(relayState, options);
273
+ if (!payload) throw new Error("Builder relay state is invalid or expired.");
274
+ const body = JSON.stringify({
275
+ relayState,
276
+ credentials,
277
+ } satisfies BuilderRelayRequestBody);
278
+ const timestamp = options.now ?? Date.now();
279
+ return {
280
+ body,
281
+ url: getBuilderPreviewRelayUrl(payload),
282
+ headers: {
283
+ "content-type": "application/json",
284
+ [BUILDER_RELAY_TIMESTAMP_HEADER]: String(timestamp),
285
+ [BUILDER_RELAY_FLOW_HEADER]: payload.flowId,
286
+ [BUILDER_RELAY_SIGNATURE_HEADER]: builderRelayRequestSignature(
287
+ timestamp,
288
+ payload.flowId,
289
+ body,
290
+ ),
291
+ },
292
+ };
293
+ }
294
+
295
+ export function verifyBuilderRelayRequest(input: {
296
+ body: string;
297
+ timestamp: string | null | undefined;
298
+ flowId: string | null | undefined;
299
+ signature: string | null | undefined;
300
+ requestOrigin: string;
301
+ requestBasePath: string;
302
+ now?: number;
303
+ }): {
304
+ payload: BuilderPreviewRelayState;
305
+ body: BuilderRelayRequestBody;
306
+ } | null {
307
+ const timestamp = Number(input.timestamp);
308
+ const now = input.now ?? Date.now();
309
+ if (
310
+ !Number.isFinite(timestamp) ||
311
+ Math.abs(now - timestamp) > BUILDER_RELAY_REQUEST_SKEW_MS ||
312
+ !input.flowId ||
313
+ !input.signature ||
314
+ !safeEqualText(
315
+ builderRelayRequestSignature(timestamp, input.flowId, input.body),
316
+ input.signature,
317
+ )
318
+ ) {
319
+ return null;
320
+ }
321
+ let body: BuilderRelayRequestBody;
322
+ try {
323
+ body = JSON.parse(input.body) as BuilderRelayRequestBody;
324
+ } catch {
325
+ return null;
326
+ }
327
+ const payload = verifyBuilderPreviewRelayState(body.relayState, { now });
328
+ if (
329
+ !payload ||
330
+ payload.flowId !== input.flowId ||
331
+ payload.targetOrigin !== input.requestOrigin ||
332
+ payload.basePath !== input.requestBasePath ||
333
+ !body.credentials ||
334
+ typeof body.credentials.privateKey !== "string" ||
335
+ typeof body.credentials.publicKey !== "string" ||
336
+ !body.credentials.privateKey ||
337
+ !body.credentials.publicKey
338
+ ) {
339
+ return null;
340
+ }
341
+ const nullableString = (value: unknown): string | null =>
342
+ typeof value === "string" ? value : null;
343
+ const nullableBoolean = (value: unknown): boolean | null =>
344
+ typeof value === "boolean" ? value : null;
345
+ return {
346
+ payload,
347
+ body: {
348
+ relayState: body.relayState,
349
+ // Explicitly rebuild the credential payload. Extra fields such as an
350
+ // attacker-supplied ownerEmail/orgId never reach the credential writer.
351
+ credentials: {
352
+ privateKey: body.credentials.privateKey,
353
+ publicKey: body.credentials.publicKey,
354
+ userId: nullableString(body.credentials.userId),
355
+ orgName: nullableString(body.credentials.orgName),
356
+ orgKind: nullableString(body.credentials.orgKind),
357
+ subscription: nullableString(body.credentials.subscription),
358
+ subscriptionLevel: nullableString(body.credentials.subscriptionLevel),
359
+ subscriptionName: nullableString(body.credentials.subscriptionName),
360
+ isEnterprise: nullableBoolean(body.credentials.isEnterprise),
361
+ isFreeAccount: nullableBoolean(body.credentials.isFreeAccount),
362
+ },
363
+ },
364
+ };
365
+ }
18
366
 
19
367
  function escapeHtml(value: string): string {
20
368
  return value.replace(/[&<>"']/g, (ch) => {
@@ -516,6 +864,7 @@ export function buildBuilderCliAuthUrl(
516
864
  state: string | null = null,
517
865
  options: {
518
866
  previewOrigin?: string;
867
+ relayState?: string;
519
868
  tracking?: BuilderConnectTrackingParams;
520
869
  } = {},
521
870
  ): string {
@@ -536,6 +885,9 @@ export function buildBuilderCliAuthUrl(
536
885
  if (state) {
537
886
  callbackUrl.searchParams.set(BUILDER_STATE_PARAM, state);
538
887
  }
888
+ if (options.relayState) {
889
+ callbackUrl.searchParams.set(BUILDER_RELAY_STATE_PARAM, options.relayState);
890
+ }
539
891
  // When the cli-auth allow-list forces preview_url onto the gateway origin,
540
892
  // the callback would otherwise lose the real opener origin and post its
541
893
  // success message to the gateway instead of the preview tab. Embed the