@securecoms/agent-bridge 0.11.0 → 0.11.2

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
@@ -1216,6 +1216,28 @@ async function agentPausedErrorFromResponse(res) {
1216
1216
  const err = detail?.error;
1217
1217
  return err === "agent_paused" ? new AgentPausedError() : null;
1218
1218
  }
1219
+ var MalformedCommitterWorkError = class extends ConnectSdkError {
1220
+ channelId;
1221
+ field;
1222
+ receivedType;
1223
+ constructor(channelId, field, receivedType) {
1224
+ super(`committer-work for channel ${channelId} returned a malformed body: \`${field}\` is ${receivedType}, expected array`);
1225
+ this.channelId = channelId;
1226
+ this.field = field;
1227
+ this.receivedType = receivedType;
1228
+ }
1229
+ };
1230
+ var MalformedGroupRosterError = class extends ConnectSdkError {
1231
+ endpoint;
1232
+ channelId;
1233
+ receivedType;
1234
+ constructor(endpoint, channelId, receivedType) {
1235
+ super(`${endpoint} for channel ${channelId} returned a malformed group roster: \`member_device_ids\` is ${receivedType}, expected a non-empty array`);
1236
+ this.endpoint = endpoint;
1237
+ this.channelId = channelId;
1238
+ this.receivedType = receivedType;
1239
+ }
1240
+ };
1219
1241
 
1220
1242
  // ../connect-sdk-ts/dist/envelope-builder.js
1221
1243
  async function buildOutboundEnvelope(p) {
@@ -1324,8 +1346,23 @@ import { hmac } from "@noble/hashes/hmac";
1324
1346
  import { sha256 as sha2562 } from "@noble/hashes/sha256";
1325
1347
  import { bytesToHex } from "@agentvault/crypto";
1326
1348
 
1349
+ // ../connect-sdk-ts/dist/groupkey/crypto-patch-guard.js
1350
+ import { MLSGroupManager } from "@agentvault/crypto";
1351
+ var CryptoPatchMissingError = class extends Error {
1352
+ constructor() {
1353
+ super("MLSGroupManager.leafIndicesForKeyPackage is missing \u2014 the @agentvault/crypto patch did not apply. Expected patches/@agentvault+crypto+0.4.1.patch to be applied by the root postinstall (`patch-package --error-on-fail --error-on-warn`). Re-run `npm ci` at the repo root, and check that @agentvault/crypto resolved to exactly 0.4.1.");
1354
+ this.name = "CryptoPatchMissingError";
1355
+ }
1356
+ };
1357
+ function leafIndicesForKeyPackage(mgr, kp) {
1358
+ if (typeof MLSGroupManager.prototype.leafIndicesForKeyPackage !== "function") {
1359
+ throw new CryptoPatchMissingError();
1360
+ }
1361
+ return mgr.leafIndicesForKeyPackage(kp);
1362
+ }
1363
+
1327
1364
  // ../connect-sdk-ts/dist/groupkey/mls.js
1328
- import { MLSGroupManager, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1365
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1329
1366
 
1330
1367
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1331
1368
  var MlsOperationQueue = class {
@@ -1389,6 +1426,61 @@ function classifyFailedRow(i) {
1389
1426
  return "defer";
1390
1427
  }
1391
1428
 
1429
+ // ../connect-sdk-ts/dist/groupkey/admission-rule.js
1430
+ function canAdmit(input) {
1431
+ if (input.targetDeviceId === input.ownDeviceId)
1432
+ return { decision: "refuse", reason: "self" };
1433
+ if (input.boundLeaves.some((b) => b.device === input.targetDeviceId)) {
1434
+ return { decision: "refuse", reason: "already-bound" };
1435
+ }
1436
+ if (input.targetLeaves.length > 0) {
1437
+ if (input.deltaVouches)
1438
+ return { decision: "refuse", reason: "identity-leaf-present" };
1439
+ if (input.targetLeaves.length > 1)
1440
+ return { decision: "refuse", reason: "identity-leaf-present" };
1441
+ const t = input.targetLeaves[0];
1442
+ if (!input.identityLeaves.some((l) => l.leafIndex === t))
1443
+ return { decision: "refuse", reason: "already-bound" };
1444
+ if (input.identityLeaves.some((l) => l.leafIndex !== t && l.leafIndex !== input.ownLeafIndex))
1445
+ return { decision: "refuse", reason: "identity-leaf-present" };
1446
+ return { decision: "already-present" };
1447
+ }
1448
+ if (input.targetIdentityId === null)
1449
+ return { decision: "refuse", reason: "unknown-identity" };
1450
+ if (input.identityLeaves.length === 0)
1451
+ return { decision: "admit" };
1452
+ const allUnbound = input.identityLeaves.every((l) => l.device === null);
1453
+ const ownLeafUnreadable = allUnbound && input.ownLeafIndex === null;
1454
+ const exemptionApplies = allUnbound && input.ownLeafIndex !== null && input.identityLeaves.every((l) => l.leafIndex === input.ownLeafIndex);
1455
+ if (exemptionApplies)
1456
+ return { decision: "admit" };
1457
+ if (input.deltaVouches)
1458
+ return { decision: "admit" };
1459
+ return {
1460
+ decision: "refuse",
1461
+ reason: ownLeafUnreadable ? "unknown-own-leaf" : "identity-leaf-present"
1462
+ };
1463
+ }
1464
+ function hasMalformedBinding(identityLeaves) {
1465
+ return identityLeaves.some((l) => l.malformed);
1466
+ }
1467
+
1468
+ // ../connect-sdk-ts/dist/groupkey/kp-expiry.js
1469
+ var SKEW_GUARD_MS = 5 * 60 * 1e3;
1470
+ function isServerExpired(exp, nowMs) {
1471
+ return nowMs - SKEW_GUARD_MS >= exp.getTime();
1472
+ }
1473
+
1474
+ // ../connect-sdk-ts/dist/sodium-ready.js
1475
+ import { generateEphemeralKeypair } from "@agentvault/crypto";
1476
+ var warming = null;
1477
+ function ensureSodiumReady() {
1478
+ return warming ??= generateEphemeralKeypair().then(() => void 0).catch((e) => {
1479
+ warming = null;
1480
+ throw e;
1481
+ });
1482
+ }
1483
+
1392
1484
  // ../connect-sdk-ts/dist/groupkey/mls.js
1393
1485
  var LABEL = "agentvault-connect-v1";
1394
1486
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1436,12 +1528,34 @@ var ForgedCredentialError = class extends Error {
1436
1528
  this.name = "ForgedCredentialError";
1437
1529
  }
1438
1530
  };
1531
+ var SelfAddRefusedError = class extends Error {
1532
+ deviceId;
1533
+ channelId;
1534
+ constructor(deviceId, channelId) {
1535
+ super(`addMemberToLiveGroup: refusing to Add this committer's OWN device ${deviceId} to channel ${channelId} \u2014 a self-Add mints a second, permanent leaf for a device that already holds one. Terminal; this is never a retryable condition.`);
1536
+ this.deviceId = deviceId;
1537
+ this.channelId = channelId;
1538
+ this.name = "SelfAddRefusedError";
1539
+ }
1540
+ };
1541
+ var AddRefusedError = class extends Error {
1542
+ reason;
1543
+ deviceId;
1544
+ identityId;
1545
+ identityLeafCount;
1546
+ constructor(reason, deviceId, identityId, identityLeafCount) {
1547
+ super(`addMemberToLiveGroup: refusing Add of device ${deviceId} (${reason}) \u2014 identity ${identityId ?? "<undecodable>"} holds ${identityLeafCount} leaf/leaves in this device's view of the tree. A plain Add here risks an irreversible duplicate leaf; the repair path is Remove+Add, never a plain Add.`);
1548
+ this.reason = reason;
1549
+ this.deviceId = deviceId;
1550
+ this.identityId = identityId;
1551
+ this.identityLeafCount = identityLeafCount;
1552
+ this.name = "AddRefusedError";
1553
+ }
1554
+ };
1439
1555
  var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1440
1556
  o;
1441
1557
  suite;
1442
1558
  managers = /* @__PURE__ */ new Map();
1443
- /** One-time libsodium (WASM) warm-up guard — see ensureCryptoReady(). */
1444
- cryptoWarmed = false;
1445
1559
  /**
1446
1560
  * Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
1447
1561
  * manager-mutating operation on a channel takes this lock keyed by channelId,
@@ -1707,6 +1821,24 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1707
1821
  * ONE active leaf. Used by the guest reconcile sweep when an approved guest
1708
1822
  * sits in `joining`.
1709
1823
  *
1824
+ * ⛔ AS OF SLICE B (ADR-0014 Decision B) THE WORKING DEVICE SWAP IS GONE.
1825
+ * When `priorDeviceId` names a device whose leaf still carries this guest
1826
+ * identity — the case the swap existed for — the Add chokepoint's admission
1827
+ * rule refuses (AddRefusedError, reason `identity-leaf-present`) and this
1828
+ * method THROWS before reaching the eviction below.
1829
+ *
1830
+ * ⚠️ That does NOT make the eviction block unreachable, and an earlier
1831
+ * revision of this note wrongly said it did. Pass a `priorDeviceId` that
1832
+ * differs from `newDeviceId` while the guest identity holds NO leaf and the
1833
+ * rule admits on clause 2's first arm, the Add commits, and the block DOES
1834
+ * run — then throws `LeafRosterMismatchError`, because the identity owns one
1835
+ * leaf where the default `orderedDeviceIds` names two. So the reachable
1836
+ * outcomes for a caller passing `priorDeviceId` are a refusal or a roster
1837
+ * mismatch, never a completed swap. The block is retained because it is still
1838
+ * correct and is what a Remove-then-Add re-enablement (or slice 5's
1839
+ * device-bound leaf) will build on. See the body comment for why the
1840
+ * count-only attestation that used to open that path was retired.
1841
+ *
1710
1842
  * Add then evict: we commit the Add first so the new leaf exists before the
1711
1843
  * old one is dropped (the guest is never momentarily leaf-less in the group).
1712
1844
  * The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
@@ -1722,16 +1854,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1722
1854
  * equals newDeviceId (the same device re-admitted).
1723
1855
  */
1724
1856
  async admitGuestDevice(channelId, g) {
1725
- await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1726
- knownLeafDevices: g.priorDeviceId ? [g.priorDeviceId] : []
1857
+ const outcome = await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1858
+ expectedIdentityId: g.identityId
1727
1859
  });
1728
- if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1860
+ if (outcome === "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1729
1861
  const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
1730
1862
  await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
1731
1863
  identity: g.identityId,
1732
1864
  orderedDeviceIds
1733
1865
  });
