@securecoms/agent-bridge 0.9.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 +386 -68
- package/dist/bridge-agent.d.ts +74 -0
- package/dist/index.js +386 -68
- package/package.json +1 -1
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;
|
|
@@ -1387,6 +1405,18 @@ var LeafRosterMismatchError = class extends Error {
|
|
|
1387
1405
|
this.name = "LeafRosterMismatchError";
|
|
1388
1406
|
}
|
|
1389
1407
|
};
|
|
1408
|
+
var AmbiguousOwnLeafError = class extends Error {
|
|
1409
|
+
deviceId;
|
|
1410
|
+
totalLeaves;
|
|
1411
|
+
candidateLeaves;
|
|
1412
|
+
constructor(deviceId, totalLeaves, candidateLeaves) {
|
|
1413
|
+
super(`removeDeviceFromLiveGroup: cannot identify device ${deviceId}'s leaf \u2014 the identity owns ${totalLeaves} leaves and ${candidateLeaves} remain after excluding this committer's own. Leaf credentials carry the identity, not the device, so the target is genuinely ambiguous. Refusing rather than evicting a device that was never revoked.`);
|
|
1414
|
+
this.deviceId = deviceId;
|
|
1415
|
+
this.totalLeaves = totalLeaves;
|
|
1416
|
+
this.candidateLeaves = candidateLeaves;
|
|
1417
|
+
this.name = "AmbiguousOwnLeafError";
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1390
1420
|
var HealAddAfterRemoveError = class extends Error {
|
|
1391
1421
|
constructor(cause) {
|
|
1392
1422
|
super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
|
|
@@ -1406,7 +1436,7 @@ var ForgedCredentialError = class extends Error {
|
|
|
1406
1436
|
this.name = "ForgedCredentialError";
|
|
1407
1437
|
}
|
|
1408
1438
|
};
|
|
1409
|
-
var MlsGroupKeyProvider = class {
|
|
1439
|
+
var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
1410
1440
|
o;
|
|
1411
1441
|
suite;
|
|
1412
1442
|
managers = /* @__PURE__ */ new Map();
|
|
@@ -1475,6 +1505,11 @@ var MlsGroupKeyProvider = class {
|
|
|
1475
1505
|
* the past-epoch {@link permanentlySkipped} drain. Read via
|
|
1476
1506
|
* {@link quarantineDrainedCount}. */
|
|
1477
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;
|
|
1478
1513
|
constructor(o) {
|
|
1479
1514
|
this.o = o;
|
|
1480
1515
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
@@ -1556,9 +1591,25 @@ var MlsGroupKeyProvider = class {
|
|
|
1556
1591
|
* Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
|
|
1557
1592
|
* Surfaces a wedged channel that would otherwise "look idle": the daemon serves
|
|
1558
1593
|
* it on GET /v1/metrics and the web shell reads it via a selector. A channel is
|
|
1559
|
-
* `stuck` once it has had `stuckThreshold`
|
|
1560
|
-
* intervening successful decrypt. Returns a plain object keyed by
|
|
1561
|
-
* absence of a key means zero errors observed for that channel.
|
|
1594
|
+
* `stuck` once it has had `stuckThreshold` GENUINELY-WEDGED decrypt failures
|
|
1595
|
+
* with no intervening successful decrypt. Returns a plain object keyed by
|
|
1596
|
+
* channelId; absence of a key means zero errors observed for that channel.
|
|
1597
|
+
*
|
|
1598
|
+
* `stuck` gates on `wedgedRun`, NOT `consecutiveErrors` — the same correction
|
|
1599
|
+
* the quarantine gate already got (P0a). `consecutiveErrors` is bumped at the
|
|
1600
|
+
* top of the catch for EVERY failure, INCLUDING the benign past-epoch
|
|
1601
|
+
* duplicate-commit churn that the `permanentlyDead` path immediately and
|
|
1602
|
+
* safely auto-drains. Deriving the user-visible health signal from it meant
|
|
1603
|
+
* ordinary at-least-once / committer-race traffic painted a perfectly healthy
|
|
1604
|
+
* channel red.
|
|
1605
|
+
*
|
|
1606
|
+
* That is the prod symptom of 2026-07-31: a freshly-welcomed federated partner
|
|
1607
|
+
* was queued the very Add commit its Welcome already contained. It joined the
|
|
1608
|
+
* correct group at the correct epoch, correctly identified the replayed commit
|
|
1609
|
+
* as dead and drained it — and still showed "This channel is stuck", because
|
|
1610
|
+
* the drain happened AFTER the error had been counted. `wedgedRun` counts only
|
|
1611
|
+
* rows left UN-acked (actually stuck), and a clean decrypt clears it, so a
|
|
1612
|
+
* genuine wedge still reports exactly as before.
|
|
1562
1613
|
*/
|
|
1563
1614
|
errorStats() {
|
|
1564
1615
|
const out = {};
|
|
@@ -1567,7 +1618,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1567
1618
|
decryptErrors: e.decryptErrors,
|
|
1568
1619
|
consecutiveErrors: e.consecutiveErrors,
|
|
1569
1620
|
lastErrorAt: e.lastErrorAt,
|
|
1570
|
-
stuck: e.
|
|
1621
|
+
stuck: e.wedgedRun >= this.stuckThreshold,
|
|
1571
1622
|
quarantined: e.quarantined
|
|
1572
1623
|
};
|
|
1573
1624
|
}
|
|
@@ -1616,6 +1667,10 @@ var MlsGroupKeyProvider = class {
|
|
|
1616
1667
|
quarantineDrainedCount() {
|
|
1617
1668
|
return this.quarantineDrained;
|
|
1618
1669
|
}
|
|
1670
|
+
/** See {@link retiredDrained}. */
|
|
1671
|
+
retiredDrainedCount() {
|
|
1672
|
+
return this.retiredDrained;
|
|
1673
|
+
}
|
|
1619
1674
|
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1620
1675
|
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1621
1676
|
* not enough on its own, because a warm manager keeps decrypting until the
|
|
@@ -1684,6 +1739,9 @@ var MlsGroupKeyProvider = class {
|
|
|
1684
1739
|
if (cached)
|
|
1685
1740
|
return cached;
|
|
1686
1741
|
const saved = await this.o.store.load(channelId);
|
|
1742
|
+
const again = this.managers.get(channelId);
|
|
1743
|
+
if (again)
|
|
1744
|
+
return again;
|
|
1687
1745
|
if (!saved)
|
|
1688
1746
|
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1689
1747
|
const mgr = new MLSGroupManager(saved.ciphersuite);
|
|
@@ -2232,6 +2290,34 @@ var MlsGroupKeyProvider = class {
|
|
|
2232
2290
|
* `removed_device_id` (instead of removed_identity_id); the server resolves the
|
|
2233
2291
|
* device's identity, asserts it matches, and purges that ONE device.
|
|
2234
2292
|
*/
|
|
2293
|
+
/**
|
|
2294
|
+
* This committer's OWN leaf index in the given group's ratchet tree, or null
|
|
2295
|
+
* when it cannot be read.
|
|
2296
|
+
*
|
|
2297
|
+
* `MLSGroupManager.state` is marked `@internal` but is a declared (non-private)
|
|
2298
|
+
* field, and `state.privatePath.leafIndex` is the same value ts-mls itself uses
|
|
2299
|
+
* as the sender leaf when protecting application data. Reading it here avoids a
|
|
2300
|
+
* change to `@agentvault/crypto`; if a public accessor is added later this
|
|
2301
|
+
* should delegate to it.
|
|
2302
|
+
*
|
|
2303
|
+
* Returns null rather than throwing so every caller must decide explicitly what
|
|
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.
|
|
2316
|
+
*/
|
|
2317
|
+
ownLeafIndex(mgr) {
|
|
2318
|
+
const idx = mgr.state?.privatePath?.leafIndex;
|
|
2319
|
+
return typeof idx === "number" ? idx : null;
|
|
2320
|
+
}
|
|
2235
2321
|
async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
|
|
2236
2322
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
|
|
2237
2323
|
}
|
|
@@ -2244,7 +2330,17 @@ var MlsGroupKeyProvider = class {
|
|
|
2244
2330
|
if (leaves.length !== opts.orderedDeviceIds.length) {
|
|
2245
2331
|
throw new LeafRosterMismatchError();
|
|
2246
2332
|
}
|
|
2247
|
-
|
|
2333
|
+
let leafIndex;
|
|
2334
|
+
const ownLeaf = this.ownLeafIndex(mgr);
|
|
2335
|
+
if (ownLeaf !== null && leaves.includes(ownLeaf)) {
|
|
2336
|
+
const others = leaves.filter((i) => i !== ownLeaf);
|
|
2337
|
+
if (others.length !== 1) {
|
|
2338
|
+
throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
|
|
2339
|
+
}
|
|
2340
|
+
leafIndex = others[0];
|
|
2341
|
+
} else {
|
|
2342
|
+
leafIndex = leaves[pos];
|
|
2343
|
+
}
|
|
2248
2344
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
2249
2345
|
const priorEpoch = Number(mgr.epoch);
|
|
2250
2346
|
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
@@ -2497,7 +2593,7 @@ var MlsGroupKeyProvider = class {
|
|
|
2497
2593
|
});
|
|
2498
2594
|
await this.o.store.save(channelId, mgr.exportState());
|
|
2499
2595
|
this.managers.set(channelId, mgr);
|
|
2500
|
-
|
|
2596
|
+
return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
|
|
2501
2597
|
});
|
|
2502
2598
|
}
|
|
2503
2599
|
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
@@ -2533,13 +2629,27 @@ var MlsGroupKeyProvider = class {
|
|
|
2533
2629
|
}
|
|
2534
2630
|
}
|
|
2535
2631
|
/**
|
|
2536
|
-
* Return the number of leaves the given identity holds in
|
|
2537
|
-
* `channelId`.
|
|
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.
|
|
2538
2643
|
*
|
|
2539
2644
|
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
2540
|
-
* does NOT create a duplicate leaf for an already-added device.
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
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.
|
|
2543
2653
|
*/
|
|
2544
2654
|
async leafCountForIdentity(channelId, identityId) {
|
|
2545
2655
|
const mgr = await this.manager(channelId);
|
|
@@ -2573,7 +2683,60 @@ var MlsGroupKeyProvider = class {
|
|
|
2573
2683
|
* ⇒ refuse, never call verify), then structural signer check, then attestation
|
|
2574
2684
|
* verify against the NON-EMPTY pinned key, then post-join group-id assertion.
|
|
2575
2685
|
*/
|
|
2686
|
+
/** C4 (split-brain Task 4): back-off for REFUSED supersedes, keyed
|
|
2687
|
+
* `${channelId}:${groupId}`. A refused supersede leaves its row un-acked by
|
|
2688
|
+
* design (it is not a stray), but pre-back-off that meant a full re-verify —
|
|
2689
|
+
* including a fetchResetAttestation HTTP round-trip — on EVERY receive poll,
|
|
2690
|
+
* forever, for a request that can never succeed (the nine production split
|
|
2691
|
+
* channels all carry NULL-attestation legacy groups).
|
|
2692
|
+
*
|
|
2693
|
+
* ⚠️ ONLY POST-FETCH refusals enter the back-off. The step-0 pin guard and
|
|
2694
|
+
* the retired-group check run BEFORE fetchResetAttestation, so they
|
|
2695
|
+
* contribute zero HTTP to the storm — and the pin guard's precondition is
|
|
2696
|
+
* TRANSIENT (cold owner cache / momentarily anomalous roster; the bridge
|
|
2697
|
+
* warms the cache before receiveOnce for exactly this reason). Backing a
|
|
2698
|
+
* pre-fetch refusal off buys nothing and converts a one-cycle stall into a
|
|
2699
|
+
* ten-minute outage on a valid recovery.
|
|
2700
|
+
*
|
|
2701
|
+
* Of the backed-off refusals: no-attestation, unparseable content,
|
|
2702
|
+
* channel/group mismatch and post-join mismatch are DETERMINISTIC per
|
|
2703
|
+
* (channel, group) — the attestation columns are written at group creation
|
|
2704
|
+
* and never change. Signer-mismatch, signature-verify and no-bundle are
|
|
2705
|
+
* ASSUMED stable for the window; they are pin- or bundle-dependent, but by
|
|
2706
|
+
* the time they run the fetch has already been paid, so suppressing the
|
|
2707
|
+
* window bounds the storm at the cost of a ≤10-minute delay if the pin or
|
|
2708
|
+
* bundle changes. Note the suppression means the reason is not RE-OBSERVED
|
|
2709
|
+
* during the window — the underlying precondition can change freely; the
|
|
2710
|
+
* window is a cost bound, not a claim of immutability.
|
|
2711
|
+
*
|
|
2712
|
+
* The reason lives in the VALUE for observability; the clearing rule lives
|
|
2713
|
+
* in the KEY: the owner minting a FIXED replacement group arrives with a
|
|
2714
|
+
* NEW group id — a different key, tried immediately. The un-openable path
|
|
2715
|
+
* (verified Welcome, KP mismatch) is EXEMPT: onSupersedeUnopenable's own
|
|
2716
|
+
* cooldown governs it. In-memory only: a reload/restart costs one extra
|
|
2717
|
+
* fetch per stranded row — reload-bounded, not poll-bounded. */
|
|
2718
|
+
supersedeRefusals = /* @__PURE__ */ new Map();
|
|
2719
|
+
static SUPERSEDE_REFUSAL_BACKOFF_MS = 10 * 6e4;
|
|
2720
|
+
/** True while (channelId, groupId) is inside its refusal back-off window.
|
|
2721
|
+
* @internal exposed for diagnostics only — not part of the stable surface. */
|
|
2722
|
+
supersedeBackoffActive(channelId, groupId) {
|
|
2723
|
+
const rec = this.supersedeRefusals.get(`${channelId}:${groupId}`);
|
|
2724
|
+
return rec !== void 0 && Date.now() < rec.until;
|
|
2725
|
+
}
|
|
2576
2726
|
async trySupersede(channelId, row, heldGroupId) {
|
|
2727
|
+
const refuse = (msg) => {
|
|
2728
|
+
this.recordDecryptError(row, new Error(msg));
|
|
2729
|
+
const now = Date.now();
|
|
2730
|
+
for (const [k, v] of this.supersedeRefusals) {
|
|
2731
|
+
if (now >= v.until)
|
|
2732
|
+
this.supersedeRefusals.delete(k);
|
|
2733
|
+
}
|
|
2734
|
+
this.supersedeRefusals.set(`${channelId}:${row.group_id}`, {
|
|
2735
|
+
reason: msg,
|
|
2736
|
+
until: now + _MlsGroupKeyProvider.SUPERSEDE_REFUSAL_BACKOFF_MS
|
|
2737
|
+
});
|
|
2738
|
+
return false;
|
|
2739
|
+
};
|
|
2577
2740
|
const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
|
|
2578
2741
|
if (!owner || !owner.publicKeyHex) {
|
|
2579
2742
|
this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
|
|
@@ -2586,33 +2749,27 @@ var MlsGroupKeyProvider = class {
|
|
|
2586
2749
|
}
|
|
2587
2750
|
const att = await this.o.backend.fetchResetAttestation(row.group_id);
|
|
2588
2751
|
if (!att) {
|
|
2589
|
-
|
|
2590
|
-
return false;
|
|
2752
|
+
return refuse(`supersede refused: no attestation for group ${row.group_id}`);
|
|
2591
2753
|
}
|
|
2592
2754
|
if (att.signerIdentityId !== owner.identityId) {
|
|
2593
|
-
|
|
2594
|
-
return false;
|
|
2755
|
+
return refuse("supersede refused: signer is not the pinned owner");
|
|
2595
2756
|
}
|
|
2596
2757
|
let content;
|
|
2597
2758
|
try {
|
|
2598
2759
|
content = JSON.parse(att.signedContent);
|
|
2599
2760
|
} catch {
|
|
2600
|
-
|
|
2601
|
-
return false;
|
|
2761
|
+
return refuse("supersede refused: unparseable signed_content");
|
|
2602
2762
|
}
|
|
2603
2763
|
if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
|
|
2604
|
-
|
|
2605
|
-
return false;
|
|
2764
|
+
return refuse("supersede refused: attestation channel/group mismatch");
|
|
2606
2765
|
}
|
|
2607
2766
|
const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
|
|
2608
2767
|
if (!ok) {
|
|
2609
|
-
|
|
2610
|
-
return false;
|
|
2768
|
+
return refuse("supersede refused: signature does not verify against pinned owner key");
|
|
2611
2769
|
}
|
|
2612
2770
|
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2613
2771
|
if (!bundle) {
|
|
2614
|
-
|
|
2615
|
-
return false;
|
|
2772
|
+
return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
|
|
2616
2773
|
}
|
|
2617
2774
|
return await this.locks.run(channelId, async () => {
|
|
2618
2775
|
const mgr = new MLSGroupManager();
|
|
@@ -2637,14 +2794,14 @@ var MlsGroupKeyProvider = class {
|
|
|
2637
2794
|
return false;
|
|
2638
2795
|
}
|
|
2639
2796
|
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
2640
|
-
|
|
2641
|
-
return false;
|
|
2797
|
+
return refuse("supersede refused: post-join group id != attested new_group_id");
|
|
2642
2798
|
}
|
|
2643
2799
|
this.evictChannelState(channelId);
|
|
2644
2800
|
await this.o.store.delete(channelId);
|
|
2645
2801
|
await this.o.store.discardStaging(channelId);
|
|
2646
2802
|
await this.o.store.addRetiredGroupId(channelId, heldGroupId);
|
|
2647
2803
|
this.errors.delete(channelId);
|
|
2804
|
+
this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
|
|
2648
2805
|
this.managers.set(channelId, mgr);
|
|
2649
2806
|
await this.o.store.save(channelId, mgr.exportState());
|
|
2650
2807
|
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
@@ -2653,16 +2810,45 @@ var MlsGroupKeyProvider = class {
|
|
|
2653
2810
|
}
|
|
2654
2811
|
async receiveOnce(deviceId) {
|
|
2655
2812
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2656
|
-
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
|
|
2657
|
-
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(", "));
|
|
2658
|
-
}
|
|
2659
2813
|
const appEntries = [];
|
|
2660
2814
|
const handshakeAcked = [];
|
|
2661
2815
|
const supersededChannelIds = [];
|
|
2662
|
-
const
|
|
2663
|
-
rows.
|
|
2664
|
-
|
|
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) {
|
|
2665
2848
|
try {
|
|
2849
|
+
if (row.message_type !== "welcome" && row.connect_channel_id) {
|
|
2850
|
+
await classCtxFor(row.connect_channel_id);
|
|
2851
|
+
}
|
|
2666
2852
|
if (row.message_type === "welcome") {
|
|
2667
2853
|
const wcid = row.connect_channel_id;
|
|
2668
2854
|
if (wcid) {
|
|
@@ -2681,9 +2867,15 @@ var MlsGroupKeyProvider = class {
|
|
|
2681
2867
|
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
2682
2868
|
handshakeAcked.push(row.queue_id);
|
|
2683
2869
|
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2870
|
+
if (this.supersedeBackoffActive(wcid, row.group_id)) {
|
|
2871
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2872
|
+
console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
|
|
2873
|
+
}
|
|
2874
|
+
} else {
|
|
2875
|
+
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
2876
|
+
if (ok)
|
|
2877
|
+
supersededChannelIds.push(wcid);
|
|
2878
|
+
}
|
|
2687
2879
|
}
|
|
2688
2880
|
} else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2689
2881
|
console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
|
|
@@ -2693,15 +2885,25 @@ var MlsGroupKeyProvider = class {
|
|
|
2693
2885
|
const cid = row.connect_channel_id;
|
|
2694
2886
|
if (!cid)
|
|
2695
2887
|
continue;
|
|
2888
|
+
let advancedEpoch;
|
|
2696
2889
|
await this.locks.run(cid, async () => {
|
|
2697
2890
|
const mgr = await this.manager(cid);
|
|
2698
2891
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
2699
2892
|
await this.o.store.save(cid, mgr.exportState());
|
|
2893
|
+
advancedEpoch = Number(mgr.epoch);
|
|
2700
2894
|
});
|
|
2701
2895
|
this.recordDecryptSuccess(cid);
|
|
2896
|
+
if (advancedEpoch !== void 0) {
|
|
2897
|
+
const ctx = classCtx.get(cid);
|
|
2898
|
+
if (ctx)
|
|
2899
|
+
ctx.liveCommitEpoch = advancedEpoch;
|
|
2900
|
+
}
|
|
2702
2901
|
handshakeAcked.push(row.queue_id);
|
|
2703
2902
|
} else {
|
|
2704
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
|
+
}
|
|
2705
2907
|
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
2706
2908
|
const aad = await buildAssociatedData({
|
|
2707
2909
|
message_id: envelope.message_id,
|
|
@@ -2729,18 +2931,25 @@ var MlsGroupKeyProvider = class {
|
|
|
2729
2931
|
this.recordDecryptError(row, err);
|
|
2730
2932
|
const cid = row.connect_channel_id;
|
|
2731
2933
|
let drained = false;
|
|
2732
|
-
if (cid &&
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
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") {
|
|
2741
2946
|
handshakeAcked.push(row.queue_id);
|
|
2742
2947
|
this.permanentlySkipped++;
|
|
2743
2948
|
drained = true;
|
|
2949
|
+
} else if (verdict === "retiredDrained") {
|
|
2950
|
+
handshakeAcked.push(row.queue_id);
|
|
2951
|
+
this.retiredDrained++;
|
|
2952
|
+
drained = true;
|
|
2744
2953
|
}
|
|
2745
2954
|
}
|
|
2746
2955
|
if (!drained && cid) {
|
|
@@ -2748,8 +2957,10 @@ var MlsGroupKeyProvider = class {
|
|
|
2748
2957
|
if (e) {
|
|
2749
2958
|
e.wedgedRun += 1;
|
|
2750
2959
|
if (e.wedgedRun >= this.quarantineThreshold) {
|
|
2751
|
-
|
|
2752
|
-
|
|
2960
|
+
if (row.message_type !== "welcome") {
|
|
2961
|
+
handshakeAcked.push(row.queue_id);
|
|
2962
|
+
this.quarantineDrained++;
|
|
2963
|
+
}
|
|
2753
2964
|
this.quarantineChannel(cid);
|
|
2754
2965
|
}
|
|
2755
2966
|
}
|
|
@@ -3386,19 +3597,8 @@ var RestMlsBackend = class {
|
|
|
3386
3597
|
created: j.created === void 0 ? true : Boolean(j.created)
|
|
3387
3598
|
};
|
|
3388
3599
|
}
|
|
3389
|
-
async reinitConnectChannelGroup(
|
|
3390
|
-
|
|
3391
|
-
if (!committer)
|
|
3392
|
-
throw new Error("reinitConnectChannelGroup: empty member set");
|
|
3393
|
-
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
3394
|
-
member_device_ids: memberDeviceIds,
|
|
3395
|
-
committer_device_id: committer
|
|
3396
|
-
});
|
|
3397
|
-
return {
|
|
3398
|
-
group_id: String(j.group_id),
|
|
3399
|
-
current_epoch: Number(j.current_epoch),
|
|
3400
|
-
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
3401
|
-
};
|
|
3600
|
+
async reinitConnectChannelGroup() {
|
|
3601
|
+
throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
|
|
3402
3602
|
}
|
|
3403
3603
|
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
3404
3604
|
const committer = memberDeviceIds[0];
|
|
@@ -3416,7 +3616,11 @@ var RestMlsBackend = class {
|
|
|
3416
3616
|
return {
|
|
3417
3617
|
group_id: String(j.group_id),
|
|
3418
3618
|
current_epoch: Number(j.current_epoch),
|
|
3419
|
-
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
3619
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
|
|
3620
|
+
// Absent on a backend that predates the dropped-member report; an empty
|
|
3621
|
+
// list is the correct read of "the server told us nothing", since the old
|
|
3622
|
+
// server also never dropped anyone for an expired KeyPackage.
|
|
3623
|
+
stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
|
|
3420
3624
|
};
|
|
3421
3625
|
}
|
|
3422
3626
|
async fetchKeyPackages(deviceIds) {
|
|
@@ -5355,11 +5559,33 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5355
5559
|
return;
|
|
5356
5560
|
}
|
|
5357
5561
|
this.providerCapabilityMissing = false;
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5562
|
+
const enclaves = await this.enclavesToCover();
|
|
5563
|
+
this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
|
|
5564
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5565
|
+
const chans = [];
|
|
5566
|
+
const failed = [];
|
|
5567
|
+
let listedAny = false;
|
|
5568
|
+
for (const encId of enclaves) {
|
|
5569
|
+
try {
|
|
5570
|
+
for (const c of await this.listChannels(encId)) {
|
|
5571
|
+
if (seen.has(c.channel_id)) continue;
|
|
5572
|
+
seen.add(c.channel_id);
|
|
5573
|
+
chans.push(c);
|
|
5574
|
+
}
|
|
5575
|
+
listedAny = true;
|
|
5576
|
+
this.enclaveListEverSucceeded.add(encId);
|
|
5577
|
+
this.enclaveListWarned.delete(encId);
|
|
5578
|
+
} catch (e) {
|
|
5579
|
+
failed.push(encId);
|
|
5580
|
+
if (!this.enclaveListWarned.has(encId)) {
|
|
5581
|
+
this.enclaveListWarned.add(encId);
|
|
5582
|
+
console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
|
|
5583
|
+
}
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
5586
|
+
this.sweepEnclavesFailed = failed;
|
|
5587
|
+
if (!listedAny) {
|
|
5588
|
+
console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
|
|
5363
5589
|
return;
|
|
5364
5590
|
}
|
|
5365
5591
|
for (const c of chans) {
|
|
@@ -5705,7 +5931,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5705
5931
|
* the other enclaves in the set.
|
|
5706
5932
|
*/
|
|
5707
5933
|
async refreshChannelMeta() {
|
|
5708
|
-
const enclaves =
|
|
5934
|
+
const enclaves = await this.enclavesToCover();
|
|
5709
5935
|
const now = Date.now();
|
|
5710
5936
|
for (const encId of enclaves) {
|
|
5711
5937
|
try {
|
|
@@ -5718,6 +5944,89 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5718
5944
|
}
|
|
5719
5945
|
}
|
|
5720
5946
|
}
|
|
5947
|
+
/** Cache for the server-side enclave enumeration (see enclavesToCover). */
|
|
5948
|
+
enclaveEnumCache = null;
|
|
5949
|
+
/** Enclaves whose channel listing failed on the NEWEST sweep (audit HIGH:
|
|
5950
|
+
* skip-one deleted the sweep's partial-coverage signal — observations_stale
|
|
5951
|
+
* is a global max, so one healthy enclave masked a failed one). Surfaced on
|
|
5952
|
+
* health() so partial coverage is never silent. */
|
|
5953
|
+
sweepEnclavesFailed = [];
|
|
5954
|
+
/** Enclave ids whose channel listing has EVER succeeded this process, seeded
|
|
5955
|
+
* with HOME (home is always expected to list; without the seed, a home
|
|
5956
|
+
* failure right after a restart would read as "never succeeded" and slip
|
|
5957
|
+
* through the expected-gap branch — weaker than the old abort-on-failure).
|
|
5958
|
+
* A failure of a member of this set is a REGRESSION and degrades health; a
|
|
5959
|
+
* failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
|
|
5960
|
+
* pending backend authz) is an EXPECTED gap — reported, not degrading, or
|
|
5961
|
+
* every federated agent would peg degraded forever. */
|
|
5962
|
+
enclaveListEverSucceeded = /* @__PURE__ */ new Set();
|
|
5963
|
+
/** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
|
|
5964
|
+
* a permanently-404ing partner enclave must not log 1440 lines a day. */
|
|
5965
|
+
enclaveListWarned = /* @__PURE__ */ new Set();
|
|
5966
|
+
static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
|
|
5967
|
+
enclaveEnumWarned = false;
|
|
5968
|
+
/**
|
|
5969
|
+
* C1 (split-brain Task 3): every enclave this agent must COVER — for the
|
|
5970
|
+
* self-heal sweep and for channel-meta refresh alike. ONE helper on purpose:
|
|
5971
|
+
* these two call sites had drifted (refreshChannelMeta looped home+learned,
|
|
5972
|
+
* selfHealSweep looped nothing), and the drift is exactly what left both
|
|
5973
|
+
* production agents with zero recovery coverage in their second enclave.
|
|
5974
|
+
*
|
|
5975
|
+
* The set is SERVER enumeration ∪ home ∪ store-learned:
|
|
5976
|
+
*
|
|
5977
|
+
* - SERVER (`GET /api/v1/enclaves`, membership-scoped, federated included)
|
|
5978
|
+
* is the authoritative source. The store CANNOT be: channel_meta's only
|
|
5979
|
+
* writers are listChannels itself (circular — you learn enclave B by
|
|
5980
|
+
* having already listed B) and the receive pump's successfully-decrypted
|
|
5981
|
+
* loop, and a FORKED agent decrypts nothing, so the agent that most needs
|
|
5982
|
+
* sweeping in enclave B is precisely the one whose store never learned B.
|
|
5983
|
+
* - home + learned stay in the union as the degraded-mode floor: if the
|
|
5984
|
+
* enumeration endpoint is down, we sweep what we can rather than nothing,
|
|
5985
|
+
* and we warn (once) so the gap is diagnosable.
|
|
5986
|
+
*
|
|
5987
|
+
* The server result is cached for ENCLAVE_ENUM_TTL_MS so per-ingest-cycle
|
|
5988
|
+
* callers (refreshChannelMeta) do not add an HTTP round-trip per cycle;
|
|
5989
|
+
* membership changes are picked up within one TTL, which matches the sweep
|
|
5990
|
+
* cadence this exists to serve.
|
|
5991
|
+
*
|
|
5992
|
+
* ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
|
|
5993
|
+
* enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
|
|
5994
|
+
* under this agent's bearer — so a partner enclave in this set still yields
|
|
5995
|
+
* no channels. Callers must treat a per-enclave listing failure as skip-one,
|
|
5996
|
+
* never abort-all. Closing that needs backend authz work, not daemon work.
|
|
5997
|
+
*/
|
|
5998
|
+
async enclavesToCover() {
|
|
5999
|
+
const now = Date.now();
|
|
6000
|
+
if (!this.enclaveEnumCache || now - this.enclaveEnumCache.at > _BridgeAgent.ENCLAVE_ENUM_TTL_MS) {
|
|
6001
|
+
try {
|
|
6002
|
+
const url = `${this.cfg.apiBase}/api/v1/enclaves?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
6003
|
+
const res = await this.fetchImpl(url, {
|
|
6004
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
6005
|
+
});
|
|
6006
|
+
if (!res.ok) throw new Error(`list_enclaves failed: ${res.status}`);
|
|
6007
|
+
const rows = await res.json();
|
|
6008
|
+
this.enclaveEnumCache = { ids: rows.map((r) => r.enclave_id), at: Date.now() };
|
|
6009
|
+
this.enclaveEnumWarned = false;
|
|
6010
|
+
} catch (e) {
|
|
6011
|
+
if (!this.enclaveEnumWarned) {
|
|
6012
|
+
this.enclaveEnumWarned = true;
|
|
6013
|
+
console.warn(
|
|
6014
|
+
"[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
|
|
6015
|
+
e
|
|
6016
|
+
);
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
}
|
|
6020
|
+
const fresh = this.enclaveEnumCache && Date.now() - this.enclaveEnumCache.at <= _BridgeAgent.ENCLAVE_ENUM_TTL_MS;
|
|
6021
|
+
if (fresh) {
|
|
6022
|
+
return /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.enclaveEnumCache.ids]);
|
|
6023
|
+
}
|
|
6024
|
+
return /* @__PURE__ */ new Set([
|
|
6025
|
+
this.cfg.enclaveId,
|
|
6026
|
+
...this.store.distinctEnclaveIds(),
|
|
6027
|
+
...this.enclaveEnumCache?.ids ?? []
|
|
6028
|
+
]);
|
|
6029
|
+
}
|
|
5721
6030
|
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
5722
6031
|
freshRoster(channelId) {
|
|
5723
6032
|
const hit = this.channelRoster.get(channelId);
|
|
@@ -6314,6 +6623,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6314
6623
|
const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
|
|
6315
6624
|
const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
|
|
6316
6625
|
const divergedChannels = channels.filter((o) => o.diverged === true).length;
|
|
6626
|
+
const sweepCoverageLost = this.sweepEnclavesFailed.some(
|
|
6627
|
+
(id) => this.enclaveListEverSucceeded.has(id)
|
|
6628
|
+
);
|
|
6317
6629
|
const liveChannels = channels.map((o) => ({
|
|
6318
6630
|
...o,
|
|
6319
6631
|
last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
|
|
@@ -6328,8 +6640,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6328
6640
|
observations_stale: observationsStale,
|
|
6329
6641
|
diverged_channels: divergedChannels,
|
|
6330
6642
|
// A2: the truthful roll-up gates read instead of `status` (D-3).
|
|
6331
|
-
degraded: divergedChannels > 0 || observationsStale,
|
|
6643
|
+
degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
|
|
6332
6644
|
channels: liveChannels,
|
|
6645
|
+
sweep_enclaves_failed: [...this.sweepEnclavesFailed],
|
|
6333
6646
|
identity_id: this.cfg.identityId,
|
|
6334
6647
|
joined_channels: joined,
|
|
6335
6648
|
buffered: this.buffer.length,
|
|
@@ -6337,6 +6650,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6337
6650
|
filtered_room: this.filteredRoomMessages,
|
|
6338
6651
|
rate_limited: this.rateLimitedSends,
|
|
6339
6652
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
6653
|
+
quarantine_drained: this.provider.quarantineDrainedCount(),
|
|
6654
|
+
retired_drained: this.provider.retiredDrainedCount(),
|
|
6340
6655
|
failed_attachments: this.failedAttachments,
|
|
6341
6656
|
delivery_pings: this.pingsReceived,
|
|
6342
6657
|
agent_sessions: this.authSessions,
|
|
@@ -6382,8 +6697,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6382
6697
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
6383
6698
|
rateLimitedSends: this.rateLimitedSends,
|
|
6384
6699
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
6700
|
+
quarantineDrained: this.provider.quarantineDrainedCount(),
|
|
6701
|
+
retiredDrained: this.provider.retiredDrainedCount(),
|
|
6385
6702
|
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
6386
|
-
failedAttachments: this.failedAttachments
|
|
6703
|
+
failedAttachments: this.failedAttachments,
|
|
6704
|
+
build: BRIDGE_VERSION
|
|
6387
6705
|
};
|
|
6388
6706
|
}
|
|
6389
6707
|
async close() {
|