@taprootio/site-authoring 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -34,6 +34,43 @@ Node 22 or later. The package has one runtime dependency,
34
34
  against one Espalier theme contract, and a floating range would let an install
35
35
  resolve a different contract than the CLI was tested against.
36
36
 
37
+ ## Keeping the CLI current
38
+
39
+ Taproot accepts only the latest published release of this package. There is no
40
+ compatibility window: the contract moves with every release, and a CLI behind
41
+ the current one is refused at sign-in and at every online verb that exchanges
42
+ the sign-in for a site credential, before it validates or writes anything.
43
+ `TAPROOT_SITE_KEY` runs perform no sign-in and no exchange, so they are not
44
+ version-gated and their `status` reports `cliRelease.latestKnown: false`; keep
45
+ an automation's CLI current yourself.
46
+
47
+ ```bash
48
+ npm install -g @taprootio/site-authoring@latest
49
+ ```
50
+
51
+ A refusal names the field `CliUpgradeRequired`, classifies as
52
+ `refusal: "cli_outdated"`, and carries both versions and that command. Nothing
53
+ is wrong with the credential, the request, or the site, and no retry of an
54
+ outdated version succeeds. If the newer release is only minutes old, npm may
55
+ not serve it yet — wait a moment and run the upgrade again.
56
+
57
+ Each sign-in exchange also reports the latest published release. `status` shows
58
+ it as `cliRelease`, `whoami` reports what the last exchange recorded (dated with
59
+ that exchange), and once a newer release has been recorded the offline verbs —
60
+ `help`, `validate`, and `whoami` — refuse with the same message rather than
61
+ letting an agent keep authoring against a contract that has moved. With nothing
62
+ recorded they run, so a clean machine can still validate a fixture.
63
+ `taproot-site --version` and `--help` always answer.
64
+
65
+ Do not pin this package's version in a script. Every release retires the one
66
+ before it, so a pin is a scheduled outage.
67
+
68
+ **0.4.0 introduces this gate.** From the API deploy that ships with it, every
69
+ CLI older than the release that API was built with is refused with
70
+ `CliUpgradeRequired`. The release order is: merge to private `main`, then the
71
+ npm publish of 0.4.0, then the API deploy — publishing first is what keeps the
72
+ upgrade available to anyone the deploy refuses.
73
+
37
74
  ## First commands
38
75
 
39
76
  `taproot-site --help` lists every verb and the configuration contract.
@@ -50,7 +87,8 @@ taproot-site help component hero-section --json
50
87
 
51
88
  `taproot-site validate <fixture-directory>` checks a complete offline fixture
52
89
  with no credential, no network, and no write: a directory laid out like a
53
- pulled workspace — `pages/`, `nav.json`, and the four `settings/` documents —
90
+ pulled workspace — `pages/`, `nav.json`, `redirects.json`, and the four
91
+ `settings/` documents —
54
92
  whose `manifest.fixture.json` binds deterministic page, resource, image, and
55
93
  settings identities under a versioned `fixture` block. It proves structure,
56
94
  page content and theme contexts, navigation references, the complete theme,
@@ -186,9 +224,12 @@ taproot-site deploy --staging
186
224
  taproot-site deploy --production
