@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
@@ -0,0 +1,777 @@
1
+ import {
2
+ CLI_NAME,
3
+ CLI_VERSION,
4
+ CREDENTIAL_REFUSAL_FIELD,
5
+ GRPC_RESOURCE_EXHAUSTED,
6
+ GRPC_UNAUTHENTICATED,
7
+ HTTP_TOO_MANY_REQUESTS,
8
+ HTTP_UNAUTHORIZED,
9
+ LIMITS,
10
+ PLAN_LIMIT_REFUSAL_FIELD,
11
+ PUBLISH_KEY_ENVIRONMENT_VARIABLE,
12
+ REFUSAL_CREDENTIAL_REJECTED,
13
+ REFUSAL_PLAN_LIMIT,
14
+ REFUSAL_PLATFORM_PAUSED,
15
+ REFUSAL_THROTTLED,
16
+ REFUSAL_UNCLASSIFIED,
17
+ ROLLOUT_REFUSAL_FIELD,
18
+ } from "./constants.js";
19
+ import { validateApiBaseUrl } from "./config.js";
20
+ import { hasControlCharacter, SiteAuthoringError } from "./errors.js";
21
+
22
+ // Every method the authoring verbs need. `pages push` updates with PATCH, page
23
+ // deletion is DELETE, and `nav push` replaces the whole tree with PUT.
24
+ const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
25
+
26
+ // Only GET may be replayed on the general API surface. None of the ordinary
27
+ // authoring mutations carry an idempotency key — TR00602 defined none — so a
28
+ // replayed POST duplicates a page, a replayed PATCH re-applies an update over an edit
29
+ // that landed in between, a replayed DELETE removes a resource the first
30
+ // attempt already removed, and a replayed navigation PUT re-asserts a tree
31
+ // after someone else changed it. An ambiguous mutation is reported as
32
+ // ambiguous, never guessed at. The one PUT that *is* replayed is the
33
+ // presigned whole-object upload in `upload()`, which writes immutable bytes
34
+ // to one key and is therefore genuinely idempotent. Two POSTs have their own
35
+ // narrow `replaceablePost()` entry point, each because its own server contract
36
+ // answers a replay rather than duplicating work: see
37
+ // `REPLACEABLE_POST_PATHS` below.
38
+ const IDEMPOTENT_METHODS = new Set(["GET"]);
39
+
40
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
41
+ const RETRYABLE_RESPONSE_ERRORS = new Set([
42
+ "transport.invalid_json",
43
+ "transport.response_read",
44
+ ]);
45
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u;
46
+ /**
47
+ * Error codes that prove a request never reached the server: the name did not
48
+ * resolve, the connection was refused or never established, or the TLS
49
+ * handshake failed. None of them can leave a mutation half-applied, because
50
+ * none of them got as far as sending one.
51
+ *
52
+ * Deliberately a list of the provable cases rather than a guess at the rest. A
53
+ * socket that dies mid-flight *is* ambiguous, and quietly reclassifying that as
54
+ * "never happened" is the one mistake here with real consequences.
55
+ */
56
+ const UNDELIVERED_CAUSE_CODES = new Set([
57
+ "ENOTFOUND",
58
+ "EAI_AGAIN",
59
+ "ECONNREFUSED",
60
+ "EHOSTUNREACH",
61
+ "ENETUNREACH",
62
+ "ERR_TLS_CERT_ALTNAME_INVALID",
63
+ "CERT_HAS_EXPIRED",
64
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
65
+ "SELF_SIGNED_CERT_IN_CHAIN",
66
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
67
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
68
+ ]);
69
+
70
+ function transportCauseCode(error) {
71
+ const code = error?.cause?.code ?? error?.code;
72
+ return typeof code === "string" ? code : undefined;
73
+ }
74
+
75
+ function isUndelivered(error) {
76
+ const code = transportCauseCode(error);
77
+ return code !== undefined && UNDELIVERED_CAUSE_CODES.has(code);
78
+ }
79
+
80
+ /**
81
+ * The underlying cause, named.
82
+ *
83
+ * `fetch` reports every one of these as "fetch failed", so discarding the cause
84
+ * — which this did — left a certificate problem, a typo in a hostname, and a
85
+ * server that is simply down producing one identical sentence. Only the code is
86
+ * surfaced, never the message: codes are a fixed vocabulary, while messages can
87
+ * carry a URL and this text reaches logs.
88
+ */
89
+ function describeTransportCause(error) {
90
+ const code = transportCauseCode(error);
91
+ if (code === undefined || !SAFE_CAUSE_CODE.test(code)) return undefined;
92
+ return code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || code === "SELF_SIGNED_CERT_IN_CHAIN"
93
+ || code === "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" || code === "DEPTH_ZERO_SELF_SIGNED_CERT"
94
+ ? `${code}: the server's TLS certificate was not trusted`
95
+ : code;
96
+ }
97
+
98
+ const SAFE_CAUSE_CODE = /^[A-Z0-9_]{1,64}$/u;
99
+
100
+ // Exported because the credential store's pre-mint size projection has to
101
+ // stand in for a key it has not been issued yet, and the only honest
102
+ // stand-in is the longest one this bound would accept.
103
+ export const MAXIMUM_TOKEN_LENGTH = 512;
104
+ const MAXIMUM_UPLOAD_URL_LENGTH = 16_384;
105
+ const MAXIMUM_UPLOAD_HEADERS = 64;
106
+ const MAXIMUM_HEADER_VALUE_LENGTH = 4_096;
107
+ const UUID_PATH = "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
108
+
109
+ /**
110
+ * The closed set of POSTs whose server contract makes a replay both safe and
111
+ * necessary when the first response is lost. Membership is a reviewed property
112
+ * of the server handler, never a caller's option — `request()` deliberately
113
+ * exposes no `replayable` flag, so an ordinary mutation cannot opt in.
114
+ *
115
+ * - **Authoring-preview handoff mint** — its transaction atomically replaces
116
+ * any prior unconsumed handoff, so a second attempt yields the same single
117
+ * live handoff rather than a second one.
118
+ * - **CLI authorization claim** — the claim is a guarded single-use status
119
+ * transition. A replay after a lost response does not mint a second key: the
120
+ * server answers `..._CONSUMED`, which `login` surfaces as an issued-but-
121
+ * unreceived credential the owner must revoke. Not replaying would leave that
122
+ * same orphan and report only a network error, which is strictly worse.
123
+ */
124
+ const REPLACEABLE_POST_PATHS = Object.freeze([
125
+ new RegExp(
126
+ `^v1/sites/${UUID_PATH}/authoring-previews/pages/${UUID_PATH}/${UUID_PATH}:mint-handoff$`,
127
+ "u",
128
+ ),
129
+ /^v1\/site-authoring\/cli-authorizations\/claim$/u,
130
+ ]);
131
+
132
+ /**
133
+ * The one shape a site-authoring credential may have before it is allowed near
134
+ * an Authorization header. Exported because the credential store applies the
135
+ * same check to a value it read from disk: a stored key reaches exactly the
136
+ * same header the environment variable does, so it must clear exactly the same
137
+ * bar, and a second, drifting copy of this rule is how that stops being true.
138
+ */
139
+ export function isWellFormedCredential(value) {
140
+ return typeof value === "string"
141
+ && value.length > 0
142
+ && value.length <= MAXIMUM_TOKEN_LENGTH
143
+ && !hasControlCharacter(value);
144
+ }
145
+
146
+ /**
147
+ * The internal marker that selects the anonymous construction path. It is a
148
+ * module-private symbol, so `SiteApiClient.anonymous()` is the only way to
149
+ * reach that path and no caller can pass a "token" that turns into one — nor
150
+ * can an anonymous client ever be handed a bearer, because the factory
151
+ * overwrites whatever `token` it was given.
152
+ */
153
+ const ANONYMOUS_CREDENTIAL = Symbol("anonymous site authoring API client");
154
+
155
+ function delayForAttempt(attempt) {
156
+ return Math.min(250 * (2 ** attempt), 2_000);
157
+ }
158
+
159
+ function operationSignal(parent, timeoutMilliseconds, timeoutSignal) {
160
+ const timeout = timeoutSignal(timeoutMilliseconds);
161
+ return parent ? AbortSignal.any([parent, timeout]) : timeout;
162
+ }
163
+
164
+ /**
165
+ * A cancellation, tagged with whether a mutation may already have run.
166
+ *
167
+ * Ctrl+C can land in five places inside one request: before an attempt is
168
+ * sent, while it is on the wire, while its body is being read, during the
169
+ * backoff between attempts, and while an error body is being read. Only some
170
+ * of those mean the server may have acted — an abort during backoff after
171
+ * nothing but certificate failures means no request ever left. `login` reads
172
+ * this tag to decide whether to warn about a possible orphan, and without it
173
+ * every cancellation warned, sending operators to revoke nothing.
174
+ */
175
+ function cancelledError(ambiguousMutation = false) {
176
+ const error = new SiteAuthoringError("site.cancelled", "The Taproot site authoring command was cancelled.");
177
+ error.ambiguousMutation = ambiguousMutation;
178
+ return error;
179
+ }
180
+
181
+ function deadlineError() {
182
+ return new SiteAuthoringError("transport.deadline", "The Taproot API request exceeded its polling deadline.");
183
+ }
184
+
185
+ function remainingBudget(deadline, now) {
186
+ return deadline === undefined ? Number.POSITIVE_INFINITY : deadline - now();
187
+ }
188
+
189
+ async function waitForRetry(client, attempt, deadline, now, ambiguousMutation) {
190
+ const remaining = remainingBudget(deadline, now);
191
+ if (remaining <= 0) throw deadlineError();
192
+ try {
193
+ await client.sleep(Math.min(delayForAttempt(attempt), remaining), client.signal);
194
+ } catch (error) {
195
+ // The sleep — default or injected — raises a bare cancellation. Re-raise
196
+ // it carrying what the loop knows: whether any attempt so far left a
197
+ // mutation possibly applied. During backoff nothing is in flight, so that
198
+ // history is the whole answer.
199
+ if (error instanceof SiteAuthoringError && error.code === "site.cancelled") {
200
+ throw cancelledError(ambiguousMutation);
201
+ }
202
+ throw error;
203
+ }
204
+ if (remainingBudget(deadline, now) <= 0) throw deadlineError();
205
+ }
206
+
207
+ async function awaitWithSignal(promise, signal) {
208
+ if (signal.aborted) throw new Error("The operation was aborted.");
209
+ return await new Promise((resolve, reject) => {
210
+ const abort = () => {
211
+ signal.removeEventListener("abort", abort);
212
+ reject(new Error("The operation was aborted."));
213
+ };
214
+ signal.addEventListener("abort", abort, { once: true });
215
+ Promise.resolve(promise).then(
216
+ (value) => {
217
+ signal.removeEventListener("abort", abort);
218
+ resolve(value);
219
+ },
220
+ (error) => {
221
+ signal.removeEventListener("abort", abort);
222
+ reject(error);
223
+ },
224
+ );
225
+ });
226
+ }
227
+
228
+ async function cancelBody(response, signal) {
229
+ const cancellation = response.body?.cancel();
230
+ if (!cancellation) return;
231
+ if (signal) await awaitWithSignal(cancellation, signal).catch(() => {});
232
+ else await cancellation.catch(() => {});
233
+ }
234
+
235
+ async function readBoundedBody(response, signal, maximumBytes = LIMITS.apiResponseBytes) {
236
+ if (response.body === null) return Buffer.alloc(0);
237
+ const reader = response.body.getReader();
238
+ const chunks = [];
239
+ let total = 0;
240
+ try {
241
+ while (true) {
242
+ const next = await awaitWithSignal(reader.read(), signal);
243
+ if (next.done) break;
244
+ total += next.value.byteLength;
245
+ if (total > maximumBytes) {
246
+ await reader.cancel();
247
+ throw new SiteAuthoringError(
248
+ "transport.response_too_large",
249
+ "A Taproot API response exceeded its byte limit.",
250
+ );
251
+ }
252
+ chunks.push(next.value);
253
+ }
254
+ } catch (error) {
255
+ if (error instanceof SiteAuthoringError) throw error;
256
+ throw new SiteAuthoringError("transport.response_read", "A Taproot API response could not be read.");
257
+ }
258
+ const body = Buffer.allocUnsafe(total);
259
+ let offset = 0;
260
+ for (const chunk of chunks) {
261
+ body.set(chunk, offset);
262
+ offset += chunk.byteLength;
263
+ }
264
+ return body;
265
+ }
266
+
267
+ /**
268
+ * Breadth-first collection of every `field` a rejection names. The API nests
269
+ * validation failures under `details[].fieldViolations[]` and, for some
270
+ * refusals, one level deeper still, so the extraction walks the whole bounded
271
+ * shape rather than pinning one path that a contract change would silently
272
+ * empty.
273
+ */
274
+ export function fieldViolations(value) {
275
+ const fields = new Set();
276
+ const queue = [{ value, depth: 0 }];
277
+ let work = 0;
278
+ while (queue.length > 0 && work < 1_000) {
279
+ const current = queue.shift();
280
+ work += 1;
281
+ if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
282
+ if (
283
+ typeof current.value.field === "string"
284
+ && current.value.field.length > 0
285
+ && current.value.field.length <= 200
286
+ && !hasControlCharacter(current.value.field)
287
+ ) {
288
+ fields.add(current.value.field);
289
+ }
290
+ for (const child of Object.values(current.value)) {
291
+ if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
292
+ }
293
+ }
294
+ return [...fields].sort();
295
+ }
296
+
297
+ /**
298
+ * The bounded walk again, keeping each named field's `description`. Kept
299
+ * separate from `fieldViolations` so its sorted-string contract stays put.
300
+ * Descriptions are operator-authored copy (the plan-limit message names the
301
+ * remedy), so refusing to carry them would leave the caller guessing at
302
+ * server policy — but they are still untrusted text: length-capped and
303
+ * control-character-refused before they can reach an output stream.
304
+ */
305
+ function fieldDescriptions(value) {
306
+ const descriptions = new Map();
307
+ const queue = [{ value, depth: 0 }];
308
+ let work = 0;
309
+ while (queue.length > 0 && work < 1_000) {
310
+ const current = queue.shift();
311
+ work += 1;
312
+ if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
313
+ if (
314
+ typeof current.value.field === "string"
315
+ && current.value.field.length > 0
316
+ && current.value.field.length <= 200
317
+ && !hasControlCharacter(current.value.field)
318
+ && typeof current.value.description === "string"
319
+ && current.value.description.length > 0
320
+ && current.value.description.length <= LIMITS.diagnosticScalars
321
+ && !hasControlCharacter(current.value.description)
322
+ && !descriptions.has(current.value.field)
323
+ ) {
324
+ descriptions.set(current.value.field, current.value.description);
325
+ }
326
+ for (const child of Object.values(current.value)) {
327
+ if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
328
+ }
329
+ }
330
+ return descriptions;
331
+ }
332
+
333
+ export class ApiError extends SiteAuthoringError {
334
+ constructor(httpStatus, body) {
335
+ const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
336
+ const fields = fieldViolations(body);
337
+ super(
338
+ "api.request_rejected",
339
+ fields.length > 0
340
+ ? `Taproot rejected the request field '${fields[0]}'.`
341
+ : `Taproot rejected the request with HTTP ${httpStatus}.`,
342
+ { field: fields[0], status: grpcCode === undefined ? `http:${httpStatus}` : `grpc:${grpcCode}` },
343
+ );
344
+ this.httpStatus = httpStatus;
345
+ this.grpcCode = grpcCode;
346
+ this.fields = fields;
347
+ this.violationDescriptions = fieldDescriptions(body);
348
+ }
349
+
350
+ hasField(field) {
351
+ return this.fields.some((candidate) => candidate.toLowerCase() === field.toLowerCase());
352
+ }
353
+
354
+ /**
355
+ * The server's own wording for one violated field, or undefined. The
356
+ * deploy-time plan-limit refusal is the canonical consumer: its description
357
+ * names the remedy ("Upgrade to publish another page"), which the CLI must
358
+ * surface rather than paraphrase away.
359
+ */
360
+ descriptionFor(field) {
361
+ for (const [candidate, description] of this.violationDescriptions) {
362
+ if (candidate.toLowerCase() === field.toLowerCase()) return description;
363
+ }
364
+ return undefined;
365
+ }
366
+
367
+ /**
368
+ * Classifies a refusal into the one thing the caller should do next. The
369
+ * four kinds are genuinely different behaviors, and collapsing them into
370
+ * "the request failed" is what makes an agent retry a revoked credential
371
+ * forever or treat a plan ceiling as a transient outage:
372
+ *
373
+ * - `platform_paused` (field `SiteAuthoringRollout`) — external authoring is
374
+ * switched off platform-wide. **Retry later** or ask the operator; the
375
+ * credential and the request are both fine.
376
+ * - `credential_rejected` (gRPC `Unauthenticated` / HTTP 401, or field
377
+ * `ExternalApiKey`) — the key is invalid, revoked, expired, or bound to a
378
+ * different site. **Stop and re-issue**; retrying cannot succeed and
379
+ * repeated attempts look like credential stuffing.
380
+ *
381
+ * Two shapes reach this, and only one of them carries a field.
382
+ * `SiteAuthoringAuthorityResolver` refuses at the boundary with a bare
383
+ * `RpcException(Unauthenticated)` and no details — deliberately, so the
384
+ * public surface cannot be used to enumerate key, site, or account state —
385
+ * which transcodes to `401 {"code":16,...}`. That is the *most likely*
386
+ * refusal this CLI will ever see, and classifying it on the field alone
387
+ * left it unclassified: no `refusal` in the JSON result, and nothing said
388
+ * on the human channel. The field arrives only from the in-transaction
389
+ * re-validation, which does attach `ExternalApiKey`.
390
+ * - `plan_limit` (field `UpgradePrompt`) — a commercial ceiling, surfaced at
391
+ * deploy time because no pre-flight read of the published-page quota
392
+ * exists on the contract. **Upgrade** (or publish fewer pages); the CLI
393
+ * never invents the numeric limit.
394
+ * - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
395
+ * throttle. **Back off** and retry with delay.
396
+ *
397
+ * Anything else is `unclassified`, which is a signal to surface the raw
398
+ * status rather than to guess.
399
+ */
400
+ refusalKind() {
401
+ // Fields first: they are the specific statement, and a named refusal
402
+ // outranks whatever status happens to carry it.
403
+ if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
404
+ if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
405
+ if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
406
+ if (this.grpcCode === GRPC_RESOURCE_EXHAUSTED || this.httpStatus === HTTP_TOO_MANY_REQUESTS) {
407
+ return REFUSAL_THROTTLED;
408
+ }
409
+ if (this.grpcCode === GRPC_UNAUTHENTICATED || this.httpStatus === HTTP_UNAUTHORIZED) {
410
+ return REFUSAL_CREDENTIAL_REJECTED;
411
+ }
412
+ return REFUSAL_UNCLASSIFIED;
413
+ }
414
+ }
415
+
416
+ async function parseJsonResponse(response, signal) {
417
+ const bytes = await readBoundedBody(response, signal);
418
+ try {
419
+ return JSON.parse(bytes.toString("utf8"));
420
+ } catch {
421
+ throw new SiteAuthoringError("transport.invalid_json", "Taproot returned an invalid JSON response.");
422
+ }
423
+ }
424
+
425
+ async function defaultSleep(milliseconds, signal) {
426
+ await new Promise((resolve, reject) => {
427
+ const finish = () => {
428
+ signal?.removeEventListener("abort", abort);
429
+ resolve();
430
+ };
431
+ const timer = setTimeout(finish, milliseconds);
432
+ const abort = () => {
433
+ clearTimeout(timer);
434
+ signal?.removeEventListener("abort", abort);
435
+ reject(cancelledError());
436
+ };
437
+ if (signal?.aborted) abort();
438
+ else signal?.addEventListener("abort", abort, { once: true });
439
+ });
440
+ }
441
+
442
+ export class SiteApiClient {
443
+ /**
444
+ * The bearer header, or `undefined` for an anonymous client. Private so the
445
+ * credential is not an enumerable property of an object that travels through
446
+ * verbs, and so no later assignment can give an anonymous client a bearer.
447
+ */
448
+ #authorization;
449
+
450
+ constructor({
451
+ apiBaseUrl,
452
+ token,
453
+ fetch = globalThis.fetch,
454
+ sleep = defaultSleep,
455
+ signal,
456
+ now = Date.now,
457
+ timeoutSignal = AbortSignal.timeout,
458
+ } = {}) {
459
+ if (typeof fetch !== "function") {
460
+ throw new SiteAuthoringError("transport.fetch_missing", "This Node runtime does not provide fetch().");
461
+ }
462
+ // The allowlist runs first, and on purpose: after this line the
463
+ // constructor may hold a bearer token, and nothing may reach that state for
464
+ // an origin outside the reviewed set. config.js already checked the same
465
+ // value; this repeats it so no future caller can assemble a client — and
466
+ // therefore an Authorization header — around the check. It runs for the
467
+ // anonymous path too: the device and claim exchanges carry the site id and
468
+ // mint a credential, so they are no more free to leave the reviewed set.
469
+ const reviewedBaseUrl = validateApiBaseUrl(apiBaseUrl);
470
+ const anonymous = token === ANONYMOUS_CREDENTIAL;
471
+ if (!anonymous && !isWellFormedCredential(token)) {
472
+ throw new SiteAuthoringError(
473
+ "auth.key_invalid",
474
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} is missing or malformed.`,
475
+ );
476
+ }
477
+ this.apiBaseUrl = `${reviewedBaseUrl}/`;
478
+ this.apiOrigin = new URL(this.apiBaseUrl).origin;
479
+ this.apiPathPrefix = new URL(this.apiBaseUrl).pathname;
480
+ this.anonymous = anonymous;
481
+ this.#authorization = anonymous ? undefined : `Bearer ${token}`;
482
+ this.fetch = fetch;
483
+ this.sleep = sleep;
484
+ this.signal = signal;
485
+ this.now = now;
486
+ this.timeoutSignal = timeoutSignal;
487
+ }
488
+
489
+ /**
490
+ * A client for the two unauthenticated CLI-authorization endpoints. It is
491
+ * identical in every other respect — the same reviewed-origin allowlist, the
492
+ * same bounded responses, the same `ApiError`, the same retry discipline —
493
+ * and it can never acquire a bearer: there is no token to hold, and the
494
+ * header is only ever emitted from the private field this path leaves unset.
495
+ */
496
+ static anonymous(options = {}) {
497
+ return new SiteApiClient({ ...options, token: ANONYMOUS_CREDENTIAL });
498
+ }
499
+
500
+ async request(path, options = {}) {
501
+ return await this.#request(path, options, false);
502
+ }
503
+
504
+ /**
505
+ * Replays only the POST shapes whose server contract answers a replay instead
506
+ * of duplicating work — see `REPLACEABLE_POST_PATHS`. This is deliberately not
507
+ * a caller-supplied `replayable` option on `request()`: ordinary mutations
508
+ * must remain impossible to opt into replay.
509
+ */
510
+ async replaceablePost(path, options = {}) {
511
+ if (typeof path !== "string" || !REPLACEABLE_POST_PATHS.some((pattern) => pattern.test(path))) {
512
+ throw new SiteAuthoringError(
513
+ "transport.request_contract",
514
+ "That path is not one of the reviewed replayable POST operations.",
515
+ );
516
+ }
517
+ return await this.#request(path, { ...options, method: "POST" }, true);
518
+ }
519
+
520
+ async #request(path, {
521
+ method = "GET",
522
+ body,
523
+ attempts = LIMITS.requestAttempts,
524
+ deadline,
525
+ now = this.now,
526
+ } = {}, replaceableMutation) {
527
+ const url = new URL(path, this.apiBaseUrl);
528
+ if (
529
+ url.origin !== this.apiOrigin
530
+ || !url.pathname.startsWith(this.apiPathPrefix)
531
+ || !ALLOWED_METHODS.has(method)
532
+ || (deadline !== undefined && !Number.isFinite(deadline))
533
+ || typeof now !== "function"
534
+ ) {
535
+ throw new SiteAuthoringError(
536
+ "transport.request_contract",
537
+ "The Taproot API request is outside the reviewed transport contract.",
538
+ );
539
+ }
540
+ const replayable = IDEMPOTENT_METHODS.has(method) || replaceableMutation;
541
+ const serializedBody = body === undefined ? undefined : JSON.stringify(body);
542
+ // Whether any attempt of a mutation has ended without an authoritative
543
+ // outcome. Accumulated, not read off the final attempt: a claim whose first
544
+ // send committed the mint and died on the reply, and whose retry then failed
545
+ // a TLS handshake, is still a claim that may have minted. Classifying only
546
+ // the last error would call that "undelivered" and tell login nothing was
547
+ // issued.
548
+ let anyAttemptAmbiguous = false;
549
+ const mutation = !IDEMPOTENT_METHODS.has(method);
550
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
551
+ // Before sending: only what earlier attempts did can be ambiguous.
552
+ if (this.signal?.aborted) throw cancelledError(anyAttemptAmbiguous && mutation);
553
+ const remaining = remainingBudget(deadline, now);
554
+ if (remaining <= 0) throw deadlineError();
555
+ const timeoutMilliseconds = Math.max(1, Math.ceil(Math.min(LIMITS.requestMilliseconds, remaining)));
556
+ const attemptSignal = operationSignal(this.signal, timeoutMilliseconds, this.timeoutSignal);
557
+ let response;
558
+ try {
559
+ response = await awaitWithSignal(
560
+ this.fetch(url, {
561
+ method,
562
+ redirect: "error",
563
+ signal: attemptSignal,
564
+ headers: {
565
+ accept: "application/json",
566
+ // Absent entirely for an anonymous client: the start and claim
567
+ // endpoints are unauthenticated, and an empty or placeholder
568
+ // bearer is a credential-shaped value on the wire for no reason.
569
+ ...(this.#authorization === undefined ? {} : { authorization: this.#authorization }),
570
+ "content-type": "application/json",
571
+ "user-agent": `${CLI_NAME}/${CLI_VERSION}`,
572
+ },
573
+ body: serializedBody,
574
+ }),
575
+ attemptSignal,
576
+ );
577
+ } catch (error) {
578
+ // An abort that interrupted the fetch itself: for a mutation the
579
+ // request may have been on the wire, so this attempt is ambiguous on
580
+ // its own, whatever came before.
581
+ if (this.signal?.aborted) throw cancelledError(mutation);
582
+ if (remainingBudget(deadline, now) <= 0) throw deadlineError();
583
+ if (!isUndelivered(error)) anyAttemptAmbiguous = true;
584
+ if (replayable && attempt + 1 < attempts) {
585
+ await waitForRetry(this, attempt, deadline, now, anyAttemptAmbiguous && mutation);
586
+ continue;
587
+ }
588
+ // Three outcomes, and the line between them is whether the server may
589
+ // have acted. A failure that provably happened before delivery — DNS,
590
+ // refused, a TLS handshake — means it did not, whatever the method. A
591
+ // read that died in flight means nothing either: there was nothing to
592
+ // apply. A mutation that died in flight may have landed, and that is
593
+ // true whether or not the transport replayed it first: replaying
594
+ // bounds how many times it was *sent*, not whether the last send
595
+ // arrived. So a replayable claim that exhausts its retries on reset
596
+ // connections is still ambiguous, and `login` needs to hear that —
597
+ // it arms its possible-orphan warning on ambiguity and must not on a
598
+ // certificate error, and "network" was carrying both.
599
+ const ambiguous = anyAttemptAmbiguous && !IDEMPOTENT_METHODS.has(method);
600
+ const cause = describeTransportCause(error);
601
+ throw new SiteAuthoringError(
602
+ ambiguous ? "transport.mutation_ambiguous" : "transport.network",
603
+ (ambiguous
604
+ ? "A Taproot API mutation ended without an authoritative response."
605
+ : "A Taproot API request failed before a response was received.")
606
+ + (cause === undefined ? "" : ` (${cause})`),
607
+ );
608
+ }
609
+ if (response.status >= 200 && response.status < 300 && !response.redirected) {
610
+ try {
611
+ const parsed = await parseJsonResponse(response, attemptSignal);
612
+ if (remainingBudget(deadline, now) <= 0) throw deadlineError();
613
+ return parsed;
614
+ } catch (error) {
615
+ // A 2xx arrived. Whatever the body turned out to be, the server
616
+ // answered success — so for a mutation this attempt did not merely
617
+ // lack an authoritative outcome, it had one this side could not
618
+ // read. The accumulator must know before any retry: a claim that
619
+ // minted, returned an unreadable body, and then had every retry fail
620
+ // a TLS handshake is the strongest orphan case there is, and it was
621
+ // being reported as "never sent".
622
+ anyAttemptAmbiguous = true;
623
+ if (this.signal?.aborted) throw cancelledError(mutation);
624
+ if (remainingBudget(deadline, now) <= 0) throw deadlineError();
625
+ if (
626
+ replayable
627
+ && error instanceof SiteAuthoringError
628
+ && RETRYABLE_RESPONSE_ERRORS.has(error.code)
629
+ && attempt + 1 < attempts
630
+ ) {
631
+ await waitForRetry(this, attempt, deadline, now, mutation);
632
+ continue;
633
+ }
634
+ throw error;
635
+ }
636
+ }
637
+ if (replayable && RETRYABLE_STATUS.has(response.status) && attempt + 1 < attempts) {
638
+ if (response.status >= 500) anyAttemptAmbiguous = true;
639
+ await cancelBody(response, attemptSignal);
640
+ await waitForRetry(this, attempt, deadline, now, anyAttemptAmbiguous && mutation);
641
+ continue;
642
+ }
643
+ let responseBody;
644
+ try {
645
+ responseBody = await parseJsonResponse(response, attemptSignal);
646
+ } catch {
647
+ // A response arrived, so the mutation's fate is whatever its status
648
+ // says: a 4xx refused it, a 5xx may have committed it first.
649
+ if (this.signal?.aborted) {
650
+ throw cancelledError((anyAttemptAmbiguous || response.status >= 500) && mutation);
651
+ }
652
+ if (remainingBudget(deadline, now) <= 0) throw deadlineError();
653
+ responseBody = Object.create(null);
654
+ }
655
+ if (remainingBudget(deadline, now) <= 0) throw deadlineError();
656
+ throw new ApiError(response.status, responseBody);
657
+ }
658
+ throw new SiteAuthoringError("transport.retry_exhausted", "The Taproot API request exhausted its retry budget.");
659
+ }
660
+
661
+ /**
662
+ * Performs one presigned whole-object PUT. The signed headers are echoed
663
+ * exactly as the server returned them and are never recomputed: the values
664
+ * are covered by the SigV4 signature, so a "helpfully" regenerated
665
+ * Content-Type or an added header turns into an opaque S3 403. The bearer is
666
+ * never forwarded — the signature is the authorization, and sending the
667
+ * site key to an object store would leak it outside the reviewed origin.
668
+ *
669
+ * The PUT of an immutable object is idempotent, so this is the one mutation
670
+ * shape that may be replayed.
671
+ */
672
+ async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
673
+ if (
674
+ typeof uploadUrl !== "string"
675
+ || uploadUrl.length === 0
676
+ || !ArrayBuffer.isView(bodyBytes)
677
+ ) {
678
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid upload capability.");
679
+ }
680
+ if (uploadUrl.length > MAXIMUM_UPLOAD_URL_LENGTH) {
681
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an oversized upload capability URL.");
682
+ }
683
+ let url;
684
+ try {
685
+ url = new URL(uploadUrl);
686
+ } catch {
687
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid upload capability URL.");
688
+ }
689
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
690
+ if (
691
+ (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
692
+ || url.username !== ""
693
+ || url.password !== ""
694
+ || url.hash !== ""
695
+ ) {
696
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an unsupported upload capability URL.");
697
+ }
698
+
699
+ // `RequestImageUpload` returns the signed set as a map, not a list.
700
+ const entries = requiredHeaders !== null
701
+ && typeof requiredHeaders === "object"
702
+ && !Array.isArray(requiredHeaders)
703
+ ? Object.entries(requiredHeaders)
704
+ : undefined;
705
+ if (entries === undefined) {
706
+ throw new SiteAuthoringError("upload.headers_invalid", "Taproot returned an invalid signed upload header map.");
707
+ }
708
+ if (entries.length > MAXIMUM_UPLOAD_HEADERS) {
709
+ throw new SiteAuthoringError("upload.headers_invalid", "Taproot returned too many signed upload headers.");
710
+ }
711
+ const headers = new Headers();
712
+ const names = new Set();
713
+ for (const [name, value] of entries) {
714
+ const normalizedName = typeof name === "string" ? name.toLowerCase() : "";
715
+ if (
716
+ !HEADER_NAME.test(name ?? "")
717
+ || typeof value !== "string"
718
+ || value.length === 0
719
+ || value.length > MAXIMUM_HEADER_VALUE_LENGTH
720
+ || hasControlCharacter(value)
721
+ || names.has(normalizedName)
722
+ ) {
723
+ throw new SiteAuthoringError("upload.headers_invalid", "Taproot returned invalid signed upload headers.");
724
+ }
725
+ names.add(normalizedName);
726
+ headers.set(name, value);
727
+ }
728
+ if (!headers.has("content-type") || !headers.has("content-length")) {
729
+ throw new SiteAuthoringError(
730
+ "upload.headers_invalid",
731
+ "The signed upload capability is missing required content headers.",
732
+ );
733
+ }
734
+ // The declared length is bound into the signature, so a body of any other
735
+ // size cannot succeed. Failing here names the real problem instead of
736
+ // surfacing an S3 signature error.
737
+ if (headers.get("content-length") !== String(bodyBytes.byteLength)) {
738
+ throw new SiteAuthoringError(
739
+ "upload.content_length_invalid",
740
+ "The signed upload content length does not match the bytes being uploaded.",
741
+ );
742
+ }
743
+
744
+ for (let attempt = 0; attempt < LIMITS.uploadAttempts; attempt += 1) {
745
+ let response;
746
+ try {
747
+ response = await this.fetch(url, {
748
+ method: "PUT",
749
+ redirect: "error",
750
+ signal: operationSignal(this.signal, LIMITS.uploadMilliseconds, this.timeoutSignal),
751
+ headers,
752
+ body: bodyBytes,
753
+ });
754
+ } catch {
755
+ if (attempt + 1 < LIMITS.uploadAttempts && !this.signal?.aborted) {
756
+ await this.sleep(delayForAttempt(attempt), this.signal);
757
+ continue;
758
+ }
759
+ throw new SiteAuthoringError(
760
+ "upload.ambiguous",
761
+ "The whole-object upload ended without an authoritative response.",
762
+ );
763
+ }
764
+ await cancelBody(response);
765
+ if (response.status >= 200 && response.status < 300 && !response.redirected) return;
766
+ if (RETRYABLE_STATUS.has(response.status) && attempt + 1 < LIMITS.uploadAttempts) {
767
+ await this.sleep(delayForAttempt(attempt), this.signal);
768
+ continue;
769
+ }
770
+ throw new SiteAuthoringError(
771
+ RETRYABLE_STATUS.has(response.status) ? "upload.ambiguous" : "upload.rejected",
772
+ `The whole-object upload was rejected with HTTP ${response.status}.`,
773
+ { status: `http:${response.status}` },
774
+ );
775
+ }
776
+ }
777
+ }