@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
package/dist/presence.js CHANGED
@@ -1,157 +1,67 @@
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 });
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { WebSocket, WebSocketServer } from 'ws';
3
+ const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
4
+ /** Foreground is a property of one verified device, never the whole account. */
5
+ export function attachPresence(server, store, users, options = { intervalMs: 15_000, freshnessMs: 45_000 }) {
6
+ const sockets = new WebSocketServer({ noServer: true, maxPayload: 4096 });
7
+ const active = new Map();
55
8
  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();
9
+ const user = users.find(c => same(req.headers.authorization ?? '', `Bearer ${c.token}`));
10
+ if (req.url !== '/api/v1/presence' || !user || req.headers.origin || req.headers.cookie || req.headers['sec-fetch-site'] || sockets.clients.size >= 128) {
11
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
63
12
  return;
64
13
  }
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) {
14
+ sockets.handleUpgrade(req, socket, head, ws => {
15
+ const deadline = setTimeout(() => ws.close(1008, 'device authentication required'), 5000);
16
+ deadline.unref();
17
+ ws.on('error', () => { });
18
+ ws.on('close', () => { clearTimeout(deadline); active.delete(ws); });
19
+ ws.on('pong', () => { const p = active.get(ws); if (p)
20
+ p.pong = true; });
21
+ ws.on('message', raw => {
104
22
  try {
105
- ws.send(JSON.stringify({ ok: false, error: 'auth rejected' }));
23
+ const value = JSON.parse(raw.toString());
24
+ const p = active.get(ws);
25
+ if (p) {
26
+ if (value.type !== 'heartbeat' || !store.deviceStatus(p.user, p.id)) {
27
+ ws.close(1008, 'invalid presence');
28
+ return;
29
+ }
30
+ p.at = Date.now();
31
+ return;
32
+ }
33
+ if (value.type !== 'hello' || typeof value.id !== 'string' || typeof value.endpoint !== 'string' || typeof value.auth !== 'string' || value.auth.length > 100)
34
+ throw new Error('invalid device proof');
35
+ const device = store.device(user.userId, value.id);
36
+ if (!device || device.subscription.endpoint !== value.endpoint || !same(device.subscription.keys.auth, value.auth))
37
+ throw new Error('invalid device proof');
38
+ if ([...active.values()].filter(p => p.user === user.userId).length >= 32)
39
+ throw new Error('presence capacity');
40
+ clearTimeout(deadline);
41
+ active.set(ws, { user: user.userId, id: value.id, at: Date.now(), pong: true });
42
+ ws.send(JSON.stringify({ type: 'presence.ready' }));
106
43
  }
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 */ }
44
+ catch {
45
+ ws.close(1008, 'invalid device proof');
46
+ }
47
+ });
118
48
  });
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
49
  });
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) {
50
+ const online = (user, id) => [...active.values()].some(p => p.user === user && p.id === id && Date.now() - p.at < options.freshnessMs && store.deviceStatus(user, id));
51
+ const notify = (user) => { for (const [ws, p] of active)
52
+ if (p.user === user && ws.readyState === WebSocket.OPEN && online(p.user, p.id))
53
+ ws.send(JSON.stringify({ type: 'inbox.changed' })); };
54
+ const timer = setInterval(() => {
55
+ for (const [ws, p] of active) {
56
+ if (!p.pong || Date.now() - p.at >= options.freshnessMs || !store.deviceStatus(p.user, p.id)) {
137
57
  ws.terminate();
138
58
  continue;
139
59
  }
140
- ws.isAlive = false;
141
- try {
142
- ws.ping();
143
- }
144
- catch { /* terminating */ }
60
+ p.pong = false;
61
+ ws.ping();
145
62
  }
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
- };
63
+ }, options.intervalMs);
64
+ timer.unref();
65
+ return { online, notify, close() { clearInterval(timer); for (const ws of sockets.clients)
66
+ ws.terminate(); sockets.close(); active.clear(); } };
157
67
  }
