@openchambery/relay-server 1.19.0-beta.9 → 1.19.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.
@@ -0,0 +1,276 @@
1
+ import http from 'node:http';
2
+
3
+ import { createApnsProvider } from './apns.js';
4
+ import { normalizePushRelayOptions, resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
5
+ import { deriveServerId, verifyP1363 } from './crypto.js';
6
+ import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
7
+ import { JSON_BODY_BYTES, validateRegisterBody, validateSendBody } from './schema.js';
8
+ import { createTokenStore } from './store.js';
9
+
10
+ const WINDOW_MS = 60_000;
11
+ const STOP_DEADLINE_MS = 5_500;
12
+
13
+ const sendJson = (response, status, payload, method = 'GET') => {
14
+ if (response.writableEnded) return;
15
+ response.setHeader('cache-control', 'no-store');
16
+ response.writeHead(status, { 'content-type': 'application/json' });
17
+ response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
18
+ };
19
+
20
+ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
21
+ let done = false;
22
+ const fail = (error) => { if (done) return; done = true; reject(error); };
23
+ const succeed = (value) => { if (done) return; done = true; resolve(value); };
24
+ const tooLarge = () => {
25
+ const error = new Error('payload too large');
26
+ error.code = 'PAYLOAD_TOO_LARGE';
27
+ fail(error);
28
+ };
29
+ const drain = () => {
30
+ request.removeListener('data', onData);
31
+ request.resume();
32
+ };
33
+ const declared = Number(request.headers['content-length']);
34
+ if (Number.isFinite(declared) && declared > maxBytes) {
35
+ tooLarge();
36
+ request.resume();
37
+ return;
38
+ }
39
+ const chunks = [];
40
+ let size = 0;
41
+ const onData = (chunk) => {
42
+ size += chunk.length;
43
+ if (size > maxBytes) {
44
+ drain();
45
+ tooLarge();
46
+ return;
47
+ }
48
+ chunks.push(chunk);
49
+ };
50
+ request.on('data', onData);
51
+ request.on('end', () => succeed(Buffer.concat(chunks)));
52
+ request.on('error', fail);
53
+ });
54
+
55
+ export const createPushRelayServer = (options = {}) => {
56
+ const config = normalizePushRelayOptions(options);
57
+ const limits = config.limits;
58
+ const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
59
+ const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
60
+ const ownedStore = !config.store;
61
+ const ownedApns = !config.apnsProvider;
62
+ const openStore = () => config.store ?? createTokenStore(config.databasePath);
63
+ const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
64
+ let store = openStore();
65
+ let apns;
66
+ try {
67
+ apns = openApns();
68
+ } catch (error) {
69
+ if (ownedStore) try { store.close(); } catch { /* ignore */ }
70
+ throw error;
71
+ }
72
+ const liveStore = () => {
73
+ if (!ownedStore) return store;
74
+ if (state === 'stopping') return store;
75
+ try { store.count(); return store; } catch {
76
+ store = createTokenStore(config.databasePath);
77
+ return store;
78
+ }
79
+ };
80
+ const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
81
+ const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
82
+ const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
83
+ const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
84
+ const inFlight = createInFlightGate(limits.maxInFlight);
85
+ const httpWork = createWorkTracker();
86
+ const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
87
+ let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
88
+ const snapshot = () => {
89
+ let tokenCount = 0;
90
+ try { tokenCount = store.count(); } catch { /* closed after stop */ }
91
+ return { state, tokenCount, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
92
+ };
93
+
94
+ const authenticate = (jwk, message, signature, ts) => {
95
+ if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
96
+ if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
97
+ return null;
98
+ };
99
+
100
+ const handleRegister = (parsed) => {
101
+ const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}`, parsed.sig, parsed.ts);
102
+ if (authError) return { status: 401, body: { error: authError } };
103
+ const serverId = deriveServerId(parsed.publicKeyJwk);
104
+ const replayKey = `register.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
105
+ if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
106
+ const tokens = liveStore();
107
+ const existing = tokens.get(parsed.token);
108
+ if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
109
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
110
+ tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
111
+ return { status: 200, body: { ok: true } };
112
+ };
113
+
114
+ const handleSend = async (parsed) => {
115
+ const sorted = [...parsed.tokens].sort();
116
+ const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
117
+ if (authError) return { status: 401, body: { error: authError } };
118
+ const serverId = deriveServerId(parsed.publicKeyJwk);
119
+ const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
120
+ if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
121
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
122
+ if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
123
+ const tokens = liveStore();
124
+ const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
125
+ const binding = tokens.get(token);
126
+ if (!binding || binding.serverId !== serverId) return { token, ok: false };
127
+ const acquired = await inFlight.acquire();
128
+ if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
129
+ try {
130
+ const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
131
+ if (outcome?.drop === true) {
132
+ tokens.delete(token);
133
+ return { token, ok: false, drop: true };
134
+ }
135
+ return { token, ok: outcome?.ok === true };
136
+ } catch {
137
+ return { token, ok: false };
138
+ } finally {
139
+ inFlight.release(acquired);
140
+ }
141
+ }));
142
+ return { status: 200, body: { results } };
143
+ };
144
+
145
+ const onRequest = (request, response) => {
146
+ let pathname;
147
+ try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch { response.writeHead(404); response.end(); return; }
148
+ const ready = pathname === '/readyz' && state === 'running';
149
+ const healthy = pathname === '/healthz';
150
+ if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
151
+ sendJson(response, 200, { status: 'ok' }, request.method);
152
+ return;
153
+ }
154
+ const isRegister = pathname === '/v1/push/register-token';
155
+ const isSend = pathname === '/v1/push/send';
156
+ if (request.method !== 'POST' || (!isRegister && !isSend) || state !== 'running') { response.writeHead(404); response.end(); return; }
157
+ const ip = resolveClientIp(request);
158
+ const limiter = isRegister ? registerIpLimit : sendIpLimit;
159
+ if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
160
+ const endHttp = httpWork.begin();
161
+ readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
162
+ let body;
163
+ try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
164
+ const parsed = isRegister ? validateRegisterBody(body) : validateSendBody(body);
165
+ if (parsed.error) {
166
+ reasons.policyRejected += 1;
167
+ sendJson(response, 400, { error: parsed.error });
168
+ return;
169
+ }
170
+ const result = isRegister ? handleRegister(parsed.value) : await handleSend(parsed.value);
171
+ sendJson(response, result.status, result.body);
172
+ }).catch((error) => {
173
+ if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
174
+ sendJson(response, 500, { error: 'internal' });
175
+ }).finally(endHttp);
176
+ };
177
+
178
+ const start = () => {
179
+ if (state === 'running') return Promise.resolve();
180
+ if (state === 'stopping') return stopPromise.then(() => start());
181
+ if (startPromise) return startPromise;
182
+ liveStore();
183
+ inFlight.reset();
184
+ httpWork.reset();
185
+ if (ownedApns && state === 'stopped') apns = openApns();
186
+ state = 'starting';
187
+ const localGeneration = ++generation;
188
+ const localServer = http.createServer(onRequest);
189
+ server = localServer;
190
+ startPromise = new Promise((resolve, rejectStart) => {
191
+ const failStart = (error) => {
192
+ if (localGeneration !== generation) return;
193
+ localServer.off('listening', ready);
194
+ cleanupStart();
195
+ rejectStart(error);
196
+ };
197
+ const ready = () => {
198
+ localServer.off('error', failStart);
199
+ if (localGeneration !== generation || state !== 'starting') return;
200
+ state = 'running';
201
+ resolve();
202
+ };
203
+ const cleanupStart = () => {
204
+ localServer.close();
205
+ if (server === localServer) server = null;
206
+ state = 'stopped';
207
+ };
208
+ abortStart = () => {
209
+ if (state === 'starting') { cleanupStart(); rejectStart(new Error('push relay stopped during start')); }
210
+ };
211
+ localServer.once('error', failStart);
212
+ localServer.once('listening', ready);
213
+ localServer.listen(config.port, config.host);
214
+ }).finally(() => { startPromise = null; abortStart = null; });
215
+ return startPromise;
216
+ };
217
+
218
+ const stop = () => {
219
+ if (stopPromise) return stopPromise;
220
+ if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
221
+ if (state === 'starting') abortStart?.();
222
+ state = 'stopping';
223
+ generation += 1;
224
+ const localServer = server;
225
+ stopPromise = Promise.resolve().then(async () => {
226
+ inFlight.rejectWaiters();
227
+ replay.clear();
228
+ registerIpLimit.clear();
229
+ sendIpLimit.clear();
230
+ sendServerLimit.clear();
231
+ let deadlineTimer = null;
232
+ try {
233
+ const closed = new Promise((resolve) => {
234
+ if (!localServer) { resolve(); return; }
235
+ localServer.close(() => resolve());
236
+ try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
237
+ });
238
+ const httpIdle = httpWork.whenIdle().then(() => {
239
+ try { localServer?.closeIdleConnections?.(); } catch { /* ignore */ }
240
+ });
241
+ const graceful = Promise.all([closed, inFlight.whenIdle(), httpIdle]);
242
+ const deadline = new Promise((resolve) => {
243
+ deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
244
+ });
245
+ const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
246
+ if (winner === 'deadline') {
247
+ try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
248
+ }
249
+ } finally {
250
+ if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
251
+ }
252
+ try { apns.close?.(); } catch { /* ignore */ }
253
+ if (ownedStore) try { store.close(); } catch { /* ignore */ }
254
+ if (server === localServer) { server = null; state = 'stopped'; }
255
+ stopPromise = null;
256
+ });
257
+ return stopPromise;
258
+ };
259
+
260
+ return {
261
+ start,
262
+ stop,
263
+ address: () => server?.address(),
264
+ get url() {
265
+ const address = server?.address();
266
+ return address && typeof address === 'object' ? formatPushRelayUrl(config.host, address.port) : null;
267
+ },
268
+ getSnapshot: snapshot,
269
+ };
270
+ };
271
+
272
+ export const startPushRelayServer = async (options) => {
273
+ const server = createPushRelayServer(options);
274
+ await server.start();
275
+ return server;
276
+ };
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+
5
+ const SCHEMA = `CREATE TABLE IF NOT EXISTS push_tokens (
6
+ token TEXT PRIMARY KEY,
7
+ server_id TEXT NOT NULL,
8
+ platform TEXT NOT NULL,
9
+ updated_at INTEGER NOT NULL
10
+ ) STRICT`;
11
+
12
+ export const createTokenStore = (databasePath) => {
13
+ if (typeof databasePath !== 'string' || databasePath.length === 0 || databasePath.includes('\0')) {
14
+ throw new RangeError('invalid database path');
15
+ }
16
+ if (databasePath !== ':memory:') {
17
+ fs.mkdirSync(path.dirname(path.resolve(databasePath)), { recursive: true });
18
+ }
19
+ const db = new DatabaseSync(databasePath, { timeout: 5_000 });
20
+ db.exec('PRAGMA journal_mode = WAL');
21
+ db.exec('PRAGMA busy_timeout = 5000');
22
+ db.exec(SCHEMA);
23
+ const getStmt = db.prepare('SELECT server_id AS serverId, platform, updated_at AS updatedAt FROM push_tokens WHERE token = ?');
24
+ const upsertStmt = db.prepare(`INSERT INTO push_tokens (token, server_id, platform, updated_at) VALUES (?, ?, ?, ?)
25
+ ON CONFLICT(token) DO UPDATE SET server_id = excluded.server_id, platform = excluded.platform, updated_at = excluded.updated_at`);
26
+ const deleteStmt = db.prepare('DELETE FROM push_tokens WHERE token = ?');
27
+ const countStmt = db.prepare('SELECT COUNT(*) AS n FROM push_tokens');
28
+ let closed = false;
29
+ const assertOpen = () => { if (closed) throw new Error('token store closed'); };
30
+ return {
31
+ get(token) {
32
+ assertOpen();
33
+ const row = getStmt.get(token);
34
+ return row ? { serverId: row.serverId, platform: row.platform, updatedAt: row.updatedAt } : null;
35
+ },
36
+ upsert(token, serverId, platform, updatedAt) {
37
+ assertOpen();
38
+ upsertStmt.run(token, serverId, platform, updatedAt);
39
+ },
40
+ delete(token) {
41
+ assertOpen();
42
+ deleteStmt.run(token);
43
+ },
44
+ count() {
45
+ assertOpen();
46
+ return Number(countStmt.get().n);
47
+ },
48
+ close() {
49
+ if (closed) return;
50
+ closed = true;
51
+ try { db.close(); } catch { /* already closed */ }
52
+ },
53
+ };
54
+ };