@muhkoo/connect 0.15.0-alpha.4 → 0.15.0-alpha.5

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.
@@ -2742,6 +2742,106 @@ class BroadcastChannel {
2742
2742
  }
2743
2743
  }
2744
2744
 
2745
+ /**
2746
+ * `HttpError` — thrown for any non-2xx accelerator response.
2747
+ *
2748
+ * In a module of its own so that `PaymentRequiredError` can extend it while
2749
+ * `HttpClient`, which throws both, imports `PaymentRequiredError`: with the
2750
+ * class in `HttpClient.ts`, the two modules would import each other, and
2751
+ * whichever loaded second would extend a class not yet defined. `HttpClient`
2752
+ * re-exports it, so `import { HttpError } from "./HttpClient"` still works.
2753
+ */
2754
+ /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
2755
+ class HttpError extends Error {
2756
+ status;
2757
+ body;
2758
+ constructor(message, status, body) {
2759
+ super(message);
2760
+ this.status = status;
2761
+ this.body = body;
2762
+ this.name = "HttpError";
2763
+ }
2764
+ }
2765
+
2766
+ /**
2767
+ * `PaymentRequiredError` — a write the accelerator refused for payment
2768
+ * (accelerator spec 2026-10-06-paid-accounts-and-past-due, "Interfaces and
2769
+ * contracts").
2770
+ *
2771
+ * The accelerator answers such a write with a 402 whose body carries one of
2772
+ * three codes:
2773
+ * - `user_plan_required`: the app takes paid Muhkoo plans only, and the
2774
+ * signed-in user is on Free;
2775
+ * - `user_payment_past_due`: the user's own Muhkoo payment is past due. Only
2776
+ * first-party apps are told this; every other app is told
2777
+ * `user_plan_required` instead;
2778
+ * - `owner_payment_past_due`: the app owner's payment is past due, so the
2779
+ * app stores nothing new for anyone.
2780
+ * Reads, sign-in and deletes still work in all three cases.
2781
+ *
2782
+ * Every other 402 — the Free-tier refusals that carry `quotaExceeded`,
2783
+ * `requiresPaidTier` or `freeTierFull` — has no `code`, and stays a plain
2784
+ * `HttpError`, as before.
2785
+ */
2786
+ const CODES = new Set([
2787
+ "user_plan_required",
2788
+ "user_payment_past_due",
2789
+ "owner_payment_past_due",
2790
+ ]);
2791
+ /** True for one of the three {@link PaymentRequiredCode}s. */
2792
+ function isPaymentRequiredCode(value) {
2793
+ return typeof value === "string" && CODES.has(value);
2794
+ }
2795
+ /** `url` when it is an absolute http(s) URL, else undefined: an app may put it straight into a link. */
2796
+ function absoluteHttpUrl(url) {
2797
+ return typeof url === "string" && /^https?:\/\/[^/]/i.test(url) ? url : undefined;
2798
+ }
2799
+ /** Thrown for a 402 whose body carries one of the three {@link PaymentRequiredCode}s. */
2800
+ class PaymentRequiredError extends HttpError {
2801
+ /** Why the write was refused. */
2802
+ code;
2803
+ /**
2804
+ * Where to send the user: the portal's upgrade page or its billing page,
2805
+ * as an absolute URL. Set only when the server sent an absolute http(s)
2806
+ * URL. The relative `upgradeUrl` of the Free-tier refusals is never here:
2807
+ * those are not this error.
2808
+ */
2809
+ upgradeUrl;
2810
+ constructor(message, code, body, upgradeUrl) {
2811
+ super(message, 402, body);
2812
+ this.name = "PaymentRequiredError";
2813
+ this.code = code;
2814
+ if (upgradeUrl !== undefined)
2815
+ this.upgradeUrl = upgradeUrl;
2816
+ }
2817
+ }
2818
+ /**
2819
+ * The error to throw for a refused response: a {@link PaymentRequiredError}
2820
+ * for a 402 whose body carries one of the three codes, and null for anything
2821
+ * else, so the caller throws what it threw before. `body` is the parsed JSON,
2822
+ * or the raw text of the response, which is parsed here.
2823
+ */
2824
+ function paymentRequiredFrom(status, body) {
2825
+ if (status !== 402)
2826
+ return null;
2827
+ let parsed = body;
2828
+ if (typeof parsed === "string") {
2829
+ try {
2830
+ parsed = JSON.parse(parsed);
2831
+ }
2832
+ catch {
2833
+ return null;
2834
+ }
2835
+ }
2836
+ if (!parsed || typeof parsed !== "object")
2837
+ return null;
2838
+ const { code, error, upgradeUrl } = parsed;
2839
+ if (!isPaymentRequiredCode(code))
2840
+ return null;
2841
+ const message = typeof error === "string" && error ? error : `Payment required (${code})`;
2842
+ return new PaymentRequiredError(message, code, parsed, absoluteHttpUrl(upgradeUrl));
2843
+ }
2844
+
2745
2845
  function _loadWasmModule (sync, filepath, src, imports) {
2746
2846
  function _instantiateOrCompile(source, imports, stream) {
2747
2847
  var instantiateFunc = WebAssembly.instantiate;
@@ -5261,14 +5361,19 @@ function nonceHexToFieldString(nonceHex) {
5261
5361
  */
5262
5362
  async function parseOrThrow$1(response, label) {
5263
5363
  if (!response.ok) {
5264
- let detail = "";
5364
+ let text = "";
5265
5365
  try {
5266
- const text = await response.text();
5267
- detail = text ? `: ${text}` : "";
5366
+ text = await response.text();
5268
5367
  }
5269
5368
  catch {
5270
5369
  // ignore — we'll just use the status code
5271
5370
  }
5371
+ // A put refused for payment: its own class. Every other failure is the
5372
+ // plain Error it always was.
5373
+ const refused = paymentRequiredFrom(response.status, text);
5374
+ if (refused)
5375
+ throw refused;
5376
+ const detail = text ? `: ${text}` : "";
5272
5377
  throw new Error(`PersonalSpaceClient ${label} failed (${response.status})${detail}`);
5273
5378
  }
5274
5379
  return (await response.json());
@@ -5916,6 +6021,8 @@ class ShardClient {
5916
6021
  baseUrl;
5917
6022
  pathPrefix;
5918
6023
  fetchFn;
6024
+ /** What a PUT goes through: {@link ShardClientOptions.fetchWithRecovery} when given, else {@link fetchFn}. */
6025
+ putFn;
5919
6026
  cache;
5920
6027
  /** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
5921
6028
  pendingShards;
@@ -5940,6 +6047,7 @@ class ShardClient {
5940
6047
  throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
5941
6048
  }
5942
6049
  this.fetchFn = f.bind(globalThis);
6050
+ this.putFn = opts.fetchWithRecovery ?? this.fetchFn;
5943
6051
  this.cache = opts.cache;
5944
6052
  // Coalescing state. `batchSupported` latches false against a deployment
5945
6053
  // without the batch route, so we degrade once rather than per request.
@@ -5983,7 +6091,7 @@ class ShardClient {
5983
6091
  }
5984
6092
  let response;
5985
6093
  try {
5986
- response = await this.fetchFn(url, {
6094
+ response = await this.putFn(url, {
5987
6095
  method: "PUT",
5988
6096
  headers: { "content-type": "application/octet-stream" },
5989
6097
  // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
@@ -6001,7 +6109,10 @@ class ShardClient {
6001
6109
  }
6002
6110
  if (!response.ok) {
6003
6111
  const text = await safeText(response);
6004
- throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6112
+ // Refused for payment: its own class, which the offline queue keeps
6113
+ // and an app can tell from any other failure.
6114
+ throw paymentRequiredFrom(response.status, text)
6115
+ ?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6005
6116
  }
6006
6117
  let dedup = false;
6007
6118
  try {
@@ -6053,14 +6164,15 @@ class ShardClient {
6053
6164
  /** PUT the shard bytes to origin, verifying the server accepted them.
6054
6165
  * Used by the background-upload path; throws on network/HTTP failure. */
6055
6166
  async uploadToOrigin(url, hash, bytes) {
6056
- const response = await this.fetchFn(url, {
6167
+ const response = await this.putFn(url, {
6057
6168
  method: "PUT",
6058
6169
  headers: { "content-type": "application/octet-stream" },
6059
6170
  body: bytes,
6060
6171
  });
6061
6172
  if (!response.ok) {
6062
6173
  const text = await safeText(response);
6063
- throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6174
+ throw paymentRequiredFrom(response.status, text)
6175
+ ?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6064
6176
  }
6065
6177
  }
6066
6178
  /**
@@ -6723,6 +6835,10 @@ async function parseOrThrow(response, op) {
6723
6835
  // non-JSON body
6724
6836
  }
6725
6837
  if (!response.ok) {
6838
+ // A manifest write refused for payment (a gated Space write): its own class.
6839
+ const refused = paymentRequiredFrom(response.status, parsed);
6840
+ if (refused)
6841
+ throw refused;
6726
6842
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6727
6843
  ? String(parsed.error)
6728
6844
  : `${response.status} ${response.statusText}`;
@@ -6739,7 +6855,7 @@ async function parseOrThrow(response, op) {
6739
6855
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6740
6856
  * `package.json` `version`.
6741
6857
  */
6742
- const VERSION = "0.15.0-alpha.4";
6858
+ const VERSION = "0.15.0-alpha.5";
6743
6859
 
6744
6860
  /**
6745
6861
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -6784,17 +6900,6 @@ function reqPath(input) {
6784
6900
  return "?";
6785
6901
  }
6786
6902
  }
6787
- /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
6788
- class HttpError extends Error {
6789
- status;
6790
- body;
6791
- constructor(message, status, body) {
6792
- super(message);
6793
- this.status = status;
6794
- this.body = body;
6795
- this.name = "HttpError";
6796
- }
6797
- }
6798
6903
  class HttpClient {
6799
6904
  baseUrl;
6800
6905
  /** Origin of {@link baseUrl} — credentials are only attached same-origin. */
@@ -6923,7 +7028,9 @@ class HttpClient {
6923
7028
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6924
7029
  ? String(parsed.error)
6925
7030
  : `${res.status} ${res.statusText}`;
6926
- throw new HttpError(msg, res.status, parsed);
7031
+ // A refusal for payment is its own class, so an app can tell it from
7032
+ // any other 4xx; every other error is the HttpError it always was.
7033
+ throw paymentRequiredFrom(res.status, parsed) ?? new HttpError(msg, res.status, parsed);
6927
7034
  }
6928
7035
  return parsed;
6929
7036
  }
@@ -9667,6 +9774,7 @@ class StorageNamespace {
9667
9774
  baseUrl: this.deps.baseUrl,
9668
9775
  pathPrefix: "/api/shards",
9669
9776
  fetch: this.deps.http.fetch,
9777
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
9670
9778
  cache: this.deps.shardCache,
9671
9779
  deferUpload: this.deps.deferShardUpload,
9672
9780
  });
@@ -9685,6 +9793,59 @@ class StorageNamespace {
9685
9793
  }
9686
9794
  }
9687
9795
 
9796
+ /**
9797
+ * `POST /api/storage/commit` and `/api/storage/release`: take or drop a
9798
+ * reference to a manifest's shards.
9799
+ *
9800
+ * The commit is what meters a file's bytes to the app and reference-counts
9801
+ * its shards, so garbage collection keeps them; the release drops that
9802
+ * reference. The body is `{ hashes, bytes }` and nothing else. The accelerator
9803
+ * keys its reference ledger on the caller, the app, the bytes and the sorted
9804
+ * hashes, so this body must not change: a commit sent in another shape would
9805
+ * take a second reference instead of finding the first.
9806
+ *
9807
+ * Shared by `Space` (`putFile`, `commitFile`, `releaseFile`) and by
9808
+ * `client.vfs`, which takes and drops references here directly. A reference
9809
+ * names only shards and needs no Space; opening the content space to take one
9810
+ * created that space for a user who had never written a file, which a
9811
+ * paid-only app refuses for a Free user (accelerator spec
9812
+ * 2026-10-06-paid-accounts-and-past-due, D12).
9813
+ *
9814
+ * Internal: not re-exported from any barrel.
9815
+ */
9816
+ /**
9817
+ * Commit or release `manifest`'s shards through `fetchFn`, which should be
9818
+ * `HttpClient.fetchWithRecovery`, so a dead session is re-established once
9819
+ * rather than refused three times. Best-effort: tries three times, or once
9820
+ * for a 401, then warns and returns, without failing the caller's write.
9821
+ */
9822
+ async function commitManifest(fetchFn, httpBaseUrl, manifest, action) {
9823
+ const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
9824
+ if (hashes.length === 0)
9825
+ return;
9826
+ const body = JSON.stringify({ hashes, bytes: manifest.size });
9827
+ for (let attempt = 0; attempt < 3; attempt++) {
9828
+ try {
9829
+ const res = await fetchFn(`${httpBaseUrl}/api/storage/${action}`, {
9830
+ method: "POST", headers: { "Content-Type": "application/json" }, body,
9831
+ });
9832
+ if (res.ok)
9833
+ return;
9834
+ // A 401 that `fetchFn`'s recovery could not fix. Another try
9835
+ // would only run that recovery again (`auth.zk.recover()`, and
9836
+ // `onSessionExpired` for the app) and be refused again.
9837
+ if (res.status === 401)
9838
+ break;
9839
+ }
9840
+ catch { /* retry */ }
9841
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
9842
+ }
9843
+ // Best-effort: a missed commit leaves the shards to orphan-GC (still not
9844
+ // durable-unmetered long-term); a missed release just delays GC. Reconciled
9845
+ // by the platform sweep. Don't fail the caller's write on a metering hiccup.
9846
+ console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
9847
+ }
9848
+
9688
9849
  /**
9689
9850
  * SpaceCipher — pure crypto for the fan-out group-encryption layer.
9690
9851
  *
@@ -11068,25 +11229,10 @@ class Space {
11068
11229
  }
11069
11230
  /** Commit/release a manifest's shards with the accelerator (metering + refcount). */
11070
11231
  async commitShards(manifest, action) {
11071
- const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
11072
- if (hashes.length === 0)
11073
- return;
11074
- const body = JSON.stringify({ hashes, bytes: manifest.size });
11075
- for (let attempt = 0; attempt < 3; attempt++) {
11076
- try {
11077
- const res = await this.deps.fetch(`${this.deps.httpBaseUrl}/api/storage/${action}`, {
11078
- method: "POST", headers: { "Content-Type": "application/json" }, body,
11079
- });
11080
- if (res.ok)
11081
- return;
11082
- }
11083
- catch { /* retry */ }
11084
- await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
11085
- }
11086
- // Best-effort: a missed commit leaves the shards to orphan-GC (still not
11087
- // durable-unmetered long-term); a missed release just delays GC. Reconciled
11088
- // by the platform sweep. Don't fail the caller's write on a metering hiccup.
11089
- console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
11232
+ // Through the client's 401 recovery when there is one: the commit refuses
11233
+ // a dead session with 401, and three tries with the same token only
11234
+ // delayed the warning.
11235
+ await commitManifest(this.deps.fetchWithRecovery ?? this.deps.fetch, this.deps.httpBaseUrl, manifest, action);
11090
11236
  }
11091
11237
  fileStorage(backgroundUpload = false) {
11092
11238
  // Global, content-addressed shard store — same store `client.storage`
@@ -11097,6 +11243,7 @@ class Space {
11097
11243
  baseUrl: this.deps.httpBaseUrl,
11098
11244
  pathPrefix: "/api/shards",
11099
11245
  fetch: this.deps.fetch,
11246
+ fetchWithRecovery: this.deps.fetchWithRecovery,
11100
11247
  cache: this.deps.shardCache,
11101
11248
  deferUpload: this.deps.deferShardUpload,
11102
11249
  peers: this.peerNet?.exchange,
@@ -11500,6 +11647,7 @@ class MessageNamespace {
11500
11647
  wsBaseUrl: this.deps.wsBaseUrl,
11501
11648
  httpBaseUrl: this.deps.http.baseUrl,
11502
11649
  fetch: this.deps.http.fetch,
11650
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
11503
11651
  myId: () => this.myId(),
11504
11652
  fetchTicket: () => this.fetchTicket(),
11505
11653
  });
@@ -13821,6 +13969,7 @@ class SpaceNamespace {
13821
13969
  wsBaseUrl: this.deps.wsBaseUrl,
13822
13970
  httpBaseUrl: this.deps.http.baseUrl,
13823
13971
  fetch: this.deps.http.fetch,
13972
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13824
13973
  myId: () => memberId,
13825
13974
  fetchTicket: () => this.fetchTicket(),
13826
13975
  historyPolicy,
@@ -13876,6 +14025,7 @@ class SpaceNamespace {
13876
14025
  wsBaseUrl: this.deps.wsBaseUrl,
13877
14026
  httpBaseUrl: this.deps.http.baseUrl,
13878
14027
  fetch: this.deps.http.fetch,
14028
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13879
14029
  myId: () => memberId,
13880
14030
  fetchTicket: () => this.fetchTicket(),
13881
14031
  keyring,
@@ -14251,6 +14401,66 @@ class AccessTokensNamespace {
14251
14401
  }
14252
14402
  }
14253
14403
 
14404
+ /**
14405
+ * `client.app` — what the signed-in user may do in this app (accelerator spec
14406
+ * 2026-10-06-paid-accounts-and-past-due, B6 and B8).
14407
+ *
14408
+ * An app's owner can turn on "Paid Muhkoo accounts only" in the portal. The
14409
+ * accelerator then refuses a Free user's writes that add data, with a 402 the
14410
+ * SDK throws as `PaymentRequiredError`. Reads, sign-in and deletes still work.
14411
+ * The same refusal reaches every user of an app whose owner's payment is past
14412
+ * due, whether or not the setting is on.
14413
+ *
14414
+ * `access()` asks up front, so an app can show the right screen before the
14415
+ * first refusal.
14416
+ */
14417
+ class AppNamespace {
14418
+ deps;
14419
+ constructor(deps) {
14420
+ this.deps = deps;
14421
+ }
14422
+ /**
14423
+ * Whether the signed-in user may add data in this app and, if not, why
14424
+ * and where to send them. Needs the app key and a session; a stale session
14425
+ * is re-established once, as for every request. The route is never
14426
+ * refused for payment. Throws `HttpError` for anything else, among them a
14427
+ * 401 without a session and a 404 from an accelerator older than the
14428
+ * route.
14429
+ *
14430
+ * Each `allowed: true` also sends any writes the offline queue kept after
14431
+ * a refusal for payment, without waiting for them. Call it after sign-in,
14432
+ * and again when the user comes back from the portal.
14433
+ */
14434
+ async access() {
14435
+ const body = await this.deps.http.get("/api/app/access");
14436
+ const access = {
14437
+ paidAccountsOnly: body?.paidAccountsOnly === true,
14438
+ // Fail open on a malformed answer: the server enforces the gate
14439
+ // whatever this says, so a wrong "allowed" costs a refused write,
14440
+ // and a wrong "not allowed" would lock the user out for nothing.
14441
+ allowed: body?.allowed !== false,
14442
+ };
14443
+ if (!access.allowed) {
14444
+ if (isPaymentRequiredCode(body?.reason))
14445
+ access.reason = body.reason;
14446
+ const upgradeUrl = absoluteHttpUrl(body?.upgradeUrl);
14447
+ if (upgradeUrl)
14448
+ access.upgradeUrl = upgradeUrl;
14449
+ }
14450
+ else if (this.deps.onAllowed) {
14451
+ // A drain that fails, or a callback that throws, must not turn a
14452
+ // good answer into an error: the writes stay queued for the next
14453
+ // reconnect.
14454
+ Promise.resolve()
14455
+ .then(() => this.deps.onAllowed())
14456
+ .catch((err) => {
14457
+ console.warn("[client.app] sending the kept writes failed (they stay queued):", err);
14458
+ });
14459
+ }
14460
+ return access;
14461
+ }
14462
+ }
14463
+
14254
14464
  /**
14255
14465
  * POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
14256
14466
  * answers a deterministic decoy, so a lookup never says whether an account
@@ -20158,9 +20368,14 @@ class ConnectivityManager {
20158
20368
  * mutation by re-invoking the real namespace method (with an `isReplay` flag so
20159
20369
  * it doesn't re-enqueue). `drain()` replays entries in HLC order:
20160
20370
  * - success → remove the entry;
20161
- * - permanent failure (4xx, or a Space whose member list refuses this user)
20162
- * → drop it and surface a `SYNC_ERROR` (a write the server will never
20163
- * accept must not wedge the queue forever);
20371
+ * - refused for payment (`PaymentRequiredError`: the app takes paid plans
20372
+ * only, or someone's payment is past due) → keep it and everything after
20373
+ * it, stop, and surface a `SYNC_ERROR` with `dropped: false`. The server
20374
+ * accepts it once the user or the app owner pays, so it waits for the next
20375
+ * reconnect, or for `client.app.access()` to report the app allowed;
20376
+ * - permanent failure (any other 4xx, or a Space whose member list refuses
20377
+ * this user) → drop it and surface a `SYNC_ERROR` (a write the server will
20378
+ * never accept must not wedge the queue forever);
20164
20379
  * - transient/network failure → stop draining and leave the rest for the next
20165
20380
  * reconnect.
20166
20381
  *
@@ -20185,14 +20400,34 @@ class OutboundQueue {
20185
20400
  register(domain, replayer) {
20186
20401
  this.replayers.set(domain, replayer);
20187
20402
  }
20188
- /** Record a mutation for later replay; resolves with its assigned seq. */
20403
+ /**
20404
+ * Record a mutation for later replay; resolves with its assigned seq.
20405
+ * Stamped with the signed-in user's commitment, so that it is only ever
20406
+ * sent under their session (see {@link waiting}).
20407
+ */
20189
20408
  enqueue(entry) {
20190
- return this.store.enqueue(entry);
20409
+ const who = entry.commitment ?? this.opts.commitment?.();
20410
+ return this.store.enqueue(who ? { ...entry, commitment: who } : entry);
20191
20411
  }
20192
- /** Number of writes still awaiting sync. */
20412
+ /** Number of writes still awaiting sync, every user's. */
20193
20413
  async pending() {
20194
20414
  return (await this.store.queued()).length;
20195
20415
  }
20416
+ /**
20417
+ * The writes a drain would send now, in order: the signed-in user's, and
20418
+ * any an older SDK queued without saying whose they are. Every tab of an
20419
+ * origin shares this queue, and every replayer builds its request from
20420
+ * the session signed in when it runs, so another user's write sent now
20421
+ * would land in this user's space, or overwrite their data. It stays
20422
+ * queued, as it is, until its own user signs in on this device again.
20423
+ */
20424
+ async waiting() {
20425
+ return (await this.store.queued()).filter((entry) => this.sendable(entry));
20426
+ }
20427
+ /** True for an entry this drain may send: the signed-in user's, or one with no user. */
20428
+ sendable(entry) {
20429
+ return !entry.commitment || entry.commitment === this.opts.commitment?.();
20430
+ }
20196
20431
  /**
20197
20432
  * Replay queued mutations in HLC order. Coalesces concurrent calls into a
20198
20433
  * single in-flight drain. Stops on the first transient failure, leaving the
@@ -20214,21 +20449,36 @@ class OutboundQueue {
20214
20449
  */
20215
20450
  async drainOnce() {
20216
20451
  const who = this.opts.commitment?.();
20217
- if (!this.opts.tryLock || !who)
20218
- return this.runDrain();
20452
+ if (!this.opts.tryLock || !who) {
20453
+ await this.runDrain();
20454
+ return;
20455
+ }
20219
20456
  await this.opts.tryLock(`muhkoo:offline-drain:${who}`, async () => {
20220
20457
  // A tab that found this one draining skipped its own drain, and
20221
20458
  // what it queued after this pass read the queue was left to this
20222
- // tab. So drain until a pass leaves nothing; a transient failure
20223
- // still stops it, by throwing.
20459
+ // tab. So drain until a pass leaves nothing of this user's; a
20460
+ // transient failure still stops it, by throwing. So does a write
20461
+ // refused for payment, which stays queued: another pass would
20462
+ // only be refused again, at once and without end. Another user's
20463
+ // writes never leave, so counting them would loop for ever too.
20224
20464
  do {
20225
- await this.runDrain();
20226
- } while ((await this.store.queued()).length > 0);
20465
+ if (!(await this.runDrain()))
20466
+ return;
20467
+ } while ((await this.waiting()).length > 0);
20227
20468
  });
20228
20469
  }
20470
+ /**
20471
+ * One pass over the queue. False when a write refused for payment stopped
20472
+ * it, which leaves that write and everything after it queued.
20473
+ */
20229
20474
  async runDrain() {
20230
20475
  const entries = await this.store.queued();
20231
20476
  for (const entry of entries) {
20477
+ // Another user's write, theirs to send under their own session.
20478
+ // Asked per entry, so a sign-out in the middle of a pass stops
20479
+ // the rest of it going out under the next user's session.
20480
+ if (!this.sendable(entry))
20481
+ continue;
20232
20482
  const replayer = this.replayers.get(entry.domain);
20233
20483
  if (!replayer) {
20234
20484
  // No handler registered (namespace not wired) — drop it rather
@@ -20245,6 +20495,22 @@ class OutboundQueue {
20245
20495
  });
20246
20496
  }
20247
20497
  catch (err) {
20498
+ // Asked before `isPermanent`, which would drop it as a 4xx.
20499
+ if (err instanceof PaymentRequiredError) {
20500
+ // The user's plan, or a payment past due: the server will
20501
+ // accept this write once that changes. Keep it, and keep
20502
+ // every write after it in order; a delete queued after an
20503
+ // upload never goes first. Stop rather than send the rest,
20504
+ // which the server would refuse for the same reason.
20505
+ EventCore.emit(EventCoreEvents.SYNC_ERROR, {
20506
+ domain: entry.domain,
20507
+ method: entry.method,
20508
+ error: err.message,
20509
+ code: err.code,
20510
+ dropped: false,
20511
+ });
20512
+ return false;
20513
+ }
20248
20514
  if (isPermanent(err)) {
20249
20515
  await this.store.dequeue(entry.seq);
20250
20516
  EventCore.emit(EventCoreEvents.SYNC_ERROR, {
@@ -20259,12 +20525,14 @@ class OutboundQueue {
20259
20525
  throw err;
20260
20526
  }
20261
20527
  }
20528
+ return true;
20262
20529
  }
20263
20530
  }
20264
20531
  /** A 4xx is the server permanently refusing the write, and so is a Space whose
20265
20532
  * member list does not admit this user (design §4.5.12): the relay refuses
20266
20533
  * their frames. Anything else, a locked session included, is worth retrying
20267
- * on the next reconnect. */
20534
+ * on the next reconnect. A refusal for payment is a 4xx that is not
20535
+ * permanent; `runDrain` keeps it before asking this. */
20268
20536
  function isPermanent(err) {
20269
20537
  if (err instanceof AdmissionDeniedError)
20270
20538
  return true;
@@ -20537,6 +20805,22 @@ class OfflineManager {
20537
20805
  args: { hash },
20538
20806
  });
20539
20807
  }
20808
+ /**
20809
+ * Replay the queue now, as a reconnect does, if anything is waiting in it.
20810
+ * `client.app.access()` calls this each time it reports the app allowed,
20811
+ * so writes kept after a refusal for payment go out once the user or the
20812
+ * app owner has paid, without waiting for the next reconnect. One drain per
20813
+ * call, never on a timer. With nothing of the signed-in user's queued it
20814
+ * does not drain at all, so the app sees no "syncing". A drain also runs the
20815
+ * catch-up tasks (Space history paging), as a reconnect does.
20816
+ */
20817
+ async resume() {
20818
+ if (!this.enabled)
20819
+ return;
20820
+ if ((await this.queue.waiting()).length === 0)
20821
+ return;
20822
+ await this.sync.run();
20823
+ }
20540
20824
  /** Resolve once the clock + node id are loaded. */
20541
20825
  ready() {
20542
20826
  return this.bootPromise;
@@ -21086,6 +21370,8 @@ class Client {
21086
21370
  functions;
21087
21371
  /** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
21088
21372
  accessTokens;
21373
+ /** This app — `client.app.access()`: may the signed-in user add data here? */
21374
+ app;
21089
21375
  /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
21090
21376
  offline;
21091
21377
  session;
@@ -21265,12 +21551,15 @@ class Client {
21265
21551
  // the capability path, so it keeps working for a file whose
21266
21552
  // handle was shared from someone else's filesystem.
21267
21553
  read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
21268
- release: async (manifest) => {
21269
- await (await resolveContentSpace()).releaseFile(manifest);
21270
- },
21271
- retain: async (manifest) => {
21272
- await (await resolveContentSpace()).commitFile(manifest);
21273
- },
21554
+ // A reference names only shards, so taking or dropping one needs
21555
+ // no Space. Opening the content space here created it for a user
21556
+ // who had never written a file: a checkout, a copy, a delete, or
21557
+ // Cloud Drive's move of its pre-VFS files. A paid-only app refuses
21558
+ // that creation for a Free user (accelerator spec
21559
+ // 2026-10-06-paid-accounts-and-past-due, D12). Same body as
21560
+ // `Space.commitFile`, so it is the same reference.
21561
+ release: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "release"),
21562
+ retain: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "commit"),
21274
21563
  },
21275
21564
  });
21276
21565
  // Sealed into the SAME personal store as the VFS metadata, so a project
@@ -21339,6 +21628,9 @@ class Client {
21339
21628
  this.agents = new AgentsNamespace({ http: this.http });
21340
21629
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
21341
21630
  this.accessTokens = new AccessTokensNamespace({ http: this.http });
21631
+ // Each "allowed" sends what the offline queue kept after a refusal for
21632
+ // payment, without waiting for the next reconnect.
21633
+ this.app = new AppNamespace({ http: this.http, onAllowed: () => this.offline.resume() });
21342
21634
  // Replay deferred shard uploads on reconnect. The bytes live in the
21343
21635
  // Cache API keyed by hash; re-PUT them through a (defer-free) shard
21344
21636
  // client so a failure surfaces as transient and the entry stays queued.
@@ -21347,6 +21639,7 @@ class Client {
21347
21639
  baseUrl: this.baseUrl,
21348
21640
  pathPrefix: "/api/shards",
21349
21641
  fetch: this.http.fetch,
21642
+ fetchWithRecovery: this.http.fetchWithRecovery,
21350
21643
  cache: this.offline.fileCache,
21351
21644
  });
21352
21645
  this.offline.registerReplayer("file", async (entry) => {
@@ -21758,5 +22051,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
21758
22051
  default: loadRsWasm
21759
22052
  });
21760
22053
 
21761
- export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
22054
+ export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AppNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PaymentRequiredError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
21762
22055
  //# sourceMappingURL=index.js.map