@muhkoo/connect 0.15.0-alpha.4 → 0.15.0-alpha.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.
@@ -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
  /**
@@ -6344,6 +6456,15 @@ async function safeText(response) {
6344
6456
  * manifest and reassembles the plaintext; {@link FileStorage.readFile} first
6345
6457
  * fetches the manifest from the space.
6346
6458
  */
6459
+ /**
6460
+ * How many chunks past the one being read a multi-chunk read fetches ahead
6461
+ * (see `chunksInOrder`). Two: enough to keep requests overlapping on a link
6462
+ * where each one waits on its round trip. While the caller holds chunk k,
6463
+ * up to `READ_AHEAD_CHUNKS + 1` more chunks can be in flight (the next
6464
+ * starts as k is handed over), so at the default 4 MB chunk about 16 MB of
6465
+ * reads in flight per file, on top of the chunk held.
6466
+ */
6467
+ const READ_AHEAD_CHUNKS = 2;
6347
6468
  class FileStorage {
6348
6469
  space;
6349
6470
  shards;
@@ -6449,11 +6570,10 @@ class FileStorage {
6449
6570
  await this.codec.ready(this.rsWasmModule);
6450
6571
  // Materialize each chunk into plaintext, then concatenate. We don't
6451
6572
  // stream across chunks here because the consuming surface returns
6452
- // `Uint8Array`; a streaming variant would reuse `readChunk` unchanged.
6573
+ // `Uint8Array`; `readChunksFromShards` is the streaming variant.
6453
6574
  const plaintextChunks = [];
6454
- for (const chunkManifest of [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex)) {
6455
- plaintextChunks.push(await this.readChunk(chunkManifest));
6456
- }
6575
+ for await (const chunk of this.chunksInOrder(manifest))
6576
+ plaintextChunks.push(chunk);
6457
6577
  let total = 0;
6458
6578
  for (const c of plaintextChunks)
6459
6579
  total += c.length;
@@ -6474,8 +6594,48 @@ class FileStorage {
6474
6594
  */
6475
6595
  async *readChunksFromShards(manifest) {
6476
6596
  await this.codec.ready(this.rsWasmModule);
6477
- for (const chunkManifest of [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex)) {
6478
- yield await this.readChunk(chunkManifest);
6597
+ yield* this.chunksInOrder(manifest);
6598
+ }
6599
+ /**
6600
+ * Each chunk's plaintext, in order, with the next {@link READ_AHEAD_CHUNKS}
6601
+ * already being fetched.
6602
+ *
6603
+ * One chunk at a time, a large file cost a full round trip per 4 MB chunk
6604
+ * with nothing overlapping it: a 33 MB file was eight serial requests,
6605
+ * each mostly waiting. Within a chunk the shard reads were already
6606
+ * parallel; this overlaps the chunks too.
6607
+ *
6608
+ * Bounded on purpose: while the caller holds a yielded chunk, at most
6609
+ * `1 + READ_AHEAD_CHUNKS` more are in flight, and nothing new starts until
6610
+ * it asks for the next — a slow consumer of the stream is not run away
6611
+ * from. A failure surfaces at its own chunk, in order; reads already
6612
+ * started past it are let finish, their failures swallowed rather than
6613
+ * left unhandled. If the caller stops early (`break` or `return()`), no
6614
+ * further read starts and the ones in flight settle on their own; they are
6615
+ * not awaited, so `return()` is not delayed by a slow shard.
6616
+ */
6617
+ async *chunksInOrder(manifest) {
6618
+ const ordered = [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex);
6619
+ const ahead = [];
6620
+ let next = 0;
6621
+ const start = () => {
6622
+ const read = this.readChunk(ordered[next++]);
6623
+ read.catch(() => { }); // reported when its turn comes
6624
+ ahead.push(read);
6625
+ };
6626
+ while (next < ordered.length && ahead.length < 1 + READ_AHEAD_CHUNKS)
6627
+ start();
6628
+ try {
6629
+ while (ahead.length > 0) {
6630
+ const chunk = await ahead.shift();
6631
+ if (next < ordered.length)
6632
+ start();
6633
+ yield chunk;
6634
+ }
6635
+ }
6636
+ finally {
6637
+ next = ordered.length; // nothing more is started
6638
+ ahead.length = 0; // in-flight reads settle unobserved; each has its own .catch
6479
6639
  }
6480
6640
  }
6481
6641
  // -------------------------------------------------------------------------
@@ -6723,6 +6883,10 @@ async function parseOrThrow(response, op) {
6723
6883
  // non-JSON body
6724
6884
  }
6725
6885
  if (!response.ok) {
6886
+ // A manifest write refused for payment (a gated Space write): its own class.
6887
+ const refused = paymentRequiredFrom(response.status, parsed);
6888
+ if (refused)
6889
+ throw refused;
6726
6890
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6727
6891
  ? String(parsed.error)
6728
6892
  : `${response.status} ${response.statusText}`;
@@ -6739,7 +6903,7 @@ async function parseOrThrow(response, op) {
6739
6903
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6740
6904
  * `package.json` `version`.
6741
6905
  */
6742
- const VERSION = "0.15.0-alpha.4";
6906
+ const VERSION = "0.15.0-alpha.6";
6743
6907
 
6744
6908
  /**
6745
6909
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -6784,17 +6948,6 @@ function reqPath(input) {
6784
6948
  return "?";
6785
6949
  }
6786
6950
  }
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
6951
  class HttpClient {
6799
6952
  baseUrl;
6800
6953
  /** Origin of {@link baseUrl} — credentials are only attached same-origin. */
@@ -6923,7 +7076,9 @@ class HttpClient {
6923
7076
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6924
7077
  ? String(parsed.error)
6925
7078
  : `${res.status} ${res.statusText}`;
6926
- throw new HttpError(msg, res.status, parsed);
7079
+ // A refusal for payment is its own class, so an app can tell it from
7080
+ // any other 4xx; every other error is the HttpError it always was.
7081
+ throw paymentRequiredFrom(res.status, parsed) ?? new HttpError(msg, res.status, parsed);
6927
7082
  }
6928
7083
  return parsed;
6929
7084
  }
@@ -9667,6 +9822,7 @@ class StorageNamespace {
9667
9822
  baseUrl: this.deps.baseUrl,
9668
9823
  pathPrefix: "/api/shards",
9669
9824
  fetch: this.deps.http.fetch,
9825
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
9670
9826
  cache: this.deps.shardCache,
9671
9827
  deferUpload: this.deps.deferShardUpload,
9672
9828
  });
@@ -9685,6 +9841,59 @@ class StorageNamespace {
9685
9841
  }
9686
9842
  }
9687
9843
 
9844
+ /**
9845
+ * `POST /api/storage/commit` and `/api/storage/release`: take or drop a
9846
+ * reference to a manifest's shards.
9847
+ *
9848
+ * The commit is what meters a file's bytes to the app and reference-counts
9849
+ * its shards, so garbage collection keeps them; the release drops that
9850
+ * reference. The body is `{ hashes, bytes }` and nothing else. The accelerator
9851
+ * keys its reference ledger on the caller, the app, the bytes and the sorted
9852
+ * hashes, so this body must not change: a commit sent in another shape would
9853
+ * take a second reference instead of finding the first.
9854
+ *
9855
+ * Shared by `Space` (`putFile`, `commitFile`, `releaseFile`) and by
9856
+ * `client.vfs`, which takes and drops references here directly. A reference
9857
+ * names only shards and needs no Space; opening the content space to take one
9858
+ * created that space for a user who had never written a file, which a
9859
+ * paid-only app refuses for a Free user (accelerator spec
9860
+ * 2026-10-06-paid-accounts-and-past-due, D12).
9861
+ *
9862
+ * Internal: not re-exported from any barrel.
9863
+ */
9864
+ /**
9865
+ * Commit or release `manifest`'s shards through `fetchFn`, which should be
9866
+ * `HttpClient.fetchWithRecovery`, so a dead session is re-established once
9867
+ * rather than refused three times. Best-effort: tries three times, or once
9868
+ * for a 401, then warns and returns, without failing the caller's write.
9869
+ */
9870
+ async function commitManifest(fetchFn, httpBaseUrl, manifest, action) {
9871
+ const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
9872
+ if (hashes.length === 0)
9873
+ return;
9874
+ const body = JSON.stringify({ hashes, bytes: manifest.size });
9875
+ for (let attempt = 0; attempt < 3; attempt++) {
9876
+ try {
9877
+ const res = await fetchFn(`${httpBaseUrl}/api/storage/${action}`, {
9878
+ method: "POST", headers: { "Content-Type": "application/json" }, body,
9879
+ });
9880
+ if (res.ok)
9881
+ return;
9882
+ // A 401 that `fetchFn`'s recovery could not fix. Another try
9883
+ // would only run that recovery again (`auth.zk.recover()`, and
9884
+ // `onSessionExpired` for the app) and be refused again.
9885
+ if (res.status === 401)
9886
+ break;
9887
+ }
9888
+ catch { /* retry */ }
9889
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
9890
+ }
9891
+ // Best-effort: a missed commit leaves the shards to orphan-GC (still not
9892
+ // durable-unmetered long-term); a missed release just delays GC. Reconciled
9893
+ // by the platform sweep. Don't fail the caller's write on a metering hiccup.
9894
+ console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
9895
+ }
9896
+
9688
9897
  /**
9689
9898
  * SpaceCipher — pure crypto for the fan-out group-encryption layer.
9690
9899
  *
@@ -11068,25 +11277,10 @@ class Space {
11068
11277
  }
11069
11278
  /** Commit/release a manifest's shards with the accelerator (metering + refcount). */
11070
11279
  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)`);
11280
+ // Through the client's 401 recovery when there is one: the commit refuses
11281
+ // a dead session with 401, and three tries with the same token only
11282
+ // delayed the warning.
11283
+ await commitManifest(this.deps.fetchWithRecovery ?? this.deps.fetch, this.deps.httpBaseUrl, manifest, action);
11090
11284
  }
11091
11285
  fileStorage(backgroundUpload = false) {
11092
11286
  // Global, content-addressed shard store — same store `client.storage`
@@ -11097,6 +11291,7 @@ class Space {
11097
11291
  baseUrl: this.deps.httpBaseUrl,
11098
11292
  pathPrefix: "/api/shards",
11099
11293
  fetch: this.deps.fetch,
11294
+ fetchWithRecovery: this.deps.fetchWithRecovery,
11100
11295
  cache: this.deps.shardCache,
11101
11296
  deferUpload: this.deps.deferShardUpload,
11102
11297
  peers: this.peerNet?.exchange,
@@ -11500,6 +11695,7 @@ class MessageNamespace {
11500
11695
  wsBaseUrl: this.deps.wsBaseUrl,
11501
11696
  httpBaseUrl: this.deps.http.baseUrl,
11502
11697
  fetch: this.deps.http.fetch,
11698
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
11503
11699
  myId: () => this.myId(),
11504
11700
  fetchTicket: () => this.fetchTicket(),
11505
11701
  });
@@ -13821,6 +14017,7 @@ class SpaceNamespace {
13821
14017
  wsBaseUrl: this.deps.wsBaseUrl,
13822
14018
  httpBaseUrl: this.deps.http.baseUrl,
13823
14019
  fetch: this.deps.http.fetch,
14020
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13824
14021
  myId: () => memberId,
13825
14022
  fetchTicket: () => this.fetchTicket(),
13826
14023
  historyPolicy,
@@ -13876,6 +14073,7 @@ class SpaceNamespace {
13876
14073
  wsBaseUrl: this.deps.wsBaseUrl,
13877
14074
  httpBaseUrl: this.deps.http.baseUrl,
13878
14075
  fetch: this.deps.http.fetch,
14076
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13879
14077
  myId: () => memberId,
13880
14078
  fetchTicket: () => this.fetchTicket(),
13881
14079
  keyring,
@@ -14251,6 +14449,66 @@ class AccessTokensNamespace {
14251
14449
  }
14252
14450
  }
14253
14451
 
14452
+ /**
14453
+ * `client.app` — what the signed-in user may do in this app (accelerator spec
14454
+ * 2026-10-06-paid-accounts-and-past-due, B6 and B8).
14455
+ *
14456
+ * An app's owner can turn on "Paid Muhkoo accounts only" in the portal. The
14457
+ * accelerator then refuses a Free user's writes that add data, with a 402 the
14458
+ * SDK throws as `PaymentRequiredError`. Reads, sign-in and deletes still work.
14459
+ * The same refusal reaches every user of an app whose owner's payment is past
14460
+ * due, whether or not the setting is on.
14461
+ *
14462
+ * `access()` asks up front, so an app can show the right screen before the
14463
+ * first refusal.
14464
+ */
14465
+ class AppNamespace {
14466
+ deps;
14467
+ constructor(deps) {
14468
+ this.deps = deps;
14469
+ }
14470
+ /**
14471
+ * Whether the signed-in user may add data in this app and, if not, why
14472
+ * and where to send them. Needs the app key and a session; a stale session
14473
+ * is re-established once, as for every request. The route is never
14474
+ * refused for payment. Throws `HttpError` for anything else, among them a
14475
+ * 401 without a session and a 404 from an accelerator older than the
14476
+ * route.
14477
+ *
14478
+ * Each `allowed: true` also sends any writes the offline queue kept after
14479
+ * a refusal for payment, without waiting for them. Call it after sign-in,
14480
+ * and again when the user comes back from the portal.
14481
+ */
14482
+ async access() {
14483
+ const body = await this.deps.http.get("/api/app/access");
14484
+ const access = {
14485
+ paidAccountsOnly: body?.paidAccountsOnly === true,
14486
+ // Fail open on a malformed answer: the server enforces the gate
14487
+ // whatever this says, so a wrong "allowed" costs a refused write,
14488
+ // and a wrong "not allowed" would lock the user out for nothing.
14489
+ allowed: body?.allowed !== false,
14490
+ };
14491
+ if (!access.allowed) {
14492
+ if (isPaymentRequiredCode(body?.reason))
14493
+ access.reason = body.reason;
14494
+ const upgradeUrl = absoluteHttpUrl(body?.upgradeUrl);
14495
+ if (upgradeUrl)
14496
+ access.upgradeUrl = upgradeUrl;
14497
+ }
14498
+ else if (this.deps.onAllowed) {
14499
+ // A drain that fails, or a callback that throws, must not turn a
14500
+ // good answer into an error: the writes stay queued for the next
14501
+ // reconnect.
14502
+ Promise.resolve()
14503
+ .then(() => this.deps.onAllowed())
14504
+ .catch((err) => {
14505
+ console.warn("[client.app] sending the kept writes failed (they stay queued):", err);
14506
+ });
14507
+ }
14508
+ return access;
14509
+ }
14510
+ }
14511
+
14254
14512
  /**
14255
14513
  * POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
14256
14514
  * answers a deterministic decoy, so a lookup never says whether an account
@@ -20158,9 +20416,14 @@ class ConnectivityManager {
20158
20416
  * mutation by re-invoking the real namespace method (with an `isReplay` flag so
20159
20417
  * it doesn't re-enqueue). `drain()` replays entries in HLC order:
20160
20418
  * - 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);
20419
+ * - refused for payment (`PaymentRequiredError`: the app takes paid plans
20420
+ * only, or someone's payment is past due) → keep it and everything after
20421
+ * it, stop, and surface a `SYNC_ERROR` with `dropped: false`. The server
20422
+ * accepts it once the user or the app owner pays, so it waits for the next
20423
+ * reconnect, or for `client.app.access()` to report the app allowed;
20424
+ * - permanent failure (any other 4xx, or a Space whose member list refuses
20425
+ * this user) → drop it and surface a `SYNC_ERROR` (a write the server will
20426
+ * never accept must not wedge the queue forever);
20164
20427
  * - transient/network failure → stop draining and leave the rest for the next
20165
20428
  * reconnect.
20166
20429
  *
@@ -20185,14 +20448,34 @@ class OutboundQueue {
20185
20448
  register(domain, replayer) {
20186
20449
  this.replayers.set(domain, replayer);
20187
20450
  }
20188
- /** Record a mutation for later replay; resolves with its assigned seq. */
20451
+ /**
20452
+ * Record a mutation for later replay; resolves with its assigned seq.
20453
+ * Stamped with the signed-in user's commitment, so that it is only ever
20454
+ * sent under their session (see {@link waiting}).
20455
+ */
20189
20456
  enqueue(entry) {
20190
- return this.store.enqueue(entry);
20457
+ const who = entry.commitment ?? this.opts.commitment?.();
20458
+ return this.store.enqueue(who ? { ...entry, commitment: who } : entry);
20191
20459
  }
20192
- /** Number of writes still awaiting sync. */
20460
+ /** Number of writes still awaiting sync, every user's. */
20193
20461
  async pending() {
20194
20462
  return (await this.store.queued()).length;
20195
20463
  }
20464
+ /**
20465
+ * The writes a drain would send now, in order: the signed-in user's, and
20466
+ * any an older SDK queued without saying whose they are. Every tab of an
20467
+ * origin shares this queue, and every replayer builds its request from
20468
+ * the session signed in when it runs, so another user's write sent now
20469
+ * would land in this user's space, or overwrite their data. It stays
20470
+ * queued, as it is, until its own user signs in on this device again.
20471
+ */
20472
+ async waiting() {
20473
+ return (await this.store.queued()).filter((entry) => this.sendable(entry));
20474
+ }
20475
+ /** True for an entry this drain may send: the signed-in user's, or one with no user. */
20476
+ sendable(entry) {
20477
+ return !entry.commitment || entry.commitment === this.opts.commitment?.();
20478
+ }
20196
20479
  /**
20197
20480
  * Replay queued mutations in HLC order. Coalesces concurrent calls into a
20198
20481
  * single in-flight drain. Stops on the first transient failure, leaving the
@@ -20214,21 +20497,36 @@ class OutboundQueue {
20214
20497
  */
20215
20498
  async drainOnce() {
20216
20499
  const who = this.opts.commitment?.();
20217
- if (!this.opts.tryLock || !who)
20218
- return this.runDrain();
20500
+ if (!this.opts.tryLock || !who) {
20501
+ await this.runDrain();
20502
+ return;
20503
+ }
20219
20504
  await this.opts.tryLock(`muhkoo:offline-drain:${who}`, async () => {
20220
20505
  // A tab that found this one draining skipped its own drain, and
20221
20506
  // 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.
20507
+ // tab. So drain until a pass leaves nothing of this user's; a
20508
+ // transient failure still stops it, by throwing. So does a write
20509
+ // refused for payment, which stays queued: another pass would
20510
+ // only be refused again, at once and without end. Another user's
20511
+ // writes never leave, so counting them would loop for ever too.
20224
20512
  do {
20225
- await this.runDrain();
20226
- } while ((await this.store.queued()).length > 0);
20513
+ if (!(await this.runDrain()))
20514
+ return;
20515
+ } while ((await this.waiting()).length > 0);
20227
20516
  });
20228
20517
  }
20518
+ /**
20519
+ * One pass over the queue. False when a write refused for payment stopped
20520
+ * it, which leaves that write and everything after it queued.
20521
+ */
20229
20522
  async runDrain() {
20230
20523
  const entries = await this.store.queued();
20231
20524
  for (const entry of entries) {
20525
+ // Another user's write, theirs to send under their own session.
20526
+ // Asked per entry, so a sign-out in the middle of a pass stops
20527
+ // the rest of it going out under the next user's session.
20528
+ if (!this.sendable(entry))
20529
+ continue;
20232
20530
  const replayer = this.replayers.get(entry.domain);
20233
20531
  if (!replayer) {
20234
20532
  // No handler registered (namespace not wired) — drop it rather
@@ -20245,6 +20543,22 @@ class OutboundQueue {
20245
20543
  });
20246
20544
  }
20247
20545
  catch (err) {
20546
+ // Asked before `isPermanent`, which would drop it as a 4xx.
20547
+ if (err instanceof PaymentRequiredError) {
20548
+ // The user's plan, or a payment past due: the server will
20549
+ // accept this write once that changes. Keep it, and keep
20550
+ // every write after it in order; a delete queued after an
20551
+ // upload never goes first. Stop rather than send the rest,
20552
+ // which the server would refuse for the same reason.
20553
+ EventCore.emit(EventCoreEvents.SYNC_ERROR, {
20554
+ domain: entry.domain,
20555
+ method: entry.method,
20556
+ error: err.message,
20557
+ code: err.code,
20558
+ dropped: false,
20559
+ });
20560
+ return false;
20561
+ }
20248
20562
  if (isPermanent(err)) {
20249
20563
  await this.store.dequeue(entry.seq);
20250
20564
  EventCore.emit(EventCoreEvents.SYNC_ERROR, {
@@ -20259,12 +20573,14 @@ class OutboundQueue {
20259
20573
  throw err;
20260
20574
  }
20261
20575
  }
20576
+ return true;
20262
20577
  }
20263
20578
  }
20264
20579
  /** A 4xx is the server permanently refusing the write, and so is a Space whose
20265
20580
  * member list does not admit this user (design §4.5.12): the relay refuses
20266
20581
  * their frames. Anything else, a locked session included, is worth retrying
20267
- * on the next reconnect. */
20582
+ * on the next reconnect. A refusal for payment is a 4xx that is not
20583
+ * permanent; `runDrain` keeps it before asking this. */
20268
20584
  function isPermanent(err) {
20269
20585
  if (err instanceof AdmissionDeniedError)
20270
20586
  return true;
@@ -20537,6 +20853,22 @@ class OfflineManager {
20537
20853
  args: { hash },
20538
20854
  });
20539
20855
  }
20856
+ /**
20857
+ * Replay the queue now, as a reconnect does, if anything is waiting in it.
20858
+ * `client.app.access()` calls this each time it reports the app allowed,
20859
+ * so writes kept after a refusal for payment go out once the user or the
20860
+ * app owner has paid, without waiting for the next reconnect. One drain per
20861
+ * call, never on a timer. With nothing of the signed-in user's queued it
20862
+ * does not drain at all, so the app sees no "syncing". A drain also runs the
20863
+ * catch-up tasks (Space history paging), as a reconnect does.
20864
+ */
20865
+ async resume() {
20866
+ if (!this.enabled)
20867
+ return;
20868
+ if ((await this.queue.waiting()).length === 0)
20869
+ return;
20870
+ await this.sync.run();
20871
+ }
20540
20872
  /** Resolve once the clock + node id are loaded. */
20541
20873
  ready() {
20542
20874
  return this.bootPromise;
@@ -21086,6 +21418,8 @@ class Client {
21086
21418
  functions;
21087
21419
  /** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
21088
21420
  accessTokens;
21421
+ /** This app — `client.app.access()`: may the signed-in user add data here? */
21422
+ app;
21089
21423
  /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
21090
21424
  offline;
21091
21425
  session;
@@ -21265,12 +21599,15 @@ class Client {
21265
21599
  // the capability path, so it keeps working for a file whose
21266
21600
  // handle was shared from someone else's filesystem.
21267
21601
  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
- },
21602
+ // A reference names only shards, so taking or dropping one needs
21603
+ // no Space. Opening the content space here created it for a user
21604
+ // who had never written a file: a checkout, a copy, a delete, or
21605
+ // Cloud Drive's move of its pre-VFS files. A paid-only app refuses
21606
+ // that creation for a Free user (accelerator spec
21607
+ // 2026-10-06-paid-accounts-and-past-due, D12). Same body as
21608
+ // `Space.commitFile`, so it is the same reference.
21609
+ release: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "release"),
21610
+ retain: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "commit"),
21274
21611
  },
21275
21612
  });
21276
21613
  // Sealed into the SAME personal store as the VFS metadata, so a project
@@ -21339,6 +21676,9 @@ class Client {
21339
21676
  this.agents = new AgentsNamespace({ http: this.http });
21340
21677
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
21341
21678
  this.accessTokens = new AccessTokensNamespace({ http: this.http });
21679
+ // Each "allowed" sends what the offline queue kept after a refusal for
21680
+ // payment, without waiting for the next reconnect.
21681
+ this.app = new AppNamespace({ http: this.http, onAllowed: () => this.offline.resume() });
21342
21682
  // Replay deferred shard uploads on reconnect. The bytes live in the
21343
21683
  // Cache API keyed by hash; re-PUT them through a (defer-free) shard
21344
21684
  // client so a failure surfaces as transient and the entry stays queued.
@@ -21347,6 +21687,7 @@ class Client {
21347
21687
  baseUrl: this.baseUrl,
21348
21688
  pathPrefix: "/api/shards",
21349
21689
  fetch: this.http.fetch,
21690
+ fetchWithRecovery: this.http.fetchWithRecovery,
21350
21691
  cache: this.offline.fileCache,
21351
21692
  });
21352
21693
  this.offline.registerReplayer("file", async (entry) => {
@@ -21758,5 +22099,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
21758
22099
  default: loadRsWasm
21759
22100
  });
21760
22101
 
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 };
22102
+ 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
22103
  //# sourceMappingURL=index.js.map