@ours.network/notifications 0.1.1 → 0.3.0

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.
Files changed (116) hide show
  1. package/README.md +122 -225
  2. package/dist/chat-target.d.ts +9 -0
  3. package/dist/chat-target.js +12 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +13 -445
  6. package/dist/presence.d.ts +12 -0
  7. package/dist/presence.js +55 -145
  8. package/dist/server.d.ts +26 -0
  9. package/dist/server.js +145 -0
  10. package/dist/store.d.ts +63 -0
  11. package/dist/store.js +181 -0
  12. package/dist/validation.d.ts +9 -0
  13. package/dist/validation.js +60 -0
  14. package/package.json +14 -24
  15. package/dist/config.js +0 -104
  16. package/dist/daemon.js +0 -353
  17. package/dist/http.js +0 -31
  18. package/dist/log-store.js +0 -48
  19. package/dist/provision.js +0 -29
  20. package/dist/push.js +0 -6
  21. package/dist/web-push-sender.js +0 -41
  22. package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
  23. package/mufl_code/config.mufl +0 -21
  24. package/mufl_code/core/.github/FUNDING.yml +0 -3
  25. package/mufl_code/core/.github/workflows/docs.yml +0 -39
  26. package/mufl_code/core/.github/workflows/tests.yml +0 -66
  27. package/mufl_code/core/COMMERCIAL-LICENCE.md +0 -25
  28. package/mufl_code/core/COMPATIBILITY.md +0 -221
  29. package/mufl_code/core/CONTRIBUTING.md +0 -37
  30. package/mufl_code/core/LICENSE +0 -98
  31. package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +0 -290
  32. package/mufl_code/core/README.md +0 -91
  33. package/mufl_code/core/SECURITY.md +0 -40
  34. package/mufl_code/core/TRADEMARKS.md +0 -24
  35. package/mufl_code/core/a2a_capabilities.mm +0 -449
  36. package/mufl_code/core/a2a_cluster.mm +0 -601
  37. package/mufl_code/core/a2a_control.mm +0 -90
  38. package/mufl_code/core/a2a_messaging.mm +0 -4817
  39. package/mufl_code/core/a2a_monitoring.mm +0 -94
  40. package/mufl_code/core/a2a_notification_integration.mm +0 -144
  41. package/mufl_code/core/a2a_notifications.mm +0 -1127
  42. package/mufl_code/core/a2a_protocol.mm +0 -246
  43. package/mufl_code/core/a2a_versions.mm +0 -801
  44. package/mufl_code/core/config.mufl +0 -29
  45. package/mufl_code/core/docs/.vitepress/config.mts +0 -58
  46. package/mufl_code/core/docs/citations.json +0 -688
  47. package/mufl_code/core/docs/guide/01-vendor-the-core.md +0 -50
  48. package/mufl_code/core/docs/guide/02-configure-and-compile.md +0 -100
  49. package/mufl_code/core/docs/guide/03-wire-the-host.md +0 -154
  50. package/mufl_code/core/docs/guide/04-connect-and-message.md +0 -156
  51. package/mufl_code/core/docs/guide/05-test-your-app.md +0 -75
  52. package/mufl_code/core/docs/guide/index.md +0 -63
  53. package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +0 -77
  54. package/mufl_code/core/docs/how-it-works/cluster.md +0 -65
  55. package/mufl_code/core/docs/how-it-works/identity.md +0 -56
  56. package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +0 -76
  57. package/mufl_code/core/docs/how-it-works/messaging.md +0 -55
  58. package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +0 -72
  59. package/mufl_code/core/docs/how-it-works/overview.md +0 -45
  60. package/mufl_code/core/docs/how-it-works/versioning.md +0 -116
  61. package/mufl_code/core/docs/index.md +0 -27
  62. package/mufl_code/core/docs/package-lock.json +0 -3467
  63. package/mufl_code/core/docs/package.json +0 -15
  64. package/mufl_code/core/docs/reference/contributing.md +0 -26
  65. package/mufl_code/core/docs/reference/glossary.md +0 -108
  66. package/mufl_code/core/docs/reference/implementations.md +0 -23
  67. package/mufl_code/core/docs/reference/modules.md +0 -19
  68. package/mufl_code/core/docs/scripts/check-citations.mjs +0 -36
  69. package/mufl_code/core/docs/scripts/check-denylist.mjs +0 -30
  70. package/mufl_code/core/docs/scripts/gen-llms.mjs +0 -32
  71. package/mufl_code/core/docs/workflows/cluster.md +0 -89
  72. package/mufl_code/core/docs/workflows/contact-exchange.md +0 -60
  73. package/mufl_code/core/docs/workflows/contact-restore.md +0 -60
  74. package/mufl_code/core/docs/workflows/control-verbs.md +0 -70
  75. package/mufl_code/core/docs/workflows/index.md +0 -57
  76. package/mufl_code/core/docs/workflows/introductions.md +0 -60
  77. package/mufl_code/core/docs/workflows/messaging.md +0 -75
  78. package/mufl_code/core/docs/workflows/monitoring.md +0 -84
  79. package/mufl_code/core/docs/workflows/notifications.md +0 -109
  80. package/mufl_code/core/release-notes/0.1.md +0 -60
  81. package/mufl_code/core/release-notes/0.3.md +0 -22
  82. package/mufl_code/core/release-notes/0.4.md +0 -97
  83. package/mufl_code/core/tests/README.md +0 -83
  84. package/mufl_code/core/tests/compat/README.md +0 -82
  85. package/mufl_code/core/tests/compat/compat.mjs +0 -245
  86. package/mufl_code/core/tests/compat/compat_peer.mjs +0 -146
  87. package/mufl_code/core/tests/compat/run_compat.sh +0 -96
  88. package/mufl_code/core/tests/corpus.mjs +0 -139
  89. package/mufl_code/core/tests/corpus_actor.mu +0 -242
  90. package/mufl_code/core/tests/fatcompile_actor.mu +0 -52
  91. package/mufl_code/core/tests/mig.mjs +0 -805
  92. package/mufl_code/core/tests/mig_actor.mu +0 -518
  93. package/mufl_code/core/tests/migapp.mjs +0 -431
  94. package/mufl_code/core/tests/migapp_actor.mu +0 -294
  95. package/mufl_code/core/tests/mufl_semantics/run.sh +0 -69
  96. package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +0 -47
  97. package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +0 -11
  98. package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +0 -30
  99. package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +0 -19
  100. package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +0 -19
  101. package/mufl_code/core/tests/notif.mjs +0 -755
  102. package/mufl_code/core/tests/notif_actor.mu +0 -450
  103. package/mufl_code/core/tests/protocol_container.mm +0 -18
  104. package/mufl_code/core/tests/run.sh +0 -65
  105. package/mufl_code/core/tests/run_corpus.sh +0 -51
  106. package/mufl_code/core/tests/run_fatcompile.sh +0 -40
  107. package/mufl_code/core/tests/run_mig.sh +0 -51
  108. package/mufl_code/core/tests/run_migapp.sh +0 -51
  109. package/mufl_code/core/tests/run_notif.sh +0 -57
  110. package/mufl_code/core/tests/test.mjs +0 -750
  111. package/mufl_code/core/tests/test_actor.mu +0 -564
  112. package/mufl_code/core/tests/test_common.mjs +0 -93
  113. package/mufl_code/core/version.mm +0 -24
  114. package/mufl_code/packet.mu +0 -170
  115. package/mufl_code/protocol_container.mm +0 -18
  116. package/scripts/gen-vapid.mjs +0 -16