1866
+ } else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1867
+ console.warn(`admitGuestDevice: channel ${channelId} \u2014 Add of ${g.newDeviceId} was a no-op (${outcome}), so the eviction of prior device ${g.priorDeviceId} was SKIPPED. The prior leaf is intact and no swap occurred; re-drive the swap only after the new device holds its own leaf.`);
1734
1868
  }
1869
+ return outcome;
1735
1870
  }
1736
1871
  /** Load (or reuse) the per-channel manager from the store. Throws if no state. */
1737
1872
  async manager(channelId) {
@@ -1744,7 +1879,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1744
1879
  return again;
1745
1880
  if (!saved)
1746
1881
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1747
- const mgr = new MLSGroupManager(saved.ciphersuite);
1882
+ const mgr = new MLSGroupManager2(saved.ciphersuite);
1748
1883
  mgr.importState(saved);
1749
1884
  this.managers.set(channelId, mgr);
1750
1885
  return mgr;
@@ -1945,27 +2080,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1945
2080
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
1946
2081
  return w?.connect_channel_id ?? null;
1947
2082
  }
1948
- /**
1949
- * Initialize the ts-mls libsodium (WASM) backend ONCE before any store
1950
- * operation that deserializes a persisted KeyPackage. generateKeyPackage
1951
- * awaits sodium.ready and is pure in-memory (it neither publishes to the
1952
- * backend nor writes the store), so the throwaway bundle is a side-effect-free
1953
- * warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
1954
- * guard, and the join path) runs while WASM is still cold, throws deep in the
1955
- * crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
1956
- * and misread as "no held bundle", and the daemon mints + republishes a FRESH
1957
- * KeyPackage on every restart — orphaning every Welcome sealed to the prior
1958
- * published KP (the root cause of "fresh DM never opens" across restarts).
1959
- * Idempotent and cheap (~ms); the flag makes repeat calls no-ops.
1960
- */
1961
- async ensureCryptoReady() {
1962
- if (this.cryptoWarmed)
1963
- return;
1964
- await new MLSGroupManager().generateKeyPackage(this.o.identity);
1965
- this.cryptoWarmed = true;
1966
- }
1967
2083
  async publishKeyPackage() {
1968
- await this.ensureCryptoReady();
2084
+ await ensureSodiumReady();
1969
2085
  return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
1970
2086
  }
