@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/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
|
|
20879
|
-
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [] }), {
|
|
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
|
}
|