@zackbart/connecta 0.26.3 → 0.27.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 (42) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +72 -1
  3. package/bin/connecta.mjs +9 -1
  4. package/dist/access-tokens.js +13 -2
  5. package/dist/artifacts/connector.d.ts +3 -0
  6. package/dist/artifacts/connector.js +6 -0
  7. package/dist/artifacts/document.js +6 -0
  8. package/dist/artifacts/module.js +7 -1
  9. package/dist/artifacts/operations.d.ts +3 -1
  10. package/dist/artifacts/operations.js +68 -10
  11. package/dist/artifacts/refresh.d.ts +2 -0
  12. package/dist/artifacts/refresh.js +3 -2
  13. package/dist/artifacts/routes.js +4 -15
  14. package/dist/artifacts/validate.js +1 -2
  15. package/dist/auth/downstream-oauth.js +95 -36
  16. package/dist/connector-scope.d.ts +2 -0
  17. package/dist/connector-scope.js +27 -0
  18. package/dist/connectors/api.js +8 -2
  19. package/dist/connectors/remote-mcp.js +57 -17
  20. package/dist/credentials.js +2 -1
  21. package/dist/execute.js +15 -0
  22. package/dist/index.js +1 -0
  23. package/dist/oauth-partition.js +32 -0
  24. package/dist/operator-ui/generated.js +1 -1
  25. package/dist/providers/notion.js +4 -1
  26. package/dist/providers/vercel.js +13 -9
  27. package/dist/registry.d.ts +1 -0
  28. package/dist/registry.js +58 -25
  29. package/dist/resumable.js +42 -10
  30. package/dist/routes/credentials.js +16 -8
  31. package/dist/routes/oauth.js +70 -64
  32. package/dist/runtime/run.js +7 -1
  33. package/dist/storage/file.js +57 -20
  34. package/dist/storage/memory.js +8 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/documentation/architecture.md +44 -3
  38. package/documentation/auth.md +7 -4
  39. package/examples/worker/README.md +4 -3
  40. package/examples/worker/src/d1-storage.ts +27 -5
  41. package/package.json +3 -3
  42. package/templates/node/package.json +2 -2
package/AGENTS.md CHANGED
@@ -141,14 +141,14 @@ an unclassified, double-classified, stale, or reasonless entry.
141
141
  Promise-based and name no Effect type: a deployment author should never need
142
142
  a second async paradigm to write a connector, so convert at the boundary,
143
143
  not in the caller. Effect is a hard dependency pinned to one exact version,
144
- never a range, and it is the version Alchemy pins, so a deployment that uses
145
- both resolves one Effect rather than two. `effect/unstable/*` modules are
146
- allowed, which is exactly why the pin is exact: they break in minor releases,
144
+ never a range, and compatible with Alchemy's peer requirement, so a deployment
145
+ that uses both resolves one Effect rather than two. Modules tagged
146
+ `@stability unstable` are allowed behind subpaths: they can break in minor releases,
147
147
  so an upgrade is its own deliberate pull request, never a drive-by in another
148
148
  change. `effect/testing` stays out of `src/` (see import-graph purity). The
149
149
  MCP edges do not move with the core — `@modelcontextprotocol/server` upward,
150
- the SDK client downstream; Effect's `McpServer` is parked until the Effect
151
- core is stable and those edges are re-proven.
150
+ the SDK client downstream. Replacing either requires separate proof of the
151
+ wire contracts and request lifetimes; the stable core release is not that proof.
152
152
  - **Style.** There is no formatter. Match the surrounding code. The docs voice
153
153
  is precise, occasionally wry, and always explains *why* — don't flatten it
154
154
  into boilerplate.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,78 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
