@tinycloud/sdk-services 3.1.0-beta.1 → 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 ${JSON.stringify(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 ${JSON.stringify(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,15 +1117,17 @@ 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
1125
  return this.authorizationFailure(
866
1126
  `Failed to batch read ${keys.length} key(s)`,
867
1127
  response,
868
- errorText
1128
+ errorText,
1129
+ paths,
1130
+ action
869
1131
  );
870
1132
  }
871
1133
  if (response.status === 413) {
@@ -920,6 +1182,8 @@ var KVService = class extends BaseService {
920
1182
  return ok({ results, count: results.length });
921
1183
  } catch (error) {
922
1184
  return err(wrapError("kv", error));
1185
+ } finally {
1186
+ request.dispose();
923
1187
  }
924
1188
  }
925
1189
  /**
@@ -955,7 +1219,7 @@ var KVService = class extends BaseService {
955
1219
  return text;
956
1220
  }
957
1221
  }
958
- async createSignedReadUrlError(response, key) {
1222
+ async createSignedReadUrlError(response, key, path) {
959
1223
  let errorText = response.statusText;
960
1224
  try {
961
1225
  const text = await response.text();
@@ -968,7 +1232,9 @@ var KVService = class extends BaseService {
968
1232
  return this.authorizationFailure(
969
1233
  `Failed to create signed read URL for key ${JSON.stringify(key)}`,
970
1234
  response,
971
- errorText
1235
+ errorText,
1236
+ [path],
1237
+ KVAction.GET
972
1238
  );
973
1239
  }
974
1240
  const code = response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR;
@@ -981,21 +1247,42 @@ var KVService = class extends BaseService {
981
1247
  )
982
1248
  );
983
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
+ }
984
1258
  /**
985
1259
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
986
1260
  * status and the server text (`<context>: <status> - <text>`, the same
987
1261
  * shape as every other KV failure) so callers that rethrow only the message
988
1262
  * still see the status; `meta.status` carries it for typed callers.
989
- * Resource/ability hints are parsed from the raw body, never the message.
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.
990
1266
  */
991
- authorizationFailure(context, response, errorText, extraMeta = {}) {
1267
+ authorizationFailure(context, response, errorText, paths, expectedAction, extraMeta = {}) {
992
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;
993
1281
  const detail = errorText.trim().length > 0 ? errorText : response.statusText || "authorization failed";
994
1282
  return err(authUnauthorizedError("kv", `${context}: ${response.status} - ${detail}`, {
995
1283
  status: response.status,
996
- ...action && { requiredAction: action },
997
- ...resource && { resource },
998
- ...extraMeta
1284
+ ...extraMeta,
1285
+ ...capability
999
1286
  }));
1000
1287
  }
1001
1288
  normalizeSignedReadUrlResponse(data) {
@@ -1029,23 +1316,35 @@ var KVService = class extends BaseService {
1029
1316
  ));
1030
1317
  }
1031
1318
  const path = this.getFullPath(key, options?.prefix);
1319
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1032
1320
  try {
1033
1321
  const response = await this.invokeOperation(
1034
1322
  path,
1035
1323
  KVAction.GET,
1036
1324
  void 0,
1037
- options?.signal,
1325
+ request.signal,
1038
1326
  options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1039
1327
  );
1040
1328
  if (!response.ok) {
1041
1329
  if (response.status === 401 || response.status === 403) {
1042
- const errorText2 = await response.text();
1330
+ const errorText2 = await this.readAuthorizationText(response);
1043
1331
  const permissionHint = parsePermissionHintFromErrorText(errorText2);
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;
1044
1341
  return this.authorizationFailure(
1045
1342
  `Failed to get key ${JSON.stringify(key)}`,
1046
1343
  response,
1047
1344
  errorText2,
1048
- permissionHint === void 0 ? {} : { permissionHint }
1345
+ [path],
1346
+ KVAction.GET,
1347
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1049
1348
  );
1050
1349
  }
1051
1350
  if (response.status === 404) {
@@ -1080,6 +1379,8 @@ var KVService = class extends BaseService {
1080
1379
  });
1081
1380
  } catch (error) {
1082
1381
  return err(wrapError("kv", error));
1382
+ } finally {
1383
+ request.dispose();
1083
1384
  }
1084
1385
  });
1085
1386
  }
@@ -1107,12 +1408,13 @@ var KVService = class extends BaseService {
1107
1408
  }
1108
1409
  const path = this.getFullPath(key, options?.prefix);
1109
1410
  const body = this.serializePutValue(value, options?.contentType);
1411
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1110
1412
  try {
1111
1413
  const response = await this.invokeOperation(
1112
1414
  path,
1113
1415
  KVAction.PUT,
1114
1416
  body,
1115
- options?.signal,
1417
+ request.signal,
1116
1418
  {
1117
1419
  ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1118
1420
  ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
@@ -1122,7 +1424,9 @@ var KVService = class extends BaseService {
1122
1424
  return this.authorizationFailure(
1123
1425
  `Failed to put key ${JSON.stringify(key)}`,
1124
1426
  response,
1125
- await response.text()
1427
+ await this.readAuthorizationText(response),
1428
+ [path],
1429
+ KVAction.PUT
1126
1430
  );
1127
1431
  }
1128
1432
  if (!response.ok) {
@@ -1166,6 +1470,8 @@ var KVService = class extends BaseService {
1166
1470
  });
1167
1471
  } catch (error) {
1168
1472
  return err(wrapError("kv", error));
1473
+ } finally {
1474
+ request.dispose();
1169
1475
  }
1170
1476
  });
1171
1477
  }
