@tinycloud/sdk-services 3.1.0-beta.7 → 3.1.0-beta.9

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/index.cjs CHANGED
@@ -155,6 +155,10 @@ var ErrorCodes = {
155
155
  AUTH_EXPIRED: "AUTH_EXPIRED",
156
156
  AUTH_REQUIRED: "AUTH_REQUIRED",
157
157
  AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
158
+ /** HTTP 401: the delegation the invocation cites was revoked. */
159
+ AUTH_DELEGATION_REVOKED: "AUTH_DELEGATION_REVOKED",
160
+ /** HTTP 401: a delegation the cited one rests on was revoked. */
161
+ AUTH_DELEGATION_ANCESTOR_REVOKED: "AUTH_DELEGATION_ANCESTOR_REVOKED",
158
162
  NETWORK_ERROR: "NETWORK_ERROR",
159
163
  TIMEOUT: "TIMEOUT",
160
164
  ABORTED: "ABORTED",
@@ -166,6 +170,10 @@ var ErrorCodes = {
166
170
  KV_PRECONDITION_FAILED: "KV_PRECONDITION_FAILED",
167
171
  KV_CONFLICT: "KV_CONFLICT",
168
172
  KV_RESPONSE_TOO_LARGE: "KV_RESPONSE_TOO_LARGE",
173
+ /** HTTP 410 from the change feed: discard sync state and restart without a cursor; `meta.reason` says why. */
174
+ KV_SYNC_RESET_REQUIRED: "KV_SYNC_RESET_REQUIRED",
175
+ /** HTTP 403 from the change feed: the retention grant was refused; `meta.reason` says why. */
176
+ KV_RETENTION_GRANT_REFUSED: "KV_RETENTION_GRANT_REFUSED",
169
177
  // SQL-specific errors
170
178
  SQL_ERROR: "SQL_ERROR",
171
179
  SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
@@ -1767,6 +1775,30 @@ var PrefixedKVService = class _PrefixedKVService {
1767
1775
  removePrefix
1768
1776
  });
1769
1777
  }
1778
+ /**
1779
+ * Read the change feed for everything under this prefix. A view with an
1780
+ * empty prefix (`""` or `"/"`) is refused: the node does not serve a
1781
+ * whole-space feed.
1782
+ */
1783
+ async changes(options) {
1784
+ if (this._prefix.replace(/\/+$/, "") === "") {
1785
+ return err(serviceError(
1786
+ ErrorCodes.INVALID_INPUT,
1787
+ "KV changes needs a non-empty prefix; this view has none",
1788
+ "kv"
1789
+ ));
1790
+ }
1791
+ const syncPrefix = `${this._prefix}/`;
1792
+ const response = await this._kv.changes({ ...options, prefix: syncPrefix });
1793
+ if (!response.ok) return response;
1794
+ return ok({
1795
+ ...response.data,
1796
+ changes: response.data.changes.map((change) => ({
1797
+ ...change,
1798
+ key: change.key.startsWith(syncPrefix) ? change.key.slice(syncPrefix.length) : change.key
1799
+ }))
1800
+ });
1801
+ }
1770
1802
  /**
1771
1803
  * Delete a key.
1772
1804
  */
@@ -1819,11 +1851,18 @@ var KVAction = {
1819
1851
  PUT: "tinycloud.kv/put",
1820
1852
  LIST: "tinycloud.kv/list",
1821
1853
  DELETE: "tinycloud.kv/del",
1822
- HEAD: "tinycloud.kv/metadata"
1854
+ HEAD: "tinycloud.kv/metadata",
1855
+ /** The change feed; never implied by `*` or `tinycloud.kv/*`. */
1856
+ SYNC: "tinycloud.kv/sync",
1857
+ /** Retention attestation; presented through `retentionGrant`, never invoked or implied. */
1858
+ RETAIN: "tinycloud.kv/retain"
1823
1859
  };
1824
1860
 
1825
1861
  // src/kv/KVService.ts
1826
1862
  var MAX_KV_BATCH_READ_ITEMS = 100;