@@ -1,755 +0,0 @@
1
- #!/usr/bin/env node
2
- // a2a_notifications N-series loopback suite (+ the V5 CAP-1 notify-client gate).
3
- //
4
- // SPLIT from test.mjs: the notification tests run against their OWN compiled unit
5
- // (notif_actor.mu, which loads BOTH a2a_messaging AND a2a_notifications) because
6
- // test_actor.mu had to drop a2a_notifications to stay under the per-unit
7
- // meta-reduction fuel ceiling (see notif_actor.mu header). The shared wrapper /
8
- // packet bootstrap + mk/wire/mutate/ro/binv/mkPacket/sleep/log helpers live in
9
- // ./test_common.mjs (imported here and by test.mjs — one copy).
10
- //
11
- // This unit is the ONLY one that loads both libraries, so the COMBINED core+notify
12
- // export/import round-trip (N7/N16 — "both halves coexist in one blob and both
13
- // restore") lives here.
14
- import { resolve } from 'node:path';
15
- import * as fs from 'node:fs';
16
- import { createHarness, mk } from './test_common.mjs';
17
-
18
- const BROKER_URL = process.env.BROKER_URL || 'ws://127.0.0.1:9790';
19
- const UNIT_DIR = resolve('.');
20
- const scorecard = [];
21
- let CUR = '';
22
- const ok = (c, m) => { if (!c) { scorecard.push(`✗ [${CUR}] ${m}`); console.log(` ✗ FAIL: ${m}`); } else { console.log(` ✓ ${m}`); } };
23
- const isT = (s) => /true/i.test(String(s));
24
-
25
- let wrapper, mutate, ro, binv, mkPacket, sleep, log;
26
-
27
- async function main() {
28
- const h = await createHarness({ brokerUrl: BROKER_URL, unitDir: UNIT_DIR, logTag: 'n' });
29
- ({ wrapper, mutate, ro, binv, mkPacket, sleep, log } = h);
30
- const setName = (id, n) => mutate(id, '::a2a_messaging::set_my_name', { name: n });
31
-
32
- // ================= a2a_notifications scenarios (N-series) =================
33
- const nst = (id) => ro(id, '::actor::qa_notify_state', undefined).Visualize();
34
- const nst2 = (id) => ro(id, '::actor::qa_notify_state_v2', undefined).Visualize();
35
-
36
- // ---------- N1 register + confirm round-trip ----------
37
- CUR = 'N1-register';
38
- console.log('\n=== N1 register+confirm ===');
39
- const nsvc = mk('nsvc'); const alice = mk('alice');
40
- await mkPacket(nsvc, 'seed-nsvc'); await mkPacket(alice, 'seed-alice'); await sleep(1000);
41
- await setName(nsvc, 'NotifyService'); await setName(alice, 'Alice');
42
- ok(/registrations/.test(nst(nsvc)), `notify state probe answers with empty stores`);
43
- // alice must be a contact of the service (normal invite machinery).
44
- {
45
- const im = await mutate(nsvc, '::a2a_messaging::generate_invite', { name: 'Alice' });
46
- const iblob = Buffer.from(im.Reduce('invite').GetBinary());
47
- await mutate(alice, '::a2a_messaging::add_contact', { invite: binv(alice, iblob), name: 'svc' });
48
- await sleep(5000);
49
- }
50
- await mutate(nsvc, '::a2a_notifications::set_vapid_public_key', { key: 'VAPID_PUB_TEST' });
51
- await mutate(alice, '::a2a_notifications::notify_register', { service: 'svc', bindings: null });
52
- await sleep(2500); // register -> confirm round-trip over the broker
53
- const n1s = nst(nsvc);
54
- ok(/recipient_cid/.test(n1s), `service stores a registration`);
55
- ok(new RegExp(alice.cid).test(n1s), `service registration is keyed to alice's cid`);
56
- // Per-contact-only model: registration mints NOTHING — no token exists until
57
- // issue_tokens is called.
58
- ok(ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('token_index').Visualize().replace(/[(),\s]/g, '') === '',
59
- `register mints no token — notify_token_index empty after registration`);
60
- const n1a = nst(alice);
61
- ok(/VAPID_PUB_TEST/.test(n1a), `client registration holds the vapid pub`);
62
- ok(!/TRUE/.test(ro(alice, '::actor::qa_notify_state', undefined).Reduce('pending').Visualize()), `pending cleared on confirm`);
63
- ok(/regconfirm/.test(n1a) && new RegExp(nsvc.cid).test(n1a), `on_notify_registration hook fired with the service cid`);
64
- // bindings management (replace-all + re-confirm):
65
- await mutate(alice, '::a2a_notifications::notify_update_bindings', { service: 'svc',
66
- bindings: [{ version: 1, binding_id: 'b1', endpoint: 'https://push.example/x',
67
- p256dh: 'PK', auth: 'AS' }] });
68
- await sleep(2500);
69
- ok(/push\.example/.test(nst(nsvc)), `service registration carries the replaced bindings`);
70
- ok(/push\.example/.test(nst(alice)), `client copy re-confirmed with the bindings`);
71
-
72
- // ---------- N2 post happy path (bare signed send from a NON-contact) ----------
73
- CUR = 'N2-post';
74
- console.log('\n=== N2 post happy path ===');
75
- const nbob = mk('nbob'); await mkPacket(nbob, 'seed-nbob'); await sleep(1000); // NEVER a contact of nsvc
76
- // Per-contact only: alice asks the service to mint nbob's scoped token first,
77
- // then exports nbob's handout from her contact-token mirror (nbob is not a
78
- // contact of alice, so the qa probe stands in for export_notify_address).
79
- await mutate(alice, '::a2a_notifications::notify_issue_tokens', { service: 'svc', contacts: [nbob.cid] });
80
- await sleep(2500);
81
- const naddr = Buffer.from(ro(alice, '::actor::qa_export_contact_notify_address',
82
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary());
83
- await mutate(nbob, '::a2a_notifications::send_notification',
84
- { address: binv(nbob, naddr), payload: 'hello, wake up' });
85
- await sleep(2500);
86
- const n2s = nst(nsvc);
87
- ok(/hello, wake up/.test(n2s), `service hook received the payload`);
88
- ok(new RegExp(nbob.cid).test(n2s), `sender_cid recorded from the signed envelope`);
89
- ok(/push\.example/.test(n2s.split('notif_log')[1] || ''), `hook received the recipient's current bindings`);
90
-
91
- // ---------- NI1 core-owned handout + post-send middleware ----------
92
- CUR = 'NI1-core-integration';
93
- console.log('\n=== NI1 core-owned notification integration ===');
94
- // Alice and Bob become ordinary messaging contacts. Alice's scoped handout
95
- // above is already bound to (recipient=Alice, scope=Bob).
96
- {
97
- const im = await mutate(alice, '::a2a_messaging::generate_invite', { name: 'Bob' });
98
- const iblob = Buffer.from(im.Reduce('invite').GetBinary());
99
- await mutate(nbob, '::a2a_messaging::add_contact', { invite: binv(nbob, iblob), name: 'alice' });
100
- await sleep(5000);
101
- }
102
- await mutate(alice, '::a2a_notification_integration::send_notify_address',
103
- { service: 'svc', contact: nbob.cid });
104
- await sleep(2000);
105
- ok(new RegExp(alice.cid).test(ro(nbob, '::actor::qa_notify_addr_store', undefined).Visualize()),
106
- `new library route validates and stores Alice's handout on Bob`);
107
-
108
- const ni1Before = nst(nsvc);
109
- const ni1Sent = await mutate(nbob, '::a2a_messaging::send_message',
110
- { contact: alice.cid, text: 'core-notify-message' });
111
- const ni1Wire = ni1Sent.Reduce('wire_id').Visualize();
112
- await sleep(3000);
113
- const ni1After = nst(nsvc);
114
- ok(/core-notify-message/.test(ro(alice, '::actor::list_incoming_messages', undefined).Visualize()),
115
- `ordinary message still reaches Alice`);
116
- ok(ni1After !== ni1Before && ni1After.includes(ni1Wire),
117
- `post-send middleware posts a notification carrying the actual message wire_id`);
118
- ok(ni1After.includes(nbob.cid) && /kind.*message/.test(ni1After),
119
- `notification payload carries sender metadata and kind=message`);
120
-
121
- // A NIL handout is the mute/delete signal. It must suppress only the wake,
122
- // never the accepted message.
123
- await mutate(alice, '::a2a_notification_integration::delete_notify_address',
124
- { contact: nbob.cid });
125
- await sleep(1500);
126
- const ni1MutedBefore = nst(nsvc);
127
- await mutate(nbob, '::a2a_messaging::send_message',
128
- { contact: alice.cid, text: 'muted-but-delivered' });
129
- await sleep(3000);
130
- ok(/muted-but-delivered/.test(ro(alice, '::actor::list_incoming_messages', undefined).Visualize()),
131
- `address deletion does not affect message delivery`);
132
- ok(nst(nsvc) === ni1MutedBefore, `address deletion suppresses the notification post`);
133
-
134
- // Restore through the deployed ::actor::* compatibility shim and prove the
135
- // same core-owned state/middleware path resumes.
136
- await mutate(alice, '::actor::qa_send_notify_address',
137
- { target: nbob.cid, address: binv(alice, naddr), legacy: true });
138
- await sleep(1500);
139
- const ni1LegacyBefore = nst(nsvc);
140
- const ni1LegacySent = await mutate(nbob, '::a2a_messaging::send_message',
141
- { contact: alice.cid, text: 'legacy-handout-message' });
142
- const ni1LegacyWire = ni1LegacySent.Reduce('wire_id').Visualize();
143
- await sleep(3000);
144
- ok(nst(nsvc) !== ni1LegacyBefore && nst(nsvc).includes(ni1LegacyWire),
145
- `legacy ::actor::receive_notify_address shim feeds the new middleware`);
146
- const ni1Export = ro(nbob, '::actor::export_state', undefined).Visualize();
147
- ok(/notify_integration/.test(ni1Export) && new RegExp(alice.cid).test(ni1Export),
148
- `notification handout is included in the composed actor export`);
149
-
150
- // ---------- N3 token rejection matrix + E9 (zero state change each) ----------
151
- CUR = 'N3-rejections';
152
- console.log('\n=== N3 rejection matrix ===');
153
- for (const mode of ['flip_recipient', 'fake_token_id', 'foreign_service', 'flip_scope']) {
154
- const before = nst(nsvc);
155
- await mutate(nbob, '::actor::qa_post_tampered', { address: binv(nbob, naddr), mode });
156
- await sleep(2000);
157
- ok(nst(nsvc) === before, `tampered post (${mode}) aborts with zero state change`);
158
- }
159
- // E9: eve IS a contact of alice but neither pending nor her registered service.
160
- const neve = mk('neve'); await mkPacket(neve, 'seed-neve'); await sleep(1000);
161
- {
162
- const im = await mutate(alice, '::a2a_messaging::generate_invite', { name: 'Eve' });
163
- const iblob = Buffer.from(im.Reduce('invite').GetBinary());
164
- await mutate(neve, '::a2a_messaging::add_contact', { invite: binv(neve, iblob), name: 'alice-target' });
165
- await sleep(5000);
166
- }
167
- const neveRejectsBeforeBinding = neve.rejects.length;
168
- await mutate(alice, '::actor::qa_send_notify_address',
169
- { target: neve.cid, address: binv(alice, naddr), legacy: false });
170
- await sleep(2000);
171
- ok(neve.rejects.slice(neveRejectsBeforeBinding).some((x) => /scoped to this sender/.test(x)) &&
172
- !new RegExp(alice.cid).test(ro(neve, '::actor::qa_notify_addr_store', undefined).Visualize()),
173
- `handout scoped to Bob is rejected when Alice sends it to Eve`);
174
- const aBefore = nst(alice);
175
- const aRejx = alice.rejects.length;
176
- await mutate(neve, '::actor::qa_send_fake_confirm', { target: alice.cid });
177
- await sleep(2500);
178
- ok(nst(alice) === aBefore && !/EVIL_VAPID/.test(nst(alice)), `unsolicited confirm_registration plants nothing (E9)`);
179
- ok(alice.rejects.slice(aRejx).some((x) => /[Uu]nsolicited/.test(x)), `unsolicited confirm was rejected with the E9 abort`);
180
-
181
- // ---------- N8 oversize payload (sender side + service side) ----------
182
- CUR = 'N8-oversize';
183
- console.log('\n=== N8 oversize payload ===');
184
- const n8Before = nst(nsvc);
185
- const senderErr = await mutate(nbob, '::a2a_notifications::send_notification',
186
- { address: binv(nbob, naddr), payload: 'x'.repeat(4001) }).then(() => null, (e) => e.message);
187
- ok(senderErr !== null && /exceeds/.test(senderErr), `sender-side oversize payload aborts locally (${(senderErr || '').split('\n')[0]})`);
188
- await mutate(nbob, '::actor::qa_post_tampered', { address: binv(nbob, naddr), mode: 'oversize' });
189
- await sleep(2000);
190
- ok(nst(nsvc) === n8Before, `service-side oversize post aborts with zero state change`);
191
-
192
- // ---------- N4 rotate-all (old handout dies atomically, new one works) ----------
193
- CUR = 'N4-rotate';
194
- console.log('\n=== N4 rotate ===');
195
- const addr1 = naddr; // pre-rotation handout (nbob's scoped token)
196
- await mutate(alice, '::a2a_notifications::notify_rotate_token', { service: 'svc' });
197
- await sleep(2500);
198
- const addr2 = Buffer.from(ro(alice, '::actor::qa_export_contact_notify_address',
199
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary());
200
- ok(!addr1.equals(addr2), `rotation minted a different token (handout bytes differ)`);
201
- const n4Before = nst(nsvc);
202
- await mutate(nbob, '::a2a_notifications::send_notification',
203
- { address: binv(nbob, addr1), payload: 'stale-handout-ping' });
204
- await sleep(2000);
205
- ok(nst(nsvc) === n4Before && !/stale-handout-ping/.test(nst(nsvc)), `old handout is rejected after rotation (E4)`);
206
- await mutate(nbob, '::a2a_notifications::send_notification',
207
- { address: binv(nbob, addr2), payload: 'post-rotate-ping' });
208
- await sleep(2000);
209
- ok(/post-rotate-ping/.test(nst(nsvc)), `new handout delivers after rotation`);
210
-
211
- // ---------- N5 unregister (full teardown; posts die; hook fires) ----------
212
- CUR = 'N5-unregister';
213
- console.log('\n=== N5 unregister ===');
214
- await mutate(alice, '::a2a_notifications::notify_unregister', { service: 'svc' });
215
- await sleep(2500);
216
- const n5s = nst(nsvc);
217
- ok(!new RegExp(alice.cid).test(ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('registrations').Visualize()), `service registration gone`);
218
- ok(ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('token_index').Visualize().replace(/[(),\s]/g, '') === '', `token index entries gone (ALL scoped tokens purged on unregister)`);
219
- ok(ro(nsvc, '::actor::qa_notify_state_v2', undefined).Reduce('sender_tokens').Visualize().replace(/[(),\s]/g, '') === '', `scoped token slots purged on unregister`);
220
- ok(new RegExp(alice.cid).test(ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('unregs_log').Visualize()), `on_unregistered fired with alice's cid`);
221
- ok(ro(alice, '::actor::qa_notify_state', undefined).Reduce('my_regs').Visualize().replace(/[(),\s]/g, '') === '', `client my_regs cleared`);
222
- ok(ro(alice, '::actor::qa_notify_state_v2', undefined).Reduce('contact_tokens').Visualize().replace(/[(),\s]/g, '') === '',
223
- `client contact-token mirror cleared on unregister (no stale-token source)`);
224
- const n5Before = nst(nsvc);
225
- await mutate(nbob, '::a2a_notifications::send_notification',
226
- { address: binv(nbob, addr2), payload: 'post-unregister-ping' });
227
- await sleep(2000);
228
- ok(nst(nsvc) === n5Before && !/post-unregister-ping/.test(nst(nsvc)), `post against a torn-down registration aborts, zero state change`);
229
-
230
- // ---------- N6 mark_read (ids subset + NIL=all; unregistered caller dies) ----------
231
- CUR = 'N6-mark-read';
232
- console.log('\n=== N6 mark_read ===');
233
- // N5 tore alice down (registration + all scoped tokens purged) — re-register
234
- // AND re-issue nbob's scoped token before posting again.
235
- await mutate(alice, '::a2a_notifications::notify_register', { service: 'svc', bindings: null });
236
- await sleep(2500);
237
- await mutate(alice, '::a2a_notifications::notify_issue_tokens', { service: 'svc', contacts: [nbob.cid] });
238
- await sleep(2500);
239
- const addr3 = Buffer.from(ro(alice, '::actor::qa_export_contact_notify_address',
240
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary());
241
- await mutate(nbob, '::a2a_notifications::send_notification', { address: binv(nbob, addr3), payload: 'n6-one' });
242
- await mutate(nbob, '::a2a_notifications::send_notification', { address: binv(nbob, addr3), payload: 'n6-two' });
243
- await sleep(2000);
244
- ok(/n6-one/.test(nst(nsvc)) && /n6-two/.test(nst(nsvc)), `two posts landed for the re-registered alice`);
245
- await mutate(alice, '::a2a_notifications::notify_mark_read', { service: 'svc', notif_ids: ['qa-notif-id-1'] });
246
- await sleep(2000);
247
- const marks1 = ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('marks_log').Visualize();
248
- ok(/qa-notif-id-1/.test(marks1) && new RegExp(alice.cid).test(marks1), `ids-subset mark_read reached the hook with exactly the ids + caller cid`);
249
- await mutate(alice, '::a2a_notifications::notify_mark_read', { service: 'svc', notif_ids: null });
250
- await sleep(2000);
251
- const marks2 = ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('marks_log').Visualize();
252
- ok(marks2 !== marks1 && !/qa-notif-id-2/.test(marks2), `NIL(=all) mark_read reached the hook as a second entry with NIL ids`);
253
- // Negative: a packet with no registration cannot even fire the client trn.
254
- const n6err = await mutate(nbob, '::a2a_notifications::notify_mark_read', { service: 'svc', notif_ids: null })
255
- .then(() => null, (e) => e.message);
256
- ok(n6err !== null && /[Uu]nknown contact|[Nn]o notification registration/.test(n6err), `mark_read without a registration aborts locally`);
257
-
258
- // ---------- N7 export/import round-trip (both halves; no secrets) ----------
259
- CUR = 'N7-export-import';
260
- console.log('\n=== N7 export/import ===');
261
- const svcExpVis = ro(nsvc, '::actor::export_state', undefined).Visualize();
262
- ok(/notify/.test(svcExpVis) && /recipient_cid/.test(svcExpVis), `service export composes the notify half (registrations present)`);
263
- ok(!/secretkey/i.test(svcExpVis) && !/VAPID_PRIV/.test(svcExpVis), `service export carries no secret material`);
264
- ok(/VAPID_PUB_TEST/.test(svcExpVis), `service export carries the PUBLIC vapid key`);
265
- const aliceExpVis = ro(alice, '::actor::export_state', undefined).Visualize();
266
- ok(/VAPID_PUB_TEST/.test(aliceExpVis) && !/secretkey/i.test(aliceExpVis), `client export carries my_regs (vapid pub) and no secret material`);
267
- // Replacement packets, fresh seeds — the T10 migration pattern (the SDK's
268
- // PacketManager refuses a duplicate cid in-process, so a SAME-identity
269
- // restart cannot be driven here; the daemon-restart test in the service repo
270
- // proves "post still lands after restart" on a true same-cid reboot). What
271
- // this asserts: BYTE-FIDELITY of both halves through export→import.
272
- const svcExpBin = ro(nsvc, '::actor::export_state', undefined);
273
- fs.writeFileSync(resolve(UNIT_DIR, 'n7-svc.bin'), Buffer.from(svcExpBin.Serialize()));
274
- const aliceExpBin = ro(alice, '::actor::export_state', undefined);
275
- fs.writeFileSync(resolve(UNIT_DIR, 'n7-alice.bin'), Buffer.from(aliceExpBin.Serialize()));
276
- const nsvc2 = mk('nsvc2'); await mkPacket(nsvc2, 'seed-nsvc-mig'); await sleep(1000);
277
- await mutate(nsvc2, '::actor::import_state',
278
- nsvc2.pw.packet.ParseValue(new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, 'n7-svc.bin')))));
279
- const n7s = nst(nsvc2);
280
- ok(new RegExp(alice.cid).test(n7s) && /recipient_cid/.test(n7s), `service registrations restored on the replacement packet`);
281
- ok(!(ro(nsvc2, '::actor::qa_notify_state', undefined).Reduce('token_index').Visualize().replace(/[(),\s]/g, '') === ''), `token index restored`);
282
- ok(/VAPID_PUB_TEST/.test(n7s), `vapid public key restored`);
283
- // Byte-stability of the scoped token store: nbob's token_id (minted in N6)
284
- // must be identical on the replacement packet (post would validate on a
285
- // same-cid restart).
286
- const n7TidOrig = ro(nsvc, '::actor::qa_scoped_token_id',
287
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
288
- const n7TidRest = ro(nsvc2, '::actor::qa_scoped_token_id',
289
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
290
- ok(n7TidOrig !== '' && n7TidOrig === n7TidRest, `stored scoped token round-trips byte-stable (post would validate on a same-cid restart)`);
291
- const alice2 = mk('alice2'); await mkPacket(alice2, 'seed-alice-mig'); await sleep(1000);
292
- await mutate(alice2, '::actor::import_state',
293
- alice2.pw.packet.ParseValue(new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, 'n7-alice.bin')))));
294
- const n7a = nst(alice2);
295
- ok(/VAPID_PUB_TEST/.test(n7a) && new RegExp(nsvc.cid).test(n7a), `client my_regs restored (vapid pub + service cid intact)`);
296
-
297
- // ====== N9-series: per-sender scoped tokens, issue_tokens, confirm extension ======
298
-
299
- // ---------- N9: pre-v2-era export — absent new fields import as empty maps ----------
300
- // Written FIRST per the brief: verifies that import_notify_state handles missing
301
- // v2 fields (keeps default empty maps) before the round-trip tests run.
302
- CUR = 'N9-v1era-import';
303
- console.log('\n=== N9 v1-era import ===');
304
- const v1Fresh = mk('v1fresh'); await mkPacket(v1Fresh, 'seed-v1fresh'); await sleep(1000);
305
- const v1Res = await mutate(v1Fresh, '::actor::qa_import_v1_notify_state',
306
- { vapid: 'V1_TEST_VAPID' });
307
- // new maps absent in the input record → kept as empty defaults.
308
- ok(v1Res.Reduce('sender_tokens').Visualize().replace(/[(),\s]/g, '') === '',
309
- `v1-era import: notify_sender_tokens stays empty (absent field keeps default)`);
310
- ok(v1Res.Reduce('contact_tokens').Visualize().replace(/[(),\s]/g, '') === '',
311
- `v1-era import: my_notify_contact_tokens stays empty (absent field keeps default)`);
312
-
313
- // ---------- N10 issue_tokens happy path — scoped tokens; confirm carries them ----------
314
- CUR = 'N10-issue-tokens';
315
- console.log('\n=== N10 issue_tokens happy path ===');
316
- // alice is registered with nsvc (re-registered in N6, state carried through N7).
317
- // nbob and neve are never contacts of nsvc.
318
- await mutate(alice, '::a2a_notifications::notify_issue_tokens',
319
- { service: 'svc', contacts: [nbob.cid, neve.cid] });
320
- await sleep(3000);
321
- const n10Svc = nst2(nsvc);
322
- const n10Alice = nst2(alice);
323
- // Service side: notify_sender_tokens[alice][nbob] and [neve] set.
324
- ok(new RegExp(nbob.cid).test(n10Svc), `service stores scoped token keyed to nbob's cid`);
325
- ok(new RegExp(neve.cid).test(n10Svc), `service stores scoped token keyed to neve's cid`);
326
- // Each token's $scope == _str(sender) (the sender's cid as a string).
327
- // In the visualization, the scope field's value should contain the sender cid.
328
- const n10NbobIdx = n10Svc.indexOf(nbob.cid);
329
- const n10NbobSection = n10NbobIdx >= 0 ? n10Svc.slice(n10NbobIdx, n10NbobIdx + 800) : '';
330
- ok(/scope/.test(n10NbobSection) && n10NbobSection.split('scope').length > 1 &&
331
- new RegExp(nbob.cid).test(n10NbobSection.split('scope')[1].slice(0, nbob.cid.length + 20)),
332
- `nbob's scoped token has $scope == _str(nbob) — i.e. scope value is nbob's cid`);
333
- // Token IDs appear in notify_token_index (service indexes them for revocation).
334
- // Stronger check: extract nbob's concrete scoped token_id from sender_tokens via
335
- // the probe and assert that exact id string is present in notify_token_index.
336
- // This FAILS if the 'notify_token_index (...) -> recipient' line in
337
- // handle_issue_tokens were removed — the token exists in sender_tokens but its
338
- // id would be absent from the index, breaking the revocation mechanism.
339
- const scopedTid = ro(nsvc, '::actor::qa_scoped_token_id',
340
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
341
- const tokenIndexVis = ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('token_index').Visualize();
342
- ok(scopedTid !== '' && tokenIndexVis.includes(scopedTid),
343
- `scoped token_id for nbob is indexed in notify_token_index (revocation mechanism)`);
344
- // Client side: my_notify_contact_tokens[nsvc][nbob] and [neve] set on alice.
345
- ok(new RegExp(nbob.cid).test(n10Alice), `alice contact_tokens includes nbob's cid`);
346
- ok(new RegExp(neve.cid).test(n10Alice), `alice contact_tokens includes neve's cid`);
347
- // Confirm keeps carrying $vapid_pub/$bindings beside the token maps.
348
- ok(/VAPID_PUB_TEST/.test(nst(alice)),
349
- `confirm still carries $vapid_pub after issue_tokens`);
350
-
351
- // ---------- N11 idempotence: repeat issue_tokens for bob → token bytes unchanged ----------
352
- CUR = 'N11-idempotence';
353
- console.log('\n=== N11 idempotence ===');
354
- const n11Before = nst2(nsvc);
355
- await mutate(alice, '::a2a_notifications::notify_issue_tokens',
356
- { service: 'svc', contacts: [nbob.cid] });
357
- await sleep(3000);
358
- const n11After = nst2(nsvc);
359
- // Extract bob's token_id from before and after: they must be identical (byte-stable).
360
- const extractTokenLine = (vis, anchorCid) => {
361
- const idx = vis.indexOf(anchorCid);
362
- if (idx < 0) return '';
363
- return (vis.slice(idx, idx + 600).match(/token_id[^\n)]*\)?/) || [''])[0];
364
- };
365
- const tid11Before = extractTokenLine(n11Before, nbob.cid);
366
- const tid11After = extractTokenLine(n11After, nbob.cid);
367
- ok(tid11Before !== '' && tid11Before === tid11After,
368
- `nbob's scoped token_id unchanged after repeat issue_tokens (byte-stable)`);
369
-
370
- // ---------- N12 batch cap: 257 senders aborts; client wrapper pages ----------
371
- CUR = 'N12-batch-cap';
372
- console.log('\n=== N12 batch cap (V12) ===');
373
- // Direct raw call with 257 senders — should abort at the service.
374
- const n12SvcBefore = nst2(nsvc);
375
- const nsvcRejectsBefore12 = nsvc.rejects.length;
376
- await mutate(alice, '::actor::qa_issue_tokens_direct',
377
- { service: nsvc.cid, senders: Array(257).fill(nbob.cid) });
378
- await sleep(2500);
379
- ok(nsvc.rejects.slice(nsvcRejectsBefore12).some((m) => /cap|exceed|256/i.test(m)),
380
- `direct issue_tokens with 257 senders aborts at service (V12 cap error in rejects)`);
381
- ok(nst2(nsvc) === n12SvcBefore,
382
- `no state change on service after 257-sender abort (V12 zero-mutation)`);
383
- // Client wrapper pages 257 into batches of 256 + 1; both confirms land.
384
- const confirmsBefore12 = ro(alice, '::actor::qa_notify_state', undefined)
385
- .Reduce('regconfirm_log').Visualize();
386
- await mutate(alice, '::a2a_notifications::notify_issue_tokens',
387
- { service: 'svc', contacts: Array(257).fill(nbob.cid) });
388
- await sleep(6000); // two service round-trips: 256 + 1
389
- const confirmsAfter12 = ro(alice, '::actor::qa_notify_state', undefined)
390
- .Reduce('regconfirm_log').Visualize();
391
- // Stronger: count VAPID_PUB_TEST occurrences (appears exactly once per confirm in
392
- // regconfirm_log via my_registration_t.$vapid_pub). 257 contacts → 256+1 = 2 pages
393
- // → 2 service round-trips → 2 confirms, so the delta must be >= 2.
394
- const countVapid = (s) => (s.match(/VAPID_PUB_TEST/g) || []).length;
395
- const confirmDelta = countVapid(confirmsAfter12) - countVapid(confirmsBefore12);
396
- ok(confirmDelta >= 2,
397
- `paged issue_tokens with 257 contacts yields >=2 confirms (256-batch + 1-batch, delta=${confirmDelta})`);
398
- ok(/token_id/.test(nst2(alice)),
399
- `alice contact_tokens set after paged issue_tokens`);
400
-
401
- // ---------- N13 non-contact cid mints anyway (V1) ----------
402
- CUR = 'N13-noncontact-mint';
403
- console.log('\n=== N13 non-contact cid mints (V1) ===');
404
- // alice2.cid: a legitimate packet cid that is NOT a contact of either alice or nsvc.
405
- // The service mints regardless (can't know R's contact set — V1).
406
- await mutate(alice, '::a2a_notifications::notify_issue_tokens',
407
- { service: 'svc', contacts: [alice2.cid] });
408
- await sleep(3000);
409
- ok(new RegExp(alice2.cid).test(nst2(nsvc)),
410
- `service mints scoped token for alice2.cid (never met by nsvc — V1 non-contact mint)`);
411
-
412
- // ---------- N14 zero-contact issue → abort (V11) ----------
413
- CUR = 'N14-zero-senders';
414
- console.log('\n=== N14 zero senders (V11) ===');
415
- const nsvcRejectsBefore14 = nsvc.rejects.length;
416
- await mutate(alice, '::actor::qa_issue_tokens_direct',
417
- { service: nsvc.cid, senders: [] });
418
- await sleep(2500);
419
- ok(nsvc.rejects.slice(nsvcRejectsBefore14).some((m) => /non-empty|at least one|V11/i.test(m)),
420
- `empty $senders aborts at service (V11 — non-empty required)`);
421
-
422
- // ---------- N15 registered-recipient gate ----------
423
- CUR = 'N15-gate';
424
- console.log('\n=== N15 registered-recipient gate ===');
425
- // Temporarily unregister alice from nsvc to create the unregistered-caller scenario.
426
- await mutate(alice, '::a2a_notifications::notify_unregister', { service: 'svc' });
427
- await sleep(2500);
428
- const n15Before = nst2(nsvc);
429
- const nsvcRejectsBefore15 = nsvc.rejects.length;
430
- await mutate(alice, '::actor::qa_issue_tokens_direct',
431
- { service: nsvc.cid, senders: [nbob.cid] });
432
- await sleep(2500);
433
- ok(nst2(nsvc) === n15Before,
434
- `issue_tokens from unregistered caller: zero state change`);
435
- ok(nsvc.rejects.slice(nsvcRejectsBefore15).some((m) => /No notification registration/i.test(m)),
436
- `issue_tokens from unregistered caller aborts with registration gate error`);
437
- // Re-register alice and re-issue the scoped tokens the unregister purged
438
- // (so the export/import tests below have per-sender state to round-trip).
439
- await mutate(alice, '::a2a_notifications::notify_register', { service: 'svc', bindings: null });
440
- await sleep(2500);
441
- await mutate(alice, '::a2a_notifications::notify_issue_tokens',
442
- { service: 'svc', contacts: [nbob.cid, neve.cid] });
443
- await sleep(2500);
444
-
445
- // ---------- N16 export/import round-trip includes the three new maps ----------
446
- CUR = 'N16-export-import-v2';
447
- console.log('\n=== N16 export/import round-trip ===');
448
- const n16SvcExp = ro(nsvc, '::actor::export_state', undefined);
449
- fs.writeFileSync(resolve(UNIT_DIR, 'n16-svc.bin'), Buffer.from(n16SvcExp.Serialize()));
450
- const n16AliceExp = ro(alice, '::actor::export_state', undefined);
451
- fs.writeFileSync(resolve(UNIT_DIR, 'n16-alice.bin'), Buffer.from(n16AliceExp.Serialize()));
452
- const nsvc3 = mk('nsvc3'); await mkPacket(nsvc3, 'seed-nsvc3'); await sleep(1000);
453
- await mutate(nsvc3, '::actor::import_state',
454
- nsvc3.pw.packet.ParseValue(new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, 'n16-svc.bin')))));
455
- const n16Svc3 = nst2(nsvc3);
456
- ok(new RegExp(nbob.cid).test(n16Svc3),
457
- `sender_tokens round-trips: nbob's entry restored on the replacement service packet`);
458
- ok(new RegExp(neve.cid).test(n16Svc3),
459
- `sender_tokens round-trips: neve's entry restored`);
460
- const alice3 = mk('alice3'); await mkPacket(alice3, 'seed-alice3'); await sleep(1000);
461
- await mutate(alice3, '::actor::import_state',
462
- alice3.pw.packet.ParseValue(new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, 'n16-alice.bin')))));
463
- const n16Alice3 = nst2(alice3);
464
- ok(/token_id/.test(n16Alice3),
465
- `my_notify_contact_tokens round-trips: contact tokens restored on alice3`);
466
-
467
- // ====== N17-N21: post_notification validation ======
468
-
469
- // ---------- N17 scoped happy path ----------
470
- CUR = 'N17-scoped-post';
471
- console.log('\n=== N17 scoped post happy path ===');
472
- // alice has scoped tokens for nbob/neve (re-issued in N15 after the purging unregister).
473
- // Export a notify_address_t blob wrapping nbob's scoped token from alice's my_notify_contact_tokens.
474
- const n17ScopedAddrBlob = Buffer.from(
475
- ro(alice, '::actor::qa_export_contact_notify_address',
476
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary()
477
- );
478
- const n17Before = nst(nsvc);
479
- await mutate(nbob, '::a2a_notifications::send_notification',
480
- { address: binv(nbob, n17ScopedAddrBlob), payload: 'scoped-hello' });
481
- await sleep(2500);
482
- const n17After = nst(nsvc);
483
- ok(/scoped-hello/.test(n17After), `scoped token: nbob posts via scoped handout, hook fired`);
484
- // sender_cid comes before payload in notification_t; extract window around payload to verify.
485
- { const idx = n17After.indexOf('scoped-hello');
486
- ok(new RegExp(nbob.cid).test(n17After.slice(Math.max(0, idx - 500), idx + 50)),
487
- `scoped post: sender_cid recorded as nbob`); }
488
-
489
- // ---------- N18 $from != $scope ----------
490
- CUR = 'N18-from-ne-scope';
491
- console.log('\n=== N18 sender binding mismatch ($from != $scope) ===');
492
- const n18Before = nst(nsvc);
493
- // neve tries to post with nbob's scoped token — envelope $from == neve.cid, token.$scope == _str(nbob.cid).
494
- await mutate(neve, '::a2a_notifications::send_notification',
495
- { address: binv(neve, n17ScopedAddrBlob), payload: 'evil-neve' });
496
- await sleep(2500);
497
- ok(nst(nsvc) === n18Before, `$from≠$scope: service rejects (step 5 binding check), zero state change`);
498
- ok(!/evil-neve/.test(nst(nsvc)), `$from≠$scope: payload does not appear in log`);
499
-
500
- // ---------- N19 muted sender (real set_sender_muted round-trip) ----------
501
- CUR = 'N19-muted';
502
- console.log('\n=== N19 muted sender ===');
503
- // Capture nbob's scoped token_id BEFORE the mute cycle to assert no re-issue.
504
- const n19TokIdBefore = ro(nsvc, '::actor::qa_scoped_token_id',
505
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
506
- // Mute via the REAL service inbound (round-trip): alice tells nsvc to mute nbob.
507
- await mutate(alice, '::a2a_notifications::notify_set_sender_muted',
508
- { service: 'svc', contact: nbob.cid, muted: true });
509
- await sleep(2500);
510
- // Hook plumbing: the confirm's on_notify_registration hook carries the
511
- // $sender_muted map (the messenger engine's mirror feed) with nbob's entry.
512
- ok(new RegExp(nbob.cid).test(ro(alice, '::actor::qa_last_confirm_muted', undefined)
513
- .Reduce('sender_muted').Visualize()),
514
- `confirm hook carries $sender_muted with nbob's entry after mute`);
515
- const n19Before = nst(nsvc);
516
- await mutate(nbob, '::a2a_notifications::send_notification',
517
- { address: binv(nbob, n17ScopedAddrBlob), payload: 'muted-ping' });
518
- await sleep(2500);
519
- ok(nst(nsvc) === n19Before, `muted: post from muted sender aborts (step 6), no hook fired, zero state change`);
520
- ok(!/muted-ping/.test(nst(nsvc)), `muted: payload does not appear in log`);
521
- // Unmute via the REAL service inbound ($muted FALSE → delete entry; absent = enabled).
522
- await mutate(alice, '::a2a_notifications::notify_set_sender_muted',
523
- { service: 'svc', contact: nbob.cid, muted: false });
524
- await sleep(2500);
525
- await mutate(nbob, '::a2a_notifications::send_notification',
526
- { address: binv(nbob, n17ScopedAddrBlob), payload: 'unmuted-ping' });
527
- await sleep(2500);
528
- ok(/unmuted-ping/.test(nst(nsvc)), `unmuted: same token delivers after mute cleared (no re-issue needed)`);
529
- // Unmute deletes the entry — the hook's mute-map feed no longer names nbob.
530
- ok(!new RegExp(nbob.cid).test(ro(alice, '::actor::qa_last_confirm_muted', undefined)
531
- .Reduce('sender_muted').Visualize()),
532
- `confirm hook $sender_muted empty again after unmute (absent = enabled)`);
533
- // token_id must be byte-identical across the mute/unmute cycle — no re-issue.
534
- const n19TokIdAfter = ro(nsvc, '::actor::qa_scoped_token_id',
535
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
536
- ok(n19TokIdBefore !== '' && n19TokIdBefore === n19TokIdAfter,
537
- `token_id unchanged across mute/unmute cycle (no re-issue: ${n19TokIdBefore})`);
538
-
539
- // ---------- N21 scoped token for absent slot ----------
540
- CUR = 'N21-absent-slot';
541
- console.log('\n=== N21 scoped token for absent slot ===');
542
- // Clear nbob's slot from notify_sender_tokens[alice] via qa probe.
543
- // The token is now a "zombie" — valid artifact, but no matching slot → step 4 aborts.
544
- await mutate(nsvc, '::actor::qa_notify_clear_sender_slot', { recipient: alice.cid, sender: nbob.cid });
545
- const n21Before = nst(nsvc);
546
- await mutate(nbob, '::a2a_notifications::send_notification',
547
- { address: binv(nbob, n17ScopedAddrBlob), payload: 'absent-slot-ping' });
548
- await sleep(2500);
549
- ok(nst(nsvc) === n21Before, `absent slot: scoped post aborts at step 4, zero state change`);
550
- ok(!/absent-slot-ping/.test(nst(nsvc)), `absent slot: payload does not appear in log`);
551
-
552
- // ====== N23-N26: per-sender rotation, revocation, receive-mute ======
553
-
554
- // ---------- N23 per-sender rotate ----------
555
- CUR = 'N23-per-sender-rotate';
556
- console.log('\n=== N23 per-sender rotate ===');
557
- // N23 setup: re-register alice (E8 idempotent — she is still registered) then
558
- // re-issue scoped tokens for neve and nbob (N21 cleared nbob's slot directly).
559
- await mutate(alice, '::a2a_notifications::notify_register', { service: 'svc', bindings: null });
560
- await sleep(2500);
561
- await mutate(alice, '::a2a_notifications::notify_issue_tokens',
562
- { service: 'svc', contacts: [neve.cid, nbob.cid] });
563
- await sleep(2500);
564
- // Capture token_ids (qa_scoped_token_id reads notify_sender_tokens[alice][sender].$c.$token_id).
565
- const n23NeveTokId1 = ro(nsvc, '::actor::qa_scoped_token_id',
566
- { recipient: alice.cid, sender: neve.cid }).Reduce('token_id').Visualize();
567
- const n23NbobTokId1 = ro(nsvc, '::actor::qa_scoped_token_id',
568
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
569
- ok(n23NeveTokId1 !== '' && n23NbobTokId1 !== '', `N23 setup: both scoped token_ids available before rotation`);
570
- // Export neve's current blob via the REAL per-contact export trn (neve is
571
- // alice's contact 'Eve' — this covers export_notify_address end-to-end).
572
- const n23NeveBlob1 = Buffer.from(
573
- ro(alice, '::a2a_notifications::export_notify_address',
574
- { service: 'svc', contact: 'Eve' }).Reduce('blob').GetBinary()
575
- );
576
- const n23NbobBlob = Buffer.from(
577
- ro(alice, '::actor::qa_export_contact_notify_address',
578
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary()
579
- );
580
- // Per-sender rotate: rotate ONLY neve's token (contact = neve.cid).
581
- await mutate(alice, '::a2a_notifications::notify_rotate_token',
582
- { service: 'svc', contact: neve.cid });
583
- await sleep(2500);
584
- // neve's token_id must change; nbob's must be unchanged.
585
- const n23NeveTokId2 = ro(nsvc, '::actor::qa_scoped_token_id',
586
- { recipient: alice.cid, sender: neve.cid }).Reduce('token_id').Visualize();
587
- const n23NbobTokId2 = ro(nsvc, '::actor::qa_scoped_token_id',
588
- { recipient: alice.cid, sender: nbob.cid }).Reduce('token_id').Visualize();
589
- ok(n23NeveTokId2 !== '' && n23NeveTokId2 !== n23NeveTokId1,
590
- `neve's token_id changed after per-sender rotate`);
591
- ok(n23NbobTokId2 !== '' && n23NbobTokId2 === n23NbobTokId1,
592
- `nbob's token_id unchanged after rotating only neve`);
593
- // neve's OLD blob must abort at index lookup (old token_id deleted from notify_token_index).
594
- const n23Before = nst(nsvc);
595
- await mutate(neve, '::a2a_notifications::send_notification',
596
- { address: binv(neve, n23NeveBlob1), payload: 'neve-old-token-ping' });
597
- await sleep(2500);
598
- ok(nst(nsvc) === n23Before, `neve's old scoped blob aborts after per-sender rotate (zero state change)`);
599
- ok(!/neve-old-token-ping/.test(nst(nsvc)), `neve old payload absent`);
600
- // neve's NEW blob (from alice's updated contact-token mirror) must deliver.
601
- const n23NeveBlob2 = Buffer.from(
602
- ro(alice, '::actor::qa_export_contact_notify_address',
603
- { service: nsvc.cid, sender: neve.cid }).Reduce('blob').GetBinary()
604
- );
605
- await mutate(neve, '::a2a_notifications::send_notification',
606
- { address: binv(neve, n23NeveBlob2), payload: 'neve-new-token-ping' });
607
- await sleep(2500);
608
- ok(/neve-new-token-ping/.test(nst(nsvc)), `neve's new scoped blob delivers after per-sender rotation`);
609
- // nbob's blob is unchanged — must still deliver (only neve's token was rotated).
610
- await mutate(nbob, '::a2a_notifications::send_notification',
611
- { address: binv(nbob, n23NbobBlob), payload: 'nbob-unrotated-ping' });
612
- await sleep(2500);
613
- ok(/nbob-unrotated-ping/.test(nst(nsvc)), `nbob's token unaffected by neve's per-sender rotation`);
614
-
615
- // ---------- N24 rotate-all (Q9 panic button) ----------
616
- CUR = 'N24-rotate-all';
617
- console.log('\n=== N24 rotate-all (Q9) ===');
618
- // Export current neve and nbob blobs from alice's updated mirror (after N23 rotation of neve).
619
- const n24NeveBlob1 = Buffer.from(
620
- ro(alice, '::actor::qa_export_contact_notify_address',
621
- { service: nsvc.cid, sender: neve.cid }).Reduce('blob').GetBinary()
622
- );
623
- const n24NbobBlob1 = Buffer.from(
624
- ro(alice, '::actor::qa_export_contact_notify_address',
625
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary()
626
- );
627
- // Rotate-all: $contact absent → rotate ALL scoped slots (Q9).
628
- await mutate(alice, '::a2a_notifications::notify_rotate_token', { service: 'svc' });
629
- await sleep(2500);
630
- // OLD neve and nbob scoped blobs must abort (old token_ids deleted from index).
631
- const n24Before = nst(nsvc);
632
- await mutate(neve, '::a2a_notifications::send_notification',
633
- { address: binv(neve, n24NeveBlob1), payload: 'neve-rotate-all-old-ping' });
634
- await sleep(2500);
635
- ok(nst(nsvc) === n24Before, `rotate-all: neve's old scoped blob aborts after rotate-all (zero state change)`);
636
- ok(!/neve-rotate-all-old-ping/.test(nst(nsvc)), `rotate-all: neve old payload absent`);
637
- await mutate(nbob, '::a2a_notifications::send_notification',
638
- { address: binv(nbob, n24NbobBlob1), payload: 'nbob-rotate-all-old-ping' });
639
- await sleep(2500);
640
- ok(nst(nsvc) === n24Before, `rotate-all: nbob's old scoped blob aborts after rotate-all (zero state change)`);
641
- ok(!/nbob-rotate-all-old-ping/.test(nst(nsvc)), `rotate-all: nbob old payload absent`);
642
- // NEW blobs (from alice's updated mirror, populated by the rotate-all confirm) must deliver.
643
- const n24NeveBlob2 = Buffer.from(
644
- ro(alice, '::actor::qa_export_contact_notify_address',
645
- { service: nsvc.cid, sender: neve.cid }).Reduce('blob').GetBinary()
646
- );
647
- const n24NbobBlob2 = Buffer.from(
648
- ro(alice, '::actor::qa_export_contact_notify_address',
649
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary()
650
- );
651
- await mutate(neve, '::a2a_notifications::send_notification',
652
- { address: binv(neve, n24NeveBlob2), payload: 'neve-rotate-all-new-ping' });
653
- await sleep(2500);
654
- ok(/neve-rotate-all-new-ping/.test(nst(nsvc)), `rotate-all: neve's new scoped blob delivers`);
655
- await mutate(nbob, '::a2a_notifications::send_notification',
656
- { address: binv(nbob, n24NbobBlob2), payload: 'nbob-rotate-all-new-ping' });
657
- await sleep(2500);
658
- ok(/nbob-rotate-all-new-ping/.test(nst(nsvc)), `rotate-all: nbob's new scoped blob delivers`);
659
- // The registration record itself carries no token — rotate-all must leave it
660
- // untouched (assert against the registrations slice, not the whole dump).
661
- ok(!/token_id/.test(ro(nsvc, '::actor::qa_notify_state', undefined).Reduce('registrations').Visualize()),
662
- `registration record carries no token after rotate-all (per-contact-only model)`);
663
-
664
- // ---------- N25 revoke (revoke-without-replace) ----------
665
- CUR = 'N25-revoke';
666
- console.log('\n=== N25 revoke (revoke-without-replace) ===');
667
- // Export current neve and nbob blobs (from alice's mirror after N24 rotate-all).
668
- const n25NeveBlob = Buffer.from(
669
- ro(alice, '::actor::qa_export_contact_notify_address',
670
- { service: nsvc.cid, sender: neve.cid }).Reduce('blob').GetBinary()
671
- );
672
- const n25NbobBlob = Buffer.from(
673
- ro(alice, '::actor::qa_export_contact_notify_address',
674
- { service: nsvc.cid, sender: nbob.cid }).Reduce('blob').GetBinary()
675
- );
676
- // Revoke neve's token (no re-mint): alice tells nsvc to delete neve's index entry + slot.
677
- await mutate(alice, '::a2a_notifications::notify_revoke_contact_tokens',
678
- { service: 'svc', contacts: [neve.cid] });
679
- await sleep(2500);
680
- // neve's posts must abort (index entry deleted, slot gone, no re-mint).
681
- const n25Before = nst(nsvc);
682
- await mutate(neve, '::a2a_notifications::send_notification',
683
- { address: binv(neve, n25NeveBlob), payload: 'revoked-neve-ping' });
684
- await sleep(2500);
685
- ok(nst(nsvc) === n25Before, `neve's posts abort after revoke (index gone, zero state change)`);
686
- ok(!/revoked-neve-ping/.test(nst(nsvc)), `revoked neve payload absent`);
687
- // no re-mint — alice's contact_token mirror must no longer contain neve's entry.
688
- const n25Alice = nst2(alice);
689
- ok(!new RegExp(neve.cid).test(n25Alice),
690
- `neve absent from alice's contact_token mirror after revoke (confirm contains no neve re-mint)`);
691
- // nbob still delivers (unaffected by revoke of neve).
692
- await mutate(nbob, '::a2a_notifications::send_notification',
693
- { address: binv(nbob, n25NbobBlob), payload: 'post-revoke-nbob-ping' });
694
- await sleep(2500);
695
- ok(/post-revoke-nbob-ping/.test(nst(nsvc)), `nbob unaffected by neve revoke, still delivers`);
696
- // Revoke of never-issued sender: no-op success (E4/E8 — delete of absent slot tolerated).
697
- const n25V2Before = nst2(nsvc);
698
- const n25RejBefore = nsvc.rejects.length;
699
- await mutate(alice, '::a2a_notifications::notify_revoke_contact_tokens',
700
- { service: 'svc', contacts: [v1Fresh.cid] });
701
- await sleep(2500);
702
- ok(nst2(nsvc) === n25V2Before,
703
- `revoke never-issued sender: no-op (E4/E8) — service v2 state unchanged`);
704
- ok(nsvc.rejects.length === n25RejBefore,
705
- `revoke never-issued sender: no rejection (success response, idempotent)`);
706
-
707
- // ---------- N26 service-side gate (registered-recipient, set_sender_muted) ----------
708
- CUR = 'N26-service-gate';
709
- console.log('\n=== N26 service-side gate (set_sender_muted, unregistered caller) ===');
710
- // Unregister alice so the service has no registration for her.
711
- await mutate(alice, '::a2a_notifications::notify_unregister', { service: 'svc' });
712
- await sleep(2500);
713
- const n26V1Before = nst(nsvc);
714
- const n26V2Before = nst2(nsvc);
715
- const n26RejBefore = nsvc.rejects.length;
716
- // qa_set_sender_muted_direct bypasses the client-side registration check and injects
717
- // set_sender_muted directly over alice's encrypted channel with nsvc.
718
- // Service gate: "No notification registration for this sender." must abort and mutate nothing.
719
- await mutate(alice, '::actor::qa_set_sender_muted_direct',
720
- { service: nsvc.cid, sender: nbob.cid, muted: true });
721
- await sleep(2500);
722
- ok(nst(nsvc) === n26V1Before && nst2(nsvc) === n26V2Before,
723
- `service gate: set_sender_muted from unregistered caller: zero service state change`);
724
- ok(nsvc.rejects.slice(n26RejBefore).some((m) => /No notification registration/i.test(m)),
725
- `service gate: set_sender_muted from unregistered caller aborts at registered-recipient gate`);
726
-
727
- // ---------- V5 CAP-1 gate (deny only on positive evidence) ----------
728
- // Moved here from test.mjs: this exercises the a2a_notifications CLIENT
729
- // capability gate (notify_register), so it needs the notifications library +
730
- // the alice/nsvc notify nodes established above.
731
- CUR = 'V5 CAP-1';
732
- console.log('\n=== V5 CAP-1 capability gate at the notify client send ===');
733
- {
734
- // Positive evidence: alice "learned" nsvc advertises caps WITHOUT
735
- // core.notifications → register must be denied AS DATA (no send, no abort).
736
- await mutate(alice, '::actor::qa_set_contact_caps', { cid: nsvc.cid, caps: ['core.cluster'] });
737
- const den = await mutate(alice, '::a2a_notifications::notify_register', { service: nsvc.cid });
738
- ok(!isT(den.Reduce('ok').Visualize()), `CAP-1: register toward a caps-lacking service denied AS DATA ($ok FALSE)`);
739
- ok(den.Reduce('error').Reduce('code').Visualize() === 'capability_not_advertised',
740
- `CAP-1: denial carries stable code capability_not_advertised`);
741
- ok(/notification support/.test(den.Reduce('error').Reduce('message').Visualize()),
742
- `CAP-1: denial message is render-ready`);
743
- // Unknown/empty caps → pass-through (pre-0.5 contacts keep working).
744
- await mutate(alice, '::actor::qa_set_contact_caps', { cid: nsvc.cid, caps: [] });
745
- const allow = await mutate(alice, '::a2a_notifications::notify_register', { service: nsvc.cid });
746
- ok(allow.Reduce('sent_to').Visualize() === nsvc.cid, `CAP-1: EMPTY/unknown caps pass through (fail-open) — register sent`);
747
- }
748
-
749
- console.log('\n================ NOTIF ================');
750
- if (scorecard.length === 0) console.log('NOTIF: ALL GREEN');
751
- else { console.log(`${scorecard.length} FAILURE(S):`); scorecard.forEach((s) => console.log(' ' + s)); }
752
- await sleep(500);
753
- process.exit(scorecard.length === 0 ? 0 : 1);
754
- }
755
- main().catch((e) => { log('DRIVER ERR:', e.stack ?? e.message); process.exit(1); });