@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/index.js CHANGED
@@ -1113,6 +1113,28 @@ async function agentPausedErrorFromResponse(res) {
1113
1113
  const err = detail?.error;
1114
1114
  return err === "agent_paused" ? new AgentPausedError() : null;
1115
1115
  }
1116
+ var MalformedCommitterWorkError = class extends ConnectSdkError {
1117
+ channelId;
1118
+ field;
1119
+ receivedType;
1120
+ constructor(channelId, field, receivedType) {
1121
+ super(`committer-work for channel ${channelId} returned a malformed body: \`${field}\` is ${receivedType}, expected array`);
1122
+ this.channelId = channelId;
1123
+ this.field = field;
1124
+ this.receivedType = receivedType;
1125
+ }
1126
+ };
1127
+ var MalformedGroupRosterError = class extends ConnectSdkError {
1128
+ endpoint;
1129
+ channelId;
1130
+ receivedType;
1131
+ constructor(endpoint, channelId, receivedType) {
1132
+ super(`${endpoint} for channel ${channelId} returned a malformed group roster: \`member_device_ids\` is ${receivedType}, expected a non-empty array`);
1133
+ this.endpoint = endpoint;
1134
+ this.channelId = channelId;
1135
+ this.receivedType = receivedType;
1136
+ }
1137
+ };
1116
1138
 
1117
1139
  // ../connect-sdk-ts/dist/envelope-builder.js
1118
1140
  async function buildOutboundEnvelope(p) {
@@ -1221,8 +1243,23 @@ import { hmac } from "@noble/hashes/hmac";
1221
1243
  import { sha256 as sha2562 } from "@noble/hashes/sha256";
1222
1244
  import { bytesToHex } from "@agentvault/crypto";
1223
1245
 
1246
+ // ../connect-sdk-ts/dist/groupkey/crypto-patch-guard.js
1247
+ import { MLSGroupManager } from "@agentvault/crypto";
1248
+ var CryptoPatchMissingError = class extends Error {
1249
+ constructor() {
1250
+ 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.");
1251
+ this.name = "CryptoPatchMissingError";
1252
+ }
1253
+ };
1254
+ function leafIndicesForKeyPackage(mgr, kp) {
1255
+ if (typeof MLSGroupManager.prototype.leafIndicesForKeyPackage !== "function") {
1256
+ throw new CryptoPatchMissingError();
1257
+ }
1258
+ return mgr.leafIndicesForKeyPackage(kp);
1259
+ }
1260
+
1224
1261
  // ../connect-sdk-ts/dist/groupkey/mls.js
1225
- import { MLSGroupManager, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1262
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1226
1263
 
1227
1264
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1228
1265
  var MlsOperationQueue = class {
@@ -1286,6 +1323,61 @@ function classifyFailedRow(i) {
1286
1323
  return "defer";
1287
1324
  }
1288
1325
 
1326
+ // ../connect-sdk-ts/dist/groupkey/admission-rule.js
1327
+ function canAdmit(input) {
1328
+ if (input.targetDeviceId === input.ownDeviceId)
1329
+ return { decision: "refuse", reason: "self" };
1330
+ if (input.boundLeaves.some((b) => b.device === input.targetDeviceId)) {
1331
+ return { decision: "refuse", reason: "already-bound" };
1332
+ }
1333
+ if (input.targetLeaves.length > 0) {
1334
+ if (input.deltaVouches)
1335
+ return { decision: "refuse", reason: "identity-leaf-present" };
1336
+ if (input.targetLeaves.length > 1)
1337
+ return { decision: "refuse", reason: "identity-leaf-present" };
1338
+ const t = input.targetLeaves[0];
1339
+ if (!input.identityLeaves.some((l) => l.leafIndex === t))
1340
+ return { decision: "refuse", reason: "already-bound" };
1341
+ if (input.identityLeaves.some((l) => l.leafIndex !== t && l.leafIndex !== input.ownLeafIndex))
1342
+ return { decision: "refuse", reason: "identity-leaf-present" };
1343
+ return { decision: "already-present" };
1344
+ }
1345
+ if (input.targetIdentityId === null)
1346
+ return { decision: "refuse", reason: "unknown-identity" };
1347
+ if (input.identityLeaves.length === 0)
1348
+ return { decision: "admit" };
1349
+ const allUnbound = input.identityLeaves.every((l) => l.device === null);
1350
+ const ownLeafUnreadable = allUnbound && input.ownLeafIndex === null;
1351
+ const exemptionApplies = allUnbound && input.ownLeafIndex !== null && input.identityLeaves.every((l) => l.leafIndex === input.ownLeafIndex);
1352
+ if (exemptionApplies)
1353
+ return { decision: "admit" };
1354
+ if (input.deltaVouches)
1355
+ return { decision: "admit" };
1356
+ return {
1357
+ decision: "refuse",
1358
+ reason: ownLeafUnreadable ? "unknown-own-leaf" : "identity-leaf-present"
1359
+ };
1360
+ }
1361
+ function hasMalformedBinding(identityLeaves) {
1362
+ return identityLeaves.some((l) => l.malformed);
1363
+ }
1364
+
1365
+ // ../connect-sdk-ts/dist/groupkey/kp-expiry.js
1366
+ var SKEW_GUARD_MS = 5 * 60 * 1e3;
1367
+ function isServerExpired(exp, nowMs) {
1368
+ return nowMs - SKEW_GUARD_MS >= exp.getTime();
1369
+ }
1370
+
1371
+ // ../connect-sdk-ts/dist/sodium-ready.js
1372
+ import { generateEphemeralKeypair } from "@agentvault/crypto";
1373
+ var warming = null;
1374
+ function ensureSodiumReady() {
1375
+ return warming ??= generateEphemeralKeypair().then(() => void 0).catch((e) => {
1376
+ warming = null;
1377
+ throw e;
1378
+ });
1379
+ }
1380
+
1289
1381
  // ../connect-sdk-ts/dist/groupkey/mls.js
1290
1382
  var LABEL = "agentvault-connect-v1";
1291
1383
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1333,12 +1425,34 @@ var ForgedCredentialError = class extends Error {
1333
1425
  this.name = "ForgedCredentialError";
1334
1426
  }
1335
1427
  };
1428
+ var SelfAddRefusedError = class extends Error {
1429
+ deviceId;
1430
+ channelId;
1431
+ constructor(deviceId, channelId) {
1432
+ 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.`);
1433
+ this.deviceId = deviceId;
1434
+ this.channelId = channelId;
1435
+ this.name = "SelfAddRefusedError";
1436
+ }
1437
+ };
1438
+ var AddRefusedError = class extends Error {
1439
+ reason;
1440
+ deviceId;
1441
+ identityId;
1442
+ identityLeafCount;
1443
+ constructor(reason, deviceId, identityId, identityLeafCount) {
1444
+ 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.`);
1445
+ this.reason = reason;
1446
+ this.deviceId = deviceId;
1447
+ this.identityId = identityId;
1448
+ this.identityLeafCount = identityLeafCount;
1449
+ this.name = "AddRefusedError";
1450
+ }
1451
+ };
1336
1452
  var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1337
1453
  o;
1338
1454
  suite;
1339
1455
  managers = /* @__PURE__ */ new Map();
1340
- /** One-time libsodium (WASM) warm-up guard — see ensureCryptoReady(). */
1341
- cryptoWarmed = false;
1342
1456
  /**
1343
1457
  * Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
1344
1458
  * manager-mutating operation on a channel takes this lock keyed by channelId,
@@ -1604,6 +1718,24 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1604
1718
  * ONE active leaf. Used by the guest reconcile sweep when an approved guest
1605
1719
  * sits in `joining`.
1606
1720
  *
1721
+ * ⛔ AS OF SLICE B (ADR-0014 Decision B) THE WORKING DEVICE SWAP IS GONE.
1722
+ * When `priorDeviceId` names a device whose leaf still carries this guest
1723
+ * identity — the case the swap existed for — the Add chokepoint's admission
1724
+ * rule refuses (AddRefusedError, reason `identity-leaf-present`) and this
1725
+ * method THROWS before reaching the eviction below.
1726
+ *
1727
+ * ⚠️ That does NOT make the eviction block unreachable, and an earlier
1728
+ * revision of this note wrongly said it did. Pass a `priorDeviceId` that
1729
+ * differs from `newDeviceId` while the guest identity holds NO leaf and the
1730
+ * rule admits on clause 2's first arm, the Add commits, and the block DOES
1731
+ * run — then throws `LeafRosterMismatchError`, because the identity owns one
1732
+ * leaf where the default `orderedDeviceIds` names two. So the reachable
1733
+ * outcomes for a caller passing `priorDeviceId` are a refusal or a roster
1734
+ * mismatch, never a completed swap. The block is retained because it is still
1735
+ * correct and is what a Remove-then-Add re-enablement (or slice 5's
1736
+ * device-bound leaf) will build on. See the body comment for why the
1737
+ * count-only attestation that used to open that path was retired.
1738
+ *
1607
1739
  * Add then evict: we commit the Add first so the new leaf exists before the
1608
1740
  * old one is dropped (the guest is never momentarily leaf-less in the group).
1609
1741
  * The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
@@ -1619,16 +1751,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1619
1751
  * equals newDeviceId (the same device re-admitted).
1620
1752
  */
1621
1753
  async admitGuestDevice(channelId, g) {
1622
- await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1623
- knownLeafDevices: g.priorDeviceId ? [g.priorDeviceId] : []
1754
+ const outcome = await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1755
+ expectedIdentityId: g.identityId
1624
1756
  });
