@securecoms/agent-bridge 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1371,6 +1371,24 @@ var MlsOperationQueue = class {
1371
1371
  }
1372
1372
  };
1373
1373
 
1374
+ // ../connect-sdk-ts/dist/groupkey/receive-classifier.js
1375
+ function classifyFailedRow(i) {
1376
+ if (i.messageType === "welcome")
1377
+ return "defer";
1378
+ if (i.messageType !== "commit" && i.messageType !== "application")
1379
+ return "defer";
1380
+ if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
1381
+ const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
1382
+ if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
1383
+ return "defer";
1384
+ const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
1385
+ return dead ? "permanentlySkipped" : "defer";
1386
+ }
1387
+ if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
1388
+ return "retiredDrained";
1389
+ return "defer";
1390
+ }
1391
+
1374
1392
  // ../connect-sdk-ts/dist/groupkey/mls.js
1375
1393
  var LABEL = "agentvault-connect-v1";
1376
1394
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1487,6 +1505,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1487
1505
  * the past-epoch {@link permanentlySkipped} drain. Read via
1488
1506
  * {@link quarantineDrainedCount}. */
1489
1507
  quarantineDrained = 0;
1508
+ /** Cumulative count of rows ack-drained because their group_id is RETIRED for
1509
+ * the channel (superseded fork-loser traffic). Distinct from the same-group
1510
+ * past-epoch {@link permanentlySkipped} drain. Read via
1511
+ * {@link retiredDrainedCount}. */
1512
+ retiredDrained = 0;
1490
1513
  constructor(o) {
1491
1514
  this.o = o;
1492
1515
  this.suite = o.suite ?? "xchacha20poly1305";
@@ -1644,6 +1667,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1644
1667
  quarantineDrainedCount() {
1645
1668
  return this.quarantineDrained;
1646
1669
  }
1670
+ /** See {@link retiredDrained}. */
1671
+ retiredDrainedCount() {
1672
+ return this.retiredDrained;
1673
+ }
1647
1674
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1648
1675
  * device leaves or deletes a channel: dropping the persisted store record is
1649
1676
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1712,6 +1739,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1712
1739
  if (cached)
1713
1740
  return cached;
1714
1741
  const saved = await this.o.store.load(channelId);
1742
+ const again = this.managers.get(channelId);
1743
+ if (again)
1744
+ return again;
1715
1745
  if (!saved)
1716
1746
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1717
1747
  const mgr = new MLSGroupManager(saved.ciphersuite);
@@ -2271,9 +2301,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2271
2301
  * should delegate to it.
2272
2302
  *
2273
2303
  * Returns null rather than throwing so every caller must decide explicitly what
2274
- * an unknown own-leaf means. The one caller today FAILS CLOSED on null by
2275
- * falling back to the positional path only for FOREIGN identities — it never
2276
- * uses null to justify guessing among an own identity's leaves.
2304
+ * an unknown own-leaf means.
2305
+ *
2306
+ * ⚠️ The one caller today does NOT fail closed on null. In
2307
+ * {@link removeDeviceFromLiveGroupUnlocked} the branch is
2308
+ * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
2309
+ * identities at all, so a null return sends an OWN-identity removal down the
2310
+ * positional `leaves[pos]` path, which is exactly the add-order guess the
2311
+ * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
2312
+ * permutes that mapping after any Remove). Null is therefore currently
2313
+ * indistinguishable from "foreign identity", and only the cardinality guard
2314
+ * stands between it and evicting the wrong device of the same human.
2315
+ * Correcting that is Phase 1b, not this comment.
2277
2316
  */
2278
2317
  ownLeafIndex(mgr) {
2279
2318
  const idx = mgr.state?.privatePath?.leafIndex;
@@ -2590,13 +2629,27 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2590
2629
  }
2591
2630
  }
