@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/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;
|
|
@@ -1284,6 +1302,18 @@ var LeafRosterMismatchError = class extends Error {
|
|
|
1284
1302
|
this.name = "LeafRosterMismatchError";
|
|
1285
1303
|
}
|
|
1286
1304
|
};
|
|
1305
|
+
var AmbiguousOwnLeafError = class extends Error {
|
|
1306
|
+
deviceId;
|
|
1307
|
+
totalLeaves;
|
|
1308
|
+
candidateLeaves;
|
|
1309
|
+
constructor(deviceId, totalLeaves, candidateLeaves) {
|
|
1310
|
+
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.`);
|
|
1311
|
+
this.deviceId = deviceId;
|
|
1312
|
+
this.totalLeaves = totalLeaves;
|
|
1313
|
+
this.candidateLeaves = candidateLeaves;
|
|
1314
|
+
this.name = "AmbiguousOwnLeafError";
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1287
1317
|
var HealAddAfterRemoveError = class extends Error {
|
|
1288
1318
|
constructor(cause) {
|
|
1289
1319
|
super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
|
|
@@ -1303,7 +1333,7 @@ var ForgedCredentialError = class extends Error {
|
|
|
1303
1333
|
this.name = "ForgedCredentialError";
|
|
1304
1334
|
}
|
|
1305
1335
|
};
|
|
1306
|
-
var MlsGroupKeyProvider = class {
|
|
1336
|
+
var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
1307
1337
|
o;
|
|
1308
1338
|
suite;
|
|
1309
1339
|
managers = /* @__PURE__ */ new Map();
|
|
@@ -1372,6 +1402,11 @@ var MlsGroupKeyProvider = class {
|
|
|
1372
1402
|
* the past-epoch {@link permanentlySkipped} drain. Read via
|
|
1373
1403
|
* {@link quarantineDrainedCount}. */
|
|
1374
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;
|
|
1375
1410
|
constructor(o) {
|
|
1376
1411
|
this.o = o;
|
|
1377
1412
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
@@ -1453,9 +1488,25 @@ var MlsGroupKeyProvider = class {
|
|
|
1453
1488
|
* Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
|
|
1454
1489
|
* Surfaces a wedged channel that would otherwise "look idle": the daemon serves
|
|
1455
1490
|
* it on GET /v1/metrics and the web shell reads it via a selector. A channel is
|
|
1456
|
-
* `stuck` once it has had `stuckThreshold`
|
|
1457
|
-
* intervening successful decrypt. Returns a plain object keyed by
|
|
1458
|
-
* absence of a key means zero errors observed for that channel.
|
|
1491
|
+
* `stuck` once it has had `stuckThreshold` GENUINELY-WEDGED decrypt failures
|
|
1492
|
+
* with no intervening successful decrypt. Returns a plain object keyed by
|
|
1493
|
+
* channelId; absence of a key means zero errors observed for that channel.
|
|
1494
|
+
*
|
|
1495
|
+
* `stuck` gates on `wedgedRun`, NOT `consecutiveErrors` — the same correction
|
|
1496
|
+
* the quarantine gate already got (P0a). `consecutiveErrors` is bumped at the
|
|
1497
|
+
* top of the catch for EVERY failure, INCLUDING the benign past-epoch
|
|
1498
|
+
* duplicate-commit churn that the `permanentlyDead` path immediately and
|
|
1499
|
+
* safely auto-drains. Deriving the user-visible health signal from it meant
|
|
1500
|
+
* ordinary at-least-once / committer-race traffic painted a perfectly healthy
|
|
1501
|
+
* channel red.
|
|
1502
|
+
*
|
|
1503
|
+
* That is the prod symptom of 2026-07-31: a freshly-welcomed federated partner
|
|
1504
|
+
* was queued the very Add commit its Welcome already contained. It joined the
|
|
1505
|
+
* correct group at the correct epoch, correctly identified the replayed commit
|
|
1506
|
+
* as dead and drained it — and still showed "This channel is stuck", because
|
|
1507
|
+
* the drain happened AFTER the error had been counted. `wedgedRun` counts only
|
|
1508
|
+
* rows left UN-acked (actually stuck), and a clean decrypt clears it, so a
|
|
1509
|
+
* genuine wedge still reports exactly as before.
|
|
1459
1510
|
*/
|
|
1460
1511
|
errorStats() {
|
|
1461
1512
|
const out = {};
|
|
@@ -1464,7 +1515,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1464
1515
|
decryptErrors: e.decryptErrors,
|
|
1465
1516
|
consecutiveErrors: e.consecutiveErrors,
|
|
1466
1517
|
lastErrorAt: e.lastErrorAt,
|
|
1467
|
-
stuck: e.
|
|
1518
|
+
stuck: e.wedgedRun >= this.stuckThreshold,
|
|
1468
1519
|
quarantined: e.quarantined
|
|
1469
1520
|
};
|
|
1470
1521
|
}
|
|
@@ -1513,6 +1564,10 @@ var MlsGroupKeyProvider = class {
|
|
|
1513
1564
|
quarantineDrainedCount() {
|
|
1514
1565
|
return this.quarantineDrained;
|
|
1515
1566
|
}
|
|
1567
|
+
/** See {@link retiredDrained}. */
|
|
1568
|
+
retiredDrainedCount() {
|
|
1569
|
+
return this.retiredDrained;
|
|
1570
|
+
}
|
|
1516
1571
|
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1517
1572
|
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1518
1573
|
* not enough on its own, because a warm manager keeps decrypting until the
|
|
@@ -1581,6 +1636,9 @@ var MlsGroupKeyProvider = class {
|
|
|
1581
1636
|
if (cached)
|
|
1582
1637
|
return cached;
|
|
1583
1638
|
const saved = await this.o.store.load(channelId);
|
|
1639
|
+
const again = this.managers.get(channelId);
|
|
1640
|
+
if (again)
|
|
1641
|
+
return again;
|
|
1584
1642
|
if (!saved)
|
|
1585
1643
|
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1586
1644
|
const mgr = new MLSGroupManager(saved.ciphersuite);
|
|
@@ -2129,6 +2187,34 @@ var MlsGroupKeyProvider = class {
|
|
|
2129
2187
|
* `removed_device_id` (instead of removed_identity_id); the server resolves the
|
|
2130
2188
|
* device's identity, asserts it matches, and purges that ONE device.
|
|
2131
2189
|
*/
|
|
2190
|
+
/**
|
|
2191
|
+
* This committer's OWN leaf index in the given group's ratchet tree, or null
|
|
2192
|
+
* when it cannot be read.
|
|
2193
|
+
*
|
|
2194
|
+
* `MLSGroupManager.state` is marked `@internal` but is a declared (non-private)
|
|
2195
|
+
* field, and `state.privatePath.leafIndex` is the same value ts-mls itself uses
|
|
2196
|
+
* as the sender leaf when protecting application data. Reading it here avoids a
|
|
2197
|
+
* change to `@agentvault/crypto`; if a public accessor is added later this
|
|
2198
|
+
* should delegate to it.
|
|
2199
|
+
*
|
|
2200
|
+
* Returns null rather than throwing so every caller must decide explicitly what
|
|
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.
|
|
2213
|
+
*/
|
|
2214
|
+
ownLeafIndex(mgr) {
|
|
2215
|
+
const idx = mgr.state?.privatePath?.leafIndex;
|
|
2216
|
+
return typeof idx === "number" ? idx : null;
|
|
2217
|
+
}
|
|
2132
2218
|
async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
|
|
2133
2219
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
|
|
2134
2220
|
}
|
|
@@ -2141,7 +2227,17 @@ var MlsGroupKeyProvider = class {
|
|
|
2141
2227
|
if (leaves.length !== opts.orderedDeviceIds.length) {
|
|
2142
2228
|
throw new LeafRosterMismatchError();
|
|
2143
2229
|
}
|
|
2144
|
-
|
|
2230
|
+
let leafIndex;
|
|
2231
|
+
const ownLeaf = this.ownLeafIndex(mgr);
|
|
2232
|
+
if (ownLeaf !== null && leaves.includes(ownLeaf)) {
|
|
2233
|
+
const others = leaves.filter((i) => i !== ownLeaf);
|
|
2234
|
+
if (others.length !== 1) {
|
|
2235
|
+
throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
|
|
2236
|
+
}
|
|
2237
|
+
leafIndex = others[0];
|
|
2238
|
+
} else {
|
|
2239
|
+
leafIndex = leaves[pos];
|
|
2240
|
+
}
|
|
2145
2241
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
2146
2242
|
const priorEpoch = Number(mgr.epoch);
|
|
2147
2243
|
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
@@ -2394,7 +2490,7 @@ var MlsGroupKeyProvider = class {
|
|
|
2394
2490
|
});
|
|
2395
2491
|
await this.o.store.save(channelId, mgr.exportState());
|
|
2396
2492
|
this.managers.set(channelId, mgr);
|
|
2397
|
-
|
|
2493
|
+
return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
|
|
2398
2494
|
});
|
|
2399
2495
|
}
|
|
2400
2496
|
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
@@ -2430,13 +2526,27 @@ var MlsGroupKeyProvider = class {
|
|
|
2430
2526
|
}
|
|
2431
2527
|
}
|
|
2432
2528
|
/**
|
|
2433
|
-
* Return the number of leaves the given identity holds in
|
|
2434
|
-
* `channelId`.
|
|
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.
|
|
2435
2540
|
*
|
|
2436
2541
|
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
2437
|
-
* does NOT create a duplicate leaf for an already-added device.
|
|
2438
|
-
*
|
|
2439
|
-
*
|
|
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.
|
|
2440
2550
|
*/
|
|
2441
2551
|
async leafCountForIdentity(channelId, identityId) {
|
|
2442
2552
|
const mgr = await this.manager(channelId);
|
|
@@ -2470,7 +2580,60 @@ var MlsGroupKeyProvider = class {
|
|
|
2470
2580
|
* ⇒ refuse, never call verify), then structural signer check, then attestation
|
|
2471
2581
|
* verify against the NON-EMPTY pinned key, then post-join group-id assertion.
|
|
2472
2582
|
*/
|
|
2583
|
+
/** C4 (split-brain Task 4): back-off for REFUSED supersedes, keyed
|
|
2584
|
+
* `${channelId}:${groupId}`. A refused supersede leaves its row un-acked by
|
|
2585
|
+
* design (it is not a stray), but pre-back-off that meant a full re-verify —
|
|
2586
|
+
* including a fetchResetAttestation HTTP round-trip — on EVERY receive poll,
|
|
2587
|
+
* forever, for a request that can never succeed (the nine production split
|
|
2588
|
+
* channels all carry NULL-attestation legacy groups).
|
|
2589
|
+
*
|
|
2590
|
+
* ⚠️ ONLY POST-FETCH refusals enter the back-off. The step-0 pin guard and
|
|
2591
|
+
* the retired-group check run BEFORE fetchResetAttestation, so they
|
|
2592
|
+
* contribute zero HTTP to the storm — and the pin guard's precondition is
|
|
2593
|
+
* TRANSIENT (cold owner cache / momentarily anomalous roster; the bridge
|
|
2594
|
+
* warms the cache before receiveOnce for exactly this reason). Backing a
|
|
2595
|
+
* pre-fetch refusal off buys nothing and converts a one-cycle stall into a
|
|
2596
|
+
* ten-minute outage on a valid recovery.
|
|
2597
|
+
*
|
|
2598
|
+
* Of the backed-off refusals: no-attestation, unparseable content,
|
|
2599
|
+
* channel/group mismatch and post-join mismatch are DETERMINISTIC per
|
|
2600
|
+
* (channel, group) — the attestation columns are written at group creation
|
|
2601
|
+
* and never change. Signer-mismatch, signature-verify and no-bundle are
|
|
2602
|
+
* ASSUMED stable for the window; they are pin- or bundle-dependent, but by
|
|
2603
|
+
* the time they run the fetch has already been paid, so suppressing the
|
|
2604
|
+
* window bounds the storm at the cost of a ≤10-minute delay if the pin or
|
|
2605
|
+
* bundle changes. Note the suppression means the reason is not RE-OBSERVED
|
|
2606
|
+
* during the window — the underlying precondition can change freely; the
|
|
2607
|
+
* window is a cost bound, not a claim of immutability.
|
|
2608
|
+
*
|
|
2609
|
+
* The reason lives in the VALUE for observability; the clearing rule lives
|
|
2610
|
+
* in the KEY: the owner minting a FIXED replacement group arrives with a
|
|
2611
|
+
* NEW group id — a different key, tried immediately. The un-openable path
|
|
2612
|
+
* (verified Welcome, KP mismatch) is EXEMPT: onSupersedeUnopenable's own
|
|
2613
|
+
* cooldown governs it. In-memory only: a reload/restart costs one extra
|
|
2614
|
+
* fetch per stranded row — reload-bounded, not poll-bounded. */
|
|
2615
|
+
supersedeRefusals = /* @__PURE__ */ new Map();
|
|
2616
|
+
static SUPERSEDE_REFUSAL_BACKOFF_MS = 10 * 6e4;
|
|
2617
|
+
/** True while (channelId, groupId) is inside its refusal back-off window.
|
|
2618
|
+
* @internal exposed for diagnostics only — not part of the stable surface. */
|
|
2619
|
+
supersedeBackoffActive(channelId, groupId) {
|
|
2620
|
+
const rec = this.supersedeRefusals.get(`${channelId}:${groupId}`);
|
|
2621
|
+
return rec !== void 0 && Date.now() < rec.until;
|
|
2622
|
+
}
|
|
2473
2623
|
async trySupersede(channelId, row, heldGroupId) {
|
|
2624
|
+
const refuse = (msg) => {
|
|
2625
|
+
this.recordDecryptError(row, new Error(msg));
|
|
2626
|
+
const now = Date.now();
|
|
2627
|
+
for (const [k, v] of this.supersedeRefusals) {
|
|
2628
|
+
if (now >= v.until)
|
|
2629
|
+
this.supersedeRefusals.delete(k);
|
|
2630
|
+
}
|
|
2631
|
+
this.supersedeRefusals.set(`${channelId}:${row.group_id}`, {
|
|
2632
|
+
reason: msg,
|
|
2633
|
+
until: now + _MlsGroupKeyProvider.SUPERSEDE_REFUSAL_BACKOFF_MS
|
|
2634
|
+
});
|
|
2635
|
+
return false;
|
|
2636
|
+
};
|
|
2474
2637
|
const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
|
|
2475
2638
|
if (!owner || !owner.publicKeyHex) {
|
|
2476
2639
|
this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
|
|
@@ -2483,33 +2646,27 @@ var MlsGroupKeyProvider = class {
|
|
|
2483
2646
|
}
|
|
2484
2647
|
const att = await this.o.backend.fetchResetAttestation(row.group_id);
|
|
2485
2648
|
if (!att) {
|
|
2486
|
-
|
|
2487
|
-
return false;
|
|
2649
|
+
return refuse(`supersede refused: no attestation for group ${row.group_id}`);
|
|
2488
2650
|
}
|
|
2489
2651
|
if (att.signerIdentityId !== owner.identityId) {
|
|
2490
|
-
|
|
2491
|
-
return false;
|
|
2652
|
+
return refuse("supersede refused: signer is not the pinned owner");
|
|
2492
2653
|
}
|
|
2493
2654
|
let content;
|
|
2494
2655
|
try {
|
|
2495
2656
|
content = JSON.parse(att.signedContent);
|
|
2496
2657
|
} catch {
|
|
2497
|
-
|
|
2498
|
-
return false;
|
|
2658
|
+
return refuse("supersede refused: unparseable signed_content");
|
|
2499
2659
|
}
|
|
2500
2660
|
if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
|
|
2501
|
-
|
|
2502
|
-
return false;
|
|
2661
|
+
return refuse("supersede refused: attestation channel/group mismatch");
|
|
2503
2662
|
}
|
|
2504
2663
|
const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
|
|
2505
2664
|
if (!ok) {
|
|
2506
|
-
|
|
2507
|
-
return false;
|
|
2665
|
+
return refuse("supersede refused: signature does not verify against pinned owner key");
|
|
2508
2666
|
}
|
|
2509
2667
|
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2510
2668
|
if (!bundle) {
|
|
2511
|
-
|
|
2512
|
-
return false;
|
|
2669
|
+
return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
|
|
2513
2670
|
}
|
|
2514
2671
|
return await this.locks.run(channelId, async () => {
|
|
2515
2672
|
const mgr = new MLSGroupManager();
|
|
@@ -2534,14 +2691,14 @@ var MlsGroupKeyProvider = class {
|
|
|
2534
2691
|
return false;
|
|
2535
2692
|
}
|
|
2536
2693
|
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
2537
|
-
|
|
2538
|
-
return false;
|
|
2694
|
+
return refuse("supersede refused: post-join group id != attested new_group_id");
|
|
2539
2695
|
}
|
|
2540
2696
|
this.evictChannelState(channelId);
|
|
2541
2697
|
await this.o.store.delete(channelId);
|
|
2542
2698
|
await this.o.store.discardStaging(channelId);
|
|
2543
2699
|
await this.o.store.addRetiredGroupId(channelId, heldGroupId);
|
|
2544
2700
|
this.errors.delete(channelId);
|
|
2701
|
+
this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
|
|
2545
2702
|
this.managers.set(channelId, mgr);
|
|
2546
2703
|
await this.o.store.save(channelId, mgr.exportState());
|
|
2547
2704
|
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
@@ -2550,16 +2707,45 @@ var MlsGroupKeyProvider = class {
|
|
|
2550
2707
|
}
|
|
2551
2708
|
async receiveOnce(deviceId) {
|
|
2552
2709
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2553
|
-
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
|
|
2554
|
-
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(", "));
|
|
2555
|
-
}
|
|
2556
2710
|
const appEntries = [];
|
|
2557
2711
|
const handshakeAcked = [];
|
|
2558
2712
|
const supersededChannelIds = [];
|
|
2559
|
-
const
|
|
2560
|
-
rows.
|
|
2561
|
-
|
|
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) {
|
|
2562
2745
|
try {
|
|
2746
|
+
if (row.message_type !== "welcome" && row.connect_channel_id) {
|
|
2747
|
+
await classCtxFor(row.connect_channel_id);
|
|
2748
|
+
}
|
|
2563
2749
|
if (row.message_type === "welcome") {
|
|
2564
2750
|
const wcid = row.connect_channel_id;
|
|
2565
2751
|
if (wcid) {
|
|
@@ -2578,9 +2764,15 @@ var MlsGroupKeyProvider = class {
|
|
|
2578
2764
|
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
2579
2765
|
handshakeAcked.push(row.queue_id);
|
|
2580
2766
|
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2767
|
+
if (this.supersedeBackoffActive(wcid, row.group_id)) {
|
|
2768
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2769
|
+
console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
|
|
2770
|
+
}
|
|
2771
|
+
} else {
|
|
2772
|
+
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
2773
|
+
if (ok)
|
|
2774
|
+
supersededChannelIds.push(wcid);
|
|
2775
|
+
}
|
|
2584
2776
|
}
|
|
2585
2777
|
} else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2586
2778
|
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)`);
|
|
@@ -2590,15 +2782,25 @@ var MlsGroupKeyProvider = class {
|
|
|
2590
2782
|
const cid = row.connect_channel_id;
|
|
2591
2783
|
if (!cid)
|
|
2592
2784
|
continue;
|
|
2785
|
+
let advancedEpoch;
|
|
2593
2786
|
await this.locks.run(cid, async () => {
|
|
2594
2787
|
const mgr = await this.manager(cid);
|
|
2595
2788
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
2596
2789
|
await this.o.store.save(cid, mgr.exportState());
|
|
2790
|
+
advancedEpoch = Number(mgr.epoch);
|
|
2597
2791
|
});
|
|
2598
2792
|
this.recordDecryptSuccess(cid);
|
|
2793
|
+
if (advancedEpoch !== void 0) {
|
|
2794
|
+
const ctx = classCtx.get(cid);
|
|
2795
|
+
if (ctx)
|
|
2796
|
+
ctx.liveCommitEpoch = advancedEpoch;
|
|
2797
|
+
}
|
|
2599
2798
|
handshakeAcked.push(row.queue_id);
|
|
2600
2799
|
} else {
|
|
2601
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
|
+
}
|
|
2602
2804
|
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
2603
2805
|
const aad = await buildAssociatedData({
|
|
2604
2806
|
message_id: envelope.message_id,
|
|
@@ -2626,18 +2828,25 @@ var MlsGroupKeyProvider = class {
|
|
|
2626
2828
|
this.recordDecryptError(row, err);
|
|
2627
2829
|
const cid = row.connect_channel_id;
|
|
2628
2830
|
let drained = false;
|
|
2629
|
-
if (cid &&
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
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") {
|
|
2638
2843
|
handshakeAcked.push(row.queue_id);
|
|
2639
2844
|
this.permanentlySkipped++;
|
|
2640
2845
|
drained = true;
|
|
2846
|
+
} else if (verdict === "retiredDrained") {
|
|
2847
|
+
handshakeAcked.push(row.queue_id);
|
|
2848
|
+
this.retiredDrained++;
|
|
2849
|
+
drained = true;
|
|
2641
2850
|
}
|
|
2642
2851
|
}
|
|
2643
2852
|
if (!drained && cid) {
|
|
@@ -2645,8 +2854,10 @@ var MlsGroupKeyProvider = class {
|
|
|
2645
2854
|
if (e) {
|
|
2646
2855
|
e.wedgedRun += 1;
|
|
2647
2856
|
if (e.wedgedRun >= this.quarantineThreshold) {
|
|
2648
|
-
|
|
2649
|
-
|
|
2857
|
+
if (row.message_type !== "welcome") {
|
|
2858
|
+
handshakeAcked.push(row.queue_id);
|
|
2859
|
+
this.quarantineDrained++;
|
|
2860
|
+
}
|
|
2650
2861
|
this.quarantineChannel(cid);
|
|
2651
2862
|
}
|
|
2652
2863
|
}
|
|
@@ -3283,19 +3494,8 @@ var RestMlsBackend = class {
|
|
|
3283
3494
|
created: j.created === void 0 ? true : Boolean(j.created)
|
|
3284
3495
|
};
|
|
3285
3496
|
}
|
|
3286
|
-
async reinitConnectChannelGroup(
|
|
3287
|
-
|
|
3288
|
-
if (!committer)
|
|
3289
|
-
throw new Error("reinitConnectChannelGroup: empty member set");
|
|
3290
|
-
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
3291
|
-
member_device_ids: memberDeviceIds,
|
|
3292
|
-
committer_device_id: committer
|
|
3293
|
-
});
|
|
3294
|
-
return {
|
|
3295
|
-
group_id: String(j.group_id),
|
|
3296
|
-
current_epoch: Number(j.current_epoch),
|
|
3297
|
-
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
3298
|
-
};
|
|
3497
|
+
async reinitConnectChannelGroup() {
|
|
3498
|
+
throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
|
|
3299
3499
|
}
|
|
3300
3500
|
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
3301
3501
|
const committer = memberDeviceIds[0];
|
|
@@ -3313,7 +3513,11 @@ var RestMlsBackend = class {
|
|
|
3313
3513
|
return {
|
|
3314
3514
|
group_id: String(j.group_id),
|
|
3315
3515
|
current_epoch: Number(j.current_epoch),
|
|
3316
|
-
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
3516
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
|
|
3517
|
+
// Absent on a backend that predates the dropped-member report; an empty
|
|
3518
|
+
// list is the correct read of "the server told us nothing", since the old
|
|
3519
|
+
// server also never dropped anyone for an expired KeyPackage.
|
|
3520
|
+
stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
|
|
3317
3521
|
};
|
|
3318
3522
|
}
|
|
3319
3523
|
async fetchKeyPackages(deviceIds) {
|
|
@@ -5211,11 +5415,33 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5211
5415
|
return;
|
|
5212
5416
|
}
|
|
5213
5417
|
this.providerCapabilityMissing = false;
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5418
|
+
const enclaves = await this.enclavesToCover();
|
|
5419
|
+
this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
|
|
5420
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5421
|
+
const chans = [];
|
|
5422
|
+
const failed = [];
|
|
5423
|
+
let listedAny = false;
|
|
5424
|
+
for (const encId of enclaves) {
|
|
5425
|
+
try {
|
|
5426
|
+
for (const c of await this.listChannels(encId)) {
|
|
5427
|
+
if (seen.has(c.channel_id)) continue;
|
|
5428
|
+
seen.add(c.channel_id);
|
|
5429
|
+
chans.push(c);
|
|
5430
|
+
}
|
|
5431
|
+
listedAny = true;
|
|
5432
|
+
this.enclaveListEverSucceeded.add(encId);
|
|
5433
|
+
this.enclaveListWarned.delete(encId);
|
|
5434
|
+
} catch (e) {
|
|
5435
|
+
failed.push(encId);
|
|
5436
|
+
if (!this.enclaveListWarned.has(encId)) {
|
|
5437
|
+
this.enclaveListWarned.add(encId);
|
|
5438
|
+
console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
|
|
5439
|
+
}
|
|
5440
|
+
}
|
|
5441
|
+
}
|
|
5442
|
+
this.sweepEnclavesFailed = failed;
|
|
5443
|
+
if (!listedAny) {
|
|
5444
|
+
console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
|
|
5219
5445
|
return;
|
|
5220
5446
|
}
|
|
5221
5447
|
for (const c of chans) {
|
|
@@ -5561,7 +5787,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5561
5787
|
* the other enclaves in the set.
|
|
5562
5788
|
*/
|
|
5563
5789
|
async refreshChannelMeta() {
|
|
5564
|
-
const enclaves =
|
|
5790
|
+
const enclaves = await this.enclavesToCover();
|
|
5565
5791
|
const now = Date.now();
|
|
5566
5792
|
for (const encId of enclaves) {
|
|
5567
5793
|
try {
|
|
@@ -5574,6 +5800,89 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5574
5800
|
}
|
|
5575
5801
|
}
|
|
5576
5802
|
}
|
|
5803
|
+
/** Cache for the server-side enclave enumeration (see enclavesToCover). */
|
|
5804
|
+
enclaveEnumCache = null;
|
|
5805
|
+
/** Enclaves whose channel listing failed on the NEWEST sweep (audit HIGH:
|
|
5806
|
+
* skip-one deleted the sweep's partial-coverage signal — observations_stale
|
|
5807
|
+
* is a global max, so one healthy enclave masked a failed one). Surfaced on
|
|
5808
|
+
* health() so partial coverage is never silent. */
|
|
5809
|
+
sweepEnclavesFailed = [];
|
|
5810
|
+
/** Enclave ids whose channel listing has EVER succeeded this process, seeded
|
|
5811
|
+
* with HOME (home is always expected to list; without the seed, a home
|
|
5812
|
+
* failure right after a restart would read as "never succeeded" and slip
|
|
5813
|
+
* through the expected-gap branch — weaker than the old abort-on-failure).
|
|
5814
|
+
* A failure of a member of this set is a REGRESSION and degrades health; a
|
|
5815
|
+
* failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
|
|
5816
|
+
* pending backend authz) is an EXPECTED gap — reported, not degrading, or
|
|
5817
|
+
* every federated agent would peg degraded forever. */
|
|
5818
|
+
enclaveListEverSucceeded = /* @__PURE__ */ new Set();
|
|
5819
|
+
/** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
|
|
5820
|
+
* a permanently-404ing partner enclave must not log 1440 lines a day. */
|
|
5821
|
+
enclaveListWarned = /* @__PURE__ */ new Set();
|
|
5822
|
+
static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
|
|
5823
|
+
enclaveEnumWarned = false;
|
|
5824
|
+
/**
|
|
5825
|
+
* C1 (split-brain Task 3): every enclave this agent must COVER — for the
|
|
5826
|
+
* self-heal sweep and for channel-meta refresh alike. ONE helper on purpose:
|
|
5827
|
+
* these two call sites had drifted (refreshChannelMeta looped home+learned,
|
|
5828
|
+
* selfHealSweep looped nothing), and the drift is exactly what left both
|
|
5829
|
+
* production agents with zero recovery coverage in their second enclave.
|
|
5830
|
+
*
|
|
5831
|
+
* The set is SERVER enumeration ∪ home ∪ store-learned:
|
|
5832
|
+
*
|
|
5833
|
+
* - SERVER (`GET /api/v1/enclaves`, membership-scoped, federated included)
|
|
5834
|
+
* is the authoritative source. The store CANNOT be: channel_meta's only
|
|
5835
|
+
* writers are listChannels itself (circular — you learn enclave B by
|
|
5836
|
+
* having already listed B) and the receive pump's successfully-decrypted
|
|
5837
|
+
* loop, and a FORKED agent decrypts nothing, so the agent that most needs
|
|
5838
|
+
* sweeping in enclave B is precisely the one whose store never learned B.
|
|
5839
|
+
* - home + learned stay in the union as the degraded-mode floor: if the
|
|
5840
|
+
* enumeration endpoint is down, we sweep what we can rather than nothing,
|
|
5841
|
+
* and we warn (once) so the gap is diagnosable.
|
|
5842
|
+
*
|
|
5843
|
+
* The server result is cached for ENCLAVE_ENUM_TTL_MS so per-ingest-cycle
|
|
5844
|
+
* callers (refreshChannelMeta) do not add an HTTP round-trip per cycle;
|
|
5845
|
+
* membership changes are picked up within one TTL, which matches the sweep
|
|
5846
|
+
* cadence this exists to serve.
|
|
5847
|
+
*
|
|
5848
|
+
* ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
|
|
5849
|
+
* enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
|
|
5850
|
+
* under this agent's bearer — so a partner enclave in this set still yields
|
|
5851
|
+
* no channels. Callers must treat a per-enclave listing failure as skip-one,
|
|
5852
|
+
* never abort-all. Closing that needs backend authz work, not daemon work.
|
|
5853
|
+
*/
|
|
5854
|
+
async enclavesToCover() {
|
|
5855
|
+
const now = Date.now();
|
|
5856
|
+
if (!this.enclaveEnumCache || now - this.enclaveEnumCache.at > _BridgeAgent.ENCLAVE_ENUM_TTL_MS) {
|
|
5857
|
+
try {
|
|
5858
|
+
const url = `${this.cfg.apiBase}/api/v1/enclaves?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
5859
|
+
const res = await this.fetchImpl(url, {
|
|
5860
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
5861
|
+
});
|
|
5862
|
+
if (!res.ok) throw new Error(`list_enclaves failed: ${res.status}`);
|
|
5863
|
+
const rows = await res.json();
|
|
5864
|
+
this.enclaveEnumCache = { ids: rows.map((r) => r.enclave_id), at: Date.now() };
|
|
5865
|
+
this.enclaveEnumWarned = false;
|
|
5866
|
+
} catch (e) {
|
|
5867
|
+
if (!this.enclaveEnumWarned) {
|
|
5868
|
+
this.enclaveEnumWarned = true;
|
|
5869
|
+
console.warn(
|
|
5870
|
+
"[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
|
|
5871
|
+
e
|
|
5872
|
+
);
|
|
5873
|
+
}
|
|
5874
|
+
}
|
|
5875
|
+
}
|
|
5876
|
+
const fresh = this.enclaveEnumCache && Date.now() - this.enclaveEnumCache.at <= _BridgeAgent.ENCLAVE_ENUM_TTL_MS;
|
|
5877
|
+
if (fresh) {
|
|
5878
|
+
return /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.enclaveEnumCache.ids]);
|
|
5879
|
+
}
|
|
5880
|
+
return /* @__PURE__ */ new Set([
|
|
5881
|
+
this.cfg.enclaveId,
|
|
5882
|
+
...this.store.distinctEnclaveIds(),
|
|
5883
|
+
...this.enclaveEnumCache?.ids ?? []
|
|
5884
|
+
]);
|
|
5885
|
+
}
|
|
5577
5886
|
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
5578
5887
|
freshRoster(channelId) {
|
|
5579
5888
|
const hit = this.channelRoster.get(channelId);
|
|
@@ -6170,6 +6479,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6170
6479
|
const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
|
|
6171
6480
|
const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
|
|
6172
6481
|
const divergedChannels = channels.filter((o) => o.diverged === true).length;
|
|
6482
|
+
const sweepCoverageLost = this.sweepEnclavesFailed.some(
|
|
6483
|
+
(id) => this.enclaveListEverSucceeded.has(id)
|
|
6484
|
+
);
|
|
6173
6485
|
const liveChannels = channels.map((o) => ({
|
|
6174
6486
|
...o,
|
|
6175
6487
|
last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
|
|
@@ -6184,8 +6496,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6184
6496
|
observations_stale: observationsStale,
|
|
6185
6497
|
diverged_channels: divergedChannels,
|
|
6186
6498
|
// A2: the truthful roll-up gates read instead of `status` (D-3).
|
|
6187
|
-
degraded: divergedChannels > 0 || observationsStale,
|
|
6499
|
+
degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
|
|
6188
6500
|
channels: liveChannels,
|
|
6501
|
+
sweep_enclaves_failed: [...this.sweepEnclavesFailed],
|
|
6189
6502
|
identity_id: this.cfg.identityId,
|
|
6190
6503
|
joined_channels: joined,
|
|
6191
6504
|
buffered: this.buffer.length,
|
|
@@ -6193,6 +6506,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6193
6506
|
filtered_room: this.filteredRoomMessages,
|
|
6194
6507
|
rate_limited: this.rateLimitedSends,
|
|
6195
6508
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
6509
|
+
quarantine_drained: this.provider.quarantineDrainedCount(),
|
|
6510
|
+
retired_drained: this.provider.retiredDrainedCount(),
|
|
6196
6511
|
failed_attachments: this.failedAttachments,
|
|
6197
6512
|
delivery_pings: this.pingsReceived,
|
|
6198
6513
|
agent_sessions: this.authSessions,
|
|
@@ -6238,8 +6553,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
6238
6553
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
6239
6554
|
rateLimitedSends: this.rateLimitedSends,
|
|
6240
6555
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
6556
|
+
quarantineDrained: this.provider.quarantineDrainedCount(),
|
|
6557
|
+
retiredDrained: this.provider.retiredDrainedCount(),
|
|
6241
6558
|
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
6242
|
-
failedAttachments: this.failedAttachments
|
|
6559
|
+
failedAttachments: this.failedAttachments,
|
|
6560
|
+
build: BRIDGE_VERSION
|
|
6243
6561
|
};
|
|
6244
6562
|
}
|
|
6245
6563
|
async close() {
|