@agent-native/core-corpus 0.216.0 → 0.217.0

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 (149) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
  3. package/corpus/templates/analytics/AGENTS.md +1 -1
  4. package/corpus/templates/analytics/CHANGELOG.md +1 -0
  5. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
  6. package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
  7. package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
  8. package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
  9. package/corpus/templates/analytics/actions/view-screen.ts +29 -3
  10. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  11. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  12. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  14. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  15. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  16. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  17. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  18. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  19. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  20. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  21. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  22. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  23. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  24. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  25. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  26. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  27. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  28. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  29. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  30. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  31. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  32. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  33. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  34. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  35. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  36. package/corpus/templates/community/qr/package.json +1 -1
  37. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  38. package/corpus/templates/content/CHANGELOG.md +8 -0
  39. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  40. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  41. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  42. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  43. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  44. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  45. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  46. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  47. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  48. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  49. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  50. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  51. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  52. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  53. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  54. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  55. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  56. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  57. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  58. package/corpus/templates/content/actions/delete-document.ts +41 -3
  59. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  60. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  61. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  62. package/corpus/templates/content/actions/get-document.ts +2 -0
  63. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  64. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  65. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  66. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  67. package/corpus/templates/content/actions/update-document.ts +139 -20
  68. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  69. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  70. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  71. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  72. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  73. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  74. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  75. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  76. package/corpus/templates/content/app/i18n-data.ts +1 -0
  77. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  78. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  79. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  80. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  81. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  82. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  83. package/corpus/templates/content/parity/matrix.md +42 -42
  84. package/corpus/templates/content/parity/matrix.ts +3 -1
  85. package/corpus/templates/content/server/db/schema.ts +20 -0
  86. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  87. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  88. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  89. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  90. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  91. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  92. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  93. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  94. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  95. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  96. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  97. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  98. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  99. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  100. package/corpus/templates/content/server/plugins/db.ts +28 -0
  101. package/corpus/templates/content/shared/api.ts +30 -1
  102. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  103. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  104. package/corpus/templates/content/shared/og-description.ts +258 -36
  105. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  106. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  107. package/corpus/templates/design/CHANGELOG.md +2 -0
  108. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  109. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  110. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  111. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  112. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  113. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  114. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  115. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  116. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  117. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  118. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  119. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  120. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  121. package/corpus/templates/design/app/global.css +6 -0
  122. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  123. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  124. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  125. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  126. package/corpus/templates/design/app/pages/Index.tsx +10 -3
  127. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  128. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  129. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  130. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  131. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  132. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  133. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  134. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  135. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  136. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  137. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  138. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  139. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  140. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  141. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  142. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  143. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  144. package/corpus/templates/slides/CHANGELOG.md +6 -0
  145. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  146. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  147. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  148. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  149. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