1863
+ function isJsonObject(value) {
1864
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1865
+ }
1827
1866
  function encodeKvBatchPartName(path) {
1828
1867
  return encodeURIComponent(path).replace(
1829
1868
  /[!'()*]/g,
@@ -2742,6 +2781,169 @@ var KVService = class extends BaseService {
2742
2781
  }
2743
2782
  });
2744
2783
  }
2784
+ /**
2785
+ * Read one page of the `tinycloud.kv/sync` change feed for a prefix.
2786
+ */
2787
+ async changes(options) {
2788
+ return this.withTelemetry("changes", options?.prefix, async () => {
2789
+ if (!this.requireAuth()) {
2790
+ return err(authRequiredError("kv"));
2791
+ }
2792
+ if (typeof options?.prefix !== "string" || options.prefix.length === 0) {
2793
+ return err(serviceError(
2794
+ ErrorCodes.INVALID_INPUT,
2795
+ "KV changes prefix must be a non-empty string",
2796
+ "kv"
2797
+ ));
2798
+ }
2799
+ if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1e3)) {
2800
+ return err(serviceError(
2801
+ ErrorCodes.INVALID_INPUT,
2802
+ "KV changes limit must be an integer from 1 through 1000",
2803
+ "kv"
2804
+ ));
2805
+ }
2806
+ const headers = {};
2807
+ if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
2808
+ if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
2809
+ if (options.retentionGrant !== void 0) {
2810
+ headers["x-tinycloud-retention-grant"] = options.retentionGrant;
2811
+ }
2812
+ const request = this.createRequestSignal(options.signal, options.timeout);
2813
+ try {
2814
+ const response = await this.invokeOperation(
2815
+ options.prefix,
2816
+ KVAction.SYNC,
2817
+ void 0,
2818
+ request.signal,
2819
+ headers
2820
+ );
2821
+ if (!response.ok) {
2822
+ return await this.changesFailure(response, options.prefix, request.signal);
2823
+ }
2824
+ const text = await response.text();
2825
+ let body;
2826
+ try {
2827
+ body = JSON.parse(text);
2828
+ } catch {
2829
+ body = void 0;
2830
+ }
2831
+ const page = this.normalizeChangesResponse(body);
2832
+ if (!page) {
2833
+ return err(serviceError(
2834
+ ErrorCodes.NETWORK_ERROR,
2835
+ "KV changes response was not a tinycloud.kv/sync page",
2836
+ "kv",
2837
+ { meta: { status: response.status, statusText: response.statusText } }
2838
+ ));
2839
+ }
2840
+ return ok(page);
2841
+ } catch (error) {
2842
+ return err(wrapError("kv", error));
2843
+ } finally {
2844
+ request.dispose();
2845
+ }
2846
+ });
2847
+ }
2848
+ /**
2849
+ * Map a failed `kv/sync` response. 410 and the retention 403 carry
2850
+ * `{"error":{"code","reason"}}`; a revoked grant is a 401 whose text names
2851
+ * `delegation-revoked` or `delegation-ancestor-revoked`. A body read that
2852
+ * fails because the request was cancelled or timed out rethrows, so the
2853
+ * caller sees `ABORTED`/`TIMEOUT` rather than a mapped status.
2854
+ */
2855
+ async changesFailure(response, prefix, signal) {
2856
+ if (response.status === 404) {
2857
+ return this.classifyNotFound(response, prefix);
2858
+ }
2859
+ let errorText;
2860
+ try {
2861
+ errorText = await response.text();
2862
+ } catch (error) {
2863
+ if (signal.aborted) throw error;
2864
+ errorText = "";
2865
+ }
2866
+ const meta = { status: response.status, statusText: response.statusText };
2867
+ let reason;
2868
+ let bodyCode;
2869
+ try {
2870
+ const body = JSON.parse(errorText);
2871
+ const error = isJsonObject(body) ? body.error : void 0;
2872
+ if (isJsonObject(error)) {
2873
+ if (typeof error.code === "string") bodyCode = error.code;
2874
+ if (typeof error.reason === "string") reason = error.reason;
2875
+ }
2876
+ } catch {
2877
+ }
2878
+ const context = `Failed to read KV changes for ${JSON.stringify(prefix)}`;
2879
+ if (response.status === 410) {
2880
+ return err(serviceError(
2881
+ ErrorCodes.KV_SYNC_RESET_REQUIRED,
2882
+ `${context}: the node requires a reset (${reason ?? "reason not given"}); discard sync state and restart without a cursor`,
2883
+ "kv",
2884
+ { meta: { ...meta, reason } }
2885
+ ));
2886
+ }
2887
+ if (response.status === 403 && bodyCode === "RETENTION_GRANT_REFUSED") {
2888
+ return err(serviceError(
2889
+ ErrorCodes.KV_RETENTION_GRANT_REFUSED,
2890
+ `${context}: the retention grant was refused (${reason ?? "reason not given"})`,
2891
+ "kv",
2892
+ { meta: { ...meta, reason } }
2893
+ ));
2894
+ }
2895
+ if (response.status === 401) {
2896
+ const revoked = /\bdelegation-ancestor-revoked\b/.test(errorText) ? ErrorCodes.AUTH_DELEGATION_ANCESTOR_REVOKED : /\bdelegation-revoked\b/.test(errorText) ? ErrorCodes.AUTH_DELEGATION_REVOKED : void 0;
2897
+ if (revoked !== void 0) {
2898
+ return err(serviceError(revoked, `${context}: 401 - ${errorText}`, "kv", { meta }));
2899
+ }
2900
+ }
2901
+ if (response.status === 401 || response.status === 403) {
2902
+ return this.authorizationFailure(context, response, errorText, [prefix], KVAction.SYNC);
2903
+ }
2904
+ return err(serviceError(
2905
+ response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR,
2906
+ `${context}: ${response.status} - ${errorText}`,
2907
+ "kv",
2908
+ { meta }
2909
+ ));
2910
+ }
2911
+ normalizeChangesResponse(data) {
2912
+ if (!isJsonObject(data)) return void 0;
2913
+ const { changes, more, cursor, source, authority } = data;
2914
+ 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)) {
2915
+ return void 0;
2916
+ }
2917
+ const { notBefore, expiresAt, retainUntil } = authority;
2918
+ if (notBefore !== null && typeof notBefore !== "string" || expiresAt !== null && typeof expiresAt !== "string" || retainUntil !== null && typeof retainUntil !== "string") {
2919
+ return void 0;
2920
+ }
2921
+ const parsed = [];
2922
+ for (const change of changes) {
2923
+ if (!isJsonObject(change) || typeof change.key !== "string") return void 0;
2924
+ if (change.deleted === true) {
2925
+ parsed.push({ key: change.key, deleted: true });
2926
+ continue;
2927
+ }
2928
+ const { metadata } = change;
2929
+ if (change.deleted !== false || typeof change.etag !== "string" || !isJsonObject(metadata) || !Object.values(metadata).every((value) => typeof value === "string")) {
2930
+ return void 0;
2931
+ }
2932
+ parsed.push({
2933
+ key: change.key,
2934
+ deleted: false,
2935
+ etag: change.etag,
2936
+ metadata
2937
+ });
2938
+ }
2939
+ return {
2940
+ changes: parsed,
2941
+ more,
2942
+ cursor,
2943
+ source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
2944
+ authority: { notBefore, expiresAt, retainUntil }
2945
+ };
2946
+ }
2745
2947
  /**
2746
2948
  * Delete a key.
2747
2949
  */
@@ -6289,6 +6491,7 @@ var SecretsService = class {
6289
6491
  }
6290
6492
  async listAll() {
6291
6493
  const entries = /* @__PURE__ */ new Map();
6494
+ const seenCursors = /* @__PURE__ */ new Set();
6292
6495
  let cursor;
6293
6496
  do {
6294
6497
  const result = await this.vault.listPage({
@@ -6311,6 +6514,14 @@ var SecretsService = class {
6311
6514
  "Secret catalog listing was truncated without a continuation cursor."
6312
6515
  );
6313
6516
  }
6517
+ if (cursor !== void 0) {
6518
+ if (seenCursors.has(cursor)) {
6519
+ return invalidSecretInput(
6520
+ "Secret catalog listing repeated a continuation cursor; the node does not support paged KV lists."
6521
+ );
6522
+ }
6523
+ seenCursors.add(cursor);
6524
+ }
6314
6525
  } while (cursor !== void 0);
6315
6526
  return {
6316
6527
  ok: true,