@bounded-sh/core 0.0.100 → 0.0.101

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.
Files changed (30) hide show
  1. package/dist/client/app-token.d.ts +77 -0
  2. package/dist/client/create-client.d.ts +69 -17
  3. package/dist/client/subscription-v2.d.ts +23 -0
  4. package/dist/{index-B8PpRj6x.js → index-BEGeDFQI.js} +739 -106
  5. package/dist/index-BEGeDFQI.js.map +1 -0
  6. package/dist/{index-DW5yMQwV.mjs → index-CMT0saxy.mjs} +736 -107
  7. package/dist/index-CMT0saxy.mjs.map +1 -0
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.js +5 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1 -1
  12. package/dist/{realtime-store-CeDT7vJF.mjs → realtime-store-DqAAqfo9.mjs} +2 -2
  13. package/dist/{realtime-store-CeDT7vJF.mjs.map → realtime-store-DqAAqfo9.mjs.map} +1 -1
  14. package/dist/{realtime-store-D7mJ7rTj.js → realtime-store-DrtEj-bE.js} +2 -2
  15. package/dist/{realtime-store-D7mJ7rTj.js.map → realtime-store-DrtEj-bE.js.map} +1 -1
  16. package/dist/{solana-message-w2FxOkFA.js → solana-message-CJTnYiJu.js} +2 -2
  17. package/dist/{solana-message-w2FxOkFA.js.map → solana-message-CJTnYiJu.js.map} +1 -1
  18. package/dist/{solana-message-BGkYFU3C.mjs → solana-message-C_717vAx.mjs} +2 -2
  19. package/dist/{solana-message-BGkYFU3C.mjs.map → solana-message-C_717vAx.mjs.map} +1 -1
  20. package/dist/{solana-operations-DJQDYdrk.js → solana-operations-CU0YbiMu.js} +3 -3
  21. package/dist/{solana-operations-DJQDYdrk.js.map → solana-operations-CU0YbiMu.js.map} +1 -1
  22. package/dist/{solana-operations-CHgW7Pue.mjs → solana-operations-DGNA2nGZ.mjs} +4 -4
  23. package/dist/{solana-operations-CHgW7Pue.mjs.map → solana-operations-DGNA2nGZ.mjs.map} +1 -1
  24. package/dist/{solana-v1-operations-BPn-poee.mjs → solana-v1-operations-Bpd4TASG.mjs} +3 -3
  25. package/dist/{solana-v1-operations-BPn-poee.mjs.map → solana-v1-operations-Bpd4TASG.mjs.map} +1 -1
  26. package/dist/{solana-v1-operations-BF2rjG23.js → solana-v1-operations-yQII8r1s.js} +3 -3
  27. package/dist/{solana-v1-operations-BF2rjG23.js.map → solana-v1-operations-yQII8r1s.js.map} +1 -1
  28. package/package.json +2 -2
  29. package/dist/index-B8PpRj6x.js.map +0 -1
  30. package/dist/index-DW5yMQwV.mjs.map +0 -1
