@openchambery/relay-server 1.19.0-beta.35 → 1.19.0-beta.37

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,11 @@
1
+ #!/usr/bin/env node
2
+ import packageJson from '../package.json' with { type: 'json' };
3
+
4
+ import { isModuleCliExecution } from './cli-entry.js';
5
+ import { runPushRelayCli } from '../src/push/cli.js';
6
+
7
+ const version = packageJson.version;
8
+
9
+ if (isModuleCliExecution(process.argv[1], import.meta.url, undefined, 'openchamber-push-relay')) {
10
+ runPushRelayCli(process.argv.slice(2), { version }).then((code) => { process.exitCode = code; });
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.0-beta.35",
3
+ "version": "1.19.0-beta.37",
4
4
  "description": "Self-hosted private relay server for OpenChamber",
5
5
  "private": false,
6
6
  "type": "module",
@@ -11,16 +11,22 @@
11
11
  "types": "./src/index.d.ts",
12
12
  "import": "./src/index.js",
13
13
  "default": "./src/index.js"
14
+ },
15
+ "./push": {
16
+ "types": "./src/push/index.d.ts",
17
+ "import": "./src/push/index.js",
18
+ "default": "./src/push/index.js"
14
19
  }
15
20
  },
16
21
  "bin": {
17
- "openchamber-relay": "./bin/openchamber-relay.js"
22
+ "openchamber-relay": "./bin/openchamber-relay.js",
23
+ "openchamber-push-relay": "./bin/openchamber-push-relay.js"
18
24
  },
19
25
  "publishConfig": {
20
26
  "access": "public"
21
27
  },
22
28
  "engines": {
23
- "node": ">=22.0.0"
29
+ "node": ">=22.13.0"
24
30
  },
25
31
  "license": "MIT",