+ import { fail, isActionContractError } from "@agent-native/core/action";
1
2
  import {
3
+ BUILDER_CONTENT_READ_SCOPE,
2
4
  BUILDER_PUBLISH_MCP_RESOURCE,
3
5
  resolveBuilderCredential,
4
6
  resolveBuilderRequestAuthorization,
@@ -35,10 +37,14 @@ export interface BuilderCmsReadProgress {
35
37
  pageSize: number;
36
38
  startOffset: number;
37
39
  nextOffset: number;
40
+ nextCursor?: string;
38
41
  fetchedEntryCount: number;
39
42
  hasMore: boolean;
40
43
  partial: boolean;
41
44
  readMode: "builder-api" | "mcp" | "none";
45
+ sourceSpacePublicKey?: string;
46
+ sourceConnectionId?: string;
47
+ observedCollectionCount?: number;
42
48
  }
43
49
 
44
50
  export interface BuilderCmsEntryLiveState {
@@ -167,6 +173,7 @@ export type BuilderCmsContentEntryReadResult =
167
173
  entry: null;
168
174
  providerStatus:
169
175
  | "http_404"
176
+ | "http_200_not_found"
170
177
  | "http_200_unexpected_entry"
171
178
  | "mcp_not_found";
172
179
  };
@@ -177,6 +184,7 @@ export class BuilderCmsContentEntryReadError extends Error {
177
184
  readonly reason:
178
185
  | "auth_failed"
179
186
  | "access_denied"
187
+ | "not_found"
180
188
  | "transient_read_failure"
181
189
  | "malformed_body",
182
190
  readonly providerStatus: string,
@@ -196,6 +204,15 @@ type BuilderMcpToolResult = {
196
204
  content?: BuilderMcpContentPart[];
197
205
  };
198
206
 
207
+ export const RETRYABLE_BUILDER_SOURCE_BINDING_ERRORS = new Set([
208
+ "builder_connection_required",
209
+ "builder_oauth_reauthorization_required",
210
+ "builder_source_binding_invalid",
211
+ "builder_source_binding_required",
212
+ "builder_source_connection_mismatch",
213
+ "builder_source_space_mismatch",
214
+ ]);
215
+
199
216
  const BUILDER_CMS_DEFAULT_READ_LIMIT = 500;
200
217
  const BUILDER_CMS_MAX_READ_LIMIT = 10_000;
201
218
  const BUILDER_CMS_PAGE_SIZE = 100;
@@ -322,6 +339,12 @@ function builderContentApiHost() {
322
339
  ).replace(/\/+$/, "");
323
340
  }
324
341
 
342
+ // The general read routes (/api/v1/models, /api/v1/query-data) carry the
343
+ // user's OAuth bearer, and Builder serves them on this host (api.builder.io
344
+ // answers them with 404). It is a constant on purpose: no host override may
345
+ // redirect the bearer.
346
+ const BUILDER_OAUTH_READ_HOST = "https://cdn.builder.io";
347
+
325
348
  function entryArrayFromResponse(value: unknown) {
326
349
  if (Array.isArray(value)) return value;
327
350
  if (!value || typeof value !== "object") return [];
@@ -329,6 +352,33 @@ function entryArrayFromResponse(value: unknown) {
329
352
  return Array.isArray(record.results) ? record.results : [];
330
353
  }
331
354
 
355
+ function parseBuilderGeneralQueryDataResponse(value: unknown) {
356
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
357
+ fail("Builder query-data returned a malformed response.", {
358
+ errorCode: "builder_response_invalid",
359
+ statusCode: 424,
360
+ });
361
+ }
362
+ const record = value as Record<string, unknown>;
363
+ if (!Array.isArray(record.results)) {
364
+ fail("Builder query-data did not return a results array.", {
365
+ errorCode: "builder_response_invalid",
366
+ statusCode: 424,
367
+ });
368
+ }
369
+ if (
370
+ typeof record.totalCount !== "number" ||
371
+ !Number.isInteger(record.totalCount) ||
372
+ record.totalCount < 0
373
+ ) {
374
+ fail("Builder query-data read did not return a valid totalCount.", {
375
+ errorCode: "builder_response_invalid",
376
+ statusCode: 424,
377
+ });
378
+ }
379
+ return { results: record.results, totalCount: record.totalCount };
380
+ }
381
+
332
382
  function stringFromUnknown(value: unknown) {
333
383
  return typeof value === "string" && value.trim() ? value.trim() : null;
334
384
  }
@@ -436,13 +486,22 @@ async function fetchBuilderContentPage(args: {
436
486
  fetchImpl: FetchLike;
437
487
  url: URL;
438
488
  privateKey?: string;
489
+ signal?: AbortSignal;
439
490
  }): Promise<Response> {
440
491
  let lastError: unknown = null;
441
492
  for (let attempt = 0; attempt <= BUILDER_CMS_READ_RETRIES; attempt += 1) {
493
+ if (args.signal?.aborted) {
494
+ throw args.signal.reason instanceof Error
495
+ ? args.signal.reason
496
+ : new Error("Builder request was aborted.");
497
+ }
442
498
  try {
443
499
  const headers: Record<string, string> = { accept: "application/json" };
444
500
  if (args.privateKey) headers.authorization = `Bearer ${args.privateKey}`;
445
- const response = await args.fetchImpl(args.url, { headers });
501
+ const response = await args.fetchImpl(args.url, {
502
+ headers,
503
+ ...(args.signal ? { signal: args.signal } : {}),
504
+ });
446
505
  if (
447
506
  !retryableBuilderReadStatus(response.status) ||
448
507
  attempt === BUILDER_CMS_READ_RETRIES
@@ -451,7 +510,7 @@ async function fetchBuilderContentPage(args: {
451
510
  }
452
511
  } catch (error) {
453
512
  lastError = error;
454
- if (attempt === BUILDER_CMS_READ_RETRIES) {
513
+ if (args.signal?.aborted || attempt === BUILDER_CMS_READ_RETRIES) {
455
514
  throw error;
456
515
  }
457
516
  }
@@ -486,11 +545,221 @@ function builderMcpEndpoint(
486
545
  ).replace(/\/+$/, "");
487
546
  }
488
547
 
489
- async function readBuilderCmsAuthorization() {
490
- return resolveBuilderRequestAuthorization({
491
- oauthResource: "publish",
492
- legacyCredentialKeys: ["BUILDER_PRIVATE_KEY", "BUILDER_CMS_PRIVATE_KEY"],
548
+ function isBuilderOAuthReauthorizationRequired(error: unknown) {
549
+ return (
550
+ isActionContractError(error) &&
551
+ error.errorCode === "builder_oauth_reauthorization_required"
552
+ );
553
+ }
554
+
555
+ function isBuilderReadSourceBound(
556
+ expectedSourceSpace?: string | null,
557
+ expectedSourceConnectionId?: string | null,
558
+ ) {
559
+ const hasSourceSpace = Boolean(expectedSourceSpace?.trim());
560
+ const hasSourceConnectionId = Boolean(expectedSourceConnectionId?.trim());
561
+ if (hasSourceSpace !== hasSourceConnectionId) {
562
+ fail(
563
+ "Builder source binding must include both its space and connection. Reconnect the source before reading.",
564
+ { errorCode: "builder_source_binding_invalid", statusCode: 412 },
565
+ );
566
+ }
567
+ return hasSourceSpace;
568
+ }
569
+
570
+ async function readGeneralBuilderContentAuthorization(sourceBound: boolean) {
571
+ try {
572
+ return {
573
+ authorization: await resolveBuilderRequestAuthorization({
574
+ oauthResource: "general",
575
+ requiredScope: BUILDER_CONTENT_READ_SCOPE,
576
+ legacyCredentialKeys: [],
577
+ }),
578
+ reauthorization: null,
579
+ };
580
+ } catch (error) {
581
+ // The shared resolver throws instead of returning null when a grant
582
+ // predates builder:content:read. Only an unbound source may keep its
583
+ // previous read path until the user re-authorizes.
584
+ if (sourceBound || !isBuilderOAuthReauthorizationRequired(error)) {
585
+ throw error;
586
+ }
587
+ return { authorization: null, reauthorization: error };
588
+ }
589
+ }
590
+
591
+ async function readBuilderCmsAuthorization(sourceBound = false) {
592
+ const general = await readGeneralBuilderContentAuthorization(sourceBound);
593
+ if (general.authorization) return general.authorization;
594
+ try {
595
+ const publish = await resolveBuilderRequestAuthorization({
596
+ oauthResource: "publish",
597
+ legacyCredentialKeys: ["BUILDER_PRIVATE_KEY", "BUILDER_CMS_PRIVATE_KEY"],
598
+ });
599
+ if (publish || !general.reauthorization) return publish;
600
+ } catch (error) {
601
+ if (!general.reauthorization) throw error;
602
+ }
603
+ throw general.reauthorization;
604
+ }
605
+
606
+ function isGeneralBuilderOAuth(
607
+ authorization: BuilderRequestAuthorization | null,
608
+ ): authorization is BuilderRequestAuthorization & {
609
+ source: "oauth";
610
+ oauthResource: "general";
611
+ } {
612
+ return (
613
+ authorization?.source === "oauth" &&
614
+ authorization.oauthResource === "general"
615
+ );
616
+ }
617
+
618
+ function assertBuilderReadSourceAuthorization(
619
+ authorization: BuilderRequestAuthorization | null,
620
+ expectedSourceSpace?: string | null,
621
+ expectedSourceConnectionId?: string | null,
622
+ ) {
623
+ if (
624
+ isBuilderReadSourceBound(expectedSourceSpace, expectedSourceConnectionId) &&
625
+ !isGeneralBuilderOAuth(authorization)
626
+ ) {
627
+ fail(
628
+ "This Builder source's OAuth connection is unavailable. Reconnect the source before reading.",
629
+ { errorCode: "builder_connection_required", statusCode: 412 },
630
+ );
631
+ }
632
+ }
633
+
634
+ function requireBuilderOAuthPublicKey(
635
+ authorization: BuilderRequestAuthorization,
636
+ expectedSourceSpace?: string | null,
637
+ expectedSourceConnectionId?: string | null,
638
+ ) {
639
+ const selected = authorization.oauthSelectedPublicKey;
640
+ if (!selected) {
641
+ fail(
642
+ "Builder OAuth access did not identify its selected space. Reconnect Builder.io in Settings.",
643
+ { errorCode: "builder_connection_required", statusCode: 412 },
644
+ );
645
+ }
646
+ if (expectedSourceSpace && selected !== expectedSourceSpace) {
647
+ fail(
648
+ "The connected Builder space does not match this Content source. Reconnect the source's Builder space before continuing.",
649
+ { errorCode: "builder_source_space_mismatch", statusCode: 409 },
650
+ );
651
+ }
652
+ if (
653
+ expectedSourceConnectionId &&
654
+ authorization.oauthConnectionId !== expectedSourceConnectionId
655
+ ) {
656
+ fail(
657
+ "The connected Builder credential does not match this Content source. Reconnect the source before continuing.",
658
+ { errorCode: "builder_source_connection_mismatch", statusCode: 409 },
659
+ );
660
+ }
661
+ return selected;
662
+ }
663
+
664
+ async function readBuilderCmsGeneralModels(args: {
665
+ authorization: BuilderRequestAuthorization;
666
+ expectedSourceSpace?: string | null;
667
+ expectedSourceConnectionId?: string | null;
668
+ fetchImpl: FetchLike;
669
+ signal?: AbortSignal;
670
+ }) {
671
+ const publicKey = requireBuilderOAuthPublicKey(
672
+ args.authorization,
673
+ args.expectedSourceSpace,
674
+ args.expectedSourceConnectionId,
675
+ );
676
+ const url = new URL("/api/v1/models", BUILDER_OAUTH_READ_HOST);
677
+ url.searchParams.set("apiKey", publicKey);
678
+ const response = await fetchBuilderContentPage({
679
+ fetchImpl: args.fetchImpl,
680
+ url,
681
+ privateKey: args.authorization.token,
682
+ signal: args.signal,
683
+ });
684
+ if (response.status === 401) {
685
+ fail(
686
+ "Builder rejected this Builder.io connection. Reconnect Builder.io in Settings.",
687
+ { errorCode: "builder_connection_required", statusCode: 412 },
688
+ );
689
+ }
690
+ if (!response.ok) {
691
+ fail(`Builder model discovery failed with HTTP ${response.status}.`, {
692
+ errorCode: "builder_upstream_failed",
693
+ statusCode: 424,
694
+ details: { httpStatus: response.status },
695
+ });
696
+ }
697
+ return builderMcpModelsFromToolResponse((await response.json()) as unknown);
698
+ }
699
+
700
+ // Body hydration reads entries one at a time and each read needs the model id.
701
+ // Rediscovering it per entry costs a request per row, so a large source floods
702
+ // Builder.
703
+ const BUILDER_MODEL_ID_TTL_MS = 60_000;
704
+ const builderModelIdCache = new WeakMap<
705
+ FetchLike,
706
+ Map<string, { expiresAt: number; modelId: string }>
707
+ >();
708
+
709
+ async function resolveBuilderCmsGeneralModelId(args: {
710
+ model: string;
711
+ authorization: BuilderRequestAuthorization;
712
+ expectedSourceSpace?: string | null;
713
+ expectedSourceConnectionId?: string | null;
714
+ fetchImpl: FetchLike;
715
+ signal?: AbortSignal;
716
+ }) {
717
+ const publicKey = requireBuilderOAuthPublicKey(
718
+ args.authorization,
719
+ args.expectedSourceSpace,
720
+ args.expectedSourceConnectionId,
721
+ );
722
+ const key = [
723
+ publicKey,
724
+ args.authorization.oauthConnectionId ?? "",
725
+ args.model.trim().toLowerCase(),
726
+ ].join("\u0000");
727
+ const entries = builderModelIdCache.get(args.fetchImpl) ?? new Map();
728
+ builderModelIdCache.set(args.fetchImpl, entries);
729
+ const cached = entries.get(key);
730
+ if (cached && cached.expiresAt > Date.now()) return cached.modelId;
731
+ const modelId = await discoverBuilderCmsGeneralModelId(args);
732
+ entries.set(key, {
733
+ expiresAt: Date.now() + BUILDER_MODEL_ID_TTL_MS,
734
+ modelId,
493
735
  });
736
+ return modelId;
737
+ }
738
+
739
+ async function discoverBuilderCmsGeneralModelId(args: {
740
+ model: string;
741
+ authorization: BuilderRequestAuthorization;
742
+ expectedSourceSpace?: string | null;
743
+ expectedSourceConnectionId?: string | null;
744
+ fetchImpl: FetchLike;
745
+ signal?: AbortSignal;
746
+ }) {
747
+ const requestedModel = args.model.trim().toLowerCase();
748
+ const models = await readBuilderCmsGeneralModels(args);
749
+ const idMatch = models.find(
750
+ (candidate) => candidate.id.trim().toLowerCase() === requestedModel,
751
+ );
752
+ const nameMatches = models.filter(
753
+ (candidate) => candidate.name.trim().toLowerCase() === requestedModel,
754
+ );
755
+ const model = nameMatches.length === 1 ? nameMatches[0] : idMatch;
756
+ if (!model) {
757
+ fail(`Builder model ${args.model} was not found.`, {
758
+ errorCode: "builder_model_not_found",
759
+ statusCode: 404,
760
+ });
761
+ }
762
+ return model.id;
494
763
  }
495
764
 
496
765
  function parseBuilderMcpToolJson(value: unknown) {
@@ -760,17 +1029,40 @@ function stringOptionsFromUnknown(value: unknown): string[] | undefined {
760
1029
  function builderMcpModelsFromToolResponse(
761
1030
  response: unknown,
762
1031
  ): BuilderCmsModelSummary[] {
763
- if (!response || typeof response !== "object") return [];
1032
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
1033
+ fail("Builder model discovery returned a malformed response.", {
1034
+ errorCode: "builder_response_invalid",
1035
+ statusCode: 424,
1036
+ });
1037
+ }
764
1038
  const record = response as Record<string, unknown>;
765
- const models = Array.isArray(record.models) ? record.models : [];
766
- return models
767
- .map((model) => normalizeBuilderCmsModel(model))
768
- .filter((model): model is BuilderCmsModelSummary => Boolean(model))
769
- .sort((a, b) => {
770
- if (a.name === "agent-native-blog-article-test") return -1;
771
- if (b.name === "agent-native-blog-article-test") return 1;
772
- return a.displayName.localeCompare(b.displayName);
1039
+ if (!Array.isArray(record.models)) {
1040
+ fail("Builder model discovery did not return a models array.", {
1041
+ errorCode: "builder_response_invalid",
1042
+ statusCode: 424,
773
1043
  });
1044
+ }
1045
+ // /api/v1/models also lists archived models, which Builder's own model list
1046
+ // hides; without this the source picker offers them.
1047
+ const models = record.models
1048
+ .filter(
1049
+ (model) =>
1050
+ !model ||
1051
+ typeof model !== "object" ||
1052
+ (model as Record<string, unknown>).archived !== true,
1053
+ )
1054
+ .map((model) => normalizeBuilderCmsModel(model));
1055
+ if (models.some((model) => !model)) {
1056
+ fail("Builder model discovery returned a malformed model.", {
1057
+ errorCode: "builder_response_invalid",
1058
+ statusCode: 424,
1059
+ });
1060
+ }
1061
+ return (models as BuilderCmsModelSummary[]).sort((a, b) => {
1062
+ if (a.name === "agent-native-blog-article-test") return -1;
1063
+ if (b.name === "agent-native-blog-article-test") return 1;
1064
+ return a.displayName.localeCompare(b.displayName);
1065
+ });
774
1066
  }
775
1067
 
776
1068
  async function initializeBuilderMcp(args: {
@@ -882,6 +1174,8 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
882
1174
  limit?: number;
883
1175
  maxPages?: number;
884
1176
  offset?: number;
1177
+ startingFetchedEntryCount?: number;
1178
+ expectedCollectionCount?: number;
885
1179
  fetchImpl: FetchLike;
886
1180
  privateKey: string;
887
1181
  endpoint: string;
@@ -894,6 +1188,8 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
894
1188
  typeof args.offset === "number" && Number.isFinite(args.offset)
895
1189
  ? Math.max(0, Math.floor(args.offset))
896
1190
  : 0;
1191
+ const startingFetchedEntryCount =
1192
+ args.startingFetchedEntryCount ?? startOffset;
897
1193
  const connection = await initializeBuilderMcp({
898
1194
  endpoint,
899
1195
  privateKey: args.privateKey,
@@ -909,6 +1205,7 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
909
1205
  let pagesRead = 0;
910
1206
  let hasMore = false;
911
1207
  let offset = startOffset;
1208
+ let observedCollectionCount: number | null = null;
912
1209
  while (contentEntries.length < requestedLimit) {
913
1210
  const pageLimit = Math.min(
914
1211
  BUILDER_CMS_PAGE_SIZE,
@@ -955,8 +1252,35 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
955
1252
  pageEntries,
956
1253
  );
957
1254
  pagesRead += 1;
958
- offset += pageLimit;
1255
+ offset += providerEntryCount;
959
1256
  const totalCount = builderMcpTotalCount(contentJson);
1257
+ if (observedCollectionCount === null && totalCount !== null) {
1258
+ observedCollectionCount = totalCount;
1259
+ if (
1260
+ args.expectedCollectionCount !== undefined &&
1261
+ totalCount !== args.expectedCollectionCount
1262
+ ) {
1263
+ fail(
1264
+ "Builder MCP collection changed between refresh pages; restart the source refresh before mutating the snapshot.",
1265
+ { errorCode: "builder_source_changed", statusCode: 409 },
1266
+ );
1267
+ }
1268
+ } else if (
1269
+ totalCount !== null &&
1270
+ observedCollectionCount !== null &&
1271
+ totalCount !== observedCollectionCount
1272
+ ) {
1273
+ fail(
1274
+ "Builder MCP collection changed during pagination; refresh the source to restart the observed collection read.",
1275
+ { errorCode: "builder_source_changed", statusCode: 409 },
1276
+ );
1277
+ }
1278
+ if (startOffset > 0 && observedCollectionCount === null) {
1279
+ fail(
1280
+ "Builder MCP continuation has no saved collection count. Start a full refresh before continuing.",
1281
+ { errorCode: "builder_source_continuation_mismatch", statusCode: 409 },
1282
+ );
1283
+ }
960
1284
  hasMore =
961
1285
  totalCount !== null
962
1286
  ? offset < totalCount
@@ -979,10 +1303,11 @@ async function readBuilderCmsContentEntriesViaMcp(args: {
979
1303
  pageSize: BUILDER_CMS_PAGE_SIZE,
980
1304
  startOffset,
981
1305
  nextOffset: offset,
982
- fetchedEntryCount: startOffset + contentEntries.length,
1306
+ fetchedEntryCount: startingFetchedEntryCount + contentEntries.length,
983
1307
  hasMore,
984
1308
  partial: hasMore,
985
1309
  readMode: "mcp",
1310
+ observedCollectionCount: observedCollectionCount ?? undefined,
986
1311
  },
987
1312
  };
988
1313
  }
@@ -1150,11 +1475,334 @@ async function readBuilderCmsContentEntriesViaContentApi(args: {
1150
1475
  };
1151
1476
  }
1152
1477
 
1478
+ async function readBuilderCmsContentEntriesViaGeneralApi(args: {
1479
+ model: string;
1480
+ limit?: number;
1481
+ maxPages?: number;
1482
+ offset?: number;
1483
+ afterId?: string;
1484
+ fetchImpl: FetchLike;
1485
+ authorization: BuilderRequestAuthorization;
1486
+ expectedSourceSpace?: string | null;
1487
+ expectedSourceConnectionId?: string | null;
1488
+ expectedCollectionCount?: number;
1489
+ }): Promise<BuilderCmsReadResult> {
1490
+ const fetchedAt = new Date().toISOString();
1491
+ const requestedLimit = readLimit(args.limit);
1492
+ const startOffset =
1493
+ typeof args.offset === "number" && Number.isFinite(args.offset)
1494
+ ? Math.max(0, Math.floor(args.offset))
1495
+ : 0;
1496
+ const startAfterId = args.afterId?.trim() || null;
1497
+ if (startOffset > 0 && !startAfterId) {
1498
+ fail(
1499
+ "Builder query-data continuation needs a stable entry cursor. Restart the source refresh to establish one.",
1500
+ { errorCode: "builder_source_continuation_mismatch", statusCode: 409 },
1501
+ );
1502
+ }
1503
+ const publicKey = requireBuilderOAuthPublicKey(
1504
+ args.authorization,
1505
+ args.expectedSourceSpace,
1506
+ args.expectedSourceConnectionId,
1507
+ );
1508
+ const modelId = await resolveBuilderCmsGeneralModelId(args);
1509
+ const entries: BuilderCmsSourceEntry[] = [];
1510
+ const seenIds = new Set<string>();
1511
+ let offset = startOffset;
1512
+ let cursor = startAfterId;
1513
+ let pagesRead = 0;
1514
+ let observedCollectionCount: number | null = null;
1515
+ let hasMore = false;
1516
+
1517
+ while (entries.length < requestedLimit) {
1518
+ if (args.maxPages && pagesRead >= args.maxPages) break;
1519
+ const pageLimit = readPageLimit(requestedLimit - entries.length);
1520
+ // Each page re-reads the previous page's last entry so a count-preserving
1521
+ // edit before the offset (delete + append) cannot silently skip a row.
1522
+ const anchored = offset > 0 && Boolean(cursor);
1523
+ const url = new URL("/api/v1/query-data", BUILDER_OAUTH_READ_HOST);
1524
+ url.searchParams.set("apiKey", publicKey);
1525
+ url.searchParams.set("query.modelId", modelId);
1526
+ url.searchParams.set("sort.id", "1");
1527
+ url.searchParams.set("offset", String(anchored ? offset - 1 : offset));
1528
+ url.searchParams.set("limit", String(anchored ? pageLimit + 1 : pageLimit));
1529
+ url.searchParams.set("fetchTotalCount", "true");
1530
+ url.searchParams.set("noCache", "true");
1531
+ url.searchParams.set("cachebust", String(Date.now() + pagesRead));
1532
+ const response = await fetchBuilderContentPage({
1533
+ fetchImpl: args.fetchImpl,
1534
+ url,
1535
+ privateKey: args.authorization.token,
1536
+ });
1537
+ if (!response.ok) {
1538
+ return {
1539
+ state: "error",
1540
+ entries: [],
1541
+ fetchedAt,
1542
+ message: `Builder CMS read failed with HTTP ${response.status}.`,
1543
+ progress: {
1544
+ requestedLimit,
1545
+ pageSize: BUILDER_CMS_PAGE_SIZE,
1546
+ startOffset,
1547
+ nextOffset: offset,
1548
+ nextCursor: cursor ?? undefined,
1549
+ fetchedEntryCount: startOffset + entries.length,
1550
+ hasMore: false,
1551
+ partial: entries.length > 0,
1552
+ readMode: "builder-api",
1553
+ },
1554
+ };
1555
+ }
1556
+ const json = (await response.json()) as unknown;
1557
+ const { results: rawPageEntries, totalCount } =
1558
+ parseBuilderGeneralQueryDataResponse(json);
1559
+ const pageCollectionCount = totalCount;
1560
+ if (observedCollectionCount === null) {
1561
+ observedCollectionCount = pageCollectionCount;
1562
+ if (startOffset > 0 && args.expectedCollectionCount === undefined) {
1563
+ fail(
1564
+ "Builder source continuation has no saved collection count. Start a full refresh before continuing.",
1565
+ {
1566
+ errorCode: "builder_source_continuation_mismatch",
1567
+ statusCode: 409,
1568
+ },
1569
+ );
1570
+ }
1571
+ if (
1572
+ args.expectedCollectionCount !== undefined &&
1573
+ pageCollectionCount !== args.expectedCollectionCount
1574
+ ) {
1575
+ fail(
1576
+ "Builder query-data collection changed between refresh pages; restart the source refresh before mutating the snapshot.",
1577
+ { errorCode: "builder_source_changed", statusCode: 409 },
1578
+ );
1579
+ }
1580
+ }
1581
+ if (
1582
+ observedCollectionCount !== null &&
1583
+ pageCollectionCount !== observedCollectionCount
1584
+ ) {
1585
+ fail(
1586
+ "Builder query-data collection changed during cursor pagination; refresh the source to restart the observed collection read.",
1587
+ { errorCode: "builder_source_changed", statusCode: 409 },
1588
+ );
1589
+ }
1590
+ const normalizedEntries = rawPageEntries
1591
+ .map((entry) => normalizeBuilderCmsApiEntry(entry, args.model))
1592
+ .filter((entry): entry is BuilderCmsSourceEntry => Boolean(entry));
1593
+ if (normalizedEntries.length !== rawPageEntries.length) {
1594
+ fail(
1595
+ "Builder query-data returned a malformed entry before the observed collection was complete.",
1596
+ { errorCode: "builder_response_invalid", statusCode: 424 },
1597
+ );
1598
+ }
1599
+ if (anchored && normalizedEntries[0]?.id !== cursor) {
1600
+ fail(
1601
+ "Builder query-data collection changed before the saved cursor; restart the source refresh.",
1602
+ { errorCode: "builder_source_changed", statusCode: 409 },
1603
+ );
1604
+ }
1605
+ const pageEntries = anchored
1606
+ ? normalizedEntries.slice(1)
1607
+ : normalizedEntries;
1608
+ const priorCursor = cursor;
1609
+ if (priorCursor && pageEntries.some((entry) => entry.id <= priorCursor)) {
1610
+ fail(
1611
+ "Builder query-data cursor pagination repeated or reordered an entry.",
1612
+ { errorCode: "builder_response_invalid", statusCode: 424 },
1613
+ );
1614
+ }
1615
+ const appended = appendUniqueBuilderEntries(entries, seenIds, pageEntries);
1616
+ if (appended !== pageEntries.length) {
1617
+ fail(
1618
+ "Builder query-data returned duplicate entries before the observed collection was complete.",
1619
+ { errorCode: "builder_response_invalid", statusCode: 424 },
1620
+ );
1621
+ }
1622
+ if (offset + pageEntries.length > totalCount) {
1623
+ fail(
1624
+ "Builder query-data returned more entries than its reported totalCount.",
1625
+ { errorCode: "builder_response_invalid", statusCode: 424 },
1626
+ );
1627
+ }
1628
+ pagesRead += 1;
1629
+ offset += pageEntries.length;
1630
+ cursor = pageEntries[pageEntries.length - 1]?.id ?? cursor;
1631
+ hasMore = offset < totalCount;
1632
+ if (hasMore && (pageEntries.length === 0 || appended === 0)) {
1633
+ fail(
1634
+ "Builder query-data pagination returned no new entries before the observed collection was complete.",
1635
+ { errorCode: "builder_response_invalid", statusCode: 424 },
1636
+ );
1637
+ }
1638
+ if (!hasMore) break;
1639
+ }
1640
+
1641
+ return {
1642
+ state: "live",
1643
+ entries,
1644
+ fetchedAt,
1645
+ message: null,
1646
+ progress: {
1647
+ requestedLimit,
1648
+ pageSize: BUILDER_CMS_PAGE_SIZE,
1649
+ startOffset,
1650
+ nextOffset: offset,
1651
+ nextCursor: cursor ?? undefined,
1652
+ fetchedEntryCount: offset,
1653
+ hasMore,
1654
+ partial: hasMore,
1655
+ readMode: "builder-api",
1656
+ sourceSpacePublicKey: publicKey,
1657
+ sourceConnectionId: args.authorization.oauthConnectionId,
1658
+ observedCollectionCount: observedCollectionCount ?? undefined,
1659
+ },
1660
+ };
1661
+ }
1662
+
1663
+ async function readBuilderCmsGeneralApiEntry(args: {
1664
+ model: string;
1665
+ entryId: string;
1666
+ authorization: BuilderRequestAuthorization;
1667
+ expectedSourceSpace?: string | null;
1668
+ expectedSourceConnectionId?: string | null;
1669
+ fetchImpl: FetchLike;
1670
+ signal?: AbortSignal;
1671
+ }) {
1672
+ const publicKey = requireBuilderOAuthPublicKey(
1673
+ args.authorization,
1674
+ args.expectedSourceSpace,
1675
+ args.expectedSourceConnectionId,
1676
+ );
1677
+ const modelId = await resolveBuilderCmsGeneralModelId(args);
1678
+ const url = new URL("/api/v1/query-data", BUILDER_OAUTH_READ_HOST);
1679
+ url.searchParams.set("apiKey", publicKey);
1680
+ url.searchParams.set("query.modelId", modelId);
1681
+ url.searchParams.set("query.id", args.entryId);
1682
+ url.searchParams.set("sort.id", "1");
1683
+ url.searchParams.set("offset", "0");
1684
+ url.searchParams.set("limit", "1");
1685
+ url.searchParams.set("fetchTotalCount", "true");
1686
+ // This read decides whether a write may overwrite the entry, and the host is
1687
+ // a CDN that may serve a cached copy (including stale-if-error), so each
1688
+ // request gets its own cache key.
1689
+ url.searchParams.set("noCache", "true");
1690
+ url.searchParams.set("cachebust", String(Date.now()));
1691
+ return fetchBuilderContentPage({
1692
+ fetchImpl: args.fetchImpl,
1693
+ url,
1694
+ privateKey: args.authorization.token,
1695
+ signal: args.signal,
1696
+ });
1697
+ }
1698
+
1699
+ function builderGeneralEntryHttpFailure(message: string, status: number) {
1700
+ const reason =
1701
+ status === 401
1702
+ ? "auth_failed"
1703
+ : status === 403
1704
+ ? "access_denied"
1705
+ : status === 429 || status >= 500
1706
+ ? "transient_read_failure"
1707
+ : "malformed_body";
1708
+ return new BuilderCmsContentEntryReadError(
1709
+ message,
1710
+ reason,
1711
+ `http_${status}`,
1712
+ reason === "transient_read_failure",
1713
+ );
1714
+ }
1715
+
1716
+ function builderGeneralEntryRequestFailure(error: unknown) {
1717
+ if (!isActionContractError(error)) {
1718
+ const message = error instanceof Error ? error.message : String(error);
1719
+ return new BuilderCmsContentEntryReadError(
1720
+ `Builder CMS entry read failed before a response was received: ${message}`,
1721
+ "transient_read_failure",
1722
+ "network_error",
1723
+ true,
1724
+ );
1725
+ }
1726
+ const httpStatus = error.details?.httpStatus;
1727
+ if (typeof httpStatus === "number") {
1728
+ return builderGeneralEntryHttpFailure(error.message, httpStatus);
1729
+ }
1730
+ const reason =
1731
+ error.statusCode === 404
1732
+ ? "not_found"
1733
+ : error.statusCode === 424
1734
+ ? "malformed_body"
1735
+ : "access_denied";
1736
+ return new BuilderCmsContentEntryReadError(
1737
+ error.message,
1738
+ reason,
1739
+ error.errorCode,
1740
+ RETRYABLE_BUILDER_SOURCE_BINDING_ERRORS.has(error.errorCode),
1741
+ );
1742
+ }
1743
+
1153
1744
  export async function readBuilderCmsEntryLiveState(args: {
1154
1745
  model: string;
1155
1746
  entryId: string;
1747
+ expectedSourceSpace?: string | null;
1748
+ expectedSourceConnectionId?: string | null;
1749
+ requireBoundOAuth?: boolean;
1156
1750
  fetchImpl?: FetchLike;
1751
+ signal?: AbortSignal;
1157
1752
  }): Promise<BuilderCmsEntryLiveState> {
1753
+ const { authorization: generalAuthorization } =
1754
+ await readGeneralBuilderContentAuthorization(
1755
+ isBuilderReadSourceBound(
1756
+ args.expectedSourceSpace,
1757
+ args.expectedSourceConnectionId,
1758
+ ),
1759
+ );
1760
+ assertBuilderReadSourceAuthorization(
1761
+ generalAuthorization,
1762
+ args.expectedSourceSpace,
1763
+ args.expectedSourceConnectionId,
1764
+ );
1765
+ if (
1766
+ args.requireBoundOAuth === true &&
1767
+ isGeneralBuilderOAuth(generalAuthorization) &&
1768
+ !isBuilderReadSourceBound(
1769
+ args.expectedSourceSpace,
1770
+ args.expectedSourceConnectionId,
1771
+ )
1772
+ ) {
1773
+ fail(
1774
+ "Builder OAuth reads require a saved space and connection binding. Reattach the source with mode replace to bind the current Builder connection before reading.",
1775
+ { errorCode: "builder_source_binding_required", statusCode: 409 },
1776
+ );
1777
+ }
1778
+ if (isGeneralBuilderOAuth(generalAuthorization)) {
1779
+ const response = await readBuilderCmsGeneralApiEntry({
1780
+ model: args.model,
1781
+ entryId: args.entryId,
1782
+ authorization: generalAuthorization,
1783
+ expectedSourceSpace: args.expectedSourceSpace,
1784
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
1785
+ fetchImpl: args.fetchImpl ?? fetch,
1786
+ signal: args.signal,
1787
+ });
1788
+ if (!response.ok) {
1789
+ fail(`Builder CMS live entry read failed with HTTP ${response.status}.`, {
1790
+ errorCode: "builder_upstream_failed",
1791
+ statusCode: 424,
1792
+ });
1793
+ }
1794
+ const json = (await response.json()) as unknown;
1795
+ const { results } = parseBuilderGeneralQueryDataResponse(json);
1796
+ if (results.length === 0) return liveStateFromBuilderEntry([]);
1797
+ const entry = normalizeBuilderCmsApiEntry(results[0], args.model);
1798
+ if (!entry || entry.id !== args.entryId) {
1799
+ fail(
1800
+ "Builder CMS live entry read returned an unexpected entry payload.",
1801
+ { errorCode: "builder_response_invalid", statusCode: 424 },
1802
+ );
1803
+ }
1804
+ return liveStateFromBuilderEntry(entry.rawEntry);
1805
+ }
1158
1806
  const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY");
1159
1807
  if (!publicKey) {
1160
1808
  throw new Error(
@@ -1173,6 +1821,7 @@ export async function readBuilderCmsEntryLiveState(args: {
1173
1821
  const response = await fetchBuilderContentPage({
1174
1822
  fetchImpl: args.fetchImpl ?? fetch,
1175
1823
  url,
1824
+ signal: args.signal,
1176
1825
  });
1177
1826
  if (response.status === 404) {
1178
1827
  return {
@@ -1196,6 +1845,9 @@ export async function readBuilderCmsEntryLiveState(args: {
1196
1845
  export async function readBuilderCmsContentEntry(args: {
1197
1846
  model: string;
1198
1847
  entryId: string;
1848
+ expectedSourceSpace?: string | null;
1849
+ expectedSourceConnectionId?: string | null;
1850
+ requireBoundOAuth?: boolean;
1199
1851
  fetchImpl?: FetchLike;
1200
1852
  }): Promise<BuilderCmsSourceEntry | null> {
1201
1853
  const result = await readBuilderCmsContentEntryResult({
@@ -1208,10 +1860,36 @@ export async function readBuilderCmsContentEntry(args: {
1208
1860
  export async function readBuilderCmsContentEntryResult(args: {
1209
1861
  model: string;
1210
1862
  entryId: string;
1863
+ expectedSourceSpace?: string | null;
1864
+ expectedSourceConnectionId?: string | null;
1865
+ requireBoundOAuth?: boolean;
1211
1866
  fetchImpl?: FetchLike;
1212
1867
  strictEntryIdentity?: boolean;
1213
1868
  }): Promise<BuilderCmsContentEntryReadResult> {
1214
- const authorization = await readBuilderCmsAuthorization();
1869
+ const authorization = await readBuilderCmsAuthorization(
1870
+ isBuilderReadSourceBound(
1871
+ args.expectedSourceSpace,
1872
+ args.expectedSourceConnectionId,
1873
+ ),
1874
+ );
1875
+ assertBuilderReadSourceAuthorization(
1876
+ authorization,
1877
+ args.expectedSourceSpace,
1878
+ args.expectedSourceConnectionId,
1879
+ );
1880
+ if (
1881
+ args.requireBoundOAuth === true &&
1882
+ isGeneralBuilderOAuth(authorization) &&
1883
+ !isBuilderReadSourceBound(
1884
+ args.expectedSourceSpace,
1885
+ args.expectedSourceConnectionId,
1886
+ )
1887
+ ) {
1888
+ fail(
1889
+ "Builder OAuth reads require a saved space and connection binding. Reattach the source with mode replace to bind the current Builder connection before reading.",
1890
+ { errorCode: "builder_source_binding_required", statusCode: 409 },
1891
+ );
1892
+ }
1215
1893
  const privateKey = authorization?.token ?? null;
1216
1894
  const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY");
1217
1895
  if (!publicKey && !privateKey) {
@@ -1223,6 +1901,85 @@ export async function readBuilderCmsContentEntryResult(args: {
1223
1901
  );
1224
1902
  }
1225
1903
 
1904
+ if (isGeneralBuilderOAuth(authorization)) {
1905
+ let response: Response;
1906
+ try {
1907
+ response = await readBuilderCmsGeneralApiEntry({
1908
+ model: args.model,
1909
+ entryId: args.entryId,
1910
+ authorization,
1911
+ expectedSourceSpace: args.expectedSourceSpace,
1912
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
1913
+ fetchImpl: args.fetchImpl ?? fetch,
1914
+ });
1915
+ } catch (error) {
1916
+ throw builderGeneralEntryRequestFailure(error);
1917
+ }
1918
+ if (!response.ok) {
1919
+ throw builderGeneralEntryHttpFailure(
1920
+ `Builder CMS entry read failed with HTTP ${response.status}.`,
1921
+ response.status,
1922
+ );
1923
+ }
1924
+ let json: unknown;
1925
+ try {
1926
+ json = (await response.json()) as unknown;
1927
+ } catch {
1928
+ throw new BuilderCmsContentEntryReadError(
1929
+ "Builder CMS entry read returned malformed JSON.",
1930
+ "malformed_body",
1931
+ "http_200_invalid_json",
1932
+ false,
1933
+ );
1934
+ }
1935
+ let entries: unknown[];
1936
+ try {
1937
+ entries = parseBuilderGeneralQueryDataResponse(json).results;
1938
+ } catch (error) {
1939
+ throw new BuilderCmsContentEntryReadError(
1940
+ error instanceof Error
1941
+ ? error.message
1942
+ : "Builder query-data returned a malformed response.",
1943
+ "malformed_body",
1944
+ "http_200_malformed_body",
1945
+ false,
1946
+ );
1947
+ }
1948
+ if (entries.length === 0) {
1949
+ return {
1950
+ state: "not_found",
1951
+ entry: null,
1952
+ providerStatus: "http_200_not_found",
1953
+ };
1954
+ }
1955
+ const rawEntry = entries[0];
1956
+ const entry = normalizeBuilderCmsApiEntry(rawEntry, args.model);
1957
+ if (!entry) {
1958
+ throw new BuilderCmsContentEntryReadError(
1959
+ "Builder CMS entry read returned a malformed entry payload.",
1960
+ "malformed_body",
1961
+ "http_200_invalid_entry",
1962
+ false,
1963
+ );
1964
+ }
1965
+ if (entry.id !== args.entryId) {
1966
+ if (args.strictEntryIdentity === false) {
1967
+ return {
1968
+ state: "not_found",
1969
+ entry: null,
1970
+ providerStatus: "http_200_unexpected_entry",
1971
+ };
1972
+ }
1973
+ throw new BuilderCmsContentEntryReadError(
1974
+ "Builder CMS entry read returned an unexpected entry payload.",
1975
+ "malformed_body",
1976
+ "http_200_unexpected_entry",
1977
+ false,
1978
+ );
1979
+ }
1980
+ return { state: "found", entry, providerStatus: "http_200" };
1981
+ }
1982
+
1226
1983
  if (privateKey && (authorization?.source === "oauth" || !publicKey)) {
1227
1984
  const endpoint = builderMcpEndpoint(authorization?.source);
1228
1985
  try {
@@ -1396,13 +2153,20 @@ export async function readBuilderCmsContentEntryResult(args: {
1396
2153
  export async function listBuilderCmsModels(
1397
2154
  args: {
1398
2155
  fetchImpl?: FetchLike;
2156
+ expectedSourceSpace?: string | null;
2157
+ expectedSourceConnectionId?: string | null;
1399
2158
  } = {},
1400
2159
  ): Promise<BuilderCmsModelsResponse> {
1401
2160
  const fetchedAt = new Date().toISOString();
1402
2161
  const fetchImpl = args.fetchImpl ?? fetch;
1403
2162
  let authorization: BuilderRequestAuthorization | null;
1404
2163
  try {
1405
- authorization = await readBuilderCmsAuthorization();
2164
+ authorization = await readBuilderCmsAuthorization(
2165
+ isBuilderReadSourceBound(
2166
+ args.expectedSourceSpace,
2167
+ args.expectedSourceConnectionId,
2168
+ ),
2169
+ );
1406
2170
  } catch (error) {
1407
2171
  return {
1408
2172
  state: "error",
@@ -1415,6 +2179,11 @@ export async function listBuilderCmsModels(
1415
2179
  };
1416
2180
  }
1417
2181
  const privateKey = authorization?.token ?? null;
2182
+ assertBuilderReadSourceAuthorization(
2183
+ authorization,
2184
+ args.expectedSourceSpace,
2185
+ args.expectedSourceConnectionId,
2186
+ );
1418
2187
  if (!privateKey) {
1419
2188
  return {
1420
2189
  state: "unconfigured",
@@ -1426,6 +2195,20 @@ export async function listBuilderCmsModels(
1426
2195
  }
1427
2196
 
1428
2197
  try {
2198
+ if (isGeneralBuilderOAuth(authorization)) {
2199
+ const models = await readBuilderCmsGeneralModels({
2200
+ authorization,
2201
+ fetchImpl,
2202
+ expectedSourceSpace: args.expectedSourceSpace,
2203
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
2204
+ });
2205
+ return {
2206
+ state: "live",
2207
+ models,
2208
+ fetchedAt,
2209
+ message: null,
2210
+ };
2211
+ }
1429
2212
  const endpoint = builderMcpEndpoint(authorization?.source);
1430
2213
  const connection = await initializeBuilderMcp({
1431
2214
  endpoint,
@@ -1475,8 +2258,14 @@ export async function listBuilderCmsModels(
1475
2258
  export async function readBuilderCmsModelFields(args: {
1476
2259
  model: string;
1477
2260
  fetchImpl?: FetchLike;
2261
+ expectedSourceSpace?: string | null;
2262
+ expectedSourceConnectionId?: string | null;
1478
2263
  }): Promise<BuilderCmsModelFieldSummary[]> {
1479
- const models = await listBuilderCmsModels({ fetchImpl: args.fetchImpl });
2264
+ const models = await listBuilderCmsModels({
2265
+ fetchImpl: args.fetchImpl,
2266
+ expectedSourceSpace: args.expectedSourceSpace,
2267
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
2268
+ });
1480
2269
  if (models.state === "unconfigured") return [];
1481
2270
  if (models.state === "error") {
1482
2271
  throw new Error(models.message ?? "Builder CMS model discovery failed.");
@@ -1495,6 +2284,8 @@ export async function readBuilderCmsModelFields(args: {
1495
2284
 
1496
2285
  export async function readBuilderCmsContentEntries(args: {
1497
2286
  model: string;
2287
+ expectedSourceSpace?: string | null;
2288
+ expectedSourceConnectionId?: string | null;
1498
2289
  fieldPaths?: readonly string[];
1499
2290
  includeBodies?: boolean;
1500
2291
  allowCached?: boolean;
@@ -1503,13 +2294,22 @@ export async function readBuilderCmsContentEntries(args: {
1503
2294
  limit?: number;
1504
2295
  maxPages?: number;
1505
2296
  offset?: number;
2297
+ afterId?: string;
2298
+ startingFetchedEntryCount?: number;
2299
+ expectedCollectionCount?: number;
2300
+ requireBoundOAuth?: boolean;
1506
2301
  fetchImpl?: FetchLike;
1507
2302
  }): Promise<BuilderCmsReadResult> {
1508
2303
  const fetchedAt = new Date().toISOString();
1509
2304
  const fetchImpl = args.fetchImpl ?? fetch;
1510
2305
  let authorization: BuilderRequestAuthorization | null;
1511
2306
  try {
1512
- authorization = await readBuilderCmsAuthorization();
2307
+ authorization = await readBuilderCmsAuthorization(
2308
+ isBuilderReadSourceBound(
2309
+ args.expectedSourceSpace,
2310
+ args.expectedSourceConnectionId,
2311
+ ),
2312
+ );
1513
2313
  } catch (error) {
1514
2314
  return {
1515
2315
  state: "error",
@@ -1531,6 +2331,11 @@ export async function readBuilderCmsContentEntries(args: {
1531
2331
  },
1532
2332
  };
1533
2333
  }
2334
+ assertBuilderReadSourceAuthorization(
2335
+ authorization,
2336
+ args.expectedSourceSpace,
2337
+ args.expectedSourceConnectionId,
2338
+ );
1534
2339
  const privateKey = authorization?.token ?? null;
1535
2340
  const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY");
1536
2341
  if (args.requirePrivateKey === true && !privateKey) {
@@ -1552,6 +2357,59 @@ export async function readBuilderCmsContentEntries(args: {
1552
2357
  },
1553
2358
  };
1554
2359
  }
2360
+ if (isGeneralBuilderOAuth(authorization)) {
2361
+ try {
2362
+ if (
2363
+ args.requireBoundOAuth === true &&
2364
+ !isBuilderReadSourceBound(
2365
+ args.expectedSourceSpace,
2366
+ args.expectedSourceConnectionId,
2367
+ )
2368
+ ) {
2369
+ fail(
2370
+ "Builder OAuth refresh requires a saved space and connection binding. Reattach this source with mode replace to bind the current Builder connection before refreshing.",
2371
+ { errorCode: "builder_source_binding_required", statusCode: 409 },
2372
+ );
2373
+ }
2374
+ return preserveProjectedBuilderFieldAbsence(
2375
+ await readBuilderCmsContentEntriesViaGeneralApi({
2376
+ model: args.model,
2377
+ limit: args.limit,
2378
+ maxPages: args.maxPages,
2379
+ offset: args.offset,
2380
+ afterId: args.afterId,
2381
+ expectedCollectionCount: args.expectedCollectionCount,
2382
+ fetchImpl,
2383
+ authorization,
2384
+ expectedSourceSpace: args.expectedSourceSpace,
2385
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
2386
+ }),
2387
+ args.fieldPaths,
2388
+ args.rawData,
2389
+ );
2390
+ } catch (error) {
2391
+ return {
2392
+ state: "error",
2393
+ entries: [],
2394
+ fetchedAt,
2395
+ message:
2396
+ error instanceof Error
2397
+ ? error.message
2398
+ : "Builder CMS authenticated read failed.",
2399
+ progress: {
2400
+ requestedLimit: readLimit(args.limit),
2401
+ pageSize: BUILDER_CMS_PAGE_SIZE,
2402
+ startOffset: args.offset ?? 0,
2403
+ nextOffset: args.offset ?? 0,
2404
+ nextCursor: args.afterId,
2405
+ fetchedEntryCount: args.offset ?? 0,
2406
+ hasMore: false,
2407
+ partial: false,
2408
+ readMode: "builder-api",
2409
+ },
2410
+ };
2411
+ }
2412
+ }
1555
2413
  if (publicKey && authorization?.source !== "oauth") {
1556
2414
  const contentApiRead = await readBuilderCmsContentEntriesViaContentApi({
1557
2415
  model: args.model,
@@ -1587,6 +2445,8 @@ export async function readBuilderCmsContentEntries(args: {
1587
2445
  limit: args.limit,
1588
2446
  maxPages: args.maxPages,
1589
2447
  offset: args.offset,
2448
+ startingFetchedEntryCount: args.startingFetchedEntryCount,
2449
+ expectedCollectionCount: args.expectedCollectionCount,
1590
2450
  fetchImpl,
1591
2451
  privateKey,
1592
2452
  endpoint: builderMcpEndpoint(authorization?.source),