@@ -4190,7 +4190,7 @@ class ServerSessionManager {
4190
4190
  const sessionConfig = (_a = this.sessionConfig) !== null && _a !== void 0 ? _a : undefined;
4191
4191
  /* fetch nonce from auth API (bound to the snapshotted app+issuer) */
4192
4192
  const nonce = await genAuthNonce(sessionConfig);
4193
- const { genSolanaMessage } = await Promise.resolve().then(function () { return require('./solana-message-w2FxOkFA.js'); });
4193
+ const { genSolanaMessage } = await Promise.resolve().then(function () { return require('./solana-message-CJTnYiJu.js'); });
4194
4194
  const message = await genSolanaMessage(address, nonce, sessionConfig === null || sessionConfig === void 0 ? void 0 : sessionConfig.appId);
4195
4195
  /* sign the message */
4196
4196
  const sigBytes = nacl.sign.detached(new TextEncoder().encode(message), kp.secretKey);
@@ -5282,6 +5282,37 @@ const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
5282
5282
  // cancel teardown and reuse it; genuinely idle sockets still close promptly.
5283
5283
  const IDLE_CONNECTION_CLOSE_GRACE_MS = 5000;
5284
5284
  const SUBSCRIPTION_CONFIRM_TIMEOUT_MS = 10000;
5285
+ /**
5286
+ * Live subscriptions the SDK puts on ONE socket before opening another.
5287
+ *
5288
+ * The realtime worker refuses the 51st distinct subscribe on a connection with
5289
+ * `{ code: 'subscription_limit', status: 429 }` - see
5290
+ * `packages/cdk/cloudflare/realtime-worker/src/subscriptions.ts`
5291
+ * (`attachment.subscriptions.length >= 50`). The cap is structural there: every
5292
+ * subscription spec has to fit the 16KB Durable Object hibernation attachment,
5293
+ * and each write fans out across the whole set of one socket.
5294
+ *
5295
+ * 45 leaves deliberate headroom under that 50: two concurrent subscribeV2 calls
5296
+ * can both see room and overshoot by a frame or two, and another tab may already
5297
+ * hold subscriptions on the same DO. A refusal that happens anyway is treated as
5298
+ * backpressure, not a failure (see rehomeRefusedSubscription).
5299
+ */
5300
+ const SHARD_CAP = 45;
5301
+ /**
5302
+ * Sockets one routing target (app, room, or wallet identity) may hold. Past this
5303
+ * a subscribe fails loudly with `subscription_limit` instead of opening
5304
+ * connections without end - 8 * 45 = 360 concurrent live subscriptions per
5305
+ * target, far beyond any legitimate UI.
5306
+ */
5307
+ const MAX_SHARDS_PER_KEY = 8;
5308
+ /** Re-homes one logical subscription may attempt before its refusal surfaces. */
5309
+ const MAX_REHOME_ATTEMPTS = MAX_SHARDS_PER_KEY;
5310
+ const SUBSCRIPTION_LIMIT_CODE = 'subscription_limit';
5311
+ // Mirror of the server's `clampSubscriptionLimit` (realtime-worker
5312
+ // src/subscriptions.ts): an omitted or non-numeric limit becomes the default, a
5313
+ // value above the ceiling is clamped down, and a zero/negative limit is rejected.
5314
+ const SUBSCRIPTION_DEFAULT_LIMIT = 100;
5315
+ const SUBSCRIPTION_MAX_LIMIT = 1000;
5285
5316
  const WS_CONFIG = {
5286
5317
  // Keep retrying indefinitely so long outages recover without page refresh.
5287
5318
  maxRetries: Infinity,
@@ -5302,6 +5333,26 @@ let reconnectInProgress = null;
5302
5333
  function generateSubscriptionId() {
5303
5334
  return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
5304
5335
  }
5336
+ /**
5337
+ * Apply the server's own subscription-limit clamp so the client identifies a
5338
+ * subscription by the value the server will actually key it on.
5339
+ *
5340
+ * The worker de-dups a connection's subscriptions on the CLAMPED limit and
5341
+ * REPLACES a colliding entry (sending `unsubscribed` for the displaced id), so
5342
+ * `limit: undefined` and `limit: 100` are one server subscription. Matching on
5343
+ * the raw option made the SDK register two, and the second silently retired the
5344
+ * first stream. `null` mirrors the server's rejection of a zero/negative limit.
5345
+ * Authority: `clampSubscriptionLimit` in realtime-worker src/subscriptions.ts.
5346
+ */
5347
+ function normalizeSubscriptionLimit(raw) {
5348
+ if (raw === undefined || raw === null)
5349
+ return SUBSCRIPTION_DEFAULT_LIMIT;
5350
+ if (typeof raw !== 'number' || !Number.isFinite(raw))
5351
+ return SUBSCRIPTION_DEFAULT_LIMIT;
5352
+ if (raw <= 0)
5353
+ return null;
5354
+ return raw > SUBSCRIPTION_MAX_LIMIT ? SUBSCRIPTION_MAX_LIMIT : Math.floor(raw);
5355
+ }
5305
5356
  function hashForKey$1(value) {
5306
5357
  let h = 5381;
5307
5358
  for (let i = 0; i < value.length; i++) {
@@ -5321,7 +5372,11 @@ function hashForKey$1(value) {
5321
5372
  function getCacheKey(path, prompt, shape, limit, cursor, filter, identity) {
5322
5373
  const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
5323
5374
  const shapeKey = shape && Object.keys(shape).length > 0 ? JSON.stringify(shape) : '';
5324
- const limitKey = limit !== undefined ? `:l${limit}` : '';
5375
+ // Normalized, for the same reason the de-dup scan is: two options that clamp
5376
+ // to one server subscription must share one cache entry, or a joined callback
5377
+ // cannot read the snapshot its own subscription just wrote.
5378
+ const normalizedLimit = normalizeSubscriptionLimit(limit);
5379
+ const limitKey = `:l${normalizedLimit === null ? 'rejected' : normalizedLimit}`;
5325
5380
  const cursorKey = cursor ? `:c${hashForKey$1(cursor)}` : '';
5326
5381
  const filterKey = filter && Object.keys(filter).length > 0 ? `:f${hashForKey$1(JSON.stringify(filter))}` : '';
5327
5382
  return `${identity}|${normalizedPath}:${prompt || 'default'}:${shapeKey}${limitKey}${cursorKey}${filterKey}`;
@@ -5650,18 +5705,76 @@ function replaySubscriptions(connection) {
5650
5705
  * connections key by their routing target; wallet-scoped connections pin the
5651
5706
  * exact bearer so two distinct tokens never share a flight or a lease, even
5652
5707
  * when their compact `#id#<principalKey>` hashes collide.
5708
+ *
5709
+ * `shardIndex` selects which socket of that target's pool this is. Shard 0 adds
5710
+ * nothing, so every key an unsharded client produces is unchanged.
5653
5711
  */
5654
- function connectionLeaseKey(appId, routePath, principalKey, principalToken, anonymous) {
5712
+ function connectionLeaseKey(appId, routePath, principalKey, principalToken, anonymous, shardIndex = 0) {
5655
5713
  const roomKey = roomKeyFromRoutePath(routePath);
5656
5714
  // A read-only scoped instance is anonymous and must never share a socket with
5657
5715
  // the authenticated global path even when it targets the same appId.
5658
5716
  const appKey = anonymous ? `${appId}#ro` : appId;
5659
- const base = roomKey ? `${appKey}#room#${roomKey}` : appKey;
5717
+ const base = routingBaseKey(roomKey ? `${appKey}#room#${roomKey}` : appKey, shardIndex);
5660
5718
  if (!principalKey)
5661
5719
  return base;
5662
5720
  const bucket = `${base}#id#${principalKey}`;
5663
5721
  return principalToken ? `${bucket}#tok#${principalToken}` : bucket;
5664
5722
  }
5723
+ /**
5724
+ * Apply a shard suffix to the ROUTING part of a connection key, ahead of any
5725
+ * `#id#`/`#tok#` identity bucket. Keeping the suffix here means the room/app
5726
+ * prefix a caller already knows (`<appId>#room#<collection>/<roomId>`) is still
5727
+ * the shard-0 key, so existing single-socket lookups keep working verbatim.
5728
+ */
5729
+ function routingBaseKey(base, shardIndex) {
5730
+ return shardIndex > 0 ? `${base}#s${shardIndex}` : base;
5731
+ }
5732
+ /** The live connection holding a given lease key, across identity collisions. */
5733
+ function connectionForLeaseKey(leaseKey) {
5734
+ for (const connection of connections.values()) {
5735
+ if (connection.acquisitionKey === leaseKey)
5736
+ return connection;
5737
+ }
5738
+ return undefined;
5739
+ }
5740
+ /**
5741
+ * The connection that currently owns a subscription id.
5742
+ *
5743
+ * A subscriber's unsubscribe closure captures the connection it registered on,
5744
+ * but a `subscription_limit` re-home can move the subscription to another shard
5745
+ * afterwards. Re-resolving by id means an unsubscribe still reaches the socket
5746
+ * that actually holds it instead of silently no-oping and leaking the
5747
+ * subscription. Ids are minted locally and globally unique, so an id identifies
5748
+ * exactly one subscription across every connection.
5749
+ */
5750
+ function connectionHoldingSubscription(subscriptionId) {
5751
+ for (const connection of connections.values()) {
5752
+ if (connection.subscriptions.has(subscriptionId))
5753
+ return connection;
5754
+ }
5755
+ return undefined;
5756
+ }
5757
+ /**
5758
+ * Walk a routing target's shard pool, shard 0 first, and return the first
5759
+ * connection the caller accepts. Callers that used to read a single
5760
+ * `connections.get(<base>)` need this because shard 0 closes when it drains
5761
+ * while a later shard is still carrying subscriptions - a bare lookup would
5762
+ * then report "no socket" for an app that has several.
5763
+ */
5764
+ function findShardConnection(baseKey, accept) {
5765
+ for (let shardIndex = 0; shardIndex < MAX_SHARDS_PER_KEY; shardIndex++) {
5766
+ const connection = connections.get(routingBaseKey(baseKey, shardIndex));
5767
+ if (connection && accept(connection))
5768
+ return connection;
5769
+ }
5770
+ return undefined;
5771
+ }
5772
+ /** An OPEN socket that is not mid-auth: safe to send an authorized frame on. */
5773
+ function isWarmConnection(connection) {
5774
+ return !!connection.ws
5775
+ && connection.ws.readyState === WS_READY_STATE_OPEN
5776
+ && !connection.isAuthenticating;
5777
+ }
5665
5778
  function cancelIdleConnectionClose(connection) {
5666
5779
  if (!connection.idleCloseTimer)
5667
5780
  return;
@@ -5725,9 +5838,9 @@ function scheduleConnectionCloseIfIdle(connection) {
5725
5838
  * reuse. The lease is released once the caller has registered its
5726
5839
  * subscription/callback.
5727
5840
  */
5728
- async function acquireConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, source) {
5841
+ async function acquireConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, source, shardIndex = 0) {
5729
5842
  var _a;
5730
- const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken, source === null || source === void 0 ? void 0 : source.anonymous);
5843
+ const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken, source === null || source === void 0 ? void 0 : source.anonymous, shardIndex);
5731
5844
  connectionAcquisitions.set(leaseKey, ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 0) + 1);
5732
5845
  let released = false;
5733
5846
  let releaseTimer = null;
@@ -5749,7 +5862,7 @@ async function acquireConnection(appId, isServer, routePath, authTokenProvider,
5749
5862
  scheduleConnectionCloseIfIdle(connection);
5750
5863
  };
5751
5864
  try {
5752
- const connection = await getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, source);
5865
+ const connection = await getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, source, shardIndex);
5753
5866
  const releaseConnection = () => release(connection);
5754
5867
  // Registration is synchronous after the caller's await. This fail-safe
5755
5868
  // releases on the next task even if an option getter/callback throws
@@ -5762,15 +5875,94 @@ async function acquireConnection(appId, isServer, routePath, authTokenProvider,
5762
5875
  throw error;
5763
5876
  }
5764
5877
  }
5765
- async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, source) {
5878
+ function shardTargetFromConnection(connection) {
5879
+ var _a;
5880
+ return {
5881
+ appId: connection.appId,
5882
+ isServer: connection.isServer,
5883
+ routePath: connection.routePath,
5884
+ authTokenProvider: connection.authTokenProvider,
5885
+ principalKey: connection.principalKey,
5886
+ principalToken: (_a = connection.connectionIdentityToken) !== null && _a !== void 0 ? _a : undefined,
5887
+ source: connection.source,
5888
+ };
5889
+ }
5890
+ function shardLeaseKeyFor(target, shardIndex) {
5891
+ var _a;
5892
+ return connectionLeaseKey(target.appId, target.routePath, target.principalKey, target.principalToken, (_a = target.source) === null || _a === void 0 ? void 0 : _a.anonymous, shardIndex);
5893
+ }
5894
+ function subscriptionLimitExhaustedError(target) {
5895
+ const error = new Error(`${SUBSCRIPTION_LIMIT_CODE}: this client already holds the maximum ${MAX_SHARDS_PER_KEY * SHARD_CAP} live subscriptions for ${target.appId}`);
5896
+ error.code = SUBSCRIPTION_LIMIT_CODE;
5897
+ error.status = 429;
5898
+ return error;
5899
+ }
5900
+ /**
5901
+ * Choose the shard that should carry a subscription.
5902
+ *
5903
+ * The de-dup scan spans EVERY shard of the target before any placement decision:
5904
+ * a second subscriber with identical specs must ref-count onto the existing
5905
+ * subscription wherever it lives, never open a duplicate on another socket.
5906
+ * Failing that, the lowest-index shard with room wins, so a client that drops
5907
+ * back under one socket's worth of subscriptions collapses onto shard 0 again.
5908
+ */
5909
+ function planSubscriptionShard(target, matches) {
5910
+ let firstShardWithRoom = -1;
5911
+ for (let shardIndex = 0; shardIndex < MAX_SHARDS_PER_KEY; shardIndex++) {
5912
+ const connection = connectionForLeaseKey(shardLeaseKeyFor(target, shardIndex));
5913
+ if (!connection) {
5914
+ if (firstShardWithRoom < 0)
5915
+ firstShardWithRoom = shardIndex;
5916
+ continue;
5917
+ }
5918
+ for (const subscription of connection.subscriptions.values()) {
5919
+ if (matches(subscription))
5920
+ return { shardIndex, existing: subscription };
5921
+ }
5922
+ if (firstShardWithRoom < 0 && connection.subscriptions.size < connection.capacityCeiling) {
5923
+ firstShardWithRoom = shardIndex;
5924
+ }
5925
+ }
5926
+ return { shardIndex: firstShardWithRoom < 0 ? MAX_SHARDS_PER_KEY : firstShardWithRoom };
5927
+ }
5928
+ /**
5929
+ * Acquire the connection that will carry a subscription, opening another shard
5930
+ * when the current ones are at SHARD_CAP.
5931
+ *
5932
+ * Placement is re-decided after every acquisition because the auth preflight is
5933
+ * an await: concurrently mounting subscribers can fill the shard this call
5934
+ * planned for, and the subscription it planned to join can be gone. Both cases
5935
+ * re-plan rather than overshoot, bounded by the shard ceiling.
5936
+ */
5937
+ async function acquireSubscriptionShard(target, matches) {
5938
+ for (let attempt = 0; attempt <= MAX_SHARDS_PER_KEY; attempt++) {
5939
+ const placement = planSubscriptionShard(target, matches);
5940
+ if (placement.shardIndex >= MAX_SHARDS_PER_KEY)
5941
+ throw subscriptionLimitExhaustedError(target);
5942
+ const acquisition = await acquireConnection(target.appId, target.isServer, target.routePath, target.authTokenProvider, target.principalKey, target.principalToken, target.source, placement.shardIndex);
5943
+ const { connection } = acquisition;
5944
+ const existing = placement.existing;
5945
+ if (existing) {
5946
+ if (connection.subscriptions.get(existing.subscriptionId) === existing) {
5947
+ return { acquisition, existing };
5948
+ }
5949
+ }
5950
+ else if (connection.subscriptions.size < connection.capacityCeiling) {
5951
+ return { acquisition };
5952
+ }
5953
+ acquisition.release();
5954
+ }
5955
+ throw subscriptionLimitExhaustedError(target);
5956
+ }
5957
+ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, source, shardIndex = 0) {
5766
5958
  // Singleflight: concurrent first-use of the same routing target shares one
5767
5959
  // in-flight connection rather than each creating an orphan socket. Keyed by
5768
5960
  // the identity-bucket lease key so distinct wallet tokens never coalesce.
5769
- const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken, source === null || source === void 0 ? void 0 : source.anonymous);
5961
+ const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken, source === null || source === void 0 ? void 0 : source.anonymous, shardIndex);
5770
5962
  const activeFlight = connectionFlights.get(leaseKey);
5771
5963
  if (activeFlight)
5772
5964
  return activeFlight;
5773
- const flight = getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey, source);
5965
+ const flight = getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey, source, shardIndex);
5774
5966
  connectionFlights.set(leaseKey, flight);
5775
5967
  try {
5776
5968
  return await flight;
@@ -5781,7 +5973,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
5781
5973
  }
5782
5974
  }
