@taprootio/site-authoring 0.8.4 → 0.8.6

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.
@@ -1,4 +1,12 @@
1
- import { encodeTheme, LIGHTNESS_KEYS, SEMANTIC_COLOR_NAMES, validateThemePair } from "@taprootio/espalier/shared/theme";
1
+ import {
2
+ DEFAULT_DARK_THEME,
3
+ DEFAULT_LIGHT_THEME,
4
+ encodeTheme,
5
+ LIGHTNESS_KEYS,
6
+ mergeTheme,
7
+ SEMANTIC_COLOR_NAMES,
8
+ validateThemePair,
9
+ } from "@taprootio/espalier/shared/theme";
2
10
 
3
11
  import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
4
12
 
@@ -286,6 +294,53 @@ function refuseAgentStylesheets(theme, scheme) {
286
294
  }
287
295
  }
288
296
 
297
+ const SCHEME_DEFAULTS = Object.freeze({ light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME });
298
+
299
+ function sameMapping(left, right) {
300
+ return isPlainObject(left) && isPlainObject(right)
301
+ && left.source === right.source && left.lightness === right.lightness;
302
+ }
303
+
304
+ /**
305
+ * Roles compile the semantic mappings at render time, and any mapping present
306
+ * in `semanticMappings` pins its token over that compilation (TR00801). A pin
307
+ * that merely repeats Espalier's default on a token the declared roles would
308
+ * have moved is the one an author never meant: it came from a copied example
309
+ * or a pre-projection pull, and it silently keeps the role from reaching the
310
+ * token — the accepted-but-gray link WTFM saw. Which tokens the roles move is
311
+ * asked of Espalier itself, by compiling the same roles with no pins at all;
312
+ * a token the roles leave at its default, or one the marker names as a
313
+ * deliberate pin, is never reported. It is a warning rather than a refusal
314
+ * because the pair still renders exactly as declared; Espalier validation
315
+ * owns the invalid cases.
316
+ */
317
+ export function shadowedRoleMappingWarnings(theme, scheme) {
318
+ if (!isPlainObject(theme) || !isPlainObject(theme.roles) || Object.keys(theme.roles).length === 0) return [];
319
+ if (!isPlainObject(theme.semanticMappings)) return [];
320
+ const defaults = SCHEME_DEFAULTS[scheme];
321
+ let compiled;
322
+ try {
323
+ compiled = mergeTheme(defaults, { ...theme, semanticMappings: {}, explicitMappingTokens: [] }).semanticMappings;
324
+ } catch {
325
+ // An invalid theme is Espalier validation's finding, not this one's.
326
+ return [];
327
+ }
328
+ const deliberate = new Set(Array.isArray(theme.explicitMappingTokens) ? theme.explicitMappingTokens : []);
329
+ const warnings = [];
330
+ for (const [token, mapping] of Object.entries(theme.semanticMappings)) {
331
+ if (deliberate.has(token)) continue;
332
+ const fallback = defaults.semanticMappings[token];
333
+ if (sameMapping(mapping, fallback) && !sameMapping(compiled[token], fallback)) {
334
+ warnings.push(
335
+ `${scheme}: semanticMappings.${token} repeats the Espalier default and pins the token, so the declared roles `
336
+ + `never reach it; remove the pin to let the roles compile ${token}, or list it in explicitMappingTokens `
337
+ + `if the default is the intent.`,
338
+ );
339
+ }
340
+ }
341
+ return warnings;
342
+ }
343
+
289
344
  export function validateAndEncodeThemePair(lightTheme, darkTheme) {
290
345
  requireCompleteTheme(lightTheme, "light");
291
346
  requireCompleteTheme(darkTheme, "dark");
@@ -317,7 +372,12 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
317
372
  }
318
373
  refuseAgentStylesheets(lightTheme, "light");
319
374
  refuseAgentStylesheets(darkTheme, "dark");
