@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.
- package/README.md +81 -9
- package/examples/riverbend-wellness/manifest.fixture.json +6 -1
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/package.json +1 -1
- package/src/api.js +175 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +70 -5
- package/src/constants.js +37 -1
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +1 -0
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +122 -5
- package/src/session.js +161 -7
- package/src/transport.js +23 -5
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +7 -2
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +350 -18
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +74 -0
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
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
|
|
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
|
|
190
|
-
`pages/`, `nav.json`, `settings/`, `media/`, and
|
|
191
|
-
that carry site binding and page identity between
|
|
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
|
|
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
|
|
229
|
-
automation should branch on are `
|
|
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":
|
|
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
|
+
"version": "0.4.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",
|
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
|
|
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
|
-
|
|
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
|
+
}
|