1625
- if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1757
+ if (outcome === "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1626
1758
  const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
1627
1759
  await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
1628
1760
  identity: g.identityId,
1629
1761
  orderedDeviceIds
1630
1762
  });
1763
+ } else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1764
+ 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.`);
1631
1765
  }
1766
+ return outcome;
1632
1767
  }
1633
1768
  /** Load (or reuse) the per-channel manager from the store. Throws if no state. */
1634
1769
  async manager(channelId) {
@@ -1641,7 +1776,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1641
1776
  return again;
1642
1777
  if (!saved)
1643
1778
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1644
- const mgr = new MLSGroupManager(saved.ciphersuite);
1779
+ const mgr = new MLSGroupManager2(saved.ciphersuite);
1645
1780
  mgr.importState(saved);
1646
1781
  this.managers.set(channelId, mgr);
1647
1782
  return mgr;
@@ -1842,27 +1977,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1842
1977
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
1843
1978
  return w?.connect_channel_id ?? null;
1844
1979
  }
1845
- /**
1846
- * Initialize the ts-mls libsodium (WASM) backend ONCE before any store
1847
- * operation that deserializes a persisted KeyPackage. generateKeyPackage
1848
- * awaits sodium.ready and is pure in-memory (it neither publishes to the
1849
- * backend nor writes the store), so the throwaway bundle is a side-effect-free
1850
- * warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
1851
- * guard, and the join path) runs while WASM is still cold, throws deep in the
1852
- * crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
1853
- * and misread as "no held bundle", and the daemon mints + republishes a FRESH
1854
- * KeyPackage on every restart — orphaning every Welcome sealed to the prior
1855
- * published KP (the root cause of "fresh DM never opens" across restarts).
1856
- * Idempotent and cheap (~ms); the flag makes repeat calls no-ops.
1857
- */
1858
- async ensureCryptoReady() {
1859
- if (this.cryptoWarmed)
1860
- return;
1861
- await new MLSGroupManager().generateKeyPackage(this.o.identity);
1862
- this.cryptoWarmed = true;
1863
- }
1864
1980
  async publishKeyPackage() {
1865
- await this.ensureCryptoReady();
1981
+ await ensureSodiumReady();
1866
1982
  return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
1867
1983
  }
1868
1984
  /**
@@ -1909,7 +2025,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1909
2025
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1910
2026
  if (!bundle)
1911
2027
  return false;
1912
- const heldHex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2028
+ const heldHex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
1913
2029
  const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
1914
2030
  const pubHex = published[this.o.deviceId];
1915
2031
  return !!pubHex && pubHex === heldHex;
@@ -1919,13 +2035,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1919
2035
  }
1920
2036
  async publishKeyPackageUnlocked() {
1921
2037
  if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
1922
- console.warn("publishKeyPackage: pending bundle exists; skipping republish");
1923
- return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2038
+ const kpId = await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2039
+ const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
2040
+ const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
2041
+ if (!expired) {
2042
+ console.warn("publishKeyPackage: pending bundle exists; skipping republish");
2043
+ return kpId;
2044
+ }
2045
+ console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
2046
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1924
2047
  }
1925
2048
  console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
1926
- const mgr = new MLSGroupManager();
2049
+ const mgr = new MLSGroupManager2();
1927
2050
  const bundle = await mgr.generateKeyPackage(this.o.identity);
1928
- const hex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2051
+ const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
1929
2052
  const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1930
2053
  await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
1931
2054
  return id;
@@ -1942,7 +2065,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1942
2065
  async ensureSessionUnlocked(channelId, opts) {
1943
2066
  if (!opts.forceReinit && await this.o.store.load(channelId))
1944
2067
  return;
1945
- await this.ensureCryptoReady();
2068
+ await ensureSodiumReady();
1946
2069
  let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1947
2070
  if (!bundle && (opts.bootstrap || opts.forceReinit)) {
1948
2071
  await this.publishKeyPackage();
@@ -1953,14 +2076,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1953
2076
  if (opts.forceReinit) {
1954
2077
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
1955
2078
  const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
1956
- const mgr = new MLSGroupManager();
2079
+ const mgr = new MLSGroupManager2();
1957
2080
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
1958
2081
  const kps = await this.o.backend.fetchKeyPackages(others);
1959
2082
  const pkgs = others.map((d) => {
1960
2083
  const hex = kps[d];
1961
2084
  if (!hex)
1962
2085
  throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
1963
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2086
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
1964
2087
  });
1965
2088
  const { commit, welcome } = await mgr.addMembers(pkgs);
1966
2089
  await this.o.backend.distribute(reinit.group_id, {
@@ -1979,7 +2102,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1979
2102
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
1980
2103
  if (init.created === false)
1981
2104
  throw new BootstrapRaceLostError();
1982
- const mgr = new MLSGroupManager();
2105
+ const mgr = new MLSGroupManager2();
1983
2106
  await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
1984
2107
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
1985
2108
  const kps = await this.o.backend.fetchKeyPackages(others);
@@ -1987,7 +2110,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1987
2110
  const hex = kps[d];
1988
2111
  if (!hex)
1989
2112
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
1990
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2113
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
1991
2114
  });
1992
2115
  const { commit, welcome } = await mgr.addMembers(pkgs);
1993
2116
  await this.o.backend.distribute(init.group_id, {
@@ -2005,7 +2128,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2005
2128
  const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
2006
2129
  if (!w)
2007
2130
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
2008
- const mgr = new MLSGroupManager();
2131
+ const mgr = new MLSGroupManager2();
2009
2132
  try {
2010
2133
  await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
2011
2134
  } catch (err) {
@@ -2041,36 +2164,66 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2041
2164
  const hex = kps[newDeviceId];
2042
2165
  if (!hex)
2043
2166
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
2044
- const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2045
- if (_attempt === 0) {
2046
- let adds;
2047
- try {
2048
- adds = (await this.o.backend.getCommitterWork(channelId)).adds;
2049
- } catch (e) {
2050
- 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)));
2167
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2168
+ const cred = pkg.leafNode.credential;
2169
+ const claimedIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2170
+ if (opts?.expectedIdentityId && claimedIdentity !== opts.expectedIdentityId) {
2171
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimedIdentity ?? "");
2172
+ }
2173
+ const targetLeaves = leafIndicesForKeyPackage(mgr, pkg);
2174
+ let deltaVouches = false;
2175
+ try {
2176
+ deltaVouches = (await this.o.backend.getCommitterWork(channelId)).adds.includes(newDeviceId);
2177
+ } catch (e) {
2178
+ if (targetLeaves.length > 0) {
2179
+ 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)));
2051
2180
  }
2052
- if (!adds.includes(newDeviceId)) {
2053
- const cred = pkg.leafNode.credential;
2054
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2055
- const leaves = claimed === null ? null : mgr.leafIndicesForIdentity(claimed);
2056
- if (leaves === null || leaves.length > 0) {
2057
- const known = opts?.knownLeafDevices ? [...new Set(opts.knownLeafDevices)] : null;
2058
- const swapSafe = leaves !== null && known !== null && !known.includes(newDeviceId) && known.length === leaves.length;
2059
- if (!swapSafe) {
2060
- 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)`);
2061
- return;
2062
- }
2181
+ deltaVouches = false;
2182
+ }
2183
+ const identityLeaves = claimedIdentity === null ? [] : mgr.leafIndicesForIdentity(claimedIdentity).map((leafIndex) => ({
2184
+ leafIndex,
2185
+ device: null,
2186
+ // SLICE 5: read the LeafNode extension here.
2187
+ malformed: false
2188
+ // SLICE 5: set from a failed extension decode.
2189
+ }));
2190
+ const ownLeaf = this.ownLeafIndex(mgr);
2191
+ const verdict = canAdmit({
2192
+ targetDeviceId: newDeviceId,
2193
+ targetIdentityId: claimedIdentity,
2194
+ ownDeviceId: this.o.deviceId,
2195
+ ownLeafIndex: ownLeaf,
2196
+ boundLeaves: [],
2197
+ // SLICE 5: the whole-tree bound-leaf scan replaces this literal.
2198
+ identityLeaves,
2199
+ targetLeaves,
2200
+ deltaVouches
2201
+ });
2202
+ if (hasMalformedBinding(identityLeaves)) {
2203
+ console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
2204
+ }
2205
+ if (targetLeaves.length > 0 && deltaVouches) {
2206
+ 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.`);
2207
+ }
2208
+ if (targetLeaves.length > 1) {
2209
+ 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.`);
2210
+ }
2211
+ switch (verdict.decision) {
2212
+ case "admit":
2213
+ break;
2214
+ case "already-present":
2215
+ return "already-present";
2216
+ case "refuse":
2217
+ if (verdict.reason === "self") {
2218
+ throw new SelfAddRefusedError(newDeviceId, channelId);
2063
2219
  }
2220
+ throw new AddRefusedError(verdict.reason, newDeviceId, claimedIdentity, identityLeaves.length);
2221
+ default: {
2222
+ const _never = verdict;
2223
+ throw new Error(`unhandled admission verdict: ${JSON.stringify(_never)}`);
2064
2224
  }
2065
2225
  }