@@ -0,0 +1,26 @@
1
+ import { type IncomingMessage } from 'node:http';
2
+ import { type PushSubscription } from 'web-push';
3
+ import { Store } from './store.js';
4
+ export interface Credential {
5
+ token: string;
6
+ userId: string;
7
+ source?: string;
8
+ }
9
+ export interface Config {
10
+ host?: string;
11
+ port: number;
12
+ stateFile: string;
13
+ users: Credential[];
14
+ producers: Credential[];
15
+ vapid: {
16
+ subject: string;
17
+ publicKey: string;
18
+ privateKey: string;
19
+ };
20
+ }
21
+ export declare function createNotificationService(config: Config, send?: (subscription: PushSubscription, payload: string) => Promise<unknown>): {
22
+ server: import("http").Server<typeof IncomingMessage, typeof import("http").ServerResponse>;
23
+ store: Store;
24
+ drain: () => Promise<void>;
25
+ close(): Promise<void>;
26
+ };
package/dist/server.js ADDED
@@ -0,0 +1,145 @@
1
+ import { createServer } from 'node:http';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import webpush from 'web-push';
4
+ import { attachPresence } from './presence.js';
5
+ import { Store } from './store.js';
6
+ import { HttpError, object, subscription, target, text } from './validation.js';
7
+ const same = (a, b) => {
8
+ const x = Buffer.from(a), y = Buffer.from(b);
9
+ return x.length === y.length && timingSafeEqual(x, y);
10
+ };
11
+ async function body(req) {
12
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? ''))
13
+ throw new HttpError(415, 'expected JSON');
14
+ let raw = '';
15
+ for await (const chunk of req) {
16
+ raw += chunk.toString();
17
+ if (Buffer.byteLength(raw) > 16_384)
18
+ throw new HttpError(413, 'body too large');
19
+ }
20
+ try {
21
+ return object(JSON.parse(raw));
22
+ }
23
+ catch {
24
+ throw new HttpError(400, 'invalid JSON');
25
+ }
26
+ }
27
+ export function createNotificationService(config, send) {
28
+ const tokens = [...config.users, ...config.producers];
29
+ if (!tokens.length || tokens.some(c => typeof c.token !== 'string' || c.token.length < 32 || !c.userId || c.userId.length > 256)
30
+ || new Set(tokens.map(c => c.token)).size !== tokens.length || config.producers.some(c => !c.source))
31
+ throw new Error('invalid scoped credentials');
32
+ webpush.setVapidDetails(config.vapid.subject, config.vapid.publicKey, config.vapid.privateKey);
33
+ const store = new Store(config.stateFile);
34
+ const deliver = send ?? ((sub, payload) => webpush.sendNotification(sub, payload, { TTL: 3600, timeout: 10_000,
35
+ vapidDetails: config.vapid, contentEncoding: 'aes128gcm' }));
36
+ const rates = new Map();
37
+ const server = createServer(async (req, res) => {
38
+ res.setHeader('cache-control', 'no-store');
39
+ res.setHeader('content-type', 'application/json');
40
+ res.setHeader('x-content-type-options', 'nosniff');
41
+ try {
42
+ // Browser cookies and claimed user IDs have no authority here. Browser
43
+ // access goes through Fleet's authenticated same-origin/CSRF gateway.
44
+ if (req.headers.origin || req.headers.cookie || req.headers['sec-fetch-site'])
45
+ throw new HttpError(403, 'use authenticated gateway');
46
+ const url = new URL(req.url ?? '/', 'http://localhost');
47
+ const producerRoute = url.pathname === '/api/v1/send';
48
+ const authorization = req.headers.authorization ?? '';
49
+ const credential = (producerRoute ? config.producers : config.users).find(c => same(authorization, `Bearer ${c.token}`));
50
+ if (!credential)
51
+ throw new HttpError(401, 'authentication required');
52
+ const key = credential.token;
53
+ let rate = rates.get(key);
54
+ if (!rate || rate.until <= Date.now()) {
55
+ rate = { count: 0, until: Date.now() + 60_000 };
56
+ rates.set(key, rate);
57
+ }
58
+ if (++rate.count > (producerRoute ? 600 : 180))
59
+ throw new HttpError(429, 'rate limit exceeded');
60
+ const user = credential.userId;
61
+ let result;
62
+ if (req.method === 'GET' && url.pathname === '/api/v1/push/config')
63
+ result = { publicKey: config.vapid.publicKey };
64
+ else if (req.method === 'GET' && url.pathname === '/api/v1/summary')
65
+ result = store.summary(user);
66
+ else if (req.method === 'GET' && url.pathname === '/api/v1/inbox') {
67
+ const limit = Number(url.searchParams.get('limit') ?? 50);
68
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
69
+ throw new HttpError(400, 'invalid limit');
70
+ result = store.inbox(user, url.searchParams.get('before') ?? undefined, limit);
71
+ }
72
+ else if (req.method === 'GET' && url.pathname === '/api/v1/devices/status')
73
+ result = { active: store.deviceStatus(user, url.searchParams.get('id') ?? '') };
74
+ else if (req.method === 'POST' && url.pathname === '/api/v1/devices') {
75
+ result = store.ensureDevice(user, subscription((await body(req)).subscription));
76
+ }
77
+ else if (req.method === 'POST' && url.pathname === '/api/v1/devices/delete')
78
+ result = { removed: store.deleteDevice(user, text((await body(req)).id, 100)) };
79
+ else if (req.method === 'POST' && url.pathname === '/api/v1/read-target') {
80
+ const value = await body(req);
81
+ store.readTarget(user, target(value.url), value.revision);
82
+ result = { ok: true };
83
+ }
84
+ else if (req.method === 'POST' && url.pathname === '/api/v1/read') {
85
+ const ids = (await body(req)).ids;
86
+ if (!Array.isArray(ids) || ids.length > 100)
87
+ throw new HttpError(400, 'invalid notification ids');
88
+ store.read(user, ids.map(id => text(id, 100)));
89
+ result = { ok: true };
90
+ }
91
+ else if (req.method === 'POST' && producerRoute) {
92
+ const value = await body(req);
93
+ if ('userId' in value || 'source' in value)
94
+ throw new HttpError(400, 'recipient and source are credential scoped');
95
+ result = store.produce({ userId: user, source: credential.source, eventId: text(value.eventId, 512),
96
+ title: text(value.title, 160), body: text(value.body, 512), url: target(value.url) });
97
+ }
98
+ else
99
+ throw new HttpError(404, 'route not found');
100
+ if (req.method === 'POST' && ['/api/v1/send', '/api/v1/read', '/api/v1/read-target'].includes(url.pathname))
101
+ presence.notify(user);
102
+ res.end(JSON.stringify(result));
103
+ }
104
+ catch (error) {
105
+ res.statusCode = error instanceof HttpError ? error.status : 500;
106
+ res.end(JSON.stringify({ error: { message: error instanceof HttpError ? error.message : 'notification service unavailable' } }));
107
+ }
108
+ });
109
+ const presence = attachPresence(server, store, config.users);
110
+ server.requestTimeout = 15_000;
111
+ server.headersTimeout = 10_000;
112
+ let draining;
113
+ const drain = () => draining ??= (async () => {
114
+ for (const j of store.due(Date.now())) {
115
+ if (!j.device || !j.notification || j.notification.readAt) {
116
+ store.finish(j.id, 'sent', Date.now());
117
+ continue;
118
+ }
119
+ if (presence.online(j.device.userId, j.device.id)) {
120
+ store.finish(j.id, 'sent', Date.now());
121
+ continue;
122
+ }
123
+ try {
124
+ await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
125
+ body: j.notification.body, url: j.notification.url, notification_id: j.notification.id, tag: j.notification.id }));
126
+ store.finish(j.id, 'sent', Date.now());
127
+ }
128
+ catch (error) {
129
+ const status = error.statusCode;
130
+ store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now());
131
+ }
132
+ }
133
+ })().finally(() => { draining = undefined; });
134
+ const timer = setInterval(() => { void drain().catch(() => { }); }, 1000);
135
+ timer.unref();
136
+ return { server, store, drain, async close() {
137
+ clearInterval(timer);
138
+ presence.close();
139
+ await draining;
140
+ server.closeAllConnections();
141
+ if (server.listening)
142
+ await new Promise(resolve => server.close(() => resolve()));
143
+ store.close();
144
+ } };
145
+ }
@@ -0,0 +1,63 @@
1
+ import type { PushSubscription } from 'web-push';
2
+ export interface Notification {
3
+ id: string;
4
+ userId: string;
5
+ source: string;
6
+ eventId: string;
7
+ title: string;
8
+ body: string;
9
+ url: string;
10
+ createdAt: string;
11
+ createdRevision?: number;
12
+ readAt: string | null;
13
+ }
14
+ interface Device {
15
+ id: string;
16
+ userId: string;
17
+ subscription: PushSubscription;
18
+ }
19
+ /** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
20
+ export declare class Store {
21
+ private readonly file;
22
+ private state;
23
+ private readonly lockToken;
24
+ private closed;
25
+ constructor(file: string);
26
+ close(): void;
27
+ private commit;
28
+ ensureDevice(userId: string, subscription: PushSubscription): {
29
+ id: string;
30
+ };
31
+ deleteDevice(userId: string, id: string): boolean;
32
+ device(userId: string, id: string): Device | undefined;
33
+ deviceStatus(userId: string, id: string): boolean;
34
+ inbox(userId: string, before?: string, limit?: number): {
35
+ items: Notification[];
36
+ unread: number;
37
+ revision: number;
38
+ nextCursor: string | null | undefined;
39
+ };
40
+ summary(userId: string): {
41
+ unread: number;
42
+ revision: number;
43
+ chats: {
44
+ source: "work" | "messenger";
45
+ chat: string;
46
+ count: number;
47
+ }[];
48
+ };
49
+ readTarget(userId: string, url: string, revision: number): void;
50
+ read(userId: string, ids: string[]): void;
51
+ produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification;
52
+ due(now: number): {
53
+ device: Device | undefined;
54
+ notification: Notification | undefined;
55
+ id: string;
56
+ notificationId: string;
57
+ deviceId: string;
58
+ attempts: number;
59
+ due: number;
60
+ }[];
61
+ finish(id: string, status: 'sent' | 'gone' | 'retry', now: number): void;
62
+ }
63
+ export {};
package/dist/store.js ADDED
@@ -0,0 +1,181 @@
1
+ import { chatTarget, chatKey } from './chat-target.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { HttpError } from './validation.js';
6
+ /** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
7
+ export class Store {
8
+ file;
9
+ state;
10
+ lockToken = `${process.pid}:${randomUUID()}`;
11
+ closed = false;
12
+ constructor(file) {
13
+ this.file = file;
14
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
15
+ const lock = file + '.lock';
16
+ let lockFd;
17
+ try {
18
+ lockFd = openSync(lock, 'wx', 0o600);
19
+ }
20
+ catch {
21
+ throw new Error('notification store already in use; inspect stale lock before manual recovery');
22
+ }
23
+ try {
24
+ writeFileSync(lockFd, this.lockToken);
25
+ fsyncSync(lockFd);
26
+ }
27
+ finally {
28
+ closeSync(lockFd);
29
+ }
30
+ try {
31
+ this.state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { version: 1, revision: 0, devices: [], notifications: [], jobs: [] };
32
+ if (this.state.version !== 1 || !Array.isArray(this.state.notifications) || !Array.isArray(this.state.devices) || !Array.isArray(this.state.jobs))
33
+ throw new Error('invalid notification state');
34
+ }
35
+ catch (error) {
36
+ this.close();
37
+ throw error;
38
+ }
39
+ }
40
+ close() {
41
+ if (this.closed)
42
+ return;
43
+ if (readFileSync(this.file + '.lock', 'utf8') !== this.lockToken)
44
+ throw new Error('notification lock ownership changed');
45
+ unlinkSync(this.file + '.lock');
46
+ this.closed = true;
47
+ }
48
+ commit(change) {
49
+ const draft = structuredClone(this.state);
50
+ change(draft);
51
+ draft.revision++;
52
+ const tmp = this.file + '.tmp';
53
+ const fd = openSync(tmp, 'w', 0o600);
54
+ try {
55
+ writeFileSync(fd, JSON.stringify(draft));
56
+ fsyncSync(fd);
57
+ }
58
+ finally {
59
+ closeSync(fd);
60
+ }
61
+ renameSync(tmp, this.file);
62
+ const dir = openSync(dirname(this.file), 'r');
63
+ try {
64
+ fsyncSync(dir);
65
+ }
66
+ finally {
67
+ closeSync(dir);
68
+ }
69
+ this.state = draft;
70
+ }
71
+ ensureDevice(userId, subscription) {
72
+ const existing = this.state.devices.find(d => d.subscription.endpoint === subscription.endpoint);
73
+ if (existing && existing.userId !== userId)
74
+ throw new HttpError(409, 'subscription belongs to another account');
75
+ if (!existing && this.state.devices.filter(d => d.userId === userId).length >= 32)
76
+ throw new HttpError(429, 'device capacity exceeded');
77
+ const changed = existing && JSON.stringify(existing.subscription.keys) !== JSON.stringify(subscription.keys);
78
+ const id = changed ? randomUUID() : existing?.id ?? randomUUID();
79
+ this.commit(s => {
80
+ s.devices = s.devices.filter(d => d.id !== (existing?.id ?? id));
81
+ if (changed)
82
+ s.jobs = s.jobs.filter(j => j.deviceId !== existing.id);
83
+ s.devices.push({ id, userId, subscription });
84
+ });
85
+ return { id };
86
+ }
87
+ deleteDevice(userId, id) {
88
+ if (!this.state.devices.some(d => d.id === id && d.userId === userId))
89
+ return false;
90
+ this.commit(s => { s.devices = s.devices.filter(d => d.id !== id); s.jobs = s.jobs.filter(j => j.deviceId !== id); });
91
+ return true;
92
+ }
93
+ device(userId, id) { const d = this.state.devices.find(d => d.id === id && d.userId === userId); return d ? structuredClone(d) : undefined; }
94
+ deviceStatus(userId, id) { return this.state.devices.some(d => d.id === id && d.userId === userId); }
95
+ inbox(userId, before, limit = 50) {
96
+ const all = this.state.notifications.filter(n => n.userId === userId);
97
+ const index = before ? all.findIndex(n => n.id === before) : all.length;
98
+ if (index < 0)
99
+ throw new HttpError(400, 'invalid cursor');
100
+ const items = all.slice(Math.max(0, index - limit), index).reverse();
101
+ return { items, unread: all.filter(n => !n.readAt).length, revision: this.state.revision,
102
+ nextCursor: index > limit ? items.at(-1)?.id : null };
103
+ }
104
+ summary(userId) {
105
+ const unread = this.state.notifications.filter(n => n.userId === userId && !n.readAt);
106
+ const groups = new Map();
107
+ for (const n of unread) {
108
+ const target = chatTarget(n.url);
109
+ if (!target)
110
+ continue;
111
+ const key = chatKey(target), group = groups.get(key) ?? { ...target, count: 0 };
112
+ group.count++;
113
+ groups.set(key, group);
114
+ }
115
+ return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()] };
116
+ }
117
+ readTarget(userId, url, revision) {
118
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision > this.state.revision)
119
+ throw new HttpError(400, 'invalid observed revision');
120
+ const target = chatTarget(url);
121
+ if (!target)
122
+ throw new HttpError(400, 'expected chat target');
123
+ const key = chatKey(target);
124
+ const ids = this.state.notifications.filter(n => n.userId === userId && !n.readAt && (n.createdRevision ?? 0) <= revision && (() => {
125
+ const other = chatTarget(n.url);
126
+ return other && chatKey(other) === key;
127
+ })()).map(n => n.id);
128
+ if (ids.length)
129
+ this.read(userId, ids);
130
+ }
131
+ read(userId, ids) {
132
+ const chosen = new Set(ids);
133
+ const at = new Date().toISOString();
134
+ this.commit(s => {
135
+ for (const n of s.notifications)
136
+ if (n.userId === userId && chosen.has(n.id))
137
+ n.readAt ??= at;
138
+ s.jobs = s.jobs.filter(j => !s.notifications.find(n => n.id === j.notificationId)?.readAt);
139
+ });
140
+ }
141
+ produce(value) {
142
+ const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
143
+ if (previous) {
144
+ if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url)
145
+ throw new HttpError(409, 'event id reused');
146
+ return previous;
147
+ }
148
+ // Explicit capacity failure instead of silently evicting unread history or dedupe records.
149
+ if (this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
150
+ throw new HttpError(503, 'notification capacity exceeded');
151
+ const n = { ...value, id: randomUUID(), createdAt: new Date().toISOString(), createdRevision: this.state.revision + 1, readAt: null };
152
+ this.commit(s => {
153
+ s.notifications.push(n);
154
+ for (const d of s.devices)
155
+ if (d.userId === n.userId)
156
+ s.jobs.push({ id: randomUUID(), notificationId: n.id, deviceId: d.id, attempts: 0, due: Date.now() });
157
+ });
158
+ return n;
159
+ }
160
+ due(now) {
161
+ return this.state.jobs.filter(j => j.due <= now).slice(0, 32).map(j => ({ ...j,
162
+ device: this.state.devices.find(d => d.id === j.deviceId), notification: this.state.notifications.find(n => n.id === j.notificationId) }));
163
+ }
164
+ finish(id, status, now) {
165
+ this.commit(s => {
166
+ const j = s.jobs.find(j => j.id === id);
167
+ if (!j)
168
+ return;
169
+ if (status === 'gone') {
170
+ s.devices = s.devices.filter(d => d.id !== j.deviceId);
171
+ s.jobs = s.jobs.filter(v => v.deviceId !== j.deviceId);
172
+ }
173
+ else if (status === 'sent' || j.attempts >= 7)
174
+ s.jobs = s.jobs.filter(v => v.id !== id);
175
+ else {
176
+ j.attempts++;
177
+ j.due = now + Math.min(60_000 * 2 ** j.attempts, 3_600_000);
178
+ }
179
+ });
180
+ }
181
+ }
@@ -0,0 +1,9 @@
1
+ import type { PushSubscription } from 'web-push';
2
+ export declare class HttpError extends Error {
3
+ readonly status: number;
4
+ constructor(status: number, message: string);
5
+ }
6
+ export declare function text(value: unknown, max: number): string;
7
+ export declare function object(value: unknown): Record<string, unknown>;
8
+ export declare function target(value: unknown): string;
9
+ export declare function subscription(value: unknown): PushSubscription;