@tinycloud/sdk-services 3.0.0 → 3.1.0-beta.10

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/dist/kv/index.js CHANGED
@@ -5,6 +5,10 @@ var ErrorCodes = {
5
5
  AUTH_EXPIRED: "AUTH_EXPIRED",
6
6
  AUTH_REQUIRED: "AUTH_REQUIRED",
7
7
  AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
8
+ /** HTTP 401: the delegation the invocation cites was revoked. */
9
+ AUTH_DELEGATION_REVOKED: "AUTH_DELEGATION_REVOKED",
10
+ /** HTTP 401: a delegation the cited one rests on was revoked. */
11
+ AUTH_DELEGATION_ANCESTOR_REVOKED: "AUTH_DELEGATION_ANCESTOR_REVOKED",
8
12
  NETWORK_ERROR: "NETWORK_ERROR",
9
13
  TIMEOUT: "TIMEOUT",
10
14
  ABORTED: "ABORTED",
@@ -16,6 +20,10 @@ var ErrorCodes = {
16
20
  KV_PRECONDITION_FAILED: "KV_PRECONDITION_FAILED",
17
21
  KV_CONFLICT: "KV_CONFLICT",
18
22
  KV_RESPONSE_TOO_LARGE: "KV_RESPONSE_TOO_LARGE",
23
+ /** HTTP 410 from the change feed: discard sync state and restart without a cursor; `meta.reason` says why. */
24
+ KV_SYNC_RESET_REQUIRED: "KV_SYNC_RESET_REQUIRED",
25
+ /** HTTP 403 from the change feed: the retention grant was refused; `meta.reason` says why. */
26
+ KV_RETENTION_GRANT_REFUSED: "KV_RETENTION_GRANT_REFUSED",
19
27
  // SQL-specific errors
20
28
  SQL_ERROR: "SQL_ERROR",
21
29
  SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
@@ -70,6 +78,55 @@ function serviceError(code, message, service, options) {
70
78
  };
71
79
  }
72
80
 
81
+ // src/errors.ts
82
+ import { CAPABILITY_REGISTRY } from "@tinycloud/bootstrap";
83
+
84
+ // src/encryption/networkId.ts
85
+ var URN_PREFIX = "urn:tinycloud:encryption:";
86
+ var NETWORK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
87
+ var NetworkIdError = class extends Error {
88
+ constructor(message) {
89
+ super(message);
90
+ this.name = "NetworkIdError";
91
+ }
92
+ };
93
+ function parseNetworkId(networkId) {
94
+ if (typeof networkId !== "string" || networkId.length === 0) {
95
+ throw new NetworkIdError("networkId must be a non-empty string");
96
+ }
97
+ if (!networkId.startsWith(URN_PREFIX)) {
98
+ throw new NetworkIdError(
99
+ `networkId must start with ${URN_PREFIX} (got ${JSON.stringify(networkId)})`
100
+ );
101
+ }
102
+ const body = networkId.slice(URN_PREFIX.length);
103
+ const lastColon = body.lastIndexOf(":");
104
+ if (lastColon <= 0 || lastColon === body.length - 1) {
105
+ throw new NetworkIdError(
106
+ `networkId missing ownerDid or name segment (got ${JSON.stringify(networkId)})`
107
+ );
108
+ }
109
+ const ownerDid = body.slice(0, lastColon);
110
+ const name = body.slice(lastColon + 1);
111
+ if (!ownerDid.startsWith("did:")) {
112
+ throw new NetworkIdError(
113
+ `networkId ownerDid must be a DID (got ${JSON.stringify(ownerDid)})`
114
+ );
115
+ }
116
+ const didParts = ownerDid.split(":");
117
+ if (didParts.length < 3 || didParts.some((p) => p.length === 0)) {
118
+ throw new NetworkIdError(
119
+ `networkId ownerDid is not a well-formed DID (got ${JSON.stringify(ownerDid)})`
120
+ );
121
+ }
122
+ if (!NETWORK_NAME_RE.test(name)) {
123
+ throw new NetworkIdError(
124
+ `networkId name ${JSON.stringify(name)} must match ${NETWORK_NAME_RE.source}`
125
+ );
126
+ }
127
+ return { networkId, ownerDid, name };
128
+ }
129
+
73
130
  // src/errors.ts
74
131
  function parsePermissionHint(value) {
75
132
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
@@ -114,11 +171,31 @@ function authRequiredError(service) {
114
171
  service
115
172
  };
116
173
  }
117
- function timeoutError(service) {
174
+ var RequestTimeoutError = class extends Error {
175
+ constructor(timeoutMs) {
176
+ super(`Request timed out after ${timeoutMs}ms.`);
177
+ this.name = "TimeoutError";
178
+ this.timeoutMs = timeoutMs;
179
+ }
180
+ };
181
+ function isRequestTimeoutError(error) {
182
+ return error instanceof RequestTimeoutError || error instanceof Error && error.name === "TimeoutError" && typeof error.timeoutMs === "number";
183
+ }
184
+ function timeoutError(service, timeoutMs, cause) {
185
+ if (timeoutMs === void 0) {
186
+ return {
187
+ code: ErrorCodes.TIMEOUT,
188
+ message: "Request timed out.",
189
+ service,
190
+ ...cause === void 0 ? {} : { cause }
191
+ };
192
+ }
118
193
  return {
119
194
  code: ErrorCodes.TIMEOUT,
120
- message: "Request timed out.",
121
- service
195
+ message: `Request timed out after ${timeoutMs}ms.`,
196
+ service,
197
+ ...cause === void 0 ? {} : { cause },
198
+ meta: { timeoutMs }
122
199
  };
123
200
  }
124
201
  function abortedError(service) {
@@ -138,6 +215,101 @@ function parseAuthError(responseText) {
138
215
  function authUnauthorizedError(service, message, meta) {
139
216
  return serviceError(ErrorCodes.AUTH_UNAUTHORIZED, message, service, { meta });
140
217
  }
218
+ var grantableActions = new Set(
219
+ CAPABILITY_REGISTRY.map(({ urn }) => urn).filter((urn) => !urn.endsWith("/*"))
220
+ );
221
+ var capabilityServices = new Set(
222
+ CAPABILITY_REGISTRY.map(({ service }) => service.slice("tinycloud.".length))
223
+ );
224
+ function parseCapabilityResource(resource, service) {
225
+ if (resource.length === 0 || resource.length > 1024 || !capabilityServices.has(service) || /[\s\p{C}*#]/u.test(resource) || resource.includes("://")) return void 0;
226
+ const segments = resource.split("/");
227
+ if (!/^tinycloud:[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(segments[0])) return void 0;
228
+ const serviceIndex = segments.findIndex(
229
+ (segment, index) => index > 0 && capabilityServices.has(segment)
230
+ );
231
+ if (serviceIndex < 1 || segments[serviceIndex] !== service) return void 0;
232
+ if (segments.slice(1, serviceIndex).some(
233
+ (segment) => segment.length === 0 || segment === "." || segment === ".."
234
+ )) return void 0;
235
+ const pathSegments = segments.slice(serviceIndex + 1);
236
+ if (pathSegments.some((segment) => segment === "." || segment === "..")) return void 0;
237
+ if (service !== "kv" && (pathSegments.length === 0 || pathSegments[pathSegments.length - 1] === "")) return void 0;
238
+ return {
239
+ space: segments.slice(0, serviceIndex).join("/"),
240
+ path: pathSegments.join("/")
241
+ };
242
+ }
243
+ function safeCapabilityResource(value, service, action) {
244
+ if (typeof value !== "string" || value.length === 0 || value.length > 1024) return false;
245
+ if (service === "encryption" && value.startsWith("urn:tinycloud:encryption:")) {
246
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.:/-]*$/.test(value) || value.includes("//") || value.split("/").some((segment) => segment === "." || segment === "..")) return false;
247
+ try {
248
+ parseNetworkId(value);
249
+ return true;
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+ const parsed = parseCapabilityResource(value, service);
255
+ return parsed !== void 0 && (!parsed.path.endsWith("/") || service === "kv" && action === "tinycloud.kv/list") && (parsed.path !== "" || service === "kv" && action === "tinycloud.kv/list");
256
+ }
257
+ function validatedCapabilityOf(error) {
258
+ const verdict = authorizationVerdictOf(error);
259
+ if (verdict !== "unauthenticated" && verdict !== "forbidden") return void 0;
260
+ const seen = /* @__PURE__ */ new Set();
261
+ let node = error;
262
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
263
+ seen.add(node);
264
+ const record = node;
265
+ const status = errorStatusOf(record);
266
+ const meta = record.meta;
267
+ if ((status === 401 || status === 403 || status === void 0 && record.code === ErrorCodes.AUTH_UNAUTHORIZED) && typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
268
+ const metadata = meta;
269
+ const { resource, requiredAction } = metadata;
270
+ if (typeof requiredAction === "string" && grantableActions.has(requiredAction)) {
271
+ const actionService = requiredAction.slice("tinycloud.".length, requiredAction.indexOf("/"));
272
+ const explicitService = record.service === void 0 ? metadata.service : record.service;
273
+ if (safeCapabilityResource(resource, actionService, requiredAction) && (explicitService === void 0 || explicitService === actionService || explicitService === `tinycloud.${actionService}`)) {
274
+ return { resource, requiredAction };
275
+ }
276
+ }
277
+ }
278
+ node = record.cause;
279
+ }
280
+ return void 0;
281
+ }
282
+ var MAX_CAUSE_DEPTH = 8;
283
+ function errorStatusOf(node) {
284
+ const meta = node.meta;
285
+ const candidates = [
286
+ node.status,
287
+ node.statusCode,
288
+ typeof meta === "object" && meta !== null ? meta.status : void 0
289
+ ];
290
+ for (const candidate of candidates) {
291
+ if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 400 && candidate <= 599) {
292
+ return candidate;
293
+ }
294
+ }
295
+ return void 0;
296
+ }
297
+ function authorizationVerdictOf(error) {
298
+ const seen = /* @__PURE__ */ new Set();
299
+ let sawUnauthorizedCode = false;
300
+ let node = error;
301
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
302
+ seen.add(node);
303
+ const record = node;
304
+ const status = errorStatusOf(record);
305
+ if (status === 401) return "unauthenticated";
306
+ if (status === 403) return "forbidden";
307
+ if (status !== void 0) return "other";
308
+ if (record.code === ErrorCodes.AUTH_UNAUTHORIZED) sawUnauthorizedCode = true;
309
+ node = record.cause;
310
+ }
311
+ return sawUnauthorizedCode ? "forbidden" : void 0;
312
+ }
141
313
  function storageQuotaExceededError(service, message, meta) {
142
314
  return {
143
315
  code: ErrorCodes.STORAGE_QUOTA_EXCEEDED,
@@ -154,8 +326,25 @@ function storageLimitReachedError(service, message, meta) {
154
326
  meta
155
327
  };
156
328
  }
329
+ var STORAGE_FULL_MESSAGE = "TinyCloud storage is full, so this change was not saved. Reading still works. Free up space or upgrade your plan to save again.";
330
+ var STORAGE_WRITE_TOO_LARGE_MESSAGE = "This change is larger than the TinyCloud storage you have left, so it was not saved. Reading still works. Free up space or upgrade your plan to save it.";
331
+ function parseStorageQuotaBytes(responseText) {
332
+ const match = responseText.match(/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i);
333
+ if (!match) return void 0;
334
+ return {
335
+ usedBytes: parseInt(match[1], 10),
336
+ limitBytes: parseInt(match[2], 10)
337
+ };
338
+ }
339
+ function storageRejectionError(service, status, meta, responseText) {
340
+ const quotaMeta = { ...meta, ...parseStorageQuotaBytes(responseText) };
341
+ return status === 402 ? storageQuotaExceededError(service, STORAGE_FULL_MESSAGE, quotaMeta) : storageLimitReachedError(service, STORAGE_WRITE_TOO_LARGE_MESSAGE, quotaMeta);
342
+ }
157
343
  function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
158
344
  if (error instanceof Error) {
345
+ if (isRequestTimeoutError(error)) {
346
+ return timeoutError(service, error.timeoutMs, error);
347
+ }
159
348
  if (error.name === "AbortError") {
160
349
  return abortedError(service);
161
350
  }
@@ -177,6 +366,7 @@ function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
177
366
  }
178
367
 
179
368
  // src/base/BaseService.ts
369
+ var MAX_TIMER_DELAY_MS = 2147483647;
180
370
  var BaseService = class {
181
371
  constructor() {
182
372
  /**
@@ -357,6 +547,64 @@ var BaseService = class {
357
547
  }
358
548
  return controller.signal;
359
549
  }
550
+ /**
551
+ * Create the abort signal for one request, bounded by the request timeout.
552
+ *
553
+ * The signal aborts when the service signs out, the context aborts, the
554
+ * caller's `signal` aborts, or the timeout elapses, whichever comes first.
555
+ * A timeout aborts with a {@link RequestTimeoutError} (`TimeoutError`), which
556
+ * `wrapError` maps to `ErrorCodes.TIMEOUT`; the other sources keep their own
557
+ * reason (normally `AbortError`, mapped to `ErrorCodes.ABORTED`).
558
+ *
559
+ * The timeout is `timeoutMs` when given, otherwise `config.timeout`. Only a
560
+ * positive, finite value (at most 2^31-1 ms) applies; `undefined`, `0`, and
561
+ * `Infinity` mean no timeout, so a per-call `0` opts out of a configured one.
562
+ *
563
+ * Callers must call `dispose()` once the response body has been consumed,
564
+ * which clears the timer and removes the listeners this adds to the
565
+ * long-lived service and context signals.
566
+ *
567
+ * @param signal - The caller's abort signal for this request
568
+ * @param timeoutMs - Per-request timeout override in milliseconds
569
+ */
570
+ createRequestSignal(signal, timeoutMs) {
571
+ const controller = new AbortController();
572
+ const detachers = [];
573
+ let timer;
574
+ const dispose = () => {
575
+ if (timer !== void 0) {
576
+ clearTimeout(timer);
577
+ timer = void 0;
578
+ }
579
+ for (const detach of detachers.splice(0)) {
580
+ detach();
581
+ }
582
+ };
583
+ const abort = (reason) => {
584
+ dispose();
585
+ controller.abort(reason);
586
+ };
587
+ const requestSignal = { signal: controller.signal, dispose };
588
+ const parents = [
589
+ this.abortController.signal,
590
+ this.context?.abortSignal,
591
+ signal
592
+ ].filter((parent) => parent !== void 0);
593
+ for (const parent of parents) {
594
+ if (parent.aborted) {
595
+ abort(parent.reason);
596
+ return requestSignal;
597
+ }
598
+ const onAbort = () => abort(parent.reason);
599
+ parent.addEventListener("abort", onAbort, { once: true });
600
+ detachers.push(() => parent.removeEventListener("abort", onAbort));
601
+ }
602
+ const timeout = timeoutMs ?? this._config.timeout;
603
+ if (typeof timeout === "number" && timeout > 0 && timeout <= MAX_TIMER_DELAY_MS) {
604
+ timer = setTimeout(() => abort(new RequestTimeoutError(timeout)), timeout);
605
+ }
606
+ return requestSignal;
607
+ }
360
608
  /**
361
609
  * Wrap an operation with error handling and telemetry.
362
610
  *
@@ -465,6 +713,30 @@ var PrefixedKVService = class _PrefixedKVService {
465
713
  removePrefix
466
714
  });
467
715
  }
716
+ /**
717
+ * Read the change feed for everything under this prefix. A view with an
718
+ * empty prefix (`""` or `"/"`) is refused: the node does not serve a
719
+ * whole-space feed.
720
+ */
721
+ async changes(options) {
722
+ if (this._prefix.replace(/\/+$/, "") === "") {
723
+ return err(serviceError(
724
+ ErrorCodes.INVALID_INPUT,
725
+ "KV changes needs a non-empty prefix; this view has none",
726
+ "kv"
727
+ ));
728
+ }
729
+ const syncPrefix = `${this._prefix}/`;
730
+ const response = await this._kv.changes({ ...options, prefix: syncPrefix });
731
+ if (!response.ok) return response;
732
+ return ok({
733
+ ...response.data,
734
+ changes: response.data.changes.map((change) => ({
735
+ ...change,
736
+ key: change.key.startsWith(syncPrefix) ? change.key.slice(syncPrefix.length) : change.key
737
+ }))
738
+ });
739
+ }
468
740
  /**
469
741
  * Delete a key.
470
742
  */
@@ -517,11 +789,24 @@ var KVAction = {
517
789
  PUT: "tinycloud.kv/put",
518
790
  LIST: "tinycloud.kv/list",
519
791
  DELETE: "tinycloud.kv/del",
520
- HEAD: "tinycloud.kv/metadata"
792
+ HEAD: "tinycloud.kv/metadata",
793
+ /** The change feed; never implied by `*` or `tinycloud.kv/*`. */
794
+ SYNC: "tinycloud.kv/sync",
795
+ /** Retention attestation; presented through `retentionGrant`, never invoked or implied. */
796
+ RETAIN: "tinycloud.kv/retain"
521
797
  };
522
798
 
523
799
  // src/kv/KVService.ts
524
800
  var MAX_KV_BATCH_READ_ITEMS = 100;
801
+ function isJsonObject(value) {
802
+ return typeof value === "object" && value !== null && !Array.isArray(value);
803
+ }
804
+ var REVOKED_BODY = /^(?:Invalid invocation: )?delegation-(?:(revoked): [A-Za-z0-9]+|(ancestor-revoked): ancestor=[A-Za-z0-9]+ invoked=[A-Za-z0-9]+)$/;
805
+ function revocationCodeOf(body) {
806
+ const match = REVOKED_BODY.exec(body.trim());
807
+ if (match === null) return void 0;
808
+ return match[1] !== void 0 ? ErrorCodes.AUTH_DELEGATION_REVOKED : ErrorCodes.AUTH_DELEGATION_ANCESTOR_REVOKED;
809
+ }
525
810
  function encodeKvBatchPartName(path) {
526
811
  return encodeURIComponent(path).replace(
527
812
  /[!'()*]/g,
@@ -544,47 +829,21 @@ var KVService = class extends BaseService {
544
829
  get config() {
545
830
  return this._config;
546
831
  }
547
- // Parses "Used: X bytes, Limit: Y bytes" from tinycloud-node error responses
548
- parseQuotaInfo(errorText) {
549
- const match = errorText.match(
550
- /Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i
551
- );
552
- if (match) {
553
- return {
554
- usedBytes: parseInt(match[1], 10),
555
- limitBytes: parseInt(match[2], 10)
556
- };
557
- }
558
- return void 0;
559
- }
560
832
  handleQuotaErrorResponse(response, errorText, key) {
561
- if (response.status === 402) {
562
- const quotaInfo = this.parseQuotaInfo(errorText);
563
- return err(
564
- storageQuotaExceededError(
565
- "kv",
566
- `Storage quota exceeded for key "${key}": ${errorText}`,
567
- {
568
- status: response.status,
569
- ...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
570
- }
571
- )
572
- );
833
+ if (response.status !== 402 && response.status !== 413) {
834
+ return void 0;
573
835
  }
574
- if (response.status === 413) {
575
- const quotaInfo = this.parseQuotaInfo(errorText);
576
- return err(
577
- storageLimitReachedError(
578
- "kv",
579
- `Storage limit reached for key "${key}": ${errorText}`,
580
- {
581
- status: response.status,
582
- ...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
583
- }
584
- )
585
- );
836
+ if (response.status === 413 && !parseStorageQuotaBytes(errorText)) {
837
+ return void 0;
586
838
  }
587
- return void 0;
839
+ return err(
840
+ storageRejectionError(
841
+ "kv",
842
+ response.status,
843
+ { status: response.status, statusText: response.statusText, key },
844
+ errorText
845
+ )
846
+ );
588
847
  }
589
848
  /**
590
849
  * Classify a KV 404 by reading the response body once.
@@ -643,7 +902,7 @@ var KVService = class extends BaseService {
643
902
  * @param path - Resource path
644
903
  * @param action - KV action
645
904
  * @param body - Optional request body
646
- * @param signal - Optional abort signal
905
+ * @param signal - Request signal from createRequestSignal()
647
906
  * @returns Fetch response
648
907
  */
649
908
  async invokeOperation(path, action, body, signal, extraHeaders) {
@@ -659,7 +918,7 @@ var KVService = class extends BaseService {
659
918
  method: "POST",
660
919
  headers: requestHeaders,
661
920
  body,
662
- signal: this.combineSignals(signal)
921
+ signal
663
922
  });
664
923
  }
665
924
  /**
@@ -836,6 +1095,7 @@ var KVService = class extends BaseService {
836
1095
  "kv"
837
1096
  ));
838
1097
  }
1098
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
839
1099
  try {
840
1100
  const session = this.context.session;
841
1101
  const invocationHeaders = this.context.invokeAny(
@@ -857,17 +1117,18 @@ var KVService = class extends BaseService {
857
1117
  const response = await this.context.fetch(`${this.host}/invoke`, {
858
1118
  method: "POST",
859
1119
  headers,
860
- signal: this.combineSignals(options?.signal)
1120
+ signal: request.signal
861
1121
  });
862
1122
  if (!response.ok) {
863
- const errorText = await response.text();
1123
+ const errorText = response.status === 401 || response.status === 403 ? await this.readAuthorizationText(response) : await response.text();
864
1124
  if (response.status === 401 || response.status === 403) {
865
- const { resource, action: requiredAction } = parseAuthError(errorText);
866
- return err(authUnauthorizedError("kv", errorText, {
867
- status: response.status,
868
- ...requiredAction && { requiredAction },
869
- ...resource && { resource }
870
- }));
1125
+ return this.authorizationFailure(
1126
+ `Failed to batch read ${keys.length} key(s)`,
1127
+ response,
1128
+ errorText,
1129
+ paths,
1130
+ action
1131
+ );
871
1132
  }
872
1133
  if (response.status === 413) {
873
1134
  return err(serviceError(
@@ -921,6 +1182,8 @@ var KVService = class extends BaseService {
921
1182
  return ok({ results, count: results.length });
922
1183
  } catch (error) {
923
1184
  return err(wrapError("kv", error));
1185
+ } finally {
1186
+ request.dispose();
924
1187
  }
925
1188
  }
926
1189
  /**
@@ -956,7 +1219,7 @@ var KVService = class extends BaseService {
956
1219
  return text;
957
1220
  }
958
1221
  }
959
- async createSignedReadUrlError(response, key) {
1222
+ async createSignedReadUrlError(response, key, path) {
960
1223
  let errorText = response.statusText;
961
1224
  try {
962
1225
  const text = await response.text();
@@ -966,23 +1229,62 @@ var KVService = class extends BaseService {
966
1229
  } catch {
967
1230
  }
968
1231
  if (response.status === 401 || response.status === 403) {
969
- const { resource, action } = parseAuthError(errorText);
970
- return err(authUnauthorizedError("kv", errorText, {
971
- status: response.status,
972
- ...action && { requiredAction: action },
973
- ...resource && { resource }
974
- }));
1232
+ return this.authorizationFailure(
1233
+ `Failed to create signed read URL for key ${JSON.stringify(key)}`,
1234
+ response,
1235
+ errorText,
1236
+ [path],
1237
+ KVAction.GET
1238
+ );
975
1239
  }
976
1240
  const code = response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR;
977
1241
  return err(
978
1242
  serviceError(
979
1243
  code,
980
- `Failed to create signed read URL for key "${key}": ${response.status} - ${errorText}`,
1244
+ `Failed to create signed read URL for key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
981
1245
  "kv",
982
1246
  { meta: { status: response.status, statusText: response.statusText } }
983
1247
  )
984
1248
  );
985
1249
  }
1250
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
1251
+ async readAuthorizationText(response) {
1252
+ try {
1253
+ return await response.text();
1254
+ } catch {
1255
+ return "";
1256
+ }
1257
+ }
1258
+ /**
1259
+ * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
1260
+ * status and the server text (`<context>: <status> - <text>`, the same
1261
+ * shape as every other KV failure) so callers that rethrow only the message
1262
+ * still see the status; `meta.status` carries it for typed callers.
1263
+ * Server hints must match the requested path/action (whether the node sends
1264
+ * a relative path or full resource); metadata uses only the request-derived
1265
+ * canonical resource under the authenticated session's space.
1266
+ */
1267
+ authorizationFailure(context, response, errorText, paths, expectedAction, extraMeta = {}) {
1268
+ const { resource, action } = parseAuthError(errorText);
1269
+ const requestedPath = paths.find(
1270
+ (path) => resource === path || resource === `${this.context.session.spaceId}/kv/${path}`
1271
+ );
1272
+ const capability = requestedPath !== void 0 && action === expectedAction ? validatedCapabilityOf({
1273
+ service: "kv",
1274
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1275
+ meta: {
1276
+ status: response.status,
1277
+ resource: `${this.context.session.spaceId}/kv/${requestedPath}`,
1278
+ requiredAction: action
1279
+ }
1280
+ }) : void 0;
1281
+ const detail = errorText.trim().length > 0 ? errorText : response.statusText || "authorization failed";
1282
+ return err(authUnauthorizedError("kv", `${context}: ${response.status} - ${detail}`, {
1283
+ status: response.status,
1284
+ ...extraMeta,
1285
+ ...capability
1286
+ }));
1287
+ }
986
1288
  normalizeSignedReadUrlResponse(data) {
987
1289
  if (!data || typeof data !== "object") {
988
1290
  return void 0;
@@ -1014,25 +1316,36 @@ var KVService = class extends BaseService {
1014
1316
  ));
1015
1317
  }
1016
1318
  const path = this.getFullPath(key, options?.prefix);
1319
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1017
1320
  try {
1018
1321
  const response = await this.invokeOperation(
1019
1322
  path,
1020
1323
  KVAction.GET,
1021
1324
  void 0,
1022
- options?.signal,
1325
+ request.signal,
1023
1326
  options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1024
1327
  );
1025
1328
  if (!response.ok) {
1026
1329
  if (response.status === 401 || response.status === 403) {
1027
- const errorText2 = await response.text();
1028
- const { resource, action } = parseAuthError(errorText2);
1330
+ const errorText2 = await this.readAuthorizationText(response);
1029
1331
  const permissionHint = parsePermissionHintFromErrorText(errorText2);
1030
- return err(authUnauthorizedError("kv", errorText2, {
1031
- status: response.status,
1032
- ...action && { requiredAction: action },
1033
- ...resource && { resource },
1034
- ...permissionHint === void 0 ? {} : { permissionHint }
1035
- }));
1332
+ const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1333
+ service: "kv",
1334
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1335
+ meta: {
1336
+ status: response.status,
1337
+ resource: `${this.context.session.spaceId}/kv/${path}`,
1338
+ requiredAction: KVAction.GET
1339
+ }
1340
+ }) : void 0;
1341
+ return this.authorizationFailure(
1342
+ `Failed to get key ${JSON.stringify(key)}`,
1343
+ response,
1344
+ errorText2,
1345
+ [path],
1346
+ KVAction.GET,
1347
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1348
+ );
1036
1349
  }
1037
1350
  if (response.status === 404) {
1038
1351
  return this.classifyNotFound(response, key);
@@ -1041,7 +1354,7 @@ var KVService = class extends BaseService {
1041
1354
  if (response.status === 413) {
1042
1355
  return err(serviceError(
1043
1356
  ErrorCodes.KV_RESPONSE_TOO_LARGE,
1044
- `KV value at key "${key}" exceeds the requested response limit`,
1357
+ `KV value at key ${JSON.stringify(key)} exceeds the requested response limit`,
1045
1358
  "kv",
1046
1359
  { meta: { status: response.status, statusText: response.statusText } }
1047
1360
  ));
@@ -1049,7 +1362,7 @@ var KVService = class extends BaseService {
1049
1362
  return err(
1050
1363
  serviceError(
1051
1364
  ErrorCodes.NETWORK_ERROR,
1052
- `Failed to get key "${key}": ${response.status} - ${errorText}`,
1365
+ `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1053
1366
  "kv",
1054
1367
  { meta: { status: response.status, statusText: response.statusText } }
1055
1368
  )
@@ -1066,6 +1379,8 @@ var KVService = class extends BaseService {
1066
1379
  });
1067
1380
  } catch (error) {
1068
1381
  return err(wrapError("kv", error));
1382
+ } finally {
1383
+ request.dispose();
1069
1384
  }
1070
1385
  });
1071
1386
  }
@@ -1093,33 +1408,33 @@ var KVService = class extends BaseService {
1093
1408
  }
1094
1409
  const path = this.getFullPath(key, options?.prefix);
1095
1410
  const body = this.serializePutValue(value, options?.contentType);
1411
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1096
1412
  try {
1097
1413
  const response = await this.invokeOperation(
1098
1414
  path,
1099
1415
  KVAction.PUT,
1100
1416
  body,
1101
- options?.signal,
1417
+ request.signal,
1102
1418
  {
1103
1419
  ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1104
1420
  ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
1105
1421
  }
1106
1422
  );
1107
1423
  if (response.status === 401 || response.status === 403) {
1108
- const errorText = await response.text();
1109
- const message = errorText.trim().length > 0 ? errorText : `Failed to put key "${key}": ${response.status} - ${response.statusText || "authorization failed"}`;
1110
- const { resource, action: requiredAction } = parseAuthError(errorText);
1111
- return err(authUnauthorizedError("kv", message, {
1112
- status: response.status,
1113
- ...requiredAction && { requiredAction },
1114
- ...resource && { resource }
1115
- }));
1424
+ return this.authorizationFailure(
1425
+ `Failed to put key ${JSON.stringify(key)}`,
1426
+ response,
1427
+ await this.readAuthorizationText(response),
1428
+ [path],
1429
+ KVAction.PUT
1430
+ );
1116
1431
  }
1117
1432
  if (!response.ok) {
1118
1433
  const errorText = await response.text();
1119
1434
  if (response.status === 412) {
1120
1435
  return err(serviceError(
1121
1436
  ErrorCodes.KV_PRECONDITION_FAILED,
1122
- `KV precondition failed for key "${key}"`,
1437
+ `KV precondition failed for key ${JSON.stringify(key)}`,
1123
1438
  "kv",
1124
1439
  { meta: { status: response.status, statusText: response.statusText } }
1125
1440
  ));
@@ -1127,7 +1442,7 @@ var KVService = class extends BaseService {
1127
1442
  if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
1128
1443
  return err(serviceError(
1129
1444
  ErrorCodes.KV_CONFLICT,
1130
- `Concurrent KV update conflicted for key "${key}"`,
1445
+ `Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
1131
1446
  "kv",
1132
1447
  { meta: { status: response.status, statusText: response.statusText } }
1133
1448
  ));
@@ -1143,7 +1458,7 @@ var KVService = class extends BaseService {
1143
1458
  return err(
1144
1459
  serviceError(
1145
1460
  ErrorCodes.KV_WRITE_FAILED,
1146
- `Failed to put key "${key}": ${response.status} - ${errorText}`,
1461
+ `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1147
1462
  "kv",
1148
1463
  { meta: { status: response.status, statusText: response.statusText } }
1149
1464
  )
@@ -1155,6 +1470,8 @@ var KVService = class extends BaseService {
1155
1470
  });
1156
1471
  } catch (error) {
1157
1472
  return err(wrapError("kv", error));
1473
+ } finally {
1474
+ request.dispose();
1158
1475
  }
1159
1476
  });
1160
1477
  }
@@ -1194,6 +1511,7 @@ var KVService = class extends BaseService {
1194
1511
  seen.add(path);
1195
1512
  }
1196
1513
  let requestMayHaveDispatched = false;
1514
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1197
1515
  try {
1198
1516
  const body = new FormData();
1199
1517
  for (let index = 0; index < items.length; index++) {
@@ -1216,7 +1534,7 @@ var KVService = class extends BaseService {
1216
1534
  method: "POST",
1217
1535
  headers,
1218
1536
  body,
1219
- signal: this.combineSignals(options?.signal)
1537
+ signal: request.signal
1220
1538
  };
1221
1539
  const fetchFn = this.context.fetch;
1222
1540
  requestMayHaveDispatched = true;
@@ -1237,12 +1555,13 @@ var KVService = class extends BaseService {
1237
1555
  );
1238
1556
  }
1239
1557
  if (response.status === 401 || response.status === 403) {
1240
- const { resource, action } = parseAuthError(errorText);
1241
- return err(authUnauthorizedError("kv", errorText, {
1242
- status: response.status,
1243
- ...action && { requiredAction: action },
1244
- ...resource && { resource }
1245
- }));
1558
+ return this.authorizationFailure(
1559
+ `Failed to batch put ${items.length} key(s)`,
1560
+ response,
1561
+ errorText,
1562
+ paths,
1563
+ KVAction.PUT
1564
+ );
1246
1565
  }
1247
1566
  const quotaError = this.handleQuotaErrorResponse(
1248
1567
  response,
@@ -1266,14 +1585,16 @@ var KVService = class extends BaseService {
1266
1585
  rawBody = await response.json();
1267
1586
  } catch (jsonError) {
1268
1587
  const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1588
+ const timedOut = isRequestTimeoutError(cause);
1269
1589
  return err(
1270
1590
  serviceError(
1271
- ErrorCodes.NETWORK_ERROR,
1272
- `KV batchPut response was not valid JSON: ${cause.message}`,
1591
+ timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
1592
+ timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
1273
1593
  "kv",
1274
1594
  {
1275
1595
  cause,
1276
1596
  meta: {
1597
+ ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1277
1598
  requestMayHaveDispatched: true,
1278
1599
  responseReceived: true,
1279
1600
  status: response.status,
@@ -1328,6 +1649,8 @@ var KVService = class extends BaseService {
1328
1649
  ...wrapped,
1329
1650
  meta: { ...wrapped.meta, requestMayHaveDispatched }
1330
1651
  });
1652
+ } finally {
1653
+ request.dispose();
1331
1654
  }
1332
1655
  });
1333
1656
  }
@@ -1350,26 +1673,27 @@ var KVService = class extends BaseService {
1350
1673
  if (options?.path) {
1351
1674
  listPath = listPath ? `${listPath}/${options.path}` : options.path;
1352
1675
  }
1676
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1353
1677
  try {
1354
1678
  const response = await this.invokeOperation(
1355
1679
  listPath,
1356
1680
  KVAction.LIST,
1357
1681
  void 0,
1358
- options?.signal,
1682
+ request.signal,
1359
1683
  options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1360
1684
  ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1361
1685
  ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1362
1686
  }
1363
1687
  );
1364
1688
  if (!response.ok) {
1365
- if (response.status === 401) {
1366
- const errorText2 = await response.text();
1367
- const { resource, action } = parseAuthError(errorText2);
1368
- return err(authUnauthorizedError("kv", errorText2, {
1369
- status: response.status,
1370
- ...action && { requiredAction: action },
1371
- ...resource && { resource }
1372
- }));
1689
+ if (response.status === 401 || response.status === 403) {
1690
+ return this.authorizationFailure(
1691
+ "Failed to list keys",
1692
+ response,
1693
+ await this.readAuthorizationText(response),
1694
+ [listPath],
1695
+ KVAction.LIST
1696
+ );
1373
1697
  }
1374
1698
  const errorText = await response.text();
1375
1699
  return err(
@@ -1396,9 +1720,174 @@ var KVService = class extends BaseService {
1396
1720
  });
1397
1721
  } catch (error) {
1398
1722
  return err(wrapError("kv", error));
1723
+ } finally {
1724
+ request.dispose();
1725
+ }
1726
+ });
1727
+ }
1728
+ /**
1729
+ * Read one page of the `tinycloud.kv/sync` change feed for a prefix.
1730
+ */
1731
+ async changes(options) {
1732
+ return this.withTelemetry("changes", options?.prefix, async () => {
1733
+ if (!this.requireAuth()) {
1734
+ return err(authRequiredError("kv"));
1735
+ }
1736
+ if (typeof options?.prefix !== "string" || options.prefix.length === 0) {
1737
+ return err(serviceError(
1738
+ ErrorCodes.INVALID_INPUT,
1739
+ "KV changes prefix must be a non-empty string",
1740
+ "kv"
1741
+ ));
1742
+ }
1743
+ if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1e3)) {
1744
+ return err(serviceError(
1745
+ ErrorCodes.INVALID_INPUT,
1746
+ "KV changes limit must be an integer from 1 through 1000",
1747
+ "kv"
1748
+ ));
1749
+ }
1750
+ const headers = {};
1751
+ if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
1752
+ if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
1753
+ if (options.retentionGrant !== void 0) {
1754
+ headers["x-tinycloud-retention-grant"] = options.retentionGrant;
1755
+ }
1756
+ const request = this.createRequestSignal(options.signal, options.timeout);
1757
+ try {
1758
+ const response = await this.invokeOperation(
1759
+ options.prefix,
1760
+ KVAction.SYNC,
1761
+ void 0,
1762
+ request.signal,
1763
+ headers
1764
+ );
1765
+ if (!response.ok) {
1766
+ return await this.changesFailure(response, options.prefix, request.signal);
1767
+ }
1768
+ const text = await response.text();
1769
+ let body;
1770
+ try {
1771
+ body = JSON.parse(text);
1772
+ } catch {
1773
+ body = void 0;
1774
+ }
1775
+ const page = this.normalizeChangesResponse(body);
1776
+ if (!page) {
1777
+ return err(serviceError(
1778
+ ErrorCodes.NETWORK_ERROR,
1779
+ "KV changes response was not a tinycloud.kv/sync page",
1780
+ "kv",
1781
+ { meta: { status: response.status, statusText: response.statusText } }
1782
+ ));
1783
+ }
1784
+ return ok(page);
1785
+ } catch (error) {
1786
+ return err(wrapError("kv", error));
1787
+ } finally {
1788
+ request.dispose();
1399
1789
  }
1400
1790
  });
1401
1791
  }
1792
+ /**
1793
+ * Map a failed `kv/sync` response. 410 and the retention 403 carry
1794
+ * `{"error":{"code","reason"}}`; a revoked grant is a 401 whose whole body
1795
+ * is the node's revocation error (see REVOKED_BODY). A body read that
1796
+ * fails because the request was cancelled or timed out rethrows, so the
1797
+ * caller sees `ABORTED`/`TIMEOUT` rather than a mapped status.
1798
+ */
1799
+ async changesFailure(response, prefix, signal) {
1800
+ if (response.status === 404) {
1801
+ return this.classifyNotFound(response, prefix);
1802
+ }
1803
+ let errorText;
1804
+ try {
1805
+ errorText = await response.text();
1806
+ } catch (error) {
1807
+ if (signal.aborted) throw error;
1808
+ errorText = "";
1809
+ }
1810
+ const meta = { status: response.status, statusText: response.statusText };
1811
+ let reason;
1812
+ let bodyCode;
1813
+ try {
1814
+ const body = JSON.parse(errorText);
1815
+ const error = isJsonObject(body) ? body.error : void 0;
1816
+ if (isJsonObject(error)) {
1817
+ if (typeof error.code === "string") bodyCode = error.code;
1818
+ if (typeof error.reason === "string") reason = error.reason;
1819
+ }
1820
+ } catch {
1821
+ }
1822
+ const context = `Failed to read KV changes for ${JSON.stringify(prefix)}`;
1823
+ if (response.status === 410) {
1824
+ return err(serviceError(
1825
+ ErrorCodes.KV_SYNC_RESET_REQUIRED,
1826
+ `${context}: the node requires a reset (${reason ?? "reason not given"}); discard sync state and restart without a cursor`,
1827
+ "kv",
1828
+ { meta: { ...meta, reason } }
1829
+ ));
1830
+ }
1831
+ if (response.status === 403 && bodyCode === "RETENTION_GRANT_REFUSED") {
1832
+ return err(serviceError(
1833
+ ErrorCodes.KV_RETENTION_GRANT_REFUSED,
1834
+ `${context}: the retention grant was refused (${reason ?? "reason not given"})`,
1835
+ "kv",
1836
+ { meta: { ...meta, reason } }
1837
+ ));
1838
+ }
1839
+ if (response.status === 401) {
1840
+ const revoked = revocationCodeOf(errorText);
1841
+ if (revoked !== void 0) {
1842
+ return err(serviceError(revoked, `${context}: 401 - ${errorText}`, "kv", { meta }));
1843
+ }
1844
+ }
1845
+ if (response.status === 401 || response.status === 403) {
1846
+ return this.authorizationFailure(context, response, errorText, [prefix], KVAction.SYNC);
1847
+ }
1848
+ return err(serviceError(
1849
+ response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR,
1850
+ `${context}: ${response.status} - ${errorText}`,
1851
+ "kv",
1852
+ { meta }
1853
+ ));
1854
+ }
1855
+ normalizeChangesResponse(data) {
1856
+ if (!isJsonObject(data)) return void 0;
1857
+ const { changes, more, cursor, source, authority } = data;
1858
+ if (!Array.isArray(changes) || typeof more !== "boolean" || typeof cursor !== "string" || !isJsonObject(source) || typeof source.nodeDid !== "string" || typeof source.space !== "string" || typeof source.prefix !== "string" || !isJsonObject(authority)) {
1859
+ return void 0;
1860
+ }
1861
+ const { notBefore, expiresAt, retainUntil } = authority;
1862
+ if (notBefore !== null && typeof notBefore !== "string" || expiresAt !== null && typeof expiresAt !== "string" || retainUntil !== null && typeof retainUntil !== "string") {
1863
+ return void 0;
1864
+ }
1865
+ const parsed = [];
1866
+ for (const change of changes) {
1867
+ if (!isJsonObject(change) || typeof change.key !== "string") return void 0;
1868
+ if (change.deleted === true) {
1869
+ parsed.push({ key: change.key, deleted: true });
1870
+ continue;
1871
+ }
1872
+ const { metadata } = change;
1873
+ if (change.deleted !== false || typeof change.etag !== "string" || !isJsonObject(metadata) || !Object.values(metadata).every((value) => typeof value === "string")) {
1874
+ return void 0;
1875
+ }
1876
+ parsed.push({
1877
+ key: change.key,
1878
+ deleted: false,
1879
+ etag: change.etag,
1880
+ metadata
1881
+ });
1882
+ }
1883
+ return {
1884
+ changes: parsed,
1885
+ more,
1886
+ cursor,
1887
+ source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
1888
+ authority: { notBefore, expiresAt, retainUntil }
1889
+ };
1890
+ }
1402
1891
  /**
1403
1892
  * Delete a key.
1404
1893
  */
@@ -1408,23 +1897,24 @@ var KVService = class extends BaseService {
1408
1897
  return err(authRequiredError("kv"));
1409
1898
  }
1410
1899
  const path = this.getFullPath(key, options?.prefix);
1900
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1411
1901
  try {
1412
1902
  const response = await this.invokeOperation(
1413
1903
  path,
1414
1904
  KVAction.DELETE,
1415
1905
  void 0,
1416
- options?.signal,
1906
+ request.signal,
1417
1907
  options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1418
1908
  );
1419
1909
  if (!response.ok) {
1420
- if (response.status === 401) {
1421
- const errorText2 = await response.text();
1422
- const { resource, action } = parseAuthError(errorText2);
1423
- return err(authUnauthorizedError("kv", errorText2, {
1424
- status: response.status,
1425
- ...action && { requiredAction: action },
1426
- ...resource && { resource }
1427
- }));
1910
+ if (response.status === 401 || response.status === 403) {
1911
+ return this.authorizationFailure(
1912
+ `Failed to delete key ${JSON.stringify(key)}`,
1913
+ response,
1914
+ await this.readAuthorizationText(response),
1915
+ [path],
1916
+ KVAction.DELETE
1917
+ );
1428
1918
  }
1429
1919
  if (response.status === 404) {
1430
1920
  return this.classifyNotFound(response, key);
@@ -1433,7 +1923,7 @@ var KVService = class extends BaseService {
1433
1923
  if (response.status === 412) {
1434
1924
  return err(serviceError(
1435
1925
  ErrorCodes.KV_PRECONDITION_FAILED,
1436
- `KV precondition failed for key "${key}"`,
1926
+ `KV precondition failed for key ${JSON.stringify(key)}`,
1437
1927
  "kv",
1438
1928
  { meta: { status: response.status, statusText: response.statusText } }
1439
1929
  ));
@@ -1441,7 +1931,7 @@ var KVService = class extends BaseService {
1441
1931
  if (response.status === 503 && options?.ifMatch !== void 0) {
1442
1932
  return err(serviceError(
1443
1933
  ErrorCodes.KV_CONFLICT,
1444
- `Concurrent KV delete conflicted for key "${key}"`,
1934
+ `Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
1445
1935
  "kv",
1446
1936
  { meta: { status: response.status, statusText: response.statusText } }
1447
1937
  ));
@@ -1449,7 +1939,7 @@ var KVService = class extends BaseService {
1449
1939
  return err(
1450
1940
  serviceError(
1451
1941
  ErrorCodes.NETWORK_ERROR,
1452
- `Failed to delete key "${key}": ${response.status} - ${errorText}`,
1942
+ `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1453
1943
  "kv",
1454
1944
  { meta: { status: response.status, statusText: response.statusText } }
1455
1945
  )
@@ -1461,6 +1951,8 @@ var KVService = class extends BaseService {
1461
1951
  });
1462
1952
  } catch (error) {
1463
1953
  return err(wrapError("kv", error));
1954
+ } finally {
1955
+ request.dispose();
1464
1956
  }
1465
1957
  });
1466
1958
  }
@@ -1473,22 +1965,23 @@ var KVService = class extends BaseService {
1473
1965
  return err(authRequiredError("kv"));
1474
1966
  }
1475
1967
  const path = this.getFullPath(key, options?.prefix);
1968
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1476
1969
  try {
1477
1970
  const response = await this.invokeOperation(
1478
1971
  path,
1479
1972
  KVAction.HEAD,
1480
1973
  void 0,
1481
- options?.signal
1974
+ request.signal
1482
1975
  );
1483
1976
  if (!response.ok) {
1484
- if (response.status === 401) {
1485
- const errorText2 = await response.text();
1486
- const { resource, action } = parseAuthError(errorText2);
1487
- return err(authUnauthorizedError("kv", errorText2, {
1488
- status: response.status,
1489
- ...action && { requiredAction: action },
1490
- ...resource && { resource }
1491
- }));
1977
+ if (response.status === 401 || response.status === 403) {
1978
+ return this.authorizationFailure(
1979
+ `Failed to get metadata for key ${JSON.stringify(key)}`,
1980
+ response,
1981
+ await this.readAuthorizationText(response),
1982
+ [path],
1983
+ KVAction.HEAD
1984
+ );
1492
1985
  }
1493
1986
  if (response.status === 404) {
1494
1987
  return this.classifyNotFound(response, key);
@@ -1497,7 +1990,7 @@ var KVService = class extends BaseService {
1497
1990
  return err(
1498
1991
  serviceError(
1499
1992
  ErrorCodes.NETWORK_ERROR,
1500
- `Failed to get metadata for key "${key}": ${response.status} - ${errorText}`,
1993
+ `Failed to get metadata for key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1501
1994
  "kv",
1502
1995
  { meta: { status: response.status, statusText: response.statusText } }
1503
1996
  )
@@ -1509,6 +2002,8 @@ var KVService = class extends BaseService {
1509
2002
  });
1510
2003
  } catch (error) {
1511
2004
  return err(wrapError("kv", error));
2005
+ } finally {
2006
+ request.dispose();
1512
2007
  }
1513
2008
  });
1514
2009
  }
@@ -1546,15 +2041,16 @@ var KVService = class extends BaseService {
1546
2041
  if (options?.etag !== void 0) {
1547
2042
  body.etag = options.etag;
1548
2043
  }
2044
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1549
2045
  try {
1550
2046
  const response = await this.context.fetch(`${this.host}/signed/kv`, {
1551
2047
  method: "POST",
1552
2048
  headers: this.withJsonContentType(headers),
1553
2049
  body: JSON.stringify(body),
1554
- signal: this.combineSignals(options?.signal)
2050
+ signal: request.signal
1555
2051
  });
1556
2052
  if (!response.ok) {
1557
- return this.createSignedReadUrlError(response, key);
2053
+ return this.createSignedReadUrlError(response, key, path);
1558
2054
  }
1559
2055
  const signedUrl = this.normalizeSignedReadUrlResponse(
1560
2056
  await response.json()
@@ -1571,6 +2067,8 @@ var KVService = class extends BaseService {
1571
2067
  return ok(signedUrl);
1572
2068
  } catch (error) {
1573
2069
  return err(wrapError("kv", error));
2070
+ } finally {
2071
+ request.dispose();
1574
2072
  }
1575
2073
  });
1576
2074
  }