@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.
Files changed (107) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +231 -0
  3. package/dist/cli.js +446 -0
  4. package/dist/config.js +104 -0
  5. package/dist/daemon.js +353 -0
  6. package/dist/http.js +31 -0
  7. package/dist/log-store.js +48 -0
  8. package/dist/presence.js +157 -0
  9. package/dist/provision.js +29 -0
  10. package/dist/push.js +6 -0
  11. package/dist/web-push-sender.js +41 -0
  12. package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
  13. package/mufl_code/config.mufl +21 -0
  14. package/mufl_code/core/.github/FUNDING.yml +3 -0
  15. package/mufl_code/core/.github/workflows/docs.yml +39 -0
  16. package/mufl_code/core/.github/workflows/tests.yml +66 -0
  17. package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
  18. package/mufl_code/core/COMPATIBILITY.md +221 -0
  19. package/mufl_code/core/CONTRIBUTING.md +37 -0
  20. package/mufl_code/core/LICENSE +98 -0
  21. package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
  22. package/mufl_code/core/README.md +91 -0
  23. package/mufl_code/core/SECURITY.md +40 -0
  24. package/mufl_code/core/TRADEMARKS.md +24 -0
  25. package/mufl_code/core/a2a_capabilities.mm +449 -0
  26. package/mufl_code/core/a2a_cluster.mm +601 -0
  27. package/mufl_code/core/a2a_control.mm +90 -0
  28. package/mufl_code/core/a2a_messaging.mm +4817 -0
  29. package/mufl_code/core/a2a_monitoring.mm +94 -0
  30. package/mufl_code/core/a2a_notification_integration.mm +144 -0
  31. package/mufl_code/core/a2a_notifications.mm +1127 -0
  32. package/mufl_code/core/a2a_protocol.mm +246 -0
  33. package/mufl_code/core/a2a_versions.mm +801 -0
  34. package/mufl_code/core/config.mufl +29 -0
  35. package/mufl_code/core/docs/.vitepress/config.mts +58 -0
  36. package/mufl_code/core/docs/citations.json +688 -0
  37. package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
  38. package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
  39. package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
  40. package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
  41. package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
  42. package/mufl_code/core/docs/guide/index.md +63 -0
  43. package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
  44. package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
  45. package/mufl_code/core/docs/how-it-works/identity.md +56 -0
  46. package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
  47. package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
  48. package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
  49. package/mufl_code/core/docs/how-it-works/overview.md +45 -0
  50. package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
  51. package/mufl_code/core/docs/index.md +27 -0
  52. package/mufl_code/core/docs/package-lock.json +3467 -0
  53. package/mufl_code/core/docs/package.json +15 -0
  54. package/mufl_code/core/docs/reference/contributing.md +26 -0
  55. package/mufl_code/core/docs/reference/glossary.md +108 -0
  56. package/mufl_code/core/docs/reference/implementations.md +23 -0
  57. package/mufl_code/core/docs/reference/modules.md +19 -0
  58. package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
  59. package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
  60. package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
  61. package/mufl_code/core/docs/workflows/cluster.md +89 -0
  62. package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
  63. package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
  64. package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
  65. package/mufl_code/core/docs/workflows/index.md +57 -0
  66. package/mufl_code/core/docs/workflows/introductions.md +60 -0
  67. package/mufl_code/core/docs/workflows/messaging.md +75 -0
  68. package/mufl_code/core/docs/workflows/monitoring.md +84 -0
  69. package/mufl_code/core/docs/workflows/notifications.md +109 -0
  70. package/mufl_code/core/release-notes/0.1.md +60 -0
  71. package/mufl_code/core/release-notes/0.3.md +22 -0
  72. package/mufl_code/core/release-notes/0.4.md +97 -0
  73. package/mufl_code/core/tests/README.md +83 -0
  74. package/mufl_code/core/tests/compat/README.md +82 -0
  75. package/mufl_code/core/tests/compat/compat.mjs +245 -0
  76. package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
  77. package/mufl_code/core/tests/compat/run_compat.sh +96 -0
  78. package/mufl_code/core/tests/corpus.mjs +139 -0
  79. package/mufl_code/core/tests/corpus_actor.mu +242 -0
  80. package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
  81. package/mufl_code/core/tests/mig.mjs +805 -0
  82. package/mufl_code/core/tests/mig_actor.mu +518 -0
  83. package/mufl_code/core/tests/migapp.mjs +431 -0
  84. package/mufl_code/core/tests/migapp_actor.mu +294 -0
  85. package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
  86. package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
  87. package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
  88. package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
  89. package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
  90. package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
  91. package/mufl_code/core/tests/notif.mjs +755 -0
  92. package/mufl_code/core/tests/notif_actor.mu +450 -0
  93. package/mufl_code/core/tests/protocol_container.mm +18 -0
  94. package/mufl_code/core/tests/run.sh +65 -0
  95. package/mufl_code/core/tests/run_corpus.sh +51 -0
  96. package/mufl_code/core/tests/run_fatcompile.sh +40 -0
  97. package/mufl_code/core/tests/run_mig.sh +51 -0
  98. package/mufl_code/core/tests/run_migapp.sh +51 -0
  99. package/mufl_code/core/tests/run_notif.sh +57 -0
  100. package/mufl_code/core/tests/test.mjs +750 -0
  101. package/mufl_code/core/tests/test_actor.mu +564 -0
  102. package/mufl_code/core/tests/test_common.mjs +93 -0
  103. package/mufl_code/core/version.mm +24 -0
  104. package/mufl_code/packet.mu +170 -0
  105. package/mufl_code/protocol_container.mm +18 -0
  106. package/package.json +43 -0
  107. package/scripts/gen-vapid.mjs +16 -0
