@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.
- package/README.md +122 -225
- package/dist/chat-target.d.ts +9 -0
- package/dist/chat-target.js +12 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +13 -445
- package/dist/presence.d.ts +12 -0
- package/dist/presence.js +55 -145
- package/dist/server.d.ts +26 -0
- package/dist/server.js +145 -0
- package/dist/store.d.ts +63 -0
- package/dist/store.js +181 -0
- package/dist/validation.d.ts +9 -0
- package/dist/validation.js +60 -0
- package/package.json +14 -24
- package/dist/config.js +0 -104
- package/dist/daemon.js +0 -353
- package/dist/http.js +0 -31
- package/dist/log-store.js +0 -48
- package/dist/provision.js +0 -29
- package/dist/push.js +0 -6
- package/dist/web-push-sender.js +0 -41
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +0 -21
- package/mufl_code/core/.github/FUNDING.yml +0 -3
- package/mufl_code/core/.github/workflows/docs.yml +0 -39
- package/mufl_code/core/.github/workflows/tests.yml +0 -66
- package/mufl_code/core/COMMERCIAL-LICENCE.md +0 -25
- package/mufl_code/core/COMPATIBILITY.md +0 -221
- package/mufl_code/core/CONTRIBUTING.md +0 -37
- package/mufl_code/core/LICENSE +0 -98
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +0 -290
- package/mufl_code/core/README.md +0 -91
- package/mufl_code/core/SECURITY.md +0 -40
- package/mufl_code/core/TRADEMARKS.md +0 -24
- package/mufl_code/core/a2a_capabilities.mm +0 -449
- package/mufl_code/core/a2a_cluster.mm +0 -601
- package/mufl_code/core/a2a_control.mm +0 -90
- package/mufl_code/core/a2a_messaging.mm +0 -4817
- package/mufl_code/core/a2a_monitoring.mm +0 -94
- package/mufl_code/core/a2a_notification_integration.mm +0 -144
- package/mufl_code/core/a2a_notifications.mm +0 -1127
- package/mufl_code/core/a2a_protocol.mm +0 -246
- package/mufl_code/core/a2a_versions.mm +0 -801
- package/mufl_code/core/config.mufl +0 -29
- package/mufl_code/core/docs/.vitepress/config.mts +0 -58
- package/mufl_code/core/docs/citations.json +0 -688
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +0 -50
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +0 -100
- package/mufl_code/core/docs/guide/03-wire-the-host.md +0 -154
- package/mufl_code/core/docs/guide/04-connect-and-message.md +0 -156
- package/mufl_code/core/docs/guide/05-test-your-app.md +0 -75
- package/mufl_code/core/docs/guide/index.md +0 -63
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +0 -77
- package/mufl_code/core/docs/how-it-works/cluster.md +0 -65
- package/mufl_code/core/docs/how-it-works/identity.md +0 -56
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +0 -76
- package/mufl_code/core/docs/how-it-works/messaging.md +0 -55
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +0 -72
- package/mufl_code/core/docs/how-it-works/overview.md +0 -45
- package/mufl_code/core/docs/how-it-works/versioning.md +0 -116
- package/mufl_code/core/docs/index.md +0 -27
- package/mufl_code/core/docs/package-lock.json +0 -3467
- package/mufl_code/core/docs/package.json +0 -15
- package/mufl_code/core/docs/reference/contributing.md +0 -26
- package/mufl_code/core/docs/reference/glossary.md +0 -108
- package/mufl_code/core/docs/reference/implementations.md +0 -23
- package/mufl_code/core/docs/reference/modules.md +0 -19
- package/mufl_code/core/docs/scripts/check-citations.mjs +0 -36
- package/mufl_code/core/docs/scripts/check-denylist.mjs +0 -30
- package/mufl_code/core/docs/scripts/gen-llms.mjs +0 -32
- package/mufl_code/core/docs/workflows/cluster.md +0 -89
- package/mufl_code/core/docs/workflows/contact-exchange.md +0 -60
- package/mufl_code/core/docs/workflows/contact-restore.md +0 -60
- package/mufl_code/core/docs/workflows/control-verbs.md +0 -70
- package/mufl_code/core/docs/workflows/index.md +0 -57
- package/mufl_code/core/docs/workflows/introductions.md +0 -60
- package/mufl_code/core/docs/workflows/messaging.md +0 -75
- package/mufl_code/core/docs/workflows/monitoring.md +0 -84
- package/mufl_code/core/docs/workflows/notifications.md +0 -109
- package/mufl_code/core/release-notes/0.1.md +0 -60
- package/mufl_code/core/release-notes/0.3.md +0 -22
- package/mufl_code/core/release-notes/0.4.md +0 -97
- package/mufl_code/core/tests/README.md +0 -83
- package/mufl_code/core/tests/compat/README.md +0 -82
- package/mufl_code/core/tests/compat/compat.mjs +0 -245
- package/mufl_code/core/tests/compat/compat_peer.mjs +0 -146
- package/mufl_code/core/tests/compat/run_compat.sh +0 -96
- package/mufl_code/core/tests/corpus.mjs +0 -139
- package/mufl_code/core/tests/corpus_actor.mu +0 -242
- package/mufl_code/core/tests/fatcompile_actor.mu +0 -52
- package/mufl_code/core/tests/mig.mjs +0 -805
- package/mufl_code/core/tests/mig_actor.mu +0 -518
- package/mufl_code/core/tests/migapp.mjs +0 -431
- package/mufl_code/core/tests/migapp_actor.mu +0 -294
- package/mufl_code/core/tests/mufl_semantics/run.sh +0 -69
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +0 -47
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +0 -11
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +0 -30
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +0 -19
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +0 -19
- package/mufl_code/core/tests/notif.mjs +0 -755
- package/mufl_code/core/tests/notif_actor.mu +0 -450
- package/mufl_code/core/tests/protocol_container.mm +0 -18
- package/mufl_code/core/tests/run.sh +0 -65
- package/mufl_code/core/tests/run_corpus.sh +0 -51
- package/mufl_code/core/tests/run_fatcompile.sh +0 -40
- package/mufl_code/core/tests/run_mig.sh +0 -51
- package/mufl_code/core/tests/run_migapp.sh +0 -51
- package/mufl_code/core/tests/run_notif.sh +0 -57
- package/mufl_code/core/tests/test.mjs +0 -750
- package/mufl_code/core/tests/test_actor.mu +0 -564
- package/mufl_code/core/tests/test_common.mjs +0 -93
- package/mufl_code/core/version.mm +0 -24
- package/mufl_code/packet.mu +0 -170
- package/mufl_code/protocol_container.mm +0 -18
- package/scripts/gen-vapid.mjs +0 -16
package/dist/presence.js
CHANGED
|
@@ -1,157 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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 {
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
ws.ping();
|
|
143
|
-
}
|
|
144
|
-
catch { /* terminating */ }
|
|
60
|
+
p.pong = false;
|
|
61
|
+
ws.ping();
|
|
145
62
|
}
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -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;
|