@taprootio/site-authoring 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. package/src/workspace.js +1085 -0
package/src/api.js ADDED
@@ -0,0 +1,1507 @@
1
+ import {
2
+ CANONICAL_TIMESTAMP,
3
+ LIMITS,
4
+ REFUSAL_CREDENTIAL_REJECTED,
5
+ REFUSAL_PLAN_LIMIT,
6
+ REFUSAL_PLATFORM_PAUSED,
7
+ REFUSAL_THROTTLED,
8
+ REFUSAL_UNCLASSIFIED,
9
+ } from "./constants.js";
10
+ // The display-prefix shape lives with the store because acceptance on claim
11
+ // must equal acceptance on save — a prefix admitted here but refused there
12
+ // would turn a successful mint into an orphaned key.
13
+ import { KEY_PREFIX } from "./credentials.js";
14
+ import { isCanonicalUuid, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
15
+ import { ApiError, isWellFormedCredential } from "./transport.js";
16
+
17
+ /**
18
+ * The wire vocabulary and the read helpers every verb shares.
19
+ *
20
+ * Two facts shape everything here. First, proto3 omits zero-valued enums and
21
+ * scalars, so an absent `status` is not missing data — it is the zero member,
22
+ * and a reader that treats absence as "unknown" will mis-report a queued
23
+ * deployment or a pending image. Second, ordinary authoring mutations carry
24
+ * no idempotency key, so the transport will not replay them; the helpers below
25
+ * issue exactly one mutation per intent and let an ambiguous outcome surface
26
+ * as ambiguous. The sole exception is handoff minting, whose transactional
27
+ * contract replaces the prior unconsumed handoff and therefore permits replay.
28
+ */
29
+
30
+ export const PAGE_STATUS_UNKNOWN = "PAGE_STATUS_UNKNOWN";
31
+ export const PAGE_STATUS_DRAFT = "PAGE_STATUS_DRAFT";
32
+ export const PAGE_STATUS_APPROVED = "PAGE_STATUS_APPROVED";
33
+ export const PAGE_STATUS_PUBLISHED = "PAGE_STATUS_PUBLISHED";
34
+ export const PAGE_STATUS_DELETED = "PAGE_STATUS_DELETED";
35
+ export const PAGE_STATUSES = Object.freeze([
36
+ PAGE_STATUS_UNKNOWN,
37
+ PAGE_STATUS_DRAFT,
38
+ PAGE_STATUS_APPROVED,
39
+ PAGE_STATUS_PUBLISHED,
40
+ PAGE_STATUS_DELETED,
41
+ ]);
42
+
43
+ export const TEMPLATE_TYPE_FREE_FORM = "TEMPLATE_TYPE_FREE_FORM";
44
+ export const FREE_FORM_TEMPLATE_VERSION = "1.0";
45
+
46
+ export const NAV_ITEM_KIND_PAGE = "NAV_ITEM_KIND_PAGE";
47
+ export const NAV_ITEM_KIND_EXTERNAL_URL = "NAV_ITEM_KIND_EXTERNAL_URL";
48
+ export const NAV_ITEM_KIND_GROUP_HEADER = "NAV_ITEM_KIND_GROUP_HEADER";
49
+ export const NAV_ITEM_KINDS = Object.freeze([
50
+ NAV_ITEM_KIND_PAGE,
51
+ NAV_ITEM_KIND_EXTERNAL_URL,
52
+ NAV_ITEM_KIND_GROUP_HEADER,
53
+ ]);
54
+ export const NAVIGATION_MAXIMUM_DEPTH = 3;
55
+
56
+ export const DEPLOYMENT_ENVIRONMENT_STAGING = "DEPLOYMENT_ENVIRONMENT_STAGING";
57
+ export const DEPLOYMENT_ENVIRONMENT_PRODUCTION = "DEPLOYMENT_ENVIRONMENT_PRODUCTION";
58
+ export const DEPLOYMENT_STATUS_QUEUED = "DEPLOYMENT_STATUS_QUEUED";
59
+ export const DEPLOYMENT_STATUS_COMPLETED = "DEPLOYMENT_STATUS_COMPLETED";
60
+ export const DEPLOYMENT_STATUS_FAILED = "DEPLOYMENT_STATUS_FAILED";
61
+ export const DEPLOYMENT_PENDING_STATUSES = Object.freeze([
62
+ DEPLOYMENT_STATUS_QUEUED,
63
+ "DEPLOYMENT_STATUS_GENERATING",
64
+ "DEPLOYMENT_STATUS_DEPLOYING",
65
+ ]);
66
+ export const DEPLOYMENT_STATUSES = Object.freeze([
67
+ ...DEPLOYMENT_PENDING_STATUSES,
68
+ DEPLOYMENT_STATUS_COMPLETED,
69
+ DEPLOYMENT_STATUS_FAILED,
70
+ ]);
71
+
72
+ export const IMAGE_PROCESSING_STATE_UNKNOWN = "IMAGE_PROCESSING_STATE_UNKNOWN";
73
+ export const IMAGE_PROCESSING_STATE_COMPLETE = "IMAGE_PROCESSING_STATE_COMPLETE";
74
+ export const IMAGE_PROCESSING_STATE_FAILED = "IMAGE_PROCESSING_STATE_FAILED";
75
+ export const IMAGE_PROCESSING_STATES = Object.freeze([
76
+ IMAGE_PROCESSING_STATE_UNKNOWN,
77
+ "IMAGE_PROCESSING_STATE_PENDING",
78
+ IMAGE_PROCESSING_STATE_COMPLETE,
79
+ "IMAGE_PROCESSING_STATE_IN_PROGRESS",
80
+ "IMAGE_PROCESSING_STATE_RETRYING",
81
+ IMAGE_PROCESSING_STATE_FAILED,
82
+ ]);
83
+
84
+ export const IMAGE_OWNERSHIP_SCOPE_SITE = "IMAGE_OWNERSHIP_SCOPE_SITE";
85
+
86
+ // The environment every read in this CLI names explicitly. `SITE_ENVIRONMENT_UNKNOWN`
87
+ // is the zero member, and the navigation and settings handlers both refuse it
88
+ // outright rather than treating it as a default — so an omitted `environment`
89
+ // is a 400, not a fallback.
90
+ export const SITE_ENVIRONMENT_DRAFT = "SITE_ENVIRONMENT_DRAFT";
91
+
92
+ export const AUTHORING_PREVIEW_STATUS_QUEUED = "AUTHORING_PREVIEW_STATUS_QUEUED";
93
+ export const AUTHORING_PREVIEW_STATUS_RENDERING = "AUTHORING_PREVIEW_STATUS_RENDERING";
94
+ export const AUTHORING_PREVIEW_STATUS_READY = "AUTHORING_PREVIEW_STATUS_READY";
95
+ export const AUTHORING_PREVIEW_STATUS_FAILED = "AUTHORING_PREVIEW_STATUS_FAILED";
96
+ export const AUTHORING_PREVIEW_STATUS_REVOKED = "AUTHORING_PREVIEW_STATUS_REVOKED";
97
+ export const AUTHORING_PREVIEW_STATUS_EXPIRED = "AUTHORING_PREVIEW_STATUS_EXPIRED";
98
+ export const AUTHORING_PREVIEW_STATUSES = Object.freeze([
99
+ AUTHORING_PREVIEW_STATUS_QUEUED,
100
+ AUTHORING_PREVIEW_STATUS_RENDERING,
101
+ AUTHORING_PREVIEW_STATUS_READY,
102
+ AUTHORING_PREVIEW_STATUS_FAILED,
103
+ AUTHORING_PREVIEW_STATUS_REVOKED,
104
+ AUTHORING_PREVIEW_STATUS_EXPIRED,
105
+ ]);
106
+
107
+ const AUTHORING_PREVIEW_FAILURE_RENDER_FAILED = "preview.render_failed";
108
+ const AUTHORING_PREVIEW_FAILURE_RENDER_UNCLAIMED = "preview.render_unclaimed";
109
+ const AUTHORING_PREVIEW_FAILURE_ARTIFACT_MISSING = "preview.artifact_missing";
110
+ const AUTHORING_PREVIEW_FAILURE_EXPIRED = "preview.expired";
111
+ const AUTHORING_PREVIEW_FAILURE_REVOKED = "preview.revoked";
112
+ const AUTHORING_PREVIEW_FAILURES = new Set([
113
+ "",
114
+ AUTHORING_PREVIEW_FAILURE_RENDER_FAILED,
115
+ AUTHORING_PREVIEW_FAILURE_RENDER_UNCLAIMED,
116
+ AUTHORING_PREVIEW_FAILURE_ARTIFACT_MISSING,
117
+ AUTHORING_PREVIEW_FAILURE_EXPIRED,
118
+ AUTHORING_PREVIEW_FAILURE_REVOKED,
119
+ ]);
120
+ const AUTHORING_PREVIEW_FIELDS = Object.freeze({
121
+ draft: "AuthoringPreviewDraft",
122
+ staging: "AuthoringPreviewStaging",
123
+ expiry: "AuthoringPreviewExpiry",
124
+ readiness: "AuthoringPreviewReadiness",
125
+ rollout: "AuthoringPreviewRollout",
126
+ siteCapacity: "AuthoringPreviewCapacity",
127
+ authorityCapacity: "AuthoringPreviewAuthorityCapacity",
128
+ manifest: "AuthoringPreviewManifest",
129
+ });
130
+ const AUTHORING_PREVIEW_FIELD_ERRORS = Object.freeze({
131
+ [AUTHORING_PREVIEW_FIELDS.draft]: ["preview.no_draft", "The requested page does not have a persisted draft to preview."],
132
+ [AUTHORING_PREVIEW_FIELDS.staging]: [
133
+ "preview.staging_unavailable",
134
+ "The site does not have an available staging hostname for authoring previews.",
135
+ ],
136
+ [AUTHORING_PREVIEW_FIELDS.expiry]: ["preview.expired", "The authoring preview has expired."],
137
+ [AUTHORING_PREVIEW_FIELDS.readiness]: ["preview.not_ready", "The authoring preview is not ready for a handoff."],
138
+ [AUTHORING_PREVIEW_FIELDS.rollout]: [
139
+ "preview.temporarily_unavailable",
140
+ "Authoring previews are temporarily unavailable while Taproot completes a deployment rollover.",
141
+ ],
142
+ [AUTHORING_PREVIEW_FIELDS.siteCapacity]: [
143
+ "preview.site_capacity",
144
+ "This site already has the maximum number of queued or rendering authoring previews. "
145
+ + "Wait for one to finish rendering, or revoke a preview that is still queued or rendering.",
146
+ ],
147
+ [AUTHORING_PREVIEW_FIELDS.authorityCapacity]: [
148
+ "preview.authority_capacity",
149
+ "This authoring key has reached its active preview limit. Use preview revoke on an older preview or wait for it to expire.",
150
+ ],
151
+ [AUTHORING_PREVIEW_FIELDS.manifest]: [
152
+ "preview.snapshot_too_large",
153
+ "The site is too large to capture as an authoring preview.",
154
+ ],
155
+ });
156
+ const CANONICAL_SHA256 = /^sha256:[0-9a-f]{64}$/u;
157
+ 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;
158
+ const CANONICAL_HANDOFF = /^[A-Za-z0-9_-]{43}$/u;
159
+ const AUTHORING_PREVIEW_PATH_PREFIX = "/_taproot/preview/pages";
160
+ const AUTHORING_PREVIEW_LIFETIME_MILLISECONDS = 60 * 60_000;
161
+ const AUTHORING_PREVIEW_HANDOFF_LIFETIME_MILLISECONDS = 2 * 60_000;
162
+ const AUTHORING_PREVIEW_CLOCK_SKEW_MILLISECONDS = 5 * 60_000;
163
+
164
+ const MAXIMUM_LIST_REQUESTS = 200;
165
+ const MAXIMUM_PAGES = 5_000;
166
+ const MAXIMUM_IMAGES = 5_000;
167
+ const LIST_PAGE_SIZE = 100;
168
+ const DEPLOYMENT_PAGE_SIZE = 50;
169
+ // Enough to observe a deployment that has not appeared in the list yet without
170
+ // spending the whole deadline on a deployment that never will.
171
+ const MAXIMUM_DEPLOYMENT_MISSES = 5;
172
+ const MAXIMUM_DIAGNOSTIC_LENGTH = 400;
173
+
174
+ export function requireObject(value, code, description) {
175
+ if (value === null || Array.isArray(value) || typeof value !== "object") {
176
+ throw new SiteAuthoringError(code, `Taproot returned an invalid ${description} response.`);
177
+ }
178
+ return value;
179
+ }
180
+
181
+ function requireIdentifier(value, code, field) {
182
+ if (typeof value !== "string" || value.length === 0 || value.length > 100 || !/^[\w-]+$/u.test(value)) {
183
+ throw new SiteAuthoringError(code, `Taproot returned an invalid ${field}.`, { field });
184
+ }
185
+ return value;
186
+ }
187
+
188
+ function requireCanonicalUuid(value, code, field) {
189
+ if (!isCanonicalUuid(value)) {
190
+ throw new SiteAuthoringError(code, `Taproot returned an invalid ${field}.`, { field });
191
+ }
192
+ return value;
193
+ }
194
+
195
+ function requireCanonicalTimestamp(value, code, field) {
196
+ const milliseconds = typeof value === "string" && CANONICAL_TIMESTAMP.test(value) ? Date.parse(value) : Number.NaN;
197
+ const wholeSecond = typeof value === "string" ? `${value.slice(0, 19)}Z` : "";
198
+ const canonicalWholeSecond = Number.isFinite(milliseconds)
199
+ ? `${new Date(milliseconds).toISOString().slice(0, 19)}Z`
200
+ : "";
201
+ if (!Number.isFinite(milliseconds) || canonicalWholeSecond !== wholeSecond) {
202
+ throw new SiteAuthoringError(code, `Taproot returned an invalid ${field}.`, { field });
203
+ }
204
+ return { value, milliseconds };
205
+ }
206
+
207
+ function requireCanonicalStagingHost(value) {
208
+ if (
209
+ typeof value !== "string"
210
+ || !CANONICAL_HOST.test(value)
211
+ ) {
212
+ throw new SiteAuthoringError(
213
+ "preview.status_contract",
214
+ "Taproot returned an invalid authoring-preview staging host.",
215
+ { field: "stagingHost" },
216
+ );
217
+ }
218
+ let canonical;
219
+ try {
220
+ canonical = new URL(`https://${value}/`).hostname;
221
+ } catch {
222
+ canonical = "";
223
+ }
224
+ if (canonical !== value) {
225
+ throw new SiteAuthoringError(
226
+ "preview.status_contract",
227
+ "Taproot returned a non-canonical authoring-preview staging host.",
228
+ { field: "stagingHost" },
229
+ );
230
+ }
231
+ return value;
232
+ }
233
+
234
+ function equalPreviewIdentity(actual, expected) {
235
+ return actual.siteId === expected.siteId
236
+ && actual.pageId === expected.pageId
237
+ && actual.snapshotId === expected.snapshotId
238
+ // PostgreSQL persists timestamps at microsecond precision while JS dates
239
+ // observe milliseconds. Compare the represented instants, not whether two
240
+ // protobuf serializers chose the same permitted fractional spelling.
241
+ && actual.capturedAtMilliseconds === expected.capturedAtMilliseconds
242
+ && actual.expiresAtMilliseconds === expected.expiresAtMilliseconds
243
+ && actual.draftRevision === expected.draftRevision
244
+ && actual.stagingHost === expected.stagingHost;
245
+ }
246
+
247
+ /** proto3 omits a zero-valued enum, so absence resolves to the zero member. */
248
+ function enumValue(value, allowed, zero, code, field) {
249
+ if (value === undefined || value === null || value === "") return zero;
250
+ if (typeof value !== "string" || !allowed.includes(value)) {
251
+ throw new SiteAuthoringError(code, `Taproot returned an unsupported ${field}.`, { status: value });
252
+ }
253
+ return value;
254
+ }
255
+
256
+ function safeCount(value) {
257
+ const numeric = typeof value === "string" ? Number(value) : value;
258
+ return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : 0;
259
+ }
260
+
261
+ /** A server string that exists to be *read* — bounded hard, because it is only ever displayed. */
262
+ function text(value) {
263
+ return typeof value === "string" ? sanitizeDiagnostic(value, "").slice(0, MAXIMUM_DIAGNOSTIC_LENGTH) : "";
264
+ }
265
+
266
+ /**
267
+ * A server string that travels back to the server unchanged: a page title,
268
+ * which `pull` writes into the manifest and `pages push` sends straight back.
269
+ *
270
+ * Control characters are still stripped — that is terminal and result safety,
271
+ * not fidelity. What is deliberately *not* applied is the diagnostic length
272
+ * cap. `Page.Title` is citext with no maximum, so capping it at 400 renamed
273
+ * every long-titled page on its next push: silently, permanently, and in the
274
+ * one direction nobody inspects. The response byte cap already bounds what can
275
+ * arrive at all, and `sanitizeDiagnostic`'s own outer bound slices whole code
276
+ * points rather than UTF-16 units, so nothing here can cut a surrogate pair in
277
+ * half and hand the server a lone half-character.
278
+ */
279
+ function roundTrippedText(value) {
280
+ return typeof value === "string" ? sanitizeDiagnostic(value, "") : "";
281
+ }
282
+
283
+ function query(parameters) {
284
+ const search = new URLSearchParams();
285
+ for (const [name, value] of parameters) {
286
+ if (value === undefined || value === "") continue;
287
+ if (Array.isArray(value)) {
288
+ for (const entry of value) search.append(name, String(entry));
289
+ continue;
290
+ }
291
+ search.set(name, String(value));
292
+ }
293
+ const serialized = search.toString();
294
+ return serialized === "" ? "" : `?${serialized}`;
295
+ }
296
+
297
+ export function sitePath(siteId, suffix) {
298
+ return `v1/sites/${encodeURIComponent(siteId)}/${suffix}`;
299
+ }
300
+
301
+ /**
302
+ * The one thing an agent should do next, per refusal kind. These are different
303
+ * behaviors — retry later, stop and re-issue, upgrade, back off — and a caller
304
+ * that collapses them retries a revoked credential forever or treats a
305
+ * commercial ceiling as an outage.
306
+ */
307
+ const REFUSAL_GUIDANCE = Object.freeze({
308
+ [REFUSAL_PLATFORM_PAUSED]:
309
+ "Taproot has paused external site authoring platform-wide. The credential and the request are both fine; "
310
+ + "retry later or ask the operator.",
311
+ [REFUSAL_CREDENTIAL_REJECTED]:
312
+ "The site authoring credential was rejected: it is invalid, revoked, or bound to a different site. "
313
+ + "Stop and re-issue it; retrying cannot succeed.",
314
+ [REFUSAL_THROTTLED]:
315
+ "This credential exceeded its request budget. Back off and retry with delay.",
316
+ });
317
+
318
+ /**
319
+ * Says out loud, on the human channel, what a classified refusal means.
320
+ *
321
+ * The plan limit gets the loudest treatment because it is the one refusal an
322
+ * agent cannot resolve by retrying, waiting, or fixing its input: the site has
323
+ * reached a commercial ceiling. It is raised at deploy — no pre-flight read of
324
+ * the published-page quota exists anywhere on the contract — and also by the
325
+ * image-upload handlers, so `action` names the operation that was actually
326
+ * refused rather than telling someone whose upload was rejected to "run the
327
+ * deploy again". The CLI never invents the numeric limit it was not told.
328
+ */
329
+ export function announceRefusal(error, onProgress, action = "request") {
330
+ if (!(error instanceof ApiError)) return;
331
+ const kind = error.refusalKind();
332
+ if (kind === REFUSAL_PLAN_LIMIT) {
333
+ onProgress("");
334
+ onProgress("PLAN LIMIT (refusal=plan_limit, field=UpgradePrompt)");
335
+ onProgress(
336
+ ` Taproot refused this ${action} against a plan ceiling rather than a content problem.`,
337
+ );
338
+ onProgress(
339
+ ` Upgrade the site's plan, or reduce what this site is using, then run the ${action} again.`,
340
+ );
341
+ // The server's own violation description names the remedy; surface it
342
+ // verbatim (sanitized) rather than paraphrasing policy the CLI does not
343
+ // own. The bounded ApiError message remains as the fallback.
344
+ const serverDescription = error.descriptionFor?.("UpgradePrompt");
345
+ onProgress(` Taproot's own detail: ${
346
+ sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")
347
+ }`);
348
+ onProgress("");
349
+ return;
350
+ }
351
+ const guidance = REFUSAL_GUIDANCE[kind];
352
+ if (guidance) onProgress(guidance);
353
+ }
354
+
355
+ /**
356
+ * Wraps wire work so a classified refusal explains itself before it propagates.
357
+ * `action` is the verb's own short noun for what it was doing — it reaches the
358
+ * plan-limit announcement, which otherwise names the wrong operation.
359
+ */
360
+ export async function withRefusalGuidance(onProgress, action, work) {
361
+ try {
362
+ return await work();
363
+ } catch (error) {
364
+ announceRefusal(error, onProgress, action);
365
+ throw error;
366
+ }
367
+ }
368
+
369
+ export function normalizePageSummary(value) {
370
+ const summary = requireObject(value, "api.page_contract", "page summary");
371
+ return {
372
+ pageId: requireIdentifier(summary.pageId, "api.page_contract", "page.pageId"),
373
+ resourceId: typeof summary.resourceId === "string" ? summary.resourceId : "",
374
+ path: typeof summary.path === "string" ? summary.path : "",
375
+ // Not `text()`: a title round-trips to the server, so truncating it here
376
+ // renames the page on the next push.
377
+ title: roundTrippedText(summary.title),
378
+ templateType: typeof summary.templateType === "string" ? summary.templateType : "TEMPLATE_TYPE_UNKNOWN",
379
+ status: enumValue(summary.status, PAGE_STATUSES, PAGE_STATUS_UNKNOWN, "api.page_status", "page status"),
380
+ hasDraft: summary.hasDraft === true,
381
+ isGenerated: summary.isGenerated === true,
382
+ };
383
+ }
384
+
385
+ export async function listSitePages(client, siteId, { onProgress = () => {} } = {}) {
386
+ const pages = [];
387
+ let pageToken = "";
388
+ for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
389
+ const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
390
+ const response = requireObject(
391
+ await client.request(`v1/pages/by_site/${encodeURIComponent(siteId)}${search}`),
392
+ "api.page_contract",
393
+ "site pages",
394
+ );
395
+ const batch = Array.isArray(response.pages) ? response.pages : [];
396
+ for (const summary of batch) pages.push(normalizePageSummary(summary));
397
+ pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : "";
398
+ if (pageToken === "" || batch.length === 0) return { pages, truncated: false };
399
+ if (pages.length >= MAXIMUM_PAGES) break;
400
+ onProgress(`Listed ${pages.length} pages.`);
401
+ }
402
+ return { pages, truncated: true };
403
+ }
404
+
405
+ /**
406
+ * Reads one page's editable body. `GetPageById` refuses an unspecified status,
407
+ * so the caller must name which version it wants: the draft when the summary
408
+ * says one exists, otherwise the version the summary reports.
409
+ */
410
+ export async function getPage(client, pageId, status) {
411
+ return requireObject(
412
+ await client.request(`v1/pages/${encodeURIComponent(pageId)}${query([["status", status]])}`),
413
+ "api.page_contract",
414
+ "page",
415
+ );
416
+ }
417
+
418
+ export function freeFormTemplate(body) {
419
+ return {
420
+ templateType: TEMPLATE_TYPE_FREE_FORM,
421
+ templateVersion: FREE_FORM_TEMPLATE_VERSION,
422
+ freeFormData: { body },
423
+ };
424
+ }
425
+
426
+ export async function createPage(client, body) {
427
+ return normalizePageSummary(await client.request("v1/pages", { method: "POST", body }));
428
+ }
429
+
430
+ export async function updatePage(client, pageId, body) {
431
+ return normalizePageSummary(
432
+ await client.request(`v1/pages/${encodeURIComponent(pageId)}`, { method: "PATCH", body }),
433
+ );
434
+ }
435
+
436
+ export async function publishDrafts(client, pageIds) {
437
+ const response = requireObject(
438
+ await client.request("v1/pages/publish_drafts", { method: "POST", body: { pageIds } }),
439
+ "api.page_contract",
440
+ "publish drafts",
441
+ );
442
+ return (Array.isArray(response.pages) ? response.pages : []).map(normalizePageSummary);
443
+ }
444
+
445
+ /**
446
+ * Reads the site's *draft* navigation tree — the one the editor and this CLI
447
+ * both work against.
448
+ *
449
+ * The environment is explicit and must be. `SITE_ENVIRONMENT_UNKNOWN` is the
450
+ * proto3 zero, so omitting the parameter does not mean "the default": the
451
+ * handler's `MapSiteNavigationEnvironment` throws `InvalidArgument` on it
452
+ * before it reads anything or checks anything. Draft is also the only branch
453
+ * that authorizes through `SiteThemeManage`, which is the permission a
454
+ * site-authoring key actually holds; production resolves against a published
455
+ * deployment instead.
456
+ */
457
+ export async function getNavigation(client, siteId) {
458
+ const search = query([["environment", SITE_ENVIRONMENT_DRAFT]]);
459
+ const response = requireObject(
460
+ await client.request(sitePath(siteId, `navigation${search}`)),
461
+ "api.navigation_contract",
462
+ "site navigation",
463
+ );
464
+ return Array.isArray(response.navItems) ? response.navItems : [];
465
+ }
466
+
467
+ export async function saveNavigation(client, siteId, navItems) {
468
+ const response = requireObject(
469
+ await client.request(sitePath(siteId, "navigation"), { method: "PUT", body: { siteId, navItems } }),
470
+ "api.navigation_contract",
471
+ "site navigation",
472
+ );
473
+ return Array.isArray(response.navItems) ? response.navItems : [];
474
+ }
475
+
476
+ /**
477
+ * Reads one *draft* settings group for the site. Same rule as navigation:
478
+ * `GetSettings` calls `RequireEnvironment`, so the zero value is refused rather
479
+ * than defaulted, and draft is the environment a key-authorized caller reads.
480
+ */
481
+ export async function getSettingsGroup(client, siteId, settingsType) {
482
+ const search = query([["entityId", siteId], ["environment", SITE_ENVIRONMENT_DRAFT]]);
483
+ return requireObject(
484
+ await client.request(`v1/settings/${encodeURIComponent(settingsType)}${search}`),
485
+ "api.settings_contract",
486
+ "settings",
487
+ );
488
+ }
489
+
490
+ export async function setSetting(client, siteId, settingsType, setting, value) {
491
+ await client.request("v1/setting", {
492
+ method: "POST",
493
+ body: { entityId: siteId, settingsType, setting, value },
494
+ });
495
+ }
496
+
497
+ export async function saveSiteFooterSettings(client, siteId, footerSettings, expectedFooterDraftHash) {
498
+ return requireObject(
499
+ await client.request(sitePath(siteId, "footer-settings"), {
500
+ method: "POST",
501
+ body: { siteId, footerSettings, expectedFooterDraftHash },
502
+ }),
503
+ "api.footer_settings_contract",
504
+ "footer settings",
505
+ );
506
+ }
507
+
508
+ export async function requestImageUpload(client, body) {
509
+ return requireObject(
510
+ await client.request("v1/images/request-upload", { method: "POST", body }),
511
+ "api.image_contract",
512
+ "image upload request",
513
+ );
514
+ }
515
+
516
+ export async function confirmImageUpload(client, uploadId) {
517
+ return requireObject(
518
+ await client.request("v1/images/confirm-upload", { method: "POST", body: { uploadId } }),
519
+ "api.image_contract",
520
+ "image upload confirmation",
521
+ );
522
+ }
523
+
524
+ export function normalizeImage(value) {
525
+ const image = requireObject(value, "api.image_contract", "image");
526
+ return {
527
+ imageId: requireIdentifier(image.imageId, "api.image_contract", "image.imageId"),
528
+ url: roundTrippedText(image.url),
529
+ responsiveUrls: (Array.isArray(image.responsiveUrls) ? image.responsiveUrls : []).map((value) => {
530
+ const responsive = requireObject(value, "api.image_contract", "image.responsiveUrls[]");
531
+ return { minWidth: safeCount(responsive.minWidth), url: roundTrippedText(responsive.url) };
532
+ }).filter((value) => value.url !== ""),
533
+ width: safeCount(image.width),
534
+ height: safeCount(image.height),
535
+ processingState: enumValue(
536
+ image.processingState,
537
+ IMAGE_PROCESSING_STATES,
538
+ IMAGE_PROCESSING_STATE_UNKNOWN,
539
+ "api.image_state",
540
+ "image processing state",
541
+ ),
542
+ uploadedName: text(image.uploadedName),
543
+ };
544
+ }
545
+
546
+ /**
547
+ * Processing is observed through `ListSiteImages`; `GetImageById` is
548
+ * session-only under TR00602's read list, so a key-authorized client watches
549
+ * the library rather than one image.
550
+ *
551
+ * `requestOptions` carries a poller's `{ deadline, now }` into *every* page of
552
+ * the listing. Without it a single read of a large library could spend far more
553
+ * than the caller's whole budget — the page bound alone permits hundreds of
554
+ * requests, each with its own request timeout and retry budget — and the
555
+ * deadline would only be noticed once the read finally returned.
556
+ */
557
+ export async function listSiteImages(client, siteId, requestOptions = {}) {
558
+ const images = [];
559
+ let pageToken = "";
560
+ let summary = { totalImages: 0, processingImages: 0 };
561
+ for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
562
+ const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
563
+ const response = requireObject(
564
+ await client.request(sitePath(siteId, `images${search}`), requestOptions),
565
+ "api.image_contract",
566
+ "site images",
567
+ );
568
+ summary = {
569
+ totalImages: safeCount(response.totalImages),
570
+ processingImages: safeCount(response.processingImages),
571
+ };
572
+ const batch = Array.isArray(response.images) ? response.images : [];
573
+ for (const entry of batch) {
574
+ const record = requireObject(entry, "api.image_contract", "image library entry");
575
+ const image = normalizeImage(record.image);
576
+ images.push({
577
+ ...image,
578
+ // The entry-level state is the library's authority; the embedded image
579
+ // carries the same value and is the fallback when the entry omits it.
580
+ processingState: enumValue(
581
+ record.processingState,
582
+ IMAGE_PROCESSING_STATES,
583
+ image.processingState,
584
+ "api.image_state",
585
+ "image processing state",
586
+ ),
587
+ processingFailureReason: text(record.processingFailureReason),
588
+ });
589
+ }
590
+ pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : "";
591
+ if (pageToken === "" || batch.length === 0) return { images, summary, truncated: false };
592
+ if (images.length >= MAXIMUM_IMAGES) break;
593
+ }
594
+ return { images, summary, truncated: true };
595
+ }
596
+
597
+ export async function getPublishingReadiness(client, siteId, selection = {}) {
598
+ const response = requireObject(
599
+ await client.request(sitePath(siteId, `publishing/readiness${query([
600
+ ["stagedPageIds", selection.stagedPageIds],
601
+ ["selectedSettingsTypes", selection.selectedSettingsTypes],
602
+ ["includeNavigation", selection.includeNavigation],
603
+ ])}`)),
604
+ "api.readiness_contract",
605
+ "publishing readiness",
606
+ );
607
+ const blockers = (Array.isArray(response.blockers) ? response.blockers : []).map((blocker) => ({
608
+ imageId: typeof blocker?.imageId === "string" ? blocker.imageId : "",
609
+ uploadedName: text(blocker?.uploadedName),
610
+ state: typeof blocker?.state === "string" ? blocker.state : "PAGE_PUBLISHING_READINESS_STATE_UNKNOWN",
611
+ message: text(blocker?.message),
612
+ }));
613
+ return {
614
+ state: typeof response.state === "string" ? response.state : "PAGE_PUBLISHING_READINESS_STATE_UNKNOWN",
615
+ approvedPageCount: safeCount(response.approvedPageCount),
616
+ selectedPageCount: safeCount(response.selectedPageCount),
617
+ blockedPageCount: safeCount(response.blockedPageCount),
618
+ hasCandidateChanges: response.hasCandidateChanges === true,
619
+ hasSuccessfulStagingDeployment: response.hasSuccessfulStagingDeployment === true,
620
+ blockers,
621
+ };
622
+ }
623
+
624
+ export async function getStagingPreviewStatus(client, siteId) {
625
+ const response = requireObject(
626
+ await client.request(sitePath(siteId, "staging-preview/status")),
627
+ "api.staging_preview_contract",
628
+ "staging preview status",
629
+ );
630
+ if (response.siteId !== siteId || typeof response.ready !== "boolean") {
631
+ throw new SiteAuthoringError(
632
+ "api.staging_preview_contract",
633
+ "Taproot returned an invalid staging preview status response.",
634
+ );
635
+ }
636
+ if (!response.ready) return { ready: false, stagingUrl: "" };
637
+
638
+ let url;
639
+ try {
640
+ url = new URL(response.stagingUrl);
641
+ } catch {
642
+ throw new SiteAuthoringError(
643
+ "api.staging_preview_contract",
644
+ "Taproot returned an invalid staging preview URL.",
645
+ { field: "stagingUrl" },
646
+ );
647
+ }
648
+ if (
649
+ url.protocol !== "https:"
650
+ || !CANONICAL_HOST.test(url.hostname)
651
+ || url.port !== ""
652
+ || url.username !== ""
653
+ || url.password !== ""
654
+ || url.pathname !== "/"
655
+ || url.search !== ""
656
+ || url.hash !== ""
657
+ ) {
658
+ throw new SiteAuthoringError(
659
+ "api.staging_preview_contract",
660
+ "Taproot returned an invalid staging preview URL.",
661
+ { field: "stagingUrl" },
662
+ );
663
+ }
664
+ return { ready: true, stagingUrl: `https://${url.hostname}/` };
665
+ }
666
+
667
+ export function normalizeDeployment(value) {
668
+ const deployment = requireObject(value, "api.deployment_contract", "deployment");
669
+ return {
670
+ id: requireIdentifier(deployment.id, "api.deployment_contract", "deployment.id"),
671
+ siteId: typeof deployment.siteId === "string" ? deployment.siteId : "",
672
+ // `DEPLOYMENT_STATUS_QUEUED` is the zero member, so an omitted status is a
673
+ // queued deployment rather than an unknown one.
674
+ status: enumValue(
675
+ deployment.status,
676
+ DEPLOYMENT_STATUSES,
677
+ DEPLOYMENT_STATUS_QUEUED,
678
+ "api.deployment_status",
679
+ "deployment status",
680
+ ),
681
+ environment: typeof deployment.environment === "string"
682
+ ? deployment.environment
683
+ : "DEPLOYMENT_ENVIRONMENT_UNKNOWN",
684
+ startedAt: text(deployment.startedAt),
685
+ completedAt: text(deployment.completedAt),
686
+ errorMessage: text(deployment.errorMessage),
687
+ pageCount: safeCount(deployment.pageCount),
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Reads one page of the deployment log. Unlike the page and image listings this
693
+ * deliberately does *not* paginate — a deployment history is unbounded and only
694
+ * its recent end is interesting — so it reports `truncated` and lets the caller
695
+ * say so rather than presenting one page's size as the site's total.
696
+ */
697
+ export async function listDeployments(
698
+ client,
699
+ siteId,
700
+ { environment, pageSize = DEPLOYMENT_PAGE_SIZE } = {},
701
+ requestOptions = {},
702
+ ) {
703
+ const response = requireObject(
704
+ await client.request(
705
+ sitePath(siteId, `deployments${query([
706
+ ["pageSize", pageSize],
707
+ ["includeStaging", true],
708
+ ["environment", environment],
709
+ ])}`),
710
+ requestOptions,
711
+ ),
712
+ "api.deployment_contract",
713
+ "site deployments",
714
+ );
715
+ return {
716
+ deployments: (Array.isArray(response.deployments) ? response.deployments : []).map(normalizeDeployment),
717
+ truncated: typeof response.nextPageToken === "string" && response.nextPageToken !== "",
718
+ };
719
+ }
720
+
721
+ export async function deploySite(client, siteId, body) {
722
+ const response = requireObject(
723
+ await client.request(sitePath(siteId, "deploy"), { method: "POST", body }),
724
+ "api.deployment_contract",
725
+ "deploy",
726
+ );
727
+ return normalizeDeployment(response.deployment);
728
+ }
729
+
730
+ /**
731
+ * Bounded polling, shaped after the Docs publisher's: a hard wall-clock
732
+ * deadline, a read budget derived from it, and a terminal state or a stable
733
+ * timeout code — never an unbounded wait. The SSE deployment stream is
734
+ * user-session-scoped and therefore unreachable from a key-authorized CLI, so
735
+ * polling is the only observation the contract offers here; the repo's
736
+ * no-polling rule governs UX surfaces and does not reach this one.
737
+ */
738
+ export async function poll({
739
+ client,
740
+ timeoutMilliseconds,
741
+ // The cadence is a parameter because one caller does not own it: the CLI
742
+ // authorization exchange polls at the interval the *server* published with
743
+ // the authorization, clamped by its own bounds. The read budget is derived
744
+ // from whatever interval is in force, so a slower cadence cannot turn into
745
+ // more reads than the deadline can pay for.
746
+ intervalMilliseconds = LIMITS.pollIntervalMilliseconds,
747
+ read,
748
+ evaluate,
749
+ timeoutCode,
750
+ timeoutError,
751
+ onProgress,
752
+ now,
753
+ }) {
754
+ const deadline = now() + timeoutMilliseconds;
755
+ const maximumReads = Math.ceil(timeoutMilliseconds / intervalMilliseconds) + 2;
756
+ for (let readCount = 0; readCount < maximumReads; readCount += 1) {
757
+ if (now() >= deadline) break;
758
+ let value;
759
+ try {
760
+ value = await read({ deadline, now });
761
+ } catch (error) {
762
+ if (error instanceof SiteAuthoringError && error.code === "transport.deadline") break;
763
+ throw error;
764
+ }
765
+ // A value in hand is evaluated even when the read finished at or past the
766
+ // deadline: discarding an answer that already arrived would report a
767
+ // timeout over a state the server has actually reached — for the claim
768
+ // poll, that would silently orphan a minted credential.
769
+ const result = evaluate(value);
770
+ if (result.done) return result.value;
771
+ // A tick with nothing new to report stays silent. The CLI-authorization
772
+ // poll runs every few seconds for up to fifteen minutes, and a line per
773
+ // tick scrolls the one thing the operator still needs — the code — off the
774
+ // top of the terminal.
775
+ if (result.progress !== undefined) onProgress(result.progress);
776
+ if (now() >= deadline) break;
777
+ await client.sleep(Math.min(intervalMilliseconds, Math.max(1, deadline - now())), client.signal);
778
+ }
779
+ throw typeof timeoutError === "function"
780
+ ? timeoutError()
781
+ : new SiteAuthoringError(
782
+ timeoutCode,
783
+ "Taproot did not reach a terminal state before the bounded deadline.",
784
+ );
785
+ }
786
+
787
+ export async function waitForDeployment(client, { siteId, deploymentId, environment, onProgress, now }) {
788
+ let misses = 0;
789
+ return await poll({
790
+ client,
791
+ now,
792
+ onProgress,
793
+ timeoutMilliseconds: LIMITS.deploymentMilliseconds,
794
+ read: async (requestOptions) =>
795
+ (await listDeployments(client, siteId, { environment }, requestOptions))
796
+ .deployments.find((deployment) => deployment.id === deploymentId),
797
+ evaluate: (deployment) => {
798
+ if (deployment === undefined) {
799
+ misses += 1;
800
+ if (misses > MAXIMUM_DEPLOYMENT_MISSES) {
801
+ throw new SiteAuthoringError(
802
+ "deploy.not_observable",
803
+ "Taproot accepted the deployment but never listed it, so its outcome cannot be observed.",
804
+ { field: "deploymentId" },
805
+ );
806
+ }
807
+ return { done: false, progress: "Waiting for the deployment to appear in the deployment log." };
808
+ }
809
+ misses = 0;
810
+ if (deployment.status === DEPLOYMENT_STATUS_COMPLETED) return { done: true, value: deployment };
811
+ if (deployment.status === DEPLOYMENT_STATUS_FAILED) {
812
+ throw new SiteAuthoringError(
813
+ "deploy.failed",
814
+ deployment.errorMessage
815
+ ? `The deployment failed: ${deployment.errorMessage}`
816
+ : "The deployment failed.",
817
+ { status: deployment.status },
818
+ );
819
+ }
820
+ return { done: false, progress: `Waiting for the deployment (${deployment.status}).` };
821
+ },
822
+ timeoutCode: "deploy.timeout",
823
+ });
824
+ }
825
+
826
+ /**
827
+ * The preview API is capability-adjacent: every identity and lifetime returned
828
+ * by it is checked before the CLI will mint or emit a browser URL. In
829
+ * particular, this does not use the permissive identifier helper used for
830
+ * ordinary resources. A preview is bound to three canonical UUIDs and one
831
+ * immutable draft digest, and a response that changes any of them is a
832
+ * contract failure rather than a new source of authority.
833
+ */
834
+ export function normalizeAuthoringPreview(value, expected = {}) {
835
+ const preview = requireObject(value, "preview.status_contract", "authoring preview");
836
+ const capturedAt = requireCanonicalTimestamp(
837
+ preview.capturedAt,
838
+ "preview.status_contract",
839
+ "capturedAt",
840
+ );
841
+ const expiresAt = requireCanonicalTimestamp(
842
+ preview.expiresAt,
843
+ "preview.status_contract",
844
+ "expiresAt",
845
+ );
846
+ if (
847
+ expiresAt.milliseconds <= capturedAt.milliseconds
848
+ || expiresAt.milliseconds - capturedAt.milliseconds !== AUTHORING_PREVIEW_LIFETIME_MILLISECONDS
849
+ ) {
850
+ throw new SiteAuthoringError(
851
+ "preview.status_contract",
852
+ "Taproot returned an invalid authoring-preview lifetime.",
853
+ { field: "expiresAt" },
854
+ );
855
+ }
856
+ if (typeof preview.draftRevision !== "string" || !CANONICAL_SHA256.test(preview.draftRevision)) {
857
+ throw new SiteAuthoringError(
858
+ "preview.status_contract",
859
+ "Taproot returned an invalid captured draft revision.",
860
+ { field: "draftRevision" },
861
+ );
862
+ }
863
+ const status = enumValue(
864
+ preview.status,
865
+ AUTHORING_PREVIEW_STATUSES,
866
+ "AUTHORING_PREVIEW_STATUS_UNSPECIFIED",
867
+ "preview.status_contract",
868
+ "authoring preview status",
869
+ );
870
+ if (status === "AUTHORING_PREVIEW_STATUS_UNSPECIFIED") {
871
+ throw new SiteAuthoringError(
872
+ "preview.status_contract",
873
+ "Taproot returned an unspecified authoring-preview status.",
874
+ { field: "status", status },
875
+ );
876
+ }
877
+ const failureCode = preview.failureCode === undefined || preview.failureCode === null
878
+ ? ""
879
+ : preview.failureCode;
880
+ if (typeof failureCode !== "string" || !AUTHORING_PREVIEW_FAILURES.has(failureCode)) {
881
+ throw new SiteAuthoringError(
882
+ "preview.status_contract",
883
+ "Taproot returned an unsupported authoring-preview failure code.",
884
+ { field: "failureCode" },
885
+ );
886
+ }
887
+ const terminalFailureMatches = (
888
+ status === AUTHORING_PREVIEW_STATUS_FAILED
889
+ && (
890
+ failureCode === AUTHORING_PREVIEW_FAILURE_RENDER_FAILED
891
+ || failureCode === AUTHORING_PREVIEW_FAILURE_RENDER_UNCLAIMED
892
+ || failureCode === AUTHORING_PREVIEW_FAILURE_ARTIFACT_MISSING
893
+ )
894
+ ) || (
895
+ status === AUTHORING_PREVIEW_STATUS_EXPIRED
896
+ && (failureCode === "" || failureCode === AUTHORING_PREVIEW_FAILURE_EXPIRED)
897
+ ) || (
898
+ status === AUTHORING_PREVIEW_STATUS_REVOKED
899
+ && (failureCode === "" || failureCode === AUTHORING_PREVIEW_FAILURE_REVOKED)
900
+ );
901
+ const nonTerminalWithoutFailure = (
902
+ status === AUTHORING_PREVIEW_STATUS_QUEUED
903
+ || status === AUTHORING_PREVIEW_STATUS_RENDERING
904
+ || status === AUTHORING_PREVIEW_STATUS_READY
905
+ ) && failureCode === "";
906
+ if (!terminalFailureMatches && !nonTerminalWithoutFailure) {
907
+ throw new SiteAuthoringError(
908
+ "preview.status_contract",
909
+ "Taproot returned an authoring-preview status inconsistent with its failure code.",
910
+ { field: "failureCode", status },
911
+ );
912
+ }
913
+ const normalized = {
914
+ siteId: requireCanonicalUuid(preview.siteId, "preview.status_contract", "siteId"),
915
+ pageId: requireCanonicalUuid(preview.pageId, "preview.status_contract", "pageId"),
916
+ snapshotId: requireCanonicalUuid(preview.snapshotId, "preview.status_contract", "snapshotId"),
917
+ status,
918
+ capturedAt: capturedAt.value,
919
+ capturedAtMilliseconds: capturedAt.milliseconds,
920
+ expiresAt: expiresAt.value,
921
+ expiresAtMilliseconds: expiresAt.milliseconds,
922
+ draftRevision: preview.draftRevision,
923
+ failureCode,
924
+ stagingHost: requireCanonicalStagingHost(preview.stagingHost),
925
+ };
926
+ for (const field of ["siteId", "pageId", "snapshotId"]) {
927
+ if (expected[field] !== undefined && normalized[field] !== expected[field]) {
928
+ throw new SiteAuthoringError(
929
+ "preview.status_contract",
930
+ `Taproot returned an authoring preview bound to a different ${field}.`,
931
+ { field },
932
+ );
933
+ }
934
+ }
935
+ return normalized;
936
+ }
937
+
938
+ export function normalizeAuthoringPreviewCreate(value, expected) {
939
+ const created = requireObject(value, "preview.create_contract", "authoring-preview create result");
940
+ // Protobuf JSON omits an empty repeated field. A cap reduction can also
941
+ // require evicting more snapshots than the newly configured cap, so the
942
+ // eviction list is bounded independently from the resulting budget.
943
+ const evictionValues = created.evictedPreviews ?? [];
944
+ let preview;
945
+ try {
946
+ preview = normalizeAuthoringPreview(created.preview, expected);
947
+ } catch (error) {
948
+ if (error instanceof SiteAuthoringError && error.code === "preview.status_contract") {
949
+ throw new SiteAuthoringError(
950
+ "preview.create_contract",
951
+ "Taproot returned an invalid created authoring preview.",
952
+ { field: "preview" },
953
+ );
954
+ }
955
+ throw error;
956
+ }
957
+ if (
958
+ !Number.isSafeInteger(created.storedPreviewCap)
959
+ || created.storedPreviewCap < 1
960
+ || !Number.isSafeInteger(created.storedPreviewCount)
961
+ || created.storedPreviewCount < 1
962
+ || created.storedPreviewCount > created.storedPreviewCap
963
+ || !Array.isArray(evictionValues)
964
+ || evictionValues.length > 100
965
+ ) {
966
+ throw new SiteAuthoringError(
967
+ "preview.create_contract",
968
+ "Taproot returned an invalid stored-preview budget.",
969
+ { field: "storedPreviewCount" },
970
+ );
971
+ }
972
+ const evictedPreviews = evictionValues.map((candidate) => {
973
+ const eviction = requireObject(candidate, "preview.create_contract", "evicted authoring preview");
974
+ return {
975
+ pageId: requireCanonicalUuid(eviction.pageId, "preview.create_contract", "evictedPreviews.pageId"),
976
+ snapshotId: requireCanonicalUuid(
977
+ eviction.snapshotId,
978
+ "preview.create_contract",
979
+ "evictedPreviews.snapshotId",
980
+ ),
981
+ capturedAt: requireCanonicalTimestamp(
982
+ eviction.capturedAt,
983
+ "preview.create_contract",
984
+ "evictedPreviews.capturedAt",
985
+ ).value,
986
+ };
987
+ });
988
+ if (
989
+ new Set(evictedPreviews.map((candidate) => candidate.snapshotId)).size !== evictedPreviews.length
990
+ || evictedPreviews.some((candidate) => candidate.snapshotId === preview.snapshotId)
991
+ ) {
992
+ throw new SiteAuthoringError(
993
+ "preview.create_contract",
994
+ "Taproot returned duplicate or self-referential authoring-preview evictions.",
995
+ { field: "evictedPreviews" },
996
+ );
997
+ }
998
+ return {
999
+ ...preview,
1000
+ storedPreviewCap: created.storedPreviewCap,
1001
+ storedPreviewCount: created.storedPreviewCount,
1002
+ evictedPreviews,
1003
+ };
1004
+ }
1005
+
1006
+ export async function createAuthoringPreview(client, siteId, pageId) {
1007
+ return normalizeAuthoringPreviewCreate(
1008
+ await client.request(sitePath(siteId, `authoring-previews/pages/${encodeURIComponent(pageId)}`), {
1009
+ method: "POST",
1010
+ body: { siteId, pageId },
1011
+ }),
1012
+ { siteId, pageId },
1013
+ );
1014
+ }
1015
+
1016
+ export async function getAuthoringPreview(client, expected, requestOptions = {}) {
1017
+ return normalizeAuthoringPreview(
1018
+ await client.request(sitePath(
1019
+ expected.siteId,
1020
+ `authoring-previews/pages/${encodeURIComponent(expected.pageId)}/${encodeURIComponent(expected.snapshotId)}`,
1021
+ ), requestOptions),
1022
+ expected,
1023
+ );
1024
+ }
1025
+
1026
+ export async function revokeAuthoringPreview(client, expected) {
1027
+ return normalizeAuthoringPreview(
1028
+ await client.request(sitePath(
1029
+ expected.siteId,
1030
+ `authoring-previews/pages/${encodeURIComponent(expected.pageId)}/${encodeURIComponent(expected.snapshotId)}`,
1031
+ ), { method: "DELETE" }),
1032
+ expected,
1033
+ );
1034
+ }
1035
+
1036
+ function assertAuthoringPreviewState(preview, now) {
1037
+ if (now() >= preview.expiresAtMilliseconds || preview.status === AUTHORING_PREVIEW_STATUS_EXPIRED) {
1038
+ throw new SiteAuthoringError("preview.expired", "The authoring preview has expired.", {
1039
+ status: preview.status,
1040
+ });
1041
+ }
1042
+ if (preview.status === AUTHORING_PREVIEW_STATUS_REVOKED) {
1043
+ throw new SiteAuthoringError("preview.revoked", "The authoring preview was revoked.", {
1044
+ status: preview.status,
1045
+ });
1046
+ }
1047
+ if (preview.status === AUTHORING_PREVIEW_STATUS_FAILED) {
1048
+ if (preview.failureCode === AUTHORING_PREVIEW_FAILURE_RENDER_UNCLAIMED) {
1049
+ throw new SiteAuthoringError(
1050
+ "preview.render_unclaimed",
1051
+ "The render service did not claim the authoring preview before its queue deadline.",
1052
+ { status: preview.status },
1053
+ );
1054
+ }
1055
+ throw new SiteAuthoringError("preview.render_failed", "Taproot could not render the authoring preview.", {
1056
+ status: preview.status,
1057
+ });
1058
+ }
1059
+ }
1060
+
1061
+ export async function waitForAuthoringPreview(client, { created, onProgress, now }) {
1062
+ let lastStatus = created.status;
1063
+ return await poll({
1064
+ client,
1065
+ now,
1066
+ onProgress,
1067
+ timeoutMilliseconds: LIMITS.previewMilliseconds,
1068
+ // Always perform the status read, even if the create response happened to
1069
+ // say READY. Status is the side-effect-free authority for render completion.
1070
+ read: async (requestOptions) => await getAuthoringPreview(client, created, requestOptions),
1071
+ evaluate: (preview) => {
1072
+ lastStatus = preview.status;
1073
+ if (!equalPreviewIdentity(preview, created)) {
1074
+ throw new SiteAuthoringError(
1075
+ "preview.status_contract",
1076
+ "Taproot changed immutable authoring-preview metadata while it was rendering.",
1077
+ );
1078
+ }
1079
+ assertAuthoringPreviewState(preview, now);
1080
+ if (preview.status === AUTHORING_PREVIEW_STATUS_READY) return { done: true, value: preview };
1081
+ return { done: false, progress: `Waiting for the authoring preview (${preview.status}).` };
1082
+ },
1083
+ timeoutCode: "preview.timeout",
1084
+ timeoutError: () => lastStatus === AUTHORING_PREVIEW_STATUS_QUEUED
1085
+ ? new SiteAuthoringError(
1086
+ "preview.timeout",
1087
+ `The render service has not claimed this job before the bounded deadline. Revoke it with: taproot-site preview revoke ${created.pageId} ${created.snapshotId}`,
1088
+ { status: lastStatus },
1089
+ )
1090
+ : new SiteAuthoringError(
1091
+ "preview.timeout",
1092
+ `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}`,
1093
+ { status: lastStatus },
1094
+ ),
1095
+ });
1096
+ }
1097
+
1098
+ function canonicalHandoffToken(value) {
1099
+ if (typeof value !== "string" || !CANONICAL_HANDOFF.test(value)) return false;
1100
+ try {
1101
+ const decoded = Buffer.from(value, "base64url");
1102
+ return decoded.byteLength === 32 && decoded.toString("base64url") === value;
1103
+ } catch {
1104
+ return false;
1105
+ }
1106
+ }
1107
+
1108
+ export function normalizeAuthoringPreviewHandoff(value, ready, now) {
1109
+ const handoff = requireObject(value, "preview.handoff_contract", "authoring-preview handoff");
1110
+ const identity = {
1111
+ siteId: requireCanonicalUuid(handoff.siteId, "preview.handoff_contract", "siteId"),
1112
+ pageId: requireCanonicalUuid(handoff.pageId, "preview.handoff_contract", "pageId"),
1113
+ snapshotId: requireCanonicalUuid(handoff.snapshotId, "preview.handoff_contract", "snapshotId"),
1114
+ };
1115
+ if (
1116
+ identity.siteId !== ready.siteId
1117
+ || identity.pageId !== ready.pageId
1118
+ || identity.snapshotId !== ready.snapshotId
1119
+ ) {
1120
+ throw new SiteAuthoringError(
1121
+ "preview.handoff_contract",
1122
+ "Taproot returned a handoff bound to a different authoring preview.",
1123
+ { field: "snapshotId" },
1124
+ );
1125
+ }
1126
+ let nested;
1127
+ try {
1128
+ nested = normalizeAuthoringPreview(handoff.preview, identity);
1129
+ } catch (error) {
1130
+ if (error instanceof SiteAuthoringError && error.code === "preview.status_contract") {
1131
+ throw new SiteAuthoringError(
1132
+ "preview.handoff_contract",
1133
+ "Taproot returned an invalid nested authoring-preview handoff binding.",
1134
+ { field: "preview" },
1135
+ );
1136
+ }
1137
+ throw error;
1138
+ }
1139
+ if (!equalPreviewIdentity(nested, ready) || nested.status !== AUTHORING_PREVIEW_STATUS_READY) {
1140
+ throw new SiteAuthoringError(
1141
+ "preview.handoff_contract",
1142
+ "Taproot returned a handoff whose preview binding does not match the ready snapshot.",
1143
+ { field: "preview" },
1144
+ );
1145
+ }
1146
+ const handoffExpiresAt = requireCanonicalTimestamp(
1147
+ handoff.handoffExpiresAt,
1148
+ "preview.handoff_contract",
1149
+ "handoffExpiresAt",
1150
+ );
1151
+ const previewExpiresAt = requireCanonicalTimestamp(
1152
+ handoff.previewExpiresAt,
1153
+ "preview.handoff_contract",
1154
+ "previewExpiresAt",
1155
+ );
1156
+ const currentTime = now();
1157
+ if (
1158
+ previewExpiresAt.milliseconds !== ready.expiresAtMilliseconds
1159
+ || handoffExpiresAt.milliseconds <= currentTime - AUTHORING_PREVIEW_CLOCK_SKEW_MILLISECONDS
1160
+ || handoffExpiresAt.milliseconds - currentTime
1161
+ > AUTHORING_PREVIEW_HANDOFF_LIFETIME_MILLISECONDS + AUTHORING_PREVIEW_CLOCK_SKEW_MILLISECONDS
1162
+ || handoffExpiresAt.milliseconds > previewExpiresAt.milliseconds
1163
+ ) {
1164
+ throw new SiteAuthoringError(
1165
+ "preview.handoff_contract",
1166
+ "Taproot returned an invalid authoring-preview handoff lifetime.",
1167
+ { field: "handoffExpiresAt" },
1168
+ );
1169
+ }
1170
+ if (typeof handoff.url !== "string" || handoff.url.length > 2_048) {
1171
+ throw new SiteAuthoringError(
1172
+ "preview.handoff_contract",
1173
+ "Taproot returned an invalid authoring-preview handoff URL.",
1174
+ { field: "url" },
1175
+ );
1176
+ }
1177
+ let url;
1178
+ try {
1179
+ url = new URL(handoff.url);
1180
+ } catch {
1181
+ throw new SiteAuthoringError(
1182
+ "preview.handoff_contract",
1183
+ "Taproot returned an invalid authoring-preview handoff URL.",
1184
+ { field: "url" },
1185
+ );
1186
+ }
1187
+ const handoffValues = url.searchParams.getAll("handoff");
1188
+ const token = handoffValues.length === 1 ? handoffValues[0] : "";
1189
+ const expectedPath = `${AUTHORING_PREVIEW_PATH_PREFIX}/${ready.pageId}/${ready.snapshotId}`;
1190
+ const expectedUrl = canonicalHandoffToken(token)
1191
+ ? `https://${ready.stagingHost}${expectedPath}?handoff=${token}`
1192
+ : "";
1193
+ if (
1194
+ url.protocol !== "https:"
1195
+ || url.hostname !== ready.stagingHost
1196
+ || url.port !== ""
1197
+ || url.username !== ""
1198
+ || url.password !== ""
1199
+ || url.hash !== ""
1200
+ || url.pathname !== expectedPath
1201
+ || [...url.searchParams].length !== 1
1202
+ || handoff.url !== expectedUrl
1203
+ ) {
1204
+ throw new SiteAuthoringError(
1205
+ "preview.handoff_contract",
1206
+ "Taproot returned an authoring-preview URL outside the bound staging capability.",
1207
+ { field: "url" },
1208
+ );
1209
+ }
1210
+ return {
1211
+ siteId: identity.siteId,
1212
+ pageId: identity.pageId,
1213
+ snapshotId: identity.snapshotId,
1214
+ url: handoff.url,
1215
+ handoffExpiresAt: handoffExpiresAt.value,
1216
+ previewExpiresAt: previewExpiresAt.value,
1217
+ preview: nested,
1218
+ };
1219
+ }
1220
+
1221
+ export async function mintAuthoringPreviewHandoff(client, ready, { now }) {
1222
+ let response;
1223
+ try {
1224
+ response = await client.replaceablePost(sitePath(
1225
+ ready.siteId,
1226
+ `authoring-previews/pages/${encodeURIComponent(ready.pageId)}/${encodeURIComponent(ready.snapshotId)}:mint-handoff`,
1227
+ ), {
1228
+ body: { siteId: ready.siteId, pageId: ready.pageId, snapshotId: ready.snapshotId },
1229
+ deadline: ready.expiresAtMilliseconds,
1230
+ now,
1231
+ });
1232
+ } catch (error) {
1233
+ if (error instanceof SiteAuthoringError && error.code === "transport.deadline") {
1234
+ throw new SiteAuthoringError("preview.expired", "The authoring preview expired before a handoff was minted.");
1235
+ }
1236
+ throw error;
1237
+ }
1238
+ return normalizeAuthoringPreviewHandoff(response, ready, now);
1239
+ }
1240
+
1241
+ // ---------------------------------------------------------------------------
1242
+ // CLI authorization (TR00634)
1243
+ // ---------------------------------------------------------------------------
1244
+
1245
+ /**
1246
+ * The device-authorization exchange `login` drives. Both endpoints are
1247
+ * unauthenticated and IP-rate-limited, so they are reached through an anonymous
1248
+ * client that can never hold a bearer.
1249
+ *
1250
+ * The claim status is the whole protocol. `CLI_AUTHORIZATION_CLAIM_STATUS_UNKNOWN`
1251
+ * is the proto3 zero and is therefore *omitted* from a JSON response — which is
1252
+ * exactly why an absent status is a contract violation here rather than
1253
+ * "still pending". The classification survives not because it aborts (it no
1254
+ * longer does — see `claimCliAuthorization`: an unreadable answer may follow
1255
+ * a committed mint, so `login` keeps polling) but because it stays
1256
+ * distinguishable from PENDING: it announces itself as a lost answer and, if
1257
+ * the deadline arrives first, ends in the MAY-have-been-issued guidance
1258
+ * instead of a plain not-approved timeout.
1259
+ */
1260
+ export const CLI_AUTHORIZATION_CLAIM_STATUS_PENDING = "CLI_AUTHORIZATION_CLAIM_STATUS_PENDING";
1261
+ export const CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED = "CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED";
1262
+ export const CLI_AUTHORIZATION_CLAIM_STATUS_DENIED = "CLI_AUTHORIZATION_CLAIM_STATUS_DENIED";
1263
+ export const CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED = "CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED";
1264
+ export const CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED = "CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED";
1265
+ export const CLI_AUTHORIZATION_CLAIM_STATUSES = Object.freeze([
1266
+ CLI_AUTHORIZATION_CLAIM_STATUS_PENDING,
1267
+ CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED,
1268
+ CLI_AUTHORIZATION_CLAIM_STATUS_DENIED,
1269
+ CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED,
1270
+ CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED,
1271
+ ]);
1272
+
1273
+ const CLI_AUTHORIZATION_PATH = "v1/site-authoring/cli-authorizations";
1274
+ const AUTHORABLE_SITES_PATH = "v1/site-authoring/authorable-sites";
1275
+ const TOKEN_EXCHANGE_PATH = "v1/site-authoring/tokens:exchange";
1276
+ const CLI_AUTHORIZATION_DEVICE_CODE = /^[A-Za-z0-9_-]{43}$/u;
1277
+ const CLI_AUTHORIZATION_USER_CODE = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/u;
1278
+ const CLI_AUTHORIZATION_MAXIMUM_EXPIRY_SECONDS = 60 * 60;
1279
+ const CLI_AUTHORIZATION_MAXIMUM_INTERVAL_SECONDS = 5 * 60;
1280
+
1281
+ function requireBoundedSeconds(value, minimum, maximum, field) {
1282
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
1283
+ throw new SiteAuthoringError("login.start_contract", `Taproot returned an invalid ${field}.`, { field });
1284
+ }
1285
+ return value;
1286
+ }
1287
+
1288
+ /**
1289
+ * Opens an authorization. Nothing durable is authorized yet, and no credential
1290
+ * exists — the response carries only the device code this process polls with
1291
+ * and the user code the owner will see in the browser.
1292
+ *
1293
+ * Deliberately *not* returned: any URL. The approval URL is composed by the
1294
+ * caller from the already-allowlisted API origin, so a compromised or
1295
+ * misconfigured server cannot steer the owner somewhere else to approve.
1296
+ */
1297
+ export async function startCliAuthorization(client, { keyName }, requestOptions = {}) {
1298
+ const response = requireObject(
1299
+ // No site: TR00645 made the account-level sign-in the front door, so the
1300
+ // CLI no longer has to know a site id before it can authorize at all.
1301
+ await client.request(CLI_AUTHORIZATION_PATH, { ...requestOptions, method: "POST", body: { keyName } }),
1302
+ "login.start_contract",
1303
+ "CLI authorization",
1304
+ );
1305
+ if (typeof response.deviceCode !== "string" || !CLI_AUTHORIZATION_DEVICE_CODE.test(response.deviceCode)) {
1306
+ throw new SiteAuthoringError("login.start_contract", "Taproot returned an invalid device code.", {
1307
+ field: "deviceCode",
1308
+ });
1309
+ }
1310
+ if (typeof response.userCode !== "string" || !CLI_AUTHORIZATION_USER_CODE.test(response.userCode)) {
1311
+ throw new SiteAuthoringError("login.start_contract", "Taproot returned an invalid user code.", {
1312
+ field: "userCode",
1313
+ });
1314
+ }
1315
+ return {
1316
+ deviceCode: response.deviceCode,
1317
+ userCode: response.userCode,
1318
+ expiresInSeconds: requireBoundedSeconds(
1319
+ response.expiresInSeconds,
1320
+ 1,
1321
+ CLI_AUTHORIZATION_MAXIMUM_EXPIRY_SECONDS,
1322
+ "expiresInSeconds",
1323
+ ),
1324
+ pollIntervalSeconds: requireBoundedSeconds(
1325
+ response.pollIntervalSeconds,
1326
+ 1,
1327
+ CLI_AUTHORIZATION_MAXIMUM_INTERVAL_SECONDS,
1328
+ "pollIntervalSeconds",
1329
+ ),
1330
+ };
1331
+ }
1332
+
1333
+ /**
1334
+ * Validates an `ISSUED` payload under its own code, `login.claim_unusable`,
1335
+ * never the generic `login.claim_contract`: by the time this runs the server
1336
+ * has said ISSUED, so the mint has already committed and a malformed field is
1337
+ * not "no answer" — it is an answer this side cannot use. The distinct code
1338
+ * lets `login` treat it as an ambiguous issuance (keep polling; the next
1339
+ * claim answers CONSUMED and names the exact key) instead of aborting behind
1340
+ * a contract error that hides a live credential.
1341
+ */
1342
+ /**
1343
+ * The sites the sign-in credential's account may author (TR00645).
1344
+ *
1345
+ * The server has already filtered these to sites an exchange would accept, so
1346
+ * anything listed here is a site `use` can select and the next command can
1347
+ * author — a picker that offered otherwise would be worse than no picker.
1348
+ */
1349
+ export async function listAuthorableSites(client, requestOptions = {}) {
1350
+ const response = requireObject(
1351
+ await client.request(AUTHORABLE_SITES_PATH, requestOptions),
1352
+ "sites.contract",
1353
+ "authorable site list",
1354
+ );
1355
+ const sites = Array.isArray(response.sites) ? response.sites : [];
1356
+ return sites.map((site) => {
1357
+ const value = requireObject(site, "sites.contract", "authorable site");
1358
+ return {
1359
+ siteId: requireCanonicalUuid(value.siteId, "sites.contract", "siteId"),
1360
+ name: typeof value.name === "string" ? value.name : "",
1361
+ primaryDomain: typeof value.primaryDomain === "string" ? value.primaryDomain : "",
1362
+ };
1363
+ });
1364
+ }
1365
+
1366
+ /**
1367
+ * Exchanges the sign-in credential for a short-lived site credential (TR00645).
1368
+ *
1369
+ * The secret crosses the wire once and is never persisted: the exchanged
1370
+ * credential lives in memory for the run that asked for it. That is what lets
1371
+ * this be retried freely — a lost response costs one more exchange, and the
1372
+ * credential nobody received expires on its own within the hour.
1373
+ */
1374
+ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities = [] }, requestOptions = {}) {
1375
+ const response = requireObject(
1376
+ await client.request(TOKEN_EXCHANGE_PATH, {
1377
+ ...requestOptions,
1378
+ method: "POST",
1379
+ body: { siteId, capabilities },
1380
+ }),
1381
+ "exchange.contract",
1382
+ "token exchange",
1383
+ );
1384
+ if (!isWellFormedCredential(response.rawKey)) {
1385
+ // The value is never quoted, here or anywhere: it is the secret.
1386
+ throw new SiteAuthoringError("exchange.contract", "Taproot returned an invalid exchanged key.", {
1387
+ field: "rawKey",
1388
+ });
1389
+ }
1390
+ return {
1391
+ key: response.rawKey,
1392
+ keyId: requireCanonicalUuid(response.keyId, "exchange.contract", "keyId"),
1393
+ keyPrefix: typeof response.keyPrefix === "string" && KEY_PREFIX.test(response.keyPrefix)
1394
+ ? response.keyPrefix
1395
+ : "",
1396
+ siteId: requireCanonicalUuid(response.siteId, "exchange.contract", "siteId"),
1397
+ expiresAt: requireCanonicalTimestamp(response.expiresAt, "exchange.contract", "expiresAt").value,
1398
+ capabilities: Array.isArray(response.capabilities)
1399
+ ? response.capabilities.filter((entry) => typeof entry === "string")
1400
+ : [],
1401
+ // The sign-in's refreshed deadline. Not a secret, and absent on a server
1402
+ // that predates the sliding window.
1403
+ ...(typeof response.signInExpiresAt === "string" && response.signInExpiresAt.length > 0
1404
+ ? { signInExpiresAt: requireCanonicalTimestamp(response.signInExpiresAt, "exchange.contract", "signInExpiresAt").value }
1405
+ : {}),
1406
+ };
1407
+ }
1408
+
1409
+ function normalizeIssuedCredential(response) {
1410
+ if (!isWellFormedCredential(response.rawKey)) {
1411
+ // The value is never quoted, here or anywhere: it is the secret.
1412
+ throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an invalid issued key.", {
1413
+ field: "rawKey",
1414
+ });
1415
+ }
1416
+ const keyId = requireCanonicalUuid(response.keyId, "login.claim_unusable", "keyId");
1417
+ if (typeof response.keyPrefix !== "string" || !KEY_PREFIX.test(response.keyPrefix)) {
1418
+ throw new SiteAuthoringError("login.claim_unusable", "Taproot returned an invalid key prefix.", {
1419
+ field: "keyPrefix",
1420
+ });
1421
+ }
1422
+ // The account-level sign-in TR00645 made the default names an account and no
1423
+ // site. A site-scoped claim still names a site, and both shapes stay readable
1424
+ // so a server serving either can be talked to.
1425
+ const accountId = requireCanonicalUuid(response.accountId, "login.claim_unusable", "accountId");
1426
+ // Absent means "never expires", which is a legitimate owner choice on the
1427
+ // approval screen — so absence is not a contract failure, but a malformed
1428
+ // value is.
1429
+ const keyExpiresAt = response.keyExpiresAt === undefined || response.keyExpiresAt === null
1430
+ || response.keyExpiresAt === ""
1431
+ ? undefined
1432
+ : requireCanonicalTimestamp(response.keyExpiresAt, "login.claim_unusable", "keyExpiresAt").value;
1433
+ return {
1434
+ key: response.rawKey,
1435
+ keyId,
1436
+ keyPrefix: response.keyPrefix,
1437
+ accountId,
1438
+ ...(keyExpiresAt === undefined ? {} : { keyExpiresAt }),
1439
+ };
1440
+ }
1441
+
1442
+ /**
1443
+ * One poll of the claim endpoint. The POST is replayed across transport-level
1444
+ * retries on purpose: the claim is a guarded single-use transition, so a replay
1445
+ * after a lost response cannot mint a second key — the server answers
1446
+ * `..._CONSUMED`, and the caller reports the orphaned credential honestly
1447
+ * rather than hiding it behind a network error.
1448
+ */
1449
+ export async function claimCliAuthorization(client, deviceCode, requestOptions = {}) {
1450
+ // Every `login.claim_contract` raised here — a non-object envelope, an
1451
+ // absent status, a status string this vocabulary does not know — is an
1452
+ // answer this client could not read, not proof the claim was refused. A
1453
+ // serializer fault or ordinary version skew (a newer server adding a
1454
+ // status value) produces exactly these shapes AFTER the handler committed
1455
+ // the mint, so `login` treats the code as a possible lost issuance and
1456
+ // keeps polling rather than aborting behind a generic error. The code
1457
+ // stays distinct from `login.claim_unusable`, which means the stronger
1458
+ // thing: the server said ISSUED, so the mint definitely happened.
1459
+ const response = requireObject(
1460
+ await client.replaceablePost(`${CLI_AUTHORIZATION_PATH}/claim`, { ...requestOptions, body: { deviceCode } }),
1461
+ "login.claim_contract",
1462
+ "CLI authorization claim",
1463
+ );
1464
+ const status = response.status;
1465
+ if (typeof status !== "string" || !CLI_AUTHORIZATION_CLAIM_STATUSES.includes(status)) {
1466
+ throw new SiteAuthoringError(
1467
+ "login.claim_contract",
1468
+ "Taproot returned an unsupported CLI authorization claim status.",
1469
+ { field: "status", ...(typeof status === "string" ? { status } : {}) },
1470
+ );
1471
+ }
1472
+ if (status === CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED) {
1473
+ return { status, credential: normalizeIssuedCredential(response) };
1474
+ }
1475
+ if (status === CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED) {
1476
+ // A consumed answer names the credential an earlier claim minted, so the
1477
+ // caller can direct the owner to revoke exactly that key. The metadata is
1478
+ // display-only guidance: a malformed value is dropped rather than refused,
1479
+ // because failing here would hide the very orphan the answer exists to
1480
+ // surface — the fallback guidance names the credential by its display
1481
+ // name instead.
1482
+ const keyId = isCanonicalUuid(response.keyId) ? response.keyId : undefined;
1483
+ const keyPrefix = typeof response.keyPrefix === "string" && KEY_PREFIX.test(response.keyPrefix)
1484
+ ? response.keyPrefix
1485
+ : undefined;
1486
+ return {
1487
+ status,
1488
+ ...(keyId === undefined ? {} : { keyId }),
1489
+ ...(keyPrefix === undefined ? {} : { keyPrefix }),
1490
+ };
1491
+ }
1492
+ return { status };
1493
+ }
1494
+
1495
+ /** Maps only preview-domain validation fields; authentication/refusal identity stays an ApiError. */
1496
+ export function translateAuthoringPreviewApiError(error) {
1497
+ if (!(error instanceof ApiError) || error.refusalKind() !== REFUSAL_UNCLASSIFIED) return error;
1498
+ for (const [field, [code, message]] of Object.entries(AUTHORING_PREVIEW_FIELD_ERRORS)) {
1499
+ if (error.hasField(field)) {
1500
+ const passesServerGuidance = field === AUTHORING_PREVIEW_FIELDS.authorityCapacity
1501
+ || field === AUTHORING_PREVIEW_FIELDS.siteCapacity;
1502
+ const serverGuidance = passesServerGuidance ? error.descriptionFor(field) : undefined;
1503
+ return new SiteAuthoringError(code, serverGuidance ?? message, { field, status: error.status });
1504
+ }
1505
+ }
1506
+ return error;
1507
+ }