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

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.
@@ -850,7 +850,6 @@ declare class KVService extends BaseService implements IKVService {
850
850
  * Get the service configuration.
851
851
  */
852
852
  get config(): KVServiceConfig;
853
- private parseQuotaInfo;
854
853
  private handleQuotaErrorResponse;
855
854
  /**
856
855
  * Classify a KV 404 by reading the response body once.
@@ -919,12 +918,16 @@ declare class KVService extends BaseService implements IKVService {
919
918
  */
920
919
  private parseResponse;
921
920
  private createSignedReadUrlError;
921
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
922
+ private readAuthorizationText;
922
923
  /**
923
924
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
924
925
  * status and the server text (`<context>: <status> - <text>`, the same
925
926
  * shape as every other KV failure) so callers that rethrow only the message
926
927
  * still see the status; `meta.status` carries it for typed callers.
927
- * Resource/ability hints are parsed from the raw body, never the message.
928
+ * Server hints must match the requested path/action (whether the node sends
929
+ * a relative path or full resource); metadata uses only the request-derived
930
+ * canonical resource under the authenticated session's space.
928
931
  */
929
932
  private authorizationFailure;
930
933
  private normalizeSignedReadUrlResponse;
@@ -850,7 +850,6 @@ declare class KVService extends BaseService implements IKVService {
850
850
  * Get the service configuration.
851
851
  */
852
852
  get config(): KVServiceConfig;
853
- private parseQuotaInfo;
854
853
  private handleQuotaErrorResponse;
855
854
  /**
856
855
  * Classify a KV 404 by reading the response body once.
@@ -919,12 +918,16 @@ declare class KVService extends BaseService implements IKVService {
919
918
  */
920
919
  private parseResponse;
921
920
  private createSignedReadUrlError;
921
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
922
+ private readAuthorizationText;
922
923
  /**
923
924
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
924
925
  * status and the server text (`<context>: <status> - <text>`, the same
925
926
  * shape as every other KV failure) so callers that rethrow only the message
926
927
  * still see the status; `meta.status` carries it for typed callers.
927
- * Resource/ability hints are parsed from the raw body, never the message.
928
+ * Server hints must match the requested path/action (whether the node sends
929
+ * a relative path or full resource); metadata uses only the request-derived
930
+ * canonical resource under the authenticated session's space.
928
931
  */
929
932
  private authorizationFailure;
930
933
  private normalizeSignedReadUrlResponse;
package/dist/kv/index.js CHANGED
@@ -70,6 +70,55 @@ function serviceError(code, message, service, options) {
70
70
  };
71
71
  }
72
72
 
73
+ // src/errors.ts
74
+ import { CAPABILITY_REGISTRY } from "@tinycloud/bootstrap";
75
+
76
+ // src/encryption/networkId.ts
77
+ var URN_PREFIX = "urn:tinycloud:encryption:";
78
+ var NETWORK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
79
+ var NetworkIdError = class extends Error {
80
+ constructor(message) {
81
+ super(message);
82
+ this.name = "NetworkIdError";
83
+ }
84
+ };
85
+ function parseNetworkId(networkId) {
86
+ if (typeof networkId !== "string" || networkId.length === 0) {
87
+ throw new NetworkIdError("networkId must be a non-empty string");
88
+ }
89
+ if (!networkId.startsWith(URN_PREFIX)) {
90
+ throw new NetworkIdError(
91
+ `networkId must start with ${URN_PREFIX} (got ${JSON.stringify(networkId)})`
92
+ );
93
+ }
94
+ const body = networkId.slice(URN_PREFIX.length);
95
+ const lastColon = body.lastIndexOf(":");
96
+ if (lastColon <= 0 || lastColon === body.length - 1) {
97
+ throw new NetworkIdError(
98
+ `networkId missing ownerDid or name segment (got ${JSON.stringify(networkId)})`
99
+ );
100
+ }
101
+ const ownerDid = body.slice(0, lastColon);
102
+ const name = body.slice(lastColon + 1);
103
+ if (!ownerDid.startsWith("did:")) {
104
+ throw new NetworkIdError(
105
+ `networkId ownerDid must be a DID (got ${JSON.stringify(ownerDid)})`
106
+ );
107
+ }
108
+ const didParts = ownerDid.split(":");
109
+ if (didParts.length < 3 || didParts.some((p) => p.length === 0)) {
110
+ throw new NetworkIdError(
111
+ `networkId ownerDid is not a well-formed DID (got ${JSON.stringify(ownerDid)})`
112
+ );
113
+ }
114
+ if (!NETWORK_NAME_RE.test(name)) {
115
+ throw new NetworkIdError(
116
+ `networkId name ${JSON.stringify(name)} must match ${NETWORK_NAME_RE.source}`
117
+ );
118
+ }
119
+ return { networkId, ownerDid, name };
120
+ }
121
+
73
122
  // src/errors.ts
74
123
  function parsePermissionHint(value) {
75
124
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
@@ -138,6 +187,101 @@ function parseAuthError(responseText) {
138
187
  function authUnauthorizedError(service, message, meta) {
139
188
  return serviceError(ErrorCodes.AUTH_UNAUTHORIZED, message, service, { meta });
140
189
  }
190
+ var grantableActions = new Set(
191
+ CAPABILITY_REGISTRY.map(({ urn }) => urn).filter((urn) => !urn.endsWith("/*"))
192
+ );
193
+ var capabilityServices = new Set(
194
+ CAPABILITY_REGISTRY.map(({ service }) => service.slice("tinycloud.".length))
195
+ );
196
+ function parseCapabilityResource(resource, service) {
197
+ if (resource.length === 0 || resource.length > 1024 || !capabilityServices.has(service) || /[\s\p{C}*#]/u.test(resource) || resource.includes("://")) return void 0;
198
+ const segments = resource.split("/");
199
+ if (!/^tinycloud:[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(segments[0])) return void 0;
200
+ const serviceIndex = segments.findIndex(
201
+ (segment, index) => index > 0 && capabilityServices.has(segment)
202
+ );
203
+ if (serviceIndex < 1 || segments[serviceIndex] !== service) return void 0;
204
+ if (segments.slice(1, serviceIndex).some(
205
+ (segment) => segment.length === 0 || segment === "." || segment === ".."
206
+ )) return void 0;
207
+ const pathSegments = segments.slice(serviceIndex + 1);
208
+ if (pathSegments.some((segment) => segment === "." || segment === "..")) return void 0;
209
+ if (service !== "kv" && (pathSegments.length === 0 || pathSegments[pathSegments.length - 1] === "")) return void 0;
210
+ return {
211
+ space: segments.slice(0, serviceIndex).join("/"),
212
+ path: pathSegments.join("/")
213
+ };
214
+ }
215
+ function safeCapabilityResource(value, service, action) {
216
+ if (typeof value !== "string" || value.length === 0 || value.length > 1024) return false;
217
+ if (service === "encryption" && value.startsWith("urn:tinycloud:encryption:")) {
218
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.:/-]*$/.test(value) || value.includes("//") || value.split("/").some((segment) => segment === "." || segment === "..")) return false;
219
+ try {
220
+ parseNetworkId(value);
221
+ return true;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+ const parsed = parseCapabilityResource(value, service);
227
+ return parsed !== void 0 && (!parsed.path.endsWith("/") || service === "kv" && action === "tinycloud.kv/list") && (parsed.path !== "" || service === "kv" && action === "tinycloud.kv/list");
228
+ }
229
+ function validatedCapabilityOf(error) {
230
+ const verdict = authorizationVerdictOf(error);
231
+ if (verdict !== "unauthenticated" && verdict !== "forbidden") return void 0;
232
+ const seen = /* @__PURE__ */ new Set();
233
+ let node = error;
234
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
235
+ seen.add(node);
236
+ const record = node;
237
+ const status = errorStatusOf(record);
238
+ const meta = record.meta;
239
+ if ((status === 401 || status === 403 || status === void 0 && record.code === ErrorCodes.AUTH_UNAUTHORIZED) && typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
240
+ const metadata = meta;
241
+ const { resource, requiredAction } = metadata;
242
+ if (typeof requiredAction === "string" && grantableActions.has(requiredAction)) {
243
+ const actionService = requiredAction.slice("tinycloud.".length, requiredAction.indexOf("/"));
244
+ const explicitService = record.service === void 0 ? metadata.service : record.service;
245
+ if (safeCapabilityResource(resource, actionService, requiredAction) && (explicitService === void 0 || explicitService === actionService || explicitService === `tinycloud.${actionService}`)) {
246
+ return { resource, requiredAction };
247
+ }
248
+ }
249
+ }
250
+ node = record.cause;
251
+ }
252
+ return void 0;
253
+ }
254
+ var MAX_CAUSE_DEPTH = 8;
255
+ function errorStatusOf(node) {
256
+ const meta = node.meta;
257
+ const candidates = [
258
+ node.status,
259
+ node.statusCode,
260
+ typeof meta === "object" && meta !== null ? meta.status : void 0
261
+ ];
262
+ for (const candidate of candidates) {
263
+ if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 400 && candidate <= 599) {
264
+ return candidate;
265
+ }
266
+ }
267
+ return void 0;
268
+ }
269
+ function authorizationVerdictOf(error) {
270
+ const seen = /* @__PURE__ */ new Set();
271
+ let sawUnauthorizedCode = false;
272
+ let node = error;
273
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
274
+ seen.add(node);
275
+ const record = node;
276
+ const status = errorStatusOf(record);
277
+ if (status === 401) return "unauthenticated";
278
+ if (status === 403) return "forbidden";
279
+ if (status !== void 0) return "other";
280
+ if (record.code === ErrorCodes.AUTH_UNAUTHORIZED) sawUnauthorizedCode = true;
281
+ node = record.cause;
282
+ }
283
+ return sawUnauthorizedCode ? "forbidden" : void 0;
284
+ }
141
285
  function storageQuotaExceededError(service, message, meta) {
142
286
  return {
143
287
  code: ErrorCodes.STORAGE_QUOTA_EXCEEDED,
@@ -154,6 +298,20 @@ function storageLimitReachedError(service, message, meta) {
154
298
  meta
155
299
  };
156
300
  }
301
+ 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.";
302
+ 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.";
303
+ function parseStorageQuotaBytes(responseText) {
304
+ const match = responseText.match(/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i);
305
+ if (!match) return void 0;
306
+ return {
307
+ usedBytes: parseInt(match[1], 10),
308
+ limitBytes: parseInt(match[2], 10)
309
+ };
310
+ }
311
+ function storageRejectionError(service, status, meta, responseText) {
312
+ const quotaMeta = { ...meta, ...parseStorageQuotaBytes(responseText) };
313
+ return status === 402 ? storageQuotaExceededError(service, STORAGE_FULL_MESSAGE, quotaMeta) : storageLimitReachedError(service, STORAGE_WRITE_TOO_LARGE_MESSAGE, quotaMeta);
314
+ }
157
315
  function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
158
316
  if (error instanceof Error) {
159
317
  if (error.name === "AbortError") {
@@ -544,47 +702,21 @@ var KVService = class extends BaseService {
544
702
  get config() {
545
703
  return this._config;
546
704
  }
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
705
  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
- );
706
+ if (response.status !== 402 && response.status !== 413) {
707
+ return void 0;
573
708
  }
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
- );
709
+ if (response.status === 413 && !parseStorageQuotaBytes(errorText)) {
710
+ return void 0;
586
711
  }
587
- return void 0;
712
+ return err(
713
+ storageRejectionError(
714
+ "kv",
715
+ response.status,
716
+ { status: response.status, statusText: response.statusText, key },
717
+ errorText
718
+ )
719
+ );
588
720
  }
589
721
  /**
590
722
  * Classify a KV 404 by reading the response body once.
@@ -860,12 +992,14 @@ var KVService = class extends BaseService {
860
992
  signal: this.combineSignals(options?.signal)
861
993
  });
862
994
  if (!response.ok) {
863
- const errorText = await response.text();
995
+ const errorText = response.status === 401 || response.status === 403 ? await this.readAuthorizationText(response) : await response.text();
864
996
  if (response.status === 401 || response.status === 403) {
865
997
  return this.authorizationFailure(
866
998
  `Failed to batch read ${keys.length} key(s)`,
867
999
  response,
868
- errorText
1000
+ errorText,
1001
+ paths,
1002
+ action
869
1003
  );
870
1004
  }
871
1005
  if (response.status === 413) {
@@ -955,7 +1089,7 @@ var KVService = class extends BaseService {
955
1089
  return text;
956
1090
  }
957
1091
  }
958
- async createSignedReadUrlError(response, key) {
1092
+ async createSignedReadUrlError(response, key, path) {
959
1093
  let errorText = response.statusText;
960
1094
  try {
961
1095
  const text = await response.text();
@@ -968,7 +1102,9 @@ var KVService = class extends BaseService {
968
1102
  return this.authorizationFailure(
969
1103
  `Failed to create signed read URL for key ${JSON.stringify(key)}`,
970
1104
  response,
971
- errorText
1105
+ errorText,
1106
+ [path],
1107
+ KVAction.GET
972
1108
  );
973
1109
  }
974
1110
  const code = response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR;
@@ -981,21 +1117,42 @@ var KVService = class extends BaseService {
981
1117
  )
982
1118
  );
983
1119
  }
1120
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
1121
+ async readAuthorizationText(response) {
1122
+ try {
1123
+ return await response.text();
1124
+ } catch {
1125
+ return "";
1126
+ }
1127
+ }
984
1128
  /**
985
1129
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
986
1130
  * status and the server text (`<context>: <status> - <text>`, the same
987
1131
  * shape as every other KV failure) so callers that rethrow only the message
988
1132
  * still see the status; `meta.status` carries it for typed callers.
989
- * Resource/ability hints are parsed from the raw body, never the message.
1133
+ * Server hints must match the requested path/action (whether the node sends
1134
+ * a relative path or full resource); metadata uses only the request-derived
1135
+ * canonical resource under the authenticated session's space.
990
1136
  */
991
- authorizationFailure(context, response, errorText, extraMeta = {}) {
1137
+ authorizationFailure(context, response, errorText, paths, expectedAction, extraMeta = {}) {
992
1138
  const { resource, action } = parseAuthError(errorText);
1139
+ const requestedPath = paths.find(
1140
+ (path) => resource === path || resource === `${this.context.session.spaceId}/kv/${path}`
1141
+ );
1142
+ const capability = requestedPath !== void 0 && action === expectedAction ? validatedCapabilityOf({
1143
+ service: "kv",
1144
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1145
+ meta: {
1146
+ status: response.status,
1147
+ resource: `${this.context.session.spaceId}/kv/${requestedPath}`,
1148
+ requiredAction: action
1149
+ }
1150
+ }) : void 0;
993
1151
  const detail = errorText.trim().length > 0 ? errorText : response.statusText || "authorization failed";
994
1152
  return err(authUnauthorizedError("kv", `${context}: ${response.status} - ${detail}`, {
995
1153
  status: response.status,
996
- ...action && { requiredAction: action },
997
- ...resource && { resource },
998
- ...extraMeta
1154
+ ...extraMeta,
1155
+ ...capability
999
1156
  }));
1000
1157
  }
1001
1158
  normalizeSignedReadUrlResponse(data) {
@@ -1039,13 +1196,24 @@ var KVService = class extends BaseService {
1039
1196
  );
1040
1197
  if (!response.ok) {
1041
1198
  if (response.status === 401 || response.status === 403) {
1042
- const errorText2 = await response.text();
1199
+ const errorText2 = await this.readAuthorizationText(response);
1043
1200
  const permissionHint = parsePermissionHintFromErrorText(errorText2);
1201
+ const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1202
+ service: "kv",
1203
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1204
+ meta: {
1205
+ status: response.status,
1206
+ resource: `${this.context.session.spaceId}/kv/${path}`,
1207
+ requiredAction: KVAction.GET
1208
+ }
1209
+ }) : void 0;
1044
1210
  return this.authorizationFailure(
1045
1211
  `Failed to get key ${JSON.stringify(key)}`,
1046
1212
  response,
1047
1213
  errorText2,
1048
- permissionHint === void 0 ? {} : { permissionHint }
1214
+ [path],
1215
+ KVAction.GET,
1216
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1049
1217
  );
1050
1218
  }
1051
1219
  if (response.status === 404) {
@@ -1122,7 +1290,9 @@ var KVService = class extends BaseService {
1122
1290
  return this.authorizationFailure(
1123
1291
  `Failed to put key ${JSON.stringify(key)}`,
1124
1292
  response,
1125
- await response.text()
1293
+ await this.readAuthorizationText(response),
1294
+ [path],
1295
+ KVAction.PUT
1126
1296
  );
1127
1297
  }
1128
1298
  if (!response.ok) {
@@ -1251,7 +1421,9 @@ var KVService = class extends BaseService {
1251
1421
  return this.authorizationFailure(
1252
1422
  `Failed to batch put ${items.length} key(s)`,
1253
1423
  response,
1254
- errorText
1424
+ errorText,
1425
+ paths,
1426
+ KVAction.PUT
1255
1427
  );
1256
1428
  }
1257
1429
  const quotaError = this.handleQuotaErrorResponse(
@@ -1372,11 +1544,13 @@ var KVService = class extends BaseService {
1372
1544
  }
1373
1545
  );
1374
1546
  if (!response.ok) {
1375
- if (response.status === 401) {
1547
+ if (response.status === 401 || response.status === 403) {
1376
1548
  return this.authorizationFailure(
1377
1549
  "Failed to list keys",
1378
1550
  response,
1379
- await response.text()
1551
+ await this.readAuthorizationText(response),
1552
+ [listPath],
1553
+ KVAction.LIST
1380
1554
  );
1381
1555
  }
1382
1556
  const errorText = await response.text();
@@ -1425,11 +1599,13 @@ var KVService = class extends BaseService {
1425
1599
  options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1426
1600
  );
1427
1601
  if (!response.ok) {
1428
- if (response.status === 401) {
1602
+ if (response.status === 401 || response.status === 403) {
1429
1603
  return this.authorizationFailure(
1430
1604
  `Failed to delete key ${JSON.stringify(key)}`,
1431
1605
  response,
1432
- await response.text()
1606
+ await this.readAuthorizationText(response),
1607
+ [path],
1608
+ KVAction.DELETE
1433
1609
  );
1434
1610
  }
1435
1611
  if (response.status === 404) {
@@ -1487,11 +1663,13 @@ var KVService = class extends BaseService {
1487
1663
  options?.signal
1488
1664
  );
1489
1665
  if (!response.ok) {
1490
- if (response.status === 401) {
1666
+ if (response.status === 401 || response.status === 403) {
1491
1667
  return this.authorizationFailure(
1492
1668
  `Failed to get metadata for key ${JSON.stringify(key)}`,
1493
1669
  response,
1494
- await response.text()
1670
+ await this.readAuthorizationText(response),
1671
+ [path],
1672
+ KVAction.HEAD
1495
1673
  );
1496
1674
  }
1497
1675
  if (response.status === 404) {
@@ -1558,7 +1736,7 @@ var KVService = class extends BaseService {
1558
1736
  signal: this.combineSignals(options?.signal)
1559
1737
  });
1560
1738
  if (!response.ok) {
1561
- return this.createSignedReadUrlError(response, key);
1739
+ return this.createSignedReadUrlError(response, key, path);
1562
1740
  }
1563
1741
  const signedUrl = this.normalizeSignedReadUrlResponse(
1564
1742
  await response.json()