@taprootio/site-authoring 0.3.0 → 0.4.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.
@@ -13,6 +13,13 @@ import {
13
13
  SHIPPED_FIXTURE_NAME,
14
14
  shippedFixtureDirectory,
15
15
  } from "./fixture-contract.js";
16
+ import {
17
+ DEFAULT_REDIRECT_STATUS,
18
+ GONE_STATUS,
19
+ REDIRECT_LIMITS,
20
+ REDIRECT_STATUSES,
21
+ REDIRECTS_FILE_NAME,
22
+ } from "./redirects-contract.js";
16
23
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
17
24
  import {
18
25
  canonicalizeComponentData,
@@ -49,7 +56,7 @@ import {
49
56
 
50
57
  export { getAppearanceReference, getFooterReference, getThemeReference };
51
58
 
52
- export const REFERENCE_VERSION = 16;
59
+ export const REFERENCE_VERSION = 17;
53
60
  export const PAGE_TYPES = Object.freeze(["free-form"]);
54
61
  export const REFERENCE_TOPICS = Object.freeze([
55
62
  Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
@@ -69,6 +76,11 @@ export const REFERENCE_TOPICS = Object.freeze([
69
76
  summary: "Describe one component schema and show a valid example.",
70
77
  }),
71
78
  Object.freeze({ name: "nav", usage: `${CLI_BINARY_NAME} help nav`, summary: "Describe nav.json item shapes." }),
79
+ Object.freeze({
80
+ name: "redirects",
81
+ usage: `${CLI_BINARY_NAME} help redirects`,
82
+ summary: "Author redirects.json: entry shape, normalization, and the refusals.",
83
+ }),
72
84
  Object.freeze({
73
85
  name: "media",
74
86
  usage: `${CLI_BINARY_NAME} help media`,
@@ -125,6 +137,81 @@ const WORKFLOW_REFERENCES = Object.freeze({
125
137
  })]),
126
138
  }),
127
139
  }),