1971
2087
  /**
@@ -2012,7 +2128,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2012
2128
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2013
2129
  if (!bundle)
2014
2130
  return false;
2015
- const heldHex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2131
+ const heldHex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
2016
2132
  const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
2017
2133
  const pubHex = published[this.o.deviceId];
2018
2134
  return !!pubHex && pubHex === heldHex;
@@ -2022,13 +2138,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2022
2138
  }
2023
2139
  async publishKeyPackageUnlocked() {
2024
2140
  if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
2025
- console.warn("publishKeyPackage: pending bundle exists; skipping republish");
2026
- return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2141
+ const kpId = await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2142
+ const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
2143
+ const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
2144
+ if (!expired) {
2145
+ console.warn("publishKeyPackage: pending bundle exists; skipping republish");
2146
+ return kpId;
2147
+ }
2148
+ console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
2149
+ await this.o.store.deletePendingBundle(this.o.deviceId);
2027
2150
  }
2028
2151
  console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
2029
- const mgr = new MLSGroupManager();
2152
+ const mgr = new MLSGroupManager2();
2030
2153
  const bundle = await mgr.generateKeyPackage(this.o.identity);
2031
- const hex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2154
+ const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
2032
2155
  const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
2033
2156
  await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
2034
2157
  return id;
@@ -2045,7 +2168,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2045
2168
  async ensureSessionUnlocked(channelId, opts) {
2046
2169
  if (!opts.forceReinit && await this.o.store.load(channelId))
2047
2170
  return;
2048
- await this.ensureCryptoReady();
2171
+ await ensureSodiumReady();
2049
2172
  let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2050
2173
  if (!bundle && (opts.bootstrap || opts.forceReinit)) {
2051
2174
  await this.publishKeyPackage();
@@ -2056,14 +2179,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2056
2179
  if (opts.forceReinit) {
2057
2180
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
2058
2181
  const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
2059
- const mgr = new MLSGroupManager();
2182
+ const mgr = new MLSGroupManager2();
2060
2183
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2061
2184
  const kps = await this.o.backend.fetchKeyPackages(others);
2062
2185
  const pkgs = others.map((d) => {
2063
2186
  const hex = kps[d];
2064
2187
  if (!hex)
2065
2188
  throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
2066
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2189
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2067
2190
  });
2068
2191
  const { commit, welcome } = await mgr.addMembers(pkgs);
2069
2192
  await this.o.backend.distribute(reinit.group_id, {
@@ -2082,7 +2205,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2082
2205
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
2083
2206
  if (init.created === false)
2084
2207
  throw new BootstrapRaceLostError();
2085
- const mgr = new MLSGroupManager();
2208
+ const mgr = new MLSGroupManager2();
2086
2209
  await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
2087
2210
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
2088
2211
  const kps = await this.o.backend.fetchKeyPackages(others);
@@ -2090,7 +2213,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2090
2213
  const hex = kps[d];
2091
2214
  if (!hex)
2092
2215
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
2093
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2216
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2094
2217
  });
2095
2218
  const { commit, welcome } = await mgr.addMembers(pkgs);
2096
2219
  await this.o.backend.distribute(init.group_id, {
@@ -2108,7 +2231,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2108
2231
  const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
2109
2232
  if (!w)
2110
2233
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
2111
- const mgr = new MLSGroupManager();
2234
+ const mgr = new MLSGroupManager2();
2112
2235
  try {
2113
2236
  await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
2114
2237
  } catch (err) {
@@ -2144,36 +2267,66 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2144
2267
  const hex = kps[newDeviceId];
2145
2268
  if (!hex)
2146
2269
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
2147
- const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2148
- if (_attempt === 0) {
2149
- let adds;
2150
- try {
2151
- adds = (await this.o.backend.getCommitterWork(channelId)).adds;
2152
- } catch (e) {
2153
- throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} \u2014 refusing plain Add for device ${newDeviceId} (fail closed): ` + (e instanceof Error ? e.message : String(e)));
2270
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2271
+ const cred = pkg.leafNode.credential;
2272
+ const claimedIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2273
+ if (opts?.expectedIdentityId && claimedIdentity !== opts.expectedIdentityId) {
2274
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimedIdentity ?? "");
2275
+ }
2276
+ const targetLeaves = leafIndicesForKeyPackage(mgr, pkg);
2277
+ let deltaVouches = false;
2278
+ try {
2279
+ deltaVouches = (await this.o.backend.getCommitterWork(channelId)).adds.includes(newDeviceId);
2280
+ } catch (e) {
2281
+ if (targetLeaves.length > 0) {
2282
+ throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} while device ${newDeviceId}'s published KeyPackage already holds ${targetLeaves.length} leaf/leaves here (indices ${targetLeaves.join(",")}) \u2014 refusing to report a no-op success on a delta we could not read (fail closed): ` + (e instanceof Error ? e.message : String(e)));
2154
2283
  }
2155
- if (!adds.includes(newDeviceId)) {
2156
- const cred = pkg.leafNode.credential;
2157
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2158
- const leaves = claimed === null ? null : mgr.leafIndicesForIdentity(claimed);
2159
- if (leaves === null || leaves.length > 0) {
2160
- const known = opts?.knownLeafDevices ? [...new Set(opts.knownLeafDevices)] : null;
2161
- const swapSafe = leaves !== null && known !== null && !known.includes(newDeviceId) && known.length === leaves.length;
2162
- if (!swapSafe) {
2163
- console.warn(`addMemberToLiveGroup: device ${newDeviceId.slice(0, 8)} is outside the committer-work add-delta and its identity already holds a leaf \u2014 terminal skip (no commit)`);
2164
- return;
2165
- }
2284
+ deltaVouches = false;
2285
+ }
2286
+ const identityLeaves = claimedIdentity === null ? [] : mgr.leafIndicesForIdentity(claimedIdentity).map((leafIndex) => ({
2287
+ leafIndex,
2288
+ device: null,
2289
+ // SLICE 5: read the LeafNode extension here.
2290
+ malformed: false
2291
+ // SLICE 5: set from a failed extension decode.
2292
+ }));
2293
+ const ownLeaf = this.ownLeafIndex(mgr);
2294
+ const verdict = canAdmit({
2295
+ targetDeviceId: newDeviceId,
2296
+ targetIdentityId: claimedIdentity,
2297
+ ownDeviceId: this.o.deviceId,
2298
+ ownLeafIndex: ownLeaf,
2299
+ boundLeaves: [],
2300
+ // SLICE 5: the whole-tree bound-leaf scan replaces this literal.
2301
+ identityLeaves,
2302
+ targetLeaves,
2303
+ deltaVouches
2304
+ });
2305
+ if (hasMalformedBinding(identityLeaves)) {
2306
+ console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
2307
+ }
2308
+ if (targetLeaves.length > 0 && deltaVouches) {
2309
+ console.warn(`addMemberToLiveGroup: channel ${channelId} \u2014 the server's add-delta vouches for device ${newDeviceId} while its published KeyPackage is ALREADY a leaf here (indices ${targetLeaves.join(",")}). An honest retry cannot produce this pair: attempt 0's init unions the device into the roster, so the delta can never list it again. Treat as a possible KeyPackage substitution, or as a lost race against a peer's Add that will self-heal on the next sweep pass.`);
2310
+ }
2311
+ if (targetLeaves.length > 1) {
2312
+ console.warn(`addMemberToLiveGroup: channel ${channelId} \u2014 device ${newDeviceId}'s published KeyPackage already occupies ${targetLeaves.length} leaves (indices ${targetLeaves.join(",")}). This is a pre-existing duplicate leaf; nothing on this path can repair it and heal refuses an identity holding more than one leaf.`);
2313
+ }
2314
+ switch (verdict.decision) {
2315
+ case "admit":
2316
+ break;
2317
+ case "already-present":
2318
+ return "already-present";
2319
+ case "refuse":
2320
+ if (verdict.reason === "self") {
2321
+ throw new SelfAddRefusedError(newDeviceId, channelId);
2166
2322
  }
2323
+ throw new AddRefusedError(verdict.reason, newDeviceId, claimedIdentity, identityLeaves.length);
2324
+ default: {
2325
+ const _never = verdict;
2326
+ throw new Error(`unhandled admission verdict: ${JSON.stringify(_never)}`);
2167
2327
  }
2168
2328
  }
2169
2329
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
2170
- if (opts?.expectedIdentityId) {
2171
- const cred = pkg.leafNode.credential;
2172
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
2173
- if (claimed !== opts.expectedIdentityId) {
2174
- throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
2175
- }
2176
- }
2177
2330
  const priorEpoch = Number(mgr.epoch);
2178
2331
  const { commit, welcome } = await mgr.addMembers([pkg]);
2179
2332
  const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
@@ -2195,15 +2348,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2195
2348
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
2196
2349
  } catch {
2197
2350
  }
2198
- try {
2199
- const work = await this.o.backend.getCommitterWork(channelId);
2200
- if (!work.adds.includes(newDeviceId))
2201
- return;
2202
- } catch {
2203
- }
2204
2351
  return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
2205
2352
  }
2206
2353
  await this.o.store.save(channelId, mgr.exportState());
2354
+ return "committed";
2207
2355
  }
2208
2356
  /**
2209
2357
  * Committer-side: REMOVE a member from a live group (Phase C eviction).
@@ -2421,6 +2569,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2421
2569
  * - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
2422
2570
  * leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
2423
2571
  *
2572
+ * ⛔⛔ LIMITATION — A MULTI-DEVICE MEMBER IS UNHEALABLE BY THIS METHOD, AND THE
2573
+ * FAILURE IS SILENT AND DESTRUCTIVE. READ THIS BEFORE CALLING IT.
2574
+ *
2575
+ * Leaf credentials encode the IDENTITY and not the device, so the leaf
2576
+ * resolution above is only exact when the identity's leaf set is unambiguous.
2577
+ * The `> 1` case refuses. The `=== 1` case DOES NOT, and it is the worse one:
2578
+ * heal device B of an identity whose device A holds the only leaf and A is
2579
+ * evicted while the call reports a clean heal. Every downstream check is
2580
+ * satisfied — the cardinality guard in `removeDeviceFromLiveGroupUnlocked`
2581
+ * compares this call site's single-element `orderedDeviceIds` against a
2582
+ * single-element leaf set, a coincidence of counts, and `leaves[0]` is A's.
2583
+ * `mls.heal-multidevice.test.ts` measures exactly that against real MLS.
2584
+ *
2585
+ * ⛔ NO GUARD IS AVAILABLE HERE, and this is a measured conclusion rather than
2586
+ * an unexplored one. The device-level predicate that would settle it (leaf
2587
+ * indices whose LeafNode signature key equals the fetched KeyPackage's — the
2588
+ * same evidence the Add chokepoint uses) was implemented and it refused EVERY
2589
+ * production heal: the repair contract requires the member to republish first,
2590
+ * `forceRepublishKeyPackage` mints a NEW SIGNING KEY, and a diverged device's
2591
+ * fresh KeyPackage therefore matches no leaf, including its own. A guard built
2592
+ * on it is a blanket disable, not a guard.
2593
+ *
2594
+ * ⚠️ CALLER CONTRACT UNTIL SLICE 5: do not drive this for a member you know to
2595
+ * hold more than one device. The caller is the only layer that has a roster,
2596
+ * and nothing in the tree binds a leaf to a device id, so the refusal has to
2597
+ * live above this function. connect-web's `foreign-identity-heal.ts` declines a
2598
+ * member whose identity holds two or more LEAVES; the residual it cannot see —
2599
+ * two DEVICES sharing one leaf — is the shape described above and is open.
2600
+ * It resolves when the leaf credential carries a device binding (slice 5),
2601
+ * which cannot be retrofitted without re-keying every live group.
2602
+ *
2424
2603
  * The Add fetches the device's freshly published KeyPackage from the backend,
2425
2604
  * exactly like a normal add — the CALLER is responsible for the member having
2426
2605
  * re-published one first (mirrors addMemberToLiveGroup's "no published
@@ -2437,7 +2616,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2437
2616
  const hex = kps[memberDeviceId];
2438
2617
  if (!hex)
2439
2618
  throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
2440
- const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
2619
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2620
+ const cred = pkg.leafNode.credential;
2441
2621
  if (cred.credentialType !== "basic") {
2442
2622
  throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
2443
2623
  }
@@ -2450,6 +2630,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2450
2630
  if (leaves.length > 1) {
2451
2631
  throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
2452
2632
  }
2633
+ if (leaves.length === 1) {
2634
+ console.warn(`[heal] channel ${channelId}: about to Remove the single leaf held by identity ${identity} in order to re-Add device ${memberDeviceId}, WITHOUT being able to prove that leaf is that device's. Leaf credentials encode the identity, not the device. If this member holds more than one device, the leaf being evicted may belong to a SIBLING device that was never diverged, and this call will still report a clean heal. Multi-device members are unhealable until leaf\u2192device attribution exists.`);
2635
+ }
2453
2636
  let removeLanded = false;
2454
2637
  if (leaves.length === 1) {
2455
2638
  await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
@@ -2460,13 +2643,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2460
2643
  throw new Error(`healDivergedMember: expected 0 leaves for the Add-only branch but found ${stillZero.length} (a leaf reappeared under the held lock \u2014 refusing to create a duplicate leaf)`);
2461
2644
  }
2462
2645
  }
2646
+ let outcome;
2463
2647
  try {
2464
- await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2648
+ outcome = await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2465
2649
  } catch (e) {
2466
2650
  if (removeLanded)
2467
2651
  throw new HealAddAfterRemoveError(e);
2468
2652
  throw e;
2469
2653
  }
2654
+ if (outcome !== "committed") {
2655
+ const err = new Error(`healDivergedMember: the re-Add of ${memberDeviceId} on channel ${channelId} committed nothing (outcome "${outcome}") \u2014 refusing to report a clean heal for a device that may hold no leaf at the live epoch.`);
2656
+ if (removeLanded)
2657
+ throw new HealAddAfterRemoveError(err);
2658
+ throw err;
2659
+ }
2470
2660
  }));
2471
2661
  }
2472
2662
  /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
@@ -2573,14 +2763,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2573
2763
  throw new Error("resetSessionSupersede: call publishKeyPackage() first");
2574
2764
  const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
2575
2765
  const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
2576
- const mgr = new MLSGroupManager();
2766
+ const mgr = new MLSGroupManager2();
2577
2767
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2578
2768
  const kps = await this.o.backend.fetchKeyPackages(others);
2579
2769
  const pkgs = others.map((d) => {
2580
2770
  const hex = kps[d];
2581
2771
  if (!hex)
2582
2772
  throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
2583
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2773
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2584
2774
  });
2585
2775
  const { commit, welcome } = await mgr.addMembers(pkgs);
2586
2776
  await this.o.backend.distribute(reinit.group_id, {
@@ -2655,6 +2845,29 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2655
2845
  const mgr = await this.manager(channelId);
2656
2846
  return mgr.leafIndicesForIdentity(identityId).length;
2657
2847
  }
2848
+ /**
2849
+ * Cache-only leaf count for `identityId` in `channelId`, or **null** when
2850
+ * there is no in-memory manager (a cold channel).
2851
+ *
2852
+ * The non-throwing sibling of {@link leafCountForIdentity}, with the same
2853
+ * cold-channel semantics as {@link hasLeafForIdentity}. It exists because the
2854
+ * join confirmer needs to know whether a leaf can be ATTRIBUTED to a device,
2855
+ * and a boolean cannot answer that: leaf credentials encode the identity, not
2856
+ * the device, so "the identity holds a leaf" is true off ANY of its devices'
2857
+ * leaves. `null` and any count other than 1 both mean "cannot attribute", and
2858
+ * a caller that clears state on attribution must treat them as such.
2859
+ *
2860
+ * ⚠️ This is still an IDENTITY-level read. It does not name which device
2861
+ * holds which leaf and it cannot until leaves carry a device binding
2862
+ * (ADR-0014 Decision B, slice 5). What it buys today is the ability to REFUSE
2863
+ * to attribute rather than to guess.
2864
+ */
2865
+ leafCountForIdentityCached(channelId, identityId) {
2866
+ const mgr = this.managers.get(channelId);
2867
+ if (!mgr)
2868
+ return null;
2869
+ return mgr.leafIndicesForIdentity(identityId).length;
2870
+ }
2658
2871
  /** Returns true when the live in-memory MLS tree for `channelId` contains at
2659
2872
  * least one leaf bearing `identityId` as its credential. Used by the agent
2660
2873
  * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
@@ -2772,7 +2985,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2772
2985
  return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2773
2986
  }
2774
2987
  return await this.locks.run(channelId, async () => {
2775
- const mgr = new MLSGroupManager();
2988
+ const mgr = new MLSGroupManager2();
2776
2989
  try {
2777
2990
  await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2778
2991
  } catch (err) {
@@ -3516,6 +3729,24 @@ var RestTransport = class {
3516
3729
  };
3517
3730
 
3518
3731
  // ../connect-sdk-ts/dist/mls/rest-backend.js
3732
+ function describeType(v) {
3733
+ if (v === void 0)
3734
+ return "missing";
3735
+ if (v === null)
3736
+ return "null";
3737
+ if (Array.isArray(v))
3738
+ return "array";
3739
+ return typeof v;
3740
+ }
3741
+ function parseGroupRoster(endpoint, channelId, value) {
3742
+ if (!Array.isArray(value)) {
3743
+ throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
3744
+ }
3745
+ if (value.length === 0) {
3746
+ throw new MalformedGroupRosterError(endpoint, channelId, "empty array");
3747
+ }
3748
+ return value.map(String);
3749
+ }
3519
3750
  var RestMlsBackend = class {
3520
3751
  opts;
3521
3752
  f;
@@ -3591,7 +3822,10 @@ var RestMlsBackend = class {
3591
3822
  return {
3592
3823
  group_id: String(j.group_id),
3593
3824
  current_epoch: Number(j.current_epoch),
3594
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3825
+ // #230: fail loud on an absent / non-array / empty roster rather than
3826
+ // coerce to `[]` (which pre-empts the consumers' `?? [self]` fallback and
3827
+ // gets POSTed back as a wipe on distribute).
3828
+ member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
3595
3829
  // Back-compat: servers that predate the created field return nothing;
3596
3830
  // treat absence as true (they always created a fresh group).
3597
3831
  created: j.created === void 0 ? true : Boolean(j.created)
@@ -3616,10 +3850,14 @@ var RestMlsBackend = class {
3616
3850
  return {
3617
3851
  group_id: String(j.group_id),
3618
3852
  current_epoch: Number(j.current_epoch),
3619
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3853
+ // #230: same guard as init. This field has ZERO consumers today
3854
+ // (resetSessionSupersede reads only stale_member_device_ids), so this is
3855
+ // defense in depth: a future consumer cannot inherit the silent-`[]` trap.
3856
+ member_device_ids: parseGroupRoster("reinit", channelId, j.member_device_ids),
3620
3857
  // Absent on a backend that predates the dropped-member report; an empty
3621
3858
  // list is the correct read of "the server told us nothing", since the old
3622
- // server also never dropped anyone for an expired KeyPackage.
3859
+ // server also never dropped anyone for an expired KeyPackage. UNCHANGED:
3860
+ // empty here is legitimate, the opposite of member_device_ids (§3.3).
3623
3861
  stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3624
3862
  };
3625
3863
  }
@@ -3635,6 +3873,22 @@ var RestMlsBackend = class {
3635
3873
  return null;
3636
3874
  }
3637
3875
  }
3876
+ async fetchKeyPackageExpiry(kpId) {
3877
+ try {
3878
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}/expiry`);
3879
+ const raw = j.expires_at;
3880
+ if (typeof raw !== "string")
3881
+ return null;
3882
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw))
3883
+ return null;
3884
+ const ms = Date.parse(raw);
3885
+ if (Number.isNaN(ms))
3886
+ return null;
3887
+ return new Date(ms);
3888
+ } catch {
3889
+ return null;
3890
+ }
3891
+ }
3638
3892
  async distribute(groupId, req) {
3639
3893
  await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
3640
3894
  ...req,
@@ -3659,10 +3913,14 @@ var RestMlsBackend = class {
3659
3913
  }
3660
3914
  async getCommitterWork(channelId) {
3661
3915
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
3662
- return {
3663
- adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
3664
- removes: Array.isArray(j.removes) ? j.removes.map(String) : []
3916
+ const requireList = (field) => {
3917
+ const v = j?.[field];
3918
+ if (!Array.isArray(v)) {
3919
+ throw new MalformedCommitterWorkError(channelId, field, describeType(v));
3920
+ }
3921
+ return v.map(String);
3665
3922
  };
3923
+ return { adds: requireList("adds"), removes: requireList("removes") };
3666
3924
  }
3667
3925
  async acquireCommitLease(channelId) {
3668
3926
  const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
@@ -3693,6 +3951,390 @@ var RestMlsBackend = class {
3693
3951
 
3694
3952
  // ../connect-sdk-ts/dist/mls/fake-backend.js
3695
3953
  import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
3954
+ var FakeConnectBackend = class _FakeConnectBackend {
3955
+ // Key-package store: deviceId → hex-encoded KeyPackage
3956
+ kps = /* @__PURE__ */ new Map();
3957
+ // Server-assigned KeyPackage ids: deviceId → id (last publish wins, mirroring
3958
+ // the real backend's single-row-per-device semantics).
3959
+ kpIds = /* @__PURE__ */ new Map();
3960
+ _kpSeq = 0;
3961
+ /** Server TTL stamps: kp_id → expiry epoch-ms. Keyed by ID, not by device, so a
3962
+ * SUPERSEDED id has no entry at all — mirroring `publish_key_package`, whose
3963
+ * first statement DELETEs every prior row for the device
3964
+ * (`mls_key_package_service.py:26-28`), which makes a superseded id 404. */
3965
+ kpExpiry = /* @__PURE__ */ new Map();
3966
+ /** Injectable clock, so a test can place a row inside or just outside the guard's
3967
+ * skew grace. The fake's stamp, its `fetchKeyPackages` filter, and the provider's
3968
+ * comparison must all read ONE clock or the grace is not expressible. */
3969
+ now = () => Date.now();
3970
+ /** The real server's default KeyPackage TTL. */
3971
+ static TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3972
+ // Per-device delivery queues
3973
+ queues = /* @__PURE__ */ new Map();
3974
+ // Per-channel state (the real backend scopes membership + epoch per group;
3975
+ // keying these by channel keeps a multi-channel test from cross-contaminating).
3976
+ membersByChannel = /* @__PURE__ */ new Map();
3977
+ epochByChannel = /* @__PURE__ */ new Map();
3978
+ groupIdByChannel = /* @__PURE__ */ new Map();
3979
+ channelByGroup = /* @__PURE__ */ new Map();
3980
+ // identity_id → device_id, recorded at registerDevice so ingestApplication can
3981
+ // exclude the sender's own device on fanout (mirrors sendApplication's
3982
+ // senderDeviceId exclusion when only the envelope's sender_id is known).
3983
+ deviceByIdentity = /* @__PURE__ */ new Map();
3984
+ // Owner-attested reset: settable attestation map keyed by NEW groupId, read by
3985
+ // fetchResetAttestation (agent verify path) + written by resetReinitConnectChannelGroup.
3986
+ resetAttestations = /* @__PURE__ */ new Map();
3987
+ // Optional signer id the owner-side resetReinit records on the attestation it
3988
+ // stores (Task 9). Undefined in Task-6 tests, which set resetAttestations directly.
3989
+ ownerIdentityId;
3990
+ // Monotonic counter for generating unique IDs
3991
+ /** Ported-from-gen2 replay support: every distributed commit, in order, so
3992
+ * syncGroup() can serve a real catch-up instead of a stub. */
3993
+ commitLog = /* @__PURE__ */ new Map();
3994
+ /** Gap beyond which syncGroup answers "rejoin" (mirrors REJOIN_THRESHOLD). */
3995
+ rejoinThreshold = 100;
3996
+ _seq = 0;
3997
+ // Dedicated monotonic counter for reinit group ids (see reinitConnectChannelGroup).
3998
+ _reinitSeq = 0;
3999
+ /** Returns (and lazily creates) the delivery queue for a device. */
4000
+ q(deviceId) {
4001
+ let queue = this.queues.get(deviceId);
4002
+ if (!queue) {
4003
+ queue = [];
4004
+ this.queues.set(deviceId, queue);
4005
+ }
4006
+ return queue;
4007
+ }
4008
+ // ── MlsBackend implementation ──────────────────────────────────────────────
4009
+ /** Test stub: a FIXED 32-byte challenge hex. The real backend mints a fresh
4010
+ * single-use nonce; tests that exercise the PoP path sign over this constant
4011
+ * and re-derive the same message to verify. Deterministic so a register spy
4012
+ * can reconstruct and check the proof. */
4013
+ async getRegisterChallenge() {
4014
+ return "ab".repeat(32);
4015
+ }
4016
+ async registerDevice(req) {
4017
+ const deviceId = "fake-device-" + this._seq++;
4018
+ if (req.identity_id)
4019
+ this.deviceByIdentity.set(req.identity_id, deviceId);
4020
+ return { device_id: deviceId };
4021
+ }
4022
+ /** Test seam: record an identity→device link for a provider constructed with a
4023
+ * fixed deviceId (the SDK e2e wires providers directly, bypassing registerDevice).
4024
+ * The real backend resolves removed_identity_id → device(s) from the devices
4025
+ * table FK; this mirrors that resolution so op==="remove" can subtract the
4026
+ * removed identity's device(s) from the roster + delivery, exactly like the
4027
+ * server's purge_device_queue. */
4028
+ linkIdentityDevice(identityId, deviceId) {
4029
+ this.deviceByIdentity.set(identityId, deviceId);
4030
+ }
4031
+ async publishKeyPackage(deviceId, keyPackageHex) {
4032
+ this.kps.set(deviceId, keyPackageHex);
4033
+ const prior = this.kpIds.get(deviceId);
4034
+ if (prior)
4035
+ this.kpExpiry.delete(prior);
4036
+ const id = `kp_${deviceId}_${this._kpSeq = (this._kpSeq ?? 0) + 1}`;
4037
+ this.kpIds.set(deviceId, id);
4038
+ this.kpExpiry.set(id, this.now() + _FakeConnectBackend.TTL_MS);
4039
+ return id;
4040
+ }
4041
+ /** Test seam: move a recorded expiry to an absolute epoch-ms instant. Named
4042
+ * rather than letting a test write `kpExpiry` directly, so the id-keyed shape
4043
+ * stays an implementation detail. A hard-coded past value would make T2
4044
+ * (the fresh-KeyPackage negative control) unbuildable. */
4045
+ setKeyPackageExpiry(kpId, expiresAtMs) {
4046
+ this.kpExpiry.set(kpId, expiresAtMs);
4047
+ }
4048
+ async fetchKeyPackageExpiry(kpId) {
4049
+ const ms = this.kpExpiry.get(kpId);
4050
+ return ms === void 0 ? null : new Date(ms);
4051
+ }
4052
+ async getConnectChannelGroup(channelId) {
4053
+ const groupId = this.groupIdByChannel.get(channelId) ?? null;
4054
+ if (!groupId)
4055
+ return { exists: false, group_id: null, current_epoch: null };
4056
+ return {
4057
+ exists: true,
4058
+ group_id: groupId,
4059
+ current_epoch: this.epochByChannel.get(channelId) ?? 0
4060
+ };
4061
+ }
4062
+ async initConnectChannelGroup(channelId, memberDeviceIds) {
4063
+ const alreadyExists = this.groupIdByChannel.has(channelId);
4064
+ let groupId = this.groupIdByChannel.get(channelId);
4065
+ if (!groupId) {
4066
+ groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
4067
+ this.groupIdByChannel.set(channelId, groupId);
4068
+ this.channelByGroup.set(groupId, channelId);
4069
+ this.epochByChannel.set(channelId, 0);
4070
+ }
4071
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
4072
+ this.membersByChannel.set(channelId, members);
4073
+ return {
4074
+ group_id: groupId,
4075
+ current_epoch: this.epochByChannel.get(channelId) ?? 0,
4076
+ member_device_ids: members,
4077
+ // Real semantics: created=true on first call (group was just created),
4078
+ // created=false on idempotent return (group already existed). Without
4079
+ // this, race tests silently pass wrong (fake is laxer than the server).
4080
+ created: !alreadyExists
4081
+ };
4082
+ }
4083
+ async reinitConnectChannelGroup(channelId, memberDeviceIds) {
4084
+ const groupId = `00000000-0000-0000-0000-${String(1e3 + (this._reinitSeq = (this._reinitSeq ?? 0) + 1)).padStart(12, "0")}`;
4085
+ this.groupIdByChannel.set(channelId, groupId);
4086
+ this.channelByGroup.set(groupId, channelId);
4087
+ this.epochByChannel.set(channelId, 0);
4088
+ const members = [...new Set(memberDeviceIds)];
4089
+ this.membersByChannel.set(channelId, members);
4090
+ return { group_id: groupId, current_epoch: 0, member_device_ids: members };
4091
+ }
4092
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
4093
+ this.groupIdByChannel.set(channelId, att.newGroupId);
4094
+ this.channelByGroup.set(att.newGroupId, channelId);
4095
+ this.epochByChannel.set(channelId, 0);
4096
+ const members = [...new Set(memberDeviceIds)];
4097
+ this.membersByChannel.set(channelId, members);
4098
+ this.resetAttestations.set(att.newGroupId, {
4099
+ signedContent: new TextDecoder().decode(hexToBytes2(att.signedContentHex)),
4100
+ signatureHex: att.signatureHex,
4101
+ signerIdentityId: this.ownerIdentityId ?? "owner"
4102
+ });
4103
+ return {
4104
+ group_id: att.newGroupId,
4105
+ current_epoch: 0,
4106
+ member_device_ids: members,
4107
+ // The fake honours the roster verbatim, so nothing is ever dropped here.
4108
+ // Tests that need the dropped-member path stub this method directly.
4109
+ stale_member_device_ids: []
4110
+ };
4111
+ }
4112
+ async fetchKeyPackages(deviceIds) {
4113
+ const out = {};
4114
+ for (const id of deviceIds) {
4115
+ const kpId = this.kpIds.get(id);
4116
+ const exp = kpId === void 0 ? void 0 : this.kpExpiry.get(kpId);
4117
+ if (exp !== void 0 && exp <= this.now())
4118
+ continue;
4119
+ out[id] = this.kps.get(id);
4120
+ }
4121
+ return out;
4122
+ }
4123
+ async fetchKeyPackageById(kpId) {
4124
+ for (const [deviceId, id] of this.kpIds) {
4125
+ if (id === kpId) {
4126
+ return this.kps.get(deviceId) ?? null;
4127
+ }
4128
+ }
4129
+ return null;
4130
+ }
4131
+ async syncGroup(groupId, lastEpoch, _deviceId) {
4132
+ const channelId = this.channelByGroup.get(groupId);
4133
+ const current = channelId ? this.epochByChannel.get(channelId) ?? 0 : 0;
4134
+ if (current - lastEpoch > this.rejoinThreshold)
4135
+ return { action: "rejoin", messages: [] };
4136
+ const log = this.commitLog.get(groupId) ?? [];
4137
+ return {
4138
+ action: "replay",
4139
+ messages: log.filter((c) => c.epoch > lastEpoch).sort((a, b) => a.epoch - b.epoch).map((c) => ({
4140
+ id: c.id,
4141
+ epoch: c.epoch,
4142
+ message_type: "commit",
4143
+ sender_device_id: c.sender_device_id,
4144
+ payload: c.payload
4145
+ }))
4146
+ };
4147
+ }
4148
+ async distribute(groupId, req) {
4149
+ const channelId = this.channelByGroup.get(groupId);
4150
+ if (!channelId)
4151
+ throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
4152
+ if (req.commit && req.expected_epoch != null) {
4153
+ const current = this.epochByChannel.get(channelId) ?? 0;
4154
+ if (req.expected_epoch !== current) {
4155
+ const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
4156
+ e.status = 409;
4157
+ throw e;
4158
+ }
4159
+ }
4160
+ if (req.commit) {
4161
+ const log = this.commitLog.get(groupId) ?? [];
4162
+ log.push({
4163
+ id: `c${++this._seq}`,
4164
+ epoch: Number(req.commit_epoch ?? 0),
4165
+ payload: String(req.commit),
4166
+ sender_device_id: String(req.device_id ?? "")
4167
+ });
4168
+ this.commitLog.set(groupId, log);
4169
+ }
4170
+ const priorMembers = this.membersByChannel.get(channelId) ?? [];
4171
+ this.epochByChannel.set(channelId, req.commit_epoch ?? this.epochByChannel.get(channelId) ?? 0);
4172
+ let evictedDevice;
4173
+ if (req.op === "remove") {
4174
+ if (!req.removed_identity_id) {
4175
+ throw new Error("removed_identity_id required");
4176
+ }
4177
+ evictedDevice = req.removed_device_id ?? (req.removed_identity_id ? this.deviceByIdentity.get(req.removed_identity_id) : void 0);
4178
+ const remaining = (this.membersByChannel.get(channelId) ?? []).filter((d) => d !== evictedDevice);
4179
+ this.membersByChannel.set(channelId, remaining);
4180
+ if (evictedDevice)
4181
+ this.queues.delete(evictedDevice);
4182
+ } else {
4183
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
4184
+ this.membersByChannel.set(channelId, members);
4185
+ }
4186
+ if (req.commit) {
4187
+ for (const dev of priorMembers) {
4188
+ if (dev === req.device_id)
4189
+ continue;
4190
+ if (dev === evictedDevice)
4191
+ continue;
4192
+ this.q(dev).push({
4193
+ queue_id: "q-" + this._seq++,
4194
+ message_id: "m-commit-" + this._seq++,
4195
+ group_id: groupId,
4196
+ message_type: "commit",
4197
+ sender_device_id: req.device_id,
4198
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4199
+ payload: req.commit,
4200
+ connect_channel_id: channelId,
4201
+ created_at: ""
4202
+ });
4203
+ }
4204
+ }
4205
+ for (const w of req.welcomes ?? []) {
4206
+ this.q(w.target_device_id).push({
4207
+ queue_id: "q-" + this._seq++,
4208
+ message_id: "m-welcome-" + this._seq++,
4209
+ group_id: groupId,
4210
+ message_type: "welcome",
4211
+ sender_device_id: req.device_id,
4212
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4213
+ payload: w.payload,
4214
+ connect_channel_id: channelId,
4215
+ created_at: ""
4216
+ });
4217
+ }
4218
+ }
4219
+ async pullDelivery(deviceId) {
4220
+ return this.queues.get(deviceId) ?? [];
4221
+ }
4222
+ async ackDelivery(deviceId, queueIds) {
4223
+ const queue = this.queues.get(deviceId);
4224
+ if (queue) {
4225
+ this.queues.set(deviceId, queue.filter((r) => !queueIds.includes(r.queue_id)));
4226
+ }
4227
+ }
4228
+ requestWelcomeCalls = [];
4229
+ async requestWelcome(groupId, deviceId, freshKpId) {
4230
+ this.requestWelcomeCalls.push({ groupId, deviceId, freshKpId });
4231
+ }
4232
+ // ADR 0004 C1: committer-work + lease stubs (in-memory for unit tests;
4233
+ // no sweep driver runs against the fake backend, so these are no-ops).
4234
+ async getCommitterWork(_channelId) {
4235
+ return { adds: [], removes: [] };
4236
+ }
4237
+ async acquireCommitLease(_channelId) {
4238
+ return "fake-lease-token";
4239
+ }
4240
+ async releaseCommitLease(_channelId, _token) {
4241
+ }
4242
+ async fetchResetAttestation(groupId) {
4243
+ return this.resetAttestations.get(groupId) ?? null;
4244
+ }
4245
+ // ── E2E helper: seal + fanout (mirrors the Python edge's Task-7 path) ──────
4246
+ async sendApplication(p) {
4247
+ const key = await p.sender.exporterKey(p.enclaveId, p.channelId, p.epoch);
4248
+ const messageId = "m-" + this._seq++;
4249
+ const replayNonce = bytesToBase64(crypto.getRandomValues(new Uint8Array(24)));
4250
+ const policy_binding = {
4251
+ binding_id: "b",
4252
+ version: "1",
4253
+ agent_skill_id: null,
4254
+ delegation_id: null,
4255
+ client_stage5_outcome: "allow",
4256
+ client_stage5_evaluators: []
4257
+ };
4258
+ const aad = await buildAssociatedData({
4259
+ message_id: messageId,
4260
+ channel_id: p.channelId,
4261
+ sender_id: p.senderId,
4262
+ mls_group_epoch: p.epoch,
4263
+ replay_nonce: replayNonce,
4264
+ policy_binding
4265
+ });
4266
+ const sealed = sealPayload({
4267
+ plaintext: new TextEncoder().encode(p.plaintext),
4268
+ key,
4269
+ associatedData: aad,
4270
+ suite: "xchacha20poly1305"
4271
+ });
4272
+ const envelope = {
4273
+ av_version: "1.0",
4274
+ message_id: messageId,
4275
+ channel_id: p.channelId,
4276
+ enclave_id: p.enclaveId,
4277
+ message_type: "text",
4278
+ sender_id: p.senderId,
4279
+ sender_type: "agent",
4280
+ identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
4281
+ trace_context: { traceparent: "" },
4282
+ policy_binding,
4283
+ mls_group_epoch: p.epoch,
4284
+ replay_nonce: replayNonce,
4285
+ payload_encrypted: bytesToBase64(sealed),
4286
+ references: []
4287
+ };
4288
+ const canonical = await canonicalEnvelopeBytes(envelope);
4289
+ const members = this.membersByChannel.get(p.channelId) ?? [];
4290
+ const groupId = this.groupIdByChannel.get(p.channelId) ?? "";
4291
+ for (const dev of members) {
4292
+ if (dev === p.senderDeviceId)
4293
+ continue;
4294
+ this.q(dev).push({
4295
+ queue_id: "q-" + this._seq++,
4296
+ message_id: messageId,
4297
+ group_id: groupId,
4298
+ message_type: "application",
4299
+ sender_device_id: p.senderDeviceId,
4300
+ epoch: p.epoch,
4301
+ payload: bytesToHex3(canonical),
4302
+ connect_channel_id: p.channelId,
4303
+ created_at: ""
4304
+ });
4305
+ }
4306
+ }
4307
+ // ── E2E helper: ingest an already-built envelope (client-side build/seal) ──
4308
+ //
4309
+ // Mirrors sendApplication's storage + fanout, but takes an AgentVaultEnvelope
4310
+ // the *client* already built, sealed, and signed (via buildOutboundEnvelope) —
4311
+ // exactly what the owner client hands to its injected `publish`. The server
4312
+ // edge stores the canonical envelope bytes (hex) and fans out one delivery row
4313
+ // per active member device except the sender's. We resolve the sender's device
4314
+ // from sender_id (recorded at registerDevice); if unknown, every member gets it.
4315
+ async ingestApplication(envelope) {
4316
+ const channelId = envelope.channel_id;
4317
+ const canonical = await canonicalEnvelopeBytes(envelope);
4318
+ const members = this.membersByChannel.get(channelId) ?? [];
4319
+ const groupId = this.groupIdByChannel.get(channelId) ?? "";
4320
+ const senderDeviceId = this.deviceByIdentity.get(envelope.sender_id);
4321
+ for (const dev of members) {
4322
+ if (dev === senderDeviceId)
4323
+ continue;
4324
+ this.q(dev).push({
4325
+ queue_id: "q-" + this._seq++,
4326
+ message_id: envelope.message_id,
4327
+ group_id: groupId,
4328
+ message_type: "application",
4329
+ sender_device_id: senderDeviceId ?? "",
4330
+ epoch: envelope.mls_group_epoch,
4331
+ payload: bytesToHex3(canonical),
4332
+ connect_channel_id: channelId,
4333
+ created_at: ""
4334
+ });
4335
+ }
4336
+ }
4337
+ };
3696
4338
 
