@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.
- package/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- 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
|
+
}
|