140
+ redirects: Object.freeze({
141
+ title: "Redirect map workspace contract",
142
+ summary:
143
+ `${REDIRECTS_FILE_NAME} is the site's whole redirect map. 'redirects push' replaces it, fenced by the `
144
+ + "revision 'redirects pull' recorded, so a stale write is refused rather than deleting an entry a page "
145
+ + "rename added.",
146
+ usage: `${CLI_BINARY_NAME} redirects pull | ${CLI_BINARY_NAME} redirects push`,
147
+ details: Object.freeze([
148
+ `${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
149
+ + "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
150
+ "An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
151
+ + `carries no target and serves ${GONE_STATUS}.`,
152
+ `status defaults to ${DEFAULT_REDIRECT_STATUS} and accepts ${REDIRECT_STATUSES.join(", ")}. A migration `
153
+ + `wants ${DEFAULT_REDIRECT_STATUS}: it is what tells search engines the old URL has moved for good.`,
154
+ "path is normalized exactly as the published-site edge normalizes a request: a leading slash is added, "
155
+ + "trailing slashes are stripped, and nothing else changes — so a legacy source keeps its suffix and "
156
+ + "'/faqs.html' is a real, representable path. It carries no query string, fragment, or host.",
157
+ "Write a path — and the path part of a site-relative target — in the percent-encoded spelling a browser "
158
+ + "sends ('/caf%C3%A9', '/old%20page'), never the raw one: the edge keys on the encoded pathname, so a raw "
159
+ + "space or non-ASCII character is stored under a key no request matches, and a raw target beside an "
160
+ + "encoded source is the self-loop loop detection cannot see.",
161
+ "target is a site-relative path such as '/classes', or an absolute credential-free http(s) URL. A "
162
+ + "site-relative target keeps its trailing slash: it is a Location a browser follows, not a route key.",
163
+ "A path, or the path part of a site-relative target, is refused when any segment is '.' or '..', when two "
164
+ + "slashes run together, or when a dot or slash is percent-encoded ('%2e', '%2f'); a target's query string "
165
+ + "may carry any of those. The edge and the generator resolve "
166
+ + "those away, so '/x/../visit' would be checked as itself and served as '/visit' — past the occupancy, "
167
+ + "chain, and loop rules that were asked about a different path. Write the resolved path instead.",
168
+ "origin is reported by the site and ignored on push: 'authored' is what you declared, 'path_history' is "
169
+ + "what a page or tag rename recorded. Pull the map, edit it, push the whole thing back — an entry you "
170
+ + "did not change keeps its origin.",
171
+ "Refused, offline and again at the site, each naming the entry index: a chain (a target that is itself a "
172
+ + "source in the same map); a loop (an entry targeting itself); a duplicate path; a gone entry carrying a "
173
+ + "target; and a status outside the allowed set. A chain or loop is judged on the target's path alone, so "
174
+ + "'/b?x=1' and '/b' are the same destination to it.",
175
+ "Refused by the site, which alone knows what it serves: a path a live page, Docs resource, or generated "
176
+ + "resource already occupies. '/' is such a path while the site's home page is served there — and a "
177
+ + "usable source once the home page has moved, which is the entry a home-page rename records.",
178
+ `Bounded: at most ${REDIRECT_LIMITS.entries} entries, ${REDIRECT_LIMITS.pathBytes} bytes of path, and `
179
+ + `${REDIRECT_LIMITS.targetBytes} bytes of target, matching the edge's own key and value limits.`,
180
+ `The ${REDIRECT_LIMITS.pathBytes}-byte path bound governs what you declare, not what the site already `
181
+ + "holds. A page may sit at a longer path, and renaming it records a path_history entry there that "
182
+ + "'redirects pull' returns; such an entry passes 'validate' and pushes back unchanged. The site is the "
183
+ + "authority on that — it alone knows whether a push introduces the path or carries it back — so a push "
184
+ + "that lengthens or changes one is still refused there.",
185
+ `The ${REDIRECT_LIMITS.entries}-entry bound is scoped the same way: a pulled map already holding more `
186
+ + "than that validates and pushes back unchanged, and only a document holding more entries than both the "
187
+ + "bound and the count the last pull recorded is refused — so accumulated path history never has to be "
188
+ + "deleted to make a push land.",
189
+ "A push whose recorded revision predates a page rename is refused as redirects.concurrent_modification. "
190
+ + `Run 'redirects pull', reconcile ${REDIRECTS_FILE_NAME}, and retry; nothing was written.`,
191
+ "Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
192
+ + "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
193
+ + "which are simply gone.",
194
+ "Entries take effect on the next deploy, staging first. They are written to the edge's key-value store "
195
+ + "when the deploy syncs routing, and that store is eventually consistent, so a spot-check run "
196
+ + "immediately afterwards can briefly still see the previous map. Re-check before concluding an entry "
197
+ + "did not land.",
198
+ ]),
199
+ example: Object.freeze({
200
+ siteId: "<site-uuid>",
201
+ revision: "<revision-recorded-by-redirects-pull>",
202
+ entries: Object.freeze([
203
+ Object.freeze({ path: "/faqs.html", kind: "redirect", target: "/faq", status: 301 }),
204
+ Object.freeze({ path: "/old-blog", kind: "redirect", target: "/journal", status: 301 }),
205
+ Object.freeze({
206
+ path: "/book",
207
+ kind: "redirect",
208
+ target: "https://booking.example.test/riverbend",
209
+ status: 302,
210
+ }),
211
+ Object.freeze({ path: "/summer-2019-retreat.html", kind: "gone" }),
212
+ ]),
213
+ }),
214
+ }),
128
215
  media: Object.freeze({
129
216
  title: "Media upload contract",
130
217
  summary: "Upload workspace-root-relative raster files and receive component-ready media objects.",
@@ -194,6 +281,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
194
281
  "pages must declare at least one entry, pagesTruncated must be false, and settingsSkipped must be empty: a "
195
282
  + "partial snapshot is not a fixture.",
196
283
  `navigation binds { file: "${NAVIGATION_FILE_NAME}", items }, and items must equal the tree's own item count.`,
284
+ `redirects binds { file: "${REDIRECTS_FILE_NAME}", revision, entries }, and entries must equal the map's own `
285
+ + "entry count. The revision is a deterministic 64-character lowercase hex placeholder, like every other "
286
+ + "identity in a fixture: it proves the shape, never a live read. No redirect source may be a page in the "
287
+ + `same fixture. See '${CLI_BINARY_NAME} help redirects' for the entry contract.`,
197
288
  `settings binds all ${SETTINGS_GROUPS.length} authorable groups, each with settingsType, file, and entityId: ${
198
289
  SETTINGS_GROUPS.map((group) => `${group.settingsType} to '${SETTINGS_DIRECTORY}/${group.file}'`).join("; ")
199
290
  }.`,
@@ -690,9 +781,11 @@ const FREE_FORM_REFERENCE = Object.freeze({
690
781
  pull:
691
782
  "pull keeps a tracked source that is still on disk and never writes a second editable file for the same page.",
692
783
  internalState:
693
- `${INTERNAL_PAGE_BASELINE_DIRECTORY}/<pageId>.pm.json holds the site's document for a page tracked as Markdown. `
694
- + "It is internal state: it is never discovered as a page source and is never pushed, and any pull rewrites "
695
- + "it, so it is safe to ignore in version control.",
784
+ `${INTERNAL_PAGE_BASELINE_DIRECTORY}/<pageId>.pm.json holds the site's document for a page tracked as Markdown, `
785
+ + "and <pageId>.revision.json beside it records the revision a refused pull already showed you — which is what "
786
+ + "lets the refusal's own recovery push proceed. Both are internal state: never discovered as a page source "
787
+ + "and never pushed, and a pull that reconciles the page rewrites or removes them, so they are safe to ignore "
788
+ + "in version control.",
696
789
  formatChange:
697
790
  "Remove the tracked source, then author the other format beside it. Two editable sources for one path is a "
698
791
  + "refusal, not a guess.",
@@ -706,6 +799,24 @@ const FREE_FORM_REFERENCE = Object.freeze({
706
799
  + "refuses before changing anything under pages/ and preserves the site's version as internal state. A "
707
800
  + "locally edited .pm.json is kept rather than overwritten, and only collides when the site changed too.",
708
801
  conflictError: "pages.pull_conflict",
802
+ conflictDetail:
803
+ "A reported conflict names the JSON paths at which the site's document differs from the copy this workspace "
804
+ + "last reconciled with, in error.differences, which carries three states. Absent: the two documents were "
805
+ + "never compared, because this workspace kept no copy of the site's previous version. Empty: they were "
806
+ + "compared and the body is identical, so the change was to the page's title, path, or description. "
807
+ + "Non-empty: the JSON paths at which the body differs.",
808
+ pushConflict:
809
+ "pages push fails closed on a concurrent remote edit: the site reports each page's stored-state revision, the "
810
+ + "workspace records the revision of what it last wrote or pulled, and a push whose target now carries a "
811
+ + "different revision is refused before any page is sent. The refusal carries both revisions in "
812
+ + "error.alternatives, recorded first and live second. The guard is armed once both are known — a page this "
813
+ + "workspace has never reconciled with, or a Taproot that predates the revision contract, leaves it unarmed "
814
+ + "rather than refusing every push. A pull that refuses records the revision it showed you, so the pull "
815
+ + "conflict's own recovery push still goes through; a page that moved again since is refused again.",
816
+ pushConflictError: "pages.push_conflict",
817
+ revisionSource:
818
+ "baseline.revision in the manifest, from bodyRevision on the page read, the page listing, and the create and "
819
+ + "update responses. It is opaque: compare it, never derive or parse it.",
709
820
  conflictRecovery:
710
821
  "Either push the local source to make the site match it, or delete the local source and pull again to adopt "
711
822
  + "the site's document as that page's source.",
@@ -993,7 +1104,13 @@ export function formatReferenceResult(result) {
993
1104
  sourceRule.renamedSource
994
1105
  } (${sourceRule.renamedSourceError})\n conflicts ${
995
1106
  sourceRule.conflict
996
- } (${sourceRule.conflictError})\n recovery ${
1107
+ } (${sourceRule.conflictError})\n conflict detail ${
1108
+ sourceRule.conflictDetail
1109
+ }\n push conflicts ${
1110
+ sourceRule.pushConflict
1111
+ } (${sourceRule.pushConflictError})\n revision ${
1112
+ sourceRule.revisionSource
1113
+ }\n recovery ${
997
1114
  sourceRule.conflictRecovery
998
1115
  }\n push selection ${sourceRule.pushSelection}\n\nSystem page projections:\n${
999
1116
  page.workspace.systemPages.map((systemPage) =>
package/src/session.js CHANGED
@@ -3,12 +3,17 @@ import {
3
3
  CLI_NAME,
4
4
  CLI_VERSION,
5
5
  DEFAULT_API_BASE_URL,
6
+ EXTERNAL_WRITES_SETTING_KEY,
7
+ EXTERNAL_WRITES_SETTING_LOCATION,
6
8
  PUBLISH_KEY_ENVIRONMENT_VARIABLE,
9
+ REFUSAL_PLATFORM_PAUSED,
7
10
  RESULT_SCHEMA_VERSION,
11
+ ROLLOUT_REFUSAL_FIELD,
8
12
  VERB_LOGIN,
9
13
  VERB_USE,
10
14
  } from "./constants.js";
11
15
  import { loadSiteConfig } from "./config.js";
16
+ import { cliOutdatedError, compareCliVersions, isBehindLatest } from "./cli-release.js";
12
17
  import {
13
18
  credentialApiOrigin,
14
19
  readCredentialStore,
@@ -16,7 +21,7 @@ import {
16
21
  updateCredentialMetadata,
17
22
  } from "./credentials.js";
18
23
  import { SiteAuthoringError } from "./errors.js";
19
- import { exchangeSiteAuthoringToken } from "./api.js";
24
+ import { exchangeSiteAuthoringToken, withRefusalGuidance } from "./api.js";
20
25
  import { SiteApiClient } from "./transport.js";
21
26
  import { readStoredApiBaseUrl } from "./settings.js";
22
27
 
@@ -38,6 +43,26 @@ import { readStoredApiBaseUrl } from "./settings.js";
38
43
  /** The optional seams a test (or a future caller) may inject. */
39
44
  const CLIENT_OPTION_KEYS = ["fetch", "signal", "sleep", "now", "timeoutSignal"];
40
45
 
46
+ /**
47
+ * What a session knows about the platform's authoring rollout switch when
48
+ * nothing told it (TR00692).
49
+ *
50
+ * Three states, not two, and the third is the honest one: `TAPROOT_SITE_KEY`
51
+ * skips the exchange entirely and no other read on the contract exposes the
52
+ * switch, so those runs know nothing about it. Collapsing unknown into "paused"
53
+ * would warn on every write against a healthy platform; collapsing it into
54
+ * "enabled" would claim a fact nobody supplied.
55
+ */
56
+ const UNKNOWN_PLATFORM = Object.freeze({ externalWritesEnabled: undefined });
57
+
58
+ /**
59
+ * What a session knows about the latest published release when nothing told it
60
+ * (TR00703) — the same three-state shape, and unknown for the same reasons: a
61
+ * `TAPROOT_SITE_KEY` run never exchanges, and a Taproot that predates the field
62
+ * answers with nothing.
63
+ */
64
+ const UNKNOWN_CLI_RELEASE = Object.freeze({ latestVersion: undefined });
65
+
41
66
  function progressSink(invocation) {
42
67
  return typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
43
68
  }
@@ -166,7 +191,19 @@ export async function openSession(invocation = {}) {
166
191
  if (!config.siteId) throw siteMissingError();
167
192
  const client = invocation.client
168
193
  ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, environmentToken));
169
- return { config, client, siteId: config.siteId, now, onProgress };
194
+ // No exchange happens on this path, and nothing else on the contract reads
195
+ // the platform switch — so its state is genuinely unknown here rather than
196
+ // assumed. Reporting it as enabled would be a guess, and reporting it as
197
+ // paused would warn on every write against a healthy platform.
198
+ return {
199
+ config,
200
+ client,
201
+ siteId: config.siteId,
202
+ platform: UNKNOWN_PLATFORM,
203
+ release: UNKNOWN_CLI_RELEASE,
204
+ now,
205
+ onProgress,
206
+ };
170
207
  }
171
208
 
172
209
  let config;
@@ -192,14 +229,33 @@ export async function openSession(invocation = {}) {
192
229
  // A caller may supply the site client directly (tests, and the verbs that
193
230
  // already hold one); otherwise the sign-in is exchanged for one.
194
231
  if (invocation.client) {
195
- return { config, client: invocation.client, siteId: config.siteId, now, onProgress };
232
+ return {
233
+ config,
234
+ client: invocation.client,
235
+ siteId: config.siteId,
236
+ platform: UNKNOWN_PLATFORM,
237
+ release: UNKNOWN_CLI_RELEASE,
238
+ now,
239
+ onProgress,
240
+ };
196
241
  }
197
242
 
198
243
  const accountClient = new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, signIn.key));
199
- const exchanged = await exchangeSiteAuthoringToken(accountClient, {
200
- siteId: config.siteId,
201
- capabilities: invocation.capabilities ?? [],
202
- });
244
+ // Announced here, not left to the generic failure line. The exchange is the
245
+ // first request every site verb makes and it sits outside each verb's own
246
+ // `withRefusalGuidance`, so a refusal raised by it used to reach the operator
247
+ // as a field name and nothing else. That matters most for `cli_outdated`,
248
+ // whose whole remedy is a command the operator has to be handed (TR00703),
249
+ // but it is true of every classified refusal the exchange can raise.
250
+ const exchanged = await withRefusalGuidance(
251
+ onProgress,
252
+ "sign-in token exchange",
253
+ async () =>
254
+ await exchangeSiteAuthoringToken(accountClient, {
255
+ siteId: config.siteId,
256
+ capabilities: invocation.capabilities ?? [],
257
+ }),
258
+ );
203
259
  // Two non-secret facts the store should now carry: the sign-in's slid
204
260
  // deadline, and what this exchange actually produced — the latter is the only
205
261
  // thing that lets `whoami` answer offline with the real grant rather than the
@@ -224,6 +280,20 @@ export async function openSession(invocation = {}) {
224
280
  siteId: exchanged.siteId,
225
281
  capabilities: exchanged.capabilities,
226
282
  expiresAt: exchanged.expiresAt,
283
+ // When this exchange happened, so `whoami` can date what it reports
284
+ // rather than presenting a snapshot as current fact. Recorded for
285
+ // every exchange, not only the ones that saw a platform state, so the
286
+ // field has one meaning.
287
+ at: new Date(now()).toISOString(),
288
+ ...(exchanged.externalWritesEnabled === undefined
289
+ ? {}
290
+ : { externalWritesEnabled: exchanged.externalWritesEnabled }),
291
+ // The latest published release Taproot named (TR00703). Recorded for
292
+ // the offline verbs, which have no request to be refused and would
293
+ // otherwise keep validating against a contract that has moved.
294
+ ...(exchanged.latestCliVersion === undefined
295
+ ? {}
296
+ : { latestCliVersion: exchanged.latestCliVersion }),
227
297
  },
228
298
  },
229
299
  );
@@ -242,11 +312,95 @@ export async function openSession(invocation = {}) {
242
312
  expiresAt: exchanged.expiresAt,
243
313
  capabilities: exchanged.capabilities,
244
314
  },
315
+ platform: Object.freeze({ externalWritesEnabled: exchanged.externalWritesEnabled }),
316
+ release: Object.freeze({ latestVersion: exchanged.latestCliVersion }),
245
317
  now,
246
318
  onProgress,
247
319
  };
248
320
  }
249
321
 
322
+ /**
323
+ * Refuses an offline verb when the last exchange said a newer CLI exists
324
+ * (TR00703).
325
+ *
326
+ * **Only for the verbs that make no request.** `help`, `validate`, and `whoami`
327
+ * are the whole set: every other verb reaches the server, and the server's own
328
+ * refusal is both authoritative and better informed than this recording. Gating
329
+ * them here would only duplicate an answer — and gating `login`, `logout`, or
330
+ * `env` would break the commands an operator needs *while* they are outdated.
331
+ *
332
+ * **Best effort, and silent on every uncertainty.** Nothing recorded proceeds,
333
+ * so a clean machine can still validate a fixture. A store this process cannot
334
+ * read or parse proceeds too: an unreadable file is not evidence that this CLI
335
+ * is behind, and turning a corrupt store into a refusal to run `help` would
336
+ * take away the command that explains how to fix it. Only a recorded version
337
+ * this side can parse and compare stops anything.
338
+ *
339
+ * The store holds one record per API origin, and the highest recorded version
340
+ * across all of them is what counts. npm publishes one package: whichever
341
+ * Taproot last answered, it was naming the same registry, so the newest answer
342
+ * is the current fact rather than a per-origin opinion. Taking the maximum also
343
+ * keeps a stale development Taproot from masking production's answer.
344
+ */
345
+ export async function assertCliCurrent(environment = process.env) {
346
+ let store;
347
+ try {
348
+ store = await readCredentialStore(environment);
349
+ } catch {
350
+ // Deliberately swallowed; see above.
351
+ return undefined;
352
+ }
353
+ let latest;
354
+ for (const credential of store.credentials) {
355
+ const recorded = credential.lastExchange?.latestCliVersion;
356
+ if (recorded === undefined) continue;
357
+ if (latest === undefined || (compareCliVersions(recorded, latest) ?? 0) > 0) latest = recorded;
358
+ }
359
+ if (latest === undefined || !isBehindLatest(latest)) return undefined;
360
+ throw cliOutdatedError(latest);
361
+ }
362
+
363
+ /**
364
+ * The one pre-write warning: the platform has external site authoring paused,
365
+ * so this verb's write is going to be refused (TR00692).
366
+ *
367
+ * **Advisory, never a gate.** The value is a snapshot the token exchange took
368
+ * before this run did any work, and the authority is still the transactional
369
+ * freeze inside the write itself — so a paused platform warns and then proceeds
370
+ * exactly as before, and the refusal that follows keeps its `platform_paused`
371
+ * classification for automation to branch on. An operator who flipped the
372
+ * switch back on between the exchange and the write gets their write.
373
+ *
374
+ * Silent in the two cases that are not "paused": enabled, and unknown. Unknown
375
+ * is the `TAPROOT_SITE_KEY` path, which never exchanges and therefore was never
376
+ * told — see {@link UNKNOWN_PLATFORM}.
377
+ *
378
+ * **Contract.** Call it once, immediately after `openSession` and before any
379
+ * validation, reading, or wire work, passing the whole session object and the
380
+ * verb's own name (its `VERB_*` constant). It prints at most one progress line
381
+ * and returns whether it printed one; it never throws, never reads, and never
382
+ * changes what the verb does next.
383
+ *
384
+ * @param {{ platform?: { externalWritesEnabled?: boolean }, onProgress?: (message: string) => void }} session
385
+ * The session `openSession` returned.
386
+ * @param {string} [verb] The verb's display name, for the warning's first clause.
387
+ * @returns {boolean} Whether a warning was printed.
388
+ */
389
+ export function warnIfExternalWritesPaused(session, verb = "This write") {
390
+ // The guard settles both questions: only a session object can carry `false`
391
+ // here, so nothing below needs to defend against a missing one.
392
+ if (session?.platform?.externalWritesEnabled !== false) return false;
393
+ const onProgress = progressSink(session);
394
+ onProgress(
395
+ `WARNING: Taproot has external site authoring paused platform-wide, so '${verb}' is expected to be refused `
396
+ + `at the write (refusal=${REFUSAL_PLATFORM_PAUSED}, field=${ROLLOUT_REFUSAL_FIELD}). The credential and the `
397
+ + `request are both fine. A Taproot administrator re-enables it with the platform setting `
398
+ + `'${EXTERNAL_WRITES_SETTING_KEY}' (${EXTERNAL_WRITES_SETTING_LOCATION}). Continuing anyway: this is what `
399
+ + "Taproot reported when this run's credential was exchanged, and the write itself is the authority.",
400
+ );
401
+ return true;
402
+ }
403
+
250
404
  /**
251
405
  * The credential-free half of the session seam, for the verbs that manage the
252
406
  * credential rather than use it.
package/src/transport.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  CAPABILITY_REFUSAL_FIELD,
3
3
  CAPABILITY_REFUSAL_REASON,
4
4
  CLI_NAME,
5
+ CLI_UPGRADE_REFUSAL_FIELD,
5
6
  CLI_VERSION,
6
7
  CREDENTIAL_REFUSAL_FIELD,
7
8
  GRPC_RESOURCE_EXHAUSTED,
@@ -12,6 +13,7 @@ import {
12
13
  PLAN_LIMIT_REFUSAL_FIELD,
13
14
  PUBLISH_KEY_ENVIRONMENT_VARIABLE,
14
15
  REFUSAL_CAPABILITY_MISSING,
16
+ REFUSAL_CLI_OUTDATED,
15
17
  REFUSAL_CREDENTIAL_REJECTED,
16
18
  REFUSAL_PLAN_LIMIT,
17
19
  REFUSAL_PLATFORM_PAUSED,
@@ -443,10 +445,17 @@ export class ApiError extends SiteAuthoringError {
443
445
 
444
446
  /**
445
447
  * Classifies a refusal into the one thing the caller should do next. The
446
- * five kinds are genuinely different behaviors, and collapsing them into
448
+ * six kinds are genuinely different behaviors, and collapsing them into
447
449
  * "the request failed" is what makes an agent retry a revoked credential
448
450
  * forever or treat a plan ceiling as a transient outage:
449
451
  *
452
+ * - `cli_outdated` (field `CliUpgradeRequired`) — this CLI is behind the
453
+ * latest published release, and Taproot supports only the latest. **Upgrade
454
+ * this package**; nothing about the credential, the request, or the site is
455
+ * wrong, and no retry of this version can succeed. Classified first,
456
+ * because it is the one refusal that says the client itself is the problem:
457
+ * every other kind describes a state the current binary could act on
458
+ * (TR00703).
450
459
  * - `platform_paused` (field `SiteAuthoringRollout`) — external authoring is
451
460
  * switched off platform-wide. **Retry later** or ask the operator; the
452
461
  * credential and the request are both fine.
@@ -484,6 +493,7 @@ export class ApiError extends SiteAuthoringError {
484
493
  refusalKind() {
485
494
  // Fields first: they are the specific statement, and a named refusal
486
495
  // outranks whatever status happens to carry it.
496
+ if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
487
497
  if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
488
498
  if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
489
499
  if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
@@ -500,8 +510,8 @@ export class ApiError extends SiteAuthoringError {
500
510
  }
501
511
  }
502
512
 
503
- async function parseJsonResponse(response, signal) {
504
- const bytes = await readBoundedBody(response, signal);
513
+ async function parseJsonResponse(response, signal, maximumBytes = LIMITS.apiResponseBytes) {
514
+ const bytes = await readBoundedBody(response, signal, maximumBytes);
505
515
  try {
506
516
  return JSON.parse(bytes.toString("utf8"));
507
517
  } catch {
@@ -610,6 +620,11 @@ export class SiteApiClient {
610
620
  attempts = LIMITS.requestAttempts,
611
621
  deadline,
612
622
  now = this.now,
623
+ // How large a body this one call may read. The default bounds every read
624
+ // the way it always has; a caller raises it only for the one contract
625
+ // whose valid answer is larger, and never above what its workspace file
626
+ // may hold.
627
+ maximumResponseBytes = LIMITS.apiResponseBytes,
613
628
  } = {}, replaceableMutation) {
614
629
  const url = new URL(path, this.apiBaseUrl);
615
630
  if (
@@ -618,6 +633,9 @@ export class SiteApiClient {
618
633
  || !ALLOWED_METHODS.has(method)
619
634
  || (deadline !== undefined && !Number.isFinite(deadline))
620
635
  || typeof now !== "function"
636
+ || !Number.isSafeInteger(maximumResponseBytes)
637
+ || maximumResponseBytes <= 0
638
+ || maximumResponseBytes > LIMITS.redirectMapResponseBytes
621
639
  ) {
622
640
  throw new SiteAuthoringError(
623
641
  "transport.request_contract",
@@ -695,7 +713,7 @@ export class SiteApiClient {
695
713
  }
696
714
  if (response.status >= 200 && response.status < 300 && !response.redirected) {
697
715
  try {
698
- const parsed = await parseJsonResponse(response, attemptSignal);
716
+ const parsed = await parseJsonResponse(response, attemptSignal, maximumResponseBytes);
699
717
  if (remainingBudget(deadline, now) <= 0) throw deadlineError();
700
718
  return parsed;
701
719
  } catch (error) {
@@ -729,7 +747,7 @@ export class SiteApiClient {
729
747
  }
730
748
  let responseBody;
731
749
  try {
732
- responseBody = await parseJsonResponse(response, attemptSignal);
750
+ responseBody = await parseJsonResponse(response, attemptSignal, maximumResponseBytes);
733
751
  } catch {
734
752
  // A response arrived, so the mutation's fate is whatever its status
735
753
  // says: a 4xx refused it, a 5xx may have committed it first.
@@ -7,7 +7,7 @@ import {
7
7
  } from "../api.js";
8
8
  import { VERB_APPROVE } from "../constants.js";
9
9
  import { SiteAuthoringError } from "../errors.js";
10
- import { boundedList, openSession, successResult } from "../session.js";
10
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
11
  import {
12
12
  normalizePagePath,
13
13
  readManifest,
@@ -36,7 +36,12 @@ const MAXIMUM_REPORTED = 200;
36
36
  const MAXIMUM_BATCH = 100;
37
37
 
38
38
  export async function approve(invocation) {
39
- const { client, config, siteId, onProgress } = await openSession(invocation);
39
+ const session = await openSession(invocation);
40
+ const { client, config, siteId, onProgress } = session;
41
+ // One advisory line before this verb does any work, and only when the
42
+ // exchange said the platform is paused. It changes nothing else: the write
43
+ // still runs and its refusal still classifies as platform_paused (TR00692).
44
+ warnIfExternalWritesPaused(session, VERB_APPROVE);
40
45
  const manifest = await readManifest(config.workspaceDir, siteId);
41
46
  const manifestByPageId = new Map(
42
47
  manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
@@ -17,10 +17,30 @@ import {
17
17
  VERB_DEPLOY,
18
18
  } from "../constants.js";
19
19
  import { SiteAuthoringError } from "../errors.js";
20
- import { boundedList, openSession, successResult } from "../session.js";
20
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
21
21
  import { CANDIDATE_SELECTABLE_SETTINGS_TYPES } from "../settings-catalog.js";
22
22
  import { readManifest, writeManifest } from "../workspace.js";
23
23
 
24
+ /**
25
+ * What a deploy does not finish (TR00702).
26
+ *
27
+ * A deploy writes the site's redirect and gone entries into the edge's
28
+ * key-value store as part of syncing routing. That store is eventually
29
+ * consistent, so a spot-check run the second a deploy reports success can
30
+ * briefly read the previous map, and taking that for "the redirect did not
31
+ * land" is the wrong conclusion to reach in front of a customer waiting to cut
32
+ * DNS over.
33
+ *
34
+ * No number is quoted, because there is none to quote: the coordinator holds
35
+ * nothing back for a standard site's redirects. Its propagation grace governs
36
+ * only the *deletion* of a superseded Docs pointer namespace; a standard site's
37
+ * rows are written and removed immediately.
38
+ */
39
+ const REDIRECT_PROPAGATION_NOTE =
40
+ "Redirect and gone entries are written to the edge's key-value store when this deploy syncs routing. That "
41
+ + "store is eventually consistent, so a spot-check run immediately afterwards can briefly still see the "
42
+ + "previous map; re-check before concluding an entry is missing.";
43
+
24
44
  /**
25
45
  * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
26
46
  * completion.
@@ -202,7 +222,12 @@ export async function deploy(invocation) {
202
222
  "deployTarget",
203
223
  );
204
224
  }
205
- const { client, config, siteId, now, onProgress } = await openSession(invocation);
225
+ const session = await openSession(invocation);
226
+ const { client, config, siteId, now, onProgress } = session;
227
+ // One advisory line before this verb does any work, and only when the
228
+ // exchange said the platform is paused. It changes nothing else: the write
229
+ // still runs and its refusal still classifies as platform_paused (TR00692).
230
+ warnIfExternalWritesPaused(session, VERB_DEPLOY);
206
231
  const manifest = await readManifest(config.workspaceDir, siteId, { required: false });
207
232
  const selection = explicitSelection(invocation);
208
233
 
@@ -233,6 +258,7 @@ export async function deploy(invocation) {
233
258
  now,
234
259
  });
235
260
  await recordDeployment(config, manifest, "production", completed);
261
+ onProgress(REDIRECT_PROPAGATION_NOTE);
236
262
  return successResult(VERB_DEPLOY, siteId, {
237
263
  target,
238
264
  environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
@@ -310,6 +336,7 @@ export async function deploy(invocation) {
310
336
  });
311
337
  await recordDeployment(config, manifest, "staging", completed);
312
338
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress);
339
+ onProgress(REDIRECT_PROPAGATION_NOTE);
313
340
  return successResult(VERB_DEPLOY, siteId, {
314
341
  target,
315
342
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -4,7 +4,7 @@ import { SiteAuthoringError } from "../errors.js";
4
4
  import { projectFooterSettingsForWorkspace, validateFooterDocument } from "../footer-contract.js";
5
5
  import { computeFooterDraftHash } from "../footer-draft-hash.js";
6
6
  import { advanceFooterManifest, FOOTER_SETTINGS_FILE, readFooterWorkspaceContext } from "../footer-workspace.js";
7
- import { openSession, successResult } from "../session.js";
7
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
8
8
  import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
9
9
  import { ApiError } from "../transport.js";
10
10
  import { readWorkspaceJson, WORKSPACE_LIMITS, writeWorkspaceJson } from "../workspace.js";
@@ -56,7 +56,12 @@ function translateConcurrency(error) {
56
56
  }
57
57
 
58
58
  export async function footerPush(invocation) {
59
- const { client, config, siteId, onProgress } = await openSession(invocation);
59
+ const session = await openSession(invocation);
60
+ const { client, config, siteId, onProgress } = session;
61
+ // One advisory line before this verb does any work, and only when the
62
+ // exchange said the platform is paused. It changes nothing else: the write
63
+ // still runs and its refusal still classifies as platform_paused (TR00692).
64
+ warnIfExternalWritesPaused(session, VERB_FOOTER_PUSH);
60
65
  const context = await readFooterWorkspaceContext(config.workspaceDir, siteId);
61
66
  const { document: document_, footerSettings } = validateFooterWorkspaceDocument(
62
67
  await readWorkspaceJson(config.workspaceDir, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
@@ -8,9 +8,11 @@ import {
8
8
  VERB_MEDIA_UPLOAD,
9
9
  VERB_NAV_PUSH,
10
10
  VERB_PAGES_PUSH,
11
- VERB_PULL,
12
11
  VERB_PREVIEW_PAGE,
13
12
  VERB_PREVIEW_REVOKE,
13
+ VERB_PULL,
14
+ VERB_REDIRECTS_PULL,
15
+ VERB_REDIRECTS_PUSH,
14
16
  VERB_SITES,
15
17
  VERB_STATUS,
16
18
  VERB_THEME_PUSH,
@@ -30,6 +32,8 @@ import { pagesPush } from "./pages-push.js";
30
32
  import { pull } from "./pull.js";
31
33
  import { previewPage } from "./preview-page.js";
32
34
  import { previewRevoke } from "./preview-revoke.js";
35
+ import { redirectsPull } from "./redirects-pull.js";
36
+ import { redirectsPush } from "./redirects-push.js";
33
37
  import { sites } from "./sites.js";
34
38
  import { status } from "./status.js";
35
39
  import { themePush } from "./theme-push.js";
@@ -110,6 +114,8 @@ export const VERB_HANDLERS = Object.freeze({
110
114
  [VERB_PULL]: pull,
111
115
  [VERB_PAGES_PUSH]: pagesPush,
112
116
  [VERB_NAV_PUSH]: navPush,
117
+ [VERB_REDIRECTS_PULL]: redirectsPull,
118
+ [VERB_REDIRECTS_PUSH]: redirectsPush,
113
119
  [VERB_THEME_PUSH]: themePush,
114
120
  [VERB_FOOTER_PUSH]: footerPush,
115
121
  [VERB_MEDIA_UPLOAD]: mediaUpload,