@@ -1205,6 +1511,7 @@ var KVService = class extends BaseService {
1205
1511
  seen.add(path);
1206
1512
  }
1207
1513
  let requestMayHaveDispatched = false;
1514
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1208
1515
  try {
1209
1516
  const body = new FormData();
1210
1517
  for (let index = 0; index < items.length; index++) {
@@ -1227,7 +1534,7 @@ var KVService = class extends BaseService {
1227
1534
  method: "POST",
1228
1535
  headers,
1229
1536
  body,
1230
- signal: this.combineSignals(options?.signal)
1537
+ signal: request.signal
1231
1538
  };
1232
1539
  const fetchFn = this.context.fetch;
1233
1540
  requestMayHaveDispatched = true;
@@ -1251,7 +1558,9 @@ var KVService = class extends BaseService {
1251
1558
  return this.authorizationFailure(
1252
1559
  `Failed to batch put ${items.length} key(s)`,
1253
1560
  response,
1254
- errorText
1561
+ errorText,
1562
+ paths,
1563
+ KVAction.PUT
1255
1564
  );
1256
1565
  }
1257
1566
  const quotaError = this.handleQuotaErrorResponse(
@@ -1276,14 +1585,16 @@ var KVService = class extends BaseService {
1276
1585
  rawBody = await response.json();
1277
1586
  } catch (jsonError) {
1278
1587
  const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1588
+ const timedOut = isRequestTimeoutError(cause);
1279
1589
  return err(
1280
1590
  serviceError(
1281
- ErrorCodes.NETWORK_ERROR,
1282
- `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}`,
1283
1593
  "kv",
1284
1594
  {
1285
1595
  cause,
1286
1596
  meta: {
1597
+ ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1287
1598
  requestMayHaveDispatched: true,
1288
1599
  responseReceived: true,
1289
1600
  status: response.status,
@@ -1338,6 +1649,8 @@ var KVService = class extends BaseService {
1338
1649
  ...wrapped,
1339
1650
  meta: { ...wrapped.meta, requestMayHaveDispatched }
1340
1651
  });
1652
+ } finally {
1653
+ request.dispose();
1341
1654
  }
1342
1655
  });
1343
1656
  }
@@ -1360,23 +1673,26 @@ var KVService = class extends BaseService {
1360
1673
  if (options?.path) {
1361
1674
  listPath = listPath ? `${listPath}/${options.path}` : options.path;
1362
1675
  }
1676
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1363
1677
  try {
1364
1678
  const response = await this.invokeOperation(
1365
1679
  listPath,
1366
1680
  KVAction.LIST,
1367
1681
  void 0,
1368
- options?.signal,
1682
+ request.signal,
1369
1683
  options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1370
1684
  ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1371
1685
  ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1372
1686
  }
1373
1687
  );
1374
1688
  if (!response.ok) {
1375
- if (response.status === 401) {
1689
+ if (response.status === 401 || response.status === 403) {
1376
1690
  return this.authorizationFailure(
1377
1691
  "Failed to list keys",
1378
1692
  response,
1379
- await response.text()
1693
+ await this.readAuthorizationText(response),
1694
+ [listPath],
1695
+ KVAction.LIST
1380
1696
  );
1381
1697
  }
1382
1698
  const errorText = await response.text();
@@ -1404,9 +1720,174 @@ var KVService = class extends BaseService {
1404
1720
  });
1405
1721
  } catch (error) {
1406
1722
  return err(wrapError("kv", error));
1723
+ } finally {
1724
+ request.dispose();
1407
1725
  }
1408
1726
  });
1409
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();
1789
+ }
1790
+ });
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
+ }
1410
1891
  /**
1411
1892
  * Delete a key.
1412
1893
  */