2592
2631
  /**
2593
- * Return the number of leaves the given identity holds in the live tree for
2594
- * `channelId`. Loads the manager from the store if not already cached.
2632
+ * Return the number of leaves the given identity holds in THIS DEVICE'S view
2633
+ * of the ratchet tree for `channelId`. Reads the cached manager, else loads
2634
+ * one from the store; it makes no backend call, so the answer is only as
2635
+ * current as the commits this device has already processed. A device that is
2636
+ * behind, forked, or holding a superseded group answers from that state, not
2637
+ * from the server's.
2638
+ *
2639
+ * ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
2640
+ * all — it does not report 0 for "we have not joined yet". Callers that may
2641
+ * run before {@link ensureSession} must handle the throw; see
2642
+ * {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
2595
2643
  *
2596
2644
  * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2597
- * does NOT create a duplicate leaf for an already-added device. A return value
2598
- * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2599
- * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2645
+ * does NOT create a duplicate leaf for an already-added device. In THAT
2646
+ * scenario each identity owns exactly one device, so 1 is healthy and > 1
2647
+ * means the S3 guard failed to suppress the re-Add. That reading does NOT
2648
+ * generalise: leaf credentials encode the IDENTITY, not the device, so a
2649
+ * multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
2650
+ * premise {@link removeDeviceFromLiveGroupUnlocked}'s
2651
+ * `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
2652
+ * count against the identity's expected device count, not against 1.
2600
2653
  */
2601
2654
  async leafCountForIdentity(channelId, identityId) {
2602
2655
  const mgr = await this.manager(channelId);
@@ -2757,16 +2810,45 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2757
2810
  }
2758
2811
  async receiveOnce(deviceId) {
2759
2812
  const rows = await this.o.backend.pullDelivery(deviceId);
2760
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2761
- console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2762
- }
2763
2813
  const appEntries = [];
2764
2814
  const handshakeAcked = [];
2765
2815
  const supersededChannelIds = [];
