@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 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 and at every online verb that exchanges
42
- the sign-in for a site credential, before it validates or writes anything.
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. If the newer release is only minutes old, npm may
55
- not serve it yet — wait a moment and run the upgrade again.
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 image processing.
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.6.3",
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]: ["preview.no_draft", "The requested page does not have a persisted draft to preview."],
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
- + "Wait for one to finish rendering, or revoke a preview that is still queued or rendering.",
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 = /^(?=.{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;
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(` Taproot's own detail: ${
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(` Taproot's own detail: ${
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
- const entries = Array.isArray(response.entries) ? response.entries : [];
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(siteId, `publishing/readiness${query([
776
- ["stagedPageIds", selection.stagedPageIds],
777
- ["selectedSettingsTypes", selection.selectedSettingsTypes],
778
- ["includeNavigation", selection.includeNavigation],
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(siteId, `deployments${query([
882
- ["pageSize", pageSize],
883
- ["includeStaging", true],
884
- ["environment", environment],
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
- const response = requireObject(
899
- await client.request(sitePath(siteId, "deploy"), { method: "POST", body }),
900
- "api.deployment_contract",
901
- "deploy",
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(sitePath(
1195
- expected.siteId,
1196
- `authoring-previews/pages/${encodeURIComponent(expected.pageId)}/${encodeURIComponent(expected.snapshotId)}`,
1197
- ), requestOptions),
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(sitePath(
1205
- expected.siteId,
1206
- `authoring-previews/pages/${encodeURIComponent(expected.pageId)}/${encodeURIComponent(expected.snapshotId)}`,
1207
- ), { method: "DELETE" }),
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("preview.render_failed", "Taproot could not render the authoring preview.", {
1232
- status: preview.status,
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: () => lastStatus === AUTHORING_PREVIEW_STATUS_QUEUED
1261
- ? new SiteAuthoringError(
1262
- "preview.timeout",
1263
- `The render service has not claimed this job before the bounded deadline. Revoke it with: taproot-site preview revoke ${created.pageId} ${created.snapshotId}`,
1264
- { status: lastStatus },
1265
- )
1266
- : new SiteAuthoringError(
1267
- "preview.timeout",
1268
- `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}`,
1269
- { status: lastStatus },
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(sitePath(
1401
- ready.siteId,
1402
- `authoring-previews/pages/${encodeURIComponent(ready.pageId)}/${encodeURIComponent(ready.snapshotId)}:mint-handoff`,
1403
- ), {
1404
- body: { siteId: ready.siteId, pageId: ready.pageId, snapshotId: ready.snapshotId },
1405
- deadline: ready.expiresAtMilliseconds,
1406
- now,
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
- ? { signInExpiresAt: requireCanonicalTimestamp(response.signInExpiresAt, "exchange.contract", "signInExpiresAt").value }
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