@securecoms/agent-bridge 0.33.0 → 0.33.1

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
@@ -17477,7 +17477,17 @@ var CODES = [
17477
17477
  // missed and before any write, so nothing landed. Definite: the committer's
17478
17478
  // work cannot name an agent in a humans-only enclave (admission refuses
17479
17479
  // one), so dropping the commit starts no resend loop.
17480
- { status: 403, code: "humans_only_enclave" }
17480
+ { status: 403, code: "humans_only_enclave" },
17481
+ // ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
17482
+ // leaf-key claim map is missing, does not cover exactly the Welcome
17483
+ // targets, or names a key that is not the target's current vendable
17484
+ // KeyPackage's. Refused before any write; definite, so the committer drops
17485
+ // the commit, refetches the KeyPackages and rebuilds instead of resending
17486
+ // the same stale bytes forever.
17487
+ { status: 409, code: "voice_stale_key_package" },
17488
+ // PR-E: a Welcome target already holds a bound leaf in this V group (the
17489
+ // DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
17490
+ { status: 409, code: "voice_leaf_already_bound" }
17481
17491
  ];
17482
17492
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
17483
17493
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -18524,6 +18534,31 @@ function ownLeafIndexOf(mgr) {
18524
18534
  function tagOf(mgr) {
18525
18535
  return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
18526
18536
  }
18537
+ function leafKeyClaims(targets, pkgs) {
18538
+ if (targets.length !== pkgs.length)
18539
+ throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
18540
+ const out = {};
18541
+ targets.forEach((d, i) => {
18542
+ out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
18543
+ });
18544
+ return out;
18545
+ }
18546
+ function leafIndicesForSignatureKey(mgr, keyHex) {
18547
+ const tree = mgr.state?.ratchetTree;
18548
+ if (!tree)
18549
+ return [];
18550
+ const want = keyHex.toLowerCase();
18551
+ const out = [];
18552
+ const numLeaves = Math.floor((tree.length + 1) / 2);
18553
+ for (let i = 0; i < numLeaves; i++) {
18554
+ const node = tree[i * 2];
18555
+ if (!node || node.nodeType !== "leaf")
18556
+ continue;
18557
+ if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
18558
+ out.push(i);
18559
+ }
18560
+ return out;
18561
+ }
18527
18562
  function tagOfState(state) {
18528
18563
  const m = new MLSGroupManager(state.ciphersuite);
18529
18564
  m.importState(state);
@@ -18638,6 +18673,12 @@ var SelfAddRefusedError = class extends Error {
18638
18673
  this.name = "SelfAddRefusedError";
18639
18674
  }
18640
18675
  };
18676
+ var VoiceMirrorAddRefusedError = class extends Error {
18677
+ constructor() {
18678
+ super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
18679
+ this.name = "VoiceMirrorAddRefusedError";
18680
+ }
18681
+ };
18641
18682
  var VaultCredentialMismatchError = class extends Error {
18642
18683
  expected;
18643
18684
  got;
@@ -20127,10 +20168,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20127
20168
  * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
20128
20169
  * joinAndReconcile consumes the same Welcome and acks it after joining.
20129
20170
  */
20130
- async peekWelcomeChannel(deviceId) {
20171
+ async peekWelcomeChannel(deviceId, opts = {}) {
20131
20172
  const rows = await this.o.backend.pullDelivery(deviceId);
20132
- const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
20133
- return w?.connect_channel_id ?? null;
20173
+ const asked = /* @__PURE__ */ new Set();
20174
+ for (const r of rows) {
20175
+ if (r.message_type !== "welcome" || r.connect_channel_id == null)
20176
+ continue;
20177
+ const ch = r.connect_channel_id;
20178
+ if (!opts.accept)
20179
+ return ch;
20180
+ if (asked.has(ch))
20181
+ continue;
20182
+ asked.add(ch);
20183
+ let ok = false;
20184
+ try {
20185
+ ok = await opts.accept(ch) === true;
20186
+ } catch {
20187
+ ok = false;
20188
+ }
20189
+ if (ok)
20190
+ return ch;
20191
+ }
20192
+ return null;
20134
20193
  }
20135
20194
  /**
20136
20195
  * Publish this device's KeyPackage if it holds none; otherwise a no-op that
@@ -20236,10 +20295,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20236
20295
  }
20237
20296
  const attempted = [];
20238
20297
  const failed = [];
20298
+ const discarded = [];
20239
20299
  for (const channelId of channelIds) {
20240
20300
  attempted.push(channelId);
20241
20301
  try {
20242
- await this.ensureSession(channelId, { enclaveId: "welcome-drain", bootstrap: false });
20302
+ await this.ensureSession(channelId, {
20303
+ enclaveId: "welcome-drain",
20304
+ bootstrap: false,
20305
+ onUnopenableWelcome: (groupId) => {
20306
+ discarded.push({ channelId, groupId });
20307
+ }
20308
+ });
20243
20309
  } catch (error) {
20244
20310
  failed.push({ channelId, error });
20245
20311
  }
@@ -20249,7 +20315,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20249
20315
  receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
20250
20316
  this.drainReceipts.set(receipt, this.nowMs());
20251
20317
  }
20252
- return { attempted, failed, skipped, unknown, pageFull, receipt };
20318
+ return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
20253
20319
  }
20254
20320
  /**
20255
20321
  * Resolve the channel's pending commit record, under the channel lock, and
@@ -20722,7 +20788,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20722
20788
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
20723
20789
  return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
20724
20790
  });
20725
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
20791
+ const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
20792
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
20726
20793
  if (init.created === false) {
20727
20794
  if (!this.isAdoptableOrphan(init, others)) {
20728
20795
  if (this.isOrphanShape(init, others))
@@ -20742,7 +20809,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20742
20809
  commit_epoch: Number(mgr.epoch),
20743
20810
  expected_epoch: init.current_epoch,
20744
20811
  member_device_ids: [this.o.deviceId, ...others],
20745
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
20812
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
20813
+ // Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
20814
+ added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
20746
20815
  }, mgr, null);
20747
20816
  const res = await this.commitWithPendingRecord(channelId, rec, mgr);
20748
20817
  if (!res.landed) {
@@ -20756,15 +20825,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20756
20825
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
20757
20826
  if (!w)
20758
20827
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
20828
+ const aborted = () => opts.shouldAbort?.() === true;
20829
+ if (aborted())
20830
+ throw new Error("ensureSession(join): aborted by the caller");
20759
20831
  let mgr;
20760
20832
  try {
20761
20833
  mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
20762
20834
  } catch (err) {
20835
+ if (aborted())
20836
+ throw new Error("ensureSession(join): aborted by the caller");
20763
20837
  this.recordDecryptError(w, err);
20764
20838
  writeServiceLog("warn", "session.unopenable_welcome_discarded", {}, err);
20765
20839
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
20840
+ try {
20841
+ opts.onUnopenableWelcome?.(w.group_id);
20842
+ } catch (e) {
20843
+ writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
20844
+ }
20845
+ try {
20846
+ this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
20847
+ } catch (e) {
20848
+ writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
20849
+ }
20766
20850
  return;
20767
20851
  }
20852
+ if (aborted())
20853
+ throw new Error("ensureSession(join): aborted by the caller");
20768
20854
  this.managers.set(channelId, mgr);
20769
20855
  await this.saveMain(channelId, mgr);
20770
20856
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
@@ -20975,6 +21061,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20975
21061
  await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
20976
21062
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
20977
21063
  groupId = init.group_id;
21064
+ if (this.o.isVoiceChannel?.(channelId) === true) {
21065
+ const mirror = init.member_device_ids;
21066
+ if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
21067
+ writeServiceLog("warn", "admission.voice_device_already_in_mirror");
21068
+ throw new VoiceMirrorAddRefusedError();
21069
+ }
21070
+ }
20978
21071
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
20979
21072
  }
20980
21073
  if (new TextDecoder().decode(mgr.groupId) !== groupId) {
@@ -20993,7 +21086,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20993
21086
  expected_epoch: priorEpoch,
20994
21087
  member_device_ids: members,
20995
21088
  op: "add",
20996
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
21089
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
21090
+ // Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
21091
+ added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
20997
21092
  }, mgr, prior), mgr);
20998
21093
  if (!res.landed) {
20999
21094
  const e = res.error;
@@ -21314,6 +21409,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21314
21409
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
21315
21410
  }
21316
21411
  }
21412
+ /**
21413
+ * ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
21414
+ * Remove of one leaf the server's committer-work lists in `remove_devices`
21415
+ * (on the hidden voice channel V: a stale device of an agent or relay
21416
+ * identity that is still on V's roster).
21417
+ *
21418
+ * ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
21419
+ * identity's leaf by add-order position, which RFC 9420's leftmost-blank
21420
+ * refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
21421
+ * evicts the current device's leaf while the distribute's `removed_device_id`
21422
+ * makes the server drop the STALE device from its mirror. The current device
21423
+ * is then in the roster with no leaf, outside the adds delta forever: an
21424
+ * unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
21425
+ *
21426
+ * - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
21427
+ * is matched against THIS committer's live tree (the device-level leaf
21428
+ * predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
21429
+ * mints a fresh signature keypair per call, so a match names exactly the
21430
+ * leaf that KeyPackage was Added as;
21431
+ * - exactly ONE matching leaf, or nothing is committed;
21432
+ * - that leaf's credential identity is read from the tree, never from the
21433
+ * server, and is what the distribute names as `removed_identity_id`.
21434
+ *
21435
+ * Outcomes. `"removed"`: one leaf evicted and the distribute landed.
21436
+ * `"unresolved"`: no KeyPackage is published for the device, or it matches no
21437
+ * leaf (the device rotated it after its Add, or the leaf is already gone).
21438
+ * Nothing is committed: the leaf, if any, stays, and is reported, never
21439
+ * guessed. The server's next delta lists it again (the caller's throttle
21440
+ * bounds the retries).
21441
+ *
21442
+ * Refusals (thrown before any commit is built, so nothing is distributed):
21443
+ * the committer's own device (`own_device`), any leaf of the committer's own
21444
+ * identity, the committer's own leaf, or an unknown own leaf
21445
+ * (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
21446
+ * or more matching leaves; a held group that is not the channel's active
21447
+ * group (#757 binding, checked BEFORE init).
21448
+ */
21449
+ async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
21450
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
21451
+ await this.requirePendingResolvedUnlocked(channelId);
21452
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
21453
+ }));
21454
+ }
21455
+ async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
21456
+ if (await this.custodyHeld(channelId))
21457
+ throw new CustodyPersistError(channelId);
21458
+ if (deviceId === this.o.deviceId)
21459
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
21460
+ const mgr = await this.manager(channelId);
21461
+ const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
21462
+ if (resolved === "unresolved")
21463
+ return "unresolved";
21464
+ const { leafIndex, leafIdentity } = resolved;
21465
+ await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
21466
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21467
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
21468
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
21469
+ }
21470
+ const priorEpoch = Number(mgr.epoch);
21471
+ const prior = tagOf(mgr);
21472
+ const { commit } = await mgr.removeMembers([leafIndex]);
21473
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
21474
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
21475
+ }
21476
+ const members = init.member_device_ids ?? [this.o.deviceId];
21477
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
21478
+ device_id: this.o.deviceId,
21479
+ commit: bytesToHex22(commit),
21480
+ commit_epoch: Number(mgr.epoch),
21481
+ expected_epoch: priorEpoch,
21482
+ member_device_ids: members,
21483
+ op: "remove",
21484
+ removed_identity_id: leafIdentity,
21485
+ removed_device_id: deviceId,
21486
+ welcomes: []
21487
+ }, mgr, prior), mgr);
21488
+ if (!res.landed) {
21489
+ const e = res.error;
21490
+ if (isSupersededRefusal(e))
21491
+ throw new GroupSupersededError(channelId, init.group_id);
21492
+ if (_attempt >= 2 || !isEpochConflict(e))
21493
+ throw e;
21494
+ try {
21495
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
21496
+ } catch (e2) {
21497
+ if (e2 instanceof CustodyPersistError)
21498
+ throw e2;
21499
+ }
21500
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
21501
+ }
21502
+ return "removed";
21503
+ }
21504
+ /** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
21505
+ * the committer's tree. Unchanged from Task 24 and its fix round 1. */
21506
+ async resolveServedKeyPackageLeaf(mgr, deviceId) {
21507
+ const kps = await this.o.backend.fetchKeyPackages([deviceId]);
21508
+ const hex = kps[deviceId];
21509
+ if (!hex) {
21510
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_key_package: true });
21511
+ return "unresolved";
21512
+ }
21513
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
21514
+ const leaves = leafIndicesForKeyPackage(mgr, pkg);
21515
+ if (leaves.length === 0) {
21516
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
21517
+ return "unresolved";
21518
+ }
21519
+ if (leaves.length > 1) {
21520
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
21521
+ }
21522
+ const leafIndex = leaves[0];
21523
+ const tree = mgr.state?.ratchetTree;
21524
+ if (!tree)
21525
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
21526
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
21527
+ if (cred.credentialType !== "basic") {
21528
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
21529
+ }
21530
+ const leafIdentity = new TextDecoder().decode(cred.identity);
21531
+ const ownIdentity = new TextDecoder().decode(this.o.identity);
21532
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
21533
+ if (!leafIdentity || leafIdentity === ownIdentity) {
21534
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
21535
+ }
21536
+ if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
21537
+ writeServiceLog("warn", "committer.remove_device_refused_last_leaf", {});
21538
+ return "unresolved";
21539
+ }
21540
+ return { leafIndex, leafIdentity };
21541
+ }
21542
+ /**
21543
+ * ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
21544
+ * resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
21545
+ * `remove_device_leaves`). The server recorded the key when the leaf was
21546
+ * born, from the KeyPackage it vended, so it survives the target's
21547
+ * KeyPackage rotation, revocation and deletion, which the served-KeyPackage
21548
+ * path cannot.
21549
+ *
21550
+ * - Leaves are matched by the bound key ONLY: exactly one match, else
21551
+ * `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
21552
+ * the served KeyPackage: two evidence sources that disagree fail closed.
21553
+ * - The matched leaf's basic-credential identity must equal the server's
21554
+ * `identityId` for the device (R4), else a throw.
21555
+ * - The committer's own leaf index (or an unknown one) is refused
21556
+ * ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
21557
+ * - R5: a leaf of the committer's OWN identity is admitted (a sibling
21558
+ * device: a revoked laptop). The brick class the own-identity refusal
21559
+ * guards came from positional or identity-level resolution, which this
21560
+ * exact-key path cannot reach, and the own leaf index is still refused.
21561
+ * - No last-leaf refusal: the binding is exact evidence, and an identity's
21562
+ * last leaf is exactly the revoked single-device case (and carry A's
21563
+ * Remove before its re-Add).
21564
+ * Synchronous and before any commit is built: a refusal distributes nothing.
21565
+ */
21566
+ resolveBoundDeviceLeaf(mgr, bound) {
21567
+ const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
21568
+ if (leaves.length === 0) {
21569
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
21570
+ return "unresolved";
21571
+ }
21572
+ if (leaves.length > 1) {
21573
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
21574
+ }
21575
+ const leafIndex = leaves[0];
21576
+ const tree = mgr.state?.ratchetTree;
21577
+ if (!tree)
21578
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
21579
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
21580
+ const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
21581
+ if (!leafIdentity || leafIdentity !== bound.identityId) {
21582
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
21583
+ }
21584
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
21585
+ return { leafIndex, leafIdentity };
21586
+ }
21317
21587
  /**
21318
21588
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
21319
21589
  * targeted by its owner-observed leaf `signaturePublicKey` against the live
@@ -23134,6 +23404,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23134
23404
  if (supersededChannelIds.length) {
23135
23405
  Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
23136
23406
  }
23407
+ if (w2Kept.size) {
23408
+ Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
23409
+ }
23137
23410
  return appEntries;
23138
23411
  }
23139
23412
  /**
@@ -24925,9 +25198,9 @@ var RestMlsBackend = class {
24925
25198
  current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
24926
25199
  };
24927
25200
  }
24928
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25201
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
24929
25202
  const path = `/api/v1/mls/connect-channels/${channelId}/init`;
24930
- const body = { member_device_ids: memberDeviceIds };
25203
+ const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
24931
25204
  let j;
24932
25205
  for (let attempt = 0; ; attempt++) {
24933
25206
  try {
@@ -25169,6 +25442,22 @@ var RestMlsBackend = class {
25169
25442
  throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
25170
25443
  return rows;
25171
25444
  }
25445
+ async signalVoiceLeafUnusable(groupId, deviceId) {
25446
+ const res = await this.fetchBounded("mls_voice_leaf_unusable", "control", this.opts.baseUrl + `/api/v1/mls/groups/${groupId}/voice-leaf-unusable`, { method: "POST", headers: this.headers(), body: JSON.stringify({ device_id: deviceId }) });
25447
+ const text = await res.text().catch(() => "");
25448
+ if (res.ok)
25449
+ return { status: res.status };
25450
+ let parsed = null;
25451
+ if (text) {
25452
+ try {
25453
+ parsed = JSON.parse(text);
25454
+ } catch {
25455
+ parsed = null;
25456
+ }
25457
+ }
25458
+ const code = parseErrorCode(parsed);
25459
+ return code === void 0 ? { status: res.status } : { status: res.status, code };
25460
+ }
25172
25461
  async getCommitterWork(channelId) {
25173
25462
  const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
25174
25463
  const requireList = (field) => {
@@ -25178,7 +25467,29 @@ var RestMlsBackend = class {
25178
25467
  }
25179
25468
  return v.map(String);
25180
25469
  };
25181
- return { adds: requireList("adds"), removes: requireList("removes") };
25470
+ const work = {
25471
+ adds: requireList("adds"),
25472
+ removes: requireList("removes")
25473
+ };
25474
+ if (j?.remove_devices !== void 0) {
25475
+ work.removeDevices = requireList("remove_devices");
25476
+ }
25477
+ const rawLeaves = j?.remove_device_leaves;
25478
+ if (rawLeaves !== void 0) {
25479
+ if (!Array.isArray(rawLeaves))
25480
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
25481
+ const seen = /* @__PURE__ */ new Set();
25482
+ work.removeDeviceLeaves = rawLeaves.map((e) => {
25483
+ const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
25484
+ const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
25485
+ if (!o || typeof o.device_id !== "string" || !o.device_id || typeof o.identity_id !== "string" || !o.identity_id || key === null || key.length === 0 || key.length % 2 !== 0 || !/^[0-9a-f]+$/.test(key) || seen.has(o.device_id)) {
25486
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
25487
+ }
25488
+ seen.add(o.device_id);
25489
+ return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
25490
+ });
25491
+ }
25492
+ return work;
25182
25493
  }