3697
4339
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
3698
4340
  import { hkdf } from "@noble/hashes/hkdf";
@@ -3742,7 +4384,7 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
3742
4384
 
3743
4385
  // ../connect-mls-store/dist/sqlite-store.js
3744
4386
  import { DatabaseSync } from "node:sqlite";
3745
- import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
4387
+ import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
3746
4388
 
3747
4389
  // ../connect-mls-store/dist/schema.js
3748
4390
  var SCHEMA_VERSION = 6;
@@ -3858,7 +4500,7 @@ var MlsStoreSealError = class extends Error {
3858
4500
  function encodeBundle(bundle) {
3859
4501
  const pp = bundle.privatePackage;
3860
4502
  const obj = {
3861
- pub: bytesToHex4(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
4503
+ pub: bytesToHex4(MLSGroupManager3.serializeKeyPackage(bundle.publicPackage)),
3862
4504
  initPriv: bytesToHex4(pp.initPrivateKey),
3863
4505
  hpkePriv: bytesToHex4(pp.hpkePrivateKey),
3864
4506
  sigPriv: bytesToHex4(pp.signaturePrivateKey)
@@ -3868,7 +4510,7 @@ function encodeBundle(bundle) {
3868
4510
  function decodeBundle(bytes) {
3869
4511
  const obj = JSON.parse(new TextDecoder().decode(bytes));
3870
4512
  return {
3871
- publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
4513
+ publicPackage: MLSGroupManager3.deserializeKeyPackage(hexToBytes3(obj.pub)),
3872
4514
  privatePackage: {
3873
4515
  initPrivateKey: hexToBytes3(obj.initPriv),
3874
4516
  hpkePrivateKey: hexToBytes3(obj.hpkePriv),
@@ -3956,6 +4598,22 @@ var SqliteMlsStateStore = class {
3956
4598
  async discardStaging(channelId) {
3957
4599
  this.db.prepare("UPDATE mls_group_state SET sealed_staging=NULL WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
3958
4600
  }
4601
+ /**
4602
+ * ⛔ THE WRITE PATH GETS NO READINESS BARRIER, AND THAT IS DELIBERATE. Two
4603
+ * independent reasons, and the FIRST is the primary one.
4604
+ *
4605
+ * 1. `:171` below populates `this.bundles` BEFORE the seal on the next line.
4606
+ * A same-process reload therefore hits the cache at `loadPendingBundle`'s
4607
+ * `:182-183` and NEVER decodes. The decode path is only ever taken for a
4608
+ * bundle written by a PREVIOUS process, which by definition is not this
4609
+ * one. A barrier here could not protect the read that actually fails.
4610
+ * 2. `encodeBundle`'s `bytesToHex` is cold-safe BECAUSE `to_hex` needs no
4611
+ * `_malloc`. ⛔ IT IS NOT "PURE JS" AND THE COMMENT MUST NEVER SAY THAT.
4612
+ * It is a libsodium call like the others; it simply does not touch the
4613
+ * WASM heap. Recording the wrong reason invites a later reader to
4614
+ * "correct" the asymmetry between this method and `loadPendingBundle` in
4615
+ * the wrong direction.
4616
+ */
3959
4617
  async savePendingBundle(ref, bundle) {
3960
4618
  this.bundles.set(ref, bundle);
3961
4619
  const sealed = await this.cipher.seal(encodeBundle(bundle));
@@ -3970,13 +4628,12 @@ var SqliteMlsStateStore = class {
3970
4628
  const row = this.db.prepare("SELECT sealed_bundle FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, ref);
3971
4629
  if (!row?.sealed_bundle)
3972
4630
  return null;
4631
+ await ensureSodiumReady();
3973
4632
  let bundle;
3974
4633
  try {
3975
4634
  bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
3976
4635
  } catch (e) {
3977
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
3978
- console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
3979
- }
4636
+ console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
3980
4637
  return null;
3981
4638
  }
3982
4639
  this.bundles.set(ref, bundle);
@@ -4413,9 +5070,10 @@ function mimeForName(name) {
4413
5070
  return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
4414
5071
  }
4415
5072
  async function prepareAttachment(filePath, caption, channelId, deps) {
5073
+ await deps.ready();
4416
5074
  const bytes = await deps.readFile(filePath);
4417
5075
  const name = basename(filePath);
4418
- const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
5076
+ const { encryptedData, fileKey, fileNonce, digest } = await deps.encryptFile(bytes);
4419
5077
  const blobId = await deps.uploadBlob(encryptedData, channelId);
4420
5078
  const body = {
4421
5079
  v: 1,
@@ -4432,12 +5090,13 @@ async function prepareAttachment(filePath, caption, channelId, deps) {
4432
5090
  return body;
4433
5091
  }
4434
5092
  async function materializeAttachment(body, ctx, deps) {
5093
+ await deps.ready();
4435
5094
  const ciphertext = await deps.downloadBlob(body.blobId);
4436
- const sha2566 = deps.computeFileDigest(ciphertext);
5095
+ const sha2566 = await deps.computeFileDigest(ciphertext);
4437
5096
  if (sha2566 !== body.digest) {
4438
5097
  throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
4439
5098
  }
4440
- const plain = deps.decryptFile(
5099
+ const plain = await deps.decryptFile(
4441
5100
  ciphertext,
4442
5101
  deps.base64ToBytes(body.fileKey),
4443
5102
  deps.base64ToBytes(body.fileNonce)
@@ -4450,6 +5109,11 @@ async function materializeAttachment(body, ctx, deps) {
4450
5109
  return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2566 };
4451
5110
  }
4452
5111
 
5112
+ // src/sodium-ready.ts
5113
+ function ensureSodiumReady2() {
5114
+ return ensureSodiumReady();
5115
+ }
5116
+
4453
5117
  // src/memory/hermes-session-map.ts
4454
5118
  import { readFileSync as readFileSync2 } from "node:fs";
4455
5119
  import { homedir } from "node:os";
@@ -5347,6 +6011,10 @@ var BridgeAgent = class _BridgeAgent {
5347
6011
  }
5348
6012
  async start() {
5349
6013
  this.startedAt = Date.now();
6014
+ try {
6015
+ await this.ingestOnce();
6016
+ } catch {
6017
+ }
5350
6018
  await this.provider.publishKeyPackage();
5351
6019
  this.startReceivePump();
5352
6020
  if (this.selfHealSweepEnabled) {
@@ -5550,6 +6218,50 @@ var BridgeAgent = class _BridgeAgent {
5550
6218
  * DEVICE never held a leaf can 403 on the signal call. That's harmless and
5551
6219
  * must not abort the sweep for every other channel.
5552
6220
  */
6221
+ /**
6222
+ * One best-effort device-level KeyPackage rotation attempt.
6223
+ *
6224
+ * `publishKeyPackage` is a cheap no-op skip unless the SERVER says the held
6225
+ * KeyPackage is expired, so calling it every cycle costs one read and rotates at
6226
+ * most once per TTL. The catch logs once and continues: it MUST NOT rethrow, MUST
6227
+ * NOT abort the sweep, and MUST NOT retry inside the cycle. A KeyPackage that
6228
+ * cannot be rotated this cycle is retried on the next interval, and the sweep's
6229
+ * per-channel repair work is independent of it.
6230
+ */
6231
+ /**
6232
+ * Tri-state: "is this device STILL serving a server-expired KeyPackage at the end
6233
+ * of the cycle that just tried to fix it?"
6234
+ *
6235
+ * `false` is the only healthy value, and it requires a POSITIVE, current proof of
6236
+ * non-expiry. `null` is unknown and reads exactly like `true` in `degraded`, so a
6237
+ * daemon that cannot prove itself un-expired stops reporting healthy. That is the
6238
+ * OPPOSITE direction to the rotation guard, which never rotates on unknown.
6239
+ * Conflating the two directions recreates the lie this change removes.
6240
+ *
6241
+ * Initialised `null` and written only by `selfHealSweep`, so a sweep-off daemon
6242
+ * reads `null` and therefore degraded.
6243
+ */
6244
+ keypackageExpired = null;
6245
+ /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
6246
+ * mapped to `null`. Never throws: a failure to measure is itself unknown. */
6247
+ async resolveKeypackageExpired() {
6248
+ try {
6249
+ const kpId = await this.provider.getLastPublishedKpId();
6250
+ if (kpId === null || kpId === "") return null;
6251
+ const exp = await this.backend.fetchKeyPackageExpiry(kpId);
6252
+ if (exp === null) return null;
6253
+ return isServerExpired(exp, Date.now());
6254
+ } catch {
6255
+ return null;
6256
+ }
6257
+ }
6258
+ async rotateDeviceKeyPackage() {
6259
+ try {
6260
+ await this.provider.publishKeyPackage();
6261
+ } catch (e) {
6262
+ console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
6263
+ }
6264
+ }
5553
6265
  async selfHealSweep() {
5554
6266
  if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
5555
6267
  this.providerCapabilityMissing = true;
@@ -5558,7 +6270,16 @@ var BridgeAgent = class _BridgeAgent {
5558
6270
  );
5559
6271
  return;
5560
6272
  }
6273
+ if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
6274
+ this.providerCapabilityMissing = true;
6275
+ console.error(
6276
+ "[bridge] selfHealSweep ABORTED: backend is missing fetchKeyPackageExpiry \u2014 the SDK dist is STALE (it predates KeyPackage expiry rotation). Rebuild the SDK dist THEN the bridge dist. KeyPackage rotation and healing are NOT running; observations are reported STALE."
6277
+ );
6278
+ return;
6279
+ }
5561
6280
  this.providerCapabilityMissing = false;
6281
+ await this.rotateDeviceKeyPackage();
6282
+ this.keypackageExpired = await this.resolveKeypackageExpired();
5562
6283
  const enclaves = await this.enclavesToCover();
5563
6284
  this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5564
6285
  const seen = /* @__PURE__ */ new Set();
@@ -5806,12 +6527,24 @@ var BridgeAgent = class _BridgeAgent {
5806
6527
  }
5807
6528
  sendDeps() {
5808
6529
  return {
6530
+ ready: () => ensureSodiumReady2(),
5809
6531
  uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
5810
6532
  encryptFile,
5811
6533
  bytesToBase64: bytesToBase642,
5812
6534
  readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
5813
6535
  };
5814
6536
  }
6537
+ receiveDeps() {
6538
+ return {
6539
+ ready: () => ensureSodiumReady2(),
6540
+ downloadBlob: this.downloadBlobImpl,
6541
+ decryptFile,
6542
+ computeFileDigest,
6543
+ base64ToBytes: base64ToBytes2,
6544
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6545
+ writeFile: (p, data) => fsp.writeFile(p, data)
6546
+ };
6547
+ }
5815
6548
  async sendAttachment(channelId, filePath, caption) {
5816
6549
  this.enforceRateFloor("attachment", channelId);
5817
6550
  const joined = await this.reconcileJoin(channelId);
@@ -5867,8 +6600,11 @@ var BridgeAgent = class _BridgeAgent {
5867
6600
  /**
5868
6601
  * List channels in one enclave (default: the daemon's own home enclave).
5869
6602
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
5870
- * cross-enclave room's is_dm gets resolved — see the scope caveat there
5871
- * (same-tenant only; a cross-tenant enclave 404s under the agent's bearer).
6603
+ * cross-enclave room's is_dm gets resolved. ⚠️ The old "same-tenant only; a
6604
+ * cross-tenant enclave 404s under the agent's bearer" caveat here is refuted
6605
+ * — see the CROSS-TENANT LISTING note on enclavesToCover. This route admits
6606
+ * any enclave the caller holds a live membership row in, partner tenants
6607
+ * included.
5872
6608
  */
5873
6609
  async listChannels(enclaveId = this.cfg.enclaveId) {
5874
6610
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
@@ -5923,12 +6659,16 @@ var BridgeAgent = class _BridgeAgent {
5923
6659
  * room is never listed under the home-enclave-only call, so its is_dm never
5924
6660
  * resolves and the @mention filter fails open for that room.
5925
6661
  *
5926
- * Each enclave is wrapped in its own try/catch (scope caveat — audit
5927
- * H2/tenant): list_channels_in_enclave filters on tenant_id, so a
5928
- * same-tenant enclave the agent has learned about resolves fine, but a
5929
- * cross-TENANT federated-partner enclave 404s under this agent's bearer;
5930
- * that enclave's channels are left unresolved (fail open) without aborting
5931
- * the other enclaves in the set.
6662
+ * Each enclave is wrapped in its own try/catch so one enclave's listing
6663
+ * failure leaves only that enclave's channels unresolved (fail open) without
6664
+ * aborting the other enclaves in the set.
6665
+ *
6666
+ * ⚠️ The old scope caveat here (audit H2/tenant) said
6667
+ * `list_channels_in_enclave` filters on tenant_id and 404s a cross-TENANT
6668
+ * federated-partner enclave. That is refuted — see the CROSS-TENANT LISTING
6669
+ * note on enclavesToCover. A partner enclave with a live membership row
6670
+ * resolves like any other, so the try/catch now covers transport errors,
6671
+ * 5xx, and genuine non-member 404s only.
5932
6672
  */
5933
6673
  async refreshChannelMeta() {
5934
6674
  const enclaves = await this.enclavesToCover();
@@ -5956,12 +6696,20 @@ var BridgeAgent = class _BridgeAgent {
5956
6696
  * failure right after a restart would read as "never succeeded" and slip
5957
6697
  * through the expected-gap branch — weaker than the old abort-on-failure).
5958
6698
  * 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. */
6699
+ * failure of a never-succeeded enclave is reported but does not degrade.
6700
+ *
6701
+ * ⚠️ The old justification for that asymmetry named a partner enclave's
6702
+ * "tenant-scoped 404, permanent pending backend authz" as the expected gap.
6703
+ * That premise is refuted — see the CROSS-TENANT LISTING note on
6704
+ * enclavesToCover; a partner enclave with a live membership row lists fine.
6705
+ * The asymmetry is kept on its remaining merit: an enclave this process has
6706
+ * never listed may simply be one this agent holds no live membership in, and
6707
+ * pegging health degraded forever on that is noise, not signal. */
5962
6708
  enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5963
6709
  /** 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. */
6710
+ * a persistently-failing enclave must not log 1440 lines a day. (The old
6711
+ * wording named "a permanently-404ing partner enclave" — refuted, see the
6712
+ * CROSS-TENANT LISTING note on enclavesToCover.) */
5965
6713
  enclaveListWarned = /* @__PURE__ */ new Set();
5966
6714
  static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5967
6715
  enclaveEnumWarned = false;
@@ -5989,11 +6737,35 @@ var BridgeAgent = class _BridgeAgent {
5989
6737
  * membership changes are picked up within one TTL, which matches the sweep
5990
6738
  * cadence this exists to serve.
5991
6739
  *
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.
6740
+ * ── CROSS-TENANT LISTING: the canonical statement for this file ──────────
6741
+ *
6742
+ * ⚠️ CORRECTED 2026-08-21 (#280 spec F6). This block, and five other comments
6743
+ * in this file, used to state that `list_channels_in_enclave` is tenant-
6744
+ * scoped and 404s a cross-tenant partner enclave under this agent's bearer,
6745
+ * leaving a partner enclave in this set with no channels. That has been FALSE
6746
+ * since #187 (`d7cc685a`, 2026-07-31). Verified by opening the code:
6747
+ *
6748
+ * - `list_channels_in_enclave` calls `_resolve_enclave_membership(...,
6749
+ * host_only=False)` (backend/app/api/v1/connect_channels.py:897-900).
6750
+ * - `host_only=False` OMITS the `ConnectEnclave.tenant_id == auth.tenant_id`
6751
+ * predicate and runs the membership query under `bypass_rls`, so the
6752
+ * host-owned enclave row is visible to a partner session (same file
6753
+ * :519-520, :531-543). The gate is membership, not tenancy.
6754
+ * - The channel query it then runs (same file :902-919) reads
6755
+ * `connect_channels` joined to `connect_channel_members`, and BOTH tables
6756
+ * are among the eight the schema deliberately leaves without RLS for
6757
+ * having no `tenant_id` column (alembic 0001_connect_initial_schema.py:
6758
+ * 750-755, :778-780). No policy filters those rows by tenant.
6759
+ *
6760
+ * So a partner-tenant agent holding a live `ConnectEnclaveMember` row
6761
+ * (`removed_at IS NULL`) gets 200 with that enclave's real channels. There is
6762
+ * no partner-enclave listing gap left to close, and no backend authz work
6763
+ * pending for it.
6764
+ *
6765
+ * What DOES still reach a per-enclave listing failure: a transport error, a
6766
+ * 5xx, or a genuine 404 for an enclave this agent holds no live membership
6767
+ * in. Those are real outages, so the skip-one, never-abort-all rule stands on
6768
+ * its own merit — it just no longer has a standing partner-enclave case.
5997
6769
  */
5998
6770
  async enclavesToCover() {
5999
6771
  const now = Date.now();
@@ -6449,14 +7221,7 @@ var BridgeAgent = class _BridgeAgent {
6449
7221
  const meta = await materializeAttachment(
6450
7222
  parsed,
6451
7223
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
6452
- {
6453
- downloadBlob: this.downloadBlobImpl,
6454
- decryptFile,
6455
- computeFileDigest,
6456
- base64ToBytes: base64ToBytes2,
6457
- mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6458
- writeFile: (p, data) => fsp.writeFile(p, data)
6459
- }
7224
+ this.receiveDeps()
6460
7225
  );
6461
7226
  this.buffer.push({
6462
7227
  channel_id: m.channelId,
@@ -6639,8 +7404,10 @@ var BridgeAgent = class _BridgeAgent {
6639
7404
  sweep_enabled: this.selfHealSweepEnabled,
6640
7405
  observations_stale: observationsStale,
6641
7406
  diverged_channels: divergedChannels,
7407
+ keypackage_expired: this.keypackageExpired,
6642
7408
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6643
- degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
7409
+ // `!== false` and not `=== true`: unknown must never read healthy.
7410
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost || this.keypackageExpired !== false,
6644
7411
  channels: liveChannels,
6645
7412
  sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6646
7413
  identity_id: this.cfg.identityId,