@taprootio/site-authoring 0.6.3 → 0.7.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 +103 -10
- package/package.json +1 -1
- package/src/api.js +281 -61
- package/src/cli.js +73 -26
- package/src/constants.js +2 -1
- package/src/content/components.js +7 -33
- package/src/content/free-form-section-registry.json +4 -2
- package/src/content/inline-facts.js +6 -0
- package/src/content/tiptap-prosemirror.ts +3 -4
- package/src/content/vocabulary.js +6 -2
- package/src/errors.js +27 -0
- package/src/fixture-contract.js +4 -4
- package/src/fixture-init.js +231 -0
- package/src/output.js +12 -2
- package/src/reference-help.js +139 -89
- package/src/staging-check.js +145 -0
- package/src/theme-validation.js +31 -12
- package/src/transport.js +12 -3
- package/src/verbs/deploy.js +35 -101
- package/src/verbs/index.js +4 -1
- package/src/verbs/pull.js +22 -5
- package/src/verbs/redirects-check.js +18 -0
- package/src/verbs/status.js +34 -17
- package/src/verbs/theme-push.js +44 -9
- package/src/verbs/validate.js +51 -12
package/README.md
CHANGED
|
@@ -38,8 +38,9 @@ resolve a different contract than the CLI was tested against.
|
|
|
38
38
|
|
|
39
39
|
Taproot accepts only the latest published release of this package. There is no
|
|
40
40
|
compatibility window: the contract moves with every release, and a CLI behind
|
|
41
|
-
the current one is refused at sign-in
|
|
42
|
-
the sign-in for a site credential, before it
|
|
41
|
+
the current one is refused at sign-in, at `sites`/`use` site listing, and at
|
|
42
|
+
every online verb that exchanges the sign-in for a site credential, before it
|
|
43
|
+
validates or writes anything.
|
|
43
44
|
`TAPROOT_SITE_KEY` runs perform no sign-in and no exchange, so they are not
|
|
44
45
|
version-gated and their `status` reports `cliRelease.latestKnown: false`; keep
|
|
45
46
|
an automation's CLI current yourself.
|
|
@@ -51,8 +52,11 @@ npm install -g @taprootio/site-authoring@latest
|
|
|
51
52
|
A refusal names the field `CliUpgradeRequired`, classifies as
|
|
52
53
|
`refusal: "cli_outdated"`, and carries both versions and that command. Nothing
|
|
53
54
|
is wrong with the credential, the request, or the site, and no retry of an
|
|
54
|
-
outdated version succeeds.
|
|
55
|
-
|
|
55
|
+
outdated version succeeds. The API puts the versions and remedy in both the
|
|
56
|
+
status message and the stable field detail. Legacy 0.2.0 ignores those messages
|
|
57
|
+
and prints only `CliUpgradeRequired`; if that is all you see, run the upgrade
|
|
58
|
+
command above. If the newer release is only minutes old, npm may not serve it
|
|
59
|
+
yet — wait a moment and run the upgrade again.
|
|
56
60
|
|
|
57
61
|
Each sign-in exchange also reports the latest published release. `status` shows
|
|
58
62
|
it as `cliRelease`, `whoami` reports what the last exchange recorded (dated with
|
|
@@ -184,11 +188,11 @@ explicitly and bypasses discovery. `login`, `logout`, `sites`, `use`,
|
|
|
184
188
|
}
|
|
185
189
|
```
|
|
186
190
|
|
|
187
|
-
| Field | Rule
|
|
188
|
-
| --------------- |
|
|
189
|
-
| `configVersion` | Must be `1`.
|
|
190
|
-
| `siteId` | Optional. The canonical lowercase UUID of the site the next command writes to. `use` writes it; a present but malformed value is refused.
|
|
191
|
-
| `workspaceDir` | Required. A relative POSIX path beneath the configuration directory that `pull` writes into. Every existing segment must be a real directory.
|
|
191
|
+
| Field | Rule |
|
|
192
|
+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
193
|
+
| `configVersion` | Must be `1`. |
|
|
194
|
+
| `siteId` | Optional. The canonical lowercase UUID of the site the next command writes to. `use` writes it; a present but malformed value is refused. |
|
|
195
|
+
| `workspaceDir` | Required. A relative POSIX path beneath the configuration directory that `pull` writes into. Every existing segment must be a real directory. |
|
|
192
196
|
|
|
193
197
|
The file is a small, closed JSON object. Unknown fields, duplicate keys, links
|
|
194
198
|
in the path, and unsupported versions are refused before any credential is
|
|
@@ -265,7 +269,14 @@ for another. The handoff also expires two minutes after it is minted;
|
|
|
265
269
|
It stages; nothing reaches an audience until `deploy --staging` publishes the
|
|
266
270
|
candidate and `deploy --production` promotes that completed staging
|
|
267
271
|
deployment. `status` reports the platform authoring switch, the CLI release,
|
|
268
|
-
deployments, readiness, and
|
|
272
|
+
deployments, readiness, image processing, and broken references under Content
|
|
273
|
+
permission. `brokenReferences` names affected pages and their missing image IDs
|
|
274
|
+
or internal page paths in the latest editable bodies. It reports `covered: true`,
|
|
275
|
+
`totalPages`, and `pages`; an empty report means no missing targets were found.
|
|
276
|
+
Output is bounded to 50 pages and 50 targets of each kind per page, with explicit
|
|
277
|
+
`pagesTruncated`, `missingImageIdsTruncated`, or `missingPagePathsTruncated` flags.
|
|
278
|
+
A denied, malformed, or failed read fails `status` instead of implying a clean
|
|
279
|
+
site.
|
|
269
280
|
|
|
270
281
|
## Output contract
|
|
271
282
|
|
|
@@ -336,3 +347,85 @@ The package's tests run in both places. A few compare this package against
|
|
|
336
347
|
sources that live only in the private monorepo — the canonical renderer, the
|
|
337
348
|
shared section registry, the seeded default theme, and the API's contracts —
|
|
338
349
|
and those skip by name in the public tree rather than pretending to pass.
|
|
350
|
+
|
|
351
|
+
### Content corrections in 0.7.0
|
|
352
|
+
|
|
353
|
+
CTA buttons accept the same safe destinations as hero actions, including `tel:`,
|
|
354
|
+
`mailto:`, fragments, queries, and relative paths. New page content rejects empty
|
|
355
|
+
contact destinations and the `tel://`/`mailto://` authority forms, consistently
|
|
356
|
+
with navigation and footer validation. Previously stored page links retain their
|
|
357
|
+
rendering behavior.
|
|
358
|
+
|
|
359
|
+
Inline-fact values support LF/CRLF line breaks (including inside their link),
|
|
360
|
+
and reading order is value then label. Unicode spaces and tabs remain valid.
|
|
361
|
+
Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejected
|
|
362
|
+
with the exact fact field path. A label is optional and stays outside the value's link. See
|
|
363
|
+
`taproot-site help page free-form` and `taproot-site help component cta`.
|
|
364
|
+
|
|
365
|
+
### Staging verification and deployment evidence
|
|
366
|
+
|
|
367
|
+
`deploy --staging` selects the same changed pages, settings groups and navigation
|
|
368
|
+
as the Deployments page. `status` checks that exact current candidate, so a
|
|
369
|
+
completed CLI stage counts as successful until that candidate changes.
|
|
370
|
+
|
|
371
|
+
A retained failed preview of selected page content refuses deployment as
|
|
372
|
+
`deploy.preview_failed`, naming the page and snapshot. Render a new successful
|
|
373
|
+
preview or change the content; `--allow-failed-preview` is the explicit override.
|
|
374
|
+
Pending retries do not erase known failure. Previews remain optional. Expired
|
|
375
|
+
snapshots removed by normal cleanup and legacy snapshots without a candidate
|
|
376
|
+
fingerprint supply no gating evidence. Typed preview failure diagnostics contain
|
|
377
|
+
only error class and known page/node/attribute labels, never renderer messages.
|
|
378
|
+
|
|
379
|
+
Staging deploys check the current redirect map through the real staging edge.
|
|
380
|
+
Run `taproot-site redirects check` to repeat after eventual edge propagation.
|
|
381
|
+
The Node CLI consumes a separate handoff, retains its short staging cookie in
|
|
382
|
+
memory, and requests each map path without following redirects. Results report
|
|
383
|
+
path, HTTP status and Location; `verified` requires all entries to match and the
|
|
384
|
+
map revision to remain unchanged. A mismatch is evidence to investigate, not a
|
|
385
|
+
failed deployment. Checks describe the current map, not a historical release.
|
|
386
|
+
Requests have a 90-second deadline with eight concurrent requests; large JSON
|
|
387
|
+
lists have explicit truncation, prioritizing failures. Human output reports each
|
|
388
|
+
entry. A gate/transport failure never counts as verified.
|
|
389
|
+
|
|
390
|
+
`stagingPreview.url` is a fresh single-use two-minute handoff minted **after**
|
|
391
|
+
checks. Opening it consumes it and establishes a five-minute staging cookie.
|
|
392
|
+
Minting again replaces the same authority's previous handoff and cookie. Current
|
|
393
|
+
site, host and credential permission are rechecked; key revocation stops access.
|
|
394
|
+
Treat the URL as secret: it is returned only in final JSON, omitted from progress
|
|
395
|
+
and `GITHUB_OUTPUT`. The same Actions exclusion applies to `preview page` URLs.
|
|
396
|
+
|
|
397
|
+
Deployment results carry `phaseTimings`. New deployments retain up to 32 durable
|
|
398
|
+
server-observed phase transitions, including repeated phases on retry. Durations
|
|
399
|
+
are calculated only between recorded transitions. `truncated` marks missing
|
|
400
|
+
older history; `known: false` marks legacy rows. CLI polling never invents phase
|
|
401
|
+
timestamps. Progress reports the same recorded timestamps and durations.
|
|
402
|
+
|
|
403
|
+
### Export a pulled workspace as an offline fixture
|
|
404
|
+
|
|
405
|
+
From a pulled workspace (or its configured project), run:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
taproot-site validate --init ../authoring-fixture
|
|
409
|
+
taproot-site validate ../authoring-fixture
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The destination must be new, outside the source workspace, and its parent must exist. Use
|
|
413
|
+
`taproot-site --config path/to/taproot-site.json validate --init ../authoring-fixture`
|
|
414
|
+
to select another source configuration. Initialization needs no credential or
|
|
415
|
+
network. It exports editable free-form pages as ProseMirror with navigation,
|
|
416
|
+
redirects, settings, and version-6 appearance/footer metadata, validates the
|
|
417
|
+
result, and leaves the source unchanged. Excluded metadata/read-only pages are
|
|
418
|
+
counted; unresolved references to them fail validation.
|
|
419
|
+
|
|
420
|
+
Exported UUIDs and HTTP(S) origins are deterministic fixture values under
|
|
421
|
+
`example.test`; URL credentials, query strings and fragments are removed.
|
|
422
|
+
Configuration, credential files, internal state and deployment receipts are
|
|
423
|
+
excluded. Authored prose remains. Exports are bounded to 64 MiB, and a failed
|
|
424
|
+
validation removes the new output directory.
|
|
425
|
+
|
|
426
|
+
Required settings omissions are collected in `error.details` with field paths
|
|
427
|
+
and the `taproot-site pull` remedy. Present-but-invalid settings keep their
|
|
428
|
+
value errors. Pull reports `pages.revisionsRecordedWithoutBodyComparison` and a
|
|
429
|
+
human notice when it adopts first revisions without a completed body comparison.
|
|
430
|
+
The count excludes new downloads, established revisions and compared bodies;
|
|
431
|
+
it is not a claim that an adopted Markdown source matches the site.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,3 +1,4 @@
|
|
|
1
|
+
import { normalizeLatestCliVersion } from "./cli-release.js";
|
|
1
2
|
import {
|
|
2
3
|
CANONICAL_TIMESTAMP,
|
|
3
4
|
CAPABILITY_REFUSAL_FIELD,
|
|
@@ -15,12 +16,11 @@ import {
|
|
|
15
16
|
REFUSAL_THROTTLED,
|
|
16
17
|
REFUSAL_UNCLASSIFIED,
|
|
17
18
|
} from "./constants.js";
|
|
18
|
-
import { normalizeLatestCliVersion } from "./cli-release.js";
|
|
19
19
|
// The display-prefix shape lives with the store because acceptance on claim
|
|
20
20
|
// must equal acceptance on save — a prefix admitted here but refused there
|
|
21
21
|
// would turn a successful mint into an orphaned key.
|
|
22
22
|
import { KEY_PREFIX } from "./credentials.js";
|
|
23
|
-
import { isCanonicalUuid, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
23
|
+
import { isCanonicalUuid, normalizePreviewDiagnostic, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_REDIRECT_STATUS,
|
|
26
26
|
GONE_STATUS,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
REDIRECT_ORIGIN_AUTHORED,
|
|
30
30
|
REDIRECT_ORIGIN_PATH_HISTORY,
|
|
31
31
|
} from "./redirects-contract.js";
|
|
32
|
+
import { CANDIDATE_SELECTABLE_SETTINGS_TYPES } from "./settings-catalog.js";
|
|
32
33
|
import { ApiError, isWellFormedCredential } from "./transport.js";
|
|
33
34
|
// The revision's shape lives with the manifest that records it: a value this
|
|
34
35
|
// module admitted but the registry then dropped would be recorded on a push
|
|
@@ -158,7 +159,10 @@ const AUTHORING_PREVIEW_FIELDS = Object.freeze({
|
|
|
158
159
|
manifest: "AuthoringPreviewManifest",
|
|
159
160
|
});
|
|
160
161
|
const AUTHORING_PREVIEW_FIELD_ERRORS = Object.freeze({
|
|
161
|
-
[AUTHORING_PREVIEW_FIELDS.draft]: [
|
|
162
|
+
[AUTHORING_PREVIEW_FIELDS.draft]: [
|
|
163
|
+
"preview.no_draft",
|
|
164
|
+
"The requested page does not have a persisted draft to preview.",
|
|
165
|
+
],
|
|
162
166
|
[AUTHORING_PREVIEW_FIELDS.staging]: [
|
|
163
167
|
"preview.staging_unavailable",
|
|
164
168
|
"The site does not have an available staging hostname for authoring previews.",
|
|
@@ -172,7 +176,7 @@ const AUTHORING_PREVIEW_FIELD_ERRORS = Object.freeze({
|
|
|
172
176
|
[AUTHORING_PREVIEW_FIELDS.siteCapacity]: [
|
|
173
177
|
"preview.site_capacity",
|
|
174
178
|
"This site already has the maximum number of queued or rendering authoring previews. "
|
|
175
|
-
|
|
179
|
+
+ "Wait for one to finish rendering, or revoke a preview that is still queued or rendering.",
|
|
176
180
|
],
|
|
177
181
|
[AUTHORING_PREVIEW_FIELDS.authorityCapacity]: [
|
|
178
182
|
"preview.authority_capacity",
|
|
@@ -184,7 +188,8 @@ const AUTHORING_PREVIEW_FIELD_ERRORS = Object.freeze({
|
|
|
184
188
|
],
|
|
185
189
|
});
|
|
186
190
|
const CANONICAL_SHA256 = /^sha256:[0-9a-f]{64}$/u;
|
|
187
|
-
const CANONICAL_HOST =
|
|
191
|
+
const CANONICAL_HOST =
|
|
192
|
+
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
188
193
|
const CANONICAL_HANDOFF = /^[A-Za-z0-9_-]{43}$/u;
|
|
189
194
|
const AUTHORING_PREVIEW_PATH_PREFIX = "/_taproot/preview/pages";
|
|
190
195
|
const AUTHORING_PREVIEW_LIFETIME_MILLISECONDS = 60 * 60_000;
|
|
@@ -342,8 +347,7 @@ const REFUSAL_GUIDANCE = Object.freeze({
|
|
|
342
347
|
[REFUSAL_CREDENTIAL_REJECTED]:
|
|
343
348
|
"The site authoring credential was rejected: it is invalid, revoked, or bound to a different site. "
|
|
344
349
|
+ "Stop and re-issue it; retrying cannot succeed.",
|
|
345
|
-
[REFUSAL_THROTTLED]:
|
|
346
|
-
"This credential exceeded its request budget. Back off and retry with delay.",
|
|
350
|
+
[REFUSAL_THROTTLED]: "This credential exceeded its request budget. Back off and retry with delay.",
|
|
347
351
|
});
|
|
348
352
|
|
|
349
353
|
/**
|
|
@@ -375,9 +379,9 @@ export function announceRefusal(error, onProgress, action = "request") {
|
|
|
375
379
|
// ApiError message stays the fallback for a server that sent no
|
|
376
380
|
// description.
|
|
377
381
|
const serverDescription = error.descriptionFor?.(CLI_UPGRADE_REFUSAL_FIELD);
|
|
378
|
-
onProgress(
|
|
379
|
-
sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")
|
|
380
|
-
|
|
382
|
+
onProgress(
|
|
383
|
+
` Taproot's own detail: ${sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")}`,
|
|
384
|
+
);
|
|
381
385
|
onProgress("");
|
|
382
386
|
return;
|
|
383
387
|
}
|
|
@@ -420,9 +424,9 @@ export function announceRefusal(error, onProgress, action = "request") {
|
|
|
420
424
|
// verbatim (sanitized) rather than paraphrasing policy the CLI does not
|
|
421
425
|
// own. The bounded ApiError message remains as the fallback.
|
|
422
426
|
const serverDescription = error.descriptionFor?.("UpgradePrompt");
|
|
423
|
-
onProgress(
|
|
424
|
-
sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")
|
|
425
|
-
|
|
427
|
+
onProgress(
|
|
428
|
+
` Taproot's own detail: ${sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")}`,
|
|
429
|
+
);
|
|
426
430
|
onProgress("");
|
|
427
431
|
return;
|
|
428
432
|
}
|
|
@@ -619,7 +623,10 @@ function requireRedirectMap(value, siteId) {
|
|
|
619
623
|
{ field: "siteId" },
|
|
620
624
|
);
|
|
621
625
|
}
|
|
622
|
-
|
|
626
|
+
if (response.entries !== undefined && !Array.isArray(response.entries)) {
|
|
627
|
+
throw new SiteAuthoringError("api.redirects_contract", "Taproot returned an invalid redirect entries list.");
|
|
628
|
+
}
|
|
629
|
+
const entries = response.entries ?? [];
|
|
623
630
|
return {
|
|
624
631
|
revision: response.revision,
|
|
625
632
|
entries: entries.map((entry) => normalizeRedirectMapEntry(entry)),
|
|
@@ -770,13 +777,84 @@ export async function listSiteImages(client, siteId, requestOptions = {}) {
|
|
|
770
777
|
return { images, summary, truncated: true };
|
|
771
778
|
}
|
|
772
779
|
|
|
780
|
+
/** Broken targets in the latest editable page bodies, authorized by Content. */
|
|
781
|
+
export async function getSiteBrokenReferences(client, siteId) {
|
|
782
|
+
const code = "api.broken_references_contract";
|
|
783
|
+
const response = requireObject(
|
|
784
|
+
await client.request(sitePath(siteId, "broken-references")),
|
|
785
|
+
code,
|
|
786
|
+
"broken-reference report",
|
|
787
|
+
);
|
|
788
|
+
// Proto3 omits empty repeated fields. A present non-array is a bad report,
|
|
789
|
+
// not evidence that the site has no broken links.
|
|
790
|
+
const repeated = (value, field) => {
|
|
791
|
+
if (value === undefined) return [];
|
|
792
|
+
if (!Array.isArray(value)) {
|
|
793
|
+
throw new SiteAuthoringError(code, `Taproot returned an invalid ${field}.`, { field });
|
|
794
|
+
}
|
|
795
|
+
return value;
|
|
796
|
+
};
|
|
797
|
+
return repeated(response.pages, "pages").map((entry) => {
|
|
798
|
+
const page = requireObject(entry, code, "broken-reference page");
|
|
799
|
+
const missingPagePaths = repeated(page.missingPagePaths, "missingPagePaths");
|
|
800
|
+
if (missingPagePaths.some((path) => typeof path !== "string")) {
|
|
801
|
+
throw new SiteAuthoringError(code, "Taproot returned an invalid missing page path.", {
|
|
802
|
+
field: "missingPagePaths",
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
pageId: requireCanonicalUuid(page.pageId, code, "pageId"),
|
|
807
|
+
pageTitle: text(page.pageTitle),
|
|
808
|
+
missingImageIds: repeated(page.missingImageIds, "missingImageIds")
|
|
809
|
+
.map((id) => requireCanonicalUuid(id, code, "missingImageIds")),
|
|
810
|
+
missingPagePaths,
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Use the same changed release set the Deployments page selects. */
|
|
816
|
+
export async function getDeploySelection(client, siteId) {
|
|
817
|
+
const code = "api.deploy_review_contract";
|
|
818
|
+
const review = requireObject(await client.request(sitePath(siteId, "deploy/review")), code, "deploy review");
|
|
819
|
+
const repeated = (value, field) => {
|
|
820
|
+
if (value === undefined) return [];
|
|
821
|
+
if (!Array.isArray(value)) throw new SiteAuthoringError(code, `Taproot returned an invalid ${field}.`, { field });
|
|
822
|
+
return value;
|
|
823
|
+
};
|
|
824
|
+
const stagedPageIds = repeated(review.stagedPages, "stagedPages").map((page) =>
|
|
825
|
+
requireCanonicalUuid(page?.pageId, code, "stagedPages.pageId")
|
|
826
|
+
);
|
|
827
|
+
const changed = repeated(review.settingsChanges, "settingsChanges").filter((group) => {
|
|
828
|
+
requireObject(group, code, "settings change");
|
|
829
|
+
if (!CANDIDATE_SELECTABLE_SETTINGS_TYPES.includes(group.settingsType)) {
|
|
830
|
+
throw new SiteAuthoringError(code, "Taproot returned an unsupported candidate settings group.");
|
|
831
|
+
}
|
|
832
|
+
return repeated(group.changes, "settings changes").length > 0;
|
|
833
|
+
});
|
|
834
|
+
if (review.navigationChanged !== undefined && typeof review.navigationChanged !== "boolean") {
|
|
835
|
+
throw new SiteAuthoringError(code, "Taproot returned an invalid navigation change flag.");
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
stagedPageIds: [...new Set(stagedPageIds)],
|
|
839
|
+
selectedSettingsTypes: CANDIDATE_SELECTABLE_SETTINGS_TYPES.filter((type) =>
|
|
840
|
+
changed.some((group) => group.settingsType === type)
|
|
841
|
+
),
|
|
842
|
+
includeNavigation: review.navigationChanged === true,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
773
846
|
export async function getPublishingReadiness(client, siteId, selection = {}) {
|
|
774
847
|
const response = requireObject(
|
|
775
|
-
await client.request(sitePath(
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
848
|
+
await client.request(sitePath(
|
|
849
|
+
siteId,
|
|
850
|
+
`publishing/readiness${
|
|
851
|
+
query([
|
|
852
|
+
["stagedPageIds", selection.stagedPageIds],
|
|
853
|
+
["selectedSettingsTypes", selection.selectedSettingsTypes],
|
|
854
|
+
["includeNavigation", selection.includeNavigation],
|
|
855
|
+
])
|
|
856
|
+
}`,
|
|
857
|
+
)),
|
|
780
858
|
"api.readiness_contract",
|
|
781
859
|
"publishing readiness",
|
|
782
860
|
);
|
|
@@ -840,6 +918,79 @@ export async function getStagingPreviewStatus(client, siteId) {
|
|
|
840
918
|
return { ready: true, stagingUrl: `https://${url.hostname}/` };
|
|
841
919
|
}
|
|
842
920
|
|
|
921
|
+
/** Mint a site-bound handoff; its URL is emitted only in final operational JSON. */
|
|
922
|
+
export async function mintStagingPreviewHandoff(client, siteId, { now = Date.now } = {}) {
|
|
923
|
+
const code = "api.staging_handoff_contract";
|
|
924
|
+
const result = requireObject(
|
|
925
|
+
await client.request(sitePath(siteId, "staging-preview:mint-handoff"), {
|
|
926
|
+
method: "POST",
|
|
927
|
+
body: { siteId },
|
|
928
|
+
}),
|
|
929
|
+
code,
|
|
930
|
+
"staging handoff",
|
|
931
|
+
);
|
|
932
|
+
let origin;
|
|
933
|
+
let url;
|
|
934
|
+
try {
|
|
935
|
+
origin = new URL(result.stagingUrl);
|
|
936
|
+
url = new URL(result.url);
|
|
937
|
+
} catch {
|
|
938
|
+
throw new SiteAuthoringError(code, "Taproot returned an invalid staging handoff URL.");
|
|
939
|
+
}
|
|
940
|
+
const expires = requireCanonicalTimestamp(result.handoffExpiresAt, code, "handoffExpiresAt");
|
|
941
|
+
const current = now();
|
|
942
|
+
if (
|
|
943
|
+
result.siteId !== siteId || origin.protocol !== "https:" || !CANONICAL_HOST.test(origin.hostname)
|
|
944
|
+
|| origin.port || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash
|
|
945
|
+
|| url.origin !== origin.origin || url.username || url.password || url.pathname !== "/" || url.hash
|
|
946
|
+
|| [...url.searchParams].length !== 1
|
|
947
|
+
|| !/^[A-Za-z0-9_-]{43}$/u.test(url.searchParams.get("__taproot_preview_handoff") ?? "")
|
|
948
|
+
|| expires.milliseconds <= current - AUTHORING_PREVIEW_CLOCK_SKEW_MILLISECONDS
|
|
949
|
+
|| expires.milliseconds > current + AUTHORING_PREVIEW_HANDOFF_LIFETIME_MILLISECONDS
|
|
950
|
+
+ AUTHORING_PREVIEW_CLOCK_SKEW_MILLISECONDS
|
|
951
|
+
) {
|
|
952
|
+
throw new SiteAuthoringError(
|
|
953
|
+
code,
|
|
954
|
+
"Taproot returned a staging handoff outside its site, host or lifetime contract.",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
return { stagingUrl: origin.href, url: url.href, handoffExpiresAt: expires.value };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function normalizePhaseTimings(history) {
|
|
961
|
+
if (history === undefined || history === null) return { known: false };
|
|
962
|
+
const code = "api.deployment_phase_contract";
|
|
963
|
+
requireObject(history, code, "deployment phase history");
|
|
964
|
+
if (
|
|
965
|
+
!Array.isArray(history.phases) || history.phases.length === 0 || history.phases.length > 32
|
|
966
|
+
|| (history.truncated !== undefined && typeof history.truncated !== "boolean")
|
|
967
|
+
) {
|
|
968
|
+
throw new SiteAuthoringError(code, "Taproot returned an invalid deployment phase history.");
|
|
969
|
+
}
|
|
970
|
+
const phases = history.phases.map((phase) => {
|
|
971
|
+
requireObject(phase, code, "deployment phase");
|
|
972
|
+
return {
|
|
973
|
+
status: enumValue(phase.status, DEPLOYMENT_STATUSES, DEPLOYMENT_STATUS_QUEUED, code, "phase status"),
|
|
974
|
+
at: requireCanonicalTimestamp(phase.enteredAt, code, "phase.enteredAt"),
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
return {
|
|
978
|
+
known: true,
|
|
979
|
+
truncated: history.truncated === true,
|
|
980
|
+
phases: phases.map((phase, index) => {
|
|
981
|
+
const next = phases[index + 1];
|
|
982
|
+
if (next && (next.at.milliseconds < phase.at.milliseconds || next.status === phase.status)) {
|
|
983
|
+
throw new SiteAuthoringError(code, "Taproot returned unordered or duplicate deployment phase evidence.");
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
status: phase.status,
|
|
987
|
+
enteredAt: phase.at.value,
|
|
988
|
+
...(next ? { endedAt: next.at.value, durationMilliseconds: next.at.milliseconds - phase.at.milliseconds } : {}),
|
|
989
|
+
};
|
|
990
|
+
}),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
843
994
|
export function normalizeDeployment(value) {
|
|
844
995
|
const deployment = requireObject(value, "api.deployment_contract", "deployment");
|
|
845
996
|
return {
|
|
@@ -861,6 +1012,7 @@ export function normalizeDeployment(value) {
|
|
|
861
1012
|
completedAt: text(deployment.completedAt),
|
|
862
1013
|
errorMessage: text(deployment.errorMessage),
|
|
863
1014
|
pageCount: safeCount(deployment.pageCount),
|
|
1015
|
+
phaseTimings: normalizePhaseTimings(deployment.phaseHistory),
|
|
864
1016
|
};
|
|
865
1017
|
}
|
|
866
1018
|
|
|
@@ -878,11 +1030,16 @@ export async function listDeployments(
|
|
|
878
1030
|
) {
|
|
879
1031
|
const response = requireObject(
|
|
880
1032
|
await client.request(
|
|
881
|
-
sitePath(
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1033
|
+
sitePath(
|
|
1034
|
+
siteId,
|
|
1035
|
+
`deployments${
|
|
1036
|
+
query([
|
|
1037
|
+
["pageSize", pageSize],
|
|
1038
|
+
["includeStaging", true],
|
|
1039
|
+
["environment", environment],
|
|
1040
|
+
])
|
|
1041
|
+
}`,
|
|
1042
|
+
),
|
|
886
1043
|
requestOptions,
|
|
887
1044
|
),
|
|
888
1045
|
"api.deployment_contract",
|
|
@@ -895,11 +1052,24 @@ export async function listDeployments(
|
|
|
895
1052
|
}
|
|
896
1053
|
|
|
897
1054
|
export async function deploySite(client, siteId, body) {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1055
|
+
let response;
|
|
1056
|
+
try {
|
|
1057
|
+
response = requireObject(
|
|
1058
|
+
await client.request(sitePath(siteId, "deploy"), { method: "POST", body }),
|
|
1059
|
+
"api.deployment_contract",
|
|
1060
|
+
"deploy",
|
|
1061
|
+
);
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
if (error instanceof ApiError && error.hasField("AuthoringPreviewFailed")) {
|
|
1064
|
+
throw new SiteAuthoringError(
|
|
1065
|
+
"deploy.preview_failed",
|
|
1066
|
+
(error.descriptionFor("AuthoringPreviewFailed") ?? "A retained preview of a selected page failed.")
|
|
1067
|
+
+ " Use --allow-failed-preview only to explicitly override this refusal.",
|
|
1068
|
+
{ field: "AuthoringPreviewFailed", status: error.status },
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
throw error;
|
|
1072
|
+
}
|
|
903
1073
|
return normalizeDeployment(response.deployment);
|
|
904
1074
|
}
|
|
905
1075
|
|
|
@@ -962,6 +1132,7 @@ export async function poll({
|
|
|
962
1132
|
|
|
963
1133
|
export async function waitForDeployment(client, { siteId, deploymentId, environment, onProgress, now }) {
|
|
964
1134
|
let misses = 0;
|
|
1135
|
+
let lastEvidence;
|
|
965
1136
|
return await poll({
|
|
966
1137
|
client,
|
|
967
1138
|
now,
|
|
@@ -983,6 +1154,23 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
|
|
|
983
1154
|
return { done: false, progress: "Waiting for the deployment to appear in the deployment log." };
|
|
984
1155
|
}
|
|
985
1156
|
misses = 0;
|
|
1157
|
+
const evidence = JSON.stringify(deployment.phaseTimings);
|
|
1158
|
+
if (evidence !== lastEvidence) {
|
|
1159
|
+
lastEvidence = evidence;
|
|
1160
|
+
if (deployment.phaseTimings.known) {
|
|
1161
|
+
for (const phase of deployment.phaseTimings.phases) {
|
|
1162
|
+
onProgress(
|
|
1163
|
+
`Deployment phase ${phase.status} entered ${phase.enteredAt}`
|
|
1164
|
+
+ (phase.durationMilliseconds === undefined
|
|
1165
|
+
? "."
|
|
1166
|
+
: `; ${phase.durationMilliseconds}ms until ${phase.endedAt}.`),
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
if (deployment.phaseTimings.truncated) {
|
|
1170
|
+
onProgress("Earlier deployment phase evidence was truncated by the server.");
|
|
1171
|
+
}
|
|
1172
|
+
} else onProgress("Deployment phase timings are unknown for this legacy deployment.");
|
|
1173
|
+
}
|
|
986
1174
|
if (deployment.status === DEPLOYMENT_STATUS_COMPLETED) return { done: true, value: deployment };
|
|
987
1175
|
if (deployment.status === DEPLOYMENT_STATUS_FAILED) {
|
|
988
1176
|
throw new SiteAuthoringError(
|
|
@@ -1098,7 +1286,14 @@ export function normalizeAuthoringPreview(value, expected = {}) {
|
|
|
1098
1286
|
draftRevision: preview.draftRevision,
|
|
1099
1287
|
failureCode,
|
|
1100
1288
|
stagingHost: requireCanonicalStagingHost(preview.stagingHost),
|
|
1289
|
+
failureDiagnostic: normalizePreviewDiagnostic(preview.failureDiagnostic),
|
|
1101
1290
|
};
|
|
1291
|
+
if (
|
|
1292
|
+
preview.failureDiagnostic !== undefined && preview.failureDiagnostic !== null
|
|
1293
|
+
&& (!normalized.failureDiagnostic || normalized.failureDiagnostic.pageId !== normalized.pageId)
|
|
1294
|
+
) {
|
|
1295
|
+
throw new SiteAuthoringError("preview.status_contract", "Taproot returned an invalid preview failure diagnostic.");
|
|
1296
|
+
}
|
|
1102
1297
|
for (const field of ["siteId", "pageId", "snapshotId"]) {
|
|
1103
1298
|
if (expected[field] !== undefined && normalized[field] !== expected[field]) {
|
|
1104
1299
|
throw new SiteAuthoringError(
|
|
@@ -1191,20 +1386,26 @@ export async function createAuthoringPreview(client, siteId, pageId) {
|
|
|
1191
1386
|
|
|
1192
1387
|
export async function getAuthoringPreview(client, expected, requestOptions = {}) {
|
|
1193
1388
|
return normalizeAuthoringPreview(
|
|
1194
|
-
await client.request(
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1389
|
+
await client.request(
|
|
1390
|
+
sitePath(
|
|
1391
|
+
expected.siteId,
|
|
1392
|
+
`authoring-previews/pages/${encodeURIComponent(expected.pageId)}/${encodeURIComponent(expected.snapshotId)}`,
|
|
1393
|
+
),
|
|
1394
|
+
requestOptions,
|
|
1395
|
+
),
|
|
1198
1396
|
expected,
|
|
1199
1397
|
);
|
|
1200
1398
|
}
|
|
1201
1399
|
|
|
1202
1400
|
export async function revokeAuthoringPreview(client, expected) {
|
|
1203
1401
|
return normalizeAuthoringPreview(
|
|
1204
|
-
await client.request(
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1402
|
+
await client.request(
|
|
1403
|
+
sitePath(
|
|
1404
|
+
expected.siteId,
|
|
1405
|
+
`authoring-previews/pages/${encodeURIComponent(expected.pageId)}/${encodeURIComponent(expected.snapshotId)}`,
|
|
1406
|
+
),
|
|
1407
|
+
{ method: "DELETE" },
|
|
1408
|
+
),
|
|
1208
1409
|
expected,
|
|
1209
1410
|
);
|
|
1210
1411
|
}
|
|
@@ -1228,9 +1429,19 @@ function assertAuthoringPreviewState(preview, now) {
|
|
|
1228
1429
|
{ status: preview.status },
|
|
1229
1430
|
);
|
|
1230
1431
|
}
|
|
1231
|
-
throw new SiteAuthoringError(
|
|
1232
|
-
|
|
1233
|
-
|
|
1432
|
+
throw new SiteAuthoringError(
|
|
1433
|
+
"preview.render_failed",
|
|
1434
|
+
"Taproot could not render the authoring preview."
|
|
1435
|
+
+ (preview.failureDiagnostic
|
|
1436
|
+
? ` Class ${preview.failureDiagnostic.errorClass}; page ${preview.pageId}`
|
|
1437
|
+
+ (preview.failureDiagnostic.nodeType ? `; node ${preview.failureDiagnostic.nodeType}` : "")
|
|
1438
|
+
+ (preview.failureDiagnostic.attribute ? `; attribute ${preview.failureDiagnostic.attribute}` : "") + "."
|
|
1439
|
+
: ""),
|
|
1440
|
+
{
|
|
1441
|
+
status: preview.status,
|
|
1442
|
+
previewDiagnostic: preview.failureDiagnostic,
|
|
1443
|
+
},
|
|
1444
|
+
);
|
|
1234
1445
|
}
|
|
1235
1446
|
}
|
|
1236
1447
|
|
|
@@ -1257,17 +1468,18 @@ export async function waitForAuthoringPreview(client, { created, onProgress, now
|
|
|
1257
1468
|
return { done: false, progress: `Waiting for the authoring preview (${preview.status}).` };
|
|
1258
1469
|
},
|
|
1259
1470
|
timeoutCode: "preview.timeout",
|
|
1260
|
-
timeoutError: () =>
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1471
|
+
timeoutError: () =>
|
|
1472
|
+
lastStatus === AUTHORING_PREVIEW_STATUS_QUEUED
|
|
1473
|
+
? new SiteAuthoringError(
|
|
1474
|
+
"preview.timeout",
|
|
1475
|
+
`The render service has not claimed this job before the bounded deadline. Revoke it with: taproot-site preview revoke ${created.pageId} ${created.snapshotId}`,
|
|
1476
|
+
{ status: lastStatus },
|
|
1477
|
+
)
|
|
1478
|
+
: new SiteAuthoringError(
|
|
1479
|
+
"preview.timeout",
|
|
1480
|
+
`The render service claimed this job but did not finish before the bounded deadline. Revoke it with: taproot-site preview revoke ${created.pageId} ${created.snapshotId}`,
|
|
1481
|
+
{ status: lastStatus },
|
|
1482
|
+
),
|
|
1271
1483
|
});
|
|
1272
1484
|
}
|
|
1273
1485
|
|
|
@@ -1397,14 +1609,19 @@ export function normalizeAuthoringPreviewHandoff(value, ready, now) {
|
|
|
1397
1609
|
export async function mintAuthoringPreviewHandoff(client, ready, { now }) {
|
|
1398
1610
|
let response;
|
|
1399
1611
|
try {
|
|
1400
|
-
response = await client.replaceablePost(
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1612
|
+
response = await client.replaceablePost(
|
|
1613
|
+
sitePath(
|
|
1614
|
+
ready.siteId,
|
|
1615
|
+
`authoring-previews/pages/${encodeURIComponent(ready.pageId)}/${
|
|
1616
|
+
encodeURIComponent(ready.snapshotId)
|
|
1617
|
+
}:mint-handoff`,
|
|
1618
|
+
),
|
|
1619
|
+
{
|
|
1620
|
+
body: { siteId: ready.siteId, pageId: ready.pageId, snapshotId: ready.snapshotId },
|
|
1621
|
+
deadline: ready.expiresAtMilliseconds,
|
|
1622
|
+
now,
|
|
1623
|
+
},
|
|
1624
|
+
);
|
|
1408
1625
|
} catch (error) {
|
|
1409
1626
|
if (error instanceof SiteAuthoringError && error.code === "transport.deadline") {
|
|
1410
1627
|
throw new SiteAuthoringError("preview.expired", "The authoring preview expired before a handoff was minted.");
|
|
@@ -1531,7 +1748,7 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
|
|
|
1531
1748
|
*/
|
|
1532
1749
|
export async function listAuthorableSites(client, requestOptions = {}) {
|
|
1533
1750
|
const response = requireObject(
|
|
1534
|
-
await client.request(AUTHORABLE_SITES_PATH, requestOptions),
|
|
1751
|
+
await client.request(`${AUTHORABLE_SITES_PATH}${query([["cliVersion", CLI_VERSION]])}`, requestOptions),
|
|
1535
1752
|
"sites.contract",
|
|
1536
1753
|
"authorable site list",
|
|
1537
1754
|
);
|
|
@@ -1584,7 +1801,10 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
|
|
|
1584
1801
|
// The sign-in's refreshed deadline. Not a secret, and absent on a server
|
|
1585
1802
|
// that predates the sliding window.
|
|
1586
1803
|
...(typeof response.signInExpiresAt === "string" && response.signInExpiresAt.length > 0
|
|
1587
|
-
? {
|
|
1804
|
+
? {
|
|
1805
|
+
signInExpiresAt:
|
|
1806
|
+
requireCanonicalTimestamp(response.signInExpiresAt, "exchange.contract", "signInExpiresAt").value,
|
|
1807
|
+
}
|
|
1588
1808
|
: {}),
|
|
1589
1809
|
// Whether the platform is accepting external authoring writes at all
|
|
1590
1810
|
// (TR00692). Read strictly: only a real boolean is a state, so a server
|