@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/bin.js CHANGED
@@ -18223,7 +18223,17 @@ var CODES = [
18223
18223
  // missed and before any write, so nothing landed. Definite: the committer's
18224
18224
  // work cannot name an agent in a humans-only enclave (admission refuses
18225
18225
  // one), so dropping the commit starts no resend loop.
18226
- { status: 403, code: "humans_only_enclave" }
18226
+ { status: 403, code: "humans_only_enclave" },
18227
+ // ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
18228
+ // leaf-key claim map is missing, does not cover exactly the Welcome
18229
+ // targets, or names a key that is not the target's current vendable
18230
+ // KeyPackage's. Refused before any write; definite, so the committer drops
18231
+ // the commit, refetches the KeyPackages and rebuilds instead of resending
18232
+ // the same stale bytes forever.
18233
+ { status: 409, code: "voice_stale_key_package" },
18234
+ // PR-E: a Welcome target already holds a bound leaf in this V group (the
18235
+ // DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
18236
+ { status: 409, code: "voice_leaf_already_bound" }
18227
18237
  ];
18228
18238
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
18229
18239
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -19270,6 +19280,31 @@ function ownLeafIndexOf(mgr) {
19270
19280
  function tagOf(mgr) {
19271
19281
  return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
19272
19282
  }
19283
+ function leafKeyClaims(targets, pkgs) {
19284
+ if (targets.length !== pkgs.length)
19285
+ throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
19286
+ const out = {};
19287
+ targets.forEach((d, i) => {
19288
+ out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
19289
+ });
19290
+ return out;
19291
+ }
19292
+ function leafIndicesForSignatureKey(mgr, keyHex) {
19293
+ const tree = mgr.state?.ratchetTree;
19294
+ if (!tree)
19295
+ return [];
19296
+ const want = keyHex.toLowerCase();
19297
+ const out = [];
19298
+ const numLeaves = Math.floor((tree.length + 1) / 2);
19299
+ for (let i = 0; i < numLeaves; i++) {
19300
+ const node = tree[i * 2];
19301
+ if (!node || node.nodeType !== "leaf")
19302
+ continue;
19303
+ if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
19304
+ out.push(i);
19305
+ }
19306
+ return out;
19307
+ }
19273
19308
  function tagOfState(state) {
19274
19309
  const m = new MLSGroupManager(state.ciphersuite);
19275
19310
  m.importState(state);
@@ -19384,6 +19419,12 @@ var SelfAddRefusedError = class extends Error {
19384
19419
  this.name = "SelfAddRefusedError";
19385
19420
  }
19386
19421
  };
19422
+ var VoiceMirrorAddRefusedError = class extends Error {
19423
+ constructor() {
19424
+ super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
19425
+ this.name = "VoiceMirrorAddRefusedError";
19426
+ }
19427
+ };
19387
19428
  var VaultCredentialMismatchError = class extends Error {
19388
19429
  expected;
19389
19430
  got;
@@ -20873,10 +20914,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20873
20914
  * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
20874
20915
  * joinAndReconcile consumes the same Welcome and acks it after joining.
20875
20916
  */
20876
- async peekWelcomeChannel(deviceId) {
20917
+ async peekWelcomeChannel(deviceId, opts = {}) {
20877
20918
  const rows = await this.o.backend.pullDelivery(deviceId);
20878
- const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
20879
- return w?.connect_channel_id ?? null;
20919
+ const asked = /* @__PURE__ */ new Set();
20920
+ for (const r of rows) {
20921
+ if (r.message_type !== "welcome" || r.connect_channel_id == null)
20922
+ continue;
20923
+ const ch = r.connect_channel_id;
20924
+ if (!opts.accept)
20925
+ return ch;
20926
+ if (asked.has(ch))
20927
+ continue;
20928
+ asked.add(ch);
20929
+ let ok = false;
20930
+ try {
20931
+ ok = await opts.accept(ch) === true;
20932
+ } catch {
20933
+ ok = false;
20934
+ }
20935
+ if (ok)
20936
+ return ch;
20937
+ }
20938
+ return null;
20880
20939
  }
20881
20940
  /**
20882
20941
  * Publish this device's KeyPackage if it holds none; otherwise a no-op that
@@ -20982,10 +21041,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20982
21041
  }
20983
21042
  const attempted = [];
20984
21043
  const failed = [];
21044
+ const discarded = [];
20985
21045
  for (const channelId of channelIds) {
20986
21046
  attempted.push(channelId);
20987
21047
  try {
20988
- await this.ensureSession(channelId, { enclaveId: "welcome-drain", bootstrap: false });
21048
+ await this.ensureSession(channelId, {
21049
+ enclaveId: "welcome-drain",
21050
+ bootstrap: false,
21051
+ onUnopenableWelcome: (groupId) => {
21052
+ discarded.push({ channelId, groupId });
21053
+ }
21054
+ });
20989
21055
  } catch (error) {
20990
21056
  failed.push({ channelId, error });
20991
21057
  }
@@ -20995,7 +21061,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20995
21061
  receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
20996
21062
  this.drainReceipts.set(receipt, this.nowMs());
20997
21063
  }
20998
- return { attempted, failed, skipped, unknown, pageFull, receipt };
21064
+ return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
20999
21065
  }
21000
21066
  /**
21001
21067
  * Resolve the channel's pending commit record, under the channel lock, and
@@ -21468,7 +21534,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21468
21534
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
21469
21535
  return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
21470
21536
  });
21471
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21537
+ const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
21538
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
21472
21539
  if (init.created === false) {
21473
21540
  if (!this.isAdoptableOrphan(init, others)) {
21474
21541
  if (this.isOrphanShape(init, others))
@@ -21488,7 +21555,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21488
21555
  commit_epoch: Number(mgr.epoch),
21489
21556
  expected_epoch: init.current_epoch,
21490
21557
  member_device_ids: [this.o.deviceId, ...others],
21491
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
21558
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
21559
+ // Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
21560
+ added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
21492
21561
  }, mgr, null);
21493
21562
  const res = await this.commitWithPendingRecord(channelId, rec, mgr);
21494
21563
  if (!res.landed) {
@@ -21502,15 +21571,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21502
21571
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
21503
21572
  if (!w)
21504
21573
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
21574
+ const aborted = () => opts.shouldAbort?.() === true;
21575
+ if (aborted())
21576
+ throw new Error("ensureSession(join): aborted by the caller");
21505
21577
  let mgr;
21506
21578
  try {
21507
21579
  mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
21508
21580
  } catch (err) {
21581
+ if (aborted())
21582
+ throw new Error("ensureSession(join): aborted by the caller");
21509
21583
  this.recordDecryptError(w, err);
21510
21584
  writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
21511
21585
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
21586
+ try {
21587
+ opts.onUnopenableWelcome?.(w.group_id);
21588
+ } catch (e) {
21589
+ writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
21590
+ }
21591
+ try {
21592
+ this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
21593
+ } catch (e) {
21594
+ writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
21595
+ }
21512
21596
  return;
21513
21597
  }
21598
+ if (aborted())
21599
+ throw new Error("ensureSession(join): aborted by the caller");
21514
21600
  this.managers.set(channelId, mgr);
21515
21601
  await this.saveMain(channelId, mgr);
21516
21602
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
@@ -21721,6 +21807,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21721
21807
  await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
21722
21808
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21723
21809
  groupId = init.group_id;
21810
+ if (this.o.isVoiceChannel?.(channelId) === true) {
21811
+ const mirror = init.member_device_ids;
21812
+ if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
21813
+ writeServiceLog2("warn", "admission.voice_device_already_in_mirror");
21814
+ throw new VoiceMirrorAddRefusedError();
21815
+ }
21816
+ }
21724
21817
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
21725
21818
  }
21726
21819
  if (new TextDecoder().decode(mgr.groupId) !== groupId) {
@@ -21739,7 +21832,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21739
21832
  expected_epoch: priorEpoch,
21740
21833
  member_device_ids: members,
21741
21834
  op: "add",
21742
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
21835
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
21836
+ // Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
21837
+ added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
21743
21838
  }, mgr, prior), mgr);
21744
21839
  if (!res.landed) {
21745
21840
  const e = res.error;
@@ -22060,6 +22155,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22060
22155
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
22061
22156
  }
22062
22157
  }
22158
+ /**
22159
+ * ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
22160
+ * Remove of one leaf the server's committer-work lists in `remove_devices`
22161
+ * (on the hidden voice channel V: a stale device of an agent or relay
22162
+ * identity that is still on V's roster).
22163
+ *
22164
+ * ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
22165
+ * identity's leaf by add-order position, which RFC 9420's leftmost-blank
22166
+ * refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
22167
+ * evicts the current device's leaf while the distribute's `removed_device_id`
22168
+ * makes the server drop the STALE device from its mirror. The current device
22169
+ * is then in the roster with no leaf, outside the adds delta forever: an
22170
+ * unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
22171
+ *
22172
+ * - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
22173
+ * is matched against THIS committer's live tree (the device-level leaf
22174
+ * predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
22175
+ * mints a fresh signature keypair per call, so a match names exactly the
22176
+ * leaf that KeyPackage was Added as;
22177
+ * - exactly ONE matching leaf, or nothing is committed;
22178
+ * - that leaf's credential identity is read from the tree, never from the
22179
+ * server, and is what the distribute names as `removed_identity_id`.
22180
+ *
22181
+ * Outcomes. `"removed"`: one leaf evicted and the distribute landed.
22182
+ * `"unresolved"`: no KeyPackage is published for the device, or it matches no
22183
+ * leaf (the device rotated it after its Add, or the leaf is already gone).
22184
+ * Nothing is committed: the leaf, if any, stays, and is reported, never
22185
+ * guessed. The server's next delta lists it again (the caller's throttle
22186
+ * bounds the retries).
22187
+ *
22188
+ * Refusals (thrown before any commit is built, so nothing is distributed):
22189
+ * the committer's own device (`own_device`), any leaf of the committer's own
22190
+ * identity, the committer's own leaf, or an unknown own leaf
22191
+ * (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
22192
+ * or more matching leaves; a held group that is not the channel's active
22193
+ * group (#757 binding, checked BEFORE init).
22194
+ */
22195
+ async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
22196
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
22197
+ await this.requirePendingResolvedUnlocked(channelId);
22198
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
22199
+ }));
22200
+ }
22201
+ async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
22202
+ if (await this.custodyHeld(channelId))
22203
+ throw new CustodyPersistError(channelId);
22204
+ if (deviceId === this.o.deviceId)
22205
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
22206
+ const mgr = await this.manager(channelId);
22207
+ const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
22208
+ if (resolved === "unresolved")
22209
+ return "unresolved";
22210
+ const { leafIndex, leafIdentity } = resolved;
22211
+ await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
22212
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
22213
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
22214
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
22215
+ }
22216
+ const priorEpoch = Number(mgr.epoch);
22217
+ const prior = tagOf(mgr);
22218
+ const { commit } = await mgr.removeMembers([leafIndex]);
22219
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
22220
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
22221
+ }
22222
+ const members = init.member_device_ids ?? [this.o.deviceId];
22223
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
22224
+ device_id: this.o.deviceId,
22225
+ commit: bytesToHex22(commit),
22226
+ commit_epoch: Number(mgr.epoch),
22227
+ expected_epoch: priorEpoch,
22228
+ member_device_ids: members,
22229
+ op: "remove",
22230
+ removed_identity_id: leafIdentity,
22231
+ removed_device_id: deviceId,
22232
+ welcomes: []
22233
+ }, mgr, prior), mgr);
22234
+ if (!res.landed) {
22235
+ const e = res.error;
22236
+ if (isSupersededRefusal(e))
22237
+ throw new GroupSupersededError(channelId, init.group_id);
22238
+ if (_attempt >= 2 || !isEpochConflict(e))
22239
+ throw e;
22240
+ try {
22241
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
22242
+ } catch (e2) {
22243
+ if (e2 instanceof CustodyPersistError)
22244
+ throw e2;
22245
+ }
22246
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
22247
+ }
22248
+ return "removed";
22249
+ }
22250
+ /** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
22251
+ * the committer's tree. Unchanged from Task 24 and its fix round 1. */
22252
+ async resolveServedKeyPackageLeaf(mgr, deviceId) {
22253
+ const kps = await this.o.backend.fetchKeyPackages([deviceId]);
22254
+ const hex = kps[deviceId];
22255
+ if (!hex) {
22256
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_key_package: true });
22257
+ return "unresolved";
22258
+ }
22259
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
22260
+ const leaves = leafIndicesForKeyPackage(mgr, pkg);
22261
+ if (leaves.length === 0) {
22262
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
22263
+ return "unresolved";
22264
+ }
22265
+ if (leaves.length > 1) {
22266
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
22267
+ }
22268
+ const leafIndex = leaves[0];
22269
+ const tree = mgr.state?.ratchetTree;
22270
+ if (!tree)
22271
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
22272
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
22273
+ if (cred.credentialType !== "basic") {
22274
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
22275
+ }
22276
+ const leafIdentity = new TextDecoder().decode(cred.identity);
22277
+ const ownIdentity = new TextDecoder().decode(this.o.identity);
22278
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
22279
+ if (!leafIdentity || leafIdentity === ownIdentity) {
22280
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
22281
+ }
22282
+ if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
22283
+ writeServiceLog2("warn", "committer.remove_device_refused_last_leaf", {});
22284
+ return "unresolved";
22285
+ }
22286
+ return { leafIndex, leafIdentity };
22287
+ }
22288
+ /**
22289
+ * ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
22290
+ * resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
22291
+ * `remove_device_leaves`). The server recorded the key when the leaf was
22292
+ * born, from the KeyPackage it vended, so it survives the target's
22293
+ * KeyPackage rotation, revocation and deletion, which the served-KeyPackage
22294
+ * path cannot.
22295
+ *
22296
+ * - Leaves are matched by the bound key ONLY: exactly one match, else
22297
+ * `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
22298
+ * the served KeyPackage: two evidence sources that disagree fail closed.
22299
+ * - The matched leaf's basic-credential identity must equal the server's
22300
+ * `identityId` for the device (R4), else a throw.
22301
+ * - The committer's own leaf index (or an unknown one) is refused
22302
+ * ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
22303
+ * - R5: a leaf of the committer's OWN identity is admitted (a sibling
22304
+ * device: a revoked laptop). The brick class the own-identity refusal
22305
+ * guards came from positional or identity-level resolution, which this
22306
+ * exact-key path cannot reach, and the own leaf index is still refused.
22307
+ * - No last-leaf refusal: the binding is exact evidence, and an identity's
22308
+ * last leaf is exactly the revoked single-device case (and carry A's
22309
+ * Remove before its re-Add).
22310
+ * Synchronous and before any commit is built: a refusal distributes nothing.
22311
+ */
22312
+ resolveBoundDeviceLeaf(mgr, bound) {
22313
+ const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
22314
+ if (leaves.length === 0) {
22315
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
22316
+ return "unresolved";
22317
+ }
22318
+ if (leaves.length > 1) {
22319
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
22320
+ }
22321
+ const leafIndex = leaves[0];
22322
+ const tree = mgr.state?.ratchetTree;
22323
+ if (!tree)
22324
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
22325
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
22326
+ const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
22327
+ if (!leafIdentity || leafIdentity !== bound.identityId) {
22328
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
22329
+ }
22330
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
22331
+ return { leafIndex, leafIdentity };
22332
+ }
22063
22333
  /**
22064
22334
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
22065
22335
  * targeted by its owner-observed leaf `signaturePublicKey` against the live
@@ -23880,6 +24150,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23880
24150
  if (supersededChannelIds.length) {
23881
24151
  Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
23882
24152
  }
24153
+ if (w2Kept.size) {
24154
+ Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
24155
+ }
23883
24156
  return appEntries;
23884
24157
  }
23885
24158
  /**
@@ -25671,9 +25944,9 @@ var RestMlsBackend = class {
25671
25944
  current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
25672
25945
  };
25673
25946
  }
25674
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25947
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
25675
25948
  const path = `/api/v1/mls/connect-channels/${channelId}/init`;
25676
- const body = { member_device_ids: memberDeviceIds };
25949
+ const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
25677
25950
  let j;
25678
25951
  for (let attempt = 0; ; attempt++) {
25679
25952
  try {
@@ -25915,6 +26188,22 @@ var RestMlsBackend = class {
25915
26188
  throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
25916
26189
  return rows;
25917
26190
  }
26191
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26192
+ 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 }) });
26193
+ const text = await res.text().catch(() => "");
26194
+ if (res.ok)
26195
+ return { status: res.status };
26196
+ let parsed = null;
26197
+ if (text) {
26198
+ try {
26199
+ parsed = JSON.parse(text);
26200
+ } catch {
26201
+ parsed = null;
26202
+ }
26203
+ }
26204
+ const code = parseErrorCode(parsed);
26205
+ return code === void 0 ? { status: res.status } : { status: res.status, code };
26206
+ }
25918
26207
  async getCommitterWork(channelId) {
25919
26208
  const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
25920
26209
  const requireList = (field) => {
@@ -25924,7 +26213,29 @@ var RestMlsBackend = class {
25924
26213
  }
25925
26214
  return v.map(String);
25926
26215
  };
25927
- return { adds: requireList("adds"), removes: requireList("removes") };
26216
+ const work = {
26217
+ adds: requireList("adds"),
26218
+ removes: requireList("removes")
26219
+ };
26220
+ if (j?.remove_devices !== void 0) {
26221
+ work.removeDevices = requireList("remove_devices");
26222
+ }
26223
+ const rawLeaves = j?.remove_device_leaves;
26224
+ if (rawLeaves !== void 0) {
26225
+ if (!Array.isArray(rawLeaves))
26226
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
26227
+ const seen = /* @__PURE__ */ new Set();
26228
+ work.removeDeviceLeaves = rawLeaves.map((e) => {
26229
+ const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
26230
+ const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
26231
+ 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)) {
26232
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
26233
+ }
26234
+ seen.add(o.device_id);
26235
+ return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
26236
+ });
26237
+ }
26238
+ return work;
25928
26239
  }