25183
25494
  async acquireCommitLease(channelId) {
25184
25495
  try {
@@ -25228,6 +25539,48 @@ var RestMlsBackend = class {
25228
25539
 
25229
25540
  // ../connect-sdk-ts/dist/mls/fake-backend.js
25230
25541
  var DELIVERY_PULL_LIMIT = 100;
25542
+ function keyPackageSignatureKeyHex(kpHex) {
25543
+ const b = hexToBytes22(kpHex);
25544
+ let at = 0;
25545
+ const u16 = () => {
25546
+ if (at + 2 > b.length)
25547
+ throw new Error("t");
25548
+ const v = b[at] << 8 | b[at + 1];
25549
+ at += 2;
25550
+ return v;
25551
+ };
25552
+ const opaque = () => {
25553
+ if (at >= b.length)
25554
+ throw new Error("t");
25555
+ const prefix = b[at] >> 6;
25556
+ if (prefix === 3)
25557
+ throw new Error("t");
25558
+ const size = 1 << prefix;
25559
+ if (at + size > b.length)
25560
+ throw new Error("t");
25561
+ let n = b[at] & 63;
25562
+ for (let i = 1; i < size; i++)
25563
+ n = n << 8 | b[at + i];
25564
+ at += size;
25565
+ if (at + n > b.length)
25566
+ throw new Error("t");
25567
+ const out = b.slice(at, at + n);
25568
+ at += n;
25569
+ return out;
25570
+ };
25571
+ try {
25572
+ if (u16() !== 1 || u16() !== 5)
25573
+ return null;
25574
+ u16();
25575
+ u16();
25576
+ opaque();
25577
+ opaque();
25578
+ const sig = opaque();
25579
+ return sig.length > 0 ? bytesToHex22(sig) : null;
25580
+ } catch {
25581
+ return null;
25582
+ }
25583
+ }
25231
25584
  var FakeConnectBackend = class _FakeConnectBackend {
25232
25585
  // Key-package store: deviceId → hex-encoded KeyPackage
25233
25586
  kps = /* @__PURE__ */ new Map();
@@ -25312,6 +25665,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25312
25665
  * server's purge_device_queue. */
25313
25666
  linkIdentityDevice(identityId, deviceId) {
25314
25667
  this.deviceByIdentity.set(identityId, deviceId);
25668
+ this.identityByDevice.set(deviceId, identityId);
25315
25669
  }
25316
25670
  async publishKeyPackage(deviceId, keyPackageHex) {
25317
25671
  this.kps.set(deviceId, keyPackageHex);
@@ -25361,8 +25715,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
25361
25715
  current_epoch: this.epochByChannel.get(channelId) ?? 0
25362
25716
  };
25363
25717
  }
25364
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25718
+ /** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
25719
+ * voice channel V. Init on an EXISTING V returns it unchanged and merges
25720
+ * nothing, as the real backend does. */
25721
+ voiceChannelIds = /* @__PURE__ */ new Set();
25722
+ /** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
25723
+ * {@link voiceChannelIds} only, as the PR-E server does for V:
25724
+ * `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
25725
+ * when a V leaf is born (the creator's init, an Add's Welcome) from the
25726
+ * verified claim, and deleted when a Remove of that device (or its
25727
+ * identity) lands. */
25728
+ voiceLeafBindings = /* @__PURE__ */ new Map();
25729
+ /** The device -> identity the server knows (the devices table). Written by
25730
+ * {@link linkIdentityDevice}; read for `remove_device_leaves`. */
25731
+ identityByDevice = /* @__PURE__ */ new Map();
25732
+ static voiceRefusal(path, code) {
25733
+ return new MlsBackendHttpError(path, 409, code);
25734
+ }
25735
+ /** The current vendable KeyPackage key of `deviceId`, or null. */
25736
+ currentLeafKey(deviceId) {
25737
+ const hex = this.kps.get(deviceId);
25738
+ return hex ? keyPackageSignatureKeyHex(hex) : null;
25739
+ }
25740
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
25365
25741
  const alreadyExists = this.groupIdByChannel.has(channelId);
25742
+ if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
25743
+ const creator = memberDeviceIds[0];
25744
+ const current = creator ? this.currentLeafKey(creator) : null;
25745
+ if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
25746
+ throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
25747
+ }
25748
+ this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
25749
+ }
25366
25750
  let groupId = this.groupIdByChannel.get(channelId);
25367
25751
  if (!groupId) {
25368
25752
  groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
@@ -25370,7 +25754,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25370
25754
  this.channelByGroup.set(groupId, channelId);
25371
25755
  this.epochByChannel.set(channelId, 0);
25372
25756
  }
25373
- const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
25757
+ const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
25374
25758
  this.membersByChannel.set(channelId, members);
25375
25759
  return {
25376
25760
  group_id: groupId,
@@ -25507,6 +25891,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
25507
25891
  throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
25508
25892
  }
25509
25893
  }