@@ -1416,20 +1897,23 @@ var KVService = class extends BaseService {
1416
1897
  return err(authRequiredError("kv"));
1417
1898
  }
1418
1899
  const path = this.getFullPath(key, options?.prefix);
1900
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1419
1901
  try {
1420
1902
  const response = await this.invokeOperation(
1421
1903
  path,
1422
1904
  KVAction.DELETE,
1423
1905
  void 0,
1424
- options?.signal,
1906
+ request.signal,
1425
1907
  options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1426
1908
  );
1427
1909
  if (!response.ok) {
1428
- if (response.status === 401) {
1910
+ if (response.status === 401 || response.status === 403) {
1429
1911
  return this.authorizationFailure(
1430
1912
  `Failed to delete key ${JSON.stringify(key)}`,
1431
1913
  response,
1432
- await response.text()
1914
+ await this.readAuthorizationText(response),
1915
+ [path],
1916
+ KVAction.DELETE
1433
1917
  );
1434
1918
  }
1435
1919
  if (response.status === 404) {
@@ -1467,6 +1951,8 @@ var KVService = class extends BaseService {
1467
1951
  });
1468
1952
  } catch (error) {
1469
1953
  return err(wrapError("kv", error));
1954
+ } finally {
1955
+ request.dispose();
1470
1956
  }
1471
1957
  });
1472
1958
  }
@@ -1479,19 +1965,22 @@ var KVService = class extends BaseService {
1479
1965
  return err(authRequiredError("kv"));
1480
1966
  }
1481
1967
  const path = this.getFullPath(key, options?.prefix);
1968
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1482
1969
  try {
1483
1970
  const response = await this.invokeOperation(
1484
1971
  path,
1485
1972
  KVAction.HEAD,
1486
1973
  void 0,
1487
- options?.signal
1974
+ request.signal
1488
1975
  );
1489
1976
  if (!response.ok) {
1490
- if (response.status === 401) {
1977
+ if (response.status === 401 || response.status === 403) {
1491
1978
  return this.authorizationFailure(
1492
1979
  `Failed to get metadata for key ${JSON.stringify(key)}`,
1493
1980
  response,
1494
- await response.text()
1981
+ await this.readAuthorizationText(response),
1982
+ [path],
1983
+ KVAction.HEAD
1495
1984
  );
1496
1985
  }
1497
1986
  if (response.status === 404) {
@@ -1513,6 +2002,8 @@ var KVService = class extends BaseService {
1513
2002
  });
1514
2003
  } catch (error) {
1515
2004
  return err(wrapError("kv", error));
2005
+ } finally {
2006
+ request.dispose();
1516
2007
  }
1517
2008
  });
1518
2009
  }
@@ -1550,15 +2041,16 @@ var KVService = class extends BaseService {
1550
2041
  if (options?.etag !== void 0) {
1551
2042
  body.etag = options.etag;
1552
2043
  }
2044
+ const request = this.createRequestSignal(options?.signal, options?.timeout);
1553
2045
  try {
1554
2046
  const response = await this.context.fetch(`${this.host}/signed/kv`, {
1555
2047
  method: "POST",
1556
2048
  headers: this.withJsonContentType(headers),
1557
2049
  body: JSON.stringify(body),
1558
- signal: this.combineSignals(options?.signal)
2050
+ signal: request.signal
1559
2051
  });
1560
2052
  if (!response.ok) {
1561
- return this.createSignedReadUrlError(response, key);
2053
+ return this.createSignedReadUrlError(response, key, path);
1562
2054
  }
1563
2055
  const signedUrl = this.normalizeSignedReadUrlResponse(
1564
2056
  await response.json()
@@ -1575,6 +2067,8 @@ var KVService = class extends BaseService {
1575
2067
  return ok(signedUrl);
1576
2068
  } catch (error) {
1577
2069
  return err(wrapError("kv", error));
2070
+ } finally {
2071
+ request.dispose();
1578
2072
  }
1579
2073
  });
1580
2074
  }