@taprootio/site-authoring 0.23.0 → 0.23.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -1,5 +1,13 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.23.1",
5
+ "note": "Image uploads support Taproot’s single-use upload gateway and send an operation key with each upload request.",
6
+ "behavior": [
7
+ "media upload sends an operation key when requesting an image upload. It accepts a single-use gateway capability only for the configured Taproot API origin and image-upload path, and never automatically retries that capability after a transport failure or retryable response. Direct signed object-store uploads retain their existing retry behavior."
8
+ ],
9
+ "help": []
10
+ },
3
11
  {
4
12
  "version": "0.23.0",
5
13
  "note": "theme resolve prints the colors Espalier compiles from the theme, per scheme and context, with their contrast ratios, offline; the help and the staging review result say how long a review lasts and what ends it; pages delete with a dry run; nav push fenced by a navigation revision.",
package/src/transport.js CHANGED
@@ -51,6 +51,10 @@ const IDEMPOTENT_METHODS = new Set(["GET"]);
51
51
 
52
52
  /** An exchanged credential is renewed once it is this close to expiring. */
53
53
  const CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS = 5 * 60_000;
54
+ const GATEWAY_UPLOAD_ATTEMPTS = 4;
55
+ const GATEWAY_CAPACITY_WINDOW_MILLISECONDS = 15_000;
56
+ const UPLOAD_CAPACITY_HEADER = "x-taproot-upload-admission";
57
+ const UPLOAD_CAPACITY_REFUSAL = "capacity-before-claim";
54
58
 
55
59
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
56
60
  const RETRYABLE_RESPONSE_ERRORS = new Set([
@@ -965,15 +969,16 @@ export class SiteApiClient {
965
969
  }
966
970
 
967
971
  /**
968
- * Performs one presigned whole-object PUT. The signed headers are echoed
972
+ * Performs a whole-object PUT. Signed S3 headers are echoed
969
973
  * exactly as the server returned them and are never recomputed: the values
970
974
  * are covered by the SigV4 signature, so a "helpfully" regenerated
971
975
  * Content-Type or an added header turns into an opaque S3 403. The bearer is
972
976
  * never forwarded — the signature is the authorization, and sending the
973
977
  * site key to an object store would leak it outside the reviewed origin.
974
978
  *
975
- * The PUT of an immutable object is idempotent, so this is the one mutation
976
- * shape that may be replayed.
979
+ * S3 create-only uploads retain their existing retry contract. A gateway
980
+ * capability is consumed once before the body is read. Only an explicit
981
+ * capacity refusal before that claim permits a bounded retry.
977
982
  */
978
983
  async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
979
984
  // A video original is streamed from disk: `{ byteLength, stream, timeoutMilliseconds }`,
@@ -1040,7 +1045,8 @@ export class SiteApiClient {
1040
1045
  names.add(normalizedName);
1041
1046
  headers.set(name, value);
1042
1047
  }
1043
- if (!headers.has("content-type") || !headers.has("content-length")) {
1048
+ const gateway = headers.has("x-taproot-upload-capability");
1049
+ if (!headers.has("content-type") || (!gateway && !headers.has("content-length"))) {
1044
1050
  throw new SiteAuthoringError(
1045
1051
  "upload.headers_invalid",
1046
1052
  "The signed upload capability is missing required content headers.",
@@ -1049,31 +1055,48 @@ export class SiteApiClient {
1049
1055
  // The declared length is bound into the signature, so a body of any other
1050
1056
  // size cannot succeed. Failing here names the real problem instead of
1051
1057
  // surfacing an S3 signature error.
1052
- if (headers.get("content-length") !== String(bodyBytes.byteLength)) {
1058
+ if (gateway && (
1059
+ url.origin !== this.apiOrigin
1060
+ || !url.pathname.startsWith(this.apiPathPrefix)
1061
+ || !/^\/api\/v1\/images\/uploads\/[0-9a-f-]{36}\/content$/u.test(url.pathname)
1062
+ || url.search !== ""
1063
+ )) {
1064
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid gateway URL.");
1065
+ }
1066
+ if (!gateway && headers.get("content-length") !== String(bodyBytes.byteLength)) {
1053
1067
  throw new SiteAuthoringError(
1054
1068
  "upload.content_length_invalid",
1055
1069
  "The signed upload content length does not match the bytes being uploaded.",
1056
1070
  );
1057
1071
  }
1058
1072
 
1059
- for (let attempt = 0; attempt < LIMITS.uploadAttempts; attempt += 1) {
1073
+ const maximumAttempts = gateway ? GATEWAY_UPLOAD_ATTEMPTS : LIMITS.uploadAttempts;
1074
+ const startedAt = this.now();
1075
+ const uploadDeadline = startedAt + ((streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds);
1076
+ for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
1077
+ if (this.signal?.aborted) throw cancelledError(false);
1078
+ const remaining = uploadDeadline - this.now();
1079
+ if (gateway && remaining <= 0) {
1080
+ throw new SiteAuthoringError("upload.capacity_exhausted", "The image upload exhausted its attempt window.");
1081
+ }
1060
1082
  let response;
1083
+ const attemptSignal = operationSignal(
1084
+ this.signal,
1085
+ gateway ? remaining : (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
1086
+ this.timeoutSignal,
1087
+ );
1061
1088
  try {
1062
- response = await this.fetch(url, {
1089
+ response = await awaitWithSignal(this.fetch(url, {
1063
1090
  method: "PUT",
1064
1091
  redirect: "error",
1065
- signal: operationSignal(
1066
- this.signal,
1067
- (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
1068
- this.timeoutSignal,
1069
- ),
1092
+ signal: attemptSignal,
1070
1093
  headers,
1071
1094
  ...(streamed
1072
1095
  ? { body: Readable.toWeb(bodyBytes.stream()), duplex: "half" }
1073
1096
  : { body: bodyBytes }),
1074
- });
1097
+ }), attemptSignal);
1075
1098
  } catch {
1076
- if (attempt + 1 < LIMITS.uploadAttempts && !this.signal?.aborted) {
1099
+ if (!gateway && attempt + 1 < maximumAttempts && !this.signal?.aborted) {
1077
1100
  await this.sleep(delayForAttempt(attempt), this.signal);
1078
1101
  continue;
1079
1102
  }
@@ -1082,14 +1105,29 @@ export class SiteApiClient {
1082
1105
  "The whole-object upload ended without an authoritative response.",
1083
1106
  );
1084
1107
  }
1085
- await cancelBody(response);
1108
+ await cancelBody(response, attemptSignal);
1086
1109
  if (response.status >= 200 && response.status < 300 && !response.redirected) return;
1087
- if (RETRYABLE_STATUS.has(response.status) && attempt + 1 < LIMITS.uploadAttempts) {
1110
+ const capacityRefused = gateway && !response.redirected && response.status === HTTP_TOO_MANY_REQUESTS
1111
+ && response.headers.get(UPLOAD_CAPACITY_HEADER) === UPLOAD_CAPACITY_REFUSAL;
1112
+ const retryAfter = response.headers.get("retry-after") ?? "";
1113
+ if (capacityRefused && /^[1-5]$/u.test(retryAfter) && attempt + 1 < maximumAttempts) {
1114
+ const delay = Number(retryAfter) * 1000;
1115
+ const capacityRemaining = Math.min(uploadDeadline, startedAt + GATEWAY_CAPACITY_WINDOW_MILLISECONDS) - this.now();
1116
+ if (delay < capacityRemaining) {
1117
+ // TR01295: the server guarantees that this rejected PUT did not
1118
+ // claim the capability. Network failures and 5xx never retry it.
1119
+ await awaitWithSignal(this.sleep(delay, this.signal), operationSignal(
1120
+ this.signal, capacityRemaining, this.timeoutSignal,
1121
+ ));
1122
+ if (this.now() - startedAt < GATEWAY_CAPACITY_WINDOW_MILLISECONDS) continue;
1123
+ }
1124
+ }
1125
+ if (!gateway && RETRYABLE_STATUS.has(response.status) && attempt + 1 < maximumAttempts) {
1088
1126
  await this.sleep(delayForAttempt(attempt), this.signal);
1089
1127
  continue;
1090
1128
  }
1091
1129
  throw new SiteAuthoringError(
1092
- RETRYABLE_STATUS.has(response.status) ? "upload.ambiguous" : "upload.rejected",
1130
+ RETRYABLE_STATUS.has(response.status) && !capacityRefused ? "upload.ambiguous" : "upload.rejected",
1093
1131
  `The whole-object upload was rejected with HTTP ${response.status}.`,
1094
1132
  { status: `http:${response.status}` },
1095
1133
  );
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Readable } from "node:stream";
2
3
 
3
4
  import {
@@ -624,6 +625,7 @@ export async function mediaUpload(invocation) {
624
625
  contentType,
625
626
  fileSize: bytes.byteLength,
626
627
  contentHash: hash,
628
+ operationKey: randomUUID(),
627
629
  width,
628
630
  height,
629
631
  ownershipScope: IMAGE_OWNERSHIP_SCOPE_SITE,