- ## Unreleased
5
+ ## 0.27.0 — 2026-10-01
6
+
7
+ This minor release moves the runtime to stable Effect v4 and fixes execution,
8
+ OAuth, storage, and operator recovery. Deployment APIs and the eight MCP tools
9
+ keep their existing contracts, and the Node template now pins 0.27.0.
10
+ Development requires TypeScript 5.9.3 or newer within 5.x.
11
+
12
+ Duplicate API tool names now fail at construction. Memory, file, and D1 storage
13
+ refuse TTLs that cannot produce finite expirations. Artifact quotas reserve 8 KiB
14
+ for frame metadata, reducing the available content allowance by 4 KiB, and apply
15
+ to historical snapshots too. Artifact render checks use the deployment's viewer
16
+ theme.
17
+
18
+ ### Changed
19
+
20
+ - Pin Effect to `4.0.0`, replacing `4.0.0-rc.117`, and align the optional
21
+ Alchemy setup instructions with the stable pin.
22
+ - Require TypeScript 5.9.3 or newer within 5.x for repository and Node-template
23
+ development, matching Effect v4's TypeScript 5.9 minimum.
24
+
25
+ ### Fixed
26
+
27
+ - Refuse already-aborted deadline operations and preserve explicit `null`
28
+ cancellation reasons (#660) (#661).
29
+ - Return structured failures for malformed executor replies (#662).
30
+ - Close callback and credential-test connector scopes after their final use,
31
+ keeping cleanup bounded and best-effort (#663) (#664).
32
+ - Prevent concurrent OAuth state handoffs from replacing another owner on
33
+ storage that supports atomic claims (#665).
34
+ - Show retryable Activity errors for unreadable pages and discard superseded
35
+ pagination responses (#666) (#667).
36
+ - Reject invalid TTLs in memory and D1 storage before mutation, and match NUL
37
+ bytes literally in D1 list prefixes (#668) (#669).
38
+ - Enforce catalog count and byte ceilings even when discovery is invalidated
39
+ while the listing is in flight (#644).
40
+ - Retain OAuth cleanup lineage after uncertain storage commits, preserve
41
+ concurrent token rotation and revocation during issuer migration, and isolate
42
+ refresh coordination by credential owner (#645) (#646) (#648).
43
+ - Return `null` for absent prototype-named credential fields (#647).
44
+ - Reserve enough artifact frame metadata for valid document names and titles,
45
+ enforce current quotas on historical snapshots, and reject refresh scan
46
+ cursors that fail to advance (#649) (#658) (#650).
47
+ - Reconcile connector state after failed operator mutations, ignore older data
48
+ reloads, and keep malformed collection responses recoverable (#651) (#652)
49
+ (#653).
50
+ - Keep committed file snapshots consistent after rename, quarantine malformed
51
+ entries, and reject TTLs that cannot produce finite expirations (#654) (#655)
52
+ (#656).
53
+ - Reject unreadable successful responses and malformed build-log streams from
54
+ named Vercel tools, preserving raw API response bodies and no-content
55
+ mutations (#657).
56
+ - Keep queued catalog mutations alive across Worker requests while preserving
57
+ cancellation and storage ordering (#623).
58
+ - Release failed access-token reservations before lookup publication, close
59
+ failed OAuth callback transports, and persist accepted rotating refresh tokens
60
+ after owner cancellation without reviving disconnected generations (#624)
61
+ (#625) (#626).
62
+ - Count historical writes across divergent resumptions and bound resumed write
63
+ answers by the journal limit, retaining truthful outcomes for writes already
64
+ sent (#627) (#628).
65
+ - Roll back rejected file-storage mutations, preserve prototype-named keys,
66
+ expire entries at their TTL boundary, and quarantine invalid snapshot roots
67
+ without losing their original bytes (#629) (#630) (#631) (#632).
68
+ - Ignore superseded artifact-list responses, preserve newly created tokens when
69
+ an older list finishes, and reload a confined artifact shell when signing in
70
+ again (#633) (#634) (#635).
71
+ - Advance artifact listings through empty continuation pages, refuse already
72
+ cancelled render checks, and validate Markdown with the viewer's theme across
73
+ writes and refreshes (#636) (#637) (#638).
74
+ - Prevent doctor redirects from forwarding Cloudflare Access credentials,
75
+ reject unreadable successful Notion responses, and reject duplicate API tool
76
+ names before classification and dispatch can disagree (#639) (#640) (#641).
6
77
 
7
78
  ## 0.26.3 — 2026-09-28
8
79
 
package/bin/connecta.mjs CHANGED
@@ -132,10 +132,18 @@ const DOCTOR_TIMEOUT_MS = 10_000;
132
132
 
133
133
  async function doctorFetch(url, init = {}) {
134
134
  try {
135
- return await fetch(url, {
135
+ const response = await fetch(url, {
136
136
  ...init,
137
+ redirect: "manual",
137
138
  signal: AbortSignal.timeout(DOCTOR_TIMEOUT_MS),
138
139
  });
140
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
141
+ await response.body?.cancel().catch(() => {});
142
+ throw new Error(
143
+ `HTTP ${response.status} redirect refused: doctor sends authentication only to the configured deployment URL.`,
144
+ );
145
+ }
146
+ return response;
139
147
  } catch (error) {
140
148
  if (
141
149
  error instanceof Error &&
@@ -191,8 +191,19 @@ export class AccessTokenManager {
191
191
  ? {}
192
192
  : { principal: { ...createdBy } }),
193
193
  };
194
- await this.updateActive(record.id, true);
195
- await this.storage.set(recordKey(record.id), JSON.stringify(record));
194
+ try {
195
+ await this.updateActive(record.id, true);
196
+ await this.storage.set(recordKey(record.id), JSON.stringify(record));
197
+ }
198
+ catch (error) {
199
+ // No lookup write has started, so this secret can never authenticate.
200
+ // A failed record or reservation write may still have committed: revoke
201
+ // whatever metadata exists and release the reservation even on a miss.
202
+ const revoked = await this.revoke(record.id, record.createdBy);
203
+ if (!revoked)
204
+ await this.updateActive(record.id, false);
205
+ throw error;
206
+ }
196
207
  // A failed lookup write may have committed. Keep its reservation and
197
208
  // metadata, so a manager can revoke it without ever returning the secret.
198
209
  await this.storage.set(lookupKey(hash), JSON.stringify({ version: 1, id: record.id }));
@@ -1,3 +1,4 @@
1
+ import { type ResolvedTheme } from "../branding.js";
1
2
  import type { Connector } from "../types.js";
2
3
  import { type ArtifactOperations } from "./operations.js";
3
4
  import { type ArtifactRefreshService } from "./refresh.js";
@@ -50,6 +51,8 @@ export interface ArtifactsConnectorOptions {
50
51
  renderCheck?: ArtifactRenderCheck;
51
52
  /** Where pages are served, when not the deployment's own origin. */
52
53
  origin?: string;
54
+ /** Resolved deployment theme, available after the module binds to core. */
55
+ theme?: () => ResolvedTheme | undefined;
53
56
  }
54
57
  export declare function artifactsConnector(options: ArtifactsConnectorOptions): Connector;
55
58
  export {};
@@ -9,6 +9,7 @@
9
9
  import { api } from "../connectors/api.js";
10
10
  import { callerOf } from "../connector-caller.js";
11
11
  import { ConnectorCallError } from "../errors.js";
12
+ import { resolveTheme } from "../branding.js";
12
13
  import { buildFrameDocument, frameCsp } from "./document.js";
13
14
  import { buildGuide, GUIDE_SUMMARY } from "./guide.js";
14
15
  import { invalidContent, } from "./operations.js";
@@ -56,6 +57,9 @@ function renderFindings(values) {
56
57
  }
57
58
  /** Run a hook under its 20-second deadline and the call's own signal. Never throws. */
58
59
  export async function runRenderCheck(hook, input, callSignal) {
60
+ if (callSignal?.aborted) {
61
+ return { ok: false, unavailable: "The render check's call was cancelled; nothing was saved." };
62
+ }
59
63
  const controller = new AbortController();
60
64
  let timer;
61
65
  const onAbort = () => controller.abort(callSignal?.reason);
@@ -162,6 +166,7 @@ export function artifactsConnector(options) {
162
166
  document: buildFrameDocument({
163
167
  kind: head.kind,
164
168
  source,
169
+ theme: options.theme?.() ?? resolveTheme(),
165
170
  global: globalFor(artifactId, head, head.view, liveOf(head), false),
166
171
  data,
167
172
  }),
@@ -450,6 +455,7 @@ export function artifactsConnector(options) {
450
455
  document: buildFrameDocument({
451
456
  kind: args.kind,
452
457
  source: args.source,
458
+ theme: options.theme?.() ?? resolveTheme(),
453
459
  global: {
454
460
  id: "preview",
455
461
  title: "Preview",
@@ -8,6 +8,12 @@
8
8
  import { scanHtml } from "./html-scan.js";
9
9
  import { scriptSafeJson } from "./json.js";
10
10
  import { markdownPage, MarkdownNestingError } from "./markdown.js";
11
+ /**
12
+ * Reserve for the injected global and Markdown theme beyond source and data.
13
+ * Sixteen 64-character document names appear twice, alongside timestamps and
14
+ * versions, and a 160-character title can expand sixfold when script-escaped.
15
+ */
16
+ export const FRAME_OVERHEAD_BYTES = 8192;
11
17
  /**
12
18
  * The frame's Content-Security-Policy. `sandbox allow-scripts` without
13
19
  * `allow-same-origin` gives the page an opaque origin — no cookies, no
@@ -1,4 +1,5 @@
1
1
  // artifacts(): the typed module a deployment passes as `ConnectaConfig.artifacts`.
2
+ import { resolveBranding } from "../branding.js";
2
3
  import { artifactsConnector } from "./connector.js";
3
4
  import { ArtifactOperations } from "./operations.js";
4
5
  import { artifactRoutes } from "./routes.js";
@@ -43,17 +44,22 @@ export function artifacts(options) {
43
44
  limits,
44
45
  });
45
46
  const refresh = new ArtifactRefreshService(operations);
47
+ let theme;
46
48
  const connector = artifactsConnector({
47
49
  operations,
48
50
  refresh,
49
51
  allowlist,
50
52
  limits,
53
+ theme: () => theme,
51
54
  ...(options.renderCheck ? { renderCheck: options.renderCheck } : {}),
52
55
  });
53
56
  return Object.freeze({
54
57
  connector,
55
58
  handle: artifactRoutes({ operations, allowlist }),
56
- bindRefresh: (runtime) => refresh.bind(runtime),
59
+ bindRefresh: (runtime) => {
60
+ refresh.bind(runtime);
61
+ theme = resolveBranding(runtime.branding).theme;
62
+ },
57
63
  refresh: (id, by) => refresh.run(id, { manual: by }),
58
64
  runDue: () => refresh.runDue(),
59
65
  });
@@ -1,3 +1,4 @@
1
+ import type { ResolvedTheme } from "../branding.js";
1
2
  import { type CheckContext } from "./validate.js";
2
3
  import type { ArtifactActor, ArtifactHeadRecord, ArtifactIssue, ArtifactKind, ArtifactStore, ArtifactValidation, ArtifactVersionRecord, ArtifactRefreshSchedule, ArtifactRunRecord } from "./types.js";
3
4
  type ArtifactFailureCode = "conflict" | "invalid_args" | "not_found" | "unavailable";
@@ -164,10 +165,11 @@ export declare class ArtifactOperations {
164
165
  page(id: string, pin?: {
165
166
  view?: number;
166
167
  documents?: Record<string, number>;
167
- }): Promise<Result<{
168
+ }, theme?: ResolvedTheme): Promise<Result<{
168
169
  head: ArtifactHeadRecord;
169
170
  view: ArtifactVersionRecord;
170
171
  source: string;
172
+ document: string;
171
173
  documents: Record<string, {
172
174
  record: ArtifactVersionRecord;
173
175
  value: unknown;
@@ -15,6 +15,7 @@
15
15
  // `{ ok: false, code, message }`, and the connector turns that into its typed
16
16
  // error; a throw means storage failed.
17
17
  import { sha256Hex, utf8Bytes } from "../run-journal.js";
18
+ import { buildFrameDocument, FRAME_OVERHEAD_BYTES } from "./document.js";
18
19
  import { markdownPage, MarkdownNestingError } from "./markdown.js";
19
20
  import { ARTIFACT_ID, checkDocument, checkDocumentTotals, checkView, finish, validDocumentName, } from "./validate.js";
20
21
  /** Physical head moves a write absorbs before giving up as busy. */
@@ -23,8 +24,6 @@ const MAX_SWAP_ATTEMPTS = 8;
23
24
  const HISTORY_LIMIT = 20;
24
25
  /** Heads one listing call may examine while searching. */
25
26
  const LIST_SCAN_LIMIT = 1000;
26
- /** What the viewer adds around a page beyond its source and documents. */
27
- const FRAME_OVERHEAD_BYTES = 4096;
28
27
  /** Tombstones keep version numbers, so distinct names need their own bound. */
29
28
  const DOCUMENT_NAMES_LIMIT = 64;
30
29
  const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
@@ -827,7 +826,7 @@ export class ArtifactOperations {
827
826
  return { ok: true, head: current.head, record, value, history };
828
827
  }
829
828
  /** The page a viewer renders: a view version and exactly the documents asked for (all current ones by default). */
830
- async page(id, pin = {}) {
829
+ async page(id, pin = {}, theme) {
831
830
  const badId = this.#checkId(id);
832
831
  if (badId)
833
832
  return badId;
@@ -841,7 +840,10 @@ export class ArtifactOperations {
841
840
  const wanted = pin.documents
842
841
  ? Object.entries(pin.documents)
843
842
  : liveDocuments(head).map(([name, record]) => [name, record.version]);
844
- const documents = {};
843
+ // Historical streams were each valid when written, but their selected
844
+ // versions may never have coexisted. Bound the selection before loading
845
+ // its bodies, then check the actual bodies against today's configuration.
846
+ const selected = [];
845
847
  for (const [name, version] of wanted) {
846
848
  const badName = this.#checkDocumentName(name);
847
849
  if (badName)
@@ -849,11 +851,58 @@ export class ArtifactOperations {
849
851
  const record = await this.#version(id, `doc:${name}`, head.documents[name], version);
850
852
  if (!record)
851
853
  return this.#notFound(id);
852
- if (record.removed)
853
- continue;
854
- documents[name] = { record, value: JSON.parse(await this.#body(record.body)) };
854
+ if (!record.removed)
855
+ selected.push([name, record]);
856
+ }
857
+ const { limits } = this.#context;
858
+ const tooMany = checkDocumentTotals(selected.length, 0, limits);
859
+ if (tooMany)
860
+ return invalidContent("The snapshot", finish({ errors: [tooMany], warnings: [] }));
861
+ const source = await this.#body(view.body);
862
+ const sourceBytes = utf8Bytes(source);
863
+ if (sourceBytes > limits.sourceBytes) {
864
+ return invalidContent("The snapshot", finish({ errors: [{
865
+ code: "E_TOO_LARGE", severity: "error",
866
+ message: `The view is ${sourceBytes} bytes; the limit is ${limits.sourceBytes}.`,
867
+ }], warnings: [] }));
855
868
  }
856
- return { ok: true, head, view, source: await this.#body(view.body), documents };
869
+ const documents = {};
870
+ const data = {};
871
+ let dataBytes = 0;
872
+ for (const [name, record] of selected) {
873
+ const value = JSON.parse(await this.#body(record.body));
874
+ const checked = checkDocument(name, value, limits);
875
+ if (checked.error)
876
+ return invalidContent("The snapshot", finish({ errors: [checked.error], warnings: [] }));
877
+ dataBytes += checked.bytes;
878
+ const totals = checkDocumentTotals(selected.length, dataBytes, limits);
879
+ if (totals)
880
+ return invalidContent("The snapshot", finish({ errors: [totals], warnings: [] }));
881
+ documents[name] = { record, value };
882
+ data[name] = checked.text;
883
+ }
884
+ const tooLarge = () => invalidContent("The snapshot", finish({ errors: [{
885
+ code: "E_TOO_LARGE", severity: "error",
886
+ message: `The rendered page exceeds the current limit of ${limits.renderedBytes} bytes.`,
887
+ }], warnings: [] }));
888
+ // For HTML, source plus data is a lower bound before frame construction.
889
+ // Markdown can shrink or expand, so its final document decides the bound.
890
+ if (dataBytes + (head.kind === "html" ? sourceBytes : 0) > limits.renderedBytes)
891
+ return tooLarge();
892
+ const rendered = buildFrameDocument({
893
+ kind: head.kind, source, ...(theme ? { theme } : {}),
894
+ global: {
895
+ id, title: head.title, view: { version: view.version },
896
+ documents: Object.fromEntries(selected.map(([name, record]) => [name, {
897
+ version: record.version, updatedAt: record.at,
898
+ }])),
899
+ snapshot: pin.view !== undefined || pin.documents !== undefined,
900
+ },
901
+ data,
902
+ });
903
+ if (utf8Bytes(rendered) > limits.renderedBytes)
904
+ return tooLarge();
905
+ return { ok: true, head, view, source, document: rendered, documents };
857
906
  }
858
907
  async list(options) {
859
908
  if (options.cursor !== undefined && !ARTIFACT_ID.test(options.cursor)) {
@@ -864,9 +913,11 @@ export class ArtifactOperations {
864
913
  let after = options.cursor;
865
914
  let scanned = 0;
866
915
  for (;;) {
916
+ const limit = Math.min(100, LIST_SCAN_LIMIT - scanned);
917
+ const previous = after;
867
918
  const page = await this.#store.heads({
868
919
  ...(after === undefined ? {} : { after }),
869
- limit: Math.min(100, LIST_SCAN_LIMIT - scanned),
920
+ limit,
870
921
  });
871
922
  for (const item of page.heads) {
872
923
  scanned++;
@@ -885,8 +936,15 @@ export class ArtifactOperations {
885
936
  }
886
937
  if (page.next === undefined)
887
938
  return { ok: true, items };
939
+ if (previous !== undefined && page.next <= previous) {
940
+ return fail("unavailable", "Artifact head scan did not advance; retry the listing.");
941
+ }
942
+ // Listed keys can disappear before the store reads them. Its cursor
943
+ // still consumed the whole page, including those missing head records.
944
+ after = page.next;
945
+ scanned += Math.max(0, limit - page.heads.length);
888
946
  if (scanned >= LIST_SCAN_LIMIT) {
889
- return after === undefined ? { ok: true, items } : { ok: true, items, nextCursor: after };
947
+ return { ok: true, items, nextCursor: after };
890
948
  }
891
949
  }
892
950
  }
@@ -28,6 +28,8 @@ export interface ArtifactRefreshRuntime {
28
28
  isError?: boolean;
29
29
  }>;
30
30
  claimMs: number;
31
+ /** UI branding shared by the viewer and every render check. */
32
+ branding?: import("../types.js").ConnectaBranding;
31
33
  }
32
34
  export type RefreshOutcome = {
33
35
  status: "skipped";
@@ -179,10 +179,11 @@ export class ArtifactRefreshService {
179
179
  ...(after ? { after } : {}),
180
180
  limit,
181
181
  });
182
+ if (page.next !== undefined && after !== undefined && page.next <= after) {
183
+ throw new Error("Artifact head scan did not advance.");
184
+ }
182
185
  if (!page.heads.length) {
183
186
  if (page.next) {
184
- if (page.next === after)
185
- throw new Error("Artifact head scan did not advance.");
186
187
  after = page.next;
187
188
  scanned += limit;
188
189
  continue;
@@ -13,7 +13,7 @@ import { resolveActorLabels, actorKey } from "../routes/actor-labels.js";
13
13
  import { authorize, mayViewArtifacts, privateJson, validateAuthPermissions, } from "../routes/shared.js";
14
14
  import { resolveBranding, schemeAttribute, themeCss } from "../branding.js";
15
15
  import { TOKENS_CSS } from "../page-styles.js";
16
- import { buildFrameDocument, frameCsp } from "./document.js";
16
+ import { frameCsp } from "./document.js";
17
17
  import { scriptSafeJson } from "./json.js";
18
18
  import { freshnessOf } from "./refresh.js";
19
19
  import { ARTIFACT_ID } from "./validate.js";
@@ -142,7 +142,8 @@ export function artifactRoutes(options) {
142
142
  }
143
143
  pin = { view: Number(pinned), documents };
144
144
  }
145
- const page = await ops.page(id, pin ?? {});
145
+ const theme = resolveBranding(context.opts.branding).theme;
146
+ const page = await ops.page(id, pin ?? {}, theme);
146
147
  if (!page.ok)
147
148
  return notFound();
148
149
  const origin = new URL(context.baseUrl).origin;
@@ -170,19 +171,7 @@ export function artifactRoutes(options) {
170
171
  freshness: freshnessOf(page.head),
171
172
  url,
172
173
  snapshotUrl: `${url}/v/${page.view.version}${pins.length ? `?${pins.join("&")}` : ""}`,
173
- document: buildFrameDocument({
174
- kind: page.head.kind,
175
- source: page.source,
176
- theme: resolveBranding(context.opts.branding).theme,
177
- global: {
178
- id,
179
- title: page.head.title,
180
- view: { version: page.view.version },
181
- documents: Object.fromEntries(documents.map(({ name, version, updatedAt }) => [name, { version, updatedAt }])),
182
- snapshot: pin !== undefined,
183
- },
184
- data: Object.fromEntries(Object.entries(page.documents).map(([name, { value }]) => [name, scriptSafeJson(value)])),
185
- }),
174
+ document: page.document,
186
175
  });
187
176
  };
188
177
  return async (context) => {
@@ -10,6 +10,7 @@ import { boundedEchoText } from "../errors.js";
10
10
  import { utf8Bytes } from "../run-journal.js";
11
11
  import { lineLocator, scanHtml } from "./html-scan.js";
12
12
  import { jsonProblem, scriptSafeJson } from "./json.js";
13
+ import { FRAME_OVERHEAD_BYTES } from "./document.js";
13
14
  import { markdownPage, MarkdownNestingError } from "./markdown.js";
14
15
  import { DEFAULT_ARTIFACT_ALLOWLIST, DEFAULT_ARTIFACT_LIMITS, } from "./types.js";
15
16
  /** An artifact id: the URL slug. No leading `_`, so `_api` and `_frame` stay free. */
@@ -23,8 +24,6 @@ export function validDocumentName(name) {
23
24
  }
24
25
  const MAX_REPORTED = 20;
25
26
  const QUOTE_BYTES = 120;
26
- /** What the viewer adds around a page beyond its source and documents. */
27
- const FRAME_OVERHEAD_BYTES = 4096;
28
27
  const quote = (text) => boundedEchoText(text, QUOTE_BYTES);
29
28
  const count = (n) => n.toLocaleString("en-US");
30
29
  const kib = (bytes) => bytes % (1024 * 1024) === 0