@ours.network/notifications 0.1.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/LICENSE +98 -0
- package/README.md +231 -0
- package/dist/cli.js +446 -0
- package/dist/config.js +104 -0
- package/dist/daemon.js +353 -0
- package/dist/http.js +31 -0
- package/dist/log-store.js +48 -0
- package/dist/presence.js +157 -0
- package/dist/provision.js +29 -0
- package/dist/push.js +6 -0
- package/dist/web-push-sender.js +41 -0
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +21 -0
- package/mufl_code/core/.github/FUNDING.yml +3 -0
- package/mufl_code/core/.github/workflows/docs.yml +39 -0
- package/mufl_code/core/.github/workflows/tests.yml +66 -0
- package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
- package/mufl_code/core/COMPATIBILITY.md +221 -0
- package/mufl_code/core/CONTRIBUTING.md +37 -0
- package/mufl_code/core/LICENSE +98 -0
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
- package/mufl_code/core/README.md +91 -0
- package/mufl_code/core/SECURITY.md +40 -0
- package/mufl_code/core/TRADEMARKS.md +24 -0
- package/mufl_code/core/a2a_capabilities.mm +449 -0
- package/mufl_code/core/a2a_cluster.mm +601 -0
- package/mufl_code/core/a2a_control.mm +90 -0
- package/mufl_code/core/a2a_messaging.mm +4817 -0
- package/mufl_code/core/a2a_monitoring.mm +94 -0
- package/mufl_code/core/a2a_notification_integration.mm +144 -0
- package/mufl_code/core/a2a_notifications.mm +1127 -0
- package/mufl_code/core/a2a_protocol.mm +246 -0
- package/mufl_code/core/a2a_versions.mm +801 -0
- package/mufl_code/core/config.mufl +29 -0
- package/mufl_code/core/docs/.vitepress/config.mts +58 -0
- package/mufl_code/core/docs/citations.json +688 -0
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
- package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
- package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
- package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
- package/mufl_code/core/docs/guide/index.md +63 -0
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
- package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
- package/mufl_code/core/docs/how-it-works/identity.md +56 -0
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
- package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
- package/mufl_code/core/docs/how-it-works/overview.md +45 -0
- package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
- package/mufl_code/core/docs/index.md +27 -0
- package/mufl_code/core/docs/package-lock.json +3467 -0
- package/mufl_code/core/docs/package.json +15 -0
- package/mufl_code/core/docs/reference/contributing.md +26 -0
- package/mufl_code/core/docs/reference/glossary.md +108 -0
- package/mufl_code/core/docs/reference/implementations.md +23 -0
- package/mufl_code/core/docs/reference/modules.md +19 -0
- package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
- package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
- package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
- package/mufl_code/core/docs/workflows/cluster.md +89 -0
- package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
- package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
- package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
- package/mufl_code/core/docs/workflows/index.md +57 -0
- package/mufl_code/core/docs/workflows/introductions.md +60 -0
- package/mufl_code/core/docs/workflows/messaging.md +75 -0
- package/mufl_code/core/docs/workflows/monitoring.md +84 -0
- package/mufl_code/core/docs/workflows/notifications.md +109 -0
- package/mufl_code/core/release-notes/0.1.md +60 -0
- package/mufl_code/core/release-notes/0.3.md +22 -0
- package/mufl_code/core/release-notes/0.4.md +97 -0
- package/mufl_code/core/tests/README.md +83 -0
- package/mufl_code/core/tests/compat/README.md +82 -0
- package/mufl_code/core/tests/compat/compat.mjs +245 -0
- package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
- package/mufl_code/core/tests/compat/run_compat.sh +96 -0
- package/mufl_code/core/tests/corpus.mjs +139 -0
- package/mufl_code/core/tests/corpus_actor.mu +242 -0
- package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
- package/mufl_code/core/tests/mig.mjs +805 -0
- package/mufl_code/core/tests/mig_actor.mu +518 -0
- package/mufl_code/core/tests/migapp.mjs +431 -0
- package/mufl_code/core/tests/migapp_actor.mu +294 -0
- package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
- package/mufl_code/core/tests/notif.mjs +755 -0
- package/mufl_code/core/tests/notif_actor.mu +450 -0
- package/mufl_code/core/tests/protocol_container.mm +18 -0
- package/mufl_code/core/tests/run.sh +65 -0
- package/mufl_code/core/tests/run_corpus.sh +51 -0
- package/mufl_code/core/tests/run_fatcompile.sh +40 -0
- package/mufl_code/core/tests/run_mig.sh +51 -0
- package/mufl_code/core/tests/run_migapp.sh +51 -0
- package/mufl_code/core/tests/run_notif.sh +57 -0
- package/mufl_code/core/tests/test.mjs +750 -0
- package/mufl_code/core/tests/test_actor.mu +564 -0
- package/mufl_code/core/tests/test_common.mjs +93 -0
- package/mufl_code/core/version.mm +24 -0
- package/mufl_code/packet.mu +170 -0
- package/mufl_code/protocol_container.mm +18 -0
- package/package.json +43 -0
- package/scripts/gen-vapid.mjs +16 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// App-e2e RECEIVE-path guard matrix (spec §5.5/§5.6/§4, increment C). Drives the REAL
|
|
3
|
+
// handle_receive_e2e_message through the broker against a SYNTHESIZED box-only committed
|
|
4
|
+
// initiator (crypto-layer stage/commit primitives build the FSM state without a live
|
|
5
|
+
// handshake), proving the §5.5 must-fix-C implicit-confirm branches:
|
|
6
|
+
// 1) implicit-confirm POSITIVE, PRODUCTION-LIKE (seen==TRUE at committed) → promote+deliver
|
|
7
|
+
// 2) must-fix-C ROLLBACK (app hook aborts → promotion+pins+flush roll back, stays committed)
|
|
8
|
+
// 3) negative already-e2e (committed WITH a live session → deliver, NO promote)
|
|
9
|
+
// 4) both interleave orders {app→confirm}/{confirm→app} → both decode, EXACTLY ONE promotion
|
|
10
|
+
// Runs migapp_actor.mu (loads a2a_messaging + e2e, fresh meta-fuel budget). Via run_migapp.sh.
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import { adapt_wrapper } from '@adapt-toolkit/sdk/executables';
|
|
14
|
+
import { PacketWrapperConfigurator } from '@adapt-toolkit/sdk/wrappers';
|
|
15
|
+
import { object_to_adapt_value } from '@adapt-toolkit/sdk/wrapper';
|
|
16
|
+
|
|
17
|
+
const BROKER_URL = process.env.BROKER_URL || 'ws://127.0.0.1:9790';
|
|
18
|
+
const UNIT_DIR = resolve('.');
|
|
19
|
+
const unitHash = fs.readdirSync(UNIT_DIR).find((f) => f.endsWith('.muflo')).slice(0, -'.muflo'.length);
|
|
20
|
+
const UNIT = new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, `${unitHash}.muflo`)));
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
const log = (...a) => process.stderr.write(`[migapp] ${a.join(' ')}\n`);
|
|
23
|
+
const scorecard = [];
|
|
24
|
+
const ok = (c, m) => { if (!c) { scorecard.push(`✗ ${m}`); console.log(` ✗ FAIL: ${m}`); } else { console.log(` ✓ ${m}`); } };
|
|
25
|
+
const T = (s) => /true/i.test(String(s));
|
|
26
|
+
|
|
27
|
+
let wrapper;
|
|
28
|
+
function mk(name) { return { name, pw: null, cid: '', pending: [] }; }
|
|
29
|
+
function wire(id) {
|
|
30
|
+
id.pw.on_return_data = (d) => {
|
|
31
|
+
const kind = d.Reduce('kind').Visualize();
|
|
32
|
+
if (kind === 'notify_agent') { try { const pl=d.Reduce('payload'); const ev=pl.Reduce('event').Visualize(); if(/migration|protocol_error/.test(ev)) process.stderr.write(`[notify ${id.name}] ${ev} cid=${pl.Reduce('cid').Visualize().slice(0,10)}\n`); } catch {} return; }
|
|
33
|
+
if (kind === 'save_state') return;
|
|
34
|
+
const p = id.pending.shift(); if (!p) return;
|
|
35
|
+
clearTimeout(p.timer); p.resolve(d.Reduce('payload'));
|
|
36
|
+
};
|
|
37
|
+
id.pw.on_transaction_failure = (msg) => { const p = id.pending.shift(); if (p) { clearTimeout(p.timer); p.reject(new Error(msg)); } };
|
|
38
|
+
}
|
|
39
|
+
async function mkNode(seed, name) {
|
|
40
|
+
const id = mk(name);
|
|
41
|
+
const cfg = new PacketWrapperConfigurator();
|
|
42
|
+
cfg.process_arguments(['--unit_hash', unitHash, '--seed_phrase', seed, '--unit_dir_path', UNIT_DIR]);
|
|
43
|
+
await new Promise((res, rej) => {
|
|
44
|
+
const t = setTimeout(() => rej(new Error(`${name} create timeout`)), 30000);
|
|
45
|
+
wrapper.packet_manager.create_packet(cfg, (pw) => { clearTimeout(t); id.pw = pw; id.cid = pw.packet.GetContainerID().Visualize(); wire(id); res(); }, UNIT);
|
|
46
|
+
});
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
const mutate = (id, name, targ) => new Promise((res, rej) => {
|
|
50
|
+
const timer = setTimeout(() => rej(new Error(`${id.name}.${name} timed out`)), 20000);
|
|
51
|
+
id.pending.push({ resolve: res, reject: rej, timer });
|
|
52
|
+
id.pw.add_client_message(object_to_adapt_value({ name, targ }));
|
|
53
|
+
});
|
|
54
|
+
const binv = (id, buf) => id.pw.packet.NewBinaryFromBuffer(Buffer.from(buf));
|
|
55
|
+
const ro = (id, name, targ) => id.pw.packet.ExecuteTransaction(object_to_adapt_value({ name, targ }));
|
|
56
|
+
const getBin = (p, k) => { try { return Buffer.from(p.Reduce(k).GetBinary()); } catch { return Buffer.alloc(0); } };
|
|
57
|
+
const hex = (buf) => Buffer.from(buf).toString('hex');
|
|
58
|
+
const seq = (() => { let n = 0; return () => n++; })();
|
|
59
|
+
|
|
60
|
+
// Build a box-only (or with-live-session, when opts.live) COMMITTED-INITIATOR I over a real staged
|
|
61
|
+
// rotation shared with responder R. Returns { I, R, sid, ic1Ik } where R has PROMOTED the rotation
|
|
62
|
+
// (its m_sessions == the shared session) and I is committed+seen (production-like). No live handshake.
|
|
63
|
+
// Bring up two fresh nodes I,R and connect them (invite → add_contact → both learn each other's
|
|
64
|
+
// AD, so peer_ads is populated BOTH ways). Returns the nodes + their e2e bundles + I's ik.
|
|
65
|
+
async function connect(tag) {
|
|
66
|
+
const n = seq();
|
|
67
|
+
const I = await mkNode(`migapp-${tag}-I${n}`, `I_${tag}`);
|
|
68
|
+
const R = await mkNode(`migapp-${tag}-R${n}`, `R_${tag}`); await sleep(1000);
|
|
69
|
+
const inv = await mutate(I, '::a2a_messaging::generate_invite', { name: R.name });
|
|
70
|
+
await mutate(R, '::a2a_messaging::add_contact', { invite: binv(R, Buffer.from(inv.Reduce('invite').GetBinary())), name: I.name });
|
|
71
|
+
await sleep(6000);
|
|
72
|
+
const iBundle = getBin(ro(I, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
73
|
+
const rBundle = getBin(ro(R, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
74
|
+
const iIk = getBin(ro(I, '::actor::qa_e2e_ik', {}), 'ik');
|
|
75
|
+
return { I, R, iBundle, rBundle, iIk };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function synthCommitted(tag, opts = {}) {
|
|
79
|
+
const { I, R, iBundle, rBundle, iIk } = await connect(tag);
|
|
80
|
+
// Optional pre-migration LIVE session (the already-E2E negative case).
|
|
81
|
+
if (opts.live) {
|
|
82
|
+
const lenv = await mutate(I, '::actor::qa_e2e_first_send', { cid: R.cid, pt: binv(I, Buffer.from('pre-migration-live')), peer: binv(I, rBundle) });
|
|
83
|
+
await mutate(R, '::actor::qa_e2e_recv', { from: I.cid, ik: binv(R, iIk), olm_type: +lenv.Reduce('olm_type').Visualize(), ciphertext: binv(R, getBin(lenv, 'ciphertext')) });
|
|
84
|
+
}
|
|
85
|
+
// I stages a fresh outbound rotation (box-only when !opts.live), encrypts the commit pre-key.
|
|
86
|
+
const sid = getBin(await mutate(I, '::actor::qa_e2e_stage_out', { cid: R.cid, peer: binv(I, rBundle) }), 'sid');
|
|
87
|
+
const cenv = await mutate(I, '::actor::qa_e2e_enc_staged', { cid: R.cid, pt: binv(I, Buffer.from('migration-commit-prekey')) });
|
|
88
|
+
// R stages that rotation inbound and PROMOTES it (goes "active" on the rotation).
|
|
89
|
+
await mutate(R, '::actor::qa_e2e_stage_in', { from: I.cid, ik: binv(R, iIk), ciphertext: binv(R, getBin(cenv, 'ciphertext')) });
|
|
90
|
+
await mutate(R, '::actor::qa_e2e_commit', { cid: I.cid });
|
|
91
|
+
// I is committed-initiator over that staged session; seen==TRUE ⇒ PRODUCTION-LIKE (advertised cap_e2e).
|
|
92
|
+
await mutate(I, '::actor::qa_mig_set_committed', { cid: R.cid, session_id: binv(I, sid), seen: true });
|
|
93
|
+
// R must route e2e to I (seen + bundle) so its app rides receive_e2e_message_tx.
|
|
94
|
+
await mutate(R, '::actor::qa_learn_peer', { cid: I.cid, pv: 9, caps: ['core.e2e'] });
|
|
95
|
+
return { I, R, sid, iBundle };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main() {
|
|
99
|
+
wrapper = await adapt_wrapper.start(['--broker_address', BROKER_URL, '--test_mode',
|
|
100
|
+
'--logger_config', '--level', 'WARNING', '--stdout', 'stderr', '--logger_config_end']);
|
|
101
|
+
wrapper.start();
|
|
102
|
+
await sleep(1500);
|
|
103
|
+
|
|
104
|
+
// ── (1) IMPLICIT-CONFIRM POSITIVE, PRODUCTION-LIKE (seen==TRUE at committed → do_ic MUST fire).
|
|
105
|
+
console.log('=== migapp: implicit-confirm POSITIVE (production-like: seen==TRUE, box-only committed) ===');
|
|
106
|
+
{ const { I, R, sid } = await synthCommitted('pos');
|
|
107
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'committed', 'setup: initiator is box-only committed');
|
|
108
|
+
ok(T(ro(I, '::actor::qa_mig_pin', { cid: R.cid }).Reduce('seen').Visualize()), 'setup: initiator has contact_e2e_seen==TRUE (production-like — the exact state that masked the do_ic bug)');
|
|
109
|
+
ok(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid').length === 0, 'setup: initiator is BOX-ONLY (no live session)');
|
|
110
|
+
await mutate(I, '::actor::qa_recv_reset', {});
|
|
111
|
+
const appPt = 'responder app on the new session BEFORE the explicit confirm';
|
|
112
|
+
const s = await mutate(R, '::a2a_messaging::send_message', { contact: I.name, text: appPt });
|
|
113
|
+
ok(s.Reduce('route').Visualize() === 'e2e', 'responder boxed its app as receive_e2e_message_tx (routes e2e)');
|
|
114
|
+
await sleep(4000);
|
|
115
|
+
ok(ro(I, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === appPt, 'implicit-confirm: app DELIVERED to the app hook');
|
|
116
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'active', '★ implicit-confirm PROMOTED to active DESPITE seen==TRUE (do_ic decoupled — the fix works)');
|
|
117
|
+
const pin = ro(I, '::actor::qa_mig_pin', { cid: R.cid });
|
|
118
|
+
ok(T(pin.Reduce('pinned').Visualize()) && hex(getBin(pin, 'session_id')) === hex(sid), 'implicit-confirm: epoch pin set to the promoted (migrated) session');
|
|
119
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === hex(sid), 'implicit-confirm: active session == the promoted rotation');
|
|
120
|
+
// §5.8 mixed-contact independence: this node is epoch-pinned to R (routes e2e) while an
|
|
121
|
+
// unrelated non-e2e contact routes "legacy" — per-contact routing is independent.
|
|
122
|
+
ok(ro(I, '::actor::qa_e2e_route', { cid: R.cid }).Reduce('route').Visualize() === 'e2e', 'mixed-contact(§5.8): the migrated peer routes e2e');
|
|
123
|
+
ok(ro(I, '::actor::qa_e2e_route', { cid: 'ab'.repeat(32) }).Reduce('route').Visualize() === 'legacy', 'mixed-contact(§5.8): an unrelated non-e2e contact routes legacy — routes are per-contact independent'); }
|
|
124
|
+
|
|
125
|
+
// ── §5.8 ROUTE-LEVEL rows (single node, store-synthesized): imported pin + no bundle → fail closed.
|
|
126
|
+
console.log('=== migapp: §5.8 route-level (import-pin-no-session → downgrade_refused; fresh → legacy) ===');
|
|
127
|
+
{ const A = await mkNode('migapp-route-A', 'A'); await sleep(800);
|
|
128
|
+
const pinnedCid = 'cd'.repeat(32), freshCid = 'ef'.repeat(32);
|
|
129
|
+
await mutate(A, '::actor::qa_set_epoch_pin', { cid: pinnedCid, session_id: binv(A, Buffer.from('imported-session-id-32-bytes-xx!')) });
|
|
130
|
+
ok(ro(A, '::actor::qa_e2e_route', { cid: pinnedCid }).Reduce('route').Visualize() === 'downgrade_refused', 'route(§5.6/§5.8): imported epoch pin + NO peer bundle → downgrade_refused (fail closed — never box a migrated peer; recovery re-rotates over the carve-out)');
|
|
131
|
+
ok(ro(A, '::actor::qa_e2e_route', { cid: freshCid }).Reduce('route').Visualize() === 'legacy', 'route(§5.8): a fresh non-e2e contact → legacy (independent of the pinned one on the same node)');
|
|
132
|
+
// Complete the 5-state route matrix on one node: a committed-INITIATOR (commit window) → "migrating".
|
|
133
|
+
const migratingCid = '01'.repeat(32);
|
|
134
|
+
await mutate(A, '::actor::qa_mig_set_committed', { cid: migratingCid, session_id: binv(A, Buffer.from('committed-window-session-32byte!')), seen: false });
|
|
135
|
+
ok(ro(A, '::actor::qa_e2e_route', { cid: migratingCid }).Reduce('route').Visualize() === 'migrating', 'route(§5.8): a committed-initiator (commit window) → "migrating" (app-data queues to mig_deferred, flushes on active) — 5-state matrix complete'); }
|
|
136
|
+
|
|
137
|
+
// ── (2) MUST-FIX-C ROLLBACK: app hook aborts → promotion+pins+flush all roll back, stays committed.
|
|
138
|
+
console.log('=== migapp: must-fix-C ROLLBACK (app-hook abort → full rollback, FSM stays committed) ===');
|
|
139
|
+
{ const { I, R } = await synthCommitted('rb');
|
|
140
|
+
await mutate(I, '::actor::qa_recv_reset', {});
|
|
141
|
+
await mutate(I, '::actor::qa_recv_set_abort', { abort: true });
|
|
142
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.name, text: 'should roll back — app hook will abort' });
|
|
143
|
+
await sleep(4000);
|
|
144
|
+
ok(ro(I, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === '', 'rollback: nothing delivered (app hook aborted)');
|
|
145
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'committed', '★ rollback: FSM stays COMMITTED (promotion rolled back with the tx)');
|
|
146
|
+
ok(!T(ro(I, '::actor::qa_mig_pin', { cid: R.cid }).Reduce('pinned').Visualize()), 'rollback: epoch pin NOT set (rolled back)');
|
|
147
|
+
ok(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid').length === 0, 'rollback: no active session (commit_rotation rolled back — still box-only)');
|
|
148
|
+
await mutate(I, '::actor::qa_recv_set_abort', { abort: false }); }
|
|
149
|
+
|
|
150
|
+
// ── (3) NEGATIVE ALREADY-E2E: committed WITH a pre-migration live session → deliver, NO promote.
|
|
151
|
+
console.log('=== migapp: negative already-E2E (committed non-box-only → deliver, NO promote) ===');
|
|
152
|
+
{ const { I, R, sid } = await synthCommitted('ae2e', { live: true });
|
|
153
|
+
const liveSid = getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid');
|
|
154
|
+
ok(liveSid.length > 0 && hex(liveSid) !== hex(sid), 'setup: initiator has a LIVE session distinct from the staged rotation');
|
|
155
|
+
await mutate(I, '::actor::qa_recv_reset', {});
|
|
156
|
+
const appPt = 'already-e2e committed: app on the rotation';
|
|
157
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.name, text: appPt });
|
|
158
|
+
await sleep(4000);
|
|
159
|
+
ok(ro(I, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === appPt, 'already-e2e: app DELIVERED (decoded on the staged rotation)');
|
|
160
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'committed', '★ already-e2e: NO promote (stays committed — explicit confirm promotes, not the app)');
|
|
161
|
+
ok(getBin(ro(I, '::actor::qa_e2e_staged', { cid: R.cid }), 'sid').length > 0, 'already-e2e: staged rotation still present (not promoted)');
|
|
162
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === hex(liveSid), 'already-e2e: live session UNCHANGED (rotation not promoted over it)'); }
|
|
163
|
+
|
|
164
|
+
// ── (4a) INTERLEAVE {app → confirm}: app promotes (do_ic), then the explicit confirm is a NO-OP.
|
|
165
|
+
console.log('=== migapp: interleave {app→confirm} — app promotes, confirm no-op → EXACTLY ONE promotion ===');
|
|
166
|
+
{ const { I, R, sid } = await synthCommitted('iac');
|
|
167
|
+
await mutate(I, '::actor::qa_recv_reset', {});
|
|
168
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.name, text: 'app first' });
|
|
169
|
+
await sleep(4000);
|
|
170
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'active', 'interleave A: app promoted to active (1st promotion)');
|
|
171
|
+
const act1 = hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid'));
|
|
172
|
+
await mutate(R, '::actor::qa_send_confirm', { contact: I.name, peer: binv(R, getBin(ro(I, '::actor::qa_e2e_bundle', {}), 'bundle')), epoch: binv(R, sid) });
|
|
173
|
+
await sleep(4000);
|
|
174
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'active', 'interleave A: still active after the confirm');
|
|
175
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === act1 && act1 === hex(sid), '★ interleave A: confirm is a NO-OP (session unchanged) → EXACTLY ONE promotion'); }
|
|
176
|
+
|
|
177
|
+
// ── (4b) INTERLEAVE {confirm → app}: confirm promotes, then the app delivers with NO extra promotion.
|
|
178
|
+
console.log('=== migapp: interleave {confirm→app} — confirm promotes, app delivers → EXACTLY ONE promotion ===');
|
|
179
|
+
{ const { I, R, sid } = await synthCommitted('ica');
|
|
180
|
+
await mutate(I, '::actor::qa_recv_reset', {});
|
|
181
|
+
await mutate(R, '::actor::qa_send_confirm', { contact: I.name, peer: binv(R, getBin(ro(I, '::actor::qa_e2e_bundle', {}), 'bundle')), epoch: binv(R, sid) });
|
|
182
|
+
await sleep(4000);
|
|
183
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'active', 'interleave B: confirm promoted to active (the ONLY promotion)');
|
|
184
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === hex(sid), 'interleave B: active == the migrated session');
|
|
185
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.name, text: 'app after confirm' });
|
|
186
|
+
await sleep(4000);
|
|
187
|
+
ok(ro(I, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === 'app after confirm', 'interleave B: app DELIVERED on the migrated session');
|
|
188
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === hex(sid), '★ interleave B: session unchanged by the app → EXACTLY ONE promotion (the confirm)');
|
|
189
|
+
// §5.8 broker-redelivery: a REDELIVERED confirm after promotion is an idempotent NO-OP (phase
|
|
190
|
+
// already active → handle_e2e_migrate_confirm returns []; no re-promotion, session untouched).
|
|
191
|
+
await mutate(R, '::actor::qa_send_confirm', { contact: I.name, peer: binv(R, getBin(ro(I, '::actor::qa_e2e_bundle', {}), 'bundle')), epoch: binv(R, sid) });
|
|
192
|
+
await sleep(4000);
|
|
193
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'active' && hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === hex(sid), 'dup-confirm(§5.8): a REDELIVERED confirm at active is a NO-OP (idempotent — no second promotion, session unchanged)'); }
|
|
194
|
+
|
|
195
|
+
// ── §5.8 dup-COMMIT re-confirm (responder) + lost-confirm recovery (initiator promotes).
|
|
196
|
+
// The responder's confirm was lost, so the initiator is stuck at "committed". Its sweep re-sends
|
|
197
|
+
// the commit; the responder is already active, so handle_e2e_migrate_commit routes to
|
|
198
|
+
// mig_handle_replayed_commit — a NO-decode, NO-state-change idempotent re-confirm on the pinned
|
|
199
|
+
// active session. That re-confirm reaches the initiator, which finally promotes. Proves BOTH
|
|
200
|
+
// §5.8 rows: "dup-commit → re-confirm (idempotent)" and "lost-confirm + redelivered-commit → re-confirm".
|
|
201
|
+
console.log('=== migapp: §5.8 dup-commit re-confirm (responder) + lost-confirm recovery (initiator promotes) ===');
|
|
202
|
+
{ const { I, R, sid } = await synthCommitted('dupc');
|
|
203
|
+
// R is crypto-promoted over the shared rotation; make it an ACTIVE responder in the FSM, pinned
|
|
204
|
+
// to that live session, carrying the initiator's committed epoch (== sid).
|
|
205
|
+
const setA = await mutate(R, '::actor::qa_mig_set_active', { cid: I.cid, epoch: binv(R, sid), initiator: false });
|
|
206
|
+
ok(hex(getBin(setA, 'asid')) === hex(sid), 'setup: responder active session == the shared rotation (both peers pin the SAME session_id — #1867 invariant)');
|
|
207
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'active', 'setup: responder is ACTIVE (already promoted + sent its now-lost confirm)');
|
|
208
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'committed', 'setup: initiator is still COMMITTED (its responder confirm was lost)');
|
|
209
|
+
ok(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid').length === 0, 'setup: initiator has no active session yet (box-only committed, staged rotation survives)');
|
|
210
|
+
// Production lost-confirm recovery: the committed initiator's sweep re-encrypts the commit on its
|
|
211
|
+
// surviving staged session and re-sends it. (Deterministic — no wall-clock; single host-fired sweep.)
|
|
212
|
+
const sw = await mutate(I, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
213
|
+
ok(+sw.Reduce('redriven').Visualize() >= 1, 'sweep: the committed initiator re-drives its commit (redriven ≥ 1) — a redelivered commit is now in flight to the active responder');
|
|
214
|
+
await sleep(4000);
|
|
215
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'active', '★ lost-confirm recovery: redelivered commit → active responder re-confirmed → initiator PROMOTED to active');
|
|
216
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_active', { cid: R.cid }), 'sid')) === hex(sid), 'recovery: initiator active session == the migrated rotation (same session both peers)');
|
|
217
|
+
const ipin = ro(I, '::actor::qa_mig_pin', { cid: R.cid });
|
|
218
|
+
ok(T(ipin.Reduce('pinned').Visualize()) && hex(getBin(ipin, 'session_id')) === hex(sid), 'recovery: initiator epoch pin == the migrated session');
|
|
219
|
+
// The responder never mutated on the replay: mig_handle_replayed_commit writes NOTHING (re-confirm only).
|
|
220
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'active' &&
|
|
221
|
+
hex(getBin(ro(R, '::actor::qa_e2e_active', { cid: I.cid }), 'sid')) === hex(sid),
|
|
222
|
+
'★ dup-commit: responder UNCHANGED by the replayed commit (idempotent re-confirm — no decode, no state mutation)'); }
|
|
223
|
+
|
|
224
|
+
// ── §5.8 forged commit at the REAL handler → GATE 3 reject. A crypto-VALID commit (S1/S2 + decrypt
|
|
225
|
+
// succeed) whose INNER epoch is forged (≠ the responder's stored epoch) must be discarded by
|
|
226
|
+
// handle_e2e_migrate_commit's inner epoch/session gate: discard_rotation, stay acknowledged, no
|
|
227
|
+
// promotion. A positive control on a fresh pair (correct epoch) promotes — proving the ONLY reason
|
|
228
|
+
// for the reject is the forged epoch, not a broken crypto path.
|
|
229
|
+
console.log('=== migapp: §5.8 forged commit (wrong inner epoch) at REAL handle_e2e_migrate_commit → GATE3 reject ===');
|
|
230
|
+
{ const { I, R, rBundle } = await connect('fce');
|
|
231
|
+
const epoch = Buffer.from('acknowledged-epoch-32-bytes-xx!!');
|
|
232
|
+
await mutate(R, '::actor::qa_mig_set_acknowledged', { cid: I.cid, epoch: binv(R, epoch) });
|
|
233
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'acknowledged', 'setup: responder is acknowledged with a stored epoch');
|
|
234
|
+
// Crypto-valid commit whose inner epoch is FORGED (session_id is correct — the ONLY bad field is epoch).
|
|
235
|
+
await mutate(I, '::actor::qa_send_commit', { contact: R.name, peer: binv(I, rBundle), epoch: binv(I, Buffer.from('FORGED-wrong-epoch-32-bytes-xx!!')) });
|
|
236
|
+
await sleep(4000);
|
|
237
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'acknowledged', '★ forged commit (wrong epoch): responder STAYS acknowledged (GATE3 reject — the decode succeeded, the inner epoch gate did not)');
|
|
238
|
+
ok(!T(ro(R, '::actor::qa_mig_pin', { cid: I.cid }).Reduce('pinned').Visualize()), '★ forged commit: NO epoch pin (the epoch pin is the SOLE migration authority — a forgery can never set it)');
|
|
239
|
+
ok(getBin(ro(R, '::actor::qa_e2e_staged', { cid: I.cid }), 'sid').length === 0, 'forged commit: staged slot cleared (discard_rotation ran on the GATE3 reject)');
|
|
240
|
+
// A box-only (no prior session) rejected commit MAY leave a TRANSIENT UNPINNED session in
|
|
241
|
+
// m_sessions: the decode of a first-contact PRE_KEY establishes it directly (there is no live
|
|
242
|
+
// session to rotate FROM, so it does not go through the staged slot), and discard_rotation only
|
|
243
|
+
// clears m_staged. This is a KNOWN, REVIEWED, ACCEPTED artifact (Impl3 finding (d)) — it is
|
|
244
|
+
// UNPINNED, so it is NOT authoritative (route/FSM ignore it; the epoch pin governs) and it
|
|
245
|
+
// self-corrects on the next valid rotation. The security invariant that matters — no pin, no
|
|
246
|
+
// promotion, phase unchanged — holds above. }
|
|
247
|
+
}
|
|
248
|
+
// Positive control: the SAME path with the CORRECT epoch promotes the responder to active.
|
|
249
|
+
console.log('=== migapp: §5.8 forged-commit positive control (correct epoch → responder promotes) ===');
|
|
250
|
+
{ const { I, R, rBundle } = await connect('fcp');
|
|
251
|
+
const epoch = Buffer.from('acknowledged-epoch-32-bytes-xx!!');
|
|
252
|
+
await mutate(R, '::actor::qa_mig_set_acknowledged', { cid: I.cid, epoch: binv(R, epoch) });
|
|
253
|
+
const c = await mutate(I, '::actor::qa_send_commit', { contact: R.name, peer: binv(I, rBundle), epoch: binv(I, epoch) });
|
|
254
|
+
await sleep(4000);
|
|
255
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'active', '★ positive control: a CORRECT-epoch commit at the same handler PROMOTES to active (the reject above is epoch-specific, not a broken crypto path)');
|
|
256
|
+
ok(hex(getBin(ro(R, '::actor::qa_e2e_active', { cid: I.cid }), 'sid')) === hex(getBin(c, 'sid')), 'positive control: active session == the committed rotation (both peers pin the same session_id)');
|
|
257
|
+
const rpin = ro(R, '::actor::qa_mig_pin', { cid: I.cid });
|
|
258
|
+
ok(T(rpin.Reduce('pinned').Visualize()) && hex(getBin(rpin, 'session_id')) === hex(getBin(c, 'sid')), 'positive control: epoch pin set to the committed session'); }
|
|
259
|
+
|
|
260
|
+
// ── §5.8 restart, SAME-packet (staged SURVIVES) → sweep resumes the SAME epoch. A same-packet
|
|
261
|
+
// restart persists m_staged (packet-state reloaded), so the committed initiator's staged rotation
|
|
262
|
+
// is intact. The sweep re-drives byte-identically on that surviving session — it does NOT mint a
|
|
263
|
+
// fresh epoch. Resume-same-epoch: the responder's session_matches collapses the duplicate commit.
|
|
264
|
+
console.log('=== migapp: §5.8 restart same-packet (staged survives) → sweep resumes the SAME epoch ===');
|
|
265
|
+
{ const { I, R, sid } = await synthCommitted('rsr');
|
|
266
|
+
const staged0 = getBin(ro(I, '::actor::qa_e2e_staged', { cid: R.cid }), 'sid');
|
|
267
|
+
ok(staged0.length > 0 && hex(staged0) === hex(sid), 'setup: committed initiator with a SURVIVING staged session (same-packet restart preserves m_staged)');
|
|
268
|
+
const sw = await mutate(I, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
269
|
+
ok(+sw.Reduce('redriven').Visualize() === 1 && +sw.Reduce('superseded').Visualize() === 0, '★ restart-resume: the sweep RE-DRIVES the committed entry (staged survives → byte-identical re-send, NOT a supersede)');
|
|
270
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'committed', 'restart-resume: FSM stays committed (same migration, not restarted from scratch)');
|
|
271
|
+
ok(hex(getBin(ro(I, '::actor::qa_e2e_staged', { cid: R.cid }), 'sid')) === hex(sid), '★ restart-resume: the re-drive resumes the SAME staged rotation (session_id unchanged → same epoch, no fresh rotation)'); }
|
|
272
|
+
|
|
273
|
+
// ── §5.8 restart, UNIT-SWAP (staged session LOST) → supersede. A unit-swap (new packet code)
|
|
274
|
+
// drops the in-memory m_staged (packet-state, never exported per INV-4). A committed initiator whose
|
|
275
|
+
// staged session is gone is UNRESUMABLE — the sweep abandons + supersedes with a fresh offer/epoch,
|
|
276
|
+
// never re-sends a commit it can no longer produce, and never strands a half-migrated pin.
|
|
277
|
+
console.log('=== migapp: §5.8 restart unit-swap (staged session LOST) → supersede ===');
|
|
278
|
+
{ const { I, R } = await connect('rsl');
|
|
279
|
+
await mutate(I, '::actor::qa_mig_set_committed', { cid: R.cid, session_id: binv(I, Buffer.from('lost-staging-session-32-bytes!!!')), seen: false });
|
|
280
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'committed', 'setup: committed initiator');
|
|
281
|
+
ok(getBin(ro(I, '::actor::qa_e2e_staged', { cid: R.cid }), 'sid').length === 0, 'setup: staged session GONE (unit-swap dropped m_staged — never exported, INV-4)');
|
|
282
|
+
const sw = await mutate(I, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
283
|
+
ok(+sw.Reduce('superseded').Visualize() === 1 && +sw.Reduce('redriven').Visualize() === 0, '★ staged-lost: the sweep SUPERSEDES (unresumable committed — staged gone → fresh offer/epoch, never a stale commit)');
|
|
284
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'offered', 'staged-lost: FSM reset to offered (a fresh migration with a new nonce/epoch)');
|
|
285
|
+
ok(!T(ro(I, '::actor::qa_mig_pin', { cid: R.cid }).Reduce('pinned').Visualize()), 'staged-lost: no epoch pin left behind (clean supersede — nothing was ever completed)'); }
|
|
286
|
+
|
|
287
|
+
// ── §5.8 recovery composition (br1): pin + peer_ads ABSENT → RESTORE first, then migrate. When a
|
|
288
|
+
// contact is epoch-pinned (migrated) but its peer AD was lost (a breaking-change migration/restart
|
|
289
|
+
// dropped peer_ads = degraded), send_message's DEGRADED branch (peer_ads==NIL) precedes the
|
|
290
|
+
// migration route: the message is queued to the RESTORE queue + a restore request is (re)issued —
|
|
291
|
+
// NOT boxed, NOT queued to mig_deferred, NOT a downgrade refusal. Restore composes BEFORE migrate;
|
|
292
|
+
// once the AD is re-established the SAME epoch pin routes e2e (proven by the setup + route tests).
|
|
293
|
+
console.log('=== migapp: §5.8 recovery composition — pin + peer_ads ABSENT → restore FIRST, then migrate ===');
|
|
294
|
+
{ const { I, R } = await connect('rec');
|
|
295
|
+
await mutate(I, '::actor::qa_set_epoch_pin', { cid: R.cid, session_id: binv(I, Buffer.from('migrated-but-degraded-session!!!')) });
|
|
296
|
+
ok(ro(I, '::actor::qa_e2e_route', { cid: R.cid }).Reduce('route').Visualize() === 'e2e', 'setup: with peer_ads present + epoch pin, the contact is MIGRATE-READY (route e2e)');
|
|
297
|
+
await mutate(I, '::actor::qa_strip_peer_ads', {});
|
|
298
|
+
ok(ro(I, '::actor::qa_e2e_route', { cid: R.cid }).Reduce('route').Visualize() === 'downgrade_refused', 'route-level view: pin present but peer bundle GONE → downgrade_refused (never box a migrated peer)');
|
|
299
|
+
const s = await mutate(I, '::a2a_messaging::send_message', { contact: R.name, text: 'sent while pinned + degraded' });
|
|
300
|
+
ok(T(s.Reduce('deferred').Visualize()), '★ restore-first: the send is DEFERRED to the restore queue + a restore is (re)issued — the peer_ads-absent branch precedes the migration route');
|
|
301
|
+
ok(!T(s.Reduce('migrating').Visualize()) && !T(s.Reduce('downgrade_refused').Visualize()), '★ restore-first: NOT the migration paths (mig_deferred / downgrade_refused) — restore composes BEFORE migrate (§5.6 br1)'); }
|
|
302
|
+
|
|
303
|
+
// ── §5.8 peer re-keys between ack and commit → supersede-or-complete (NO downgrade either way).
|
|
304
|
+
// The epoch is FROZEN at ack time (from both bundle fps). A bundle change after ack is therefore
|
|
305
|
+
// irrelevant to the epoch. Two outcomes, both safe:
|
|
306
|
+
// (a) the acked bundle is still installable (v1 uses a REUSABLE fallback key — a routine rekey does
|
|
307
|
+
// NOT consume it) → the commit decodes on the frozen bundle and COMPLETES on the frozen epoch.
|
|
308
|
+
// (b) the acked bundle is gone (a full account regen) → the responder cannot Olm-establish the
|
|
309
|
+
// commit's PRE_KEY → it is rejected at decode, the responder does NOT promote, NO epoch pin is
|
|
310
|
+
// set, and NOTHING is delivered as plaintext (NO downgrade). The migration simply does not
|
|
311
|
+
// complete on the stale identity; a fresh offer (new epoch on the new bundle) is the recovery.
|
|
312
|
+
console.log('=== migapp: §5.8 peer-rekey (a) — commit on the acked bundle COMPLETES on the FROZEN epoch ===');
|
|
313
|
+
{ const { I, R, rBundle } = await connect('prkA');
|
|
314
|
+
const epoch = Buffer.from('acknowledged-epoch-32-bytes-xx!!');
|
|
315
|
+
await mutate(R, '::actor::qa_mig_set_acknowledged', { cid: I.cid, epoch: binv(R, epoch) });
|
|
316
|
+
const c = await mutate(I, '::actor::qa_send_commit', { contact: R.name, peer: binv(I, rBundle), epoch: binv(I, epoch) });
|
|
317
|
+
await sleep(4000);
|
|
318
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'active', '★ peer-rekey (a): the commit on the acked bundle COMPLETES on the FROZEN epoch (a post-ack rekey is irrelevant — the epoch froze at ack)');
|
|
319
|
+
const rpin = ro(R, '::actor::qa_mig_pin', { cid: I.cid });
|
|
320
|
+
ok(T(rpin.Reduce('pinned').Visualize()) && hex(getBin(rpin, 'session_id')) === hex(getBin(c, 'sid')), 'peer-rekey (a): epoch-pinned to the committed session (e2e — no downgrade)'); }
|
|
321
|
+
|
|
322
|
+
console.log('=== migapp: §5.8 peer-rekey (b) — acked bundle GONE → commit un-decryptable → safe reject, NO downgrade ===');
|
|
323
|
+
{ const { I, R } = await connect('prkB');
|
|
324
|
+
const X = await mkNode('migapp-prkB-X', 'X'); await sleep(600);
|
|
325
|
+
const xBundle = getBin(ro(X, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
326
|
+
const epoch = Buffer.from('acknowledged-epoch-32-bytes-xx!!');
|
|
327
|
+
await mutate(R, '::actor::qa_mig_set_acknowledged', { cid: I.cid, epoch: binv(R, epoch) });
|
|
328
|
+
// Stage the commit to a DIFFERENT bundle (throwaway X) so R's account cannot Olm-establish it —
|
|
329
|
+
// exactly what a rotated-away (regenerated) responder fallback yields at the decode seam.
|
|
330
|
+
await mutate(I, '::actor::qa_send_commit', { contact: R.name, peer: binv(I, xBundle), epoch: binv(I, epoch) });
|
|
331
|
+
await sleep(4000);
|
|
332
|
+
ok(ro(R, '::actor::qa_mig_state', { cid: I.cid }).Reduce('phase').Visualize() === 'acknowledged', '★ peer-rekey (b): an un-decryptable commit is REJECTED — responder STAYS acknowledged (never promotes on a stale identity)');
|
|
333
|
+
ok(!T(ro(R, '::actor::qa_mig_pin', { cid: I.cid }).Reduce('pinned').Visualize()), '★ peer-rekey (b): NO epoch pin — the failed migration NEVER downgrades a session (no pin = no e2e authority claimed)');
|
|
334
|
+
ok(getBin(ro(R, '::actor::qa_e2e_active', { cid: I.cid }), 'sid').length === 0, 'peer-rekey (b): no active session (nothing staged or consumed)');
|
|
335
|
+
ok(ro(R, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === '', 'peer-rekey (b): nothing delivered as plaintext (no downgrade — the commit is rejected as data, not delivered)'); }
|
|
336
|
+
|
|
337
|
+
// ── §5.8 committed liveness backstop (MR2, closes #8): a committed initiator must never PERMANENTLY
|
|
338
|
+
// stall (that would strand its mig_deferred app data forever — the responder-full-regen case, where
|
|
339
|
+
// the staged commit can never decode on the peer's rotated identity). At the attempts cap a committed
|
|
340
|
+
// entry SUPERSEDES (fresh offer/epoch) instead of only notifying $migration_stalled — releasing the
|
|
341
|
+
// barrier and re-attempting migration onto the peer's current bundle. It was never epoch-pinned, so
|
|
342
|
+
// re-offering (legacy flows at `offered`) is NOT a downgrade.
|
|
343
|
+
console.log('=== migapp: §5.8 committed liveness backstop — attempts cap → SUPERSEDE, never permanent stall ===');
|
|
344
|
+
{ const { I, R } = await synthCommitted('bkstp');
|
|
345
|
+
await mutate(I, '::actor::qa_mig_set_attempts', { cid: R.cid, n: 30 }); // mig_max_attempts
|
|
346
|
+
const sw = await mutate(I, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
347
|
+
ok(+sw.Reduce('superseded').Visualize() === 1 && +sw.Reduce('stalled').Visualize() === 0, '★ backstop: a committed entry at the attempts cap SUPERSEDES (never a permanent $migration_stalled — the mig_deferred barrier is released)');
|
|
348
|
+
ok(ro(I, '::actor::qa_mig_state', { cid: R.cid }).Reduce('phase').Visualize() === 'offered', 'backstop: FSM re-offered (fresh nonce/epoch) → re-attempts migration onto the peer bundle; no permanent strand'); }
|
|
349
|
+
|
|
350
|
+
// ── §5.6 recovery br2 (MR2 gate): pin + peer_ads PRESENT-but-v1 → downgrade_refused, NOT restore.
|
|
351
|
+
// The route verdict downgrade_refused for pin+!peer_has_e2e_bundle is covered; the untested COMPOSITION
|
|
352
|
+
// is the send-path branch: a v1-presenting (self-downgraded) peer whose AD is PRESENT but carries no
|
|
353
|
+
// e2e_bundle must fail CLOSED (typed downgrade_refused, never boxed), and must NOT enter the
|
|
354
|
+
// peer_ads==NIL restore-first branch (row #9) — else it would restore-loop on an AD that is actually
|
|
355
|
+
// present and never resolve. The degraded check is peer_ads==NIL ONLY, so a present-but-v1 AD skips it.
|
|
356
|
+
console.log('=== migapp: §5.6 br2 — pin + peer_ads PRESENT-but-v1 → downgrade_refused (NOT restore-loop) ===');
|
|
357
|
+
{ const { I, R } = await connect('v1nb');
|
|
358
|
+
await mutate(I, '::actor::qa_set_epoch_pin', { cid: R.cid, session_id: binv(I, Buffer.from('migrated-pin-session-32-bytes!!!')) });
|
|
359
|
+
ok(ro(I, '::actor::qa_e2e_route', { cid: R.cid }).Reduce('route').Visualize() === 'e2e', 'setup: pinned + a v2 peer bundle → route e2e (migrate-ready)');
|
|
360
|
+
const dg = await mutate(I, '::actor::qa_downgrade_peer_ad', { cid: R.cid });
|
|
361
|
+
ok(T(dg.Reduce('ok').Visualize()), 'setup: peer AD downgraded to v1 IN PLACE (present in peer_ads, but no e2e_bundle)');
|
|
362
|
+
ok(ro(I, '::actor::qa_e2e_route', { cid: R.cid }).Reduce('route').Visualize() === 'downgrade_refused', '★ v1-no-bundle: pinned + peer_ads PRESENT-but-v1 → route downgrade_refused (fail closed — pin governs, no v2 bundle to ride)');
|
|
363
|
+
const s = await mutate(I, '::a2a_messaging::send_message', { contact: R.name, text: 'to a v1-downgraded pinned peer' });
|
|
364
|
+
ok(T(s.Reduce('downgrade_refused').Visualize()) && s.Reduce('code').Visualize() === 'e2e_downgrade_refused', '★ v1-no-bundle: send → typed downgrade_refused ($code e2e_downgrade_refused) — the anti-downgrade barrier holds (never boxed to plaintext)');
|
|
365
|
+
ok(!T(s.Reduce('deferred').Visualize()), '★ v1-no-bundle: NOT deferred to restore — peer_ads is PRESENT (v1), so it does not restore-loop on an AD that is actually there (the degraded/restore branch is peer_ads==NIL only)'); }
|
|
366
|
+
|
|
367
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
368
|
+
// ★ (D) NATURAL trigger via INBOUND E2E traffic — the exact production bug: an already-e2e-pinned pair
|
|
369
|
+
// routes app data e2e → handle_receive_e2e_message/_file, which had NO trigger, so it never migrated.
|
|
370
|
+
// mig_trigger_actions is now mirrored into BOTH e2e delivery tails. Driven over the REAL send_*→receive
|
|
371
|
+
// path (NOT qa_mig_trigger_offer), using the live-session harness (fresh meta-fuel + the recv hooks).
|
|
372
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
373
|
+
console.log('=== migapp: ★ born-DR invariant (D-message) — inbound e2e app message does NOT migrate a born-DR pair (core 0.10 B2) ===');
|
|
374
|
+
{ const { I, R } = await connect('dmsg');
|
|
375
|
+
const [RCV, SND] = I.cid < R.cid ? [I, R] : [R, I]; // receiver = lower cid = elected initiator when it offers
|
|
376
|
+
const rcvIk = getBin(ro(RCV, '::actor::qa_e2e_ik', {}), 'ik');
|
|
377
|
+
const sndBundle = getBin(ro(SND, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
378
|
+
// A live PRE-migration e2e session (the ALREADY-E2E pair that used to never migrate). RCV initiates it
|
|
379
|
+
// so SND — the app sender — replies with a fully-ratcheted (olm_type 1) message RCV decodes cleanly.
|
|
380
|
+
const lenv = await mutate(RCV, '::actor::qa_e2e_first_send', { cid: SND.cid, pt: binv(RCV, Buffer.from('pre-migration-live')), peer: binv(RCV, sndBundle) });
|
|
381
|
+
await mutate(SND, '::actor::qa_e2e_recv', { from: RCV.cid, ik: binv(SND, rcvIk), olm_type: +lenv.Reduce('olm_type').Visualize(), ciphertext: binv(SND, getBin(lenv, 'ciphertext')) });
|
|
382
|
+
// RCV advertises cap_e2e_migrate via qa_init_caps (NO proactive offer — isolates the INBOUND trigger from B)
|
|
383
|
+
// and learns SND as 0.9 + seen (accepts the e2e app AND mig_should_trigger fires on receipt via pv>=9).
|
|
384
|
+
await mutate(RCV, '::actor::qa_init_caps', { advertise: ['core.e2e.migrate'] });
|
|
385
|
+
await mutate(RCV, '::actor::qa_learn_peer', { cid: SND.cid, pv: 9, caps: ['core.e2e'] });
|
|
386
|
+
await mutate(SND, '::actor::qa_learn_peer', { cid: RCV.cid, pv: 9, caps: ['core.e2e'] }); // SND routes e2e to RCV
|
|
387
|
+
ok(!T(ro(RCV, '::actor::qa_mig_state', { cid: SND.cid }).Reduce('present').Visualize()), 'D-msg setup: no migration on the receiver before inbound e2e (the gap — the e2e receive handler never triggered)');
|
|
388
|
+
await mutate(RCV, '::actor::qa_recv_reset', {});
|
|
389
|
+
const appPt = 'inbound e2e app message on an already-e2e pair';
|
|
390
|
+
const s = await mutate(SND, '::a2a_messaging::send_message', { contact: RCV.name, text: appPt });
|
|
391
|
+
ok(s.Reduce('route').Visualize() === 'e2e', 'D-msg: sender routes the app e2e (receive_e2e_message_tx) — the already-e2e path that had no trigger');
|
|
392
|
+
await sleep(10000); // delivery + offer → ack → commit → confirm
|
|
393
|
+
ok(ro(RCV, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === appPt, 'D-msg: the inbound e2e app message was DELIVERED to the app hook (over the existing born-DR session)');
|
|
394
|
+
const rSt = ro(RCV, '::actor::qa_mig_state', { cid: SND.cid }), sSt = ro(SND, '::actor::qa_mig_state', { cid: RCV.cid });
|
|
395
|
+
console.log(` DIAG D-msg rcv=${rSt.Reduce('phase').Visualize()} snd=${sSt.Reduce('phase').Visualize()}`);
|
|
396
|
+
// core 0.10 (B2): this pair is BORN-DR (both v2 from first contact → contact_born_dr set at
|
|
397
|
+
// registration), so it is ALREADY on the double ratchet. Migration is reserved STRICTLY for
|
|
398
|
+
// pre-existing LEGACY sessions, so inbound e2e traffic must NOT start a migration here (it would
|
|
399
|
+
// be a redundant epoch-pin re-key of an already-DR session). The message still delivers over DR.
|
|
400
|
+
ok(!T(rSt.Reduce('present').Visualize()), '★ D-msg(B2): a born-DR pair does NOT migrate on inbound e2e — no contact_migration on the receiver (migration reserved for legacy)');
|
|
401
|
+
ok(!T(sSt.Reduce('present').Visualize()), '★ D-msg(B2): the born-DR peer likewise did not migrate');
|
|
402
|
+
// A second inbound e2e message likewise starts no migration (stays pure born-DR).
|
|
403
|
+
await mutate(SND, '::a2a_messaging::send_message', { contact: RCV.name, text: 'second message stays born-DR' });
|
|
404
|
+
await sleep(4000);
|
|
405
|
+
ok(!T(ro(RCV, '::actor::qa_mig_state', { cid: SND.cid }).Reduce('present').Visualize()), '★ D-msg(B2): a second inbound e2e message still does NOT migrate (stays pure born-DR)'); }
|
|
406
|
+
|
|
407
|
+
console.log('=== migapp: ★ born-DR invariant (D-file) — inbound e2e FILE does NOT migrate a born-DR pair (core 0.10 B2) ===');
|
|
408
|
+
{ const { I, R } = await connect('dfile');
|
|
409
|
+
const [RCV, SND] = I.cid < R.cid ? [I, R] : [R, I];
|
|
410
|
+
const rcvIk = getBin(ro(RCV, '::actor::qa_e2e_ik', {}), 'ik');
|
|
411
|
+
const sndBundle = getBin(ro(SND, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
412
|
+
const lenv = await mutate(RCV, '::actor::qa_e2e_first_send', { cid: SND.cid, pt: binv(RCV, Buffer.from('pre-migration-live-file')), peer: binv(RCV, sndBundle) });
|
|
413
|
+
await mutate(SND, '::actor::qa_e2e_recv', { from: RCV.cid, ik: binv(SND, rcvIk), olm_type: +lenv.Reduce('olm_type').Visualize(), ciphertext: binv(SND, getBin(lenv, 'ciphertext')) });
|
|
414
|
+
await mutate(RCV, '::actor::qa_init_caps', { advertise: ['core.e2e.migrate'] });
|
|
415
|
+
await mutate(RCV, '::actor::qa_learn_peer', { cid: SND.cid, pv: 9, caps: ['core.e2e'] });
|
|
416
|
+
await mutate(SND, '::actor::qa_learn_peer', { cid: RCV.cid, pv: 9, caps: ['core.e2e'] });
|
|
417
|
+
ok(!T(ro(RCV, '::actor::qa_mig_state', { cid: SND.cid }).Reduce('present').Visualize()), 'D-file setup: no migration on the receiver before inbound e2e file');
|
|
418
|
+
await mutate(RCV, '::actor::qa_recv_reset', {});
|
|
419
|
+
const s = await mutate(SND, '::a2a_messaging::send_file', { contact: RCV.name, filename: 'photo.bin', mime: 'application/octet-stream', data: binv(SND, Buffer.from('e2e-file-payload')) });
|
|
420
|
+
ok(s.Reduce('route').Visualize() === 'e2e', 'D-file: sender routes the file e2e (receive_e2e_file_tx)');
|
|
421
|
+
await sleep(5000);
|
|
422
|
+
ok(ro(RCV, '::actor::qa_recv_last', {}).Reduce('filename').Visualize() === 'photo.bin', 'D-file: the inbound e2e file was DELIVERED to on_file_received (over the existing born-DR session)');
|
|
423
|
+
ok(!T(ro(RCV, '::actor::qa_mig_state', { cid: SND.cid }).Reduce('present').Visualize()), '★ D-file(B2): a born-DR pair does NOT migrate on inbound e2e file (migration reserved for legacy)'); }
|
|
424
|
+
|
|
425
|
+
console.log('\n================ MIGAPP ================');
|
|
426
|
+
if (scorecard.length === 0) console.log('MIGAPP: ALL GREEN');
|
|
427
|
+
else { console.log(`${scorecard.length} FAILURE(S):`); scorecard.forEach((s) => console.log(' ' + s)); }
|
|
428
|
+
await sleep(300);
|
|
429
|
+
process.exit(scorecard.length === 0 ? 0 : 1);
|
|
430
|
+
}
|
|
431
|
+
main().catch((e) => { log('DRIVER ERR:', e.stack ?? e.message); process.exit(1); });
|