@tinycloud/sdk-services 3.1.0-beta.1 → 3.1.0-beta.11

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.cjs CHANGED
@@ -34,6 +34,10 @@ var ErrorCodes = {
34
34
  AUTH_EXPIRED: "AUTH_EXPIRED",
35
35
  AUTH_REQUIRED: "AUTH_REQUIRED",
36
36
  AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
37
+ /** HTTP 401: the delegation the invocation cites was revoked. */
38
+ AUTH_DELEGATION_REVOKED: "AUTH_DELEGATION_REVOKED",
39
+ /** HTTP 401: a delegation the cited one rests on was revoked. */
40
+ AUTH_DELEGATION_ANCESTOR_REVOKED: "AUTH_DELEGATION_ANCESTOR_REVOKED",
37
41
  NETWORK_ERROR: "NETWORK_ERROR",
38
42
  TIMEOUT: "TIMEOUT",
39
43
  ABORTED: "ABORTED",
@@ -45,6 +49,10 @@ var ErrorCodes = {
45
49
  KV_PRECONDITION_FAILED: "KV_PRECONDITION_FAILED",
46
50
  KV_CONFLICT: "KV_CONFLICT",
47
51
  KV_RESPONSE_TOO_LARGE: "KV_RESPONSE_TOO_LARGE",
52
+ /** HTTP 410 from the change feed: discard sync state and restart without a cursor; `meta.reason` says why. */
53
+ KV_SYNC_RESET_REQUIRED: "KV_SYNC_RESET_REQUIRED",
54
+ /** HTTP 403 from the change feed: the retention grant was refused; `meta.reason` says why. */
55
+ KV_RETENTION_GRANT_REFUSED: "KV_RETENTION_GRANT_REFUSED",
48
56
  // SQL-specific errors
49
57
  SQL_ERROR: "SQL_ERROR",
50
58
  SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
@@ -99,6 +107,55 @@ function serviceError(code, message, service, options) {
99
107
  };
100
108
  }
101
109
 
110
+ // src/errors.ts
111
+ var import_bootstrap = require("@tinycloud/bootstrap");
112
+
113
+ // src/encryption/networkId.ts
114
+ var URN_PREFIX = "urn:tinycloud:encryption:";
115
+ var NETWORK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
116
+ var NetworkIdError = class extends Error {
117
+ constructor(message) {
118
+ super(message);
119
+ this.name = "NetworkIdError";
120
+ }
121
+ };
122
+ function parseNetworkId(networkId) {
123
+ if (typeof networkId !== "string" || networkId.length === 0) {
124
+ throw new NetworkIdError("networkId must be a non-empty string");
125
+ }
126
+ if (!networkId.startsWith(URN_PREFIX)) {
127
+ throw new NetworkIdError(
128
+ `networkId must start with ${URN_PREFIX} (got ${JSON.stringify(networkId)})`
129
+ );
130
+ }
131
+ const body = networkId.slice(URN_PREFIX.length);
132
+ const lastColon = body.lastIndexOf(":");
133
+ if (lastColon <= 0 || lastColon === body.length - 1) {
134
+ throw new NetworkIdError(
135
+ `networkId missing ownerDid or name segment (got ${JSON.stringify(networkId)})`
136
+ );
137
+ }
138
+ const ownerDid = body.slice(0, lastColon);
139
+ const name = body.slice(lastColon + 1);
140
+ if (!ownerDid.startsWith("did:")) {
141
+ throw new NetworkIdError(
142
+ `networkId ownerDid must be a DID (got ${JSON.stringify(ownerDid)})`
143
+ );
144
+ }
145
+ const didParts = ownerDid.split(":");
146
+ if (didParts.length < 3 || didParts.some((p) => p.length === 0)) {
147
+ throw new NetworkIdError(
148
+ `networkId ownerDid is not a well-formed DID (got ${JSON.stringify(ownerDid)})`
149
+ );
150
+ }
151
+ if (!NETWORK_NAME_RE.test(name)) {
152
+ throw new NetworkIdError(
153
+ `networkId name ${JSON.stringify(name)} must match ${NETWORK_NAME_RE.source}`
154
+ );
155
+ }
156
+ return { networkId, ownerDid, name };
157
+ }
158
+
102
159
  // src/errors.ts
103
160
  function parsePermissionHint(value) {
104
161
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
@@ -143,11 +200,31 @@ function authRequiredError(service) {
143
200
  service
144
201
  };
145
202
  }
146
- function timeoutError(service) {
203
+ var RequestTimeoutError = class extends Error {
204
+ constructor(timeoutMs) {
205
+ super(`Request timed out after ${timeoutMs}ms.`);
206
+ this.name = "TimeoutError";
207
+ this.timeoutMs = timeoutMs;
208
+ }
209
+ };
210
+ function isRequestTimeoutError(error) {
211
+ return error instanceof RequestTimeoutError || error instanceof Error && error.name === "TimeoutError" && typeof error.timeoutMs === "number";
212
+ }
213
+ function timeoutError(service, timeoutMs, cause) {
214
+ if (timeoutMs === void 0) {
215
+ return {
216
+ code: ErrorCodes.TIMEOUT,
217
+ message: "Request timed out.",
218
+ service,
219
+ ...cause === void 0 ? {} : { cause }
220
+ };
221
+ }
147
222
  return {
148
223
  code: ErrorCodes.TIMEOUT,
149
- message: "Request timed out.",
150
- service
224
+ message: `Request timed out after ${timeoutMs}ms.`,
225
+ service,
226
+ ...cause === void 0 ? {} : { cause },
227
+ meta: { timeoutMs }
151
228
  };
152
229
  }
153
230
  function abortedError(service) {
@@ -167,6 +244,101 @@ function parseAuthError(responseText) {
167
244
  function authUnauthorizedError(service, message, meta) {
168
245
  return serviceError(ErrorCodes.AUTH_UNAUTHORIZED, message, service, { meta });
169
246
  }
247
+ var grantableActions = new Set(
248
+ import_bootstrap.CAPABILITY_REGISTRY.map(({ urn }) => urn).filter((urn) => !urn.endsWith("/*"))
249
+ );
250
+ var capabilityServices = new Set(
251
+ import_bootstrap.CAPABILITY_REGISTRY.map(({ service }) => service.slice("tinycloud.".length))
252
+ );
253
+ function parseCapabilityResource(resource, service) {
254
+ if (resource.length === 0 || resource.length > 1024 || !capabilityServices.has(service) || /[\s\p{C}*#]/u.test(resource) || resource.includes("://")) return void 0;
255
+ const segments = resource.split("/");
256
+ if (!/^tinycloud:[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(segments[0])) return void 0;
257
+ const serviceIndex = segments.findIndex(
258
+ (segment, index) => index > 0 && capabilityServices.has(segment)
259
+ );
260
+ if (serviceIndex < 1 || segments[serviceIndex] !== service) return void 0;
261
+ if (segments.slice(1, serviceIndex).some(
262
+ (segment) => segment.length === 0 || segment === "." || segment === ".."
263
+ )) return void 0;
264
+ const pathSegments = segments.slice(serviceIndex + 1);
265
+ if (pathSegments.some((segment) => segment === "." || segment === "..")) return void 0;
266
+ if (service !== "kv" && (pathSegments.length === 0 || pathSegments[pathSegments.length - 1] === "")) return void 0;
267
+ return {
268
+ space: segments.slice(0, serviceIndex).join("/"),
269
+ path: pathSegments.join("/")
270
+ };
271
+ }
272
+ function safeCapabilityResource(value, service, action) {
273
+ if (typeof value !== "string" || value.length === 0 || value.length > 1024) return false;
274
+ if (service === "encryption" && value.startsWith("urn:tinycloud:encryption:")) {
275
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.:/-]*$/.test(value) || value.includes("//") || value.split("/").some((segment) => segment === "." || segment === "..")) return false;
276
+ try {
277
+ parseNetworkId(value);
278
+ return true;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+ const parsed = parseCapabilityResource(value, service);
284
+ return parsed !== void 0 && (!parsed.path.endsWith("/") || service === "kv" && action === "tinycloud.kv/list") && (parsed.path !== "" || service === "kv" && action === "tinycloud.kv/list");
285
+ }
286
+ function validatedCapabilityOf(error) {
287
+ const verdict = authorizationVerdictOf(error);
288
+ if (verdict !== "unauthenticated" && verdict !== "forbidden") return void 0;
289
+ const seen = /* @__PURE__ */ new Set();
290
+ let node = error;
291
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
292
+ seen.add(node);
293
+ const record = node;
294
+ const status = errorStatusOf(record);
295
+ const meta = record.meta;
296
+ if ((status === 401 || status === 403 || status === void 0 && record.code === ErrorCodes.AUTH_UNAUTHORIZED) && typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
297
+ const metadata = meta;
298
+ const { resource, requiredAction } = metadata;
299
+ if (typeof requiredAction === "string" && grantableActions.has(requiredAction)) {
300
+ const actionService = requiredAction.slice("tinycloud.".length, requiredAction.indexOf("/"));
301
+ const explicitService = record.service === void 0 ? metadata.service : record.service;
302
+ if (safeCapabilityResource(resource, actionService, requiredAction) && (explicitService === void 0 || explicitService === actionService || explicitService === `tinycloud.${actionService}`)) {
303
+ return { resource, requiredAction };
304
+ }
305
+ }
306
+ }
307
+ node = record.cause;
308
+ }
309
+ return void 0;
310
+ }
311
+ var MAX_CAUSE_DEPTH = 8;
312
+ function errorStatusOf(node) {
313
+ const meta = node.meta;
314
+ const candidates = [
315
+ node.status,
316
+ node.statusCode,
317
+ typeof meta === "object" && meta !== null ? meta.status : void 0
318
+ ];
319
+ for (const candidate of candidates) {
320
+ if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 400 && candidate <= 599) {
321
+ return candidate;
322
+ }
323
+ }
324
+ return void 0;
325
+ }
326
+ function authorizationVerdictOf(error) {
327
+ const seen = /* @__PURE__ */ new Set();
328
+ let sawUnauthorizedCode = false;
329
+ let node = error;
330
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
331
+ seen.add(node);
332
+ const record = node;
333
+ const status = errorStatusOf(record);
334
+ if (status === 401) return "unauthenticated";
335
+ if (status === 403) return "forbidden";
336
+ if (status !== void 0) return "other";
337
+ if (record.code === ErrorCodes.AUTH_UNAUTHORIZED) sawUnauthorizedCode = true;
338
+ node = record.cause;
339
+ }
340
+ return sawUnauthorizedCode ? "forbidden" : void 0;
341
+ }
170
342
  function storageQuotaExceededError(service, message, meta) {
171
343
  return {
172
344
  code: ErrorCodes.STORAGE_QUOTA_EXCEEDED,
@@ -183,8 +355,25 @@ function storageLimitReachedError(service, message, meta) {
183
355
  meta
184
356
  };
185
357
  }
358
+ 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.";
359
+ 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.";
360
+ function parseStorageQuotaBytes(responseText) {
361
+ const match = responseText.match(/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i);
362
+ if (!match) return void 0;
363
+ return {
364
+ usedBytes: parseInt(match[1], 10),
365
+ limitBytes: parseInt(match[2], 10)
366
+ };
367
+ }
368
+ function storageRejectionError(service, status, meta, responseText) {
369
+ const quotaMeta = { ...meta, ...parseStorageQuotaBytes(responseText) };
370
+ return status === 402 ? storageQuotaExceededError(service, STORAGE_FULL_MESSAGE, quotaMeta) : storageLimitReachedError(service, STORAGE_WRITE_TOO_LARGE_MESSAGE, quotaMeta);
371
+ }
186
372
  function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
187
373
  if (error instanceof Error) {
374
+ if (isRequestTimeoutError(error)) {
375
+ return timeoutError(service, error.timeoutMs, error);
376
+ }
188
377
  if (error.name === "AbortError") {
189
378
  return abortedError(service);
190
379
  }
@@ -206,6 +395,7 @@ function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
206
395
  }
207
396
 
208
397
  // src/base/BaseService.ts
398
+ var MAX_TIMER_DELAY_MS = 2147483647;
209
399
  var BaseService = class {
210
400
  constructor() {
211
401
  /**
@@ -386,6 +576,64 @@ var BaseService = class {
386
576
  }
387
577
  return controller.signal;
388
578
  }
579
+ /**
580
+ * Create the abort signal for one request, bounded by the request timeout.
581
+ *
582
+ * The signal aborts when the service signs out, the context aborts, the
583
+ * caller's `signal` aborts, or the timeout elapses, whichever comes first.
584
+ * A timeout aborts with a {@link RequestTimeoutError} (`TimeoutError`), which
585
+ * `wrapError` maps to `ErrorCodes.TIMEOUT`; the other sources keep their own
586
+ * reason (normally `AbortError`, mapped to `ErrorCodes.ABORTED`).
587
+ *
588
+ * The timeout is `timeoutMs` when given, otherwise `config.timeout`. Only a
589
+ * positive, finite value (at most 2^31-1 ms) applies; `undefined`, `0`, and
590
+ * `Infinity` mean no timeout, so a per-call `0` opts out of a configured one.
591
+ *
592
+ * Callers must call `dispose()` once the response body has been consumed,
593
+ * which clears the timer and removes the listeners this adds to the
594
+ * long-lived service and context signals.
595
+ *
596
+ * @param signal - The caller's abort signal for this request
597
+ * @param timeoutMs - Per-request timeout override in milliseconds
598
+ */
599
+ createRequestSignal(signal, timeoutMs) {
600
+ const controller = new AbortController();
601
+ const detachers = [];
602
+ let timer;
603
+ const dispose = () => {
604
+ if (timer !== void 0) {
605
+ clearTimeout(timer);
606
+ timer = void 0;
607
+ }
608
+ for (const detach of detachers.splice(0)) {
609
+ detach();
610
+ }
611
+ };
612
+ const abort = (reason) => {
613
+ dispose();
614
+ controller.abort(reason);
615
+ };
616
+ const requestSignal = { signal: controller.signal, dispose };
617
+ const parents = [
618
+ this.abortController.signal,
619
+ this.context?.abortSignal,
620
+ signal
621
+ ].filter((parent) => parent !== void 0);
622
+ for (const parent of parents) {
623
+ if (parent.aborted) {
624
+ abort(parent.reason);
625
+ return requestSignal;
626
+ }
627
+ const onAbort = () => abort(parent.reason);
628
+ parent.addEventListener("abort", onAbort, { once: true });
629
+ detachers.push(() => parent.removeEventListener("abort", onAbort));
630
+ }
631
+ const timeout = timeoutMs ?? this._config.timeout;
632
+ if (typeof timeout === "number" && timeout > 0 && timeout <= MAX_TIMER_DELAY_MS) {
633
+ timer = setTimeout(() => abort(new RequestTimeoutError(timeout)), timeout);
634
+ }
635
+ return requestSignal;
636
+ }
389
637
  /**
390
638
  * Wrap an operation with error handling and telemetry.
391
639
  *
@@ -494,6 +742,30 @@ var PrefixedKVService = class _PrefixedKVService {
494
742
  removePrefix
495
743
  });
496
744
  }
745
+ /**
746
+ * Read the change feed for everything under this prefix. A view with an
747
+ * empty prefix (`""` or `"/"`) is refused: the node does not serve a
748
+ * whole-space feed.
749
+ */
750
+ async changes(options) {
751
+ if (this._prefix.replace(/\/+$/, "") === "") {
752
+ return err(serviceError(
753
+ ErrorCodes.INVALID_INPUT,
754
+ "KV changes needs a non-empty prefix; this view has none",
755
+ "kv"
756
+ ));
757
+ }
758
+ const syncPrefix = `${this._prefix}/`;
759
+ const response = await this._kv.changes({ ...options, prefix: syncPrefix });
760
+ if (!response.ok) return response;
761
+ return ok({
762
+ ...response.data,
763
+ changes: response.data.changes.map((change) => ({
764
+ ...change,
765
+ key: change.key.startsWith(syncPrefix) ? change.key.slice(syncPrefix.length) : change.key
766
+ }))
767
+ });
768
+ }
497
769
  /**
498
770
  * Delete a key.
499
771
  */
@@ -546,11 +818,24 @@ var KVAction = {
546
818
  PUT: "tinycloud.kv/put",
547
819
  LIST: "tinycloud.kv/list",
548
820
  DELETE: "tinycloud.kv/del",
549
- HEAD: "tinycloud.kv/metadata"
821
+ HEAD: "tinycloud.kv/metadata",
822
+ /** The change feed; never implied by `*` or `tinycloud.kv/*`. */
823
+ SYNC: "tinycloud.kv/sync",
824
+ /** Retention attestation; presented through `retentionGrant`, never invoked or implied. */
825
+ RETAIN: "tinycloud.kv/retain"
550
826
  };
551
827
 
552
828
  // src/kv/KVService.ts
553
829
  var MAX_KV_BATCH_READ_ITEMS = 100;
830
+ function isJsonObject(value) {
831
+ return typeof value === "object" && value !== null && !Array.isArray(value);
832
+ }
833
+ var REVOKED_BODY = /^(?:Invalid invocation: )?delegation-(?:(revoked): [A-Za-z0-9]+|(ancestor-revoked): ancestor=[A-Za-z0-9]+ invoked=[A-Za-z0-9]+)$/;
834
+ function revocationCodeOf(body) {
835
+ const match = REVOKED_BODY.exec(body.trim());
836
+ if (match === null) return void 0;
837
+ return match[1] !== void 0 ? ErrorCodes.AUTH_DELEGATION_REVOKED : ErrorCodes.AUTH_DELEGATION_ANCESTOR_REVOKED;
838
+ }
554
839
  function encodeKvBatchPartName(path) {
555
840
  return encodeURIComponent(path).replace(
556
841
  /[!'()*]/g,
@@ -573,47 +858,21 @@ var KVService = class extends BaseService {
573
858
  get config() {
574
859
  return this._config;
575
860
  }
576
- // Parses "Used: X bytes, Limit: Y bytes" from tinycloud-node error responses
577
- parseQuotaInfo(errorText) {
578
- const match = errorText.match(
579
- /Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i
580
- );
581
- if (match) {
582
- return {
583
- usedBytes: parseInt(match[1], 10),
584
- limitBytes: parseInt(match[2], 10)
585
- };
586
- }
587
- return void 0;
588
- }
589
861
  handleQuotaErrorResponse(response, errorText, key) {
590
- if (response.status === 402) {
591
- const quotaInfo = this.parseQuotaInfo(errorText);
592
- return err(
593
- storageQuotaExceededError(
594
- "kv",
595
- `Storage quota exceeded for key ${JSON.stringify(key)}: ${errorText}`,
596
- {
597
- status: response.status,
598
- ...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
599
- }
600
- )
601
- );
862
+ if (response.status !== 402 && response.status !== 413) {
863
+ return void 0;
602
864
  }
603
- if (response.status === 413) {
604
- const quotaInfo = this.parseQuotaInfo(errorText);
605
- return err(
606
- storageLimitReachedError(
607
- "kv",
608
- `Storage limit reached for key ${JSON.stringify(key)}: ${errorText}`,
609
- {
610
- status: response.status,
611
- ...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
612
- }
613
- )
614
- );
865
+ if (response.status === 413 && !parseStorageQuotaBytes(errorText)) {
866
+ return void 0;
615
867
  }
616
- return void 0;
868
+ return err(
869
+ storageRejectionError(
870
+ "kv",
871
+ response.status,
872
+ { status: response.status, statusText: response.statusText, key },
873
+ errorText
874
+ )
875
+ );
617
876
  }
618
877
  /**
619
878
  * Classify a KV 404 by reading the response body once.
@@ -672,7 +931,7 @@ var KVService = class extends BaseService {
672
931
  * @param path - Resource path
673
932
  * @param action - KV action
674
933
  * @param body - Optional request body
675
- * @param signal - Optional abort signal
934
+ * @param signal - Request signal from createRequestSignal()
676
935
  * @returns Fetch response
677
936
  */
678
937
  async invokeOperation(path, action, body, signal, extraHeaders) {
@@ -688,7 +947,7 @@ var KVService = class extends BaseService {
688
947
  method: "POST",
689
948
  headers: requestHeaders,
690
949
  body,
691
- signal: this.combineSignals(signal)
950
+ signal
692
951
  });
693
952
  }
694
953
  /**
@@ -865,6 +1124,7 @@ var KVService = class extends BaseService {
865
1124
  "kv"
866
1125
  ));
867
1126
  }
1127
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
868
1128
  try {
869
1129
  const session = this.context.session;
870
1130
  const invocationHeaders = this.context.invokeAny(
@@ -886,15 +1146,17 @@ var KVService = class extends BaseService {
886
1146
  const response = await this.context.fetch(`${this.host}/invoke`, {
887
1147
  method: "POST",
888
1148
  headers,
889
- signal: this.combineSignals(options?.signal)
1149
+ signal: request.signal
890
1150
  });
891
1151
  if (!response.ok) {
892
- const errorText = await response.text();
1152
+ const errorText = response.status === 401 || response.status === 403 ? await this.readAuthorizationText(response) : await response.text();
893
1153
  if (response.status === 401 || response.status === 403) {
894
1154
  return this.authorizationFailure(
895
1155
  `Failed to batch read ${keys.length} key(s)`,
896
1156
  response,
897
- errorText
1157
+ errorText,
1158
+ paths,
1159
+ action
898
1160
  );
899
1161
  }
900
1162
  if (response.status === 413) {
@@ -949,6 +1211,8 @@ var KVService = class extends BaseService {
949
1211
  return ok({ results, count: results.length });
950
1212
  } catch (error) {
951
1213
  return err(wrapError("kv", error));
1214
+ } finally {
1215
+ request.dispose();
952
1216
  }
953
1217
  }
954
1218
  /**
@@ -984,7 +1248,7 @@ var KVService = class extends BaseService {
984
1248
  return text;
985
1249
  }
986
1250
  }
987
- async createSignedReadUrlError(response, key) {
1251
+ async createSignedReadUrlError(response, key, path) {
988
1252
  let errorText = response.statusText;
989
1253
  try {
990
1254
  const text = await response.text();
@@ -997,7 +1261,9 @@ var KVService = class extends BaseService {
997
1261
  return this.authorizationFailure(
998
1262
  `Failed to create signed read URL for key ${JSON.stringify(key)}`,
999
1263
  response,
1000
- errorText
1264
+ errorText,
1265
+ [path],
1266
+ KVAction.GET
1001
1267
  );
1002
1268
  }
1003
1269
  const code = response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR;
@@ -1010,21 +1276,42 @@ var KVService = class extends BaseService {
1010
1276
  )
1011
1277
  );
1012
1278
  }
1279
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
1280
+ async readAuthorizationText(response) {
1281
+ try {
1282
+ return await response.text();
1283
+ } catch {
1284
+ return "";
1285
+ }
1286
+ }
1013
1287
  /**
1014
1288
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
1015
1289
  * status and the server text (`<context>: <status> - <text>`, the same
1016
1290
  * shape as every other KV failure) so callers that rethrow only the message
1017
1291
  * still see the status; `meta.status` carries it for typed callers.
1018
- * Resource/ability hints are parsed from the raw body, never the message.
1292
+ * Server hints must match the requested path/action (whether the node sends
1293
+ * a relative path or full resource); metadata uses only the request-derived
1294
+ * canonical resource under the authenticated session's space.
1019
1295
  */
1020
- authorizationFailure(context, response, errorText, extraMeta = {}) {
1296
+ authorizationFailure(context, response, errorText, paths, expectedAction, extraMeta = {}) {
1021
1297
  const { resource, action } = parseAuthError(errorText);
1298
+ const requestedPath = paths.find(
1299
+ (path) => resource === path || resource === `${this.context.session.spaceId}/kv/${path}`
1300
+ );
1301
+ const capability = requestedPath !== void 0 && action === expectedAction ? validatedCapabilityOf({
1302
+ service: "kv",
1303
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1304
+ meta: {
1305
+ status: response.status,
1306
+ resource: `${this.context.session.spaceId}/kv/${requestedPath}`,
1307
+ requiredAction: action
1308
+ }
1309
+ }) : void 0;
1022
1310
  const detail = errorText.trim().length > 0 ? errorText : response.statusText || "authorization failed";
1023
1311
  return err(authUnauthorizedError("kv", `${context}: ${response.status} - ${detail}`, {
1024
1312
  status: response.status,
1025
- ...action && { requiredAction: action },
1026
- ...resource && { resource },
1027
- ...extraMeta
1313
+ ...extraMeta,
1314
+ ...capability
1028
1315
  }));
1029
1316
  }
1030
1317
  normalizeSignedReadUrlResponse(data) {
@@ -1058,23 +1345,35 @@ var KVService = class extends BaseService {
1058
1345
  ));
1059
1346
  }
1060
1347
  const path = this.getFullPath(key, options?.prefix);
1348
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1061
1349
  try {
1062
1350
  const response = await this.invokeOperation(
1063
1351
  path,
1064
1352
  KVAction.GET,
1065
1353
  void 0,
1066
- options?.signal,
1354
+ request.signal,
1067
1355
  options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1068
1356
  );
1069
1357
  if (!response.ok) {
1070
1358
  if (response.status === 401 || response.status === 403) {
1071
- const errorText2 = await response.text();
1359
+ const errorText2 = await this.readAuthorizationText(response);
1072
1360
  const permissionHint = parsePermissionHintFromErrorText(errorText2);
1361
+ const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1362
+ service: "kv",
1363
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1364
+ meta: {
1365
+ status: response.status,
1366
+ resource: `${this.context.session.spaceId}/kv/${path}`,
1367
+ requiredAction: KVAction.GET
1368
+ }
1369
+ }) : void 0;
1073
1370
  return this.authorizationFailure(
1074
1371
  `Failed to get key ${JSON.stringify(key)}`,
1075
1372
  response,
1076
1373
  errorText2,
1077
- permissionHint === void 0 ? {} : { permissionHint }
1374
+ [path],
1375
+ KVAction.GET,
1376
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1078
1377
  );
1079
1378
  }
1080
1379
  if (response.status === 404) {
@@ -1109,6 +1408,8 @@ var KVService = class extends BaseService {
1109
1408
  });
1110
1409
  } catch (error) {
1111
1410
  return err(wrapError("kv", error));
1411
+ } finally {
1412
+ request.dispose();
1112
1413
  }
1113
1414
  });
1114
1415
  }
@@ -1136,12 +1437,13 @@ var KVService = class extends BaseService {
1136
1437
  }
1137
1438
  const path = this.getFullPath(key, options?.prefix);
1138
1439
  const body = this.serializePutValue(value, options?.contentType);
1440
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1139
1441
  try {
1140
1442
  const response = await this.invokeOperation(
1141
1443
  path,
1142
1444
  KVAction.PUT,
1143
1445
  body,
1144
- options?.signal,
1446
+ request.signal,
1145
1447
  {
1146
1448
  ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1147
1449
  ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
@@ -1151,7 +1453,9 @@ var KVService = class extends BaseService {
1151
1453
  return this.authorizationFailure(
1152
1454
  `Failed to put key ${JSON.stringify(key)}`,
1153
1455
  response,
1154
- await response.text()
1456
+ await this.readAuthorizationText(response),
1457
+ [path],
1458
+ KVAction.PUT
1155
1459
  );
1156
1460
  }
1157
1461
  if (!response.ok) {
@@ -1195,6 +1499,8 @@ var KVService = class extends BaseService {
1195
1499
  });
1196
1500
  } catch (error) {
1197
1501
  return err(wrapError("kv", error));
1502
+ } finally {
1503
+ request.dispose();
1198
1504
  }
1199
1505
  });
1200
1506
  }
@@ -1234,6 +1540,7 @@ var KVService = class extends BaseService {
1234
1540
  seen.add(path);
1235
1541
  }
1236
1542
  let requestMayHaveDispatched = false;
1543
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1237
1544
  try {
1238
1545
  const body = new FormData();
1239
1546
  for (let index = 0; index < items.length; index++) {
@@ -1256,7 +1563,7 @@ var KVService = class extends BaseService {
1256
1563
  method: "POST",
1257
1564
  headers,
1258
1565
  body,
1259
- signal: this.combineSignals(options?.signal)
1566
+ signal: request.signal
1260
1567
  };
1261
1568
  const fetchFn = this.context.fetch;
1262
1569
  requestMayHaveDispatched = true;
@@ -1280,7 +1587,9 @@ var KVService = class extends BaseService {
1280
1587
  return this.authorizationFailure(
1281
1588
  `Failed to batch put ${items.length} key(s)`,
1282
1589
  response,
1283
- errorText
1590
+ errorText,
1591
+ paths,
1592
+ KVAction.PUT
1284
1593
  );
1285
1594
  }
1286
1595
  const quotaError = this.handleQuotaErrorResponse(
@@ -1305,14 +1614,16 @@ var KVService = class extends BaseService {
1305
1614
  rawBody = await response.json();
1306
1615
  } catch (jsonError) {
1307
1616
  const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1617
+ const timedOut = isRequestTimeoutError(cause);
1308
1618
  return err(
1309
1619
  serviceError(
1310
- ErrorCodes.NETWORK_ERROR,
1311
- `KV batchPut response was not valid JSON: ${cause.message}`,
1620
+ timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
1621
+ timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
1312
1622
  "kv",
1313
1623
  {
1314
1624
  cause,
1315
1625
  meta: {
1626
+ ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1316
1627
  requestMayHaveDispatched: true,
1317
1628
  responseReceived: true,
1318
1629
  status: response.status,
@@ -1367,6 +1678,8 @@ var KVService = class extends BaseService {
1367
1678
  ...wrapped,
1368
1679
  meta: { ...wrapped.meta, requestMayHaveDispatched }
1369
1680
  });
1681
+ } finally {
1682
+ request.dispose();
1370
1683
  }
1371
1684
  });
1372
1685
  }
@@ -1389,23 +1702,26 @@ var KVService = class extends BaseService {
1389
1702
  if (options?.path) {
1390
1703
  listPath = listPath ? `${listPath}/${options.path}` : options.path;
1391
1704
  }
1705
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1392
1706
  try {
1393
1707
  const response = await this.invokeOperation(
1394
1708
  listPath,
1395
1709
  KVAction.LIST,
1396
1710
  void 0,
1397
- options?.signal,
1711
+ request.signal,
1398
1712
  options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1399
1713
  ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1400
1714
  ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1401
1715
  }
1402
1716
  );
1403
1717
  if (!response.ok) {
1404
- if (response.status === 401) {
1718
+ if (response.status === 401 || response.status === 403) {
1405
1719
  return this.authorizationFailure(
1406
1720
  "Failed to list keys",
1407
1721
  response,
1408
- await response.text()
1722
+ await this.readAuthorizationText(response),
1723
+ [listPath],
1724
+ KVAction.LIST
1409
1725
  );
1410
1726
  }
1411
1727
  const errorText = await response.text();
@@ -1433,9 +1749,174 @@ var KVService = class extends BaseService {
1433
1749
  });
1434
1750
  } catch (error) {
1435
1751
  return err(wrapError("kv", error));
1752
+ } finally {
1753
+ request.dispose();
1436
1754
  }
1437
1755
  });
1438
1756
  }
1757
+ /**
1758
+ * Read one page of the `tinycloud.kv/sync` change feed for a prefix.
1759
+ */
1760
+ async changes(options) {
1761
+ return this.withTelemetry("changes", options?.prefix, async () => {
1762
+ if (!this.requireAuth()) {
1763
+ return err(authRequiredError("kv"));
1764
+ }
1765
+ if (typeof options?.prefix !== "string" || options.prefix.length === 0) {
1766
+ return err(serviceError(
1767
+ ErrorCodes.INVALID_INPUT,
1768
+ "KV changes prefix must be a non-empty string",
1769
+ "kv"
1770
+ ));
1771
+ }
1772
+ if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1e3)) {
1773
+ return err(serviceError(
1774
+ ErrorCodes.INVALID_INPUT,
1775
+ "KV changes limit must be an integer from 1 through 1000",
1776
+ "kv"
1777
+ ));
1778
+ }
1779
+ const headers = {};
1780
+ if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
1781
+ if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
1782
+ if (options.retentionGrant !== void 0) {
1783
+ headers["x-tinycloud-retention-grant"] = options.retentionGrant;
1784
+ }
1785
+ const request = this.createRequestSignal(options.signal, options.timeout);
1786
+ try {
1787
+ const response = await this.invokeOperation(
1788
+ options.prefix,
1789
+ KVAction.SYNC,
1790
+ void 0,
1791
+ request.signal,
1792
+ headers
1793
+ );
1794
+ if (!response.ok) {
1795
+ return await this.changesFailure(response, options.prefix, request.signal);
1796
+ }
1797
+ const text = await response.text();
1798
+ let body;
1799
+ try {
1800
+ body = JSON.parse(text);
1801
+ } catch {
1802
+ body = void 0;
1803
+ }
1804
+ const page = this.normalizeChangesResponse(body);
1805
+ if (!page) {
1806
+ return err(serviceError(
1807
+ ErrorCodes.NETWORK_ERROR,
1808
+ "KV changes response was not a tinycloud.kv/sync page",
1809
+ "kv",
1810
+ { meta: { status: response.status, statusText: response.statusText } }
1811
+ ));
1812
+ }
1813
+ return ok(page);
1814
+ } catch (error) {
1815
+ return err(wrapError("kv", error));
1816
+ } finally {
1817
+ request.dispose();
1818
+ }
1819
+ });
1820
+ }
1821
+ /**
1822
+ * Map a failed `kv/sync` response. 410 and the retention 403 carry
1823
+ * `{"error":{"code","reason"}}`; a revoked grant is a 401 whose whole body
1824
+ * is the node's revocation error (see REVOKED_BODY). A body read that
1825
+ * fails because the request was cancelled or timed out rethrows, so the
1826
+ * caller sees `ABORTED`/`TIMEOUT` rather than a mapped status.
1827
+ */
1828
+ async changesFailure(response, prefix, signal) {
1829
+ if (response.status === 404) {
1830
+ return this.classifyNotFound(response, prefix);
1831
+ }
1832
+ let errorText;
1833
+ try {
1834
+ errorText = await response.text();
1835
+ } catch (error) {
1836
+ if (signal.aborted) throw error;
1837
+ errorText = "";
1838
+ }
1839
+ const meta = { status: response.status, statusText: response.statusText };
1840
+ let reason;
1841
+ let bodyCode;
1842
+ try {
1843
+ const body = JSON.parse(errorText);
1844
+ const error = isJsonObject(body) ? body.error : void 0;
1845
+ if (isJsonObject(error)) {
1846
+ if (typeof error.code === "string") bodyCode = error.code;
1847
+ if (typeof error.reason === "string") reason = error.reason;
1848
+ }
1849
+ } catch {
1850
+ }
1851
+ const context = `Failed to read KV changes for ${JSON.stringify(prefix)}`;
1852
+ if (response.status === 410) {
1853
+ return err(serviceError(
1854
+ ErrorCodes.KV_SYNC_RESET_REQUIRED,
1855
+ `${context}: the node requires a reset (${reason ?? "reason not given"}); discard sync state and restart without a cursor`,
1856
+ "kv",
1857
+ { meta: { ...meta, reason } }
1858
+ ));
1859
+ }
1860
+ if (response.status === 403 && bodyCode === "RETENTION_GRANT_REFUSED") {
1861
+ return err(serviceError(
1862
+ ErrorCodes.KV_RETENTION_GRANT_REFUSED,
1863
+ `${context}: the retention grant was refused (${reason ?? "reason not given"})`,
1864
+ "kv",
1865
+ { meta: { ...meta, reason } }
1866
+ ));
1867
+ }
1868
+ if (response.status === 401) {
1869
+ const revoked = revocationCodeOf(errorText);
1870
+ if (revoked !== void 0) {
1871
+ return err(serviceError(revoked, `${context}: 401 - ${errorText}`, "kv", { meta }));
1872
+ }
1873
+ }
1874
+ if (response.status === 401 || response.status === 403) {
1875
+ return this.authorizationFailure(context, response, errorText, [prefix], KVAction.SYNC);
1876
+ }
1877
+ return err(serviceError(
1878
+ response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR,
1879
+ `${context}: ${response.status} - ${errorText}`,
1880
+ "kv",
1881
+ { meta }
1882
+ ));
1883
+ }
1884
+ normalizeChangesResponse(data) {
1885
+ if (!isJsonObject(data)) return void 0;
1886
+ const { changes, more, cursor, source, authority } = data;
1887
+ 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)) {
1888
+ return void 0;
1889
+ }
1890
+ const { notBefore, expiresAt, retainUntil } = authority;
1891
+ if (notBefore !== null && typeof notBefore !== "string" || expiresAt !== null && typeof expiresAt !== "string" || retainUntil !== null && typeof retainUntil !== "string") {
1892
+ return void 0;
1893
+ }
1894
+ const parsed = [];
1895
+ for (const change of changes) {
1896
+ if (!isJsonObject(change) || typeof change.key !== "string") return void 0;
1897
+ if (change.deleted === true) {
1898
+ parsed.push({ key: change.key, deleted: true });
1899
+ continue;
1900
+ }
1901
+ const { metadata } = change;
1902
+ if (change.deleted !== false || typeof change.etag !== "string" || !isJsonObject(metadata) || !Object.values(metadata).every((value) => typeof value === "string")) {
1903
+ return void 0;
1904
+ }
1905
+ parsed.push({
1906
+ key: change.key,
1907
+ deleted: false,
1908
+ etag: change.etag,
1909
+ metadata
1910
+ });
1911
+ }
1912
+ return {
1913
+ changes: parsed,
1914
+ more,
1915
+ cursor,
1916
+ source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
1917
+ authority: { notBefore, expiresAt, retainUntil }
1918
+ };
1919
+ }
1439
1920
  /**
1440
1921
  * Delete a key.
1441
1922
  */
@@ -1445,20 +1926,23 @@ var KVService = class extends BaseService {
1445
1926
  return err(authRequiredError("kv"));
1446
1927
  }
1447
1928
  const path = this.getFullPath(key, options?.prefix);
1929
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1448
1930
  try {
1449
1931
  const response = await this.invokeOperation(
1450
1932
  path,
1451
1933
  KVAction.DELETE,
1452
1934
  void 0,
1453
- options?.signal,
1935
+ request.signal,
1454
1936
  options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1455
1937
  );
1456
1938
  if (!response.ok) {
1457
- if (response.status === 401) {
1939
+ if (response.status === 401 || response.status === 403) {
1458
1940
  return this.authorizationFailure(
1459
1941
  `Failed to delete key ${JSON.stringify(key)}`,
1460
1942
  response,
1461
- await response.text()
1943
+ await this.readAuthorizationText(response),
1944
+ [path],
1945
+ KVAction.DELETE
1462
1946
  );
1463
1947
  }
1464
1948
  if (response.status === 404) {
@@ -1496,6 +1980,8 @@ var KVService = class extends BaseService {
1496
1980
  });
1497
1981
  } catch (error) {
1498
1982
  return err(wrapError("kv", error));
1983
+ } finally {
1984
+ request.dispose();
1499
1985
  }
1500
1986
  });
1501
1987
  }
@@ -1508,19 +1994,22 @@ var KVService = class extends BaseService {
1508
1994
  return err(authRequiredError("kv"));
1509
1995
  }
1510
1996
  const path = this.getFullPath(key, options?.prefix);
1997
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1511
1998
  try {
1512
1999
  const response = await this.invokeOperation(
1513
2000
  path,
1514
2001
  KVAction.HEAD,
1515
2002
  void 0,
1516
- options?.signal
2003
+ request.signal
1517
2004
  );
1518
2005
  if (!response.ok) {
1519
- if (response.status === 401) {
2006
+ if (response.status === 401 || response.status === 403) {
1520
2007
  return this.authorizationFailure(
1521
2008
  `Failed to get metadata for key ${JSON.stringify(key)}`,
1522
2009
  response,
1523
- await response.text()
2010
+ await this.readAuthorizationText(response),
2011
+ [path],
2012
+ KVAction.HEAD
1524
2013
  );
1525
2014
  }
1526
2015
  if (response.status === 404) {
@@ -1542,6 +2031,8 @@ var KVService = class extends BaseService {
1542
2031
  });
1543
2032
  } catch (error) {
1544
2033
  return err(wrapError("kv", error));
2034
+ } finally {
2035
+ request.dispose();
1545
2036
  }
1546
2037
  });
1547
2038
  }
@@ -1579,15 +2070,16 @@ var KVService = class extends BaseService {
1579
2070
  if (options?.etag !== void 0) {
1580
2071
  body.etag = options.etag;
1581
2072
  }
2073
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1582
2074
  try {
1583
2075
  const response = await this.context.fetch(`${this.host}/signed/kv`, {
1584
2076
  method: "POST",
1585
2077
  headers: this.withJsonContentType(headers),
1586
2078
  body: JSON.stringify(body),
1587
- signal: this.combineSignals(options?.signal)
2079
+ signal: request.signal
1588
2080
  });
1589
2081
  if (!response.ok) {
1590
- return this.createSignedReadUrlError(response, key);
2082
+ return this.createSignedReadUrlError(response, key, path);
1591
2083
  }
1592
2084
  const signedUrl = this.normalizeSignedReadUrlResponse(
1593
2085
  await response.json()
@@ -1604,6 +2096,8 @@ var KVService = class extends BaseService {
1604
2096
  return ok(signedUrl);
1605
2097
  } catch (error) {
1606
2098
  return err(wrapError("kv", error));
2099
+ } finally {
2100
+ request.dispose();
1607
2101
  }
1608
2102
  });
1609
2103
  }