@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,750 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// M2 loopback test suite for the core-3.0 ephemeral-invite redeem flow.
|
|
3
|
+
// Asserts RECEIVER-SIDE outcomes (state), per the Critic requirement.
|
|
4
|
+
//
|
|
5
|
+
// T/V/RC-series only — the a2a_notifications N-series moved to notif.mjs (its own
|
|
6
|
+
// compiled unit, notif_actor.mu). The shared wrapper/packet bootstrap + the
|
|
7
|
+
// mk/wire/mutate/ro/binv/mkPacket/sleep/log helpers live in ./test_common.mjs.
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import { createHarness, mk } from './test_common.mjs';
|
|
11
|
+
|
|
12
|
+
const BROKER_URL = process.env.BROKER_URL || 'ws://127.0.0.1:9790';
|
|
13
|
+
const UNIT_DIR = resolve('.');
|
|
14
|
+
const scorecard = [];
|
|
15
|
+
let CUR = '';
|
|
16
|
+
const ok = (c, m) => { if (!c) { scorecard.push(`✗ [${CUR}] ${m}`); console.log(` ✗ FAIL: ${m}`); } else { console.log(` ✓ ${m}`); } };
|
|
17
|
+
const isT = (s) => /true/i.test(String(s));
|
|
18
|
+
|
|
19
|
+
let wrapper, mutate, ro, binv, mkPacket, sleep, log;
|
|
20
|
+
const st = (id) => { const s = ro(id, '::actor::qa_state', undefined); return {
|
|
21
|
+
c: +s.Reduce('n_contacts').Visualize(), p: +s.Reduce('n_peer_ads').Visualize(),
|
|
22
|
+
pi: +s.Reduce('n_pending_invites').Visualize(), pr: +s.Reduce('n_pending_redemptions').Visualize(),
|
|
23
|
+
cr: +s.Reduce('n_contact_roots').Visualize(),
|
|
24
|
+
prs: +s.Reduce('n_pending_restores').Visualize(), rr: +s.Reduce('n_restore_replies').Visualize(),
|
|
25
|
+
dq: +s.Reduce('n_deferred').Visualize() }; };
|
|
26
|
+
const lc = (id) => ro(id, '::a2a_messaging::list_contacts', undefined).Visualize();
|
|
27
|
+
const adBlob = (id) => Buffer.from(ro(id, '::actor::qa_export_ad', undefined).Reduce('ad').GetBinary());
|
|
28
|
+
const setName = (id, n) => mutate(id, '::a2a_messaging::set_my_name', { name: n });
|
|
29
|
+
|
|
30
|
+
async function delegate(root, role, roleName) {
|
|
31
|
+
const roleAd = Buffer.from(ro(role, '::actor::export_address_document', undefined).GetBinary());
|
|
32
|
+
const signed = await mutate(root, '::actor::sign_delegation', { role_ad: binv(root, roleAd), role_id: roleName });
|
|
33
|
+
const cert = Buffer.from(signed.Reduce('cert').GetBinary());
|
|
34
|
+
const prof = await mutate(root, '::actor::export_root_profile', {});
|
|
35
|
+
const profBlob = Buffer.from(prof.Reduce('profile').GetBinary());
|
|
36
|
+
const rootAd = Buffer.from(ro(root, '::actor::export_address_document', undefined).GetBinary());
|
|
37
|
+
// Also mint the v1-AD-bound cert: the root signs a delegation over the role's
|
|
38
|
+
// DOWN-LEVELLED (bundle-less) AD, so a cross-version (0.11.2) peer that hashes
|
|
39
|
+
// the v1 AD it receives finds a matching $role_ad_hash. This is the daemon's
|
|
40
|
+
// real sa() flow — sign_delegation takes any AD blob, so we hand it the v1 one.
|
|
41
|
+
const roleAdV1 = Buffer.from((await mutate(role, '::actor::export_v1_address_document', {})).Reduce('ad').GetBinary());
|
|
42
|
+
const signedV1 = await mutate(root, '::actor::sign_delegation', { role_ad: binv(root, roleAdV1), role_id: roleName });
|
|
43
|
+
const certV1 = Buffer.from(signedV1.Reduce('cert').GetBinary());
|
|
44
|
+
await mutate(role, '::actor::set_delegation', { cert: binv(role, cert), root_ad: binv(role, rootAd), root_profile: binv(role, profBlob), cert_v1: binv(role, certV1) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const h = await createHarness({ brokerUrl: BROKER_URL, unitDir: UNIT_DIR, logTag: 't' });
|
|
49
|
+
({ wrapper, mutate, ro, binv, mkPacket, sleep, log } = h);
|
|
50
|
+
|
|
51
|
+
const I = mk('I'); const R = mk('R'); const F = mk('F');
|
|
52
|
+
await mkPacket(I, 'eph-t-I-01'); await mkPacket(R, 'eph-t-R-02'); await mkPacket(F, 'eph-t-F-03');
|
|
53
|
+
await sleep(1200);
|
|
54
|
+
await setName(I, 'Inviter'); await setName(R, 'Responder'); await setName(F, 'Foreign');
|
|
55
|
+
|
|
56
|
+
// ---------- T1 happy-flat ----------
|
|
57
|
+
CUR = 'T1 happy-flat';
|
|
58
|
+
console.log('\n=== T1 happy-flat ===');
|
|
59
|
+
let m = await mutate(I, '::a2a_messaging::generate_invite', { name: 'TheResponder' });
|
|
60
|
+
const blob1 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
61
|
+
ok(st(I).pi === 1, `inviter has 1 pending_invite after mint`);
|
|
62
|
+
await mutate(R, '::a2a_messaging::add_contact', { invite: binv(R, blob1), name: 'MyInviter' });
|
|
63
|
+
await sleep(5000);
|
|
64
|
+
const i1 = st(I); const r1 = st(R);
|
|
65
|
+
log(`I=${JSON.stringify(i1)} R=${JSON.stringify(r1)}`);
|
|
66
|
+
ok(i1.pi === 0, `leg-2: inviter consumed pending_invites (${i1.pi})`);
|
|
67
|
+
ok(i1.c >= 1 && i1.p >= 1, `leg-2: inviter registered responder (c=${i1.c},peer_ads=${i1.p})`);
|
|
68
|
+
ok(r1.pr === 0, `leg-3: responder cleared pending_redemptions (${r1.pr})`);
|
|
69
|
+
ok(r1.c >= 1 && r1.p >= 1, `leg-3 RECEIVER-SIDE: responder DECRYPTED + registered inviter (c=${r1.c},peer_ads=${r1.p})`);
|
|
70
|
+
ok(new RegExp(I.cid).test(lc(R)), `responder list_contacts includes inviter cid`);
|
|
71
|
+
ok(new RegExp(R.cid).test(lc(I)), `inviter list_contacts includes responder cid`);
|
|
72
|
+
// bidirectional encrypted_channel round-trip
|
|
73
|
+
const smIR = await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'msg-I-to-R' });
|
|
74
|
+
const msgWireIR = smIR.Reduce('wire_id').Visualize();
|
|
75
|
+
await sleep(2500);
|
|
76
|
+
ok(/msg-I-to-R/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()), `send_message I→R round-trips over encrypted_channel`);
|
|
77
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.cid, text: 'msg-R-to-I' });
|
|
78
|
+
await sleep(2500);
|
|
79
|
+
ok(/msg-R-to-I/.test(ro(I, '::actor::list_incoming_messages', undefined).Visualize()), `send_message R→I round-trips over encrypted_channel`);
|
|
80
|
+
|
|
81
|
+
// ---------- T1 file round-trip (send_file both directions) ----------
|
|
82
|
+
const sfIR = await mutate(I, '::a2a_messaging::send_file',
|
|
83
|
+
{ contact: R.cid, filename: 'hello.png', mime: 'image/png', data: binv(I, Buffer.from('\x89PNG\r\n\x1a\nDATA')) });
|
|
84
|
+
const fileWireIR = sfIR.Reduce('wire_id').Visualize();
|
|
85
|
+
await sleep(2500);
|
|
86
|
+
{
|
|
87
|
+
const rf = ro(R, '::actor::list_incoming_files', undefined).Visualize();
|
|
88
|
+
ok(/hello\.png/.test(rf), `send_file I→R: responder received file hello.png`);
|
|
89
|
+
ok(/image\/png/.test(rf), `send_file I→R: responder sees mime image/png`);
|
|
90
|
+
ok(new RegExp(I.cid).test(rf), `send_file I→R: file is attributed to the inviter cid`);
|
|
91
|
+
ok(new RegExp(fileWireIR).test(rf), `send_file I→R: stored wire_id matches the send return`);
|
|
92
|
+
}
|
|
93
|
+
await mutate(R, '::a2a_messaging::send_file',
|
|
94
|
+
{ contact: I.cid, filename: 'reply.txt', mime: 'text/plain', data: binv(R, Buffer.from('hi')) });
|
|
95
|
+
await sleep(2500);
|
|
96
|
+
ok(/reply\.txt/.test(ro(I, '::actor::list_incoming_files', undefined).Visualize()),
|
|
97
|
+
`send_file R→I: inviter received file reply.txt`);
|
|
98
|
+
|
|
99
|
+
// ---------- T1 file monitoring summary (metadata-only; never the bytes) ----------
|
|
100
|
+
{
|
|
101
|
+
const r = await mutate(I, '::actor::qa_file_summary',
|
|
102
|
+
{ filename: 'hello.png', mime: 'image/png', data: binv(I, Buffer.from('ABCDE')) });
|
|
103
|
+
const summary = r.Reduce('summary').Visualize();
|
|
104
|
+
ok(summary === '[file] hello.png (image/png, 5 B)', `file monitor summary is metadata-only: "${summary}"`);
|
|
105
|
+
ok(!/ABCDE/.test(summary), `file monitor summary never carries the file bytes`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- T1 cross-kind reply (one shared wire_id namespace) ----------
|
|
109
|
+
// A MESSAGE replying to a FILE: reply_to points at the file's wire_id (from I→R).
|
|
110
|
+
await mutate(R, '::a2a_messaging::send_message',
|
|
111
|
+
{ contact: I.cid, text: 'reply-to-file', reply_to: { wire_id: fileWireIR } });
|
|
112
|
+
await sleep(2500);
|
|
113
|
+
{
|
|
114
|
+
const im = ro(I, '::actor::list_incoming_messages', undefined).Visualize();
|
|
115
|
+
ok(/reply-to-file/.test(im) && new RegExp(fileWireIR).test(im),
|
|
116
|
+
`a send_message can reply_to a FILE's wire_id (cross-kind, shared namespace)`);
|
|
117
|
+
}
|
|
118
|
+
// A FILE replying to a MESSAGE: reply_to points at the message's wire_id (from I→R).
|
|
119
|
+
await mutate(R, '::a2a_messaging::send_file',
|
|
120
|
+
{ contact: I.cid, filename: 'answer.bin', mime: 'application/octet-stream',
|
|
121
|
+
data: binv(R, Buffer.from('Z')), reply_to: { wire_id: msgWireIR } });
|
|
122
|
+
await sleep(2500);
|
|
123
|
+
{
|
|
124
|
+
const ifs = ro(I, '::actor::list_incoming_files', undefined).Visualize();
|
|
125
|
+
ok(/answer\.bin/.test(ifs) && new RegExp(msgWireIR).test(ifs),
|
|
126
|
+
`a send_file can reply_to a MESSAGE's wire_id (cross-kind, shared namespace)`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- T3 single-use (reuse blob1, already consumed) ----------
|
|
130
|
+
CUR = 'T3 single-use';
|
|
131
|
+
console.log('\n=== T3 single-use ===');
|
|
132
|
+
const before = st(I); const rej0 = I.rejects.length;
|
|
133
|
+
await mutate(R, '::a2a_messaging::add_contact', { invite: binv(R, blob1), name: 'again' });
|
|
134
|
+
await sleep(3000);
|
|
135
|
+
const after = st(I);
|
|
136
|
+
ok(I.rejects.slice(rej0).some((x) => /already-redeemed|Unknown or already/.test(x)), `2nd leg-1 for the same invite_id aborts "already-redeemed" at inviter`);
|
|
137
|
+
ok(after.c === before.c && after.pi === before.pi, `single-use: inviter state unchanged by replay (c=${after.c},pi=${after.pi})`);
|
|
138
|
+
|
|
139
|
+
// ---------- T4 invalid-then-valid (bad box must NOT consume) ----------
|
|
140
|
+
CUR = 'T4 invalid-then-valid';
|
|
141
|
+
console.log('\n=== T4 invalid-then-valid ===');
|
|
142
|
+
m = await mutate(I, '::a2a_messaging::generate_invite', { name: 'P4' });
|
|
143
|
+
const blob4 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
144
|
+
const piAfterMint = st(I).pi; const rejb = I.rejects.length;
|
|
145
|
+
await mutate(R, '::actor::qa_leg1_badbox', { invite: binv(R, blob4) });
|
|
146
|
+
await sleep(3000);
|
|
147
|
+
ok(I.rejects.slice(rejb).some((x) => /Decryption failed|crypto box|read/i.test(x)), `bad box: inviter leg-2 aborts at decrypt`);
|
|
148
|
+
ok(st(I).pi === piAfterMint, `bad box did NOT consume the invite (pi=${st(I).pi}, was ${piAfterMint})`);
|
|
149
|
+
// now redeem the SAME invite validly
|
|
150
|
+
await mutate(R, '::a2a_messaging::add_contact', { invite: binv(R, blob4), name: 'P4ok' });
|
|
151
|
+
await sleep(5000);
|
|
152
|
+
ok(st(I).pi === piAfterMint - 1, `valid redeem after the bad box consumed the invite (pi=${st(I).pi})`);
|
|
153
|
+
|
|
154
|
+
// ---------- T5 tamper (state-unchanged form) ----------
|
|
155
|
+
CUR = 'T5 tamper';
|
|
156
|
+
console.log('\n=== T5 tamper ===');
|
|
157
|
+
m = await mutate(I, '::a2a_messaging::generate_invite', { name: 'P5' });
|
|
158
|
+
const blob5 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
159
|
+
const s5 = st(I); const rej5 = I.rejects.length;
|
|
160
|
+
await mutate(R, '::actor::qa_leg1_badbox', { invite: binv(R, blob5) });
|
|
161
|
+
await sleep(3000);
|
|
162
|
+
ok(I.rejects.slice(rej5).some((x) => /Decryption failed|crypto box|read/i.test(x)), `tampered box aborts at inviter decrypt`);
|
|
163
|
+
const s5b = st(I);
|
|
164
|
+
ok(s5b.c === s5.c && s5b.p === s5.p && s5b.pi === s5.pi, `tamper mutated NO state (contacts/peer_ads/pending unchanged)`);
|
|
165
|
+
|
|
166
|
+
// ---------- T6 cid-bind leg-2 (foreign AD) ----------
|
|
167
|
+
CUR = 'T6 cid-bind-leg2';
|
|
168
|
+
console.log('\n=== T6 cid-bind leg-2 ===');
|
|
169
|
+
m = await mutate(I, '::a2a_messaging::generate_invite', { name: 'P6' });
|
|
170
|
+
const blob6 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
171
|
+
const s6 = st(I); const rej6 = I.rejects.length;
|
|
172
|
+
await mutate(R, '::actor::qa_leg1_foreign_ad', { invite: binv(R, blob6), foreign_ad: binv(R, adBlob(F)) });
|
|
173
|
+
await sleep(3000);
|
|
174
|
+
ok(I.rejects.slice(rej6).some((x) => /does not belong to the sender/.test(x)), `leg-2 cid-bind: foreign AD (cid≠sender) aborts`);
|
|
175
|
+
ok(st(I).pi === s6.pi && st(I).c === s6.c, `leg-2 cid-bind failure consumed/registered nothing`);
|
|
176
|
+
|
|
177
|
+
// ---------- T7 PoP leg-2 (forged AD) ----------
|
|
178
|
+
CUR = 'T7 PoP-leg2';
|
|
179
|
+
console.log('\n=== T7 PoP leg-2 ===');
|
|
180
|
+
m = await mutate(I, '::a2a_messaging::generate_invite', { name: 'P7' });
|
|
181
|
+
const blob7 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
182
|
+
const s7 = st(I); const rej7 = I.rejects.length;
|
|
183
|
+
await mutate(R, '::actor::qa_leg1_forged_ad', { invite: binv(R, blob7) });
|
|
184
|
+
await sleep(3000);
|
|
185
|
+
ok(I.rejects.slice(rej7).some((x) => /authoriz|signature|process|key/i.test(x)), `leg-2 PoP: forged AD (stripped self-sig) aborts in process_address_document`);
|
|
186
|
+
ok(st(I).pi === s7.pi && st(I).c === s7.c, `leg-2 PoP failure consumed/registered nothing`);
|
|
187
|
+
|
|
188
|
+
// ---------- T8 leg-3 gates (unexpected-inviter, cid-bind, PoP) ----------
|
|
189
|
+
CUR = 'T8 leg3-gates';
|
|
190
|
+
console.log('\n=== T8 leg-3 gates ===');
|
|
191
|
+
// helper: give R a LIVE pending redemption pinned to I.cid (a fake invite I never minted)
|
|
192
|
+
async function liveRedemption() {
|
|
193
|
+
const fk = await mutate(I, '::actor::qa_mint_fake_invite', { inviter_cid: I.cid });
|
|
194
|
+
const fblob = Buffer.from(fk.Reduce('blob').GetBinary());
|
|
195
|
+
const fid = fk.Reduce('invite_id').Visualize();
|
|
196
|
+
const ac = await mutate(R, '::a2a_messaging::add_contact', { invite: binv(R, fblob), name: 'leg3' });
|
|
197
|
+
await sleep(2500); // I's leg-2 aborts (unknown invite) → no real leg-3 → R's pend stays live
|
|
198
|
+
const rpk = ac.Reduce('resp_eph_pub');
|
|
199
|
+
return { fid, rpk };
|
|
200
|
+
}
|
|
201
|
+
// T8a unexpected-inviter: a DIFFERENT sender (F) → R aborts on the inviter-cid pin
|
|
202
|
+
{
|
|
203
|
+
const { fid, rpk } = await liveRedemption();
|
|
204
|
+
const rejx = R.rejects.length; const sR = st(R);
|
|
205
|
+
await mutate(F, '::actor::qa_send_complete', { target: R.cid, invite_id: fid, resp_eph_pub: rpk, mode: 'real', foreign_ad: binv(F, adBlob(F)) });
|
|
206
|
+
await sleep(3000);
|
|
207
|
+
ok(R.rejects.slice(rejx).some((x) => /unexpected inviter/i.test(x)), `leg-3 sender-pin: completion from a non-inviter aborts`);
|
|
208
|
+
ok(st(R).c === sR.c, `leg-3 unexpected-inviter registered nothing`);
|
|
209
|
+
}
|
|
210
|
+
// T8b cid-bind leg-3: real inviter (I) sends a FOREIGN AD → R aborts cid-bind
|
|
211
|
+
{
|
|
212
|
+
const { fid, rpk } = await liveRedemption();
|
|
213
|
+
const rejx = R.rejects.length; const sR = st(R);
|
|
214
|
+
await mutate(I, '::actor::qa_send_complete', { target: R.cid, invite_id: fid, resp_eph_pub: rpk, mode: 'foreign', foreign_ad: binv(I, adBlob(F)) });
|
|
215
|
+
await sleep(3000);
|
|
216
|
+
ok(R.rejects.slice(rejx).some((x) => /does not belong to the sender/.test(x)), `leg-3 cid-bind: inviter AD (cid≠sender) aborts`);
|
|
217
|
+
ok(st(R).c === sR.c, `leg-3 cid-bind registered nothing`);
|
|
218
|
+
}
|
|
219
|
+
// T8c PoP leg-3: real inviter (I) sends a FORGED AD (cid ok) → R aborts in process_address_document
|
|
220
|
+
{
|
|
221
|
+
const { fid, rpk } = await liveRedemption();
|
|
222
|
+
const rejx = R.rejects.length; const sR = st(R);
|
|
223
|
+
await mutate(I, '::actor::qa_send_complete', { target: R.cid, invite_id: fid, resp_eph_pub: rpk, mode: 'forged', foreign_ad: binv(I, adBlob(F)) });
|
|
224
|
+
await sleep(3000);
|
|
225
|
+
ok(R.rejects.slice(rejx).some((x) => /authoriz|signature|process|key/i.test(x)), `leg-3 PoP: forged inviter AD aborts in process_address_document`);
|
|
226
|
+
ok(st(R).c === sR.c, `leg-3 PoP registered nothing`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------- T9 export-secrecy ----------
|
|
230
|
+
CUR = 'T9 export-secrecy';
|
|
231
|
+
console.log('\n=== T9 export-secrecy ===');
|
|
232
|
+
await mutate(I, '::a2a_messaging::generate_invite', { name: 'P9' }); // ensure an outstanding invite (eph secret present)
|
|
233
|
+
const exp = ro(I, '::actor::qa_export_core', undefined).Reduce('core');
|
|
234
|
+
const expStr = exp.Serialize ? Buffer.from(exp.Serialize()).toString('latin1') : '';
|
|
235
|
+
const expVis = exp.Visualize();
|
|
236
|
+
ok(!/pending_invite_keys/.test(expVis), `export_core_state has NO pending_invite_keys field`);
|
|
237
|
+
ok(!/pending_redemption_keys/.test(expVis), `export_core_state has NO pending_redemption_keys field`);
|
|
238
|
+
// the exported pending_invites entry must carry only assigned/eph_pub/scheme (public) — not a secret field
|
|
239
|
+
ok(/eph_pub|\$eph_pub/.test(expVis) || /pending_invites/.test(expVis), `export carries the public pending_invites record (eph_pub/assigned/scheme only)`);
|
|
240
|
+
|
|
241
|
+
// ---------- T2 happy-role (chain verified BOTH legs) ----------
|
|
242
|
+
CUR = 'T2 happy-role';
|
|
243
|
+
console.log('\n=== T2 happy-role ===');
|
|
244
|
+
const rA = mk('rootA'); const lA = mk('roleA'); const rB = mk('rootB'); const lB = mk('roleB');
|
|
245
|
+
await mkPacket(rA, 'eph-t-rootA-1'); await mkPacket(lA, 'eph-t-roleA-1');
|
|
246
|
+
await mkPacket(rB, 'eph-t-rootB-1'); await mkPacket(lB, 'eph-t-roleB-1');
|
|
247
|
+
await sleep(1200);
|
|
248
|
+
await setName(rA, 'RootA'); await setName(lA, 'RoleA'); await setName(rB, 'RootB'); await setName(lB, 'RoleB');
|
|
249
|
+
await delegate(rA, lA, 'RoleA'); await delegate(rB, lB, 'RoleB');
|
|
250
|
+
log('delegations done');
|
|
251
|
+
const rm = await mutate(lA, '::a2a_messaging::generate_invite', { name: 'RoleB' });
|
|
252
|
+
const rblob = Buffer.from(rm.Reduce('invite').GetBinary());
|
|
253
|
+
await mutate(lB, '::a2a_messaging::add_contact', { invite: binv(lB, rblob), name: 'PeerRoleA' });
|
|
254
|
+
await sleep(5500);
|
|
255
|
+
const sA = st(lA); const sB = st(lB);
|
|
256
|
+
log(`roleA=${JSON.stringify(sA)} roleB=${JSON.stringify(sB)}`);
|
|
257
|
+
ok(sA.c >= 1 && sA.p >= 1, `role inviter registered role responder`);
|
|
258
|
+
ok(sB.c >= 1 && sB.p >= 1, `role responder registered role inviter (receiver-side)`);
|
|
259
|
+
ok(sA.cr >= 1, `leg-2: inviter pinned responder's root linkage (contact_roots=${sA.cr})`);
|
|
260
|
+
ok(sB.cr >= 1, `leg-3: responder pinned inviter's root linkage (contact_roots=${sB.cr})`);
|
|
261
|
+
|
|
262
|
+
// ---------- T10 import migration (state round-trips; pending_invites reset) ----------
|
|
263
|
+
CUR = 'T10 migration';
|
|
264
|
+
console.log('\n=== T10 import migration ===');
|
|
265
|
+
await mutate(I, '::a2a_messaging::generate_invite', { name: 'P10' }); // leave an outstanding invite
|
|
266
|
+
const exported = ro(I, '::actor::export_state', undefined);
|
|
267
|
+
fs.writeFileSync(resolve(UNIT_DIR, 'mig.bin'), Buffer.from(exported.Serialize()));
|
|
268
|
+
const I2 = mk('I2'); await mkPacket(I2, 'eph-t-I2-mig'); await sleep(1000);
|
|
269
|
+
const adData = I2.pw.packet.ParseValue(new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, 'mig.bin'))));
|
|
270
|
+
await mutate(I2, '::actor::import_state', adData);
|
|
271
|
+
const s10 = st(I2);
|
|
272
|
+
ok(s10.c >= 1 && s10.p >= 1, `import restored contacts + peer_ads (c=${s10.c},peer_ads=${s10.p})`);
|
|
273
|
+
ok(s10.pi === 0, `import reset pending_invites to empty (migration) → ${s10.pi}`);
|
|
274
|
+
ok(new RegExp(R.cid).test(lc(I2)), `imported state includes the original contact (responder)`);
|
|
275
|
+
// version-0 path: strip the stamp from a re-exported blob → still imports.
|
|
276
|
+
const exp0 = ro(I2, '::actor::export_state', undefined);
|
|
277
|
+
ok(exp0.Reduce('core').Reduce('format_version').Visualize() === '1', `re-export carries the stamp`);
|
|
278
|
+
|
|
279
|
+
// ---------- T11a format stamp + restore-state export hygiene ----------
|
|
280
|
+
CUR = 'T11a format-stamp';
|
|
281
|
+
console.log('\n=== T11a format stamp + export hygiene ===');
|
|
282
|
+
{
|
|
283
|
+
const core = ro(I, '::actor::qa_export_core', undefined).Reduce('core');
|
|
284
|
+
const vis = core.Visualize();
|
|
285
|
+
ok(/format_version/.test(vis), `export_core_state carries a format_version stamp`);
|
|
286
|
+
ok(core.Reduce('format_version').Visualize() === '1', `format_version == 1`);
|
|
287
|
+
ok(!/pending_restore_keys/.test(vis), `export has NO pending_restore_keys (INV-4)`);
|
|
288
|
+
ok(!/pending_restore_reply_keys/.test(vis), `export has NO pending_restore_reply_keys (INV-4)`);
|
|
289
|
+
ok(/deferred_msgs/.test(vis), `export carries deferred_msgs`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------- T11b degraded contact: defer + 3-leg restore + flush ----------
|
|
293
|
+
CUR = 'T11b restore';
|
|
294
|
+
console.log('\n=== T11b degraded-contact restore ===');
|
|
295
|
+
{
|
|
296
|
+
// I ↔ R are established contacts (T1). Simulate a breaking-change migration
|
|
297
|
+
// outcome on I: contacts survive, peer_ads dropped.
|
|
298
|
+
await mutate(I, '::actor::qa_strip_peer_ads', {});
|
|
299
|
+
const s0 = st(I);
|
|
300
|
+
ok(s0.c >= 1 && s0.p === 0, `strip: I keeps contacts (${s0.c}) with no peer_ads`);
|
|
301
|
+
|
|
302
|
+
// Deferred send: queues + fires leg 0; restore legs run; both sides notify.
|
|
303
|
+
const evI = I.events.length, evR = R.events.length;
|
|
304
|
+
const dm = await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'queued-while-degraded' });
|
|
305
|
+
ok(dm.Reduce('deferred').Visualize() === 'TRUE' || /true/i.test(dm.Reduce('deferred').Visualize()), `send to degraded contact reports deferred`);
|
|
306
|
+
const dg = ro(I, '::a2a_messaging::list_degraded_contacts', undefined);
|
|
307
|
+
ok(new RegExp(R.cid).test(dg.Visualize()), `list_degraded_contacts includes the degraded contact`);
|
|
308
|
+
ok(/queued/.test(dg.Visualize()) && /attempts/.test(dg.Visualize()), `list_degraded_contacts carries attempts+queued fields`);
|
|
309
|
+
const lq = ro(I, '::a2a_messaging::list_deferred_queues', undefined);
|
|
310
|
+
ok(new RegExp(R.cid).test(lq.Visualize()) && /degraded/.test(lq.Visualize()), `list_deferred_queues shows the non-empty queue with its degraded flag`);
|
|
311
|
+
await sleep(6000);
|
|
312
|
+
const s1 = st(I);
|
|
313
|
+
ok(s1.p >= 1, `restore re-established R's AD at I (peer_ads=${s1.p})`);
|
|
314
|
+
ok(s1.prs === 0, `restore consumed I's pending_restores`);
|
|
315
|
+
ok(st(R).rr === 0, `restore consumed R's reply record`);
|
|
316
|
+
ok(I.events.slice(evI).includes('contact_restored'), `I notified contact_restored`);
|
|
317
|
+
ok(R.events.slice(evR).includes('contact_restored'), `R notified contact_restored`);
|
|
318
|
+
|
|
319
|
+
// Host-driven flush: drain the deferred queue, message arrives at R.
|
|
320
|
+
const fl = await mutate(I, '::a2a_messaging::flush_deferred', { contact: R.cid });
|
|
321
|
+
ok(+fl.Reduce('flushed').Visualize() === 1, `flush_deferred drained 1 message`);
|
|
322
|
+
await sleep(2500);
|
|
323
|
+
ok(/queued-while-degraded/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()), `deferred message delivered after restore`);
|
|
324
|
+
ok(st(I).dq === 0, `deferred queue cleared`);
|
|
325
|
+
ok(!(new RegExp(R.cid)).test(ro(I, '::a2a_messaging::list_degraded_contacts', undefined).Visualize()), `contact no longer listed degraded after restore`);
|
|
326
|
+
ok(!(new RegExp(R.cid)).test(ro(I, '::a2a_messaging::list_deferred_queues', undefined).Visualize()), `deferred queue gone after flush`);
|
|
327
|
+
|
|
328
|
+
// Channel fully healthy again, both directions.
|
|
329
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.cid, text: 'post-restore-R-to-I' });
|
|
330
|
+
await sleep(2500);
|
|
331
|
+
ok(/post-restore-R-to-I/.test(ro(I, '::actor::list_incoming_messages', undefined).Visualize()), `R→I works after restore (one-sided loss: R replaced I's AD)`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------- T11c both-sides degraded + host sweep ----------
|
|
335
|
+
CUR = 'T11c both-degraded';
|
|
336
|
+
console.log('\n=== T11c both-sides degraded + sweep ===');
|
|
337
|
+
{
|
|
338
|
+
await mutate(I, '::actor::qa_strip_peer_ads', {});
|
|
339
|
+
await mutate(R, '::actor::qa_strip_peer_ads', {});
|
|
340
|
+
ok(st(I).p === 0 && st(R).p === 0, `both sides stripped`);
|
|
341
|
+
const sw = await mutate(I, '::a2a_messaging::restore_degraded_contacts', {});
|
|
342
|
+
ok(+sw.Reduce('requested').Visualize() >= 1, `sweep requested restore for I's degraded contacts`);
|
|
343
|
+
await mutate(R, '::a2a_messaging::restore_degraded_contacts', {});
|
|
344
|
+
await sleep(7000);
|
|
345
|
+
ok(st(I).p >= 1, `I restored (both-degraded, symmetric handshakes)`);
|
|
346
|
+
ok(st(R).p >= 1, `R restored (both-degraded)`);
|
|
347
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'after-double-restore' });
|
|
348
|
+
await sleep(2500);
|
|
349
|
+
ok(/after-double-restore/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()), `channel healthy after double restore`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- T11d restore gates: foreign requester + unsolicited response ----------
|
|
353
|
+
CUR = 'T11d restore-gates';
|
|
354
|
+
console.log('\n=== T11d restore gates ===');
|
|
355
|
+
{
|
|
356
|
+
// Foreign requester: F is NOT a contact of R → SILENT ignore (no reply, no
|
|
357
|
+
// reject, no reply-record).
|
|
358
|
+
const rrBefore = st(R).rr; const fRej = F.rejects.length;
|
|
359
|
+
await mutate(F, '::actor::qa_send_restore_request', { target: R.cid });
|
|
360
|
+
await sleep(3000);
|
|
361
|
+
ok(st(R).rr === rrBefore, `foreign restore request left NO reply record at R`);
|
|
362
|
+
ok(st(F).p === 0 || true, `foreign requester gained nothing`); // F never had R's AD
|
|
363
|
+
ok(!F.rejects.slice(fRej).some((x) => /restore/i.test(x)), `R sent NO error reply (silent ignore — knowledge does not leak)`);
|
|
364
|
+
|
|
365
|
+
// Unsolicited leg 1 at I (no pending_restores entry for F) → rejected, no state change.
|
|
366
|
+
const sI = st(I); const rejI = I.rejects.length;
|
|
367
|
+
await mutate(F, '::actor::qa_send_fake_restore_response', { target: I.cid });
|
|
368
|
+
await sleep(3000);
|
|
369
|
+
ok(I.rejects.slice(rejI).some((x) => /Unsolicited restore response/.test(x)), `unsolicited restore response rejected at I`);
|
|
370
|
+
const sI2 = st(I);
|
|
371
|
+
ok(sI2.c === sI.c && sI2.p === sI.p, `unsolicited response mutated nothing`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------- T11e restore adversarial: wrong-rid leg 1 + leg-0 non-destructiveness ----------
|
|
375
|
+
CUR = 'T11e restore-adversarial';
|
|
376
|
+
console.log('\n=== T11e restore adversarial (rid-mismatch + leg-0 non-destructive) ===');
|
|
377
|
+
{
|
|
378
|
+
// (a) rid-mismatch leg 1, seen by a non-contact (F): F fires a restore
|
|
379
|
+
// request at R (R silently ignores it since F is not R's contact, so F's
|
|
380
|
+
// own pending_restores[R.cid] entry stays live, unconsumed). R then answers
|
|
381
|
+
// F with a FRESH, unrelated rid via qa_send_fake_restore_response — F DOES
|
|
382
|
+
// have a live pending entry keyed by R.cid, so the "Unsolicited restore
|
|
383
|
+
// response" gate passes, but the rid the fake reply carries (freshly
|
|
384
|
+
// random-minted) can never match F's stored rid → deterministic
|
|
385
|
+
// rid-mismatch rejection that mutates nothing.
|
|
386
|
+
const fRejBefore = F.rejects.length;
|
|
387
|
+
await mutate(F, '::actor::qa_send_restore_request', { target: R.cid });
|
|
388
|
+
await sleep(3000);
|
|
389
|
+
const fMid = st(F);
|
|
390
|
+
ok(fMid.prs >= 1, `F has a live pending_restores entry toward R (silently ignored by R)`);
|
|
391
|
+
await mutate(R, '::actor::qa_send_fake_restore_response', { target: F.cid });
|
|
392
|
+
await sleep(3000);
|
|
393
|
+
ok(F.rejects.slice(fRejBefore).some((x) => /does not match the outstanding request/.test(x)), `wrong-rid leg 1 rejected at F ("does not match the outstanding request")`);
|
|
394
|
+
const fAfter = st(F);
|
|
395
|
+
ok(fAfter.p === fMid.p, `wrong-rid leg 1 installed no peer_ads at F (p=${fAfter.p})`);
|
|
396
|
+
ok(fAfter.prs === fMid.prs, `F's pending_restores entry survives the rejected reply unchanged (a failed gate consumes nothing, prs=${fAfter.prs})`);
|
|
397
|
+
|
|
398
|
+
// (b) leg 0 from a REAL, healthy contact (I, R — established via T11b) is
|
|
399
|
+
// non-destructive, and a repeat request REPLACES the outstanding reply
|
|
400
|
+
// record instead of accumulating it: fire two leg-0s from I at R
|
|
401
|
+
// back-to-back (no network round trip between them). I's own
|
|
402
|
+
// pending_restores[R.cid] is overwritten to the SECOND rid synchronously,
|
|
403
|
+
// long before either leg-1 reply can return over the loopback broker, so
|
|
404
|
+
// the first (now-stale) leg-1 deterministically rid-mismatches at I, and
|
|
405
|
+
// only the second request's leg 1/2 round-trip completes.
|
|
406
|
+
const rBefore = st(R); const iBefore = st(I); const iRejBefore = I.rejects.length;
|
|
407
|
+
await mutate(I, '::actor::qa_send_restore_request', { target: R.cid });
|
|
408
|
+
await mutate(I, '::actor::qa_send_restore_request', { target: R.cid });
|
|
409
|
+
await sleep(3000);
|
|
410
|
+
const rMid = st(R);
|
|
411
|
+
ok(rMid.c === rBefore.c && rMid.p === rBefore.p, `leg 0 (x2) from a known contact is non-destructive at R (c=${rMid.c},p=${rMid.p})`);
|
|
412
|
+
// rr is 1 while the surviving handshake is in flight and 0 once its leg 2
|
|
413
|
+
// lands — both are legal here depending on loopback timing. The
|
|
414
|
+
// accumulation property is that TWO requests never yield TWO records.
|
|
415
|
+
ok(rMid.rr <= 1, `second leg-0 REPLACED R's reply record, not accumulated (rr=${rMid.rr})`);
|
|
416
|
+
await sleep(5000);
|
|
417
|
+
const rAfter = st(R); const iAfter = st(I);
|
|
418
|
+
ok(rAfter.c === rBefore.c && rAfter.p === rBefore.p, `R's state still unchanged once the round trip completes (c=${rAfter.c},p=${rAfter.p})`);
|
|
419
|
+
ok(rAfter.rr === 0, `R's reply record consumed once the surviving (second) leg 1/2 round-trips (rr=${rAfter.rr})`);
|
|
420
|
+
ok(I.rejects.slice(iRejBefore).some((x) => /does not match the outstanding request/.test(x)), `the FIRST (superseded) leg-1 reply is rejected at I ("does not match the outstanding request")`);
|
|
421
|
+
ok(iAfter.c === iBefore.c && iAfter.p === iBefore.p, `I's state unchanged in count after the race (c=${iAfter.c},p=${iAfter.p})`);
|
|
422
|
+
|
|
423
|
+
// Channel still fully healthy after the double-restore race.
|
|
424
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'post-T11e-I-to-R' });
|
|
425
|
+
await sleep(2500);
|
|
426
|
+
ok(/post-T11e-I-to-R/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()), `channel healthy after the T11e restore race`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ================= V-series: versioned type registry (core 0.5.0) =========
|
|
430
|
+
// Cross-version leg-1 shapes against a 0.5.0 inviter (fresh packet pair so
|
|
431
|
+
// earlier scenarios' contacts cannot mask the registration outcomes).
|
|
432
|
+
const VI = mk('VI'); const VL = mk('VL');
|
|
433
|
+
await mkPacket(VI, 'eph-t-VI-04'); await mkPacket(VL, 'eph-t-VL-05');
|
|
434
|
+
await sleep(1200);
|
|
435
|
+
await setName(VI, 'VerInviter'); await setName(VL, 'VerLegacy');
|
|
436
|
+
const pvOf = (id, cid) => ro(id, '::actor::qa_contact_pv_of', { cid }).Reduce('pv').Visualize();
|
|
437
|
+
const capsOf = (id, cid) => ro(id, '::actor::qa_contact_pv_of', { cid }).Reduce('caps').Visualize();
|
|
438
|
+
const nameOfContact = (id, cid) => ro(id, '::actor::qa_contact_name', { cid }).Reduce('name').Visualize();
|
|
439
|
+
|
|
440
|
+
// ---------- V1 legacy v2 leg-1 (the 0.2.0 incident shape: no $name) ----------
|
|
441
|
+
CUR = 'V1 legacy-v2-leg1';
|
|
442
|
+
console.log('\n=== V1 legacy v2 leg-1 (no $name — THE incident shape) ===');
|
|
443
|
+
m = await mutate(VI, '::a2a_messaging::generate_invite', {}); // NO assigned name — the incident precondition
|
|
444
|
+
const blobV1 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
445
|
+
{
|
|
446
|
+
const before = st(VI); const rejB = VI.rejects.length; const legRejB = VL.rejects.length;
|
|
447
|
+
await mutate(VL, '::actor::qa_send_versioned_leg1', { invite: binv(VL, blobV1), shape: 'v2', name: '' });
|
|
448
|
+
await sleep(4000);
|
|
449
|
+
const after = st(VI);
|
|
450
|
+
ok(VI.rejects.length === rejB, `v2 leg-1: inviter did NOT abort (no EVAL_ERROR reject) — the incident is dead`);
|
|
451
|
+
ok(after.pi === before.pi - 1, `v2 leg-1: invite consumed (pi ${before.pi}→${after.pi})`);
|
|
452
|
+
ok(after.c === before.c + 1, `v2 leg-1: responder registered as contact (c ${before.c}→${after.c})`);
|
|
453
|
+
ok(nameOfContact(VI, VL.cid) === VL.cid, `v2 leg-1: no $name → contact named by sender cid (typed v2 branch)`);
|
|
454
|
+
ok(pvOf(VI, VL.cid) === '2', `v2 leg-1: contact_pv learned as 2 (shape-inferred)`);
|
|
455
|
+
ok(VL.rejects.slice(legRejB).some((x) => /Unsolicited completion|Redemption ephemeral key/.test(x)),
|
|
456
|
+
`v2 leg-1: inviter DID send leg-3 (arrived at the emulated legacy responder)`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------- V2 legacy v3 leg-1 ($name honored) ----------
|
|
460
|
+
CUR = 'V2 legacy-v3-leg1';
|
|
461
|
+
console.log('\n=== V2 legacy v3 leg-1 ($name, no $pv) ===');
|
|
462
|
+
m = await mutate(VI, '::a2a_messaging::generate_invite', {});
|
|
463
|
+
const blobV2 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
464
|
+
{
|
|
465
|
+
const rejB = VI.rejects.length;
|
|
466
|
+
await mutate(VL, '::actor::qa_send_versioned_leg1', { invite: binv(VL, blobV2), shape: 'v3', name: 'LegacyBob' });
|
|
467
|
+
await sleep(4000);
|
|
468
|
+
ok(VI.rejects.length === rejB, `v3 leg-1: no abort at inviter`);
|
|
469
|
+
ok(nameOfContact(VI, VL.cid) === 'LegacyBob', `v3 leg-1: $name honored (typed v3 branch)`);
|
|
470
|
+
ok(pvOf(VI, VL.cid) === '3', `v3 leg-1: contact_pv learned as 3 (shape-inferred)`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------- V3 v5 leg-1 ($pv stamped + $caps piggyback) ----------
|
|
474
|
+
CUR = 'V3 v5-leg1';
|
|
475
|
+
console.log('\n=== V3 v5 leg-1 ($pv + $caps) ===');
|
|
476
|
+
m = await mutate(VI, '::a2a_messaging::generate_invite', {});
|
|
477
|
+
const blobV3 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
478
|
+
{
|
|
479
|
+
const rejB = VI.rejects.length;
|
|
480
|
+
await mutate(VL, '::actor::qa_send_versioned_leg1', { invite: binv(VL, blobV3), shape: 'v5', name: 'NewCarol' });
|
|
481
|
+
await sleep(4000);
|
|
482
|
+
ok(VI.rejects.length === rejB, `v5 leg-1: no abort at inviter`);
|
|
483
|
+
ok(nameOfContact(VI, VL.cid) === 'NewCarol', `v5 leg-1: $name honored (typed v5 branch)`);
|
|
484
|
+
ok(pvOf(VI, VL.cid) === '5', `v5 leg-1: contact_pv learned as 5 ($pv stamped)`);
|
|
485
|
+
ok(/core\.notifications/.test(String(capsOf(VI, VL.cid))), `v5 leg-1: piggybacked $caps learned into contact_caps`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------- V4 Additions A+B: below-floor leg-1 → error-as-data to inviter ----------
|
|
489
|
+
CUR = 'V4 too-old-leg1 (Additions A/B)';
|
|
490
|
+
console.log('\n=== V4 below-floor leg-1 ($pv=1) → inviter error-as-data, invite NOT consumed ===');
|
|
491
|
+
m = await mutate(VI, '::a2a_messaging::generate_invite', {});
|
|
492
|
+
const blobV4 = Buffer.from(m.Reduce('invite').GetBinary());
|
|
493
|
+
{
|
|
494
|
+
const before = st(VI); const rejB = VI.rejects.length; const errB = VI.errEvents.length;
|
|
495
|
+
await mutate(VL, '::actor::qa_send_versioned_leg1', { invite: binv(VL, blobV4), shape: 'too_old', name: '' });
|
|
496
|
+
await sleep(4000);
|
|
497
|
+
const after = st(VI);
|
|
498
|
+
ok(VI.rejects.length === rejB, `A: inviter did NOT abort/rollback on a below-floor peer (no reject)`);
|
|
499
|
+
const errs = VI.errEvents.slice(errB);
|
|
500
|
+
ok(errs.length === 1, `A: exactly one $protocol_error event surfaced to the inviter's client`);
|
|
501
|
+
const e = errs[0] ?? {};
|
|
502
|
+
ok(e.code === 'peer_version_unsupported', `A: error-as-data code is peer_version_unsupported (got ${e.code})`);
|
|
503
|
+
ok(e.surface === 'sir' && e.peerVersion === '1' && e.minSupported === '2',
|
|
504
|
+
`A: typed error carries surface=sir peer_version=1 min_supported=2`);
|
|
505
|
+
ok(e.context === 'invite_redeem', `B: context marks the invite second phase (invite_redeem)`);
|
|
506
|
+
ok(/An invite you created was accepted by a peer running an unsupported \(too old\) protocol version/.test(e.message)
|
|
507
|
+
&& /update their client/.test(e.message),
|
|
508
|
+
`B: inviter-facing message is the clear human-readable wording`);
|
|
509
|
+
ok(after.pi === before.pi && after.c === before.c && after.p === before.p,
|
|
510
|
+
`B: NO state consumed (invite intact, no contact registered)`);
|
|
511
|
+
// The same invite redeems fine once the peer "updates" (proves not consumed).
|
|
512
|
+
await mutate(VL, '::actor::qa_send_versioned_leg1', { invite: binv(VL, blobV4), shape: 'v3', name: 'UpdatedPeer' });
|
|
513
|
+
await sleep(4000);
|
|
514
|
+
ok(st(VI).pi === before.pi - 1, `B: SAME invite redeems after the peer updates (invite was not consumed)`);
|
|
515
|
+
ok(nameOfContact(VI, VL.cid) === 'UpdatedPeer', `B: post-update redeem registered the contact normally`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// NOTE: V5 CAP-1 (the notify client's capability gate — notify_register denied
|
|
519
|
+
// on positive missing-cap evidence) exercises the a2a_notifications library and
|
|
520
|
+
// the alice/nsvc notify nodes, so it moved to notif.mjs with the N-series (this
|
|
521
|
+
// messaging-only unit no longer loads a2a_notifications).
|
|
522
|
+
|
|
523
|
+
// ---------- V6 $pv stamping on message traffic + passive learning ----------
|
|
524
|
+
// Uses the T1 pair (I↔R, MUTUAL contacts from the real 0.5.0 redeem): both
|
|
525
|
+
// legs of that redeem were the stamped v5 shapes, and message $targs carry
|
|
526
|
+
// $pv — so both sides must have learned dialect 5, and a fresh stamped
|
|
527
|
+
// message must deliver normally.
|
|
528
|
+
CUR = 'V6 pv-stamp';
|
|
529
|
+
console.log('\n=== V6 $pv stamp on send_message + passive learning at the receiver ===');
|
|
530
|
+
{
|
|
531
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'v5-stamped-msg' });
|
|
532
|
+
await sleep(2500);
|
|
533
|
+
ok(/v5-stamped-msg/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()),
|
|
534
|
+
`stamped $targ delivers normally (receiver tolerant of the added $pv)`);
|
|
535
|
+
ok(pvOf(R, I.cid) === '8', `responder learned contact_pv=8 (wire-8 leg-3 + stamped messages)`);
|
|
536
|
+
ok(pvOf(I, R.cid) === '8', `inviter learned contact_pv=8 (real current-build leg-1)`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------- V7 upgrade-later + monotonic learning (owner scenario) ----------
|
|
540
|
+
// A contact was v2 at invite time (V1 proves a real v2 leg-1 learns
|
|
541
|
+
// contact_pv=2; the emulated legacy responder has no reverse channel, so that
|
|
542
|
+
// v2-era state is ARRANGED here on the channel-connected T1 pair). Then the
|
|
543
|
+
// peer "upgrades": its first v5-STAMPED ordinary message must re-learn
|
|
544
|
+
// contact_pv 2→5 through handle_receive_message (ongoing learning, not
|
|
545
|
+
// invite-time-only). Finally a stale/replayed v2-SHAPE (unstamped) legacy
|
|
546
|
+
// message must NOT downgrade the learned pv nor clobber learned v5 caps.
|
|
547
|
+
CUR = 'V7 upgrade+monotonic';
|
|
548
|
+
console.log('\n=== V7 upgrade-later re-learn + monotonic (no downgrade by legacy traffic) ===');
|
|
549
|
+
{
|
|
550
|
+
await mutate(I, '::actor::qa_set_contact_pv', { cid: R.cid, pv: 2 });
|
|
551
|
+
await mutate(I, '::actor::qa_set_contact_caps', { cid: R.cid, caps: [] }); // v2-era: nothing advertised
|
|
552
|
+
ok(pvOf(I, R.cid) === '2', `arranged: contact recorded as v2-era (the state a real v2 leg-1 yields, per V1)`);
|
|
553
|
+
// The upgrade: the peer's next ORDINARY message is v5-stamped.
|
|
554
|
+
await mutate(R, '::a2a_messaging::send_message', { contact: I.cid, text: 'post-upgrade-hello' });
|
|
555
|
+
await sleep(2500);
|
|
556
|
+
ok(/post-upgrade-hello/.test(ro(I, '::actor::list_incoming_messages', undefined).Visualize()),
|
|
557
|
+
`post-upgrade stamped message delivered`);
|
|
558
|
+
ok(pvOf(I, R.cid) === '8', `UPGRADE: first stamped ordinary message re-learned contact_pv 2→8 (ongoing learning)`);
|
|
559
|
+
// Learned v5 caps (as the next bundle exchange would set), then stale legacy traffic.
|
|
560
|
+
await mutate(I, '::actor::qa_set_contact_caps', { cid: R.cid, caps: ['core.notifications'] });
|
|
561
|
+
await mutate(R, '::actor::qa_send_legacy_message', { target: I.cid, text: 'stale-legacy-msg' });
|
|
562
|
+
await sleep(2500);
|
|
563
|
+
ok(/stale-legacy-msg/.test(ro(I, '::actor::list_incoming_messages', undefined).Visualize()),
|
|
564
|
+
`legacy (pre-wire_id, unstamped) message still delivers`);
|
|
565
|
+
ok(pvOf(I, R.cid) === '8', `MONOTONIC: unstamped v2-shape message did NOT downgrade the learned pv`);
|
|
566
|
+
ok(/core\.notifications/.test(String(capsOf(I, R.cid))),
|
|
567
|
+
`MONOTONIC: learned v5 caps NOT clobbered by legacy traffic`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ================= RC-series: receipts (core 0.7.0, Rev 2 spec) ==========
|
|
571
|
+
// Uses the I/R mutual pair. Gate polarity: fail-CLOSED on unknown caps.
|
|
572
|
+
const rlog = (id) => ro(id, '::actor::qa_receipts_log', undefined).Visualize();
|
|
573
|
+
const rcount = (id) => (String(rlog(id)).match(/"kind"/g) || []).length;
|
|
574
|
+
|
|
575
|
+
CUR = 'RC1 receipts-off baseline';
|
|
576
|
+
console.log('\n=== RC1 receipts baseline (no caps → zero receipt traffic, zero failures) ===');
|
|
577
|
+
{
|
|
578
|
+
const rejI = I.rejects.length; const rejR = R.rejects.length; const c0 = rcount(I);
|
|
579
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'rc1-no-receipts' });
|
|
580
|
+
await sleep(2500);
|
|
581
|
+
ok(/rc1-no-receipts/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()), 'RC1: message delivered');
|
|
582
|
+
ok(rcount(I) === c0, 'RC1: NO receipt emitted (peer caps absent → gate closed, the new↔old cell)');
|
|
583
|
+
ok(I.rejects.length === rejI && R.rejects.length === rejR, 'RC1: zero failures either side');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
CUR = 'RC2 delivered receipt';
|
|
587
|
+
console.log('\n=== RC2 delivered receipt (both caps positive) ===');
|
|
588
|
+
let rcWid = '';
|
|
589
|
+
{
|
|
590
|
+
await mutate(R, '::actor::qa_init_caps', { advertise: ['core.receipts.emit', 'core.receipts.receive'] });
|
|
591
|
+
await mutate(R, '::actor::qa_set_contact_caps', { cid: I.cid, caps: ['core.receipts.receive'] });
|
|
592
|
+
const c0 = rcount(I); const rejI = I.rejects.length;
|
|
593
|
+
const sm = await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'rc2-confirm-me' });
|
|
594
|
+
rcWid = sm.Reduce('wire_id').Visualize();
|
|
595
|
+
await sleep(3000);
|
|
596
|
+
ok(rcount(I) === c0 + 1, 'RC2: exactly ONE delivered receipt arrived at the sender');
|
|
597
|
+
const lg = String(rlog(I)).replace(/\s/g, '');
|
|
598
|
+
ok(lg.includes('"kind"->"delivered"'), 'RC2: receipt kind is "delivered"');
|
|
599
|
+
ok(lg.includes(rcWid), 'RC2: receipt carries the message wire_id');
|
|
600
|
+
ok(I.rejects.length === rejI, 'RC2: receipt ingest clean (no rejects at sender)');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
CUR = 'RC3 expectation';
|
|
604
|
+
console.log('\n=== RC3 sender-side expectation (derived, unknown ≠ failed) ===');
|
|
605
|
+
{
|
|
606
|
+
await mutate(I, '::actor::qa_set_contact_caps', { cid: R.cid, caps: ['core.receipts.emit'] });
|
|
607
|
+
ok(ro(I, '::actor::qa_receipt_expectation', { cid: R.cid }).Reduce('state').Visualize() === 'expected',
|
|
608
|
+
'RC3: peer advertising emit → "expected"');
|
|
609
|
+
ok(ro(I, '::actor::qa_receipt_expectation', { cid: VL.cid }).Reduce('state').Visualize() === 'unknown',
|
|
610
|
+
'RC3: peer without emit cap → "unknown" (never failed)');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
CUR = 'RC4 read receipt';
|
|
614
|
+
console.log('\n=== RC4 read receipt (auto on the get/mark-read path) ===');
|
|
615
|
+
{
|
|
616
|
+
const c0 = rcount(I);
|
|
617
|
+
await mutate(R, '::actor::qa_mark_read', { contact: I.cid, wire_ids: [rcWid] });
|
|
618
|
+
await sleep(2500);
|
|
619
|
+
ok(rcount(I) === c0 + 1, 'RC4: read receipt arrived');
|
|
620
|
+
ok(String(rlog(I)).replace(/\s/g, '').includes('"kind"->"read"'), 'RC4: kind is "read"');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
CUR = 'RC5 file receipt';
|
|
624
|
+
console.log('\n=== RC5 file delivery receipt (shared wire_id namespace) ===');
|
|
625
|
+
{
|
|
626
|
+
const c0 = rcount(I);
|
|
627
|
+
const sf = await mutate(I, '::a2a_messaging::send_file',
|
|
628
|
+
{ contact: R.cid, filename: 'rc5.bin', mime: 'application/octet-stream', data: binv(I, Buffer.from('RC5')) });
|
|
629
|
+
const fwid = sf.Reduce('wire_id').Visualize();
|
|
630
|
+
await sleep(3000);
|
|
631
|
+
ok(rcount(I) === c0 + 1 && String(rlog(I)).includes(fwid), 'RC5: delivered receipt for the FILE wire_id');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
CUR = 'RC6 emit toggle off';
|
|
635
|
+
console.log('\n=== RC6 emit toggle off (dynamic user policy) ===');
|
|
636
|
+
{
|
|
637
|
+
await mutate(R, '::actor::qa_init_caps', { advertise: ['core.receipts.receive'] }); // emit dropped
|
|
638
|
+
const c0 = rcount(I);
|
|
639
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'rc6-silent' });
|
|
640
|
+
await sleep(2500);
|
|
641
|
+
ok(/rc6-silent/.test(ro(R, '::actor::list_incoming_messages', undefined).Visualize()), 'RC6: message still delivered');
|
|
642
|
+
ok(rcount(I) === c0, 'RC6: no receipt after the emitter toggled off');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
CUR = 'RC7 forward-compat + tolerance';
|
|
646
|
+
console.log('\n=== RC7 unknown kind + malformed shape are ignored (never load-bearing) ===');
|
|
647
|
+
{
|
|
648
|
+
const c0 = rcount(I); const rejI = I.rejects.length;
|
|
649
|
+
await mutate(R, '::actor::qa_send_raw_receipt', { target: I.cid, kind: 'typing', wire_ids: [rcWid] });
|
|
650
|
+
await sleep(2000);
|
|
651
|
+
await mutate(R, '::actor::qa_send_raw_receipt', { target: I.cid, kind: 42, wire_ids: [rcWid] });
|
|
652
|
+
await sleep(2000);
|
|
653
|
+
await mutate(R, '::actor::qa_send_raw_receipt', { target: I.cid, kind: 'delivered', wire_ids: 'not-a-list' });
|
|
654
|
+
await sleep(2000);
|
|
655
|
+
ok(rcount(I) === c0, 'RC7: unknown kind / mistyped kind / scalar wire_ids — nothing reached the hook');
|
|
656
|
+
ok(I.rejects.length === rejI, 'RC7: and nothing aborted (ignore-success, forward-compat)');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
CUR = 'RC8 stale-caps self-heal';
|
|
660
|
+
console.log('\n=== RC8 hybrid gate: stale caps + learned pv>=7 → receipts fire (the upgrade fix) ===');
|
|
661
|
+
{
|
|
662
|
+
await mutate(R, '::actor::qa_init_caps', { advertise: ['core.receipts.emit', 'core.receipts.receive'] });
|
|
663
|
+
await mutate(R, '::actor::qa_set_contact_caps', { cid: I.cid, caps: ['core.notifications'] }); // stale pre-receipts caps (no opinion)
|
|
664
|
+
await mutate(R, '::actor::qa_set_contact_pv', { cid: I.cid, pv: 7 });
|
|
665
|
+
const c0 = rcount(I);
|
|
666
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'rc8-selfheal' });
|
|
667
|
+
await sleep(3000);
|
|
668
|
+
ok(rcount(I) === c0 + 1, 'RC8: delivered receipt fired despite stale caps (pv>=7 implied receive)');
|
|
669
|
+
await mutate(I, '::actor::qa_set_contact_caps', { cid: R.cid, caps: [] });
|
|
670
|
+
await mutate(I, '::actor::qa_set_contact_pv', { cid: R.cid, pv: 7 });
|
|
671
|
+
ok(ro(I, '::actor::qa_receipt_expectation', { cid: R.cid }).Reduce('state').Visualize() === 'expected',
|
|
672
|
+
'RC8: expectation = expected via pv>=7 (caps silent)');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
CUR = 'RC9 explicit opt-out';
|
|
676
|
+
console.log('\n=== RC9 hybrid gate: caps WITH receipts opinion but no receive → strict opt-out ===');
|
|
677
|
+
{
|
|
678
|
+
await mutate(R, '::actor::qa_set_contact_caps', { cid: I.cid, caps: ['core.receipts.emit'] }); // opinion, no receive
|
|
679
|
+
await mutate(R, '::actor::qa_set_contact_pv', { cid: I.cid, pv: 7 });
|
|
680
|
+
const c0 = rcount(I);
|
|
681
|
+
await mutate(I, '::a2a_messaging::send_message', { contact: R.cid, text: 'rc9-optout' });
|
|
682
|
+
await sleep(2500);
|
|
683
|
+
ok(rcount(I) === c0, 'RC9: NO receipt — explicit caps opinion without receive wins over pv (opt-out respected)');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
CUR = 'RC10 old peer stays silent';
|
|
687
|
+
console.log('\n=== RC10 hybrid gate: no caps opinion + pv 5 (old peer) → silent ===');
|
|
688
|
+
{
|
|
689
|
+
await mutate(R, '::actor::qa_set_contact_caps', { cid: I.cid, caps: [] });
|
|
690
|
+
const c0 = rcount(I);
|
|
691
|
+
// the OLD peer's message must itself carry the old dialect: the receiver
|
|
692
|
+
// learns pv from THIS message before gating (that's the self-heal), so a
|
|
693
|
+
// current-build send_message (stamps 7) cannot emulate a pv-5 peer.
|
|
694
|
+
await mutate(I, '::actor::qa_send_stamped_message', { target: R.cid, text: 'rc10-oldpeer', pv: 5, wire_id: 'rc10-w1' });
|
|
695
|
+
await sleep(2500);
|
|
696
|
+
ok(rcount(I) === c0, 'RC10: NO receipt toward a pv-5 peer (old clients stay silent, zero noise)');
|
|
697
|
+
await mutate(I, '::actor::qa_set_contact_caps', { cid: R.cid, caps: [] });
|
|
698
|
+
await mutate(I, '::actor::qa_set_contact_pv', { cid: R.cid, pv: 5 });
|
|
699
|
+
ok(ro(I, '::actor::qa_receipt_expectation', { cid: R.cid }).Reduce('state').Visualize() === 'unknown',
|
|
700
|
+
'RC10: expectation = unknown toward a pv-5 peer');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---------- CN contact naming after an AD introduction ----------
|
|
704
|
+
CUR = 'CN contact naming';
|
|
705
|
+
console.log('\n=== CN AD introduction adopts only a CID fallback name ===');
|
|
706
|
+
{
|
|
707
|
+
const N = mk('N'); const C = mk('C'); const P = mk('P');
|
|
708
|
+
await mkPacket(N, 'name-node-01');
|
|
709
|
+
await mkPacket(C, 'name-controller-02');
|
|
710
|
+
await mkPacket(P, 'name-peer-03');
|
|
711
|
+
await sleep(1200);
|
|
712
|
+
await setName(N, 'Node');
|
|
713
|
+
await setName(C, 'ControllerSelf');
|
|
714
|
+
await setName(P, 'PeerReal');
|
|
715
|
+
|
|
716
|
+
const ni = await mutate(N, '::a2a_messaging::generate_invite', { name: 'ControllerAlias' });
|
|
717
|
+
await mutate(C, '::a2a_messaging::add_contact',
|
|
718
|
+
{ invite: binv(C, Buffer.from(ni.Reduce('invite').GetBinary())), name: 'NodeAlias' });
|
|
719
|
+
await sleep(5000);
|
|
720
|
+
await mutate(N, '::actor::qa_init_connect', {});
|
|
721
|
+
await mutate(N, '::a2a_messaging::set_proxy_pending', { code: '123456', proxy: C.cid });
|
|
722
|
+
await mutate(N, '::a2a_messaging::verify_proxy_code', { code: '123456', sender: C.cid });
|
|
723
|
+
|
|
724
|
+
const pAd = adBlob(P);
|
|
725
|
+
await mutate(C, '::actor::qa_send_ingest_descriptor',
|
|
726
|
+
{ target: N.cid, peer_ad: binv(C, pAd), peer_name: '' });
|
|
727
|
+
await sleep(1800);
|
|
728
|
+
ok(new RegExp(`\"name\"->\"${P.cid}\"`).test(lc(N)),
|
|
729
|
+
'CN1: nameless introduction initially uses the peer CID fallback');
|
|
730
|
+
|
|
731
|
+
await mutate(C, '::actor::qa_send_ingest_descriptor',
|
|
732
|
+
{ target: N.cid, peer_ad: binv(C, pAd), peer_name: 'PeerReal' });
|
|
733
|
+
await sleep(1800);
|
|
734
|
+
ok(/"name"->"PeerReal"/.test(lc(N)),
|
|
735
|
+
'CN2: later AD introduction replaces the CID fallback with the real peer name');
|
|
736
|
+
|
|
737
|
+
await mutate(C, '::actor::qa_send_ingest_descriptor',
|
|
738
|
+
{ target: N.cid, peer_ad: binv(C, adBlob(C)), peer_name: 'ControllerSelf' });
|
|
739
|
+
await sleep(1800);
|
|
740
|
+
ok(/"name"->"ControllerAlias"/.test(lc(N)) && !/"name"->"ControllerSelf"/.test(lc(N)),
|
|
741
|
+
'CN3: an explicit invite name is preserved on re-introduction');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
console.log('\n================ SCORECARD ================');
|
|
745
|
+
if (scorecard.length === 0) console.log('ALL TESTS PASSED');
|
|
746
|
+
else { console.log(`${scorecard.length} FAILURE(S):`); scorecard.forEach((s) => console.log(' ' + s)); }
|
|
747
|
+
await sleep(500);
|
|
748
|
+
process.exit(scorecard.length === 0 ? 0 : 1);
|
|
749
|
+
}
|
|
750
|
+
main().catch((e) => { log('DRIVER ERR:', e.stack ?? e.message); process.exit(1); });
|