2066
2226
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
2067
- if (opts?.expectedIdentityId) {
2068
- const cred = pkg.leafNode.credential;
2069
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
2070
- if (claimed !== opts.expectedIdentityId) {
2071
- throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
2072
- }
2073
- }
2074
2227
  const priorEpoch = Number(mgr.epoch);
2075
2228
  const { commit, welcome } = await mgr.addMembers([pkg]);
2076
2229
  const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
@@ -2092,15 +2245,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2092
2245
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
2093
2246
  } catch {
2094
2247
  }
2095
- try {
2096
- const work = await this.o.backend.getCommitterWork(channelId);
2097
- if (!work.adds.includes(newDeviceId))
2098
- return;
2099
- } catch {
2100
- }
2101
2248
  return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
2102
2249
  }
2103
2250
  await this.o.store.save(channelId, mgr.exportState());
2251
+ return "committed";
2104
2252
  }
2105
2253
  /**
2106
2254
  * Committer-side: REMOVE a member from a live group (Phase C eviction).
@@ -2318,6 +2466,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2318
2466
  * - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
2319
2467
  * leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
2320
2468
  *
2469
+ * ⛔⛔ LIMITATION — A MULTI-DEVICE MEMBER IS UNHEALABLE BY THIS METHOD, AND THE
2470
+ * FAILURE IS SILENT AND DESTRUCTIVE. READ THIS BEFORE CALLING IT.
2471
+ *
2472
+ * Leaf credentials encode the IDENTITY and not the device, so the leaf
2473
+ * resolution above is only exact when the identity's leaf set is unambiguous.
2474
+ * The `> 1` case refuses. The `=== 1` case DOES NOT, and it is the worse one:
2475
+ * heal device B of an identity whose device A holds the only leaf and A is
2476
+ * evicted while the call reports a clean heal. Every downstream check is
2477
+ * satisfied — the cardinality guard in `removeDeviceFromLiveGroupUnlocked`
2478
+ * compares this call site's single-element `orderedDeviceIds` against a
2479
+ * single-element leaf set, a coincidence of counts, and `leaves[0]` is A's.
2480
+ * `mls.heal-multidevice.test.ts` measures exactly that against real MLS.
2481
+ *
2482
+ * ⛔ NO GUARD IS AVAILABLE HERE, and this is a measured conclusion rather than
2483
+ * an unexplored one. The device-level predicate that would settle it (leaf
2484
+ * indices whose LeafNode signature key equals the fetched KeyPackage's — the
2485
+ * same evidence the Add chokepoint uses) was implemented and it refused EVERY
2486
+ * production heal: the repair contract requires the member to republish first,
2487
+ * `forceRepublishKeyPackage` mints a NEW SIGNING KEY, and a diverged device's
2488
+ * fresh KeyPackage therefore matches no leaf, including its own. A guard built
2489
+ * on it is a blanket disable, not a guard.
2490
+ *
2491
+ * ⚠️ CALLER CONTRACT UNTIL SLICE 5: do not drive this for a member you know to
2492
+ * hold more than one device. The caller is the only layer that has a roster,
2493
+ * and nothing in the tree binds a leaf to a device id, so the refusal has to
2494
+ * live above this function. connect-web's `foreign-identity-heal.ts` declines a
2495
+ * member whose identity holds two or more LEAVES; the residual it cannot see —
2496
+ * two DEVICES sharing one leaf — is the shape described above and is open.
2497
+ * It resolves when the leaf credential carries a device binding (slice 5),
2498
+ * which cannot be retrofitted without re-keying every live group.
2499
+ *
2321
2500
  * The Add fetches the device's freshly published KeyPackage from the backend,
2322
2501
  * exactly like a normal add — the CALLER is responsible for the member having
2323
2502
  * re-published one first (mirrors addMemberToLiveGroup's "no published
@@ -2334,7 +2513,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2334
2513
  const hex = kps[memberDeviceId];
2335
2514
  if (!hex)
2336
2515
  throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
2337
- const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
2516
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2517
+ const cred = pkg.leafNode.credential;
2338
2518
  if (cred.credentialType !== "basic") {
2339
2519
  throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
2340
2520
  }
@@ -2347,6 +2527,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2347
2527
  if (leaves.length > 1) {
2348
2528
  throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
2349
2529
  }
2530
+ if (leaves.length === 1) {
2531
+ 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.`);
2532
+ }
2350
2533
  let removeLanded = false;
2351
2534
  if (leaves.length === 1) {
2352
2535
  await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
@@ -2357,13 +2540,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2357
2540
  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)`);
2358
2541
  }
2359
2542
  }
2543
+ let outcome;
2360
2544
  try {
2361
- await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2545
+ outcome = await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2362
2546
  } catch (e) {
2363
2547
  if (removeLanded)
2364
2548
  throw new HealAddAfterRemoveError(e);
2365
2549
  throw e;
2366
2550
  }
2551
+ if (outcome !== "committed") {
2552
+ 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.`);
2553
+ if (removeLanded)
2554
+ throw new HealAddAfterRemoveError(err);
2555
+ throw err;
2556
+ }
2367
2557
  }));
2368
2558
  }
2369
2559
  /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
@@ -2470,14 +2660,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2470
2660
  throw new Error("resetSessionSupersede: call publishKeyPackage() first");
2471
2661
  const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
2472
2662
  const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
2473
- const mgr = new MLSGroupManager();
2663
+ const mgr = new MLSGroupManager2();
2474
2664
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2475
2665
  const kps = await this.o.backend.fetchKeyPackages(others);
2476
2666
  const pkgs = others.map((d) => {
2477
2667
  const hex = kps[d];
2478
2668
  if (!hex)
2479
2669
  throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
2480
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2670
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2481
2671
  });
2482
2672
  const { commit, welcome } = await mgr.addMembers(pkgs);
2483
2673
  await this.o.backend.distribute(reinit.group_id, {
@@ -2552,6 +2742,29 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2552
2742
  const mgr = await this.manager(channelId);
2553
2743
  return mgr.leafIndicesForIdentity(identityId).length;
2554
2744
  }
2745
+ /**
2746
+ * Cache-only leaf count for `identityId` in `channelId`, or **null** when
2747
+ * there is no in-memory manager (a cold channel).
2748
+ *
2749
+ * The non-throwing sibling of {@link leafCountForIdentity}, with the same
2750
+ * cold-channel semantics as {@link hasLeafForIdentity}. It exists because the
2751
+ * join confirmer needs to know whether a leaf can be ATTRIBUTED to a device,
2752
+ * and a boolean cannot answer that: leaf credentials encode the identity, not
2753
+ * the device, so "the identity holds a leaf" is true off ANY of its devices'
2754
+ * leaves. `null` and any count other than 1 both mean "cannot attribute", and
2755
+ * a caller that clears state on attribution must treat them as such.
2756
+ *
2757
+ * ⚠️ This is still an IDENTITY-level read. It does not name which device
2758
+ * holds which leaf and it cannot until leaves carry a device binding
2759
+ * (ADR-0014 Decision B, slice 5). What it buys today is the ability to REFUSE
2760
+ * to attribute rather than to guess.
2761
+ */
2762
+ leafCountForIdentityCached(channelId, identityId) {
2763
+ const mgr = this.managers.get(channelId);
2764
+ if (!mgr)
2765
+ return null;
2766
+ return mgr.leafIndicesForIdentity(identityId).length;
2767
+ }
2555
2768
  /** Returns true when the live in-memory MLS tree for `channelId` contains at
2556
2769
  * least one leaf bearing `identityId` as its credential. Used by the agent
2557
2770
  * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
@@ -2669,7 +2882,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2669
2882
  return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2670
2883
  }
2671
2884
  return await this.locks.run(channelId, async () => {
2672
- const mgr = new MLSGroupManager();
2885
+ const mgr = new MLSGroupManager2();
2673
2886
  try {
2674
2887
  await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2675
2888
  } catch (err) {
@@ -3413,6 +3626,24 @@ var RestTransport = class {
3413
3626
  };
3414
3627
 
3415
3628
  // ../connect-sdk-ts/dist/mls/rest-backend.js
3629
+ function describeType(v) {
3630
+ if (v === void 0)
3631
+ return "missing";
3632
+ if (v === null)
3633
+ return "null";
3634
+ if (Array.isArray(v))
3635
+ return "array";
3636
+ return typeof v;
3637
+ }
3638
+ function parseGroupRoster(endpoint, channelId, value) {
3639
+ if (!Array.isArray(value)) {
3640
+ throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
3641
+ }
3642
+ if (value.length === 0) {
3643
+ throw new MalformedGroupRosterError(endpoint, channelId, "empty array");
3644
+ }
3645
+ return value.map(String);
3646
+ }
3416
3647
  var RestMlsBackend = class {
3417
3648
  opts;
3418
3649
  f;
@@ -3488,7 +3719,10 @@ var RestMlsBackend = class {
3488
3719
  return {
3489
3720
  group_id: String(j.group_id),
3490
3721
  current_epoch: Number(j.current_epoch),
3491
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3722
+ // #230: fail loud on an absent / non-array / empty roster rather than
3723
+ // coerce to `[]` (which pre-empts the consumers' `?? [self]` fallback and
3724
+ // gets POSTed back as a wipe on distribute).
3725
+ member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
3492
3726
  // Back-compat: servers that predate the created field return nothing;
3493
3727
  // treat absence as true (they always created a fresh group).
3494
3728
  created: j.created === void 0 ? true : Boolean(j.created)
@@ -3513,10 +3747,14 @@ var RestMlsBackend = class {
3513
3747
  return {
3514
3748
  group_id: String(j.group_id),
3515
3749
  current_epoch: Number(j.current_epoch),
3516
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3750
+ // #230: same guard as init. This field has ZERO consumers today
3751
+ // (resetSessionSupersede reads only stale_member_device_ids), so this is
3752
+ // defense in depth: a future consumer cannot inherit the silent-`[]` trap.
3753
+ member_device_ids: parseGroupRoster("reinit", channelId, j.member_device_ids),
3517
3754
  // Absent on a backend that predates the dropped-member report; an empty
3518
3755
  // list is the correct read of "the server told us nothing", since the old
3519
- // server also never dropped anyone for an expired KeyPackage.
3756
+ // server also never dropped anyone for an expired KeyPackage. UNCHANGED:
3757
+ // empty here is legitimate, the opposite of member_device_ids (§3.3).
3520
3758
  stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3521
3759
  };
3522
3760
  }
@@ -3532,6 +3770,22 @@ var RestMlsBackend = class {
3532
3770
  return null;
3533
3771
  }
3534
3772
  }
3773
+ async fetchKeyPackageExpiry(kpId) {
3774
+ try {
3775
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}/expiry`);
3776
+ const raw = j.expires_at;
3777
+ if (typeof raw !== "string")
3778
+ return null;
3779
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw))
3780
+ return null;
3781
+ const ms = Date.parse(raw);
3782
+ if (Number.isNaN(ms))
3783
+ return null;
3784
+ return new Date(ms);
3785
+ } catch {
3786
+ return null;
3787
+ }
3788
+ }
3535
3789
  async distribute(groupId, req) {
3536
3790
  await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
3537
3791
  ...req,
@@ -3556,10 +3810,14 @@ var RestMlsBackend = class {
3556
3810
  }
3557
3811
  async getCommitterWork(channelId) {
3558
3812
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
3559
- return {
3560
- adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
3561
- removes: Array.isArray(j.removes) ? j.removes.map(String) : []
3813
+ const requireList = (field) => {
3814
+ const v = j?.[field];
3815
+ if (!Array.isArray(v)) {
3816
+ throw new MalformedCommitterWorkError(channelId, field, describeType(v));
3817
+ }
3818
+ return v.map(String);
3562
3819
  };
3820
+ return { adds: requireList("adds"), removes: requireList("removes") };
3563
3821
  }
3564
3822
  async acquireCommitLease(channelId) {
3565
3823
  const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
@@ -3590,6 +3848,390 @@ var RestMlsBackend = class {
3590
3848
 
3591
3849
  // ../connect-sdk-ts/dist/mls/fake-backend.js
3592
3850
  import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
3851
+ var FakeConnectBackend = class _FakeConnectBackend {
3852
+ // Key-package store: deviceId → hex-encoded KeyPackage
3853
+ kps = /* @__PURE__ */ new Map();
3854
+ // Server-assigned KeyPackage ids: deviceId → id (last publish wins, mirroring
3855
+ // the real backend's single-row-per-device semantics).
3856
+ kpIds = /* @__PURE__ */ new Map();
3857
+ _kpSeq = 0;
3858
+ /** Server TTL stamps: kp_id → expiry epoch-ms. Keyed by ID, not by device, so a
3859
+ * SUPERSEDED id has no entry at all — mirroring `publish_key_package`, whose
3860
+ * first statement DELETEs every prior row for the device
3861
+ * (`mls_key_package_service.py:26-28`), which makes a superseded id 404. */
3862
+ kpExpiry = /* @__PURE__ */ new Map();
3863
+ /** Injectable clock, so a test can place a row inside or just outside the guard's
3864
+ * skew grace. The fake's stamp, its `fetchKeyPackages` filter, and the provider's
3865
+ * comparison must all read ONE clock or the grace is not expressible. */
3866
+ now = () => Date.now();
3867
+ /** The real server's default KeyPackage TTL. */
3868
+ static TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3869
+ // Per-device delivery queues
3870
+ queues = /* @__PURE__ */ new Map();
3871
+ // Per-channel state (the real backend scopes membership + epoch per group;
3872
+ // keying these by channel keeps a multi-channel test from cross-contaminating).
3873
+ membersByChannel = /* @__PURE__ */ new Map();
3874
+ epochByChannel = /* @__PURE__ */ new Map();
3875
+ groupIdByChannel = /* @__PURE__ */ new Map();
3876
+ channelByGroup = /* @__PURE__ */ new Map();
3877
+ // identity_id → device_id, recorded at registerDevice so ingestApplication can
3878
+ // exclude the sender's own device on fanout (mirrors sendApplication's
3879
+ // senderDeviceId exclusion when only the envelope's sender_id is known).
3880
+ deviceByIdentity = /* @__PURE__ */ new Map();
3881
+ // Owner-attested reset: settable attestation map keyed by NEW groupId, read by
3882
+ // fetchResetAttestation (agent verify path) + written by resetReinitConnectChannelGroup.
3883
+ resetAttestations = /* @__PURE__ */ new Map();
3884
+ // Optional signer id the owner-side resetReinit records on the attestation it
3885
+ // stores (Task 9). Undefined in Task-6 tests, which set resetAttestations directly.
3886
+ ownerIdentityId;
3887
+ // Monotonic counter for generating unique IDs
3888
+ /** Ported-from-gen2 replay support: every distributed commit, in order, so
3889
+ * syncGroup() can serve a real catch-up instead of a stub. */
3890
+ commitLog = /* @__PURE__ */ new Map();
3891
+ /** Gap beyond which syncGroup answers "rejoin" (mirrors REJOIN_THRESHOLD). */
3892
+ rejoinThreshold = 100;
3893
+ _seq = 0;
3894
+ // Dedicated monotonic counter for reinit group ids (see reinitConnectChannelGroup).
3895
+ _reinitSeq = 0;
3896
+ /** Returns (and lazily creates) the delivery queue for a device. */
3897
+ q(deviceId) {
3898
+ let queue = this.queues.get(deviceId);
3899
+ if (!queue) {
3900
+ queue = [];
3901
+ this.queues.set(deviceId, queue);
3902
+ }
3903
+ return queue;
3904
+ }
3905
+ // ── MlsBackend implementation ──────────────────────────────────────────────
3906
+ /** Test stub: a FIXED 32-byte challenge hex. The real backend mints a fresh
3907
+ * single-use nonce; tests that exercise the PoP path sign over this constant
3908
+ * and re-derive the same message to verify. Deterministic so a register spy
3909
+ * can reconstruct and check the proof. */
3910
+ async getRegisterChallenge() {
3911
+ return "ab".repeat(32);
3912
+ }
3913
+ async registerDevice(req) {
3914
+ const deviceId = "fake-device-" + this._seq++;
3915
+ if (req.identity_id)
3916
+ this.deviceByIdentity.set(req.identity_id, deviceId);
3917
+ return { device_id: deviceId };
3918
+ }
3919
+ /** Test seam: record an identity→device link for a provider constructed with a
3920
+ * fixed deviceId (the SDK e2e wires providers directly, bypassing registerDevice).
3921
+ * The real backend resolves removed_identity_id → device(s) from the devices
3922
+ * table FK; this mirrors that resolution so op==="remove" can subtract the
3923
+ * removed identity's device(s) from the roster + delivery, exactly like the
3924
+ * server's purge_device_queue. */
3925
+ linkIdentityDevice(identityId, deviceId) {
3926
+ this.deviceByIdentity.set(identityId, deviceId);
3927
+ }
3928
+ async publishKeyPackage(deviceId, keyPackageHex) {
3929
+ this.kps.set(deviceId, keyPackageHex);
3930
+ const prior = this.kpIds.get(deviceId);
3931
+ if (prior)
3932
+ this.kpExpiry.delete(prior);
3933
+ const id = `kp_${deviceId}_${this._kpSeq = (this._kpSeq ?? 0) + 1}`;
3934
+ this.kpIds.set(deviceId, id);
3935
+ this.kpExpiry.set(id, this.now() + _FakeConnectBackend.TTL_MS);
3936
+ return id;
3937
+ }
3938
+ /** Test seam: move a recorded expiry to an absolute epoch-ms instant. Named
3939
+ * rather than letting a test write `kpExpiry` directly, so the id-keyed shape
3940
+ * stays an implementation detail. A hard-coded past value would make T2
3941
+ * (the fresh-KeyPackage negative control) unbuildable. */
3942
+ setKeyPackageExpiry(kpId, expiresAtMs) {
3943
+ this.kpExpiry.set(kpId, expiresAtMs);
3944
+ }
3945
+ async fetchKeyPackageExpiry(kpId) {
3946
+ const ms = this.kpExpiry.get(kpId);
3947
+ return ms === void 0 ? null : new Date(ms);
3948
+ }
3949
+ async getConnectChannelGroup(channelId) {
3950
+ const groupId = this.groupIdByChannel.get(channelId) ?? null;
3951
+ if (!groupId)
3952
+ return { exists: false, group_id: null, current_epoch: null };
3953
+ return {
3954
+ exists: true,
3955
+ group_id: groupId,
3956
+ current_epoch: this.epochByChannel.get(channelId) ?? 0
3957
+ };
3958
+ }
3959
+ async initConnectChannelGroup(channelId, memberDeviceIds) {
3960
+ const alreadyExists = this.groupIdByChannel.has(channelId);
3961
+ let groupId = this.groupIdByChannel.get(channelId);
3962
+ if (!groupId) {
3963
+ groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
3964
+ this.groupIdByChannel.set(channelId, groupId);
3965
+ this.channelByGroup.set(groupId, channelId);
3966
+ this.epochByChannel.set(channelId, 0);
3967
+ }
3968
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
3969
+ this.membersByChannel.set(channelId, members);
3970
+ return {
3971
+ group_id: groupId,
3972
+ current_epoch: this.epochByChannel.get(channelId) ?? 0,
3973
+ member_device_ids: members,
3974
+ // Real semantics: created=true on first call (group was just created),
3975
+ // created=false on idempotent return (group already existed). Without
3976
+ // this, race tests silently pass wrong (fake is laxer than the server).
3977
+ created: !alreadyExists
3978
+ };
3979
+ }
3980
+ async reinitConnectChannelGroup(channelId, memberDeviceIds) {
3981
+ const groupId = `00000000-0000-0000-0000-${String(1e3 + (this._reinitSeq = (this._reinitSeq ?? 0) + 1)).padStart(12, "0")}`;
3982
+ this.groupIdByChannel.set(channelId, groupId);
3983
+ this.channelByGroup.set(groupId, channelId);
3984
+ this.epochByChannel.set(channelId, 0);
3985
+ const members = [...new Set(memberDeviceIds)];
3986
+ this.membersByChannel.set(channelId, members);
3987
+ return { group_id: groupId, current_epoch: 0, member_device_ids: members };
3988
+ }
3989
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
3990
+ this.groupIdByChannel.set(channelId, att.newGroupId);
3991
+ this.channelByGroup.set(att.newGroupId, channelId);
3992
+ this.epochByChannel.set(channelId, 0);
3993
+ const members = [...new Set(memberDeviceIds)];
3994
+ this.membersByChannel.set(channelId, members);
3995
+ this.resetAttestations.set(att.newGroupId, {
3996
+ signedContent: new TextDecoder().decode(hexToBytes2(att.signedContentHex)),
3997
+ signatureHex: att.signatureHex,
3998
+ signerIdentityId: this.ownerIdentityId ?? "owner"
3999
+ });
4000
+ return {
4001
+ group_id: att.newGroupId,
4002
+ current_epoch: 0,
4003
+ member_device_ids: members,
4004
+ // The fake honours the roster verbatim, so nothing is ever dropped here.
4005
+ // Tests that need the dropped-member path stub this method directly.
4006
+ stale_member_device_ids: []
4007
+ };
4008
+ }
4009
+ async fetchKeyPackages(deviceIds) {
4010
+ const out = {};
4011
+ for (const id of deviceIds) {
4012
+ const kpId = this.kpIds.get(id);
4013
+ const exp = kpId === void 0 ? void 0 : this.kpExpiry.get(kpId);
4014
+ if (exp !== void 0 && exp <= this.now())
4015
+ continue;
4016
+ out[id] = this.kps.get(id);
4017
+ }
4018
+ return out;
4019
+ }
4020
+ async fetchKeyPackageById(kpId) {
4021
+ for (const [deviceId, id] of this.kpIds) {
4022
+ if (id === kpId) {
4023
+ return this.kps.get(deviceId) ?? null;
4024
+ }
4025
+ }
4026
+ return null;
4027
+ }
4028
+ async syncGroup(groupId, lastEpoch, _deviceId) {
4029
+ const channelId = this.channelByGroup.get(groupId);
4030
+ const current = channelId ? this.epochByChannel.get(channelId) ?? 0 : 0;
4031
+ if (current - lastEpoch > this.rejoinThreshold)
4032
+ return { action: "rejoin", messages: [] };
4033
+ const log = this.commitLog.get(groupId) ?? [];
4034
+ return {
4035
+ action: "replay",
4036
+ messages: log.filter((c) => c.epoch > lastEpoch).sort((a, b) => a.epoch - b.epoch).map((c) => ({
4037
+ id: c.id,
4038
+ epoch: c.epoch,
4039
+ message_type: "commit",
4040
+ sender_device_id: c.sender_device_id,
4041
+ payload: c.payload
4042
+ }))
4043
+ };
4044
+ }
4045
+ async distribute(groupId, req) {
4046
+ const channelId = this.channelByGroup.get(groupId);
4047
+ if (!channelId)
4048
+ throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
4049
+ if (req.commit && req.expected_epoch != null) {
4050
+ const current = this.epochByChannel.get(channelId) ?? 0;
4051
+ if (req.expected_epoch !== current) {
4052
+ const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
4053
+ e.status = 409;
4054
+ throw e;
4055
+ }
4056
+ }
4057
+ if (req.commit) {
4058
+ const log = this.commitLog.get(groupId) ?? [];
4059
+ log.push({
4060
+ id: `c${++this._seq}`,
4061
+ epoch: Number(req.commit_epoch ?? 0),
4062
+ payload: String(req.commit),
4063
+ sender_device_id: String(req.device_id ?? "")
4064
+ });
4065
+ this.commitLog.set(groupId, log);
4066
+ }
4067
+ const priorMembers = this.membersByChannel.get(channelId) ?? [];
4068
+ this.epochByChannel.set(channelId, req.commit_epoch ?? this.epochByChannel.get(channelId) ?? 0);
4069
+ let evictedDevice;
4070
+ if (req.op === "remove") {
4071
+ if (!req.removed_identity_id) {
4072
+ throw new Error("removed_identity_id required");
4073
+ }
4074
+ evictedDevice = req.removed_device_id ?? (req.removed_identity_id ? this.deviceByIdentity.get(req.removed_identity_id) : void 0);
4075
+ const remaining = (this.membersByChannel.get(channelId) ?? []).filter((d) => d !== evictedDevice);
4076
+ this.membersByChannel.set(channelId, remaining);
4077
+ if (evictedDevice)
4078
+ this.queues.delete(evictedDevice);
4079
+ } else {
4080
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
4081
+ this.membersByChannel.set(channelId, members);
4082
+ }
4083
+ if (req.commit) {
4084
+ for (const dev of priorMembers) {
4085
+ if (dev === req.device_id)
4086
+ continue;
4087
+ if (dev === evictedDevice)
4088
+ continue;
4089
+ this.q(dev).push({
4090
+ queue_id: "q-" + this._seq++,
4091
+ message_id: "m-commit-" + this._seq++,
4092
+ group_id: groupId,
4093
+ message_type: "commit",
4094
+ sender_device_id: req.device_id,
4095
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4096
+ payload: req.commit,
4097
+ connect_channel_id: channelId,
4098
+ created_at: ""
4099
+ });
4100
+ }
4101
+ }
4102
+ for (const w of req.welcomes ?? []) {
4103
+ this.q(w.target_device_id).push({
4104
+ queue_id: "q-" + this._seq++,
4105
+ message_id: "m-welcome-" + this._seq++,
4106
+ group_id: groupId,
4107
+ message_type: "welcome",
4108
+ sender_device_id: req.device_id,
4109
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4110
+ payload: w.payload,
4111
+ connect_channel_id: channelId,
4112
+ created_at: ""
4113
+ });
4114
+ }
4115
+ }
4116
+ async pullDelivery(deviceId) {
4117
+ return this.queues.get(deviceId) ?? [];
4118
+ }
4119
+ async ackDelivery(deviceId, queueIds) {
4120
+ const queue = this.queues.get(deviceId);
4121
+ if (queue) {
4122
+ this.queues.set(deviceId, queue.filter((r) => !queueIds.includes(r.queue_id)));
4123
+ }
4124
+ }
4125
+ requestWelcomeCalls = [];
4126
+ async requestWelcome(groupId, deviceId, freshKpId) {
4127
+ this.requestWelcomeCalls.push({ groupId, deviceId, freshKpId });
4128
+ }
4129
+ // ADR 0004 C1: committer-work + lease stubs (in-memory for unit tests;
4130
+ // no sweep driver runs against the fake backend, so these are no-ops).
4131
+ async getCommitterWork(_channelId) {
4132
+ return { adds: [], removes: [] };
4133
+ }
4134
+ async acquireCommitLease(_channelId) {
4135
+ return "fake-lease-token";
4136
+ }
4137
+ async releaseCommitLease(_channelId, _token) {
4138
+ }
4139
+ async fetchResetAttestation(groupId) {
4140
+ return this.resetAttestations.get(groupId) ?? null;
4141
+ }
4142
+ // ── E2E helper: seal + fanout (mirrors the Python edge's Task-7 path) ──────
4143
+ async sendApplication(p) {
4144
+ const key = await p.sender.exporterKey(p.enclaveId, p.channelId, p.epoch);
4145
+ const messageId = "m-" + this._seq++;
4146
+ const replayNonce = bytesToBase64(crypto.getRandomValues(new Uint8Array(24)));
4147
+ const policy_binding = {
4148
+ binding_id: "b",
4149
+ version: "1",
4150
+ agent_skill_id: null,
4151
+ delegation_id: null,
4152
+ client_stage5_outcome: "allow",
4153
+ client_stage5_evaluators: []
4154
+ };
4155
+ const aad = await buildAssociatedData({
4156
+ message_id: messageId,
4157
+ channel_id: p.channelId,
4158
+ sender_id: p.senderId,
4159
+ mls_group_epoch: p.epoch,
4160
+ replay_nonce: replayNonce,
4161
+ policy_binding
4162
+ });
4163
+ const sealed = sealPayload({
4164
+ plaintext: new TextEncoder().encode(p.plaintext),
4165
+ key,
4166
+ associatedData: aad,
4167
+ suite: "xchacha20poly1305"
4168
+ });
4169
+ const envelope = {
4170
+ av_version: "1.0",
4171
+ message_id: messageId,
4172
+ channel_id: p.channelId,
4173
+ enclave_id: p.enclaveId,
4174
+ message_type: "text",
4175
+ sender_id: p.senderId,
4176
+ sender_type: "agent",
4177
+ identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
4178
+ trace_context: { traceparent: "" },
4179
+ policy_binding,
4180
+ mls_group_epoch: p.epoch,
4181
+ replay_nonce: replayNonce,
4182
+ payload_encrypted: bytesToBase64(sealed),
4183
+ references: []
4184
+ };
4185
+ const canonical = await canonicalEnvelopeBytes(envelope);
4186
+ const members = this.membersByChannel.get(p.channelId) ?? [];
4187
+ const groupId = this.groupIdByChannel.get(p.channelId) ?? "";
4188
+ for (const dev of members) {
4189
+ if (dev === p.senderDeviceId)
4190
+ continue;
4191
+ this.q(dev).push({
4192
+ queue_id: "q-" + this._seq++,
4193
+ message_id: messageId,
4194
+ group_id: groupId,
4195
+ message_type: "application",
4196
+ sender_device_id: p.senderDeviceId,
4197
+ epoch: p.epoch,
4198
+ payload: bytesToHex3(canonical),
4199
+ connect_channel_id: p.channelId,
4200
+ created_at: ""
4201
+ });
4202
+ }
4203
+ }
4204
+ // ── E2E helper: ingest an already-built envelope (client-side build/seal) ──
4205
+ //
4206
+ // Mirrors sendApplication's storage + fanout, but takes an AgentVaultEnvelope
4207
+ // the *client* already built, sealed, and signed (via buildOutboundEnvelope) —
4208
+ // exactly what the owner client hands to its injected `publish`. The server
4209
+ // edge stores the canonical envelope bytes (hex) and fans out one delivery row
4210
+ // per active member device except the sender's. We resolve the sender's device
4211
+ // from sender_id (recorded at registerDevice); if unknown, every member gets it.
4212
+ async ingestApplication(envelope) {
4213
+ const channelId = envelope.channel_id;
4214
+ const canonical = await canonicalEnvelopeBytes(envelope);
4215
+ const members = this.membersByChannel.get(channelId) ?? [];
4216
+ const groupId = this.groupIdByChannel.get(channelId) ?? "";
4217
+ const senderDeviceId = this.deviceByIdentity.get(envelope.sender_id);
4218
+ for (const dev of members) {
4219
+ if (dev === senderDeviceId)
4220
+ continue;
4221
+ this.q(dev).push({
4222
+ queue_id: "q-" + this._seq++,
4223
+ message_id: envelope.message_id,
4224
+ group_id: groupId,
4225
+ message_type: "application",
4226
+ sender_device_id: senderDeviceId ?? "",
4227
+ epoch: envelope.mls_group_epoch,
4228
+ payload: bytesToHex3(canonical),
4229
+ connect_channel_id: channelId,
4230
+ created_at: ""
4231
+ });
4232
+ }
4233
+ }
4234
+ };
3593
4235
 