25929
26240
  async acquireCommitLease(channelId) {
25930
26241
  try {
@@ -25974,6 +26285,48 @@ var RestMlsBackend = class {
25974
26285
 
25975
26286
  // ../connect-sdk-ts/dist/mls/fake-backend.js
25976
26287
  var DELIVERY_PULL_LIMIT = 100;
26288
+ function keyPackageSignatureKeyHex(kpHex) {
26289
+ const b = hexToBytes22(kpHex);
26290
+ let at = 0;
26291
+ const u16 = () => {
26292
+ if (at + 2 > b.length)
26293
+ throw new Error("t");
26294
+ const v = b[at] << 8 | b[at + 1];
26295
+ at += 2;
26296
+ return v;
26297
+ };
26298
+ const opaque = () => {
26299
+ if (at >= b.length)
26300
+ throw new Error("t");
26301
+ const prefix = b[at] >> 6;
26302
+ if (prefix === 3)
26303
+ throw new Error("t");
26304
+ const size = 1 << prefix;
26305
+ if (at + size > b.length)
26306
+ throw new Error("t");
26307
+ let n = b[at] & 63;
26308
+ for (let i = 1; i < size; i++)
26309
+ n = n << 8 | b[at + i];
26310
+ at += size;
26311
+ if (at + n > b.length)
26312
+ throw new Error("t");
26313
+ const out = b.slice(at, at + n);
26314
+ at += n;
26315
+ return out;
26316
+ };
26317
+ try {
26318
+ if (u16() !== 1 || u16() !== 5)
26319
+ return null;
26320
+ u16();
26321
+ u16();
26322
+ opaque();
26323
+ opaque();
26324
+ const sig = opaque();
26325
+ return sig.length > 0 ? bytesToHex22(sig) : null;
26326
+ } catch {
26327
+ return null;
26328
+ }
26329
+ }
25977
26330
  var FakeConnectBackend = class _FakeConnectBackend {
25978
26331
  // Key-package store: deviceId → hex-encoded KeyPackage
25979
26332
  kps = /* @__PURE__ */ new Map();
@@ -26058,6 +26411,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26058
26411
  * server's purge_device_queue. */
26059
26412
  linkIdentityDevice(identityId, deviceId) {
26060
26413
  this.deviceByIdentity.set(identityId, deviceId);
26414
+ this.identityByDevice.set(deviceId, identityId);
26061
26415
  }
26062
26416
  async publishKeyPackage(deviceId, keyPackageHex) {
26063
26417
  this.kps.set(deviceId, keyPackageHex);
@@ -26107,8 +26461,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
26107
26461
  current_epoch: this.epochByChannel.get(channelId) ?? 0
26108
26462
  };
26109
26463
  }
26110
- async initConnectChannelGroup(channelId, memberDeviceIds) {
26464
+ /** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
26465
+ * voice channel V. Init on an EXISTING V returns it unchanged and merges
26466
+ * nothing, as the real backend does. */
26467
+ voiceChannelIds = /* @__PURE__ */ new Set();
26468
+ /** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
26469
+ * {@link voiceChannelIds} only, as the PR-E server does for V:
26470
+ * `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
26471
+ * when a V leaf is born (the creator's init, an Add's Welcome) from the
26472
+ * verified claim, and deleted when a Remove of that device (or its
26473
+ * identity) lands. */
26474
+ voiceLeafBindings = /* @__PURE__ */ new Map();
26475
+ /** The device -> identity the server knows (the devices table). Written by
26476
+ * {@link linkIdentityDevice}; read for `remove_device_leaves`. */
26477
+ identityByDevice = /* @__PURE__ */ new Map();
26478
+ static voiceRefusal(path, code) {
26479
+ return new MlsBackendHttpError(path, 409, code);
26480
+ }
26481
+ /** The current vendable KeyPackage key of `deviceId`, or null. */
26482
+ currentLeafKey(deviceId) {
26483
+ const hex = this.kps.get(deviceId);
26484
+ return hex ? keyPackageSignatureKeyHex(hex) : null;
26485
+ }
26486
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
26111
26487
  const alreadyExists = this.groupIdByChannel.has(channelId);
26488
+ if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
26489
+ const creator = memberDeviceIds[0];
26490
+ const current = creator ? this.currentLeafKey(creator) : null;
26491
+ if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
26492
+ throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
26493
+ }
26494
+ this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
26495
+ }
26112
26496
  let groupId = this.groupIdByChannel.get(channelId);
26113
26497
  if (!groupId) {
26114
26498
  groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
@@ -26116,7 +26500,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26116
26500
  this.channelByGroup.set(groupId, channelId);
26117
26501
  this.epochByChannel.set(channelId, 0);
26118
26502
  }
26119
- const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
26503
+ const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
26120
26504
  this.membersByChannel.set(channelId, members);
26121
26505
  return {
26122
26506
  group_id: groupId,
@@ -26253,6 +26637,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
26253
26637
  throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
26254
26638
  }
26255
26639
  }
26640
+ const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
26641
+ let bindNow = [];
26642
+ if (vBindings && req.commit && req.op !== "remove") {
26643
+ const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
26644
+ const claims = req.added_leaf_signature_keys;
26645
+ const path = _FakeConnectBackend.distributePath(groupId);
26646
+ if (!claims)
26647
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26648
+ const claimed = Object.keys(claims);
26649
+ if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
26650
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26651
+ }
26652
+ for (const t of targets) {
26653
+ const current = this.currentLeafKey(t);
26654
+ if (current === null || claims[t].toLowerCase() !== current)
26655
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26656
+ if (vBindings.has(t))
26657
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
26658
+ }
26659
+ bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
26660
+ }
26256
26661
  if (req.commit) {
26257
26662
  const log = this.commitLog.get(groupId) ?? [];
26258
26663
  log.push({
@@ -26275,6 +26680,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
26275
26680
  this.membersByChannel.set(channelId, remaining);
26276
26681
  if (evictedDevice)
26277
26682
  this.queues.delete(evictedDevice);
26683
+ if (vBindings) {
26684
+ for (const d of [...vBindings.keys()]) {
26685
+ if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
26686
+ vBindings.delete(d);
26687
+ }
26688
+ }
26278
26689
  } else {
26279
26690
  const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
26280
26691
  this.membersByChannel.set(channelId, members);
@@ -26298,6 +26709,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
26298
26709
  });
26299
26710
  }