package/dist/daemon.js ADDED
@@ -0,0 +1,353 @@
1
+ // The ours-notifications host daemon: boots the service packet via the
2
+ // @adapt-toolkit SDK, connects to a broker, persists identity + packet state
3
+ // across restarts, consumes the packet's $notify_agent events
4
+ // (notification_posted / marked_read / unregistered), keeps the app-side
5
+ // notification log (notifications.json), and performs WebPush egress.
6
+ //
7
+ // Run directly (node dist/daemon.js) it speaks a JSON-lines control protocol:
8
+ // stdin {"cmd":"invite","name":"..."} | {"cmd":"contacts"} | {"cmd":"export_vis"} | {"cmd":"status"}
9
+ // stdout {"reply":...} for commands; {"event":...} lines for daemon events
10
+ // (first line after boot: {"event":"ready","cid":...,"version":{...}}).
11
+ import * as fs from 'node:fs';
12
+ import { resolve } from 'node:path';
13
+ import { randomBytes } from 'node:crypto';
14
+ import { createInterface } from 'node:readline';
15
+ import { adapt_wrapper } from '@adapt-toolkit/sdk/executables';
16
+ import { PacketWrapperConfigurator } from '@adapt-toolkit/sdk/wrappers';
17
+ import { object_to_adapt_value } from '@adapt-toolkit/sdk/wrapper';
18
+ import { loadConfig } from './config.js';
19
+ import { LogStore } from './log-store.js';
20
+ import { DisabledPushSender } from './push.js';
21
+ import { WebPushSender } from './web-push-sender.js';
22
+ import { startDiscoveryServer } from './http.js';
23
+ import { PresenceRegistry, attachPresenceServer } from './presence.js';
24
+ const SERVICE_NAME = 'ours notifications service';
25
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ export async function startDaemon(cfg) {
27
+ fs.mkdirSync(cfg.stateDir, { recursive: true });
28
+ const unitFile = fs.readdirSync(cfg.unitDir).find((f) => f.endsWith('.muflo'));
29
+ if (!unitFile)
30
+ throw new Error(`no .muflo unit in ${cfg.unitDir} — run scripts/compile-mufl.sh`);
31
+ const unitHash = unitFile.slice(0, -'.muflo'.length);
32
+ const unitBytes = new Uint8Array(fs.readFileSync(resolve(cfg.unitDir, unitFile)));
33
+ const keyPath = resolve(cfg.stateDir, 'identity.key');
34
+ const dataPath = resolve(cfg.stateDir, 'state_data.bin');
35
+ const wrapper = await adapt_wrapper.start(['--broker_address', cfg.brokerUrl, '--test_mode',
36
+ '--logger_config', '--level', 'WARNING', '--stdout', 'stderr', '--logger_config_end']);
37
+ wrapper.start();
38
+ await sleep(1500);
39
+ const logStore = new LogStore(cfg.stateDir);
40
+ const push = cfg.pushDisabled
41
+ ? new DisabledPushSender()
42
+ : new WebPushSender(cfg.vapidSubject, cfg.vapidPublicKey, cfg.vapidPrivateKey, cfg.pushTlsInsecure);
43
+ const presence = new PresenceRegistry();
44
+ const listeners = [];
45
+ const emit = (e) => { for (const cb of listeners)
46
+ cb(e); };
47
+ const pending = [];
48
+ let pw = null;
49
+ const ro = (name, targ) => pw.packet.ExecuteTransaction(object_to_adapt_value({ name, targ }));
50
+ const mutate = (name, targ) => new Promise((res, rej) => {
51
+ const timer = setTimeout(() => rej(new Error(`${name} timed out`)), 20000);
52
+ pending.push({ resolve: res, reject: rej, timer });
53
+ pw.add_client_message(object_to_adapt_value({ name, targ }));
54
+ });
55
+ function saveState() {
56
+ try {
57
+ const bytes = Buffer.from(ro('::actor::export_state').Serialize());
58
+ // Per-contact tokens grow state O(recipients × contacts) — surface
59
+ // real-world numbers once the blob crosses 10 MB so capping (LRU/paging)
60
+ // can be revisited with data.
61
+ if (bytes.length > 10 * 1024 * 1024) {
62
+ console.error(`[daemon] state size ${(bytes.length / (1024 * 1024)).toFixed(1)} MB exceeds 10 MB — review per-contact token growth`);
63
+ }
64
+ const tmp = `${dataPath}.tmp`;
65
+ fs.writeFileSync(tmp, bytes);
66
+ fs.renameSync(tmp, dataPath);
67
+ }
68
+ catch (err) {
69
+ console.error('[daemon] failed to save state:', String(err));
70
+ }
71
+ }
72
+ function readBindings(av) {
73
+ const out = [];
74
+ if (av.IsNil())
75
+ return out;
76
+ for (let i = 0;; i++) {
77
+ const b = av.Reduce(i);
78
+ if (b.IsNil())
79
+ break;
80
+ out.push({
81
+ binding_id: b.Reduce('binding_id').Visualize(),
82
+ endpoint: b.Reduce('endpoint').Visualize(),
83
+ p256dh: b.Reduce('p256dh').Visualize(),
84
+ auth: b.Reduce('auth').Visualize(),
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+ function readIds(av) {
90
+ if (av.IsNil())
91
+ return null;
92
+ const out = [];
93
+ for (let i = 0;; i++) {
94
+ const e = av.Reduce(i);
95
+ if (e.IsNil())
96
+ break;
97
+ out.push(e.Visualize());
98
+ }
99
+ return out;
100
+ }
101
+ function handleNotifyAgent(payload) {
102
+ const event = payload.Reduce('event').Visualize();
103
+ if (event === 'notification_posted') {
104
+ const n = payload.Reduce('notification');
105
+ const record = {
106
+ notif_id: n.Reduce('notif_id').Visualize(),
107
+ wire_id: n.Reduce('wire_id').Visualize(),
108
+ recipient_cid: n.Reduce('recipient_cid').Visualize(),
109
+ sender_cid: n.Reduce('sender_cid').Visualize(),
110
+ payload: n.Reduce('payload').Visualize(),
111
+ date: n.Reduce('date').Visualize(),
112
+ };
113
+ const bindings = readBindings(payload.Reduce('bindings'));
114
+ // BUG-2 foreground gate: a live authed presence socket for the recipient
115
+ // means their PWA is frontmost — they already got this message in-app, so
116
+ // SUPPRESS the WebPush. Log it as 'read' for history/badge coherence.
117
+ // No socket ⇒ recipient is closed/backgrounded ⇒ push as usual. Self-
118
+ // healing: the socket drop clears presence instantly, so the next post pushes.
119
+ const suppressed = presence.isOnline(record.recipient_cid);
120
+ if (suppressed) {
121
+ logStore.append(record, 'read');
122
+ }
123
+ else {
124
+ logStore.append(record);
125
+ // One WebPush per binding, log-and-continue on failure (E12) — never
126
+ // block or crash the ingest on a dead endpoint.
127
+ void push.sendPushes(bindings, record.payload).then((results) => {
128
+ for (const r of results) {
129
+ if (!r.ok)
130
+ console.error(`[push] ${r.binding_id}: ${r.error ?? r.status}`);
131
+ }
132
+ });
133
+ }
134
+ emit({ event, notification: record, bindings, suppressed });
135
+ }
136
+ else if (event === 'marked_read') {
137
+ const recipient = payload.Reduce('recipient_cid').Visualize();
138
+ const ids = readIds(payload.Reduce('notif_ids'));
139
+ logStore.markRead(recipient, ids);
140
+ emit({ event, recipient_cid: recipient, notif_ids: ids });
141
+ }
142
+ else if (event === 'unregistered') {
143
+ const recipient = payload.Reduce('recipient_cid').Visualize();
144
+ logStore.purgeRecipient(recipient);
145
+ emit({ event, recipient_cid: recipient });
146
+ }
147
+ else {
148
+ emit({ event });
149
+ }
150
+ }
151
+ function createPacket(seed, signingSecret) {
152
+ const pcfg = new PacketWrapperConfigurator();
153
+ const args = ['--unit_hash', unitHash, '--seed_phrase', seed, '--unit_dir_path', cfg.unitDir];
154
+ if (signingSecret)
155
+ args.push('--init_trn_argument', JSON.stringify(signingSecret));
156
+ pcfg.process_arguments(args);
157
+ return new Promise((res, rej) => {
158
+ const t = setTimeout(() => rej(new Error('packet creation timed out')), 30000);
159
+ wrapper.packet_manager.create_packet(pcfg, (p) => {
160
+ clearTimeout(t);
161
+ pw = p;
162
+ pw.on_return_data = (d) => {
163
+ const kind = d.Reduce('kind').Visualize();
164
+ if (kind === 'save_state') {
165
+ saveState();
166
+ return;
167
+ }
168
+ if (kind === 'notify_agent') {
169
+ handleNotifyAgent(d.Reduce('payload'));
170
+ return;
171
+ }
172
+ const p2 = pending.shift();
173
+ if (p2) {
174
+ clearTimeout(p2.timer);
175
+ p2.resolve(d.Reduce('payload'));
176
+ }
177
+ };
178
+ pw.on_transaction_failure = (msg) => {
179
+ const p2 = pending.shift();
180
+ if (p2) {
181
+ clearTimeout(p2.timer);
182
+ p2.reject(new Error(msg));
183
+ }
184
+ else
185
+ console.error('[daemon] inbound rejected:', String(msg).split('\n')[0]);
186
+ };
187
+ res();
188
+ }, unitBytes);
189
+ });
190
+ }
191
+ // Boot: restore the persisted identity (same container id via reseed) or
192
+ // mint a fresh one and persist its SIGN secret + state.
193
+ if (fs.existsSync(keyPath)) {
194
+ const secret = fs.readFileSync(keyPath, 'utf8').trim();
195
+ await createPacket('', secret);
196
+ if (fs.existsSync(dataPath) && fs.statSync(dataPath).size > 0) {
197
+ const adaptData = pw.packet.ParseValue(new Uint8Array(fs.readFileSync(dataPath)));
198
+ await mutate('::actor::import_state', adaptData);
199
+ }
200
+ }
201
+ else {
202
+ await createPacket(randomBytes(24).toString('hex'));
203
+ const secretHex = Buffer.from(ro('::actor::export_signing_secret').Serialize()).toString('hex');
204
+ fs.writeFileSync(keyPath, secretHex, { mode: 0o600 });
205
+ }
206
+ // The packet echoes this in every confirm_registration; PRIVATE key stays here.
207
+ await mutate('::a2a_notifications::set_vapid_public_key', { key: cfg.vapidPublicKey });
208
+ const cid = pw.packet.GetContainerID().Visualize();
209
+ const vv = ro('::actor::get_version');
210
+ const version = {
211
+ MAJ: Number(vv.Reduce('MAJ').Visualize()),
212
+ MIN: Number(vv.Reduce('MIN').Visualize()),
213
+ PATCH: Number(vv.Reduce('PATCH').Visualize()),
214
+ };
215
+ const mintInvite = async (name = '') => {
216
+ const m = await mutate('::a2a_messaging::generate_invite', { name });
217
+ return Buffer.from(m.Reduce('invite').GetBinary()).toString('hex');
218
+ };
219
+ // BUG-2: verify a presence socket belongs to the claimed recipient — PURE TS,
220
+ // ZERO mufl (app+server-only by design). The panel presents
221
+ // { recipient, endpoint, auth } from its OWN WebPush subscription; we read the
222
+ // packet's registration state via the EXISTING ::actor::export_state readonly
223
+ // and check the pair against a stored binding of registration[recipient]. Runs
224
+ // only at socket-auth time (foreground reconnect), not per notification, so the
225
+ // export_state cost is fine. Returns the bound cid on success, else null; never
226
+ // throws into the WS layer.
227
+ const verifyPresence = (frame) => {
228
+ const recipient = frame.recipient;
229
+ const endpoint = frame.endpoint;
230
+ const auth = frame.auth;
231
+ if (typeof recipient !== 'string' || typeof endpoint !== 'string' || typeof auth !== 'string')
232
+ return null;
233
+ if (!recipient || !endpoint || !auth)
234
+ return null;
235
+ try {
236
+ const notify = ro('::actor::export_state').Reduce('notify');
237
+ if (notify.IsNil())
238
+ return null;
239
+ const regs = notify.Reduce('notify_registrations');
240
+ if (regs.IsNil())
241
+ return null;
242
+ // notify_registrations is a global_id-keyed map; find the recipient's entry.
243
+ let reg = null;
244
+ for (const k of regs.GetKeys()) {
245
+ if (k.Visualize() === recipient) {
246
+ reg = regs.Reduce(k);
247
+ break;
248
+ }
249
+ }
250
+ if (!reg || reg.IsNil())
251
+ return null;
252
+ const bindings = reg.Reduce('bindings');
253
+ if (bindings.IsNil())
254
+ return null;
255
+ for (let i = 0;; i++) {
256
+ const b = bindings.Reduce(i);
257
+ if (b.IsNil())
258
+ break;
259
+ if (b.Reduce('endpoint').Visualize() === endpoint && b.Reduce('auth').Visualize() === auth) {
260
+ return recipient;
261
+ }
262
+ }
263
+ return null;
264
+ }
265
+ catch (err) {
266
+ console.error('[presence] verify failed:', String(err).split('\n')[0]);
267
+ return null;
268
+ }
269
+ };
270
+ let discovery;
271
+ let presenceServer;
272
+ if (cfg.discoveryPort !== undefined) {
273
+ discovery = await startDiscoveryServer(cfg.discoveryPort, cfg.discoveryHost, {
274
+ serviceName: () => SERVICE_NAME,
275
+ serviceCid: () => cid,
276
+ brokerUrl: cfg.brokerUrl,
277
+ vapidPublicKey: cfg.vapidPublicKey,
278
+ mintInvite,
279
+ });
280
+ // Attach the foreground-presence WebSocket to the SAME server/port (reachable
281
+ // over the same tunnel as /v1/discovery). No new port, no new tunnel route.
282
+ presenceServer = attachPresenceServer(discovery, presence, {
283
+ verify: verifyPresence,
284
+ onChange: (recipient_cid, online) => emit({ event: 'presence_change', recipient_cid, online }),
285
+ });
286
+ }
287
+ return {
288
+ cid,
289
+ version,
290
+ logStore,
291
+ onEvent: (cb) => listeners.push(cb),
292
+ invite: async (name) => {
293
+ return mintInvite(name);
294
+ },
295
+ contacts: () => ro('::a2a_messaging::list_contacts').Visualize(),
296
+ exportVis: () => ro('::actor::export_state').Visualize(),
297
+ stop: async () => {
298
+ saveState();
299
+ if (presenceServer)
300
+ await presenceServer.close();
301
+ if (discovery)
302
+ await new Promise((res) => discovery.close(() => res()));
303
+ },
304
+ };
305
+ }
306
+ // ---- CLI entry (JSON-lines control protocol) --------------------------------
307
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.url.replace('file://', ''));
308
+ if (isMain) {
309
+ const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
310
+ startDaemon(loadConfig())
311
+ .then((daemon) => {
312
+ daemon.onEvent((e) => out(e));
313
+ out({ event: 'ready', cid: daemon.cid, version: daemon.version });
314
+ const rl = createInterface({ input: process.stdin });
315
+ rl.on('line', (line) => {
316
+ void (async () => {
317
+ let msg;
318
+ try {
319
+ msg = JSON.parse(line);
320
+ }
321
+ catch {
322
+ return;
323
+ }
324
+ try {
325
+ if (msg.cmd === 'invite')
326
+ out({ reply: await daemon.invite(msg.name ?? '') });
327
+ else if (msg.cmd === 'contacts')
328
+ out({ reply: daemon.contacts() });
329
+ else if (msg.cmd === 'export_vis')
330
+ out({ reply: daemon.exportVis() });
331
+ else if (msg.cmd === 'status')
332
+ out({ reply: { cid: daemon.cid, version: daemon.version } });
333
+ else if (msg.cmd === 'notifications')
334
+ out({ reply: daemon.logStore.list() });
335
+ else
336
+ out({ reply: { error: `unknown cmd ${msg.cmd}` } });
337
+ }
338
+ catch (err) {
339
+ out({ reply: { error: String(err) } });
340
+ }
341
+ })();
342
+ });
343
+ const shutdown = () => {
344
+ void daemon.stop().then(() => process.exit(0));
345
+ };
346
+ process.on('SIGTERM', shutdown);
347
+ process.on('SIGINT', shutdown);
348
+ })
349
+ .catch((err) => {
350
+ console.error('[daemon] boot failed:', err);
351
+ process.exit(1);
352
+ });
353
+ }
package/dist/http.js ADDED
@@ -0,0 +1,31 @@
1
+ import http from 'node:http';
2
+ const CORS = { 'Access-Control-Allow-Origin': '*' };
3
+ export function startDiscoveryServer(port, host, deps) {
4
+ const server = http.createServer(async (req, res) => {
5
+ if (req.method === 'OPTIONS') {
6
+ res.writeHead(204, CORS);
7
+ return void res.end();
8
+ }
9
+ if (req.method === 'GET' && req.url?.split('?')[0] === '/v1/discovery') {
10
+ try {
11
+ const invite = await deps.mintInvite();
12
+ res.writeHead(200, { 'content-type': 'application/json', ...CORS });
13
+ return void res.end(JSON.stringify({
14
+ version: 1,
15
+ service_name: deps.serviceName(),
16
+ service_cid: deps.serviceCid(),
17
+ broker_url: deps.brokerUrl,
18
+ vapid_public_key: deps.vapidPublicKey,
19
+ invite,
20
+ }));
21
+ }
22
+ catch (e) {
23
+ res.writeHead(500, { 'content-type': 'application/json', ...CORS });
24
+ return void res.end(JSON.stringify({ error: String(e) }));
25
+ }
26
+ }
27
+ res.writeHead(404, CORS);
28
+ res.end();
29
+ });
30
+ return new Promise((resolve) => server.listen(port, host, () => resolve(server)));
31
+ }
@@ -0,0 +1,48 @@
1
+ // The app-side notification log (notifications.json): the daemon owns
2
+ // storage + read/unread status; the packet only validates and hands records
3
+ // over. v1: unbounded append (documented; GC is an optional enhancement).
4
+ import * as fs from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ export class LogStore {
7
+ path;
8
+ records = [];
9
+ constructor(dir) {
10
+ this.path = resolve(dir, 'notifications.json');
11
+ try {
12
+ this.records = JSON.parse(fs.readFileSync(this.path, 'utf8'));
13
+ }
14
+ catch {
15
+ this.records = [];
16
+ }
17
+ }
18
+ flush() {
19
+ const tmp = `${this.path}.tmp`;
20
+ fs.writeFileSync(tmp, JSON.stringify(this.records, null, 2));
21
+ fs.renameSync(tmp, this.path);
22
+ }
23
+ // status defaults to 'unread'. Foreground-suppressed posts are appended as
24
+ // 'read' (BUG-2): the recipient already saw the message in-app, so the record
25
+ // is kept for history but must not inflate the unread badge.
26
+ append(rec, status = 'unread') {
27
+ this.records.push({ ...rec, status });
28
+ this.flush();
29
+ }
30
+ // ids === null ⇒ mark ALL of this recipient's notifications read.
31
+ // Unknown / foreign ids are ignored (E7 — idempotent).
32
+ markRead(recipientCid, ids) {
33
+ for (const r of this.records) {
34
+ if (r.recipient_cid !== recipientCid)
35
+ continue;
36
+ if (ids === null || ids.includes(r.notif_id))
37
+ r.status = 'read';
38
+ }
39
+ this.flush();
40
+ }
41
+ purgeRecipient(recipientCid) {
42
+ this.records = this.records.filter((r) => r.recipient_cid !== recipientCid);
43
+ this.flush();
44
+ }
45
+ list() {
46
+ return this.records;
47
+ }
48
+ }
@@ -0,0 +1,157 @@
1
+ // BUG-2 foreground-presence: a long-lived WebSocket per ONLINE recipient device.
2
+ //
3
+ // The panel opens a socket to /v1/presence while its PWA is foreground and lets
4
+ // it die when the app closes/backgrounds (the OS tears the socket down on
5
+ // suspend). A live authed socket for recipient R therefore means "R is
6
+ // frontmost right now" — and the daemon SUPPRESSES R's WebPush while such a
7
+ // socket exists (R already sees the message in-app). The socket dropping is the
8
+ // ONLY liveness signal: instant, certain, and needing no message from the
9
+ // client — which is why this survives an abrupt close that an app-level "I'm
10
+ // gone" beacon never could.
11
+ //
12
+ // AUTH: the first frame is an opaque JSON object the daemon's `verify` inspects
13
+ // (in practice { recipient, endpoint, auth } — R's own WebPush credential). The
14
+ // daemon returns the recipient cid the socket is bound to, or null to reject. A
15
+ // forged credential can only suppress that recipient's own pushes, so proving
16
+ // possession of R's subscription is the right strength.
17
+ //
18
+ // LIVENESS of the socket itself: WS ping/pong (transport level, not an app
19
+ // heartbeat) reaps half-open sockets — e.g. an iOS PWA backgrounded on a flaky
20
+ // network whose TCP close hasn't propagated yet — within ~pingIntervalMs.
21
+ import { WebSocketServer } from 'ws';
22
+ // recipient_cid -> its live authed sockets (a recipient may have >1 device).
23
+ export class PresenceRegistry {
24
+ online = new Map();
25
+ isOnline(cid) {
26
+ const s = this.online.get(cid);
27
+ return s !== undefined && s.size > 0;
28
+ }
29
+ add(cid, ws) {
30
+ let s = this.online.get(cid);
31
+ if (!s) {
32
+ s = new Set();
33
+ this.online.set(cid, s);
34
+ }
35
+ s.add(ws);
36
+ }
37
+ remove(cid, ws) {
38
+ const s = this.online.get(cid);
39
+ if (!s)
40
+ return false;
41
+ const had = s.delete(ws);
42
+ if (s.size === 0)
43
+ this.online.delete(cid);
44
+ return had && !this.isOnline(cid);
45
+ }
46
+ onlineCount() { return this.online.size; }
47
+ socketCount() { let n = 0; for (const s of this.online.values())
48
+ n += s.size; return n; }
49
+ }
50
+ export function attachPresenceServer(server, registry, deps) {
51
+ const path = deps.path ?? '/v1/presence';
52
+ const pingIntervalMs = deps.pingIntervalMs ?? 30_000;
53
+ const authTimeoutMs = deps.authTimeoutMs ?? 5_000;
54
+ const wss = new WebSocketServer({ noServer: true });
55
+ server.on('upgrade', (req, socket, head) => {
56
+ let pathname = '/';
57
+ try {
58
+ pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
59
+ }
60
+ catch { /* keep default */ }
61
+ if (pathname !== path) {
62
+ socket.destroy();
63
+ return;
64
+ }
65
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
66
+ });
67
+ wss.on('connection', (ws) => {
68
+ let boundCid = null;
69
+ let authed = false;
70
+ ws.isAlive = true;
71
+ ws.on('pong', () => { ws.isAlive = true; });
72
+ const authTimer = setTimeout(() => { if (!authed)
73
+ ws.close(4001, 'auth timeout'); }, authTimeoutMs);
74
+ ws.on('message', async (data) => {
75
+ if (authed)
76
+ return; // presence is passive after auth; ignore further frames
77
+ let msg;
78
+ try {
79
+ msg = JSON.parse(data.toString());
80
+ }
81
+ catch {
82
+ ws.close(4002, 'bad json');
83
+ return;
84
+ }
85
+ if (typeof msg !== 'object' || msg === null) {
86
+ ws.close(4003, 'bad auth frame');
87
+ return;
88
+ }
89
+ // Claim this socket's single auth attempt BEFORE the await: verify() yields,
90
+ // so a second frame racing in during it must not also authenticate. Without
91
+ // this, two frames for different cids can both register, but only the last
92
+ // boundCid is cleaned up on close — leaving the first cid "online" forever
93
+ // (permanent push suppression for that recipient).
94
+ authed = true;
95
+ clearTimeout(authTimer);
96
+ let cid = null;
97
+ try {
98
+ cid = await deps.verify(msg);
99
+ }
100
+ catch {
101
+ cid = null;
102
+ }
103
+ if (!cid) {
104
+ try {
105
+ ws.send(JSON.stringify({ ok: false, error: 'auth rejected' }));
106
+ }
107
+ catch { /* closing */ }
108
+ ws.close(4004, 'auth rejected');
109
+ return;
110
+ }
111
+ boundCid = cid;
112
+ registry.add(cid, ws);
113
+ deps.onChange?.(cid, true);
114
+ try {
115
+ ws.send(JSON.stringify({ ok: true }));
116
+ }
117
+ catch { /* closing */ }
118
+ });
119
+ const drop = () => {
120
+ clearTimeout(authTimer);
121
+ if (boundCid) {
122
+ const wentOffline = registry.remove(boundCid, ws);
123
+ if (wentOffline)
124
+ deps.onChange?.(boundCid, false);
125
+ boundCid = null;
126
+ }
127
+ };
128
+ ws.on('close', drop);
129
+ ws.on('error', drop);
130
+ });
131
+ // Transport-level keepalive: a socket that misses a pong round is terminated,
132
+ // which fires 'close' and clears its presence. Bounds the "backgrounded but
133
+ // socket not yet dropped" window to ~pingIntervalMs.
134
+ const pinger = setInterval(() => {
135
+ for (const ws of wss.clients) {
136
+ if (ws.isAlive === false) {
137
+ ws.terminate();
138
+ continue;
139
+ }
140
+ ws.isAlive = false;
141
+ try {
142
+ ws.ping();
143
+ }
144
+ catch { /* terminating */ }
145
+ }
146
+ }, pingIntervalMs);
147
+ if (typeof pinger.unref === 'function')
148
+ pinger.unref();
149
+ return {
150
+ close: () => new Promise((resolve) => {
151
+ clearInterval(pinger);
152
+ wss.close(() => resolve());
153
+ for (const ws of wss.clients)
154
+ ws.terminate();
155
+ }),
156
+ };
157
+ }
@@ -0,0 +1,29 @@
1
+ // Self-provisioning: make a fresh install "just work" on first boot. Creates the
2
+ // state dir and, if the VAPID keypair is absent, generates + persists it (public
3
+ // 0644, private 0600) into the state dir. The identity signing secret
4
+ // (identity.key → stable cid) is provisioned separately by startDaemon() the
5
+ // first time it boots without one; here we only ensure the state dir + VAPID.
6
+ //
7
+ // Inline VAPID_PUBLIC_KEY/PRIVATE_KEY env keys take precedence and suppress file
8
+ // generation (the operator supplied their own).
9
+ import * as fs from 'node:fs';
10
+ import { dirname } from 'node:path';
11
+ import webpush from 'web-push';
12
+ export function provision(r, env = process.env) {
13
+ fs.mkdirSync(r.stateDir, { recursive: true });
14
+ // Operator-supplied inline keys win — nothing to generate.
15
+ if (env.VAPID_PUBLIC_KEY?.trim() && env.VAPID_PRIVATE_KEY?.trim()) {
16
+ return { stateDir: r.stateDir, vapidGenerated: false };
17
+ }
18
+ const havePublic = fs.existsSync(r.vapidPublicFile);
19
+ const havePrivate = fs.existsSync(r.vapidPrivateFile);
20
+ if (havePublic && havePrivate) {
21
+ return { stateDir: r.stateDir, vapidGenerated: false };
22
+ }
23
+ const { publicKey, privateKey } = webpush.generateVAPIDKeys();
24
+ fs.mkdirSync(dirname(r.vapidPublicFile), { recursive: true });
25
+ fs.mkdirSync(dirname(r.vapidPrivateFile), { recursive: true });
26
+ fs.writeFileSync(r.vapidPublicFile, publicKey + '\n', { mode: 0o644 });
27
+ fs.writeFileSync(r.vapidPrivateFile, privateKey + '\n', { mode: 0o600 });
28
+ return { stateDir: r.stateDir, vapidGenerated: true };
29
+ }
package/dist/push.js ADDED
@@ -0,0 +1,6 @@
1
+ // No-op sender (used when PUSH_DISABLED=1): reports every binding as dropped.
2
+ export class DisabledPushSender {
3
+ async sendPushes(bindings, _payload) {
4
+ return bindings.map((b) => ({ binding_id: b.binding_id, ok: false, error: 'push disabled' }));
5
+ }
6
+ }
@@ -0,0 +1,41 @@
1
+ // Real WebPush egress via the `web-push` npm package (VAPID). Per-binding
2
+ // try/catch, log-and-continue on 4xx/5xx — a dead endpoint never
3
+ // fails the batch. Binding auto-prune on 404/410 is a non-v1 enhancement.
4
+ import webpush from 'web-push';
5
+ import { Agent } from 'node:https';
6
+ export class WebPushSender {
7
+ subject;
8
+ publicKey;
9
+ privateKey;
10
+ agent;
11
+ constructor(subject, publicKey, privateKey, tlsInsecure = false) {
12
+ this.subject = subject;
13
+ this.publicKey = publicKey;
14
+ this.privateKey = privateKey;
15
+ // PUSH_TLS_INSECURE=1: accept self-signed push endpoints (the mock-push
16
+ // integration test / a self-hosted lab). NEVER for real browser pushes.
17
+ if (tlsInsecure)
18
+ this.agent = new Agent({ rejectUnauthorized: false });
19
+ }
20
+ async sendPushes(bindings, payload) {
21
+ const results = [];
22
+ for (const b of bindings) {
23
+ try {
24
+ const res = await webpush.sendNotification({ endpoint: b.endpoint, keys: { p256dh: b.p256dh, auth: b.auth } }, payload, {
25
+ vapidDetails: { subject: this.subject, publicKey: this.publicKey, privateKey: this.privateKey },
26
+ ...(this.agent ? { agent: this.agent } : {}),
27
+ });
28
+ results.push({ binding_id: b.binding_id, ok: true, status: res.statusCode });
29
+ }
30
+ catch (err) {
31
+ results.push({
32
+ binding_id: b.binding_id,
33
+ ok: false,
34
+ status: err?.statusCode,
35
+ error: String(err?.body || err?.message || err).slice(0, 200),
36
+ });
37
+ }
38
+ }
39
+ return results;
40
+ }
41
+ }