@openchambery/relay-server 1.19.0-beta.4 → 1.19.0-beta.40
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 +91 -6
- package/README.md +119 -4
- package/bin/openchamber-push-relay.js +11 -0
- package/package.json +11 -5
- package/src/push/apns.js +137 -0
- package/src/push/cli.js +73 -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/index.d.ts +110 -0
- package/src/push/index.js +3 -0
- package/src/push/schema.js +108 -0
- package/src/push/server.js +276 -0
- package/src/push/store.js +54 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
5
|
+
export const DEFAULT_PORT = 8788;
|
|
6
|
+
export const DEFAULT_DATABASE_PATH = './data/push-relay.sqlite';
|
|
7
|
+
export const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
|
|
8
|
+
export const ENV_PREFIX = 'OPENCHAMBER_PUSH_RELAY_';
|
|
9
|
+
export const DEFAULT_LIMITS = {
|
|
10
|
+
timestampSkewMs: 300_000,
|
|
11
|
+
replayMs: 600_000,
|
|
12
|
+
maxReplayEntries: 10_000,
|
|
13
|
+
registerLimitPerMinute: 60,
|
|
14
|
+
sendLimitPerMinute: 60,
|
|
15
|
+
serverSendLimitPerMinute: 120,
|
|
16
|
+
maxTokens: 100_000,
|
|
17
|
+
maxInFlight: 64,
|
|
18
|
+
maxRateLimitEntries: 10_000,
|
|
19
|
+
jsonBodyBytes: 16 * 1024,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const LIMIT_KEYS = ['timestampSkewMs', 'replayMs', 'maxReplayEntries', 'registerLimitPerMinute', 'sendLimitPerMinute', 'serverSendLimitPerMinute', 'maxTokens', 'maxInFlight'];
|
|
23
|
+
|
|
24
|
+
export const resolvePushRelayClientIp = (request, trustProxy = false) => {
|
|
25
|
+
const remoteAddress = request.socket.remoteAddress ?? 'unknown';
|
|
26
|
+
if (!trustProxy) return remoteAddress;
|
|
27
|
+
const forwarded = request.headers['x-forwarded-for'];
|
|
28
|
+
const candidate = typeof forwarded === 'string' && !forwarded.includes(',') ? forwarded.trim() : '';
|
|
29
|
+
return isIP(candidate) ? candidate : remoteAddress;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const formatPushRelayUrl = (host, port) => `http://${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
|
|
33
|
+
|
|
34
|
+
const upperSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
|
|
35
|
+
export const envName = (key) => `${ENV_PREFIX}${upperSnake(key)}`;
|
|
36
|
+
export const fail = (name) => { throw new Error(`Invalid ${name}`); };
|
|
37
|
+
|
|
38
|
+
export const positive = (name, value) => {
|
|
39
|
+
if (!/^[1-9][0-9]*$/.test(String(value))) fail(name);
|
|
40
|
+
const number = Number(value);
|
|
41
|
+
if (!Number.isSafeInteger(number)) fail(name);
|
|
42
|
+
return number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const bool = (name, value) => {
|
|
46
|
+
if (value === undefined) return false;
|
|
47
|
+
if (value === 'true' || value === '1' || value === true) return true;
|
|
48
|
+
if (value === 'false' || value === '0' || value === false) return false;
|
|
49
|
+
fail(name);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const validHost = (name, value) => {
|
|
53
|
+
if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.includes('/') || value.includes('\\') || value.includes('@') || value.includes(':') && !isIP(value) || (!isIP(value) && !/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/.test(value))) fail(name);
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const validDatabasePath = (name, value) => {
|
|
58
|
+
if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.includes('\0')) fail(name);
|
|
59
|
+
return value;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
|
|
63
|
+
|
|
64
|
+
export const validatePushRelayLimits = (limits) => {
|
|
65
|
+
if (limits.replayMs < limits.timestampSkewMs * 2 || limits.maxReplayEntries < 1) throw new RangeError('invalid replay limits');
|
|
66
|
+
if (limits.maxTokens < 1 || limits.maxInFlight < 1 || limits.registerLimitPerMinute < 1 || limits.sendLimitPerMinute < 1 || limits.serverSendLimitPerMinute < 1) {
|
|
67
|
+
throw new RangeError('invalid push limits');
|
|
68
|
+
}
|
|
69
|
+
return limits;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const normalizePushRelayOptions = (options = {}) => {
|
|
73
|
+
const limits = validatePushRelayLimits({ ...DEFAULT_LIMITS, ...options.limits });
|
|
74
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
75
|
+
validHost('host', host);
|
|
76
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
77
|
+
if (!Number.isSafeInteger(port) || port < 0 || port > 65535) fail('port');
|
|
78
|
+
const databasePath = options.databasePath ?? DEFAULT_DATABASE_PATH;
|
|
79
|
+
validDatabasePath('databasePath', databasePath);
|
|
80
|
+
if (!options.apnsProvider) {
|
|
81
|
+
const apns = options.apns ?? {};
|
|
82
|
+
if (!apns.keyId || !apns.teamId || !apns.p8 || !apns.bundleId) throw new Error('Invalid APNs configuration');
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
host,
|
|
86
|
+
port,
|
|
87
|
+
trustProxy: Boolean(options.trustProxy),
|
|
88
|
+
databasePath,
|
|
89
|
+
limits,
|
|
90
|
+
apns: options.apns,
|
|
91
|
+
apnsProvider: options.apnsProvider,
|
|
92
|
+
clock: options.clock,
|
|
93
|
+
logger: options.logger,
|
|
94
|
+
resolveClientIp: options.resolveClientIp,
|
|
95
|
+
store: options.store,
|
|
96
|
+
http2: options.http2,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const envValue = (env, key) => {
|
|
101
|
+
const value = env[envName(key)];
|
|
102
|
+
return typeof value === 'string' && value.trim().length === 0 ? undefined : value;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const loadP8 = (env) => {
|
|
106
|
+
const inline = envValue(env, 'apnsP8') ?? env[`${ENV_PREFIX}APNS_P8`];
|
|
107
|
+
if (typeof inline === 'string' && inline.trim().length > 0) return normalizePem(inline);
|
|
108
|
+
const p8Path = envValue(env, 'apnsP8Path') ?? env[`${ENV_PREFIX}APNS_P8_PATH`];
|
|
109
|
+
if (typeof p8Path === 'string' && p8Path.trim().length > 0) {
|
|
110
|
+
try { return normalizePem(fs.readFileSync(p8Path.trim(), 'utf8')); } catch { fail(`${ENV_PREFIX}APNS_P8_PATH`); }
|
|
111
|
+
}
|
|
112
|
+
fail(`${ENV_PREFIX}APNS_P8`);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const buildPushRelayConfig = (parsed = {}, env = process.env) => {
|
|
116
|
+
const pick = (key, fallback) => parsed[key] ?? envValue(env, key) ?? fallback;
|
|
117
|
+
const portValue = pick('port', DEFAULT_PORT);
|
|
118
|
+
const port = positive(parsed.port !== undefined ? '--port' : envName('port'), portValue);
|
|
119
|
+
if (port > 65535) fail(parsed.port !== undefined ? '--port' : envName('port'));
|
|
120
|
+
const host = validHost(parsed.host !== undefined ? '--host' : envName('host'), pick('host', DEFAULT_HOST));
|
|
121
|
+
const databasePath = validDatabasePath(envName('databasePath'), pick('databasePath', DEFAULT_DATABASE_PATH));
|
|
122
|
+
const limits = {};
|
|
123
|
+
for (const key of LIMIT_KEYS) {
|
|
124
|
+
const value = pick(key, undefined);
|
|
125
|
+
if (value !== undefined) limits[key] = positive(parsed[key] !== undefined ? `--${upperSnake(key).toLowerCase().replaceAll('_', '-')}` : envName(key), value);
|
|
126
|
+
}
|
|
127
|
+
const merged = { ...DEFAULT_LIMITS, ...limits };
|
|
128
|
+
if (merged.replayMs < merged.timestampSkewMs * 2) fail(parsed.replayMs !== undefined ? '--replay-ms' : envName('replayMs'));
|
|
129
|
+
validatePushRelayLimits(merged);
|
|
130
|
+
const keyId = pick('apnsKeyId', env[`${ENV_PREFIX}APNS_KEY_ID`]);
|
|
131
|
+
const teamId = pick('apnsTeamId', env[`${ENV_PREFIX}APNS_TEAM_ID`]);
|
|
132
|
+
const bundleId = pick('apnsBundleId', env[`${ENV_PREFIX}APNS_BUNDLE_ID`]) || DEFAULT_BUNDLE_ID;
|
|
133
|
+
if (typeof keyId !== 'string' || keyId.trim().length === 0) fail(`${ENV_PREFIX}APNS_KEY_ID`);
|
|
134
|
+
if (typeof teamId !== 'string' || teamId.trim().length === 0) fail(`${ENV_PREFIX}APNS_TEAM_ID`);
|
|
135
|
+
const p8 = loadP8(env);
|
|
136
|
+
if (!p8) fail(`${ENV_PREFIX}APNS_P8`);
|
|
137
|
+
return {
|
|
138
|
+
host,
|
|
139
|
+
port,
|
|
140
|
+
databasePath,
|
|
141
|
+
trustProxy: parsed.trustProxy ?? bool(envName('trustProxy'), envValue(env, 'trustProxy')),
|
|
142
|
+
limits: merged,
|
|
143
|
+
apns: { keyId: keyId.trim(), teamId: teamId.trim(), p8, bundleId: String(bundleId).trim() },
|
|
144
|
+
};
|
|
145
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const B64 = /^[A-Za-z0-9_-]+$/;
|
|
4
|
+
|
|
5
|
+
export const canonicalPublicJwkString = (jwk) => JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
|
6
|
+
|
|
7
|
+
export const b64urlExact = (value, length) => {
|
|
8
|
+
if (typeof value !== 'string' || !B64.test(value)) return null;
|
|
9
|
+
try {
|
|
10
|
+
const decoded = Buffer.from(value, 'base64url');
|
|
11
|
+
return decoded.length === length && decoded.toString('base64url') === value ? decoded : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const parsePublicJwk = (value) => {
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
19
|
+
const keys = Object.keys(value);
|
|
20
|
+
if (keys.length !== 4 || !keys.includes('crv') || !keys.includes('kty') || !keys.includes('x') || !keys.includes('y')) return null;
|
|
21
|
+
if (value.kty !== 'EC' || value.crv !== 'P-256' || !b64urlExact(value.x, 32) || !b64urlExact(value.y, 32)) return null;
|
|
22
|
+
return { crv: value.crv, kty: value.kty, x: value.x, y: value.y };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const deriveServerId = (jwk) => crypto.createHash('sha256').update(canonicalPublicJwkString(jwk)).digest('base64url');
|
|
26
|
+
|
|
27
|
+
export const verifyP1363 = (message, jwk, signature) => {
|
|
28
|
+
try {
|
|
29
|
+
const key = crypto.createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: 'jwk' });
|
|
30
|
+
return crypto.verify('SHA256', Buffer.from(message), { key, dsaEncoding: 'ieee-p1363' }, signature);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
export const createReplayGuard = ({ replayMs, maxReplayEntries, now }) => {
|
|
2
|
+
const replay = new Map();
|
|
3
|
+
const purge = () => {
|
|
4
|
+
const ts = now();
|
|
5
|
+
for (const [key, expiry] of replay) if (expiry <= ts) replay.delete(key);
|
|
6
|
+
};
|
|
7
|
+
return {
|
|
8
|
+
has(key) {
|
|
9
|
+
purge();
|
|
10
|
+
return replay.has(key);
|
|
11
|
+
},
|
|
12
|
+
remember(key) {
|
|
13
|
+
purge();
|
|
14
|
+
if (replay.size >= maxReplayEntries && !replay.has(key)) return false;
|
|
15
|
+
replay.set(key, now() + replayMs);
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
clear() { replay.clear(); },
|
|
19
|
+
get size() { return replay.size; },
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const createSlidingWindowLimiter = ({ windowMs, maxCount, maxEntries, now }) => {
|
|
24
|
+
const entries = new Map();
|
|
25
|
+
const pruneKey = (key, cutoff) => {
|
|
26
|
+
const stamps = entries.get(key);
|
|
27
|
+
if (!stamps) return null;
|
|
28
|
+
let index = 0;
|
|
29
|
+
while (index < stamps.length && stamps[index] <= cutoff) index += 1;
|
|
30
|
+
if (index) stamps.splice(0, index);
|
|
31
|
+
if (stamps.length === 0) { entries.delete(key); return null; }
|
|
32
|
+
return stamps;
|
|
33
|
+
};
|
|
34
|
+
const purge = (cutoff) => {
|
|
35
|
+
for (const key of [...entries.keys()]) pruneKey(key, cutoff);
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
allow(key) {
|
|
39
|
+
const ts = now();
|
|
40
|
+
const cutoff = ts - windowMs;
|
|
41
|
+
let stamps = pruneKey(key, cutoff);
|
|
42
|
+
if (!stamps) {
|
|
43
|
+
if (entries.size >= maxEntries) {
|
|
44
|
+
purge(cutoff);
|
|
45
|
+
if (entries.size >= maxEntries) return false;
|
|
46
|
+
}
|
|
47
|
+
stamps = [];
|
|
48
|
+
entries.set(key, stamps);
|
|
49
|
+
}
|
|
50
|
+
if (stamps.length >= maxCount) return false;
|
|
51
|
+
stamps.push(ts);
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
clear() { entries.clear(); },
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const createInFlightGate = (maxInFlight) => {
|
|
59
|
+
const maxWaiters = maxInFlight;
|
|
60
|
+
let generation = 1;
|
|
61
|
+
let active = 0;
|
|
62
|
+
let accepting = true;
|
|
63
|
+
const waiters = [];
|
|
64
|
+
const idleWaiters = [];
|
|
65
|
+
const notifyIdle = () => {
|
|
66
|
+
if (active !== 0) return;
|
|
67
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
acquire() {
|
|
71
|
+
if (!accepting) return Promise.resolve(false);
|
|
72
|
+
if (active < maxInFlight) {
|
|
73
|
+
active += 1;
|
|
74
|
+
return Promise.resolve(generation);
|
|
75
|
+
}
|
|
76
|
+
if (waiters.length >= maxWaiters) return Promise.resolve(false);
|
|
77
|
+
const gen = generation;
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
waiters.push(() => {
|
|
80
|
+
if (!accepting || generation !== gen) { resolve(false); return; }
|
|
81
|
+
resolve(generation);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
release(ticket) {
|
|
86
|
+
if (ticket !== generation) return;
|
|
87
|
+
if (accepting) {
|
|
88
|
+
const next = waiters.shift();
|
|
89
|
+
if (next) { next(); return; }
|
|
90
|
+
} else {
|
|
91
|
+
while (waiters.length) waiters.shift()();
|
|
92
|
+
}
|
|
93
|
+
active = Math.max(0, active - 1);
|
|
94
|
+
notifyIdle();
|
|
95
|
+
},
|
|
96
|
+
rejectWaiters() {
|
|
97
|
+
accepting = false;
|
|
98
|
+
while (waiters.length) waiters.shift()();
|
|
99
|
+
},
|
|
100
|
+
reset() {
|
|
101
|
+
generation += 1;
|
|
102
|
+
accepting = true;
|
|
103
|
+
active = 0;
|
|
104
|
+
waiters.length = 0;
|
|
105
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
106
|
+
},
|
|
107
|
+
whenIdle() {
|
|
108
|
+
if (active === 0) return Promise.resolve();
|
|
109
|
+
return new Promise((resolve) => { idleWaiters.push(resolve); });
|
|
110
|
+
},
|
|
111
|
+
get active() { return active; },
|
|
112
|
+
get waiting() { return waiters.length; },
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const createWorkTracker = () => {
|
|
117
|
+
let generation = 1;
|
|
118
|
+
let active = 0;
|
|
119
|
+
const idleWaiters = [];
|
|
120
|
+
const notifyIdle = () => {
|
|
121
|
+
if (active !== 0) return;
|
|
122
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
begin() {
|
|
126
|
+
active += 1;
|
|
127
|
+
const gen = generation;
|
|
128
|
+
return () => {
|
|
129
|
+
if (gen !== generation) return;
|
|
130
|
+
active = Math.max(0, active - 1);
|
|
131
|
+
notifyIdle();
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
whenIdle() {
|
|
135
|
+
if (active === 0) return Promise.resolve();
|
|
136
|
+
return new Promise((resolve) => { idleWaiters.push(resolve); });
|
|
137
|
+
},
|
|
138
|
+
reset() {
|
|
139
|
+
generation += 1;
|
|
140
|
+
active = 0;
|
|
141
|
+
while (idleWaiters.length) idleWaiters.shift()();
|
|
142
|
+
},
|
|
143
|
+
get active() { return active; },
|
|
144
|
+
};
|
|
145
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PushApnsSendResult {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
drop?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PushApnsProvider {
|
|
49
|
+
send(input: PushApnsSendInput): Promise<PushApnsSendResult>;
|
|
50
|
+
close?(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PushTokenRecord {
|
|
54
|
+
serverId: string;
|
|
55
|
+
platform: string;
|
|
56
|
+
updatedAt: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PushTokenStore {
|
|
60
|
+
get(token: string): PushTokenRecord | null;
|
|
61
|
+
upsert(token: string, serverId: string, platform: string, updatedAt: number): void;
|
|
62
|
+
delete(token: string): void;
|
|
63
|
+
count(): number;
|
|
64
|
+
close(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PushRelaySnapshotReasons {
|
|
68
|
+
authRejected: number;
|
|
69
|
+
policyRejected: number;
|
|
70
|
+
limited: number;
|
|
71
|
+
replayRejected: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PushRelaySnapshot {
|
|
75
|
+
state: PushRelayState;
|
|
76
|
+
tokenCount: number;
|
|
77
|
+
inFlight: number;
|
|
78
|
+
replayEntries: number;
|
|
79
|
+
reasons: PushRelaySnapshotReasons;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PushRelayOptions {
|
|
83
|
+
host?: string;
|
|
84
|
+
port?: number;
|
|
85
|
+
trustProxy?: boolean;
|
|
86
|
+
databasePath?: string;
|
|
87
|
+
limits?: Partial<PushRelayLimits>;
|
|
88
|
+
apns?: PushRelayApnsConfig;
|
|
89
|
+
apnsProvider?: PushApnsProvider;
|
|
90
|
+
clock?: Partial<PushRelayClock>;
|
|
91
|
+
logger?: Pick<Console, 'info' | 'warn' | 'error'>;
|
|
92
|
+
resolveClientIp?: (request: IncomingMessage) => string;
|
|
93
|
+
store?: PushTokenStore;
|
|
94
|
+
http2?: { connect: typeof import('node:http2').connect };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface PushRelayServerInstance {
|
|
98
|
+
start(): Promise<void>;
|
|
99
|
+
stop(): Promise<void>;
|
|
100
|
+
address(): string | AddressInfo | null | undefined;
|
|
101
|
+
readonly url: string | null;
|
|
102
|
+
getSnapshot(): PushRelaySnapshot;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function resolvePushRelayClientIp(request: IncomingMessage, trustProxy?: boolean): string;
|
|
106
|
+
export function formatPushRelayUrl(host: string, port: number): string;
|
|
107
|
+
export function canonicalPublicJwkString(jwk: { crv: string; kty: string; x: string; y: string }): string;
|
|
108
|
+
export function deriveServerId(jwk: { crv: string; kty: string; x: string; y: string }): string;
|
|
109
|
+
export function createPushRelayServer(options?: PushRelayOptions): PushRelayServerInstance;
|
|
110
|
+
export function startPushRelayServer(options?: PushRelayOptions): Promise<PushRelayServerInstance>;
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
|
|
15
|
+
const bytes = (value) => Buffer.byteLength(value, 'utf8');
|
|
16
|
+
const isSafeInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
|
|
17
|
+
|
|
18
|
+
export const isIosToken = (value) => typeof value === 'string' && IOS_TOKEN.test(value);
|
|
19
|
+
|
|
20
|
+
const parseTs = (value) => (isSafeInt(value) ? value : null);
|
|
21
|
+
const parseSig = (value) => b64urlExact(value, 64);
|
|
22
|
+
|
|
23
|
+
const parseData = (value) => {
|
|
24
|
+
if (value === undefined) return {};
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
26
|
+
const entries = Object.entries(value);
|
|
27
|
+
if (entries.length > MAX_DATA_ENTRIES) return null;
|
|
28
|
+
let total = 0;
|
|
29
|
+
const data = {};
|
|
30
|
+
for (const [key, entry] of entries) {
|
|
31
|
+
if (typeof key !== 'string' || key.length === 0 || key === 'aps' || typeof entry !== 'string') return null;
|
|
32
|
+
const keyBytes = bytes(key);
|
|
33
|
+
const valueBytes = bytes(entry);
|
|
34
|
+
if (keyBytes > MAX_DATA_KEY_BYTES || valueBytes > MAX_DATA_VALUE_BYTES) return null;
|
|
35
|
+
total += keyBytes + valueBytes;
|
|
36
|
+
if (total > MAX_DATA_TOTAL_BYTES) return null;
|
|
37
|
+
data[key] = entry;
|
|
38
|
+
}
|
|
39
|
+
return data;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const buildApnsPayload = ({ title, body, badge, collapseId, data }) => {
|
|
43
|
+
const alert = { title };
|
|
44
|
+
if (body) alert.body = body;
|
|
45
|
+
const aps = { alert, sound: 'default', 'mutable-content': 1 };
|
|
46
|
+
if (badge !== undefined) aps.badge = badge;
|
|
47
|
+
if (collapseId) aps['thread-id'] = collapseId;
|
|
48
|
+
return Object.keys(data).length > 0 ? { aps, ...data } : { aps };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const validateRegisterBody = (body) => {
|
|
52
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
53
|
+
if (body.platform === 'android') return { error: 'unsupported_platform' };
|
|
54
|
+
if (body.platform !== 'ios') return { error: 'invalid_request' };
|
|
55
|
+
if (!isIosToken(body.token)) return { error: 'invalid_request' };
|
|
56
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
57
|
+
const ts = parseTs(body.ts);
|
|
58
|
+
const sig = parseSig(body.sig);
|
|
59
|
+
if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
|
|
60
|
+
return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig } };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const validateSendBody = (body) => {
|
|
64
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
65
|
+
const tokens = body.tokens;
|
|
66
|
+
if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
|
|
67
|
+
if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
|
|
68
|
+
if (typeof body.title !== 'string' || body.title.length === 0 || bytes(body.title) > MAX_TITLE_BYTES) return { error: 'invalid_request' };
|
|
69
|
+
if (body.body !== undefined && (typeof body.body !== 'string' || bytes(body.body) > MAX_BODY_BYTES)) return { error: 'invalid_request' };
|
|
70
|
+
if (body.badge !== undefined && (!isSafeInt(body.badge) || body.badge < 0)) return { error: 'invalid_request' };
|
|
71
|
+
if (body.collapseId !== undefined && (typeof body.collapseId !== 'string' || bytes(body.collapseId) > MAX_COLLAPSE_ID_BYTES)) return { error: 'invalid_request' };
|
|
72
|
+
if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
|
|
73
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
74
|
+
const ts = parseTs(body.ts);
|
|
75
|
+
const sig = parseSig(body.sig);
|
|
76
|
+
const data = parseData(body.data);
|
|
77
|
+
if (!jwk || ts === null || !sig || !data) return { error: 'invalid_request' };
|
|
78
|
+
const unique = [];
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
for (const token of tokens) {
|
|
81
|
+
if (seen.has(token)) continue;
|
|
82
|
+
seen.add(token);
|
|
83
|
+
unique.push(token);
|
|
84
|
+
}
|
|
85
|
+
const payload = buildApnsPayload({
|
|
86
|
+
title: body.title,
|
|
87
|
+
body: body.body ?? '',
|
|
88
|
+
badge: body.badge,
|
|
89
|
+
collapseId: body.collapseId,
|
|
90
|
+
data,
|
|
91
|
+
});
|
|
92
|
+
if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
|
|
93
|
+
return {
|
|
94
|
+
value: {
|
|
95
|
+
tokens,
|
|
96
|
+
uniqueTokens: unique,
|
|
97
|
+
title: body.title,
|
|
98
|
+
body: typeof body.body === 'string' ? body.body : '',
|
|
99
|
+
badge: body.badge,
|
|
100
|
+
collapseId: body.collapseId || undefined,
|
|
101
|
+
env: body.env === 'production' ? 'production' : 'sandbox',
|
|
102
|
+
publicKeyJwk: jwk,
|
|
103
|
+
ts,
|
|
104
|
+
sig,
|
|
105
|
+
payload,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
};
|