@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/README.md +15 -0
- package/dist/bin.js +513 -18
- package/dist/bridge-agent.d.ts +6 -0
- package/dist/index.js +513 -18
- package/dist/memory/unified/item-book.d.ts +5 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
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
|
|
20133
|
-
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [] }), {
|
|
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
|
}
|