26300
26711
  }
26712
+ if (vBindings && bindNow.length > 0) {
26713
+ for (const [d, k] of bindNow)
26714
+ vBindings.set(d, { key: k, unusableSignaledAt: null });
26715
+ this.voiceLeafBindings.set(channelId, vBindings);
26716
+ }
26301
26717
  for (const w of req.welcomes ?? []) {
26302
26718
  this.q(w.target_device_id).push({
26303
26719
  queue_id: "q-" + this._seq++,
@@ -26358,6 +26774,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
26358
26774
  async getCommitterWork(_channelId) {
26359
26775
  return { adds: [], removes: [] };
26360
26776
  }
26777
+ /** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
26778
+ * modelled: the group is a V (else 404), a binding exists (else 409), the
26779
+ * device's current vendable key differs from the bound one (409
26780
+ * `voice_leaf_key_current` when equal, `voice_no_current_key_package`
26781
+ * when it has none). An already-signalled row is an idempotent 202. The
26782
+ * bind and roster gates are the server's (backend tests). */
26783
+ signalVoiceLeafUnusableCalls = [];
26784
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26785
+ this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
26786
+ const channelId = this.channelByGroup.get(groupId);
26787
+ if (!channelId || !this.voiceChannelIds.has(channelId))
26788
+ return { status: 404, code: "Not Found" };
26789
+ const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
26790
+ if (!row)
26791
+ return { status: 409, code: "voice_leaf_binding_missing" };
26792
+ const current = this.currentLeafKey(deviceId);
26793
+ if (current === null)
26794
+ return { status: 409, code: "voice_no_current_key_package" };
26795
+ if (current === row.key)
26796
+ return { status: 409, code: "voice_leaf_key_current" };
26797
+ if (row.unusableSignaledAt === null)
26798
+ row.unusableSignaledAt = this.now();
26799
+ return { status: 202 };
26800
+ }
26801
+ /** Task 24b test seam: V's committer work as the PR-E server derives it,
26802
+ * for a test's `roster` (the server's roster devices): `adds` = roster
26803
+ * devices not in the mirror; `remove_devices` / `remove_device_leaves` =
26804
+ * roster devices whose binding is signalled unusable (NOT in `adds`: they
26805
+ * are still in the mirror). The re-Add follows only from a later call,
26806
+ * after the Remove landed and the mirror dropped the device. */
26807
+ voiceCommitterWork(channelId, roster) {
26808
+ const mirror = this.membersByChannel.get(channelId) ?? [];
26809
+ const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
26810
+ const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
26811
+ return {
26812
+ adds: roster.filter((d) => !mirror.includes(d)),
26813
+ removes: [],
26814
+ removeDevices: signalled,
26815
+ removeDeviceLeaves: signalled.map((d) => ({
26816
+ deviceId: d,
26817
+ identityId: this.identityByDevice.get(d) ?? "",
26818
+ leafSignatureKeyHex: bindings.get(d).key
26819
+ }))
26820
+ };
26821
+ }
26361
26822
  async acquireCommitLease(_channelId) {
26362
26823
  return "fake-lease-token";
26363
26824
  }
@@ -40293,6 +40754,12 @@ var BridgeAgent = class _BridgeAgent {
40293
40754
  * frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
40294
40755
  * 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;
40295
40756
  * otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
40757
+ *
40758
+ * Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
40759
+ * per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
40760
+ * channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
40761
+ * inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
40762
+ * persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
40296
40763
  */
40297
40764
  async tapUnifiedVoice(m, c) {
40298
40765
  const skip = (reason) => this.logUnifiedTapSkip(reason);
@@ -40301,9 +40768,22 @@ var BridgeAgent = class _BridgeAgent {
40301
40768
  if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
40302
40769
  if (c.stamp === void 0) return skip("stamp_missing");
40303
40770
  if (c.capture.purgeEpoch < 0) return skip("purged");
40304
- const scopeId = await this.ownerScopeForWrite(m.channel_id);
40771
+ const parent3 = c.parent;
40772
+ const parentCapture = parent3?.capture;
40773
+ if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
40774
+ if (parent3 !== void 0) {
40775
+ if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
40776
+ if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
40777
+ }
40778
+ const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
40305
40779
  if (scopeId === null) return skip("scope");
40306
40780
  if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
40781
+ const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
40782
+ const now = this.store.itemCapture(parent3.channelId);
40783
+ if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
40784
+ if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
40785
+ return null;
40786
+ };
40307
40787
  const r = await this.items.commit({
40308
40788
  scopeKind: "owner_dm",
40309
40789
  scopeId,
@@ -40314,7 +40794,8 @@ var BridgeAgent = class _BridgeAgent {
40314
40794
  source: "owner_voice",
40315
40795
  trust: OWNER_VOICE_TRUST,
40316
40796
  text: typeof m.text === "string" ? m.text : "",
40317
- expect: { ...c.capture, clearGeneration: c.stamp }
40797
+ expect: { ...c.capture, clearGeneration: c.stamp },
40798
+ ...precommit ? { precommit } : {}
40318
40799
  });
40319
40800
  return r.ok ? { ok: true } : { ok: false, reason: r.reason };
40320
40801
  } catch {
@@ -42493,11 +42974,20 @@ var BridgeAgent = class _BridgeAgent {
42493
42974
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
42494
42975
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
42495
42976
  const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
42977
+ const unifiedVoiceParentAt = (p) => {
42978
+ const cap = itemCaptureAtReceipt.get(p);
42979
+ return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
42980
+ };
42496
42981
  for (const m of entries) {
42497
42982
  this.lastInboundAt.set(m.channelId, receivedAt);
42498
42983
  if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
42499
42984
  if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
42500
42985
  if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
42986
+ const receiptParent = this.voiceChannels.parentOf(m.channelId);
42987
+ if (receiptParent !== void 0) {
42988
+ if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
42989
+ if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
42990
+ }
42501
42991
  this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
42502
42992
  receiptIds.push(m.messageId);
42503
42993
  const stamp = voiceStamp.get(m.channelId);
@@ -42843,6 +43333,7 @@ var BridgeAgent = class _BridgeAgent {
42843
43333
  pending: Promise.resolve(),
42844
43334
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
42845
43335
  itemCapture: itemCaptureAtReceipt.get(m.channelId),
43336
+ ...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
42846
43337
  bufferGeneration
42847
43338
  };
42848
43339
  this.voiceCoalescing.set(windowKey, win);
@@ -42873,7 +43364,7 @@ var BridgeAgent = class _BridgeAgent {
42873
43364
  const merged = { ...held, text, memory: windowMemory };
42874
43365
  if (this.unifiedOn) {
42875
43366
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
42876
- const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
43367
+ const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
42877
43368
  this.unifiedWork.add(work);
42878
43369
  void work.finally(() => this.unifiedWork.delete(work));
42879
43370
  }
@@ -42963,7 +43454,11 @@ var BridgeAgent = class _BridgeAgent {
42963
43454
  );
42964
43455
  if (windowEligible && turnAuthorization.relay_speaker === "owner") {
42965
43456
  const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
42966
- const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), { stamp: this.ingestGeneration.get(m.messageId), capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 } });
43457
+ const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
43458
+ stamp: this.ingestGeneration.get(m.messageId),
43459
+ capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
43460
+ ...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
43461
+ });
42967
43462
  this.unifiedWork.add(work);
42968
43463
  void work.finally(() => this.unifiedWork.delete(work));
42969
43464
  }