2766
- const order = { welcome: 0, commit: 1, application: 2 };
2767
- rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2768
- for (const row of rows) {
2816
+ const welcomes = rows.filter((r) => r.message_type === "welcome");
2817
+ const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
2818
+ const ordered = [...welcomes, ...nonWelcomes];
2819
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
2820
+ console.warn(`[trace] receiveOnce pulled ${ordered.length}: ` + ordered.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2821
+ }
2822
+ const classCtx = /* @__PURE__ */ new Map();
2823
+ const classCtxFor = async (cid) => {
2824
+ let ctx = classCtx.get(cid);
2825
+ if (!ctx) {
2826
+ let heldGroupId = null;
2827
+ let snapshotEpoch;
2828
+ let retired = null;
2829
+ try {
2830
+ const mgr = await this.manager(cid);
2831
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2832
+ snapshotEpoch = Number(mgr.epoch);
2833
+ } catch {
2834
+ heldGroupId = null;
2835
+ snapshotEpoch = void 0;
2836
+ }
2837
+ try {
2838
+ retired = await this.o.store.loadRetiredGroupIds(cid);
2839
+ } catch {
2840
+ retired = null;
2841
+ }
2842
+ ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
2843
+ classCtx.set(cid, ctx);
2844
+ }
2845
+ return ctx;
2846
+ };
2847
+ for (const row of ordered) {
2769
2848
  try {
2849
+ if (row.message_type !== "welcome" && row.connect_channel_id) {
2850
+ await classCtxFor(row.connect_channel_id);
2851
+ }
2770
2852
  if (row.message_type === "welcome") {
2771
2853
  const wcid = row.connect_channel_id;
2772
2854
  if (wcid) {
@@ -2803,15 +2885,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2803
2885
  const cid = row.connect_channel_id;
2804
2886
  if (!cid)
2805
2887
  continue;
2888
+ let advancedEpoch;
2806
2889
  await this.locks.run(cid, async () => {
2807
2890
  const mgr = await this.manager(cid);
2808
2891
  await mgr.decrypt(hexToBytes(row.payload));
2809
2892
  await this.o.store.save(cid, mgr.exportState());
2893
+ advancedEpoch = Number(mgr.epoch);
2810
2894
  });
2811
2895
  this.recordDecryptSuccess(cid);
2896
+ if (advancedEpoch !== void 0) {
2897
+ const ctx = classCtx.get(cid);
2898
+ if (ctx)
2899
+ ctx.liveCommitEpoch = advancedEpoch;
2900
+ }
2812
2901
  handshakeAcked.push(row.queue_id);
2813
2902
  } else {
2814
2903
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
2904
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
2905
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
2906
+ }
2815
2907
  const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2816
2908
  const aad = await buildAssociatedData({
2817
2909
  message_id: envelope.message_id,
@@ -2839,18 +2931,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2839
2931
  this.recordDecryptError(row, err);
2840
2932
  const cid = row.connect_channel_id;
2841
2933
  let drained = false;
2842
- if (cid && typeof row.epoch === "number") {
2843
- let liveEpoch;
2844
- try {
2845
- liveEpoch = await this.currentEpoch(cid);
2846
- } catch {
2847
- liveEpoch = void 0;
2848
- }
2849
- const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2850
- if (permanentlyDead) {
2934
+ if (cid && row.message_type !== "welcome") {
2935
+ const ctx = classCtx.get(cid);
2936
+ const verdict = classifyFailedRow({
2937
+ messageType: row.message_type,
2938
+ rowGroupId: row.group_id,
2939
+ rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
2940
+ heldGroupId: ctx?.heldGroupId ?? null,
2941
+ retiredGroupIds: ctx?.retired ?? null,
2942
+ snapshotEpoch: ctx?.snapshotEpoch,
2943
+ liveCommitEpoch: ctx?.liveCommitEpoch
2944
+ });
2945
+ if (verdict === "permanentlySkipped") {
2851
2946
  handshakeAcked.push(row.queue_id);
2852
2947
  this.permanentlySkipped++;
2853
2948
  drained = true;
2949
+ } else if (verdict === "retiredDrained") {
2950
+ handshakeAcked.push(row.queue_id);
2951
+ this.retiredDrained++;
2952
+ drained = true;
2854
2953
  }
2855
2954
  }
2856
2955
  if (!drained && cid) {
@@ -2858,8 +2957,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2858
2957
  if (e) {
2859
2958
  e.wedgedRun += 1;
2860
2959
  if (e.wedgedRun >= this.quarantineThreshold) {
2861
- handshakeAcked.push(row.queue_id);
2862
- this.quarantineDrained++;
2960
+ if (row.message_type !== "welcome") {
2961
+ handshakeAcked.push(row.queue_id);
2962
+ this.quarantineDrained++;
2963
+ }
2863
2964
  this.quarantineChannel(cid);
2864
2965
  }
2865
2966
  }
@@ -6549,6 +6650,8 @@ var BridgeAgent = class _BridgeAgent {
6549
6650
  filtered_room: this.filteredRoomMessages,
6550
6651
  rate_limited: this.rateLimitedSends,
6551
6652
  permanently_skipped: this.provider.permanentlySkippedCount(),
6653
+ quarantine_drained: this.provider.quarantineDrainedCount(),
6654
+ retired_drained: this.provider.retiredDrainedCount(),
6552
6655
  failed_attachments: this.failedAttachments,
6553
6656
  delivery_pings: this.pingsReceived,
6554
6657
  agent_sessions: this.authSessions,
@@ -6594,8 +6697,11 @@ var BridgeAgent = class _BridgeAgent {
6594
6697
  filteredRoomMessages: this.filteredRoomMessages,
6595
6698
  rateLimitedSends: this.rateLimitedSends,
6596
6699
  permanentlySkipped: this.provider.permanentlySkippedCount(),
6700
+ quarantineDrained: this.provider.quarantineDrainedCount(),
6701
+ retiredDrained: this.provider.retiredDrainedCount(),
6597
6702
  welcomeRequestsSent: this.welcomeRequestsSent,
6598
- failedAttachments: this.failedAttachments
6703
+ failedAttachments: this.failedAttachments,
6704
+ build: BRIDGE_VERSION
6599
6705
  };
6600
6706
  }
6601
6707
  async close() {
@@ -851,6 +851,12 @@ export declare class BridgeAgent {
851
851
  rate_limited: number;
852
852
  /** Cumulative inbound rows acked-and-dropped because they were provably past-epoch undecryptable (forward-secrecy secret gone). */
853
853
  permanently_skipped: number;
854
+ /** SDK-6: cumulative rows ack-drained via QUARANTINE, distinct from the
855
+ * same-group past-epoch permanently_skipped drain. */
856
+ quarantine_drained: number;
857
+ /** SDK-6: cumulative rows ack-drained because their group_id is RETIRED for
858
+ * the channel (superseded fork-loser traffic). */
859
+ retired_drained: number;
854
860
  /** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
855
861
  failed_attachments: number;
856
862
  /** 2026-07-03 latency triage: delivery pings received on the WS stream.
@@ -940,9 +946,19 @@ export declare class BridgeAgent {
940
946
  filteredRoomMessages: number;
941
947
  rateLimitedSends: number;
942
948
  permanentlySkipped: number;
949
+ /** SDK-6: cumulative rows ack-drained via QUARANTINE, distinct from the
950
+ * same-group past-epoch permanentlySkipped drain. */
951
+ quarantineDrained: number;
952
+ /** SDK-6: cumulative rows ack-drained because their group_id is RETIRED for
953
+ * the channel (superseded fork-loser traffic). */
954
+ retiredDrained: number;
943
955
  welcomeRequestsSent: number;
944
956
  /** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
945
957
  failedAttachments: number;
958
+ /** SDK-6: bridge build id, so a /v1/metrics reading captured across a daemon
959
+ * restart can be attributed to a version — counters are process-lifetime
960
+ * and zero on restart (spec §5.5). */
961
+ build: string;
946
962
  };
947
963
  close(): Promise<void>;
948
964
  }
package/dist/index.js CHANGED
@@ -1268,6 +1268,24 @@ var MlsOperationQueue = class {
1268
1268
  }
1269
1269
  };
1270
1270
 
1271
+ // ../connect-sdk-ts/dist/groupkey/receive-classifier.js
1272
+ function classifyFailedRow(i) {
1273
+ if (i.messageType === "welcome")
1274
+ return "defer";
1275
+ if (i.messageType !== "commit" && i.messageType !== "application")
1276
+ return "defer";
1277
+ if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
1278
+ const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
1279
+ if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
1280
+ return "defer";
1281
+ const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
1282
+ return dead ? "permanentlySkipped" : "defer";
1283
+ }
1284
+ if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
1285
+ return "retiredDrained";
1286
+ return "defer";
1287
+ }
1288
+
1271
1289
  // ../connect-sdk-ts/dist/groupkey/mls.js
1272
1290
  var LABEL = "agentvault-connect-v1";
1273
1291
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1384,6 +1402,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1384
1402
  * the past-epoch {@link permanentlySkipped} drain. Read via
1385
1403
  * {@link quarantineDrainedCount}. */
1386
1404
  quarantineDrained = 0;
1405
+ /** Cumulative count of rows ack-drained because their group_id is RETIRED for
1406
+ * the channel (superseded fork-loser traffic). Distinct from the same-group
1407
+ * past-epoch {@link permanentlySkipped} drain. Read via
1408
+ * {@link retiredDrainedCount}. */
1409
+ retiredDrained = 0;
1387
1410
  constructor(o) {
1388
1411
  this.o = o;
1389
1412
  this.suite = o.suite ?? "xchacha20poly1305";
@@ -1541,6 +1564,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1541
1564
  quarantineDrainedCount() {
1542
1565
  return this.quarantineDrained;
1543
1566
  }
1567
+ /** See {@link retiredDrained}. */
1568
+ retiredDrainedCount() {
1569
+ return this.retiredDrained;
1570
+ }
1544
1571
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1545
1572
  * device leaves or deletes a channel: dropping the persisted store record is
1546
1573
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1609,6 +1636,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1609
1636
  if (cached)
1610
1637
  return cached;
1611
1638
  const saved = await this.o.store.load(channelId);
1639
+ const again = this.managers.get(channelId);
1640
+ if (again)
1641
+ return again;
1612
1642
  if (!saved)
1613
1643
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1614
1644
  const mgr = new MLSGroupManager(saved.ciphersuite);
@@ -2168,9 +2198,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2168
2198
  * should delegate to it.
2169
2199
  *
2170
2200
  * Returns null rather than throwing so every caller must decide explicitly what
2171
- * an unknown own-leaf means. The one caller today FAILS CLOSED on null by
2172
- * falling back to the positional path only for FOREIGN identities — it never
2173
- * uses null to justify guessing among an own identity's leaves.
2201
+ * an unknown own-leaf means.
2202
+ *
2203
+ * ⚠️ The one caller today does NOT fail closed on null. In
2204
+ * {@link removeDeviceFromLiveGroupUnlocked} the branch is
2205
+ * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
2206
+ * identities at all, so a null return sends an OWN-identity removal down the
2207
+ * positional `leaves[pos]` path, which is exactly the add-order guess the
2208
+ * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
2209
+ * permutes that mapping after any Remove). Null is therefore currently
2210
+ * indistinguishable from "foreign identity", and only the cardinality guard
2211
+ * stands between it and evicting the wrong device of the same human.
2212
+ * Correcting that is Phase 1b, not this comment.
2174
2213
  */
2175
2214
  ownLeafIndex(mgr) {
2176
2215
  const idx = mgr.state?.privatePath?.leafIndex;
@@ -2487,13 +2526,27 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2487
2526
  }
2488
2527
  }
2489
2528
  /**
2490
- * Return the number of leaves the given identity holds in the live tree for
2491
- * `channelId`. Loads the manager from the store if not already cached.
2529
+ * Return the number of leaves the given identity holds in THIS DEVICE'S view
2530
+ * of the ratchet tree for `channelId`. Reads the cached manager, else loads
2531
+ * one from the store; it makes no backend call, so the answer is only as
2532
+ * current as the commits this device has already processed. A device that is
2533
+ * behind, forked, or holding a superseded group answers from that state, not
2534
+ * from the server's.
2535
+ *
2536
+ * ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
2537
+ * all — it does not report 0 for "we have not joined yet". Callers that may
2538
+ * run before {@link ensureSession} must handle the throw; see
2539
+ * {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
2492
2540
  *
2493
2541
  * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2494
- * does NOT create a duplicate leaf for an already-added device. A return value
2495
- * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2496
- * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2542
+ * does NOT create a duplicate leaf for an already-added device. In THAT
2543
+ * scenario each identity owns exactly one device, so 1 is healthy and > 1
2544
+ * means the S3 guard failed to suppress the re-Add. That reading does NOT
2545
+ * generalise: leaf credentials encode the IDENTITY, not the device, so a
2546
+ * multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
2547
+ * premise {@link removeDeviceFromLiveGroupUnlocked}'s
2548
+ * `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
2549
+ * count against the identity's expected device count, not against 1.
2497
2550
  */
2498
2551
  async leafCountForIdentity(channelId, identityId) {
2499
2552
  const mgr = await this.manager(channelId);
@@ -2654,16 +2707,45 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2654
2707
  }
2655
2708
  async receiveOnce(deviceId) {
2656
2709
  const rows = await this.o.backend.pullDelivery(deviceId);
2657
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2658
- console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2659
- }
2660
2710
  const appEntries = [];
2661
2711
  const handshakeAcked = [];
2662
2712
  const supersededChannelIds = [];
2663
- const order = { welcome: 0, commit: 1, application: 2 };
2664
- rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2665
- for (const row of rows) {
2713
+ const welcomes = rows.filter((r) => r.message_type === "welcome");
2714
+ const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
2715
+ const ordered = [...welcomes, ...nonWelcomes];
2716
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
2717
+ console.warn(`[trace] receiveOnce pulled ${ordered.length}: ` + ordered.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2718
+ }
2719
+ const classCtx = /* @__PURE__ */ new Map();
2720
+ const classCtxFor = async (cid) => {
2721
+ let ctx = classCtx.get(cid);
2722
+ if (!ctx) {
2723
+ let heldGroupId = null;
2724
+ let snapshotEpoch;
2725
+ let retired = null;
2726
+ try {
2727
+ const mgr = await this.manager(cid);
2728
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2729
+ snapshotEpoch = Number(mgr.epoch);
2730
+ } catch {
2731
+ heldGroupId = null;
2732
+ snapshotEpoch = void 0;
2733
+ }
2734
+ try {
2735
+ retired = await this.o.store.loadRetiredGroupIds(cid);
2736
+ } catch {
2737
+ retired = null;
2738
+ }
2739
+ ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
2740
+ classCtx.set(cid, ctx);
2741
+ }
2742
+ return ctx;
2743
+ };
2744
+ for (const row of ordered) {
2666
2745
  try {
2746
+ if (row.message_type !== "welcome" && row.connect_channel_id) {
2747
+ await classCtxFor(row.connect_channel_id);
2748
+ }
2667
2749
  if (row.message_type === "welcome") {
2668
2750
  const wcid = row.connect_channel_id;
2669
2751
  if (wcid) {
@@ -2700,15 +2782,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2700
2782
  const cid = row.connect_channel_id;
2701
2783
  if (!cid)
2702
2784
  continue;
2785
+ let advancedEpoch;
2703
2786
  await this.locks.run(cid, async () => {
2704
2787
  const mgr = await this.manager(cid);
2705
2788
  await mgr.decrypt(hexToBytes(row.payload));
2706
2789
  await this.o.store.save(cid, mgr.exportState());
2790
+ advancedEpoch = Number(mgr.epoch);
2707
2791
  });
2708
2792
  this.recordDecryptSuccess(cid);
2793
+ if (advancedEpoch !== void 0) {
2794
+ const ctx = classCtx.get(cid);
2795
+ if (ctx)
2796
+ ctx.liveCommitEpoch = advancedEpoch;
2797
+ }
2709
2798
  handshakeAcked.push(row.queue_id);
2710
2799
  } else {
2711
2800
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
2801
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
2802
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
2803
+ }
2712
2804
  const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2713
2805
  const aad = await buildAssociatedData({
2714
2806
  message_id: envelope.message_id,
@@ -2736,18 +2828,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2736
2828
  this.recordDecryptError(row, err);
2737
2829
  const cid = row.connect_channel_id;
2738
2830
  let drained = false;
2739
- if (cid && typeof row.epoch === "number") {
2740
- let liveEpoch;
2741
- try {
2742
- liveEpoch = await this.currentEpoch(cid);
2743
- } catch {
2744
- liveEpoch = void 0;
2745
- }
2746
- const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2747
- if (permanentlyDead) {
2831
+ if (cid && row.message_type !== "welcome") {
2832
+ const ctx = classCtx.get(cid);
2833
+ const verdict = classifyFailedRow({
2834
+ messageType: row.message_type,
2835
+ rowGroupId: row.group_id,
2836
+ rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
2837
+ heldGroupId: ctx?.heldGroupId ?? null,
2838
+ retiredGroupIds: ctx?.retired ?? null,
2839
+ snapshotEpoch: ctx?.snapshotEpoch,
2840
+ liveCommitEpoch: ctx?.liveCommitEpoch
2841
+ });
2842
+ if (verdict === "permanentlySkipped") {
2748
2843
  handshakeAcked.push(row.queue_id);
2749
2844
  this.permanentlySkipped++;
2750
2845
  drained = true;
2846
+ } else if (verdict === "retiredDrained") {
2847
+ handshakeAcked.push(row.queue_id);
2848
+ this.retiredDrained++;
2849
+ drained = true;
2751
2850
  }
2752
2851
  }
2753
2852
  if (!drained && cid) {
@@ -2755,8 +2854,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2755
2854
  if (e) {
2756
2855
  e.wedgedRun += 1;
2757
2856
  if (e.wedgedRun >= this.quarantineThreshold) {
2758
- handshakeAcked.push(row.queue_id);
2759
- this.quarantineDrained++;
2857
+ if (row.message_type !== "welcome") {
2858
+ handshakeAcked.push(row.queue_id);
2859
+ this.quarantineDrained++;
2860
+ }
2760
2861
  this.quarantineChannel(cid);
2761
2862
  }
2762
2863
  }
@@ -6405,6 +6506,8 @@ var BridgeAgent = class _BridgeAgent {
6405
6506
  filtered_room: this.filteredRoomMessages,
6406
6507
  rate_limited: this.rateLimitedSends,
6407
6508
  permanently_skipped: this.provider.permanentlySkippedCount(),
6509
+ quarantine_drained: this.provider.quarantineDrainedCount(),
6510
+ retired_drained: this.provider.retiredDrainedCount(),
6408
6511
  failed_attachments: this.failedAttachments,
6409
6512
  delivery_pings: this.pingsReceived,
6410
6513
  agent_sessions: this.authSessions,
@@ -6450,8 +6553,11 @@ var BridgeAgent = class _BridgeAgent {
6450
6553
  filteredRoomMessages: this.filteredRoomMessages,
6451
6554
  rateLimitedSends: this.rateLimitedSends,
6452
6555
  permanentlySkipped: this.provider.permanentlySkippedCount(),
6556
+ quarantineDrained: this.provider.quarantineDrainedCount(),
6557
+ retiredDrained: this.provider.retiredDrainedCount(),
6453
6558
  welcomeRequestsSent: this.welcomeRequestsSent,
6454
- failedAttachments: this.failedAttachments
6559
+ failedAttachments: this.failedAttachments,
6560
+ build: BRIDGE_VERSION
6455
6561
  };
6456
6562
  }
6457
6563
  async close() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",