26
32
  "repository": {
@@ -40,8 +46,8 @@
40
46
  "scripts": {
41
47
  "test": "vitest run",
42
48
  "test:node": "node test/node-smoke.js",
43
- "type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js",
44
- "lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js",
49
+ "type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js",
50
+ "lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js",
45
51
  "build:standalone": "bun build --compile --outfile dist/openchamber-relay bin/openchamber-relay.js"
46
52
  },
47
53
  "dependencies": {
@@ -0,0 +1,120 @@
1
+ import crypto from 'node:crypto';
2
+ import http2 from 'node:http2';
3
+
4
+ const APNS_HOST = {
5
+ production: 'https://api.push.apple.com',
6
+ sandbox: 'https://api.sandbox.push.apple.com',
7
+ };
8
+ const JWT_TTL_MS = 50 * 60 * 1000;
9
+ const REQUEST_TIMEOUT_MS = 5_000;
10
+ const MAX_RESPONSE_BYTES = 4_096;
11
+ export const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
12
+
13
+ const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
14
+
15
+ export const createApnsProvider = (options = {}) => {
16
+ const clock = { now: Date.now, setTimeout, clearTimeout, ...options.clock };
17
+ const connect = options.http2?.connect ?? http2.connect;
18
+ let privateKey;
19
+ try {
20
+ privateKey = crypto.createPrivateKey(normalizePem(options.p8));
21
+ } catch {
22
+ throw new Error('Invalid APNs key');
23
+ }
24
+ const bundleId = options.bundleId;
25
+ const sessions = new Map();
26
+ let cachedJwt = null;
27
+
28
+ const signJwt = () => {
29
+ const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: options.keyId })).toString('base64url');
30
+ const claims = Buffer.from(JSON.stringify({ iss: options.teamId, iat: Math.floor(clock.now() / 1000) })).toString('base64url');
31
+ const signingInput = `${header}.${claims}`;
32
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
33
+ return `${signingInput}.${signature}`;
34
+ };
35
+
36
+ const getJwt = (force) => {
37
+ const now = clock.now();
38
+ if (!force && cachedJwt && now - cachedJwt.issuedAtMs < JWT_TTL_MS) return cachedJwt.token;
39
+ cachedJwt = { token: signJwt(), issuedAtMs: now };
40
+ return cachedJwt.token;
41
+ };
42
+
43
+ const dropSession = (env, client) => {
44
+ if (sessions.get(env) === client) sessions.delete(env);
45
+ try { client.close(); } catch { /* session already gone */ }
46
+ };
47
+
48
+ const getSession = (env) => {
49
+ const existing = sessions.get(env);
50
+ if (existing && !existing.closed && existing.destroyed !== true) return existing;
51
+ const client = connect(APNS_HOST[env]);
52
+ client.on('error', () => dropSession(env, client));
53
+ client.on('close', () => { if (sessions.get(env) === client) sessions.delete(env); });
54
+ sessions.set(env, client);
55
+ return client;
56
+ };
57
+
58
+ const dispatch = (input, forceJwt) => new Promise((resolve) => {
59
+ const jwt = getJwt(forceJwt);
60
+ let client;
61
+ try { client = getSession(input.env); } catch { resolve({ ok: false }); return; }
62
+ const headers = {
63
+ ':method': 'POST',
64
+ ':path': `/3/device/${input.token}`,
65
+ authorization: `bearer ${jwt}`,
66
+ 'apns-topic': bundleId,
67
+ 'apns-push-type': 'alert',
68
+ 'apns-priority': '10',
69
+ };
70
+ if (input.collapseId) headers['apns-collapse-id'] = input.collapseId;
71
+ let req;
72
+ try { req = client.request(headers); } catch { resolve({ ok: false }); return; }
73
+ let status = 0;
74
+ let responseBody = '';
75
+ let settled = false;
76
+ const finish = (result) => {
77
+ if (settled) return;
78
+ settled = true;
79
+ clock.clearTimeout(timer);
80
+ resolve(result);
81
+ };
82
+ const timer = clock.setTimeout(() => {
83
+ try { req.close(); } catch { /* ignore */ }
84
+ finish({ ok: false });
85
+ }, options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
86
+ req.on('response', (responseHeaders) => { status = Number(responseHeaders[':status']) || 0; });
87
+ req.setEncoding('utf8');
88
+ req.on('data', (chunk) => {
89
+ if (responseBody.length < MAX_RESPONSE_BYTES) responseBody += chunk.slice(0, MAX_RESPONSE_BYTES - responseBody.length);
90
+ });
91
+ req.on('end', () => {
92
+ if (status === 200) { finish({ ok: true }); return; }
93
+ let reason = '';
94
+ try { reason = JSON.parse(responseBody)?.reason || ''; } catch { /* non-JSON */ }
95
+ if (reason === 'ExpiredProviderToken') { finish({ ok: false, expired: true }); return; }
96
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { finish({ ok: false, drop: true }); return; }
97
+ finish({ ok: false });
98
+ });
99
+ req.on('error', () => finish({ ok: false }));
100
+ req.end(JSON.stringify(input.payload));
101
+ });
102
+
103
+ return {
104
+ async send(input) {
105
+ const first = await dispatch(input, false);
106
+ if (first.expired) {
107
+ const retry = await dispatch(input, true);
108
+ return { ok: retry.ok === true, drop: retry.drop === true ? true : undefined };
109
+ }
110
+ return { ok: first.ok === true, drop: first.drop === true ? true : undefined };
111
+ },
112
+ close() {
113
+ for (const [env, client] of sessions) {
114
+ sessions.delete(env);
115
+ try { client.close(); } catch { /* ignore */ }
116
+ }
117
+ cachedJwt = null;
118
+ },
119
+ };
120
+ };
@@ -0,0 +1,73 @@
1
+ import { startPushRelayServer } from './server.js';
2
+ import { buildPushRelayConfig, fail, formatPushRelayUrl } from './config.js';
3
+
4
+ export { buildPushRelayConfig } from './config.js';
5
+
6
+ export const parsePushRelayArgs = (argv = []) => {
7
+ const parsed = {};
8
+ const values = new Map([['--host', 'host'], ['--port', 'port']]);
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const arg = argv[index];
11
+ if (values.has(arg)) {
12
+ const value = argv[++index];
13
+ if (!value || value.startsWith('--')) fail(arg);
14
+ parsed[values.get(arg)] = value;
15
+ continue;
16
+ }
17
+ if (arg === '--trust-proxy') { parsed.trustProxy = true; continue; }
18
+ if (arg === '--no-trust-proxy') { parsed.trustProxy = false; continue; }
19
+ if (arg === '--json') { parsed.json = true; continue; }
20
+ if (arg === '--quiet' || arg === '-q') { parsed.quiet = true; continue; }
21
+ if (arg === '--help' || arg === '-h') { parsed.help = true; continue; }
22
+ if (arg === '--version' || arg === '-v') { parsed.version = true; continue; }
23
+ fail(arg);
24
+ }
25
+ return parsed;
26
+ };
27
+
28
+ const helpText = 'Usage: openchamber-push-relay [--host HOST] [--port PORT] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
29
+ const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
30
+
31
+ export const runPushRelayCli = async (argv, dependencies = {}) => {
32
+ const processLike = dependencies.process ?? process;
33
+ const stdout = dependencies.stdout ?? process.stdout;
34
+ const stderr = dependencies.stderr ?? process.stderr;
35
+ const version = dependencies.version ?? '0.0.0';
36
+ let parsed;
37
+ try { parsed = parsePushRelayArgs(argv ?? processLike.argv?.slice(2) ?? []); } catch (error) {
38
+ const json = (argv ?? processLike.argv?.slice(2) ?? []).includes('--json');
39
+ if (json) writeJson(stdout, { status: 'error', error: error.message }); else stderr.write(`${error.message}\n`);
40
+ processLike.exitCode = 1; return 1;
41
+ }
42
+ const json = parsed.json;
43
+ const respond = (payload, error = false, essential = false) => {
44
+ if (json) writeJson(stdout, payload);
45
+ else if (payload.message && (essential || !parsed.quiet || error)) (error ? stderr : stdout).write(`${payload.message}\n`);
46
+ };
47
+ if (parsed.help) { respond(json ? { status: 'ok', help: helpText.trim() } : { message: helpText.trim() }, false, true); return 0; }
48
+ if (parsed.version) { respond(json ? { status: 'ok', version } : { message: version }, false, true); return 0; }
49
+ let config;
50
+ try { config = buildPushRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
51
+ try {
52
+ const relay = await (dependencies.start ?? startPushRelayServer)(config);
53
+ const port = relay.address?.()?.port ?? config.port;
54
+ const url = formatPushRelayUrl(config.host, port);
55
+ respond(json ? { status: 'ok', url, host: config.host, port } : { message: `Push relay listening at ${url}` });
56
+ let stopping = false;
57
+ const stop = async () => {
58
+ if (stopping) return Promise.resolve();
59
+ stopping = true;
60
+ processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
61
+ try {
62
+ await relay.stop();
63
+ processLike.exit?.(0);
64
+ } catch {
65
+ processLike.exitCode = 1;
66
+ if (json) writeJson(stderr, { status: 'error', error: 'Push relay stop failed' }); else stderr.write('Push relay stop failed\n');
67
+ processLike.exit?.(1);
68
+ }
69
+ };
70
+ processLike.on?.('SIGINT', stop); processLike.on?.('SIGTERM', stop);
71
+ return 0;
72
+ } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
73
+ };
@@ -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.openchamber.app';
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,80 @@
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 active = 0;
61
+ const waiters = [];
62
+ return {
63
+ acquire() {
64
+ if (active < maxInFlight) { active += 1; return Promise.resolve(true); }
65
+ if (waiters.length >= maxWaiters) return Promise.resolve(false);
66
+ return new Promise((resolve) => { waiters.push(resolve); });
67
+ },
68
+ release() {
69
+ const next = waiters.shift();
70
+ if (next) next(true);
71
+ else active = Math.max(0, active - 1);
72
+ },
73
+ clear() {
74
+ while (waiters.length) waiters.shift()(false);
75
+ active = 0;
76
+ },
77
+ get active() { return active; },
78
+ get waiting() { return waiters.length; },
79
+ };
80
+ };
@@ -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,3 @@
1
+ export { createPushRelayServer, startPushRelayServer } from './server.js';
2
+ export { resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
3
+ export { canonicalPublicJwkString, deriveServerId } from './crypto.js';
@@ -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
+ };
@@ -0,0 +1,245 @@
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 } 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
+
12
+ const sendJson = (response, status, payload, method = 'GET') => {
13
+ if (response.writableEnded) return;
14
+ response.setHeader('cache-control', 'no-store');
15
+ response.writeHead(status, { 'content-type': 'application/json' });
16
+ response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
17
+ };
18
+
19
+ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
20
+ let done = false;
21
+ const fail = (error) => { if (done) return; done = true; reject(error); };
22
+ const succeed = (value) => { if (done) return; done = true; resolve(value); };
23
+ const tooLarge = () => {
24
+ const error = new Error('payload too large');
25
+ error.code = 'PAYLOAD_TOO_LARGE';
26
+ fail(error);
27
+ };
28
+ const declared = Number(request.headers['content-length']);
29
+ if (Number.isFinite(declared) && declared > maxBytes) {
30
+ tooLarge();
31
+ request.resume();
32
+ return;
33
+ }
34
+ const chunks = [];
35
+ let size = 0;
36
+ request.on('data', (chunk) => {
37
+ size += chunk.length;
38
+ if (size > maxBytes) {
39
+ tooLarge();
40
+ request.destroy();
41
+ return;
42
+ }
43
+ chunks.push(chunk);
44
+ });
45
+ request.on('end', () => succeed(Buffer.concat(chunks)));
46
+ request.on('error', fail);
47
+ });
48
+
49
+ export const createPushRelayServer = (options = {}) => {
50
+ const config = normalizePushRelayOptions(options);
51
+ const limits = config.limits;
52
+ const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
53
+ const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
54
+ const ownedStore = !config.store;
55
+ const ownedApns = !config.apnsProvider;
56
+ const openStore = () => config.store ?? createTokenStore(config.databasePath);
57
+ const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
58
+ let store = openStore();
59
+ let apns;
60
+ try {
61
+ apns = openApns();
62
+ } catch (error) {
63
+ if (ownedStore) try { store.close(); } catch { /* ignore */ }
64
+ throw error;
65
+ }
66
+ const liveStore = () => {
67
+ if (!ownedStore) return store;
68
+ try { store.count(); return store; } catch {
69
+ store = createTokenStore(config.databasePath);
70
+ return store;
71
+ }
72
+ };
73
+ const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
74
+ const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
75
+ const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
76
+ const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
77
+ const inFlight = createInFlightGate(limits.maxInFlight);
78
+ const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
79
+ let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
80
+ const snapshot = () => {
81
+ let tokenCount = 0;
82
+ try { tokenCount = store.count(); } catch { /* closed after stop */ }
83
+ return { state, tokenCount, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
84
+ };
85
+
86
+ const authenticate = (jwk, message, signature, ts) => {
87
+ if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
88
+ if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
89
+ return null;
90
+ };
91
+
92
+ const handleRegister = (parsed) => {
93
+ const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}`, parsed.sig, parsed.ts);
94
+ if (authError) return { status: 401, body: { error: authError } };
95
+ const serverId = deriveServerId(parsed.publicKeyJwk);
96
+ const tokens = liveStore();
97
+ const existing = tokens.get(parsed.token);
98
+ if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
99
+ tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
100
+ return { status: 200, body: { ok: true } };
101
+ };
102
+
103
+ const handleSend = async (parsed) => {
104
+ const sorted = [...parsed.tokens].sort();
105
+ const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
106
+ if (authError) return { status: 401, body: { error: authError } };
107
+ const serverId = deriveServerId(parsed.publicKeyJwk);
108
+ const replayKey = `${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
109
+ if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
110
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
111
+ if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
112
+ const tokens = liveStore();
113
+ const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
114
+ const binding = tokens.get(token);
115
+ if (!binding || binding.serverId !== serverId) return { token, ok: false };
116
+ const acquired = await inFlight.acquire();
117
+ if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
118
+ try {
119
+ const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
120
+ if (outcome?.drop === true) {
121
+ tokens.delete(token);
122
+ return { token, ok: false, drop: true };
123
+ }
124
+ return { token, ok: outcome?.ok === true };
125
+ } catch {
126
+ return { token, ok: false };
127
+ } finally {
128
+ inFlight.release();
129
+ }
130
+ }));
131
+ return { status: 200, body: { results } };
132
+ };
133
+
134
+ const onRequest = (request, response) => {
135
+ let pathname;
136
+ try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch { response.writeHead(404); response.end(); return; }
137
+ const ready = pathname === '/readyz' && state === 'running';
138
+ const healthy = pathname === '/healthz';
139
+ if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
140
+ sendJson(response, 200, { status: 'ok' }, request.method);
141
+ return;
142
+ }
143
+ const isRegister = pathname === '/v1/push/register-token';
144
+ const isSend = pathname === '/v1/push/send';
145
+ if (request.method !== 'POST' || (!isRegister && !isSend)) { response.writeHead(404); response.end(); return; }
146
+ const ip = resolveClientIp(request);
147
+ const limiter = isRegister ? registerIpLimit : sendIpLimit;
148
+ if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
149
+ readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
150
+ let body;
151
+ try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
152
+ const parsed = isRegister ? validateRegisterBody(body) : validateSendBody(body);
153
+ if (parsed.error) {
154
+ reasons.policyRejected += 1;
155
+ sendJson(response, 400, { error: parsed.error });
156
+ return;
157
+ }
158
+ const result = isRegister ? handleRegister(parsed.value) : await handleSend(parsed.value);
159
+ sendJson(response, result.status, result.body);
160
+ }).catch((error) => {
161
+ if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
162
+ sendJson(response, 500, { error: 'internal' });
163
+ });
164
+ };
165
+
166
+ const start = () => {
167
+ if (state === 'running') return Promise.resolve();
168
+ if (state === 'stopping') return stopPromise.then(() => start());
169
+ if (startPromise) return startPromise;
170
+ liveStore();
171
+ if (ownedApns && state === 'stopped') apns = openApns();
172
+ state = 'starting';
173
+ const localGeneration = ++generation;
174
+ const localServer = http.createServer(onRequest);
175
+ server = localServer;
176
+ startPromise = new Promise((resolve, rejectStart) => {
177
+ const failStart = (error) => {
178
+ if (localGeneration !== generation) return;
179
+ localServer.off('listening', ready);
180
+ cleanupStart();
181
+ rejectStart(error);
182
+ };
183
+ const ready = () => {
184
+ localServer.off('error', failStart);
185
+ if (localGeneration !== generation || state !== 'starting') return;
186
+ state = 'running';
187
+ resolve();
188
+ };
189
+ const cleanupStart = () => {
190
+ localServer.close();
191
+ if (server === localServer) server = null;
192
+ state = 'stopped';
193
+ };
194
+ abortStart = () => {
195
+ if (state === 'starting') { cleanupStart(); rejectStart(new Error('push relay stopped during start')); }
196
+ };
197
+ localServer.once('error', failStart);
198
+ localServer.once('listening', ready);
199
+ localServer.listen(config.port, config.host);
200
+ }).finally(() => { startPromise = null; abortStart = null; });
201
+ return startPromise;
202
+ };
203
+
204
+ const stop = () => {
205
+ if (stopPromise) return stopPromise;
206
+ if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
207
+ if (state === 'starting') abortStart?.();
208
+ state = 'stopping';
209
+ generation += 1;
210
+ const localServer = server;
211
+ stopPromise = new Promise((resolve) => {
212
+ inFlight.clear();
213
+ replay.clear();
214
+ registerIpLimit.clear();
215
+ sendIpLimit.clear();
216
+ sendServerLimit.clear();
217
+ try { apns.close?.(); } catch { /* ignore */ }
218
+ if (!localServer) return resolve();
219
+ localServer.close(() => resolve());
220
+ clock.setTimeout(resolve, 100);
221
+ }).then(() => {
222
+ if (server === localServer) { server = null; state = 'stopped'; }
223
+ if (ownedStore) try { store.close(); } catch { /* ignore */ }
224
+ stopPromise = null;
225
+ });
226
+ return stopPromise;
227
+ };
228
+
229
+ return {
230
+ start,
231
+ stop,
232
+ address: () => server?.address(),
233
+ get url() {
234
+ const address = server?.address();
235
+ return address && typeof address === 'object' ? formatPushRelayUrl(config.host, address.port) : null;
236
+ },
237
+ getSnapshot: snapshot,
238
+ };
239
+ };
240
+
241
+ export const startPushRelayServer = async (options) => {
242
+ const server = createPushRelayServer(options);
243
+ await server.start();
244
+ return server;
245
+ };
@@ -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
+ };