3594
4236
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
3595
4237
  import { hkdf } from "@noble/hashes/hkdf";
@@ -3639,7 +4281,7 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
3639
4281
 
3640
4282
  // ../connect-mls-store/dist/sqlite-store.js
3641
4283
  import { DatabaseSync } from "node:sqlite";
3642
- import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
4284
+ import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
3643
4285
 
3644
4286
  // ../connect-mls-store/dist/schema.js
3645
4287
  var SCHEMA_VERSION = 6;
@@ -3755,7 +4397,7 @@ var MlsStoreSealError = class extends Error {
3755
4397
  function encodeBundle(bundle) {
3756
4398
  const pp = bundle.privatePackage;
3757
4399
  const obj = {
3758
- pub: bytesToHex4(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
4400
+ pub: bytesToHex4(MLSGroupManager3.serializeKeyPackage(bundle.publicPackage)),
3759
4401
  initPriv: bytesToHex4(pp.initPrivateKey),
3760
4402
  hpkePriv: bytesToHex4(pp.hpkePrivateKey),
3761
4403
  sigPriv: bytesToHex4(pp.signaturePrivateKey)
@@ -3765,7 +4407,7 @@ function encodeBundle(bundle) {
3765
4407
  function decodeBundle(bytes) {
3766
4408
  const obj = JSON.parse(new TextDecoder().decode(bytes));
3767
4409
  return {
3768
- publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
4410
+ publicPackage: MLSGroupManager3.deserializeKeyPackage(hexToBytes3(obj.pub)),
3769
4411
  privatePackage: {
3770
4412
  initPrivateKey: hexToBytes3(obj.initPriv),
3771
4413
  hpkePrivateKey: hexToBytes3(obj.hpkePriv),
@@ -3853,6 +4495,22 @@ var SqliteMlsStateStore = class {
3853
4495
  async discardStaging(channelId) {
3854
4496
  this.db.prepare("UPDATE mls_group_state SET sealed_staging=NULL WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
3855
4497
  }
4498
+ /**
4499
+ * ⛔ THE WRITE PATH GETS NO READINESS BARRIER, AND THAT IS DELIBERATE. Two
4500
+ * independent reasons, and the FIRST is the primary one.
4501
+ *
4502
+ * 1. `:171` below populates `this.bundles` BEFORE the seal on the next line.
4503
+ * A same-process reload therefore hits the cache at `loadPendingBundle`'s
4504
+ * `:182-183` and NEVER decodes. The decode path is only ever taken for a
4505
+ * bundle written by a PREVIOUS process, which by definition is not this
4506
+ * one. A barrier here could not protect the read that actually fails.
4507
+ * 2. `encodeBundle`'s `bytesToHex` is cold-safe BECAUSE `to_hex` needs no
4508
+ * `_malloc`. ⛔ IT IS NOT "PURE JS" AND THE COMMENT MUST NEVER SAY THAT.
4509
+ * It is a libsodium call like the others; it simply does not touch the
4510
+ * WASM heap. Recording the wrong reason invites a later reader to
4511
+ * "correct" the asymmetry between this method and `loadPendingBundle` in
4512
+ * the wrong direction.
4513
+ */
3856
4514
  async savePendingBundle(ref, bundle) {
3857
4515
  this.bundles.set(ref, bundle);
3858
4516
  const sealed = await this.cipher.seal(encodeBundle(bundle));
@@ -3867,13 +4525,12 @@ var SqliteMlsStateStore = class {
3867
4525
  const row = this.db.prepare("SELECT sealed_bundle FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, ref);
3868
4526
  if (!row?.sealed_bundle)
3869
4527
  return null;
4528
+ await ensureSodiumReady();
3870
4529
  let bundle;
3871
4530
  try {
3872
4531
  bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
3873
4532
  } catch (e) {
3874
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
3875
- console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
3876
- }
4533
+ console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
3877
4534
  return null;
3878
4535
  }
3879
4536
  this.bundles.set(ref, bundle);
@@ -4269,9 +4926,10 @@ function mimeForName(name) {
4269
4926
  return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
4270
4927
  }
4271
4928
  async function prepareAttachment(filePath, caption, channelId, deps) {
4929
+ await deps.ready();
4272
4930
  const bytes = await deps.readFile(filePath);
4273
4931
  const name = basename(filePath);
4274
- const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
4932
+ const { encryptedData, fileKey, fileNonce, digest } = await deps.encryptFile(bytes);
4275
4933
  const blobId = await deps.uploadBlob(encryptedData, channelId);
4276
4934
  const body = {
4277
4935
  v: 1,
@@ -4288,12 +4946,13 @@ async function prepareAttachment(filePath, caption, channelId, deps) {
4288
4946
  return body;
4289
4947
  }
4290
4948
  async function materializeAttachment(body, ctx, deps) {
4949
+ await deps.ready();
4291
4950
  const ciphertext = await deps.downloadBlob(body.blobId);
4292
- const sha2566 = deps.computeFileDigest(ciphertext);
4951
+ const sha2566 = await deps.computeFileDigest(ciphertext);
4293
4952
  if (sha2566 !== body.digest) {
4294
4953
  throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
4295
4954
  }
4296
- const plain = deps.decryptFile(
4955
+ const plain = await deps.decryptFile(
4297
4956
  ciphertext,
4298
4957
  deps.base64ToBytes(body.fileKey),
4299
4958
  deps.base64ToBytes(body.fileNonce)
@@ -4306,6 +4965,11 @@ async function materializeAttachment(body, ctx, deps) {
4306
4965
  return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2566 };
4307
4966
  }
4308
4967
 
4968
+ // src/sodium-ready.ts
4969
+ function ensureSodiumReady2() {
4970
+ return ensureSodiumReady();
4971
+ }
4972
+
4309
4973
  // src/memory/hermes-session-map.ts
4310
4974
  import { readFileSync } from "node:fs";
4311
4975
  import { homedir } from "node:os";
@@ -5203,6 +5867,10 @@ var BridgeAgent = class _BridgeAgent {
5203
5867
  }
5204
5868
  async start() {
5205
5869
  this.startedAt = Date.now();
5870
+ try {
5871
+ await this.ingestOnce();
5872
+ } catch {
5873
+ }
5206
5874
  await this.provider.publishKeyPackage();
5207
5875
  this.startReceivePump();
5208
5876
  if (this.selfHealSweepEnabled) {
@@ -5406,6 +6074,50 @@ var BridgeAgent = class _BridgeAgent {
5406
6074
  * DEVICE never held a leaf can 403 on the signal call. That's harmless and
5407
6075
  * must not abort the sweep for every other channel.
5408
6076
  */
6077
+ /**
6078
+ * One best-effort device-level KeyPackage rotation attempt.
6079
+ *
6080
+ * `publishKeyPackage` is a cheap no-op skip unless the SERVER says the held
6081
+ * KeyPackage is expired, so calling it every cycle costs one read and rotates at
6082
+ * most once per TTL. The catch logs once and continues: it MUST NOT rethrow, MUST
6083
+ * NOT abort the sweep, and MUST NOT retry inside the cycle. A KeyPackage that
6084
+ * cannot be rotated this cycle is retried on the next interval, and the sweep's
6085
+ * per-channel repair work is independent of it.
6086
+ */
6087
+ /**
6088
+ * Tri-state: "is this device STILL serving a server-expired KeyPackage at the end
6089
+ * of the cycle that just tried to fix it?"
6090
+ *
6091
+ * `false` is the only healthy value, and it requires a POSITIVE, current proof of
6092
+ * non-expiry. `null` is unknown and reads exactly like `true` in `degraded`, so a
6093
+ * daemon that cannot prove itself un-expired stops reporting healthy. That is the
6094
+ * OPPOSITE direction to the rotation guard, which never rotates on unknown.
6095
+ * Conflating the two directions recreates the lie this change removes.
6096
+ *
6097
+ * Initialised `null` and written only by `selfHealSweep`, so a sweep-off daemon
6098
+ * reads `null` and therefore degraded.
6099
+ */
6100
+ keypackageExpired = null;
6101
+ /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
6102
+ * mapped to `null`. Never throws: a failure to measure is itself unknown. */
6103
+ async resolveKeypackageExpired() {
6104
+ try {
6105
+ const kpId = await this.provider.getLastPublishedKpId();
6106
+ if (kpId === null || kpId === "") return null;
6107
+ const exp = await this.backend.fetchKeyPackageExpiry(kpId);
6108
+ if (exp === null) return null;
6109
+ return isServerExpired(exp, Date.now());
6110
+ } catch {
6111
+ return null;
6112
+ }
6113
+ }
6114
+ async rotateDeviceKeyPackage() {
6115
+ try {
6116
+ await this.provider.publishKeyPackage();
6117
+ } catch (e) {
6118
+ console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
6119
+ }
6120
+ }
5409
6121
  async selfHealSweep() {
5410
6122
  if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
5411
6123
  this.providerCapabilityMissing = true;
@@ -5414,7 +6126,16 @@ var BridgeAgent = class _BridgeAgent {
5414
6126
  );
5415
6127
  return;
5416
6128
  }
6129
+ if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
6130
+ this.providerCapabilityMissing = true;
6131
+ console.error(
6132
+ "[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."
6133
+ );
6134
+ return;
6135
+ }
5417
6136
  this.providerCapabilityMissing = false;
6137
+ await this.rotateDeviceKeyPackage();
6138
+ this.keypackageExpired = await this.resolveKeypackageExpired();
5418
6139
  const enclaves = await this.enclavesToCover();
5419
6140
  this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5420
6141
  const seen = /* @__PURE__ */ new Set();
@@ -5662,12 +6383,24 @@ var BridgeAgent = class _BridgeAgent {
5662
6383
  }
5663
6384
  sendDeps() {
5664
6385
  return {
6386
+ ready: () => ensureSodiumReady2(),
5665
6387
  uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
5666
6388
  encryptFile,
5667
6389
  bytesToBase64: bytesToBase642,
5668
6390
  readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
5669
6391
  };
5670
6392
  }
6393
+ receiveDeps() {
6394
+ return {
6395
+ ready: () => ensureSodiumReady2(),
6396
+ downloadBlob: this.downloadBlobImpl,
6397
+ decryptFile,
6398
+ computeFileDigest,
6399
+ base64ToBytes: base64ToBytes2,
6400
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6401
+ writeFile: (p, data) => fsp.writeFile(p, data)
6402
+ };
6403
+ }
5671
6404
  async sendAttachment(channelId, filePath, caption) {
5672
6405
  this.enforceRateFloor("attachment", channelId);
5673
6406
  const joined = await this.reconcileJoin(channelId);
@@ -5723,8 +6456,11 @@ var BridgeAgent = class _BridgeAgent {
5723
6456
  /**
5724
6457
  * List channels in one enclave (default: the daemon's own home enclave).
5725
6458
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
5726
- * cross-enclave room's is_dm gets resolved — see the scope caveat there
5727
- * (same-tenant only; a cross-tenant enclave 404s under the agent's bearer).
6459
+ * cross-enclave room's is_dm gets resolved. ⚠️ The old "same-tenant only; a
6460
+ * cross-tenant enclave 404s under the agent's bearer" caveat here is refuted
6461
+ * — see the CROSS-TENANT LISTING note on enclavesToCover. This route admits
6462
+ * any enclave the caller holds a live membership row in, partner tenants
6463
+ * included.
5728
6464
  */
5729
6465
  async listChannels(enclaveId = this.cfg.enclaveId) {
5730
6466
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
@@ -5779,12 +6515,16 @@ var BridgeAgent = class _BridgeAgent {
5779
6515
  * room is never listed under the home-enclave-only call, so its is_dm never
5780
6516
  * resolves and the @mention filter fails open for that room.
5781
6517
  *
5782
- * Each enclave is wrapped in its own try/catch (scope caveat — audit
5783
- * H2/tenant): list_channels_in_enclave filters on tenant_id, so a
5784
- * same-tenant enclave the agent has learned about resolves fine, but a
5785
- * cross-TENANT federated-partner enclave 404s under this agent's bearer;
5786
- * that enclave's channels are left unresolved (fail open) without aborting
5787
- * the other enclaves in the set.
6518
+ * Each enclave is wrapped in its own try/catch so one enclave's listing
6519
+ * failure leaves only that enclave's channels unresolved (fail open) without
6520
+ * aborting the other enclaves in the set.
6521
+ *
6522
+ * ⚠️ The old scope caveat here (audit H2/tenant) said
6523
+ * `list_channels_in_enclave` filters on tenant_id and 404s a cross-TENANT
6524
+ * federated-partner enclave. That is refuted — see the CROSS-TENANT LISTING
6525
+ * note on enclavesToCover. A partner enclave with a live membership row
6526
+ * resolves like any other, so the try/catch now covers transport errors,
6527
+ * 5xx, and genuine non-member 404s only.
5788
6528
  */
5789
6529
  async refreshChannelMeta() {
5790
6530
  const enclaves = await this.enclavesToCover();
@@ -5812,12 +6552,20 @@ var BridgeAgent = class _BridgeAgent {
5812
6552
  * failure right after a restart would read as "never succeeded" and slip
5813
6553
  * through the expected-gap branch — weaker than the old abort-on-failure).
5814
6554
  * 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. */
6555
+ * failure of a never-succeeded enclave is reported but does not degrade.
6556
+ *
6557
+ * ⚠️ The old justification for that asymmetry named a partner enclave's
6558
+ * "tenant-scoped 404, permanent pending backend authz" as the expected gap.
6559
+ * That premise is refuted — see the CROSS-TENANT LISTING note on
6560
+ * enclavesToCover; a partner enclave with a live membership row lists fine.
6561
+ * The asymmetry is kept on its remaining merit: an enclave this process has
6562
+ * never listed may simply be one this agent holds no live membership in, and
6563
+ * pegging health degraded forever on that is noise, not signal. */
5818
6564
  enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5819
6565
  /** 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. */
6566
+ * a persistently-failing enclave must not log 1440 lines a day. (The old
6567
+ * wording named "a permanently-404ing partner enclave" — refuted, see the
6568
+ * CROSS-TENANT LISTING note on enclavesToCover.) */
5821
6569
  enclaveListWarned = /* @__PURE__ */ new Set();
5822
6570
  static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5823
6571
  enclaveEnumWarned = false;
@@ -5845,11 +6593,35 @@ var BridgeAgent = class _BridgeAgent {
5845
6593
  * membership changes are picked up within one TTL, which matches the sweep
5846
6594
  * cadence this exists to serve.
5847
6595
  *
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.
6596
+ * ── CROSS-TENANT LISTING: the canonical statement for this file ──────────
6597
+ *
6598
+ * ⚠️ CORRECTED 2026-08-21 (#280 spec F6). This block, and five other comments
6599
+ * in this file, used to state that `list_channels_in_enclave` is tenant-
6600
+ * scoped and 404s a cross-tenant partner enclave under this agent's bearer,
6601
+ * leaving a partner enclave in this set with no channels. That has been FALSE
6602
+ * since #187 (`d7cc685a`, 2026-07-31). Verified by opening the code:
6603
+ *
6604
+ * - `list_channels_in_enclave` calls `_resolve_enclave_membership(...,
6605
+ * host_only=False)` (backend/app/api/v1/connect_channels.py:897-900).
6606
+ * - `host_only=False` OMITS the `ConnectEnclave.tenant_id == auth.tenant_id`
6607
+ * predicate and runs the membership query under `bypass_rls`, so the
6608
+ * host-owned enclave row is visible to a partner session (same file
6609
+ * :519-520, :531-543). The gate is membership, not tenancy.
6610
+ * - The channel query it then runs (same file :902-919) reads
6611
+ * `connect_channels` joined to `connect_channel_members`, and BOTH tables
6612
+ * are among the eight the schema deliberately leaves without RLS for
6613
+ * having no `tenant_id` column (alembic 0001_connect_initial_schema.py:
6614
+ * 750-755, :778-780). No policy filters those rows by tenant.
6615
+ *
6616
+ * So a partner-tenant agent holding a live `ConnectEnclaveMember` row
6617
+ * (`removed_at IS NULL`) gets 200 with that enclave's real channels. There is
6618
+ * no partner-enclave listing gap left to close, and no backend authz work
6619
+ * pending for it.
6620
+ *
6621
+ * What DOES still reach a per-enclave listing failure: a transport error, a
6622
+ * 5xx, or a genuine 404 for an enclave this agent holds no live membership
6623
+ * in. Those are real outages, so the skip-one, never-abort-all rule stands on
6624
+ * its own merit — it just no longer has a standing partner-enclave case.
5853
6625
  */
5854
6626
  async enclavesToCover() {
5855
6627
  const now = Date.now();
@@ -6305,14 +7077,7 @@ var BridgeAgent = class _BridgeAgent {
6305
7077
  const meta = await materializeAttachment(
6306
7078
  parsed,
6307
7079
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
6308
- {
6309
- downloadBlob: this.downloadBlobImpl,
6310
- decryptFile,
6311
- computeFileDigest,
6312
- base64ToBytes: base64ToBytes2,
6313
- mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6314
- writeFile: (p, data) => fsp.writeFile(p, data)
6315
- }
7080
+ this.receiveDeps()
6316
7081
  );
6317
7082
  this.buffer.push({
6318
7083
  channel_id: m.channelId,
@@ -6495,8 +7260,10 @@ var BridgeAgent = class _BridgeAgent {
6495
7260
  sweep_enabled: this.selfHealSweepEnabled,
6496
7261
  observations_stale: observationsStale,
6497
7262
  diverged_channels: divergedChannels,
7263
+ keypackage_expired: this.keypackageExpired,
6498
7264
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6499
- degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
7265
+ // `!== false` and not `=== true`: unknown must never read healthy.
7266
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost || this.keypackageExpired !== false,
6500
7267
  channels: liveChannels,
6501
7268
  sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6502
7269
  identity_id: this.cfg.identityId,
@@ -7256,6 +8023,7 @@ export {
7256
8023
  attachAgentWebSocket,
7257
8024
  capabilities,
7258
8025
  createHttpServer,
8026
+ ensureSodiumReady2 as ensureSodiumReady,
7259
8027
  errorBody,
7260
8028
  httpStatus,
7261
8029
  isLoopback,