187
225
  ```
188
226
 
189
- `pull` snapshots pages, navigation, and settings into the workspace:
190
- `pages/`, `nav.json`, `settings/`, `media/`, and two dot-prefixed manifests
191
- that carry site binding and page identity between commands. Every page path
227
+ `pull` snapshots pages, navigation, redirects, and settings into the
228
+ workspace: `pages/`, `nav.json`, `redirects.json`, `settings/`, `media/`, and
229
+ two dot-prefixed manifests that carry site binding and page identity between
230
+ commands. Against a Taproot that does not serve the redirect map yet, `pull`
231
+ writes no `redirects.json` and records no redirect baseline, and
232
+ `redirects push` asks for a pull once the site has one. Every page path
192
233
  has exactly one authoritative source — `pages/<name>.md` in the documented
193
234
  Markdown subset with `title`, `path`, and `description` front matter, or
194
235
  `pages/<name>.pm.json` as a ProseMirror document — and pull never writes a
@@ -199,11 +240,18 @@ edited on the site since the last pull is a `pages.pull_conflict` refusal
199
240
  before anything under `pages/` changes, with both recovery paths named.
200
241
 
201
242
  `pages push [page-path...]` validates and sends the selected pages, or every
202
- workspace page when none is named; the homepage is addressed as `/`.
243
+ workspace page when none is named; the homepage is addressed as `/`. It fails
244
+ closed with `pages.push_conflict` when a page's stored-state revision has
245
+ moved since this workspace last reconciled with it, naming both revisions;
246
+ `pull` first, or push after a refused pull has shown you the site's version.
203
247
  `media upload [path...]` uploads raster files and records component-ready
204
248
  delivery fields. `nav push`, `theme push`, and `footer push` replace the whole
205
249
  navigation tree, the complete light/dark theme pair with its appearance
206
- settings, and the closed footer document.
250
+ settings, and the closed footer document. `redirects pull` writes the site's
251
+ redirect map to `redirects.json` with a baseline, and `redirects push`
252
+ validates the file offline and replaces the whole map; `help redirects`
253
+ states the entry shape (`path`, `kind` redirect or gone, `target`, `status`
254
+ defaulting to 301), the normalization rule, and every refusal.
207
255
 
208
256
  `preview page <page-path-or-id>` renders one persisted draft behind the
209
257
  staging gate and mints a browser handoff URL, reported only in the final JSON
@@ -216,7 +264,8 @@ for another. The handoff also expires two minutes after it is minted;
216
264
  `approve [page-path...]` publishes drafts into the approved candidate pool.
217
265
  It stages; nothing reaches an audience until `deploy --staging` publishes the
218
266
  candidate and `deploy --production` promotes that completed staging
219
- deployment. `status` reports deployments, readiness, and image processing.
267
+ deployment. `status` reports the platform authoring switch, the CLI release,
268
+ deployments, readiness, and image processing.
220
269
 
221
270
  ## Output contract
222
271
 
@@ -225,8 +274,10 @@ whose approval code and URL exist only as progress). Stdout carries exactly
225
274
  one JSON object per run, schema version `1`: `ok`, the CLI name and version,
226
275
  the verb, and the verb's own result on success; a stable `error.code` with an
227
276
  optional `field`, `status`, and classified `refusal` on failure. Exit codes are
228
- `0` success, `1` failure, and `2` usage fault. The five refusal classes an
229
- automation should branch on are `platform_paused` (external authoring is
277
+ `0` success, `1` failure, and `2` usage fault. The six refusal classes an
278
+ automation should branch on are `cli_outdated` (this package is behind the
279
+ latest published release, which is the only one Taproot accepts; upgrade it,
280
+ and see "Keeping the CLI current"), `platform_paused` (external authoring is
230
281
  paused; retry later), `credential_rejected` (stop and re-issue),
231
282
  `capability_missing` (the credential is valid and correctly scoped but was
232
283
  not exchanged for a capability the request needs; the refusal carries
@@ -242,6 +293,27 @@ Authorization values, signed upload headers, preview handoffs outside the
242
293
  final result, and page bodies never appear in progress, diagnostics, JSON, or
243
294
  Actions output.
244
295
 
296
+ ### Every write answers `platform_paused`
297
+
298
+ `pull` and `status` succeed, and every write is refused with
299
+ `refusal: "platform_paused"` and `field: "SiteAuthoringRollout"`. Nothing is
300
+ wrong with the credential, the request, or the site: Taproot has external site
301
+ authoring switched off platform-wide. A Taproot administrator turns it back on
302
+ with the platform setting `site_authoring.external_writes_enabled`, under
303
+ Admin → Platform settings → Site Authoring ("External site authoring writes
304
+ enabled"); nobody else can, and no amount of retrying or re-issuing changes it.
305
+
306
+ You do not have to discover this from a refusal. Each sign-in exchange reports
307
+ the switch's state, so `status` names it as `platform.externalWritesEnabled`
308
+ and every write verb prints one warning before it does any work when the last
309
+ exchange said the platform was paused. The warning is advisory and never a
310
+ gate — the switch is enforced inside each write's own transaction, so a run
311
+ that starts while it is off still succeeds if an operator turns it on
312
+ meanwhile. `whoami` stays offline and reports what the last exchange recorded,
313
+ dated with that exchange; `status` is where a current answer comes from.
314
+ `TAPROOT_SITE_KEY` runs perform no exchange, so their `status` reports
315
+ `platform.externalWritesKnown: false` rather than guessing.
316
+
245
317
  ## Public release
246
318
 
247
319
  Private Taproot is the source authority. Merging a version bump to private
@@ -1,5 +1,5 @@
1
1
  {
2
- "manifestVersion": 5,
2
+ "manifestVersion": 6,
3
3
  "siteId": "a0000000-0000-4000-8000-000000000001",
4
4
  "pulledAt": "2026-09-04T00:00:00.000Z",
5
5
  "pages": [
@@ -37,6 +37,11 @@
37
37
  "file": "nav.json",
38
38
  "items": 3
39
39
  },
40
+ "redirects": {
41
+ "file": "redirects.json",
42
+ "revision": "0000000000000000000000000000000000000000000000000000000000000001",
43
+ "entries": 4
44
+ },
40
45
  "settings": [
41
46
  {
42
47
  "settingsType": "SETTING_TYPE_TAPROOT_STYLES",
@@ -0,0 +1,33 @@
1
+ {
2
+ "siteId": "a0000000-0000-4000-8000-000000000001",
3
+ "revision": "0000000000000000000000000000000000000000000000000000000000000001",
4
+ "entries": [
5
+ {
6
+ "path": "/classes.html",
7
+ "kind": "redirect",
8
+ "target": "/visit",
9
+ "status": 301,
10
+ "origin": "authored"
11
+ },
12
+ {
13
+ "path": "/schedule",
14
+ "kind": "redirect",
15
+ "target": "https://booking.example.test/riverbend",
16
+ "status": 302,
17
+ "origin": "authored"
18
+ },
19
+ {
20
+ "path": "/summer-2019-retreat.html",
21
+ "kind": "gone",
22
+ "status": 410,
23
+ "origin": "authored"
24
+ },
25
+ {
26
+ "path": "/welcome.html",
27
+ "kind": "redirect",
28
+ "target": "/",
29
+ "status": 301,
30
+ "origin": "path_history"
31
+ }
32
+ ]
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@taprootio/espalier": "4.7.0"
31
+ "@taprootio/espalier": "4.8.0"
32
32
  },
33
33
  "scripts": {
34
34
  "test": "node --test",
package/src/api.js CHANGED
@@ -1,20 +1,48 @@
1
1
  import {
2
2
  CANONICAL_TIMESTAMP,
3
3
  CAPABILITY_REFUSAL_FIELD,
4
+ CLI_UPGRADE_COMMAND,
5
+ CLI_UPGRADE_REFUSAL_FIELD,
6
+ CLI_VERSION,
7
+ EXTERNAL_WRITES_SETTING_KEY,
8
+ EXTERNAL_WRITES_SETTING_LOCATION,
4
9
  LIMITS,
5
10
  REFUSAL_CAPABILITY_MISSING,
11
+ REFUSAL_CLI_OUTDATED,
6
12
  REFUSAL_CREDENTIAL_REJECTED,
7
13
  REFUSAL_PLAN_LIMIT,
8
14
  REFUSAL_PLATFORM_PAUSED,
9
15
  REFUSAL_THROTTLED,
10
16
  REFUSAL_UNCLASSIFIED,
11
17
  } from "./constants.js";
18
+ import { normalizeLatestCliVersion } from "./cli-release.js";
12
19
  // The display-prefix shape lives with the store because acceptance on claim
13
20
  // must equal acceptance on save — a prefix admitted here but refused there
14
21
  // would turn a successful mint into an orphaned key.
15
22
  import { KEY_PREFIX } from "./credentials.js";
16
23
  import { isCanonicalUuid, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
24
+ import {
25
+ DEFAULT_REDIRECT_STATUS,
26
+ GONE_STATUS,
27
+ REDIRECT_KIND_GONE,
28
+ REDIRECT_KIND_REDIRECT,
29
+ REDIRECT_ORIGIN_AUTHORED,
30
+ REDIRECT_ORIGIN_PATH_HISTORY,
31
+ } from "./redirects-contract.js";
17
32
  import { ApiError, isWellFormedCredential } from "./transport.js";
33
+ // The revision's shape lives with the manifest that records it: a value this
34
+ // module admitted but the registry then dropped would be recorded on a push
35
+ // and unreadable on the next pull, which reads as "never reconciled" forever.
36
+ import { normalizePageBodyRevision } from "./workspace.js";
37
+
38
+ /**
39
+ * The proto enum spellings the redirect map travels as. The CLI's own
40
+ * vocabulary is the lowercase word the workspace file uses; these are the wire
41
+ * identities it is translated from, kept beside the translation.
42
+ */
43
+ const REDIRECT_KIND_REDIRECT_WIRE = "SITE_REDIRECT_KIND_REDIRECT";
44
+ const REDIRECT_KIND_GONE_WIRE = "SITE_REDIRECT_KIND_GONE";
45
+ const REDIRECT_ORIGIN_AUTHORED_WIRE = "SITE_REDIRECT_ORIGIN_AUTHORED";
18
46
 
19
47
  /**
20
48
  * The wire vocabulary and the read helpers every verb shares.
@@ -309,7 +337,8 @@ export function sitePath(siteId, suffix) {
309
337
  const REFUSAL_GUIDANCE = Object.freeze({
310
338
  [REFUSAL_PLATFORM_PAUSED]:
311
339
  "Taproot has paused external site authoring platform-wide. The credential and the request are both fine; "
312
- + "retry later or ask the operator.",
340
+ + "retry later, or ask a Taproot administrator to re-enable the platform setting "
341
+ + `'${EXTERNAL_WRITES_SETTING_KEY}' (${EXTERNAL_WRITES_SETTING_LOCATION}).`,
313
342
  [REFUSAL_CREDENTIAL_REJECTED]:
314
343
  "The site authoring credential was rejected: it is invalid, revoked, or bound to a different site. "
315
344
  + "Stop and re-issue it; retrying cannot succeed.",
@@ -331,6 +360,27 @@ const REFUSAL_GUIDANCE = Object.freeze({
331
360
  export function announceRefusal(error, onProgress, action = "request") {
332
361
  if (!(error instanceof ApiError)) return;
333
362
  const kind = error.refusalKind();
363
+ if (kind === REFUSAL_CLI_OUTDATED) {
364
+ onProgress("");
365
+ onProgress(`CLI UPGRADE REQUIRED (refusal=${REFUSAL_CLI_OUTDATED}, field=${CLI_UPGRADE_REFUSAL_FIELD})`);
366
+ onProgress(
367
+ ` Taproot supports only the latest published release of this package, and refused this ${action} because `
368
+ + `this CLI (${CLI_VERSION}) is behind it. Nothing about the credential, the request, or the site is wrong, `
369
+ + "and no retry of this version can succeed.",
370
+ );
371
+ onProgress(` Upgrade with: ${CLI_UPGRADE_COMMAND}`);
372
+ // The server names both versions and says when waiting is the right move
373
+ // (a deploy that landed ahead of its npm publish). Surface it verbatim
374
+ // rather than restating a policy this side does not own; the bounded
375
+ // ApiError message stays the fallback for a server that sent no
376
+ // description.
377
+ const serverDescription = error.descriptionFor?.(CLI_UPGRADE_REFUSAL_FIELD);
378
+ onProgress(` Taproot's own detail: ${
379
+ sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")
380
+ }`);
381
+ onProgress("");
382
+ return;
383
+ }
334
384
  if (kind === REFUSAL_CAPABILITY_MISSING) {
335
385
  const { permission, granted, required } = error.capability;
336
386
  onProgress("");
@@ -407,6 +457,12 @@ export function normalizePageSummary(value) {
407
457
  status: enumValue(summary.status, PAGE_STATUSES, PAGE_STATUS_UNKNOWN, "api.page_status", "page status"),
408
458
  hasDraft: summary.hasDraft === true,
409
459
  isGenerated: summary.isGenerated === true,
460
+ // Absent against a Taproot that predates the revision contract, and absent
461
+ // on the listings that never resolve stored body state. Both mean the same
462
+ // thing to a caller — "this read cannot say what version you are looking
463
+ // at" — and both are carried as `undefined` rather than as a sentinel that
464
+ // would compare unequal to every recorded baseline and refuse every push.
465
+ bodyRevision: normalizePageBodyRevision(summary.bodyRevision),
410
466
  };
411
467
  }
412
468
 
@@ -501,6 +557,98 @@ export async function saveNavigation(client, siteId, navItems) {
501
557
  return Array.isArray(response.navItems) ? response.navItems : [];
502
558
  }
503
559
 
560
+ /**
561
+ * Reads the site's whole redirect map with the revision a replace must carry
562
+ * (TR00702).
563
+ */
564
+ export async function getSiteRedirectMap(client, siteId) {
565
+ return requireRedirectMap(
566
+ await client.request(sitePath(siteId, "redirects"), {
567
+ maximumResponseBytes: LIMITS.redirectMapResponseBytes,
568
+ }),
569
+ siteId,
570
+ );
571
+ }
572
+
573
+ /**
574
+ * Replaces the whole map. `expectedRevision` is the revision the map was read
575
+ * at; the site refuses the write rather than dropping an entry a rename
576
+ * recorded since.
577
+ */
578
+ export async function replaceSiteRedirectMap(client, siteId, expectedRevision, entries) {
579
+ return requireRedirectMap(
580
+ await client.request(sitePath(siteId, "redirects"), {
581
+ method: "PUT",
582
+ body: { siteId, expectedRevision, entries: entries.map(toWireRedirectEntry) },
583
+ // The reply is the whole replaced map, as large as the read.
584
+ maximumResponseBytes: LIMITS.redirectMapResponseBytes,
585
+ }),
586
+ siteId,
587
+ );
588
+ }
589
+
590
+ /**
591
+ * The wire shape of one entry. `origin` is deliberately not sent: it is what
592
+ * the site decided about a stored row, and echoing a pulled value back would
593
+ * be asking to relabel a rename-recorded entry as one this workspace authored.
594
+ */
595
+ function toWireRedirectEntry(entry) {
596
+ return entry.kind === REDIRECT_KIND_GONE
597
+ ? { path: entry.path, kind: REDIRECT_KIND_GONE_WIRE, status: GONE_STATUS }
598
+ : {
599
+ path: entry.path,
600
+ kind: REDIRECT_KIND_REDIRECT_WIRE,
601
+ target: entry.target,
602
+ status: entry.status,
603
+ };
604
+ }
605
+
606
+ function requireRedirectMap(value, siteId) {
607
+ const response = requireObject(value, "api.redirects_contract", "site redirects");
608
+ if (typeof response.revision !== "string" || response.revision === "") {
609
+ throw new SiteAuthoringError(
610
+ "api.redirects_contract",
611
+ "Taproot returned a redirect map with no revision, so a later push could not be fenced against it.",
612
+ { field: "revision" },
613
+ );
614
+ }
615
+ if (typeof response.siteId === "string" && response.siteId !== "" && response.siteId !== siteId) {
616
+ throw new SiteAuthoringError(
617
+ "api.redirects_contract",
618
+ "Taproot returned a redirect map for a different site.",
619
+ { field: "siteId" },
620
+ );
621
+ }
622
+ const entries = Array.isArray(response.entries) ? response.entries : [];
623
+ return {
624
+ revision: response.revision,
625
+ entries: entries.map((entry) => normalizeRedirectMapEntry(entry)),
626
+ };
627
+ }
628
+
629
+ /**
630
+ * Reads one entry off the wire. Transcoding omits proto default values, so an
631
+ * absent `kind`, `status`, or `origin` is the enum's zero — which the contract
632
+ * reads as a redirect, the default status, and path history respectively.
633
+ */
634
+ function normalizeRedirectMapEntry(entry) {
635
+ const source = requireObject(entry, "api.redirects_contract", "site redirect entry");
636
+ const kind = source.kind === REDIRECT_KIND_GONE_WIRE ? REDIRECT_KIND_GONE : REDIRECT_KIND_REDIRECT;
637
+ return {
638
+ path: typeof source.path === "string" ? source.path : "",
639
+ kind,
640
+ target: kind === REDIRECT_KIND_GONE || typeof source.target !== "string" ? "" : source.target,
641
+ status: Number.isSafeInteger(source.status) && source.status > 0
642
+ ? source.status
643
+ : kind === REDIRECT_KIND_GONE
644
+ ? GONE_STATUS
645
+ : DEFAULT_REDIRECT_STATUS,
646
+ origin: source.origin === REDIRECT_ORIGIN_AUTHORED_WIRE
647
+ ? REDIRECT_ORIGIN_AUTHORED
648
+ : REDIRECT_ORIGIN_PATH_HISTORY,
649
+ };
650
+ }
651
+
504
652
  /**
505
653
  * Reads one *draft* settings group for the site. Same rule as navigation:
506
654
  * `GetSettings` calls `RequireEnvironment`, so the zero value is refused rather
@@ -1326,7 +1474,14 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
1326
1474
  const response = requireObject(
1327
1475
  // No site: TR00645 made the account-level sign-in the front door, so the
1328
1476
  // CLI no longer has to know a site id before it can authorize at all.
1329
- await client.request(CLI_AUTHORIZATION_PATH, { ...requestOptions, method: "POST", body: { keyName } }),
1477
+ // `cliVersion` is sent on both calls every online verb passes through
1478
+ // (TR00703). Sign-in is the earlier of the two, so an outdated CLI is told
1479
+ // to upgrade before an owner is asked to approve anything.
1480
+ await client.request(CLI_AUTHORIZATION_PATH, {
1481
+ ...requestOptions,
1482
+ method: "POST",
1483
+ body: { keyName, cliVersion: CLI_VERSION },
1484
+ }),
1330
1485
  "login.start_contract",
1331
1486
  "CLI authorization",
1332
1487
  );
@@ -1404,7 +1559,7 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
1404
1559
  await client.request(TOKEN_EXCHANGE_PATH, {
1405
1560
  ...requestOptions,
1406
1561
  method: "POST",
1407
- body: { siteId, capabilities },
1562
+ body: { siteId, capabilities, cliVersion: CLI_VERSION },
1408
1563
  }),
1409
1564
  "exchange.contract",
1410
1565
  "token exchange",
@@ -1431,6 +1586,23 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
1431
1586
  ...(typeof response.signInExpiresAt === "string" && response.signInExpiresAt.length > 0
1432
1587
  ? { signInExpiresAt: requireCanonicalTimestamp(response.signInExpiresAt, "exchange.contract", "signInExpiresAt").value }
1433
1588
  : {}),
1589
+ // Whether the platform is accepting external authoring writes at all
1590
+ // (TR00692). Read strictly: only a real boolean is a state, so a server
1591
+ // that predates the field leaves this undefined rather than reading as
1592
+ // paused and warning before every write against a healthy platform.
1593
+ ...(typeof response.externalWritesEnabled === "boolean"
1594
+ ? { externalWritesEnabled: response.externalWritesEnabled }
1595
+ : {}),
1596
+ // The latest published release Taproot accepts (TR00703). This exchange
1597
+ // already succeeded, so it is never news about *this* request — it is what
1598
+ // lets the offline verbs refuse later. Read strictly for the same reason
1599
+ // the switch above is: only a value this side can compare is kept, so a
1600
+ // server that predates the field, or one that answers something
1601
+ // unparseable, leaves the local gate silent rather than refusing work it
1602
+ // cannot justify.
1603
+ ...(normalizeLatestCliVersion(response.latestCliVersion) === undefined
1604
+ ? {}
1605
+ : { latestCliVersion: response.latestCliVersion }),
1434
1606
  };
1435
1607
  }
1436
1608
 
@@ -0,0 +1,152 @@
1
+ import {
2
+ CLI_UPGRADE_COMMAND,
3
+ CLI_UPGRADE_REFUSAL_FIELD,
4
+ CLI_VERSION,
5
+ REFUSAL_CLI_OUTDATED,
6
+ } from "./constants.js";
7
+ import { SiteAuthoringError } from "./errors.js";
8
+
9
+ /**
10
+ * Whether this CLI is still the release Taproot accepts (TR00703).
11
+ *
12
+ * **The server is the authority, and this is its mirror.** Taproot refuses a
13
+ * CLI that is behind the latest published release at sign-in and at token
14
+ * exchange, so every online verb already gets a definitive answer without any
15
+ * of this. What this file is for is the case where there is no request to be
16
+ * refused: `help`, `validate`, and `whoami` run entirely offline, and an agent
17
+ * that keeps authoring against a contract that has moved will happily spend a
18
+ * whole session validating documents the site can no longer accept.
19
+ *
20
+ * So the exchange returns the latest version, the CLI records it beside the
21
+ * sign-in, and the offline verbs refuse against that recording. A recording is
22
+ * weaker evidence than a refusal, and the rules here follow from that: nothing
23
+ * recorded means proceed (a clean machine can still validate a fixture), an
24
+ * unreadable or malformed recording means proceed, and only a value this side
25
+ * can actually parse and compare is allowed to stop anything.
26
+ */
27
+
28
+ // Exactly the shape `release/trunk/public-repo/scripts/npm-release-guard.mjs`
29
+ // admits to npm, and exactly the shape the API's `SiteAuthoringCliRelease`
30
+ // parses: three numeric components with no leading zeroes, plus optional
31
+ // dot-separated prerelease identifiers. Build metadata is deliberately absent —
32
+ // no version this project publishes carries any.
33
+ const SEMANTIC_VERSION =
34
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
35
+
36
+ // A version string is a handful of characters. Anything longer is not one, and
37
+ // bounding it before the regex keeps a hostile response from spending time here.
38
+ const MAXIMUM_VERSION_LENGTH = 64;
39
+
40
+ const NUMERIC_IDENTIFIER = /^\d+$/u;
41
+
42
+ /**
43
+ * Parses a version into what precedence needs, or returns `undefined`. A
44
+ * leading zero on a numeric prerelease identifier is invalid, exactly as the
45
+ * release guard treats it.
46
+ */
47
+ export function parseCliVersion(value) {
48
+ if (typeof value !== "string" || value.length === 0 || value.length > MAXIMUM_VERSION_LENGTH) {
49
+ return undefined;
50
+ }
51
+ const match = SEMANTIC_VERSION.exec(value);
52
+ if (!match) return undefined;
53
+ const prerelease = match[4] === undefined ? [] : match[4].split(".");
54
+ if (prerelease.some((part) => NUMERIC_IDENTIFIER.test(part) && part.length > 1 && part.startsWith("0"))) {
55
+ return undefined;
56
+ }
57
+ return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease };
58
+ }
59
+
60
+ function comparePrereleaseIdentifier(left, right) {
61
+ if (left === right) return 0;
62
+ const leftNumeric = NUMERIC_IDENTIFIER.test(left);
63
+ const rightNumeric = NUMERIC_IDENTIFIER.test(right);
64
+ if (leftNumeric && rightNumeric) {
65
+ // Digit-only with no leading zero, so length then lexicographic order is
66
+ // numeric order at any magnitude — the same rule the API applies, where a
67
+ // fixed-width parse would lose precision or overflow.
68
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
69
+ return left < right ? -1 : 1;
70
+ }
71
+ // A numeric identifier always has lower precedence than an alphanumeric one.
72
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
73
+ return left < right ? -1 : 1;
74
+ }
75
+
76
+ /**
77
+ * Semantic-version precedence, or `undefined` when either side is unparseable.
78
+ * Not string order: `0.10.0` is above `0.9.0`, and a prerelease is below the
79
+ * release it leads to, so `0.4.0-rc.1` is behind `0.4.0`.
80
+ */
81
+ export function compareCliVersions(leftVersion, rightVersion) {
82
+ const left = parseCliVersion(leftVersion);
83
+ const right = parseCliVersion(rightVersion);
84
+ if (left === undefined || right === undefined) return undefined;
85
+ for (let index = 0; index < left.core.length; index += 1) {
86
+ if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index] ? -1 : 1;
87
+ }
88
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
89
+ if (left.prerelease.length === right.prerelease.length) return 0;
90
+ return left.prerelease.length === 0 ? 1 : -1;
91
+ }
92
+ const shared = Math.min(left.prerelease.length, right.prerelease.length);
93
+ for (let index = 0; index < shared; index += 1) {
94
+ const comparison = comparePrereleaseIdentifier(left.prerelease[index], right.prerelease[index]);
95
+ if (comparison !== 0) return comparison;
96
+ }
97
+ if (left.prerelease.length === right.prerelease.length) return 0;
98
+ return left.prerelease.length < right.prerelease.length ? -1 : 1;
99
+ }
100
+
101
+ /**
102
+ * A latest-version value from the wire, kept only if this side can compare it.
103
+ *
104
+ * Read strictly and dropped silently on anything else. The value drives an
105
+ * advisory local gate, so a server that predates the field, or one that sends
106
+ * something this parser does not understand, must leave the CLI working rather
107
+ * than refusing offline work it cannot justify.
108
+ */
109
+ export function normalizeLatestCliVersion(value) {
110
+ return parseCliVersion(value) === undefined ? undefined : value;
111
+ }
112
+
113
+ /**
114
+ * Whether {@link CLI_VERSION} is strictly behind a recorded latest. Anything
115
+ * unparseable on either side is not behind: an answer this side cannot compute
116
+ * is not evidence.
117
+ */
118
+ export function isBehindLatest(latestVersion, currentVersion = CLI_VERSION) {
119
+ const comparison = compareCliVersions(currentVersion, latestVersion);
120
+ return comparison !== undefined && comparison < 0;
121
+ }
122
+
123
+ /**
124
+ * The offline refusal.
125
+ *
126
+ * It classifies as `cli_outdated` and carries the server's own field name
127
+ * rather than CLI-local labels, because an agent branching on `refusal` or
128
+ * `field` should not have to know whether the answer came from Taproot or from
129
+ * the recording of Taproot's last answer — the remedy is identical. The `code`
130
+ * is what says it was raised here: `cli.outdated` rather than
131
+ * `api.request_rejected`.
132
+ */
133
+ class CliOutdatedError extends SiteAuthoringError {
134
+ refusalKind() {
135
+ return REFUSAL_CLI_OUTDATED;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The offline refusal, worded to match what the server says on the wire so an
141
+ * operator meeting it either way reads the same instruction.
142
+ */
143
+ export function cliOutdatedError(latestVersion) {
144
+ return new CliOutdatedError(
145
+ "cli.outdated",
146
+ `This CLI is version ${CLI_VERSION}, and the last Taproot sign-in exchange reported ${latestVersion} as the `
147
+ + "latest published release. Taproot accepts only the latest, so every online verb is refused until this "
148
+ + `package is upgraded. Upgrade with: ${CLI_UPGRADE_COMMAND}. If ${latestVersion} was published in the last `
149
+ + "few minutes, npm may not serve it yet — wait a moment and retry the upgrade.",
150
+ { field: CLI_UPGRADE_REFUSAL_FIELD },
151
+ );
152
+ }