25894
+ const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
25895
+ let bindNow = [];
25896
+ if (vBindings && req.commit && req.op !== "remove") {
25897
+ const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
25898
+ const claims = req.added_leaf_signature_keys;
25899
+ const path = _FakeConnectBackend.distributePath(groupId);
25900
+ if (!claims)
25901
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25902
+ const claimed = Object.keys(claims);
25903
+ if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
25904
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25905
+ }
25906
+ for (const t of targets) {
25907
+ const current = this.currentLeafKey(t);
25908
+ if (current === null || claims[t].toLowerCase() !== current)
25909
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25910
+ if (vBindings.has(t))
25911
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
25912
+ }
25913
+ bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
25914
+ }
25510
25915
  if (req.commit) {
25511
25916
  const log = this.commitLog.get(groupId) ?? [];
25512
25917
  log.push({
@@ -25529,6 +25934,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
25529
25934
  this.membersByChannel.set(channelId, remaining);
25530
25935
  if (evictedDevice)
25531
25936
  this.queues.delete(evictedDevice);
25937
+ if (vBindings) {
25938
+ for (const d of [...vBindings.keys()]) {
25939
+ if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
25940
+ vBindings.delete(d);
25941
+ }
25942
+ }
25532
25943
  } else {
25533
25944
  const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
25534
25945
  this.membersByChannel.set(channelId, members);
@@ -25552,6 +25963,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
25552
25963
  });
25553
25964
  }
