@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/transport.js
ADDED
|
@@ -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
|
+
}
|