5783
5975
  }
5784
- async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey, source) {
5976
+ async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey, source, shardIndex = 0) {
5785
5977
  var _a, _b;
5786
5978
  attachBrowserReconnectHooksOnce();
5787
5979
  const anonymous = (source === null || source === void 0 ? void 0 : source.anonymous) === true;
@@ -5795,8 +5987,11 @@ async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenPr
5795
5987
  // never shares a connection (or token) with any top-level server caller. A
5796
5988
  // read-only scoped (anonymous) instance is likewise isolated by an `#ro`
5797
5989
  // marker so it never shares the authenticated global socket on the same appId.
5990
+ // Shard N>0 of the same routing target is a SEPARATE socket to the SAME
5991
+ // Durable Object: the server's cap is per websocket attachment, so an extra
5992
+ // connection buys an extra budget without changing where anything routes.
5798
5993
  const appKey = anonymous ? `${appId}#ro` : appId;
5799
- const base = roomKey ? `${appKey}#room#${roomKey}` : appKey;
5994
+ const base = routingBaseKey(roomKey ? `${appKey}#room#${roomKey}` : appKey, shardIndex);
5800
5995
  const identityBucketKey = principalKey
5801
5996
  ? `${base}#id#${principalKey}`
5802
5997
  : base;
@@ -5871,6 +6066,9 @@ async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenPr
5871
6066
  appId,
5872
6067
  key: connKey,
5873
6068
  acquisitionKey: leaseKey,
6069
+ shardIndex,
6070
+ principalKey,
6071
+ capacityCeiling: SHARD_CAP,
5874
6072
  routePath: roomKey ? routePath : undefined,
5875
6073
  authTokenProvider,
5876
6074
  connectionIdentityToken: principalToken !== null && principalToken !== void 0 ? principalToken : null,
@@ -6171,6 +6369,16 @@ function handleServerMessage(connection, message) {
6171
6369
  if (connection.isAuthenticating && connection.pendingAuthToken && !connection.authTokenProvider && !connection.anonymous) {
6172
6370
  void recoverFromServerAuthRejection(connection);
6173
6371
  }
6372
+ // `subscription_limit` is backpressure on THIS socket, not a verdict
6373
+ // on the read: the DO refuses a subscribe once its hibernation
6374
+ // attachment is full. Move the subscription to another shard instead
6375
+ // of failing the caller. The client shards below the server's cap, so
6376
+ // this is the residual case - another tab on the same DO, a race, or
6377
+ // a server enforcing a lower cap.
6378
+ if (message.code === SUBSCRIPTION_LIMIT_CODE && message.subscriptionId
6379
+ && rehomeRefusedSubscription(connection, message.subscriptionId, message)) {
6380
+ break;
6381
+ }
6174
6382
  // Build an Error that ALSO carries the stable `code` as a structured
6175
6383
  // field (not just string-interpolated into the message) so callers can
6176
6384
  // branch on it — e.g. `catch (e) { if (e.code === 'invariant_violation') }`
@@ -6179,6 +6387,11 @@ function handleServerMessage(connection, message) {
6179
6387
  const buildWsError = () => {
6180
6388
  const err = new Error(`${message.code}: ${message.message}`);
6181
6389
  err.code = message.code;
6390
+ // The server's HTTP-equivalent status, so a caller can tell 429
6391
+ // backpressure from a 413 oversize frame from a 403 policy
6392
+ // denial without pattern-matching the code string.
6393
+ if (typeof message.status === 'number')
6394
+ err.status = message.status;
6182
6395
  if (message.requestId)
6183
6396
  err.requestId = message.requestId;
6184
6397
  if (message.subscriptionId)
@@ -6354,6 +6567,23 @@ function rejectPendingSubscriptionConfirmation(connection, subscriptionId, error
6354
6567
  pending.reject(error);
6355
6568
  return true;
6356
6569
  }
6570
+ /**
6571
+ * Take a confirmation record off a connection WITHOUT settling it, so it can be
6572
+ * re-armed on another socket. A `subscription_limit` refusal has not failed the
6573
+ * caller's subscribe - the subscription is still being placed - so rejecting the
6574
+ * promise here would turn backpressure into an error the app has to handle.
6575
+ */
6576
+ function detachPendingSubscriptionConfirmation(connection, subscriptionId) {
6577
+ const pending = connection.pendingSubscriptions.get(subscriptionId);
6578
+ if (!pending)
6579
+ return null;
6580
+ connection.pendingSubscriptions.delete(subscriptionId);
6581
+ if (pending.timer) {
6582
+ clearTimeout(pending.timer);
6583
+ pending.timer = null;
6584
+ }
6585
+ return pending;
6586
+ }
6357
6587
  function armSubscriptionConfirmationTimeout(connection, subscription, pending) {
6358
6588
  pending.timer = setTimeout(() => {
6359
6589
  if (connection.pendingSubscriptions.get(subscription.subscriptionId) !== pending)
@@ -6447,6 +6677,96 @@ function registerDetachedReplayConfirmation(connection, subscription) {
6447
6677
  }
6448
6678
  });
6449
6679
  }
6680
+ function subscriptionLimitError(message) {
6681
+ const error = new Error(`${message.code}: ${message.message}`);
6682
+ error.code = message.code;
6683
+ error.status = typeof message.status === 'number' ? message.status : 429;
6684
+ if (message.subscriptionId)
6685
+ error.subscriptionId = message.subscriptionId;
6686
+ return error;
6687
+ }
6688
+ /**
6689
+ * Handle a server `subscription_limit` refusal as backpressure: unregister the
6690
+ * subscription from the socket that refused it and place it on another shard.
6691
+ *
6692
+ * Unregistering is what makes the refusal non-permanent. A refused subscription
6693
+ * that stayed in `connection.subscriptions` was re-sent by replaySubscriptions()
6694
+ * on every reconnect, to be refused again forever.
6695
+ *
6696
+ * Returns true when this refusal has been taken over and must not also be
6697
+ * reported to the subscription's callbacks.
6698
+ */
6699
+ function rehomeRefusedSubscription(connection, subscriptionId, message) {
6700
+ const subscription = connection.subscriptions.get(subscriptionId);
6701
+ if (!subscription)
6702
+ return false;
6703
+ connection.subscriptions.delete(subscriptionId);
6704
+ // Believe the server over SHARD_CAP: whatever this socket still holds is the
6705
+ // most it demonstrably accepts. The planner stops offering it until it drains.
6706
+ connection.capacityCeiling = Math.min(connection.capacityCeiling, connection.subscriptions.size);
6707
+ const pending = detachPendingSubscriptionConfirmation(connection, subscriptionId);
6708
+ subscription.rehomeAttempts += 1;
6709
+ if (subscription.rehomeAttempts > MAX_REHOME_ATTEMPTS) {
6710
+ // Every shard refused it. This is the one case where the cap reaches the
6711
+ // app, and it arrives as a real error with `code` and `status`.
6712
+ const error = subscriptionLimitError(message);
6713
+ notifyErrorCallbacks(subscription, error);
6714
+ rejectSubscriptionRefresh(subscription, error);
6715
+ pending === null || pending === void 0 ? void 0 : pending.reject(error);
6716
+ scheduleConnectionCloseIfIdle(connection);
6717
+ return true;
6718
+ }
6719
+ void moveSubscriptionToAnotherShard(connection, subscription, pending, message);
6720
+ scheduleConnectionCloseIfIdle(connection);
6721
+ return true;
6722
+ }
6723
+ async function moveSubscriptionToAnotherShard(refusedConnection, subscription, pending, message) {
6724
+ const target = shardTargetFromConnection(refusedConnection);
6725
+ let acquisition;
6726
+ try {
6727
+ // Never join an existing subscription here: this id is the thing being
6728
+ // placed, and its de-dup identity was already settled when it was created.
6729
+ ({ acquisition } = await acquireSubscriptionShard(target, () => false));
6730
+ }
6731
+ catch (error) {
6732
+ const failure = error instanceof Error ? error : subscriptionLimitError(message);
6733
+ notifyErrorCallbacks(subscription, failure);
6734
+ rejectSubscriptionRefresh(subscription, failure);
6735
+ pending === null || pending === void 0 ? void 0 : pending.reject(failure);
6736
+ return;
6737
+ }
6738
+ const connection = acquisition.connection;
6739
+ try {
6740
+ if (subscription.callbacks.length === 0) {
6741
+ // Every consumer unsubscribed while the re-home was in flight.
6742
+ pending === null || pending === void 0 ? void 0 : pending.reject(new Error('Subscription unsubscribed'));
6743
+ return;
6744
+ }
6745
+ // The new socket owes this subscription a fresh snapshot, exactly as a
6746
+ // reconnect replay does, so callbacks are not left holding stale data.
6747
+ subscription.lastData = undefined;
6748
+ connection.subscriptions.set(subscription.subscriptionId, subscription);
6749
+ if (pending) {
6750
+ pending.retrySent = false;
6751
+ connection.pendingSubscriptions.set(subscription.subscriptionId, pending);
6752
+ }
6753
+ else {
6754
+ registerDetachedReplayConfirmation(connection, subscription);
6755
+ }
6756
+ if (isWarmConnection(connection)) {
6757
+ sendSubscribeWithConfirmation(connection, subscription);
6758
+ }
6759
+ else if (pending) {
6760
+ // Cold or authenticating: replaySubscriptions() sends it and re-arms
6761
+ // the per-socket window once the socket authenticates. Bound the wait
6762
+ // until then, the same way an original cold registration does.
6763
+ armSubscriptionConfirmationTimeout(connection, subscription, pending);
6764
+ }
6765
+ }
6766
+ finally {
6767
+ acquisition.release();
6768
+ }
6769
+ }
6450
6770
  function resolveSubscriptionRefresh(subscription) {
6451
6771
  const refresh = subscription.refresh;
6452
6772
  if (!refresh)
@@ -6542,7 +6862,7 @@ function sendUnsubscribe(connection, subscriptionId) {
6542
6862
  * the subscription onto, since a WS connection routes to a single DO.
6543
6863
  */
6544
6864
  async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6545
- var _a, _b, _c, _d, _e, _f;
6865
+ var _a, _b, _c, _d, _e;
6546
6866
  const overrides = subscriptionOptions._overrides;
6547
6867
  // A scoped createClient() instance passes its own resolved config; the
6548
6868
  // default path reads the module-global init() config.
@@ -6581,16 +6901,52 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6581
6901
  const cacheKey = responseCacheEnabled
6582
6902
  ? getCacheKey(normalizedPath, subscriptionOptions.prompt, subscriptionOptions.shape, subscriptionOptions.limit, subscriptionOptions.cursor, subscriptionOptions.filter, identity)
6583
6903
  : null;
6904
+ // Stringify THIS call's specs once; existing subscriptions carry their
6905
+ // precomputed specKey, so the shared-subscription scan is a string compare per
6906
+ // entry (previously each new subscribe re-stringified every existing
6907
+ // subscription's shape/filter/sort - O(N²) with many live hooks). Resolved
6908
+ // before acquisition because shard placement needs the de-dup predicate.
6909
+ const specKey = JSON.stringify([
6910
+ (_b = subscriptionOptions.shape) !== null && _b !== void 0 ? _b : null,
6911
+ (_c = subscriptionOptions.filter) !== null && _c !== void 0 ? _c : null,
6912
+ (_d = subscriptionOptions.sort) !== null && _d !== void 0 ? _d : null,
6913
+ ]);
6914
+ const normalizedLimit = normalizeSubscriptionLimit(subscriptionOptions.limit);
6915
+ const includeSubPaths = (_e = subscriptionOptions.includeSubPaths) !== null && _e !== void 0 ? _e : false;
6916
+ // H1: the scan also requires the same principal identity (compact hash AND
6917
+ // exact token) - a different logged-in user must not piggy-back on (and
6918
+ // inherit lastData from) another user's existing subscription, even with
6919
+ // identical path/prompt/shape/filter/sort.
6920
+ const matchesThisSubscription = (sub) => (sub.identity === identity
6921
+ && sub.identityToken === identityToken
6922
+ && sub.path === normalizedPath
6923
+ && sub.prompt === subscriptionOptions.prompt
6924
+ && sub.specKey === specKey
6925
+ && sub.includeSubPaths === includeSubPaths
6926
+ && sub.normalizedLimit === normalizedLimit
6927
+ && sub.cursor === subscriptionOptions.cursor);
6584
6928
  // Acquire a connection for this routing target (room-scoped when a room route
6585
- // is supplied by the live helper, else the app-level connection). The lease is
6586
- // held across auth preflight and released once this call registers below, so a
6587
- // concurrent old-route teardown cannot close a socket we are about to reuse.
6929
+ // is supplied by the live helper, else the app-level connection), on the shard
6930
+ // that either already holds this exact subscription or still has room under
6931
+ // the server's per-socket cap. The lease is held across auth preflight and
6932
+ // released once this call registers below, so a concurrent old-route teardown
6933
+ // cannot close a socket we are about to reuse.
6588
6934
  let connection;
6589
6935
  let releaseConnection;
6936
+ let existingSubscription;
6590
6937
  try {
6591
- const acquisition = await acquireConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey, principalToken, source);
6938
+ const { acquisition, existing } = await acquireSubscriptionShard({
6939
+ appId: effectiveAppId,
6940
+ isServer: config.isServer,
6941
+ routePath: roomRoutePath,
6942
+ authTokenProvider,
6943
+ principalKey,
6944
+ principalToken,
6945
+ source,
6946
+ }, matchesThisSubscription);
6592
6947
  connection = acquisition.connection;
6593
6948
  releaseConnection = acquisition.release;
6949
+ existingSubscription = existing;
6594
6950
  }
6595
6951
  catch (error) {
6596
6952
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6601,34 +6957,6 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6601
6957
  throw err;
6602
6958
  }
6603
6959
  const cachedEntry = cacheKey ? getResponseCacheEntry(cacheKey, identityToken) : undefined;
6604
- // Check if we already have a subscription for this path+prompt+shape+limit+cursor+filter+sort
6605
- let existingSubscription;
6606
- let specKey = '';
6607
- try {
6608
- // Stringify THIS call's specs once; existing subscriptions carry their
6609
- // precomputed specKey, so the scan is a string compare per entry
6610
- // (previously each new subscribe re-stringified every existing
6611
- // subscription's shape/filter/sort — O(N²) with many live hooks).
6612
- specKey = JSON.stringify([
6613
- (_b = subscriptionOptions.shape) !== null && _b !== void 0 ? _b : null,
6614
- (_c = subscriptionOptions.filter) !== null && _c !== void 0 ? _c : null,
6615
- (_d = subscriptionOptions.sort) !== null && _d !== void 0 ? _d : null,
6616
- ]);
6617
- for (const sub of connection.subscriptions.values()) {
6618
- // H1: also require the same principal identity (compact hash AND exact
6619
- // token) — a different logged-in user must not piggy-back on (and
6620
- // inherit lastData from) another user's existing subscription, even
6621
- // with identical path/prompt/shape/filter/sort.
6622
- if (sub.identity === identity && sub.identityToken === identityToken && sub.path === normalizedPath && sub.prompt === subscriptionOptions.prompt && sub.specKey === specKey && sub.includeSubPaths === ((_e = subscriptionOptions.includeSubPaths) !== null && _e !== void 0 ? _e : false) && sub.limit === subscriptionOptions.limit && sub.cursor === subscriptionOptions.cursor) {
6623
- existingSubscription = sub;
6624
- break;
6625
- }
6626
- }
6627
- }
6628
- catch (error) {
6629
- releaseConnection();
6630
- throw error;
6631
- }
6632
6960
  if (existingSubscription) {
6633
6961
  let replayData = undefined;
6634
6962
  let replayOnData;
@@ -6711,8 +7039,9 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6711
7039
  prompt: subscriptionOptions.prompt,
6712
7040
  shape: subscriptionOptions.shape,
6713
7041
  limit: subscriptionOptions.limit,
7042
+ normalizedLimit,
6714
7043
  cursor: subscriptionOptions.cursor,
6715
- includeSubPaths: (_f = subscriptionOptions.includeSubPaths) !== null && _f !== void 0 ? _f : false,
7044
+ includeSubPaths,
6716
7045
  callbacks: [subscriptionOptions],
6717
7046
  lastData: undefined,
6718
7047
  dataRevision: 0,
@@ -6721,6 +7050,7 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6721
7050
  identity,
6722
7051
  identityToken,
6723
7052
  specKey,
7053
+ rehomeAttempts: 0,
6724
7054
  };
6725
7055
  connection.subscriptions.set(subscriptionId, subscription);
6726
7056
  }
@@ -6794,15 +7124,21 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6794
7124
  await removeCallbackFromSubscription(connection, subscriptionId, subscriptionOptions);
6795
7125
  };
6796
7126
  }
6797
- async function removeCallbackFromSubscription(connection, subscriptionId, callback) {
6798
- var _a;
7127
+ async function removeCallbackFromSubscription(registeredOn, subscriptionId, callback) {
7128
+ var _a, _b;
7129
+ // A `subscription_limit` re-home can have moved this subscription to another
7130
+ // shard since the caller captured its connection, so resolve the current
7131
+ // owner rather than no-oping (which would leak the subscription forever).
7132
+ const connection = registeredOn.subscriptions.has(subscriptionId)
7133
+ ? registeredOn
7134
+ : (_a = connectionHoldingSubscription(subscriptionId)) !== null && _a !== void 0 ? _a : registeredOn;
6799
7135
  const subscription = connection.subscriptions.get(subscriptionId);
6800
7136
  if (!subscription) {
6801
7137
  return;
6802
7138
  }
6803
7139
  // Remove this callback
6804
7140
  subscription.callbacks = subscription.callbacks.filter(cb => cb !== callback);
6805
- (_a = subscription.refresh) === null || _a === void 0 ? void 0 : _a.callbacks.delete(callback);
7141
+ (_b = subscription.refresh) === null || _b === void 0 ? void 0 : _b.callbacks.delete(callback);
6806
7142
  // If there are still callbacks, don't unsubscribe
6807
7143
  if (subscription.callbacks.length > 0) {
6808
7144
  return;
@@ -6968,7 +7304,7 @@ async function doReconnectWithNewAuth() {
6968
7304
  console.warn('[WS v2] Failed to clear HTTP read cache on auth change:', error);
6969
7305
  }
6970
7306
  try {
6971
- const { reconnectRealtimeStoreWithNewAuth } = await Promise.resolve().then(function () { return require('./realtime-store-D7mJ7rTj.js'); });
7307
+ const { reconnectRealtimeStoreWithNewAuth } = await Promise.resolve().then(function () { return require('./realtime-store-DrtEj-bE.js'); });
6972
7308
  await reconnectRealtimeStoreWithNewAuth();
6973
7309
  }
6974
7310
  catch (error) {
@@ -7043,8 +7379,10 @@ const WS_RUNQUERY_MAX_FRAME_BYTES = 60000;
7043
7379
  */
7044
7380
  async function runNamedQueriesOverSocket(appId, queries, isServer) {
7045
7381
  var _a;
7046
- const connection = connections.get(appId);
7047
- if (!connection || !connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN || connection.isAuthenticating) {
7382
+ // Any warm shard of this app's pool will do: a query is not bound to the
7383
+ // socket a subscription happens to live on.
7384
+ const connection = findShardConnection(appId, isWarmConnection);
7385
+ if (!connection) {
7048
7386
  return null; // no warm socket -> HTTP (never open one just for queries)
7049
7387
  }
7050
7388
  if (!((_a = connection.capabilities) === null || _a === void 0 ? void 0 : _a.runQuery)) {
@@ -7094,9 +7432,8 @@ async function runNamedQueriesOverSocket(appId, queries, isServer) {
7094
7432
  */
7095
7433
  async function wsIntent(appId, roomRoutePath, intent, isServer) {
7096
7434
  const roomKey = roomKeyFromRoutePath(roomRoutePath);
7097
- const connKey = roomKey ? `${appId}#room#${roomKey}` : appId;
7098
- const connection = connections.get(connKey);
7099
- if (!connection || !connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN || connection.isAuthenticating) {
7435
+ const connection = findShardConnection(roomKey ? `${appId}#room#${roomKey}` : appId, isWarmConnection);
7436
+ if (!connection) {
7100
7437
  return false;
7101
7438
  }
7102
7439
  // Identity-aware send gate. The server attributes intents to the
@@ -7126,6 +7463,9 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
7126
7463
  return false;
7127
7464
  }
7128
7465
  try {
7466
+ // The identity gate above awaits, so the socket is re-read here; a
7467
+ // teardown in that window throws and is reported as not-sent, exactly as
7468
+ // wsIntentReliable does.
7129
7469
  connection.ws.send(JSON.stringify({ type: 'intent', intent }));
7130
7470
  return true;
7131
7471
  }
@@ -7163,9 +7503,8 @@ async function maybeReauthRoomConnection(connection, isServer) {
7163
7503
  */
7164
7504
  async function wsIntentReliable(appId, roomRoutePath, intent, isServer) {
7165
7505
  const roomKey = roomKeyFromRoutePath(roomRoutePath);
7166
- const connKey = roomKey ? `${appId}#room#${roomKey}` : appId;
7167
- const connection = connections.get(connKey);
7168
- if (!connection || !connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN || connection.isAuthenticating) {
7506
+ const connection = findShardConnection(roomKey ? `${appId}#room#${roomKey}` : appId, isWarmConnection);
7507
+ if (!connection) {
7169
7508
  return undefined;
7170
7509
  }
7171
7510
  if (!(await canSendAuthenticatedLiveIntent(connection, isServer))) {
@@ -8419,7 +8758,7 @@ async function setMany(many, options) {
8419
8758
  'Nothing was signed or submitted.');
8420
8759
  }
8421
8760
  const solanaChain = workerChain;
8422
- const { handlePreBuiltTransaction, buildExpectedWriteIntent } = await Promise.resolve().then(function () { return require('./solana-operations-DJQDYdrk.js'); });
8761
+ const { handlePreBuiltTransaction, buildExpectedWriteIntent } = await Promise.resolve().then(function () { return require('./solana-operations-CU0YbiMu.js'); });
8423
8762
  // OA-0001: derive the expected intent from OUR OWN request (the one
8424
8763
  // ground truth a tampered builder response cannot change) so the
8425
8764
  // validator can reject any transaction whose operation semantics
@@ -8783,7 +9122,7 @@ async function getTransactionJson(rpc, signature, commitment = 'confirmed') {
8783
9122
  }
8784
9123
 
8785
9124
  const DEFAULT_TIMEOUT_MS = 60000;
8786
- const DEFAULT_MAX_ATTEMPTS = 20;
9125
+ const DEFAULT_MAX_ATTEMPTS$1 = 20;
8787
9126
  const DEFAULT_POLL_INTERVAL_MS = 3000;
8788
9127
  /** Mirror read after a late confirmation. No settle delay: the tx already landed. */
8789
9128
  const RECONCILE_MIRROR_SETTLE_MS = 0;
@@ -8990,7 +9329,7 @@ async function waitForSetResult(receipt, options) {
8990
9329
  return reconcileSetResult(receipt, options);
8991
9330
  }
8992
9331
  const timeoutMs = (_a = options === null || options === void 0 ? void 0 : options.timeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_TIMEOUT_MS;
8993
- const maxAttempts = (_b = options === null || options === void 0 ? void 0 : options.maxAttempts) !== null && _b !== void 0 ? _b : DEFAULT_MAX_ATTEMPTS;
9332
+ const maxAttempts = (_b = options === null || options === void 0 ? void 0 : options.maxAttempts) !== null && _b !== void 0 ? _b : DEFAULT_MAX_ATTEMPTS$1;
8994
9333
  const pollIntervalMs = (_c = options === null || options === void 0 ? void 0 : options.pollIntervalMs) !== null && _c !== void 0 ? _c : DEFAULT_POLL_INTERVAL_MS;
8995
9334
  const startedAt = Date.now();
8996
9335
  let current = receipt;
@@ -9108,72 +9447,95 @@ async function reconnectWithNewAuth() {
9108
9447
  return reconnectWithNewAuthV2();
9109
9448
  }
9110
9449
 
9111
- /**
9112
- * Scoped multi-app client factory.
9113
- *
9114
- * `createClient()` returns an isolated, READ-ONLY client bound to one Bounded
9115
- * app, independent of the module-global `init()` client. It exists so a single
9116
- * frontend can hold its normal authenticated client for its own app AND a
9117
- * read-only client for a SECOND Bounded app (e.g. a shared steward/runtime app),
9118
- * with live subscriptions on both and zero interference between them.
9119
- *
9120
- * Each instance carries its OWN fully-resolved config (endpoints + appId) and
9121
- * routes through the SAME shared transport/subscription implementation as the
9122
- * global path - it never forks that code. Isolation comes from threading the
9123
- * instance config (and a read-only marker) through the existing per-request /
9124
- * per-subscription override channel, plus the websocket layer already keying
9125
- * connections by appId.
9126
- *
9127
- * ## v1 scope: read-only, unauthenticated
9128
- *
9129
- * A scoped instance is UNAUTHENTICATED. It sends no auth, never reads or
9130
- * refreshes the global session, and connects to its app's realtime endpoint
9131
- * anonymously. It is therefore only useful against collections a Bounded app
9132
- * exposes with a public read rule (`read: true`) - private/per-user reads will
9133
- * be denied by the target app's policy, exactly as an anonymous caller would be.
9134
- *
9135
- * Writes and any auth-requiring surface are NOT supported on a scoped instance
9136
- * and throw a clear error. Per-instance authentication is a planned follow-up.
9137
- */
9138
- /** Error thrown when an unsupported (write / auth-requiring) call is made on a
9139
- * read-only scoped client. */
9450
+ var __rest$1 = (undefined && undefined.__rest) || function (s, e) {
9451
+ var t = {};
9452
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
9453
+ t[p] = s[p];
9454
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
9455
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
9456
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9457
+ t[p[i]] = s[p[i]];
9458
+ }
9459
+ return t;
9460
+ };
9461
+ /** Error thrown when an unsupported (write) call is made on a scoped client.
9462
+ * Reads may be authenticated per instance; writes still are not supported. */
9140
9463
  class ScopedClientReadOnlyError extends Error {
9141
9464
  constructor(operation) {
9142
- super(`createClient() instances are read-only in v1: "${operation}" is not supported. ` +
9143
- `Use the global init() client for authenticated writes, or read only public ` +
9144
- `(read: true) collections on this scoped instance.`);
9465
+ super(`createClient() instances are read-only: "${operation}" is not supported. ` +
9466
+ `Use the global init() client for authenticated writes, or read public ` +
9467
+ `(read: true) collections - and, with getAuthHeaders, this credential's own ` +
9468
+ `readable collections - on this scoped instance.`);
9145
9469
  this.name = 'ScopedClientReadOnlyError';
9146
9470
  }
9147
9471
  }
9148
9472
  /**
9149
- * Create a read-only client scoped to a single Bounded app.
9473
+ * Create a read-only client scoped to a single Bounded app, anonymous by default
9474
+ * and authenticated when given `getAuthHeaders`.
9150
9475
  *
9151
9476
  * @example
9152
9477
  * ```ts
9153
9478
  * // Global client stays the venue's own authenticated app:
9154
9479
  * await init({ appId: VENUE_APP, network: 'bounded-production', authMethod: 'email' });
9155
9480
  *
9156
- * // A second, read-only client for the steward runtime app:
9481
+ * // A second, anonymous client for the steward runtime app's public collections:
9157
9482
  * const steward = createClient({ appId: STEWARD_APP, network: 'bounded-production' });
9158
9483
  * const state = await steward.get('runtime/current');
9159
9484
  * const stop = await steward.subscribe('runtime/current', (d) => render(d));
9485
+ *
9486
+ * // The same second app, read AS THE SIGNED-IN HUMAN: the venue session is
9487
+ * // exchanged for a token pinned to the child app (see createAppTokenAuthHeaders).
9488
+ * const mine = createClient({
9489
+ * appId: ROOT_APP,
9490
+ * network: 'bounded-production',
9491
+ * getAuthHeaders: createAppTokenAuthHeaders({
9492
+ * authApiUrl: HUMAN_AUTH_URL,
9493
+ * appId: ROOT_APP,
9494
+ * parentToken: () => getIdToken(),
9495
+ * }),
9496
+ * });
9497
+ * const stopChat = await mine.subscribe(`conversations/${id}/m`, (d) => render(d));
9160
9498
  * ```
9161
9499
  */
9162
9500
  function createClient(config) {
9163
9501
  if (!config || !config.appId) {
9164
9502
  throw new Error('createClient requires an appId.');
9165
9503
  }
9504
+ // The credential source is an instance concern, not a ClientConfig field: keep
9505
+ // it out of the resolved config so it can never leak through getConfig() or the
9506
+ // `_config` override channel.
9507
+ const { getAuthHeaders } = config, clientFields = __rest$1(config, ["getAuthHeaders"]);
9166
9508
  // Resolve from the PRISTINE defaults (never the mutated global) so a scoped
9167
9509
  // instance is fully independent of any prior init(). Same precedence as init:
9168
9510
  // defaults < network preset < explicit fields.
9169
- const resolved = resolveClientConfig(DEFAULT_CLIENT_CONFIG, config);
9511
+ const resolved = resolveClientConfig(DEFAULT_CLIENT_CONFIG, clientFields);
9512
+ // Normalize the provider once. Downstream (api.ts, operations.ts,
9513
+ // subscription-v2.ts) consumes the same `_getAuthHeaders` shape a server wallet
9514
+ // client uses, so a null/undefined return becomes "no headers" here rather than
9515
+ // becoming a special case in three transports.
9516
+ const authHeaders = getAuthHeaders
9517
+ ? async () => { var _a; return (_a = (await getAuthHeaders())) !== null && _a !== void 0 ? _a : {}; }
9518
+ : undefined;
9519
+ /** True when the provider currently yields an Authorization header. */
9520
+ const hasCredential = async () => {
9521
+ if (!authHeaders)
9522
+ return false;
9523
+ const headers = await authHeaders();
9524
+ return Object.keys(headers).some((name) => name.toLowerCase() === 'authorization');
9525
+ };
9170
9526
  // The override channel that carries this instance's identity through the
9171
9527
  // shared transport and subscription code. `_config` retargets endpoints +
9172
- // appId; `_readOnly` omits auth and blocks ambient-session/global-cache reads.
9173
- const instanceOverrides = () => ({ _config: resolved, _readOnly: true });
9528
+ // appId. Then either `_getAuthHeaders` (this instance's credential is the SOLE
9529
+ // auth source - never the ambient session, and never a managed-credential 401
9530
+ // recovery that could sign another principal out) or `_readOnly` (omit auth and
9531
+ // block ambient-session/global-cache reads).
9532
+ const instanceOverrides = () => authHeaders
9533
+ ? { _config: resolved, _getAuthHeaders: authHeaders }
9534
+ : { _config: resolved, _readOnly: true };
9174
9535
  // Merge caller-supplied opts with the instance overrides. The instance
9175
- // `_config`/`_readOnly` are applied LAST so app code can never override the
9176
- // routing/identity of a scoped call (but may still pass e.g. an AbortSignal).
9536
+ // `_config` plus its `_getAuthHeaders`/`_readOnly` are applied LAST so app code
9537
+ // can never override the routing/identity of a scoped call (but may still pass
9538
+ // e.g. an AbortSignal).
9177
9539
  const withOverrides = (opts) => {
9178
9540
  var _a;
9179
9541
  const base = (opts !== null && opts !== void 0 ? opts : {});
@@ -9181,6 +9543,7 @@ function createClient(config) {
9181
9543
  };
9182
9544
  return {
9183
9545
  appId: resolved.appId,
9546
+ authenticated: !!authHeaders,
9184
9547
  getConfig: () => (Object.assign({}, resolved)),
9185
9548
  get: (path, opts) => get(path, withOverrides(opts)),
9186
9549
  getMany: (paths, opts) => getMany(paths, withOverrides(opts)),
@@ -9190,11 +9553,19 @@ function createClient(config) {
9190
9553
  aggregate: (path, operation, opts) => aggregate(path, operation, withOverrides(opts)),
9191
9554
  runQuery: (absolutePath, queryName, queryArgs, opts) => runQuery(absolutePath, queryName, queryArgs, withOverrides(opts)),
9192
9555
  runQueryMany: (many, opts) => runQueryMany(many, withOverrides(opts)),
9193
- subscribe: (path, options) => {
9194
- var _a;
9556
+ subscribe: async (path, options) => {
9557
+ var _a, _b;
9195
9558
  const opts = typeof options === 'function' ? { onData: options } : Object.assign({}, options);
9196
- // Force this instance's scoped, read-only routing onto the subscription.
9197
- opts._overrides = Object.assign(Object.assign({}, ((_a = opts._overrides) !== null && _a !== void 0 ? _a : {})), { _config: resolved, _readOnly: true });
9559
+ // Force this instance's scoped routing onto the subscription. An
9560
+ // authenticated instance threads its credential provider, which gives the
9561
+ // subscription its own connection keyed by that exact credential; with no
9562
+ // credential right now it subscribes anonymously (the `#ro` socket) instead
9563
+ // of failing, so a signed-out page still gets public data. This is resolved
9564
+ // per subscribe, so the socket a subscription lands on always matches the
9565
+ // credential it was authorized with - including after a shard re-home,
9566
+ // which rebuilds the same routing + auth arguments from the connection.
9567
+ opts._overrides = (await hasCredential())
9568
+ ? Object.assign(Object.assign({}, ((_a = opts._overrides) !== null && _a !== void 0 ? _a : {})), { _config: resolved, _getAuthHeaders: authHeaders }) : Object.assign(Object.assign({}, ((_b = opts._overrides) !== null && _b !== void 0 ? _b : {})), { _config: resolved, _readOnly: true });
9198
9569
  return subscribe(path, opts);
9199
9570
  },
9200
9571
  set: () => { throw new ScopedClientReadOnlyError('set'); },
@@ -9202,6 +9573,264 @@ function createClient(config) {
9202
9573
  };
9203
9574
  }
9204
9575
 
9576
+ /**
9577
+ * Second-app tokens: exchange a signed-in app's id token for one pinned to a
9578
+ * DIFFERENT Bounded app the platform records as its child.
9579
+ *
9580
+ * WHY. A browser signed in to app A holds an id token pinned to A
9581
+ * (`custom:appId`), and the realtime data plane refuses it for app B
9582
+ * (`app_mismatch`). When B is a child app of A - an OpenApps agent root under its
9583
+ * venue - the Bounded Auth issuer will mint a short-lived, B-pinned token for the
9584
+ * SAME human at `POST {authApiUrl}/app-token/exchange`. The issuer, not this
9585
+ * helper, decides whether the relationship holds; it reads it from the platform's
9586
+ * own records and refuses anything else. `custom:userId` of the minted token is
9587
+ * the presenting token's own, so B sees the same principal A does.
9588
+ *
9589
+ * WHAT THIS ADDS over a bare POST, all of it in memory and per process:
9590
+ * - a cache keyed by (exact parent credential, child appId), so one exchange
9591
+ * serves every read and socket for that pair;
9592
+ * - refresh shortly BEFORE expiry, driven by use rather than a timer, so no
9593
+ * interval keeps a process alive and no request ever carries a token that
9594
+ * expires in flight;
9595
+ * - single-flight, so N components mounting at once make ONE exchange;
9596
+ * - BOUNDED retry with backoff and a recorded give-up. A 429 stops the schedule
9597
+ * immediately rather than feeding it, and so does any other definitive
9598
+ * refusal. There is no unbounded retry loop anywhere in this module;
9599
+ * - invalidation on sign-out and identity change: the cache key IS the exact
9600
+ * parent credential (hashed), so a new session cannot read the old session's
9601
+ * entry, and `clearAppTokenCache()` drops entries outright.
9602
+ *
9603
+ * The parent token is treated as OPAQUE credential material for keying: it is
9604
+ * hashed, never decoded for identity, and never logged.
9605
+ */
9606
+ /** Thrown when an exchange stops trying. `giveUp` carries the recorded reason. */
9607
+ class AppTokenExchangeError extends Error {
9608
+ constructor(giveUp) {
9609
+ super(`app token exchange for ${giveUp.appId} gave up after ${giveUp.attempts} attempt(s): ${giveUp.reason}`);
9610
+ this.name = 'AppTokenExchangeError';
9611
+ this.giveUp = giveUp;
9612
+ }
9613
+ }
9614
+ /** Re-exchange once the remaining life drops under this. */
9615
+ const REFRESH_SKEW_MS = 60000;
9616
+ /** Floor for a response that reports no usable expiry of its own. */
9617
+ const FALLBACK_LIFETIME_MS = 5 * 60000;
9618
+ const DEFAULT_MAX_ATTEMPTS = 3;
9619
+ const MAX_ATTEMPTS_CEILING = 5;
9620
+ const FIRST_RETRY_DELAY_MS = 300;
9621
+ const MAX_RETRY_DELAY_MS = 2000;
9622
+ const cache = new Map();
9623
+ const inFlight = new Map();
9624
+ /** Opaque, stable key material for a credential we must never decode or log. */
9625
+ function fingerprint(value) {
9626
+ let hash = 5381;
9627
+ for (let index = 0; index < value.length; index++) {
9628
+ hash = ((hash << 5) + hash + value.charCodeAt(index)) >>> 0;
9629
+ }
9630
+ return hash.toString(16);
9631
+ }
9632
+ function cacheKey(appId, parentToken) {
9633
+ return `${appId}:${fingerprint(parentToken)}`;
9634
+ }
9635
+ function usable(entry, nowMs) {
9636
+ return !!entry && entry.expiresAtMs - REFRESH_SKEW_MS > nowMs;
9637
+ }
9638
+ /** Cache entries above which a lookup also drops the expired ones. */
9639
+ const PRUNE_THRESHOLD = 32;
9640
+ /**
9641
+ * Drop expired entries so a long-lived process (SSR, React Native) that sees a
9642
+ * new parent credential every rotation does not grow this map forever. Keyed by
9643
+ * the exact credential, entries are otherwise only replaced by a same-key refresh.
9644
+ */
9645
+ function pruneExpired(nowMs) {
9646
+ if (cache.size <= PRUNE_THRESHOLD)
9647
+ return;
9648
+ for (const [key, entry] of Array.from(cache.entries())) {
9649
+ if (entry.expiresAtMs <= nowMs)
9650
+ cache.delete(key);
9651
+ }
9652
+ }
9653
+ function exchangeUrl(authApiUrl) {
9654
+ return `${authApiUrl.replace(/\/+$/, '')}/app-token/exchange`;
9655
+ }
9656
+ /**
9657
+ * When the minted token stops being usable: the issuer's own `expiresAt`, else
9658
+ * the token's `exp`, else a short floor for a token that carries neither.
9659
+ *
9660
+ * Returns null when both stated expiries are already in the PAST. Caching a dead
9661
+ * credential under an invented lifetime would make every request fail for that
9662
+ * long, so an expired mint is a give-up instead.
9663
+ */
9664
+ function expiryFromResponse(body, token, nowMs) {
9665
+ const reported = Number(body.expiresAt);
9666
+ if (Number.isFinite(reported))
9667
+ return reported > nowMs ? reported : null;
9668
+ try {
9669
+ const payload = JSON.parse(decodeBase64Url(token.split('.')[1]));
9670
+ const exp = Number(payload.exp);
9671
+ if (Number.isFinite(exp))
9672
+ return exp * 1000 > nowMs ? exp * 1000 : null;
9673
+ }
9674
+ catch (_a) {
9675
+ /* an opaque token is fine; fall through to the floor */
9676
+ }
9677
+ return nowMs + FALLBACK_LIFETIME_MS;
9678
+ }
9679
+ /** 5xx and transport faults are worth one more try; nothing else is. */
9680
+ function retriable(status) {
9681
+ return status === null || (status >= 500 && status !== 501);
9682
+ }
9683
+ function delay(ms) {
9684
+ return new Promise((resolve) => setTimeout(resolve, ms));
9685
+ }
9686
+ function recordGiveUp(options, giveUp) {
9687
+ var _a;
9688
+ const report = (_a = options.onGiveUp) !== null && _a !== void 0 ? _a : ((info) => console.warn('[bounded] app token exchange gave up', info));
9689
+ try {
9690
+ report(giveUp);
9691
+ }
9692
+ catch (_b) {
9693
+ /* a reporting fault must not replace the exchange failure */
9694
+ }
9695
+ return new AppTokenExchangeError(giveUp);
9696
+ }
9697
+ async function performExchange(options) {
9698
+ var _a, _b;
9699
+ const attemptCeiling = Math.max(1, Math.min(MAX_ATTEMPTS_CEILING, (_a = options.maxAttempts) !== null && _a !== void 0 ? _a : DEFAULT_MAX_ATTEMPTS));
9700
+ const doFetch = (_b = options.fetchImpl) !== null && _b !== void 0 ? _b : fetch;
9701
+ let retryDelay = FIRST_RETRY_DELAY_MS;
9702
+ let lastStatus = null;
9703
+ let lastReason = 'unknown';
9704
+ for (let attempt = 1; attempt <= attemptCeiling; attempt++) {
9705
+ let status = null;
9706
+ let reason = 'transport_failed';
9707
+ try {
9708
+ const response = await doFetch(exchangeUrl(options.authApiUrl), {
9709
+ method: 'POST',
9710
+ headers: { 'Content-Type': 'application/json' },
9711
+ body: JSON.stringify({ idToken: options.parentToken, appId: options.appId }),
9712
+ });
9713
+ status = response.status;
9714
+ const body = await response.json().catch(() => ({}));
9715
+ if (response.ok) {
9716
+ const token = typeof body.idToken === 'string' ? body.idToken : '';
9717
+ if (!token) {
9718
+ throw recordGiveUp(options, { appId: options.appId, status, reason: 'no_token_in_response', attempts: attempt });
9719
+ }
9720
+ const nowMs = Date.now();
9721
+ const expiresAtMs = expiryFromResponse(body, token, nowMs);
9722
+ if (expiresAtMs === null) {
9723
+ throw recordGiveUp(options, { appId: options.appId, status, reason: 'expired_token_in_response', attempts: attempt });
9724
+ }
9725
+ return { token, expiresAtMs, appId: options.appId };
9726
+ }
9727
+ reason = typeof body.error === 'string' && body.error ? body.error : `http_${status}`;
9728
+ }
9729
+ catch (error) {
9730
+ // A give-up raised above is already final.
9731
+ if (error instanceof AppTokenExchangeError)
9732
+ throw error;
9733
+ status = null;
9734
+ reason = 'transport_failed';
9735
+ }
9736
+ lastStatus = status;
9737
+ lastReason = reason;
9738
+ // A 429 is the issuer telling us to stop. It stops the schedule; it never
9739
+ // feeds it. Every other definitive refusal stops too.
9740
+ if (!retriable(status) || attempt === attemptCeiling) {
9741
+ throw recordGiveUp(options, { appId: options.appId, status, reason, attempts: attempt });
9742
+ }
9743
+ await delay(retryDelay);
9744
+ retryDelay = Math.min(MAX_RETRY_DELAY_MS, retryDelay * 2);
9745
+ }
9746
+ // Unreachable: the loop always returns or throws.
9747
+ throw recordGiveUp(options, { appId: options.appId, status: lastStatus, reason: lastReason, attempts: attemptCeiling });
9748
+ }
9749
+ /**
9750
+ * Get a child-app id token for the human the parent token belongs to, from cache
9751
+ * when one is still comfortably fresh and otherwise by exchanging once.
9752
+ *
9753
+ * Concurrent callers for the same (parent credential, appId) share one exchange.
9754
+ * On give-up this rejects with {@link AppTokenExchangeError} and caches nothing.
9755
+ */
9756
+ async function exchangeAppToken(options) {
9757
+ if (!(options === null || options === void 0 ? void 0 : options.authApiUrl))
9758
+ throw new Error('exchangeAppToken requires an authApiUrl.');
9759
+ if (!(options === null || options === void 0 ? void 0 : options.appId))
9760
+ throw new Error('exchangeAppToken requires an appId.');
9761
+ if (!(options === null || options === void 0 ? void 0 : options.parentToken))
9762
+ throw new Error('exchangeAppToken requires a parentToken.');
9763
+ const key = cacheKey(options.appId, options.parentToken);
9764
+ pruneExpired(Date.now());
9765
+ const cached = cache.get(key);
9766
+ if (usable(cached, Date.now()))
9767
+ return cached;
9768
+ // Past the refresh skew the entry is no longer served; drop it so a failed
9769
+ // refresh can never fall back to a token that is about to expire mid-request.
9770
+ if (cached)
9771
+ cache.delete(key);
9772
+ const active = inFlight.get(key);
9773
+ if (active)
9774
+ return active;
9775
+ const flight = performExchange(options).then((token) => {
9776
+ cache.set(key, token);
9777
+ return token;
9778
+ }).finally(() => {
9779
+ if (inFlight.get(key) === flight)
9780
+ inFlight.delete(key);
9781
+ });
9782
+ inFlight.set(key, flight);
9783
+ return flight;
9784
+ }
9785
+ /**
9786
+ * Drop cached child-app tokens. Call this on sign-out; an identity change needs
9787
+ * no call, because the cache key is the exact parent credential.
9788
+ *
9789
+ * With no argument every entry goes. `appId` narrows to one child app, and
9790
+ * `parentToken` narrows to one session's entries.
9791
+ */
9792
+ function clearAppTokenCache(scope) {
9793
+ if (!scope || (!scope.appId && !scope.parentToken)) {
9794
+ cache.clear();
9795
+ inFlight.clear();
9796
+ return;
9797
+ }
9798
+ if (scope.appId && scope.parentToken) {
9799
+ const key = cacheKey(scope.appId, scope.parentToken);
9800
+ cache.delete(key);
9801
+ inFlight.delete(key);
9802
+ return;
9803
+ }
9804
+ const prefix = scope.appId ? `${scope.appId}:` : null;
9805
+ const suffix = scope.parentToken ? `:${fingerprint(scope.parentToken)}` : null;
9806
+ for (const key of Array.from(cache.keys())) {
9807
+ if ((prefix && key.startsWith(prefix)) || (suffix && key.endsWith(suffix)))
9808
+ cache.delete(key);
9809
+ }
9810
+ for (const key of Array.from(inFlight.keys())) {
9811
+ if ((prefix && key.startsWith(prefix)) || (suffix && key.endsWith(suffix)))
9812
+ inFlight.delete(key);
9813
+ }
9814
+ }
9815
+ /**
9816
+ * Build the `getAuthHeaders` a scoped `createClient({ appId, getAuthHeaders })`
9817
+ * instance authenticates with, backed by {@link exchangeAppToken}.
9818
+ *
9819
+ * Signed out (`parentToken()` yields nothing) returns NO headers, which leaves
9820
+ * the scoped instance anonymous - the public read lane - instead of failing. A
9821
+ * give-up on the exchange itself rejects, so a broken exchange surfaces as an
9822
+ * error rather than as silently unauthenticated reads.
9823
+ */
9824
+ function createAppTokenAuthHeaders(options) {
9825
+ return async () => {
9826
+ const parentToken = await options.parentToken();
9827
+ if (!parentToken)
9828
+ return {};
9829
+ const minted = await exchangeAppToken(Object.assign(Object.assign({}, options), { parentToken }));
9830
+ return { Authorization: `Bearer ${minted.token}` };
9831
+ };
9832
+ }
9833
+
9205
9834
  /**
9206
9835
  * Live named-query subscriptions (subscribeQuery) - HTTP client poller (v1).
9207
9836
  *
@@ -9904,7 +10533,7 @@ function toMillis(seconds) {
9904
10533
 
9905
10534
  /** SIWS message creation is async already, so its onchain module can be lazy. */
9906
10535
  async function genSolanaMessage(address, nonce) {
9907
- const implementation = await Promise.resolve().then(function () { return require('./solana-message-w2FxOkFA.js'); });
10536
+ const implementation = await Promise.resolve().then(function () { return require('./solana-message-CJTnYiJu.js'); });
9908
10537
  return implementation.genSolanaMessage(address, nonce);
9909
10538
  }
9910
10539
 
@@ -10455,6 +11084,7 @@ function defineLiveModule(mod) {
10455
11084
 
10456
11085
  exports.AUTH_CHANGED_CODE = AUTH_CHANGED_CODE;
10457
11086
  exports.AUTH_EXPIRED_CODE = AUTH_EXPIRED_CODE;
11087
+ exports.AppTokenExchangeError = AppTokenExchangeError;
10458
11088
  exports.BoundedDeclineError = BoundedDeclineError;
10459
11089
  exports.DECLINE_EVENT_NAME = DECLINE_EVENT_NAME;
10460
11090
  exports.DEFAULT_QUERY_POLL_INTERVAL_MS = DEFAULT_QUERY_POLL_INTERVAL_MS;
@@ -10477,10 +11107,12 @@ exports.browserSignInDomain = browserSignInDomain;
10477
11107
  exports.bufferExports = bufferExports;
10478
11108
  exports.canonicalizeArgs = canonicalizeArgs;
10479
11109
  exports.canonicalizeResult = canonicalizeResult;
11110
+ exports.clearAppTokenCache = clearAppTokenCache;
10480
11111
  exports.clearCache = clearCache;
10481
11112
  exports.closeAllQuerySubscriptions = closeAllQuerySubscriptions;
10482
11113
  exports.closeAllSubscriptions = closeAllSubscriptions;
10483
11114
  exports.count = count;
11115
+ exports.createAppTokenAuthHeaders = createAppTokenAuthHeaders;
10484
11116
  exports.createClient = createClient;
10485
11117
  exports.createSessionWithSignature = createSessionWithSignature;
10486
11118
  exports.createSolanaSignInMessageText = createSolanaSignInMessageText;
@@ -10491,6 +11123,7 @@ exports.deriveUserIdentityFromIdToken = deriveUserIdentityFromIdToken;
10491
11123
  exports.dispatchDeclineEvent = dispatchDeclineEvent;
10492
11124
  exports.dispatchRateLimitEvent = dispatchRateLimitEvent;
10493
11125
  exports.docId = docId;
11126
+ exports.exchangeAppToken = exchangeAppToken;
10494
11127
  exports.functions = functions;
10495
11128
  exports.genAuthNonce = genAuthNonce;
10496
11129
  exports.genSiweMessage = genSiweMessage;
@@ -10547,4 +11180,4 @@ exports.toMillis = toMillis;
10547
11180
  exports.toSeconds = toSeconds;
10548
11181
  exports.waitForSetResult = waitForSetResult;
10549
11182
  exports.withEffects = withEffects;
10550
- //# sourceMappingURL=index-B8PpRj6x.js.map
11183
+ //# sourceMappingURL=index-BEGeDFQI.js.map