25554
25965
  }
25966
+ if (vBindings && bindNow.length > 0) {
25967
+ for (const [d, k] of bindNow)
25968
+ vBindings.set(d, { key: k, unusableSignaledAt: null });
25969
+ this.voiceLeafBindings.set(channelId, vBindings);
25970
+ }
25555
25971
  for (const w of req.welcomes ?? []) {
25556
25972
  this.q(w.target_device_id).push({
25557
25973
  queue_id: "q-" + this._seq++,
@@ -25612,6 +26028,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
25612
26028
  async getCommitterWork(_channelId) {
25613
26029
  return { adds: [], removes: [] };
25614
26030
  }
26031
+ /** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
26032
+ * modelled: the group is a V (else 404), a binding exists (else 409), the
26033
+ * device's current vendable key differs from the bound one (409
26034
+ * `voice_leaf_key_current` when equal, `voice_no_current_key_package`
26035
+ * when it has none). An already-signalled row is an idempotent 202. The
26036
+ * bind and roster gates are the server's (backend tests). */
26037
+ signalVoiceLeafUnusableCalls = [];
26038
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26039
+ this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
26040
+ const channelId = this.channelByGroup.get(groupId);
26041
+ if (!channelId || !this.voiceChannelIds.has(channelId))
26042
+ return { status: 404, code: "Not Found" };
26043
+ const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
26044
+ if (!row)
26045
+ return { status: 409, code: "voice_leaf_binding_missing" };
26046
+ const current = this.currentLeafKey(deviceId);
26047
+ if (current === null)
26048
+ return { status: 409, code: "voice_no_current_key_package" };
26049
+ if (current === row.key)
26050
+ return { status: 409, code: "voice_leaf_key_current" };
26051
+ if (row.unusableSignaledAt === null)
26052
+ row.unusableSignaledAt = this.now();
26053
+ return { status: 202 };
26054
+ }
26055
+ /** Task 24b test seam: V's committer work as the PR-E server derives it,
26056
+ * for a test's `roster` (the server's roster devices): `adds` = roster
26057
+ * devices not in the mirror; `remove_devices` / `remove_device_leaves` =
26058
+ * roster devices whose binding is signalled unusable (NOT in `adds`: they
26059
+ * are still in the mirror). The re-Add follows only from a later call,
26060
+ * after the Remove landed and the mirror dropped the device. */
26061
+ voiceCommitterWork(channelId, roster) {
26062
+ const mirror = this.membersByChannel.get(channelId) ?? [];
26063
+ const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
26064
+ const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
26065
+ return {
26066
+ adds: roster.filter((d) => !mirror.includes(d)),
26067
+ removes: [],
26068
+ removeDevices: signalled,
26069
+ removeDeviceLeaves: signalled.map((d) => ({
26070
+ deviceId: d,
26071
+ identityId: this.identityByDevice.get(d) ?? "",
26072
+ leafSignatureKeyHex: bindings.get(d).key
26073
+ }))
26074
+ };
26075
+ }
25615
26076
  async acquireCommitLease(_channelId) {
25616
26077
  return "fake-lease-token";
25617
26078
  }
@@ -40009,6 +40470,12 @@ var BridgeAgent = class _BridgeAgent {
40009
40470
  * frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
40010
40471
  * window flush, and the ingest tap block when the window did not take the turn (off, 0 by default). Like the typed tap, the write-time owner scope's pinned identity must be the speaking identity;
40011
40472
  * otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
40473
+ *
40474
+ * Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
40475
+ * per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
40476
+ * channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
40477
+ * inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
40478
+ * persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
40012
40479
  */
40013
40480
  async tapUnifiedVoice(m, c) {
40014
40481
  const skip = (reason) => this.logUnifiedTapSkip(reason);
@@ -40017,9 +40484,22 @@ var BridgeAgent = class _BridgeAgent {
40017
40484
  if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
40018
40485
  if (c.stamp === void 0) return skip("stamp_missing");
40019
40486
  if (c.capture.purgeEpoch < 0) return skip("purged");
40020
- const scopeId = await this.ownerScopeForWrite(m.channel_id);
40487
+ const parent3 = c.parent;
40488
+ const parentCapture = parent3?.capture;
40489
+ if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
40490
+ if (parent3 !== void 0) {
40491
+ if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
40492
+ if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
40493
+ }
40494
+ const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
40021
40495
  if (scopeId === null) return skip("scope");
40022
40496
  if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
40497
+ const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
40498
+ const now = this.store.itemCapture(parent3.channelId);
40499
+ if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
40500
+ if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
40501
+ return null;
40502
+ };
40023
40503
  const r = await this.items.commit({
40024
40504
  scopeKind: "owner_dm",
40025
40505
  scopeId,
@@ -40030,7 +40510,8 @@ var BridgeAgent = class _BridgeAgent {
40030
40510
  source: "owner_voice",
40031
40511
  trust: OWNER_VOICE_TRUST,
40032
40512
  text: typeof m.text === "string" ? m.text : "",
40033
- expect: { ...c.capture, clearGeneration: c.stamp }
40513
+ expect: { ...c.capture, clearGeneration: c.stamp },
40514
+ ...precommit ? { precommit } : {}
40034
40515
  });
40035
40516
  return r.ok ? { ok: true } : { ok: false, reason: r.reason };
40036
40517
  } catch {
@@ -42209,11 +42690,20 @@ var BridgeAgent = class _BridgeAgent {
42209
42690
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
42210
42691
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
42211
42692
  const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
42693
+ const unifiedVoiceParentAt = (p) => {
42694
+ const cap = itemCaptureAtReceipt.get(p);
42695
+ return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
42696
+ };
42212
42697
  for (const m of entries) {
42213
42698
  this.lastInboundAt.set(m.channelId, receivedAt);
42214
42699
  if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
42215
42700
  if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
42216
42701
  if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
42702
+ const receiptParent = this.voiceChannels.parentOf(m.channelId);
42703
+ if (receiptParent !== void 0) {
42704
+ if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
42705
+ if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
42706
+ }
42217
42707
  this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
42218
42708
  receiptIds.push(m.messageId);
42219
42709
  const stamp = voiceStamp.get(m.channelId);
@@ -42559,6 +43049,7 @@ var BridgeAgent = class _BridgeAgent {
42559
43049
  pending: Promise.resolve(),
42560
43050
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
42561
43051
  itemCapture: itemCaptureAtReceipt.get(m.channelId),
43052
+ ...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
42562
43053
  bufferGeneration
42563
43054
  };
42564
43055
  this.voiceCoalescing.set(windowKey, win);
@@ -42589,7 +43080,7 @@ var BridgeAgent = class _BridgeAgent {
42589
43080
  const merged = { ...held, text, memory: windowMemory };
42590
43081
  if (this.unifiedOn) {
42591
43082
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
42592
- const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
43083
+ const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
42593
43084
  this.unifiedWork.add(work);
42594
43085
  void work.finally(() => this.unifiedWork.delete(work));
42595
43086
  }
@@ -42679,7 +43170,11 @@ var BridgeAgent = class _BridgeAgent {
42679
43170
  );
42680
43171
  if (windowEligible && turnAuthorization.relay_speaker === "owner") {
42681
43172
  const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
42682
- const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), { stamp: this.ingestGeneration.get(m.messageId), capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 } });
43173
+ const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
43174
+ stamp: this.ingestGeneration.get(m.messageId),
43175
+ capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
43176
+ ...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
43177
+ });
42683
43178
  this.unifiedWork.add(work);
42684
43179
  void work.finally(() => this.unifiedWork.delete(work));
42685
43180
  }