320
- const warnings = result.warnings
375
+ const allWarnings = [
376
+ ...result.warnings,
377
+ ...shadowedRoleMappingWarnings(lightTheme, "light"),
378
+ ...shadowedRoleMappingWarnings(darkTheme, "dark"),
379
+ ];
380
+ const warnings = allWarnings
321
381
  .slice(0, MAXIMUM_THEME_WARNINGS)
322
382
  .map((warning) =>
323
383
  [...sanitizeDiagnostic(warning, "Theme validation warning.")]
@@ -328,8 +388,8 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
328
388
  light,
329
389
  dark,
330
390
  warnings,
331
- warningCount: result.warnings.length,
332
- warningsTruncated: result.warnings.length > warnings.length,
391
+ warningCount: allWarnings.length,
392
+ warningsTruncated: allWarnings.length > warnings.length,
333
393
  };
334
394
  }
335
395
 
package/src/transport.js CHANGED
@@ -396,8 +396,21 @@ export function capabilityRefusal(value) {
396
396
  return undefined;
397
397
  }
398
398
 
399
+ /**
400
+ * Reads the `retry-after` response header as a non-negative integer number of
401
+ * seconds. `headers` is a Fetch `Headers` instance when available; anything
402
+ * else, a missing header, or a value that does not parse as a non-negative
403
+ * number, returns undefined rather than throwing.
404
+ */
405
+ function parseRetryAfterHeader(headers) {
406
+ const raw = typeof headers?.get === "function" ? headers.get("retry-after") : undefined;
407
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
408
+ const seconds = Number(raw);
409
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
410
+ }
411
+
399
412
  export class ApiError extends SiteAuthoringError {
400
- constructor(httpStatus, body) {
413
+ constructor(httpStatus, body, headers) {
401
414
  const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
402
415
  const fields = fieldViolations(body);
403
416
  const capability = capabilityRefusal(body);
@@ -433,6 +446,11 @@ export class ApiError extends SiteAuthoringError {
433
446
  this.fields = fields;
434
447
  this.violationDescriptions = fieldDescriptions(body);
435
448
  this.capability = capability;
449
+ // The `retry-after` trailer (TR00839's deployment throttle, or any other
450
+ // ResourceExhausted refusal that sets it) transcodes to an ordinary
451
+ // response header. Undefined when absent or unparseable — callers must
452
+ // not assume every throttle carries a delay.
453
+ this.retryAfterSeconds = parseRetryAfterHeader(headers);
436
454
  }
437
455
 
438
456
  hasField(field) {
@@ -767,7 +785,7 @@ export class SiteApiClient {
767
785
  responseBody = Object.create(null);
768
786
  }
769
787
  if (remainingBudget(deadline, now) <= 0) throw deadlineError();
770
- throw new ApiError(response.status, responseBody);
788
+ throw new ApiError(response.status, responseBody, response.headers);
771
789
  }
772
790
  throw new SiteAuthoringError("transport.retry_exhausted", "The Taproot API request exhausted its retry budget.");
773
791
  }
@@ -0,0 +1,185 @@
1
+ import {
2
+ DEPLOYMENT_ENVIRONMENT_PRODUCTION,
3
+ DEPLOYMENT_ENVIRONMENT_STAGING,
4
+ DEPLOYMENT_STATUS_COMPLETED,
5
+ getStagingPreviewStatus,
6
+ listDeployments,
7
+ mintStagingPreviewHandoff,
8
+ withRefusalGuidance,
9
+ } from "../api.js";
10
+ import { DEPLOY_TARGET_PRODUCTION, DEPLOY_TARGET_STAGING, VERB_DELIVERY_CHECK } from "../constants.js";
11
+ import { probeDeliveryWithBrowser } from "../delivery-browser.js";
12
+ import { checkDelivery, DELIVERY_LIMITS } from "../delivery-check.js";
13
+ import { SiteAuthoringError } from "../errors.js";
14
+ import { openSession, successResult } from "../session.js";
15
+ import { authorizeStagingHandoff } from "../staging-check.js";
16
+ import { readManifest } from "../workspace.js";
17
+
18
+ /**
19
+ * `delivery check (--staging | --production)` — verify what a visitor
20
+ * receives after a deployment completed (TR00824).
21
+ *
22
+ * The deployment log says the job finished. This verb reads the target the
23
+ * way a visitor does and reports three dimensions separately: the completed
24
+ * deployment (from the site's deployment log and the workspace record), HTTP
25
+ * delivery of the authored routes, their assets and the declared runtime, and
26
+ * — only when a browser is available — what a fresh and a returning browser
27
+ * actually loaded. Nothing is purged, republished, rolled back or mutated.
28
+ */
29
+
30
+ function usageError(code, message, field) {
31
+ return new SiteAuthoringError(code, message, { field, exitCode: 2 });
32
+ }
33
+
34
+ function environmentFor(target) {
35
+ return target === DEPLOY_TARGET_STAGING ? DEPLOYMENT_ENVIRONMENT_STAGING : DEPLOYMENT_ENVIRONMENT_PRODUCTION;
36
+ }
37
+
38
+ async function resolveTarget(client, siteId, target, invocation, onProgress, now) {
39
+ if (typeof invocation.deliveryUrl === "string" && invocation.deliveryUrl !== "") {
40
+ if (target === DEPLOY_TARGET_STAGING) {
41
+ // The staging edge serves only an authorized preview session bound to
42
+ // the acknowledged host, so another origin could not be checked as
43
+ // staging; refusing is truer than an unauthenticated probe.
44
+ throw usageError(
45
+ "delivery.url_not_for_staging",
46
+ "--url applies to --production; --staging always checks the site's acknowledged staging host with an "
47
+ + "authorized preview session.",
48
+ "url",
49
+ );
50
+ }
51
+ return { baseUrl: invocation.deliveryUrl, source: "option" };
52
+ }
53
+ if (target === DEPLOY_TARGET_STAGING) {
54
+ const status = await getStagingPreviewStatus(client, siteId);
55
+ if (!status.ready) {
56
+ throw new SiteAuthoringError(
57
+ "delivery.staging_unavailable",
58
+ "The site has no acknowledged staging host yet, so there is nothing a visitor could receive. Wait for the "
59
+ + "staging hostname to become ready (taproot-site status), then run delivery check again.",
60
+ { field: "stagingUrl" },
61
+ );
62
+ }
63
+ // The staging edge serves nothing without the preview cookie; the
64
+ // handoff is consumed here, once, and the cookie never leaves this run.
65
+ onProgress("Authorizing a staging preview session for the check.");
66
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
67
+ const cookie = await authorizeStagingHandoff(client, handoff, client.timeoutSignal(90_000));
68
+ // The cookie is bound to the host the handoff was minted for; if the
69
+ // acknowledged host moved between the status read and the mint, that
70
+ // host — not the earlier status — is the only origin the cookie may reach.
71
+ if (handoff.stagingUrl !== status.stagingUrl) {
72
+ onProgress(`The acknowledged staging host changed to ${handoff.stagingUrl} while the check started; checking that host.`);
73
+ }
74
+ return { baseUrl: handoff.stagingUrl, source: "staging-status", cookie };
75
+ }
76
+ throw usageError(
77
+ "delivery.production_url_required",
78
+ "delivery check --production needs the site's public origin: pass --url https://<published-domain>/ using the "
79
+ + "primaryDomain that 'taproot-site sites' reports. A site-authoring key cannot read hosting configuration, so "
80
+ + "the CLI does not guess it.",
81
+ "url",
82
+ );
83
+ }
84
+
85
+ export async function deliveryCheck(invocation) {
86
+ const target = invocation.deployTarget;
87
+ if (target !== DEPLOY_TARGET_STAGING && target !== DEPLOY_TARGET_PRODUCTION) {
88
+ throw usageError("delivery.target_missing", "delivery check requires exactly one of --staging or --production.", "deployTarget");
89
+ }
90
+ const session = await openSession(invocation);
91
+ const { client, config, siteId, now, onProgress } = session;
92
+ const environment = environmentFor(target);
93
+ const manifest = await readManifest(config.workspaceDir, siteId, { required: false });
94
+ const recorded = manifest?.deployments?.[target];
95
+
96
+ return await withRefusalGuidance(onProgress, "delivery check", async () => {
97
+ onProgress("Reading the deployment log.");
98
+ const { deployments } = await listDeployments(client, siteId, { environment });
99
+ const completed = deployments.find((deployment) => deployment.status === DEPLOYMENT_STATUS_COMPLETED);
100
+ if (completed === undefined) {
101
+ throw new SiteAuthoringError(
102
+ "delivery.no_completed_deployment",
103
+ `No completed ${target} deployment exists for this site, so there is no delivery to verify. Run `
104
+ + `'taproot-site deploy --${target}' first.`,
105
+ { field: "deploymentId" },
106
+ );
107
+ }
108
+ const deployment = {
109
+ id: completed.id,
110
+ completedAt: completed.completedAt,
111
+ recordedInWorkspace: typeof recorded?.id === "string" ? recorded.id === completed.id : undefined,
112
+ ...(typeof recorded?.id === "string" && recorded.id !== completed.id ? { workspaceRecordedId: recorded.id } : {}),
113
+ };
114
+ if (deployment.recordedInWorkspace === false) {
115
+ onProgress(
116
+ `Note: the workspace recorded ${target} deployment ${recorded.id}, but the latest completed ${target} deployment is ${completed.id}; the latest one is what visitors receive and is what this check verifies.`,
117
+ );
118
+ }
119
+
120
+ const resolved = await resolveTarget(client, siteId, target, invocation, onProgress, now);
121
+ const routes = (manifest?.pages ?? [])
122
+ .filter((page) => page !== null && typeof page === "object" && typeof page.path === "string")
123
+ .map((page) => (page.path === "" ? "/" : `/${page.path.replace(/^\/+/u, "")}/`.replace(/\/\/$/u, "/")));
124
+ onProgress(`Checking ${resolved.baseUrl} (${Math.min(routes.length + 1, DELIVERY_LIMITS.routes)} route(s), assets, runtime).`);
125
+ const report = await checkDelivery({
126
+ fetch: client.fetch,
127
+ timeoutSignal: client.timeoutSignal,
128
+ baseUrl: resolved.baseUrl,
129
+ routes,
130
+ cookie: resolved.cookie,
131
+ propagationWaitSeconds: invocation.propagationWaitSeconds ?? 0,
132
+ now,
133
+ });
134
+ for (const failure of report.failures) onProgress(`Delivery: ${failure}`);
135
+
136
+ let browser;
137
+ if (invocation.browser === false) {
138
+ browser = { status: "unchecked", reason: "disabled", note: "Browser verification was not requested." };
139
+ } else {
140
+ onProgress("Browser dimension: probing with Playwright when it is installed.");
141
+ browser = await probeDeliveryWithBrowser({
142
+ baseUrl: resolved.baseUrl,
143
+ expectedEntryUrl: report.runtime.entry?.url ?? "",
144
+ capabilities: report.runtime.capabilities,
145
+ cookie: resolved.cookie,
146
+ importPlaywright: invocation.importPlaywright,
147
+ });
148
+ }
149
+ if (browser.status === "unchecked") onProgress(`Browser dimension unchecked: ${browser.reason}.`);
150
+ else if (browser.status === "checked" && !browser.ok) onProgress("Browser dimension: a load did not match the declared runtime or left the site origin; see browser.fresh and browser.returning.");
151
+ if (browser.status === "checked" && browser.returningCache?.status !== "checked") onProgress("Browser dimension: cache reuse on the returning load could not be observed; it is unchecked.");
152
+
153
+ const verdict = report.verdict === "delivered" && browser.status === "checked" && !browser.ok ? "degraded" : report.verdict;
154
+ onProgress(
155
+ verdict === "delivered"
156
+ ? "Delivery verified over HTTP" + (browser.status === "checked" ? " and in a browser." : "; browser behaviour unchecked.")
157
+ : `Delivery ${verdict}: ${report.failures.length} HTTP finding(s)` + (browser.status === "checked" && !browser.ok ? " and a browser mismatch." : "."),
158
+ );
159
+ return successResult(VERB_DELIVERY_CHECK, siteId, {
160
+ environment,
161
+ target: { ...report.target, resolvedFrom: resolved.source },
162
+ deployment,
163
+ verdict,
164
+ routes: report.routes,
165
+ assets: report.assets,
166
+ links: report.links,
167
+ localOnlyReferences: report.localOnlyReferences,
168
+ ...(report.localOnlyReferencesTotal === undefined ? {} : { localOnlyReferencesTotal: report.localOnlyReferencesTotal }),
169
+ runtime: report.runtime,
170
+ browser,
171
+ failures: report.failures,
172
+ ...(report.failuresTruncated ? { failuresTruncated: true, failuresTotal: report.failuresTotal } : {}),
173
+ propagation: report.propagation,
174
+ limits: report.limits,
175
+ checkedAt: report.checkedAt,
176
+ doesNotProve: [
177
+ "a full visual or accessibility audit",
178
+ "member-only content delivery, which stays behind the member boundary",
179
+ ...(browser.status === "checked" && browser.returningCache?.status === "checked"
180
+ ? []
181
+ : ["what a returning browser loads from its own cache"]),
182
+ ],
183
+ });
184
+ });
185
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DEPLOYMENT_ENVIRONMENT_PRODUCTION,
3
3
  DEPLOYMENT_ENVIRONMENT_STAGING,
4
+ DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
4
5
  DEPLOYMENT_STATUS_COMPLETED,
5
6
  deploySite,
6
7
  getDeploySelection,
@@ -10,10 +11,19 @@ import {
10
11
  waitForDeployment,
11
12
  withRefusalGuidance,
12
13
  } from "../api.js";
13
- import { DEPLOY_TARGET_PRODUCTION, DEPLOY_TARGET_STAGING, SURFACE_DOCS_PRESENTATION, VERB_DEPLOY } from "../constants.js";
14
+ import {
15
+ DEPLOY_TARGET_PRODUCTION,
16
+ DEPLOY_TARGET_STAGING,
17
+ REFUSAL_CAPABILITY_MISSING,
18
+ REFUSAL_CREDENTIAL_REJECTED,
19
+ REFUSAL_UNCLASSIFIED,
20
+ SURFACE_DOCS_PRESENTATION,
21
+ VERB_DEPLOY,
22
+ } from "../constants.js";
14
23
  import { SiteAuthoringError } from "../errors.js";
15
24
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
16
25
  import { checkStagingRedirects } from "../staging-check.js";
26
+ import { ApiError } from "../transport.js";
17
27
  import { readManifest, writeManifest } from "../workspace.js";
18
28
 
19
29
  /**
@@ -71,6 +81,16 @@ function usageError(code, message, field) {
71
81
  return new SiteAuthoringError(code, message, { field, exitCode: 2 });
72
82
  }
73
83
 
84
+ /**
85
+ * One line when this request coalesced into an already in-flight or recently
86
+ * anchored identical deployment (TR00839), instead of creating a new one.
87
+ */
88
+ function announceOutcome(created, onProgress) {
89
+ if (created.outcome === DEPLOYMENT_REQUEST_OUTCOME_COALESCED) {
90
+ onProgress(`An identical deployment request is already in progress; reusing deployment ${created.id}.`);
91
+ }
92
+ }
93
+
74
94
  function explicitSelection(invocation) {
75
95
  const stagedPageIds = Array.isArray(invocation.stagedPageIds) ? invocation.stagedPageIds : undefined;
76
96
  const selectedSettingsTypes = Array.isArray(invocation.selectedSettingsTypes)
@@ -157,6 +177,65 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
157
177
  }
158
178
  }
159
179
 
180
+ const PRESENTATION_REVIEW_GUIDANCE = "Open the URL once in a browser, then switch the site's theme toggle between "
181
+ + "light and dark to review both schemes; no page draft is needed to preview a presentation change.";
182
+
183
+ /**
184
+ * Why a managed Docs staging handoff could not be minted, as a stable reason
185
+ * the caller can act on. The recovery never points at production or at a host
186
+ * the CLI has not been told about.
187
+ */
188
+ function presentationHandoffUnavailable(error) {
189
+ if (error instanceof ApiError) {
190
+ const refusal = error.refusalKind();
191
+ if (refusal === REFUSAL_CREDENTIAL_REJECTED || refusal === REFUSAL_CAPABILITY_MISSING) {
192
+ return {
193
+ reason: "staging.authority_denied",
194
+ recovery: "This credential cannot view staging for this site. Re-issue a site-authoring key that carries "
195
+ + "the Deployments capability, then run 'taproot-site staging review' for a handoff.",
196
+ };
197
+ }
198
+ if (refusal === REFUSAL_UNCLASSIFIED && error.hasField("SiteId")) {
199
+ return {
200
+ reason: "staging.surface_refused",
201
+ recovery: "The site no longer accepts external presentation authoring (its Docs source is prebuilt). "
202
+ + "Run 'taproot-site use' to re-record the site's surface; a prebuilt Docs site stages nothing.",
203
+ };
204
+ }
205
+ if (refusal === REFUSAL_UNCLASSIFIED && error.hasField("Host")) {
206
+ return {
207
+ reason: "staging.host_unavailable",
208
+ recovery: "The site has no acknowledged staging host yet. Wait for the staging hostname to become ready "
209
+ + "(check 'taproot-site status'), then run 'taproot-site staging review' to mint a review handoff.",
210
+ };
211
+ }
212
+ }
213
+ return {
214
+ reason: "staging.handoff_unavailable",
215
+ recovery: "Run 'taproot-site staging review' to mint a fresh review handoff; the staged presentation is "
216
+ + "already deployed and waits for review.",
217
+ };
218
+ }
219
+
220
+ /**
221
+ * A managed Docs site deploys settings only, so there are no page drafts or
222
+ * redirects to inspect on staging; the review handoff is the whole handoff.
223
+ * It is minted after the deployment completed, bound by the server to the
224
+ * site's exact acknowledged staging host, and single-use like every other.
225
+ */
226
+ async function mintPresentationStagingHandoff(client, siteId, onProgress, now) {
227
+ try {
228
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
229
+ onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${PRESENTATION_REVIEW_GUIDANCE}`);
230
+ return { ...handoff, review: PRESENTATION_REVIEW_GUIDANCE };
231
+ } catch (error) {
232
+ if (error instanceof SiteAuthoringError && !(error instanceof ApiError)) throw error;
233
+ const unavailable = presentationHandoffUnavailable(error);
234
+ onProgress(`Warning: the deployment completed, but a staging review handoff could not be minted (${unavailable.reason}). ${unavailable.recovery}`);
235
+ return { url: "", stagingUrl: "", ...unavailable };
236
+ }
237
+ }
238
+
160
239
  export async function deploy(invocation) {
161
240
  const target = invocation.deployTarget;
162
241
  if (target !== DEPLOY_TARGET_STAGING && target !== DEPLOY_TARGET_PRODUCTION) {
@@ -200,6 +279,7 @@ export async function deploy(invocation) {
200
279
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
201
280
  });
202
281
  onProgress(`Production deployment ${created.id} accepted; waiting for it to complete.`);
282
+ announceOutcome(created, onProgress);
203
283
  const completed = await waitForDeployment(client, {
204
284
  siteId,
205
285
  deploymentId: created.id,
@@ -215,6 +295,7 @@ export async function deploy(invocation) {
215
295
  ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
216
296
  promotedStagingDeploymentId: stagingDeploymentId,
217
297
  deployment: completed,
298
+ outcome: created.outcome,
218
299
  readiness: reportReadiness(readiness),
219
300
  });
220
301
  }
@@ -289,6 +370,7 @@ export async function deploy(invocation) {
289
370
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
290
371
  });
291
372
  onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
373
+ announceOutcome(created, onProgress);
292
374
  const completed = await waitForDeployment(client, {
293
375
  siteId,
294
376
  deploymentId: created.id,
@@ -298,7 +380,7 @@ export async function deploy(invocation) {
298
380
  });
299
381
  await recordDeployment(config, manifest, "staging", completed);
300
382
  if (presentationOnly) {
301
- onProgress("Review the staged presentation on the site's staging host, then promote it.");
383
+ const stagingPreview = await mintPresentationStagingHandoff(client, siteId, onProgress, now);
302
384
  return successResult(VERB_DEPLOY, siteId, {
303
385
  target,
304
386
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -309,8 +391,10 @@ export async function deploy(invocation) {
309
391
  includeNavigation: false,
310
392
  },
311
393
  deployment: completed,
394
+ outcome: created.outcome,
312
395
  readiness: reportReadiness(readiness),
313
- nextStep: "deploy --production",
396
+ stagingPreview,
397
+ nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
314
398
  });
315
399
  }
316
400
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
@@ -324,6 +408,7 @@ export async function deploy(invocation) {
324
408
  includeNavigation,
325
409
  },
326
410
  deployment: completed,
411
+ outcome: created.outcome,
327
412
  readiness: reportReadiness(readiness),
328
413
  stagingPreview,
329
414
  nextStep: stagingPreview.redirects?.verified === true ? "deploy --production" : "redirects check",
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  VERB_APPROVE,
3
+ VERB_DELIVERY_CHECK,
3
4
  VERB_DEPLOY,
4
5
  VERB_ENV,
5
6
  VERB_FOOTER_PUSH,
@@ -15,6 +16,7 @@ import {
15
16
  VERB_REDIRECTS_PULL,
16
17
  VERB_REDIRECTS_PUSH,
17
18
  VERB_SITES,
19
+ VERB_STAGING_REVIEW,
18
20
  VERB_STATUS,
19
21
  VERB_THEME_PUSH,
20
22
  VERB_USE,
@@ -22,6 +24,7 @@ import {
22
24
  VERB_WHOAMI,
23
25
  } from "../constants.js";
24
26
  import { approve } from "./approve.js";
27
+ import { deliveryCheck } from "./delivery-check.js";
25
28
  import { deploy } from "./deploy.js";
26
29
  import { env } from "./env.js";
27
30
  import { footerPush } from "./footer-push.js";
@@ -37,6 +40,7 @@ import { redirectsCheck } from "./redirects-check.js";
37
40
  import { redirectsPull } from "./redirects-pull.js";
38
41
  import { redirectsPush } from "./redirects-push.js";
39
42
  import { sites } from "./sites.js";
43
+ import { stagingReview } from "./staging-review.js";
40
44
  import { status } from "./status.js";
41
45
  import { themePush } from "./theme-push.js";
42
46
  import { use } from "./use.js";
@@ -131,4 +135,6 @@ export const VERB_HANDLERS = Object.freeze({
131
135
  [VERB_WHOAMI]: whoami,
132
136
  [VERB_ENV]: env,
133
137
  [VERB_STATUS]: status,
138
+ [VERB_DELIVERY_CHECK]: deliveryCheck,
139
+ [VERB_STAGING_REVIEW]: stagingReview,
134
140
  });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  AUTHORING_PREVIEW_STATUS_REVOKED,
3
3
  revokeAuthoringPreview,
4
- translateAuthoringPreviewApiError,
4
+ translateAuthoringPreviewRevokeApiError,
5
5
  withRefusalGuidance,
6
6
  } from "../api.js";
7
7
  import { VERB_PREVIEW_REVOKE } from "../constants.js";
@@ -56,6 +56,6 @@ export async function previewRevoke(invocation) {
56
56
  });
57
57
  });
58
58
  } catch (error) {
59
- throw translateAuthoringPreviewApiError(error);
59
+ throw translateAuthoringPreviewRevokeApiError(error);
60
60
  }
61
61
  }
@@ -0,0 +1,29 @@
1
+ import { mintStagingPreviewHandoff, withRefusalGuidance } from "../api.js";
2
+ import { VERB_STAGING_REVIEW } from "../constants.js";
3
+ import { openSession, successResult } from "../session.js";
4
+
5
+ export const STAGING_REVIEW_GUIDANCE = "Open the URL once in a browser, then switch the site's theme toggle between "
6
+ + "light and dark to review both schemes; no page draft is needed to review a presentation change.";
7
+
8
+ /**
9
+ * `staging review` — mint a fresh single-use review handoff for the site's
10
+ * acknowledged staging host, and nothing else (TR00800 follow-up).
11
+ *
12
+ * It is the recovery `deploy --staging` names when it could not mint one,
13
+ * and the only such path a managed Docs site can take: `redirects check`
14
+ * inspects a redirect map the Docs shell does not have and needs the
15
+ * Standard surface. Minting is read-only for the site: the handoff is a
16
+ * short-lived capability bound to the current host, reported only in the
17
+ * final JSON and never in progress or GITHUB_OUTPUT.
18
+ */
19
+ export async function stagingReview(invocation) {
20
+ const { client, siteId, now, onProgress } = await openSession(invocation);
21
+ return await withRefusalGuidance(onProgress, "staging review", async () => {
22
+ onProgress("Minting a fresh single-use staging review handoff.");
23
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
24
+ onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${STAGING_REVIEW_GUIDANCE}`);
25
+ return successResult(VERB_STAGING_REVIEW, siteId, {
26
+ stagingPreview: { ...handoff, review: STAGING_REVIEW_GUIDANCE },
27
+ });
28
+ });
29
+ }
@@ -6,7 +6,7 @@ import {
6
6
  footerColorOverlay,
7
7
  } from "../appearance-contract.js";
8
8
  import { VERB_THEME_PUSH } from "../constants.js";
9
- import { SiteAuthoringError } from "../errors.js";
9
+ import { MISSING_SETTING_MESSAGE, SiteAuthoringError } from "../errors.js";
10
10
  import { projectFooterSettingsForWorkspace } from "../footer-contract.js";
11
11
  import { computeFooterDraftHash } from "../footer-draft-hash.js";
12
12
  import {
@@ -122,13 +122,13 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
122
122
  if (missing.length > 0) {
123
123
  throw new SiteAuthoringError(
124
124
  "theme.settings_missing",
125
- "Required settings keys are missing. Run 'taproot-site pull' to update this workspace's contract.",
125
+ "Required settings keys are absent from this workspace. Restore them locally, or run 'taproot-site pull' if the workspace is stale; a fresh pull that still omits them means the server projection is incomplete for the listed paths.",
126
126
  {
127
127
  field: missing[0],
128
128
  details: missing.map((field) => ({
129
129
  code: "theme.setting_missing",
130
130
  field,
131
- message: `${field} is missing (added in a later contract; run taproot-site pull)`,
131
+ message: MISSING_SETTING_MESSAGE,
132
132
  })),
133
133
  },
134
134
  );