@tinycloud/sdk-services 3.1.0-beta.2 → 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.
@@ -918,12 +918,16 @@ declare class KVService extends BaseService implements IKVService {
918
918
  */
919
919
  private parseResponse;
920
920
  private createSignedReadUrlError;
921
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
922
+ private readAuthorizationText;
921
923
  /**
922
924
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
923
925
  * status and the server text (`<context>: <status> - <text>`, the same
924
926
  * shape as every other KV failure) so callers that rethrow only the message
925
927
  * still see the status; `meta.status` carries it for typed callers.
926
- * 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.
927
931
  */
928
932
  private authorizationFailure;
929
933
  private normalizeSignedReadUrlResponse;
@@ -918,12 +918,16 @@ declare class KVService extends BaseService implements IKVService {
918
918
  */
919
919
  private parseResponse;
920
920
  private createSignedReadUrlError;
921
+ /** A known 401/403 keeps its typed status even if reading its body fails. */
922
+ private readAuthorizationText;
921
923
  /**
922
924
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
923
925
  * status and the server text (`<context>: <status> - <text>`, the same
924
926
  * shape as every other KV failure) so callers that rethrow only the message
925
927
  * still see the status; `meta.status` carries it for typed callers.
926
- * 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.
927
931
  */
928
932
  private authorizationFailure;
929
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,
@@ -848,12 +992,14 @@ var KVService = class extends BaseService {
848
992
  signal: this.combineSignals(options?.signal)
849
993
  });
850
994
  if (!response.ok) {
851
- const errorText = await response.text();
995
+ const errorText = response.status === 401 || response.status === 403 ? await this.readAuthorizationText(response) : await response.text();
852
996
  if (response.status === 401 || response.status === 403) {
853
997
  return this.authorizationFailure(
854
998
  `Failed to batch read ${keys.length} key(s)`,
855
999
  response,
856
- errorText
1000
+ errorText,
1001
+ paths,
1002
+ action
857
1003
  );
858
1004
  }
859
1005
  if (response.status === 413) {
@@ -943,7 +1089,7 @@ var KVService = class extends BaseService {
943
1089
  return text;
944
1090
  }
945
1091
  }
946
- async createSignedReadUrlError(response, key) {
1092
+ async createSignedReadUrlError(response, key, path) {
947
1093
  let errorText = response.statusText;
948
1094
  try {
949
1095
  const text = await response.text();
@@ -956,7 +1102,9 @@ var KVService = class extends BaseService {
956
1102
  return this.authorizationFailure(
957
1103
  `Failed to create signed read URL for key ${JSON.stringify(key)}`,
958
1104
  response,
959
- errorText
1105
+ errorText,
1106
+ [path],
1107
+ KVAction.GET
960
1108
  );
961
1109
  }
962
1110
  const code = response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR;
@@ -969,21 +1117,42 @@ var KVService = class extends BaseService {
969
1117
  )
970
1118
  );
971
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
+ }
972
1128
  /**
973
1129
  * AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
974
1130
  * status and the server text (`<context>: <status> - <text>`, the same
975
1131
  * shape as every other KV failure) so callers that rethrow only the message
976
1132
  * still see the status; `meta.status` carries it for typed callers.
977
- * 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.
978
1136
  */
979
- authorizationFailure(context, response, errorText, extraMeta = {}) {
1137
+ authorizationFailure(context, response, errorText, paths, expectedAction, extraMeta = {}) {
980
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;
981
1151
  const detail = errorText.trim().length > 0 ? errorText : response.statusText || "authorization failed";
982
1152
  return err(authUnauthorizedError("kv", `${context}: ${response.status} - ${detail}`, {
983
1153
  status: response.status,
984
- ...action && { requiredAction: action },
985
- ...resource && { resource },
986
- ...extraMeta
1154
+ ...extraMeta,
1155
+ ...capability
987
1156
  }));
988
1157
  }
989
1158
  normalizeSignedReadUrlResponse(data) {
@@ -1027,13 +1196,24 @@ var KVService = class extends BaseService {
1027
1196
  );
1028
1197
  if (!response.ok) {
1029
1198
  if (response.status === 401 || response.status === 403) {
1030
- const errorText2 = await response.text();
1199
+ const errorText2 = await this.readAuthorizationText(response);
1031
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;
1032
1210
  return this.authorizationFailure(
1033
1211
  `Failed to get key ${JSON.stringify(key)}`,
1034
1212
  response,
1035
1213
  errorText2,
1036
- permissionHint === void 0 ? {} : { permissionHint }
1214
+ [path],
1215
+ KVAction.GET,
1216
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1037
1217
  );
1038
1218
  }
1039
1219
  if (response.status === 404) {
@@ -1110,7 +1290,9 @@ var KVService = class extends BaseService {
1110
1290
  return this.authorizationFailure(
1111
1291
  `Failed to put key ${JSON.stringify(key)}`,
1112
1292
  response,
1113
- await response.text()
1293
+ await this.readAuthorizationText(response),
1294
+ [path],
1295
+ KVAction.PUT
1114
1296
  );
1115
1297
  }
1116
1298
  if (!response.ok) {
@@ -1239,7 +1421,9 @@ var KVService = class extends BaseService {
1239
1421
  return this.authorizationFailure(
1240
1422
  `Failed to batch put ${items.length} key(s)`,
1241
1423
  response,
1242
- errorText
1424
+ errorText,
1425
+ paths,
1426
+ KVAction.PUT
1243
1427
  );
1244
1428
  }
1245
1429
  const quotaError = this.handleQuotaErrorResponse(
@@ -1360,11 +1544,13 @@ var KVService = class extends BaseService {
1360
1544
  }
1361
1545
  );
1362
1546
  if (!response.ok) {
1363
- if (response.status === 401) {
1547
+ if (response.status === 401 || response.status === 403) {
1364
1548
  return this.authorizationFailure(
1365
1549
  "Failed to list keys",
1366
1550
  response,
1367
- await response.text()
1551
+ await this.readAuthorizationText(response),
1552
+ [listPath],
1553
+ KVAction.LIST
1368
1554
  );
1369
1555
  }
1370
1556
  const errorText = await response.text();
@@ -1413,11 +1599,13 @@ var KVService = class extends BaseService {
1413
1599
  options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1414
1600
  );
1415
1601
  if (!response.ok) {
1416
- if (response.status === 401) {
1602
+ if (response.status === 401 || response.status === 403) {
1417
1603
  return this.authorizationFailure(
1418
1604
  `Failed to delete key ${JSON.stringify(key)}`,
1419
1605
  response,
1420
- await response.text()
1606
+ await this.readAuthorizationText(response),
1607
+ [path],
1608
+ KVAction.DELETE
1421
1609
  );
1422
1610
  }
1423
1611
  if (response.status === 404) {
@@ -1475,11 +1663,13 @@ var KVService = class extends BaseService {
1475
1663
  options?.signal
1476
1664
  );
1477
1665
  if (!response.ok) {
1478
- if (response.status === 401) {
1666
+ if (response.status === 401 || response.status === 403) {
1479
1667
  return this.authorizationFailure(
1480
1668
  `Failed to get metadata for key ${JSON.stringify(key)}`,
1481
1669
  response,
1482
- await response.text()
1670
+ await this.readAuthorizationText(response),
1671
+ [path],
1672
+ KVAction.HEAD
1483
1673
  );
1484
1674
  }
1485
1675
  if (response.status === 404) {
@@ -1546,7 +1736,7 @@ var KVService = class extends BaseService {
1546
1736
  signal: this.combineSignals(options?.signal)
1547
1737
  });
1548
1738
  if (!response.ok) {
1549
- return this.createSignedReadUrlError(response, key);
1739
+ return this.createSignedReadUrlError(response, key, path);
1550
1740
  }
1551
1741
  const signedUrl = this.normalizeSignedReadUrlResponse(
1552
1742
  await response.json()