@openchambery/relay-server 1.18.7-beta.1 → 1.18.7-beta.3
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/DOCUMENTATION.md +123 -6
- package/README.md +156 -4
- package/bin/openchamber-push-relay.js +11 -0
- package/package.json +11 -5
- package/src/cli.js +20 -1
- package/src/push/apns.js +138 -0
- package/src/push/cli.js +73 -0
- package/src/push/combined.js +34 -0
- package/src/push/config.js +145 -0
- package/src/push/crypto.js +34 -0
- package/src/push/guard.js +145 -0
- package/src/push/handler.js +292 -0
- package/src/push/index.d.ts +111 -0
- package/src/push/index.js +3 -0
- package/src/push/schema.js +185 -0
- package/src/push/server.js +103 -0
- package/src/push/store.js +54 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createApnsProvider } from './apns.js';
|
|
2
|
+
import { normalizePushRelayOptions, resolvePushRelayClientIp } from './config.js';
|
|
3
|
+
import { deriveServerId, verifyP1363 } from './crypto.js';
|
|
4
|
+
import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
|
|
5
|
+
import { JSON_BODY_BYTES, buildLiveActivityPayload, validateLiveActivityBody, validateLiveActivityRegisterBody, validateRegisterBody, validateSendBody } from './schema.js';
|
|
6
|
+
import { createTokenStore } from './store.js';
|
|
7
|
+
|
|
8
|
+
const WINDOW_MS = 60_000;
|
|
9
|
+
const STOP_DEADLINE_MS = 5_500;
|
|
10
|
+
|
|
11
|
+
const sendJson = (response, status, payload, method = 'GET') => {
|
|
12
|
+
if (response.writableEnded) return;
|
|
13
|
+
response.setHeader('cache-control', 'no-store');
|
|
14
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
15
|
+
response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
|
|
19
|
+
let done = false;
|
|
20
|
+
const fail = (error) => { if (done) return; done = true; reject(error); };
|
|
21
|
+
const succeed = (value) => { if (done) return; done = true; resolve(value); };
|
|
22
|
+
const tooLarge = () => {
|
|
23
|
+
const error = new Error('payload too large');
|
|
24
|
+
error.code = 'PAYLOAD_TOO_LARGE';
|
|
25
|
+
fail(error);
|
|
26
|
+
};
|
|
27
|
+
const drain = () => {
|
|
28
|
+
request.removeListener('data', onData);
|
|
29
|
+
request.resume();
|
|
30
|
+
};
|
|
31
|
+
const declared = Number(request.headers['content-length']);
|
|
32
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
33
|
+
tooLarge();
|
|
34
|
+
request.resume();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
const onData = (chunk) => {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > maxBytes) {
|
|
42
|
+
drain();
|
|
43
|
+
tooLarge();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
};
|
|
48
|
+
request.on('data', onData);
|
|
49
|
+
request.on('end', () => succeed(Buffer.concat(chunks)));
|
|
50
|
+
request.on('error', fail);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const createPushRelayHandler = (options = {}) => {
|
|
54
|
+
const claimHealthEndpoints = options.claimHealthEndpoints !== false;
|
|
55
|
+
const config = normalizePushRelayOptions(options);
|
|
56
|
+
const limits = config.limits;
|
|
57
|
+
const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
|
|
58
|
+
const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
|
|
59
|
+
const ownedStore = !config.store;
|
|
60
|
+
const ownedApns = !config.apnsProvider;
|
|
61
|
+
const openStore = () => config.store ?? createTokenStore(config.databasePath);
|
|
62
|
+
const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
|
|
63
|
+
let store = openStore();
|
|
64
|
+
let apns;
|
|
65
|
+
try {
|
|
66
|
+
apns = openApns();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const liveStore = () => {
|
|
72
|
+
if (!ownedStore) return store;
|
|
73
|
+
if (state === 'stopping') return store;
|
|
74
|
+
try { store.count(); return store; } catch {
|
|
75
|
+
store = createTokenStore(config.databasePath);
|
|
76
|
+
return store;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
|
|
80
|
+
const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
81
|
+
const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
82
|
+
const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
83
|
+
const inFlight = createInFlightGate(limits.maxInFlight);
|
|
84
|
+
const httpWork = createWorkTracker();
|
|
85
|
+
const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
|
|
86
|
+
let stopPromise = null; let state = 'idle';
|
|
87
|
+
const snapshot = () => {
|
|
88
|
+
let tokenCount = 0;
|
|
89
|
+
try { tokenCount = store.count(); } catch { /* closed after stop */ }
|
|
90
|
+
return { state, tokenCount, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const authenticate = (jwk, message, signature, ts) => {
|
|
94
|
+
if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
|
|
95
|
+
if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleRegister = (parsed) => {
|
|
100
|
+
const message = parsed.kind
|
|
101
|
+
? `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`
|
|
102
|
+
: `${parsed.ts}.${parsed.token}.${parsed.platform}`;
|
|
103
|
+
const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
|
|
104
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
105
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
106
|
+
const replayKey = `${parsed.kind ? 'register-live-activity' : 'register'}.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
107
|
+
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
108
|
+
const tokens = liveStore();
|
|
109
|
+
const existing = tokens.get(parsed.token);
|
|
110
|
+
if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
|
|
111
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
112
|
+
tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
|
|
113
|
+
return { status: 200, body: { ok: true } };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleUnregisterLiveActivity = (parsed) => {
|
|
117
|
+
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`, parsed.sig, parsed.ts);
|
|
118
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
119
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
120
|
+
const replayKey = `unregister-live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
121
|
+
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
122
|
+
const tokens = liveStore();
|
|
123
|
+
const existing = tokens.get(parsed.token);
|
|
124
|
+
if (existing && existing.serverId !== serverId) { reasons.authRejected += 1; return { status: 401, body: { error: 'invalid_signature' } }; }
|
|
125
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
126
|
+
if (existing) tokens.delete(parsed.token);
|
|
127
|
+
return { status: 200, body: { ok: true } };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSend = async (parsed) => {
|
|
131
|
+
const sorted = [...parsed.tokens].sort();
|
|
132
|
+
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
|
|
133
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
134
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
135
|
+
const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
136
|
+
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
137
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
138
|
+
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
139
|
+
const tokens = liveStore();
|
|
140
|
+
const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
|
|
141
|
+
const binding = tokens.get(token);
|
|
142
|
+
if (!binding || binding.serverId !== serverId) return { token, ok: false };
|
|
143
|
+
const acquired = await inFlight.acquire();
|
|
144
|
+
if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
|
|
145
|
+
try {
|
|
146
|
+
const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
|
|
147
|
+
if (outcome?.drop === true) {
|
|
148
|
+
tokens.delete(token);
|
|
149
|
+
return { token, ok: false, drop: true };
|
|
150
|
+
}
|
|
151
|
+
return { token, ok: outcome?.ok === true };
|
|
152
|
+
} catch {
|
|
153
|
+
return { token, ok: false };
|
|
154
|
+
} finally {
|
|
155
|
+
inFlight.release(acquired);
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
return { status: 200, body: { results } };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleLiveActivity = async (parsed) => {
|
|
162
|
+
const sorted = [...parsed.tokens].sort();
|
|
163
|
+
const contentState = parsed.contentState;
|
|
164
|
+
const message = `${parsed.ts}.${sorted.join(',')}.${parsed.event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${parsed.dismissalDate ?? ''}.${parsed.staleDate ?? ''}`;
|
|
165
|
+
const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
|
|
166
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
167
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
168
|
+
const replayKey = `live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
169
|
+
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
170
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
171
|
+
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
172
|
+
const payload = buildLiveActivityPayload({
|
|
173
|
+
event: parsed.event,
|
|
174
|
+
contentState,
|
|
175
|
+
dismissalDate: parsed.dismissalDate,
|
|
176
|
+
staleDate: parsed.staleDate,
|
|
177
|
+
timestamp: Math.floor(clock.now() / 1000),
|
|
178
|
+
});
|
|
179
|
+
const tokens = liveStore();
|
|
180
|
+
const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
|
|
181
|
+
const binding = tokens.get(token);
|
|
182
|
+
if (!binding || binding.serverId !== serverId) return { token, ok: false };
|
|
183
|
+
const acquired = await inFlight.acquire();
|
|
184
|
+
if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
|
|
185
|
+
try {
|
|
186
|
+
const outcome = await apns.send({ token, env: parsed.env, payload, pushType: 'liveactivity' });
|
|
187
|
+
if (outcome?.drop === true) {
|
|
188
|
+
tokens.delete(token);
|
|
189
|
+
return { token, ok: false, drop: true };
|
|
190
|
+
}
|
|
191
|
+
if (outcome?.ok === true && parsed.event === 'end') tokens.delete(token);
|
|
192
|
+
return { token, ok: outcome?.ok === true };
|
|
193
|
+
} catch {
|
|
194
|
+
return { token, ok: false };
|
|
195
|
+
} finally {
|
|
196
|
+
inFlight.release(acquired);
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
return { status: 200, body: { results } };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handleRequest = (request, response) => {
|
|
203
|
+
let pathname;
|
|
204
|
+
try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch {
|
|
205
|
+
if (!claimHealthEndpoints) return false;
|
|
206
|
+
response.writeHead(404); response.end(); return true;
|
|
207
|
+
}
|
|
208
|
+
if (!claimHealthEndpoints && !pathname.startsWith('/v1/push/')) return false;
|
|
209
|
+
const ready = pathname === '/readyz' && state === 'running';
|
|
210
|
+
const healthy = pathname === '/healthz';
|
|
211
|
+
if (claimHealthEndpoints && (healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
212
|
+
sendJson(response, 200, { status: 'ok' }, request.method);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
const isRegister = pathname === '/v1/push/register-token';
|
|
216
|
+
const isRegisterLive = pathname === '/v1/push/register-live-activity-token';
|
|
217
|
+
const isUnregisterLive = pathname === '/v1/push/unregister-live-activity-token';
|
|
218
|
+
const isSend = pathname === '/v1/push/send';
|
|
219
|
+
const isLiveActivity = pathname === '/v1/push/live-activity';
|
|
220
|
+
if (request.method !== 'POST' || (!isRegister && !isRegisterLive && !isUnregisterLive && !isSend && !isLiveActivity) || state !== 'running') { response.writeHead(404); response.end(); return true; }
|
|
221
|
+
const ip = resolveClientIp(request);
|
|
222
|
+
const limiter = (isRegister || isRegisterLive || isUnregisterLive) ? registerIpLimit : sendIpLimit;
|
|
223
|
+
if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return true; }
|
|
224
|
+
const endHttp = httpWork.begin();
|
|
225
|
+
readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
|
|
226
|
+
let body;
|
|
227
|
+
try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
|
|
228
|
+
const parsed = isRegister ? validateRegisterBody(body)
|
|
229
|
+
: (isRegisterLive || isUnregisterLive) ? validateLiveActivityRegisterBody(body)
|
|
230
|
+
: isLiveActivity ? validateLiveActivityBody(body)
|
|
231
|
+
: validateSendBody(body);
|
|
232
|
+
if (parsed.error) {
|
|
233
|
+
reasons.policyRejected += 1;
|
|
234
|
+
sendJson(response, 400, { error: parsed.error });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const result = (isRegister || isRegisterLive) ? handleRegister(parsed.value)
|
|
238
|
+
: isUnregisterLive ? handleUnregisterLiveActivity(parsed.value)
|
|
239
|
+
: isLiveActivity ? await handleLiveActivity(parsed.value)
|
|
240
|
+
: await handleSend(parsed.value);
|
|
241
|
+
sendJson(response, result.status, result.body);
|
|
242
|
+
}).catch((error) => {
|
|
243
|
+
if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
|
|
244
|
+
sendJson(response, 500, { error: 'internal' });
|
|
245
|
+
}).finally(endHttp);
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const activate = () => {
|
|
250
|
+
if (state === 'running') return;
|
|
251
|
+
liveStore();
|
|
252
|
+
inFlight.reset();
|
|
253
|
+
httpWork.reset();
|
|
254
|
+
if (ownedApns && state === 'stopped') apns = openApns();
|
|
255
|
+
state = 'running';
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const deactivate = () => {
|
|
259
|
+
if (stopPromise) return stopPromise;
|
|
260
|
+
if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
|
|
261
|
+
state = 'stopping';
|
|
262
|
+
stopPromise = Promise.resolve().then(async () => {
|
|
263
|
+
inFlight.rejectWaiters();
|
|
264
|
+
replay.clear();
|
|
265
|
+
registerIpLimit.clear();
|
|
266
|
+
sendIpLimit.clear();
|
|
267
|
+
sendServerLimit.clear();
|
|
268
|
+
let deadlineTimer = null;
|
|
269
|
+
try {
|
|
270
|
+
const graceful = Promise.all([inFlight.whenIdle(), httpWork.whenIdle()]);
|
|
271
|
+
const deadline = new Promise((resolve) => {
|
|
272
|
+
deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
|
|
273
|
+
});
|
|
274
|
+
await Promise.race([graceful.then(() => 'graceful'), deadline]);
|
|
275
|
+
} finally {
|
|
276
|
+
if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
|
|
277
|
+
}
|
|
278
|
+
try { apns.close?.(); } catch { /* ignore */ }
|
|
279
|
+
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
280
|
+
state = 'stopped';
|
|
281
|
+
stopPromise = null;
|
|
282
|
+
});
|
|
283
|
+
return stopPromise;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
handleRequest,
|
|
288
|
+
activate,
|
|
289
|
+
deactivate,
|
|
290
|
+
getSnapshot: snapshot,
|
|
291
|
+
};
|
|
292
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
|
|
4
|
+
export type PushRelayState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped';
|
|
5
|
+
export type PushRelayEnv = 'production' | 'sandbox';
|
|
6
|
+
|
|
7
|
+
export interface PushRelayLimits {
|
|
8
|
+
timestampSkewMs: number;
|
|
9
|
+
replayMs: number;
|
|
10
|
+
maxReplayEntries: number;
|
|
11
|
+
registerLimitPerMinute: number;
|
|
12
|
+
sendLimitPerMinute: number;
|
|
13
|
+
serverSendLimitPerMinute: number;
|
|
14
|
+
maxTokens: number;
|
|
15
|
+
maxInFlight: number;
|
|
16
|
+
maxRateLimitEntries: number;
|
|
17
|
+
jsonBodyBytes: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PushRelayApnsConfig {
|
|
21
|
+
keyId: string;
|
|
22
|
+
teamId: string;
|
|
23
|
+
p8: string;
|
|
24
|
+
bundleId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PushRelayClock {
|
|
28
|
+
now: () => number;
|
|
29
|
+
setTimeout: typeof globalThis.setTimeout;
|
|
30
|
+
clearTimeout: typeof globalThis.clearTimeout;
|
|
31
|
+
setInterval: typeof globalThis.setInterval;
|
|
32
|
+
clearInterval: typeof globalThis.clearInterval;
|
|
33
|
+
setImmediate: typeof globalThis.setImmediate;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PushApnsSendInput {
|
|
37
|
+
token: string;
|
|
38
|
+
env: PushRelayEnv;
|
|
39
|
+
payload: unknown;
|
|
40
|
+
collapseId?: string;
|
|
41
|
+
pushType?: 'alert' | 'liveactivity';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PushApnsSendResult {
|
|
45
|
+
ok: boolean;
|
|
46
|
+
drop?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PushApnsProvider {
|
|
50
|
+
send(input: PushApnsSendInput): Promise<PushApnsSendResult>;
|
|
51
|
+
close?(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PushTokenRecord {
|
|
55
|
+
serverId: string;
|
|
56
|
+
platform: string;
|
|
57
|
+
updatedAt: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PushTokenStore {
|
|
61
|
+
get(token: string): PushTokenRecord | null;
|
|
62
|
+
upsert(token: string, serverId: string, platform: string, updatedAt: number): void;
|
|
63
|
+
delete(token: string): void;
|
|
64
|
+
count(): number;
|
|
65
|
+
close(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PushRelaySnapshotReasons {
|
|
69
|
+
authRejected: number;
|
|
70
|
+
policyRejected: number;
|
|
71
|
+
limited: number;
|
|
72
|
+
replayRejected: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PushRelaySnapshot {
|
|
76
|
+
state: PushRelayState;
|
|
77
|
+
tokenCount: number;
|
|
78
|
+
inFlight: number;
|
|
79
|
+
replayEntries: number;
|
|
80
|
+
reasons: PushRelaySnapshotReasons;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PushRelayOptions {
|
|
84
|
+
host?: string;
|
|
85
|
+
port?: number;
|
|
86
|
+
trustProxy?: boolean;
|
|
87
|
+
databasePath?: string;
|
|
88
|
+
limits?: Partial<PushRelayLimits>;
|
|
89
|
+
apns?: PushRelayApnsConfig;
|
|
90
|
+
apnsProvider?: PushApnsProvider;
|
|
91
|
+
clock?: Partial<PushRelayClock>;
|
|
92
|
+
logger?: Pick<Console, 'info' | 'warn' | 'error'>;
|
|
93
|
+
resolveClientIp?: (request: IncomingMessage) => string;
|
|
94
|
+
store?: PushTokenStore;
|
|
95
|
+
http2?: { connect: typeof import('node:http2').connect };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PushRelayServerInstance {
|
|
99
|
+
start(): Promise<void>;
|
|
100
|
+
stop(): Promise<void>;
|
|
101
|
+
address(): string | AddressInfo | null | undefined;
|
|
102
|
+
readonly url: string | null;
|
|
103
|
+
getSnapshot(): PushRelaySnapshot;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolvePushRelayClientIp(request: IncomingMessage, trustProxy?: boolean): string;
|
|
107
|
+
export function formatPushRelayUrl(host: string, port: number): string;
|
|
108
|
+
export function canonicalPublicJwkString(jwk: { crv: string; kty: string; x: string; y: string }): string;
|
|
109
|
+
export function deriveServerId(jwk: { crv: string; kty: string; x: string; y: string }): string;
|
|
110
|
+
export function createPushRelayServer(options?: PushRelayOptions): PushRelayServerInstance;
|
|
111
|
+
export function startPushRelayServer(options?: PushRelayOptions): Promise<PushRelayServerInstance>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { b64urlExact, parsePublicJwk } from './crypto.js';
|
|
2
|
+
|
|
3
|
+
export const IOS_TOKEN = /^[0-9a-fA-F]{64}$/;
|
|
4
|
+
export const JSON_BODY_BYTES = 16 * 1024;
|
|
5
|
+
export const APNS_PAYLOAD_BYTES = 4096;
|
|
6
|
+
export const MAX_TOKENS_PER_REQUEST = 100;
|
|
7
|
+
export const MAX_TITLE_BYTES = 256;
|
|
8
|
+
export const MAX_BODY_BYTES = 1024;
|
|
9
|
+
export const MAX_COLLAPSE_ID_BYTES = 64;
|
|
10
|
+
export const MAX_DATA_ENTRIES = 16;
|
|
11
|
+
export const MAX_DATA_KEY_BYTES = 64;
|
|
12
|
+
export const MAX_DATA_VALUE_BYTES = 256;
|
|
13
|
+
export const MAX_DATA_TOTAL_BYTES = 2048;
|
|
14
|
+
export const LIVE_ACTIVITY_KIND = 'liveactivity';
|
|
15
|
+
export const LIVE_ACTIVITY_EVENTS = new Set(['update', 'end']);
|
|
16
|
+
export const LIVE_ACTIVITY_STATUSES = new Set(['working', 'tool', 'retry', 'input', 'permission', 'stale', 'complete', 'error']);
|
|
17
|
+
export const LIVE_ACTIVITY_CONTENT_KEYS = new Set(['status', 'eventVersion', 'updatedAt', 'endedAt']);
|
|
18
|
+
|
|
19
|
+
const bytes = (value) => Buffer.byteLength(value, 'utf8');
|
|
20
|
+
const isSafeInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
|
|
21
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
22
|
+
|
|
23
|
+
export const isIosToken = (value) => typeof value === 'string' && IOS_TOKEN.test(value);
|
|
24
|
+
|
|
25
|
+
const parseTs = (value) => (isSafeInt(value) ? value : null);
|
|
26
|
+
const parseSig = (value) => b64urlExact(value, 64);
|
|
27
|
+
|
|
28
|
+
const parseData = (value) => {
|
|
29
|
+
if (value === undefined) return {};
|
|
30
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
31
|
+
const entries = Object.entries(value);
|
|
32
|
+
if (entries.length > MAX_DATA_ENTRIES) return null;
|
|
33
|
+
let total = 0;
|
|
34
|
+
const data = {};
|
|
35
|
+
for (const [key, entry] of entries) {
|
|
36
|
+
if (typeof key !== 'string' || key.length === 0 || key === 'aps' || typeof entry !== 'string') return null;
|
|
37
|
+
const keyBytes = bytes(key);
|
|
38
|
+
const valueBytes = bytes(entry);
|
|
39
|
+
if (keyBytes > MAX_DATA_KEY_BYTES || valueBytes > MAX_DATA_VALUE_BYTES) return null;
|
|
40
|
+
total += keyBytes + valueBytes;
|
|
41
|
+
if (total > MAX_DATA_TOTAL_BYTES) return null;
|
|
42
|
+
data[key] = entry;
|
|
43
|
+
}
|
|
44
|
+
return data;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const buildApnsPayload = ({ title, body, badge, collapseId, data }) => {
|
|
48
|
+
const alert = { title };
|
|
49
|
+
if (body) alert.body = body;
|
|
50
|
+
const aps = { alert, sound: 'default', 'mutable-content': 1 };
|
|
51
|
+
if (badge !== undefined) aps.badge = badge;
|
|
52
|
+
if (collapseId) aps['thread-id'] = collapseId;
|
|
53
|
+
return Object.keys(data).length > 0 ? { aps, ...data } : { aps };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const buildLiveActivityPayload = ({ event, contentState, dismissalDate, staleDate, timestamp }) => {
|
|
57
|
+
const aps = { timestamp, event, 'content-state': contentState };
|
|
58
|
+
if (event === 'end' && dismissalDate !== undefined) aps['dismissal-date'] = dismissalDate;
|
|
59
|
+
if (event === 'update' && staleDate !== undefined) aps['stale-date'] = staleDate;
|
|
60
|
+
return { aps };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const uniqueTokens = (tokens) => {
|
|
64
|
+
const unique = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const token of tokens) {
|
|
67
|
+
if (seen.has(token)) continue;
|
|
68
|
+
seen.add(token);
|
|
69
|
+
unique.push(token);
|
|
70
|
+
}
|
|
71
|
+
return unique;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const parseContentState = (value, event) => {
|
|
75
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
76
|
+
const keys = Object.keys(value);
|
|
77
|
+
if (keys.length === 0 || keys.some((key) => !LIVE_ACTIVITY_CONTENT_KEYS.has(key))) return null;
|
|
78
|
+
if (typeof value.status !== 'string' || !LIVE_ACTIVITY_STATUSES.has(value.status)) return null;
|
|
79
|
+
if (!isSafeInt(value.eventVersion) || !isFiniteNumber(value.updatedAt)) return null;
|
|
80
|
+
if (event === 'end' && !isFiniteNumber(value.endedAt)) return null;
|
|
81
|
+
if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return null;
|
|
82
|
+
const contentState = { status: value.status, eventVersion: value.eventVersion, updatedAt: value.updatedAt };
|
|
83
|
+
if (value.endedAt !== undefined) contentState.endedAt = value.endedAt;
|
|
84
|
+
return contentState;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const validateRegisterBody = (body) => {
|
|
88
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
89
|
+
if (body.platform === 'android') return { error: 'unsupported_platform' };
|
|
90
|
+
if (body.platform !== 'ios') return { error: 'invalid_request' };
|
|
91
|
+
if (!isIosToken(body.token)) return { error: 'invalid_request' };
|
|
92
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
93
|
+
const ts = parseTs(body.ts);
|
|
94
|
+
const sig = parseSig(body.sig);
|
|
95
|
+
if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
|
|
96
|
+
return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig } };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const validateLiveActivityRegisterBody = (body) => {
|
|
100
|
+
const parsed = validateRegisterBody(body);
|
|
101
|
+
if (parsed.error) return parsed;
|
|
102
|
+
if (body.kind !== LIVE_ACTIVITY_KIND) return { error: 'invalid_request' };
|
|
103
|
+
return { value: { ...parsed.value, kind: LIVE_ACTIVITY_KIND } };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const validateSendBody = (body) => {
|
|
107
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
108
|
+
const tokens = body.tokens;
|
|
109
|
+
if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
|
|
110
|
+
if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
|
|
111
|
+
if (typeof body.title !== 'string' || body.title.length === 0 || bytes(body.title) > MAX_TITLE_BYTES) return { error: 'invalid_request' };
|
|
112
|
+
if (body.body !== undefined && (typeof body.body !== 'string' || bytes(body.body) > MAX_BODY_BYTES)) return { error: 'invalid_request' };
|
|
113
|
+
if (body.badge !== undefined && (!isSafeInt(body.badge) || body.badge < 0)) return { error: 'invalid_request' };
|
|
114
|
+
if (body.collapseId !== undefined && (typeof body.collapseId !== 'string' || bytes(body.collapseId) > MAX_COLLAPSE_ID_BYTES)) return { error: 'invalid_request' };
|
|
115
|
+
if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
|
|
116
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
117
|
+
const ts = parseTs(body.ts);
|
|
118
|
+
const sig = parseSig(body.sig);
|
|
119
|
+
const data = parseData(body.data);
|
|
120
|
+
if (!jwk || ts === null || !sig || !data) return { error: 'invalid_request' };
|
|
121
|
+
const unique = uniqueTokens(tokens);
|
|
122
|
+
const payload = buildApnsPayload({
|
|
123
|
+
title: body.title,
|
|
124
|
+
body: body.body ?? '',
|
|
125
|
+
badge: body.badge,
|
|
126
|
+
collapseId: body.collapseId,
|
|
127
|
+
data,
|
|
128
|
+
});
|
|
129
|
+
if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
|
|
130
|
+
return {
|
|
131
|
+
value: {
|
|
132
|
+
tokens,
|
|
133
|
+
uniqueTokens: unique,
|
|
134
|
+
title: body.title,
|
|
135
|
+
body: typeof body.body === 'string' ? body.body : '',
|
|
136
|
+
badge: body.badge,
|
|
137
|
+
collapseId: body.collapseId || undefined,
|
|
138
|
+
env: body.env === 'production' ? 'production' : 'sandbox',
|
|
139
|
+
publicKeyJwk: jwk,
|
|
140
|
+
ts,
|
|
141
|
+
sig,
|
|
142
|
+
payload,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const validateLiveActivityBody = (body) => {
|
|
148
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
149
|
+
const tokens = body.tokens;
|
|
150
|
+
if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
|
|
151
|
+
if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
|
|
152
|
+
if (typeof body.event !== 'string' || !LIVE_ACTIVITY_EVENTS.has(body.event)) return { error: 'invalid_request' };
|
|
153
|
+
const contentState = parseContentState(body.contentState, body.event);
|
|
154
|
+
if (!contentState) return { error: 'invalid_request' };
|
|
155
|
+
if (body.dismissalDate !== undefined && !isSafeInt(body.dismissalDate)) return { error: 'invalid_request' };
|
|
156
|
+
if (body.staleDate !== undefined && !isSafeInt(body.staleDate)) return { error: 'invalid_request' };
|
|
157
|
+
if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
|
|
158
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
159
|
+
const ts = parseTs(body.ts);
|
|
160
|
+
const sig = parseSig(body.sig);
|
|
161
|
+
if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
|
|
162
|
+
const payload = buildLiveActivityPayload({
|
|
163
|
+
event: body.event,
|
|
164
|
+
contentState,
|
|
165
|
+
dismissalDate: body.dismissalDate,
|
|
166
|
+
staleDate: body.staleDate,
|
|
167
|
+
timestamp: 1_000_000_000,
|
|
168
|
+
});
|
|
169
|
+
if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
|
|
170
|
+
return {
|
|
171
|
+
value: {
|
|
172
|
+
tokens,
|
|
173
|
+
uniqueTokens: uniqueTokens(tokens),
|
|
174
|
+
event: body.event,
|
|
175
|
+
contentState,
|
|
176
|
+
dismissalDate: body.dismissalDate,
|
|
177
|
+
staleDate: body.staleDate,
|
|
178
|
+
env: body.env === 'production' ? 'production' : 'sandbox',
|
|
179
|
+
publicKeyJwk: jwk,
|
|
180
|
+
ts,
|
|
181
|
+
sig,
|
|
182
|
+
payload,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|