@openchambery/relay-server 1.19.3-beta.8 → 1.19.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 CHANGED
@@ -41,6 +41,38 @@ openchamber-push-relay --host 127.0.0.1 --port 8788
41
41
 
42
42
  The Host maps the effective Relay `wss://`/`ws://` URL to the same host as `https://`/`http://` `/v1/push/send`. Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override. After a Relay switch, the Host re-registers persisted tokens and binds them before the first send. iOS Live Activity uses the same Push origin: `POST /v1/push/register-live-activity-token`, `POST /v1/push/unregister-live-activity-token`, and `POST /v1/push/live-activity`. Each Live Activity APNs request authenticates with the Host signing key, uses topic `{bundleId}.push-type.liveactivity`, and carries only `aps.timestamp`, `aps.event`, `aps.content-state`, and optional `dismissal-date` / `stale-date`. Successful `end` deliveries delete the token binding.
43
43
 
44
+ ### Combined mode (single port)
45
+
46
+ When `openchamber-relay` sees any non-empty `OPENCHAMBER_PUSH_RELAY_APNS_*` variable, it mounts Push HTTP on the same listener at `/v1/push/*`. Missing required APNs fields fail startup instead of silently skipping Push. With no such variables, Layer 1 does not load the Push module.
47
+
48
+ Combined mode ignores `OPENCHAMBER_PUSH_RELAY_HOST` and `OPENCHAMBER_PUSH_RELAY_PORT`. Public `/healthz` and `/readyz` stay Layer 1. The standalone `openchamber-push-relay` entry is unchanged.
49
+
50
+ ```sh
51
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
52
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
53
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
54
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
55
+ export OPENCHAMBER_PUSH_RELAY_DATABASE_PATH=/var/lib/openchamber/push-relay.sqlite
56
+ openchamber-relay --public-url wss://relay.example.com/ws
57
+ ```
58
+
59
+ Minimal Compose environment:
60
+
61
+ ```yaml
62
+ services:
63
+ relay:
64
+ image: openchamber-relay:<version>
65
+ environment:
66
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
67
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID}
68
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID}
69
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.openchamber
70
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
71
+ OPENCHAMBER_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
72
+ ports:
73
+ - "127.0.0.1:8787:8787"
74
+ ```
75
+
44
76
  ### Caddy
45
77
 
46
78
  ```caddyfile
package/README.md CHANGED
@@ -59,6 +59,43 @@ openchamber-push-relay --host 127.0.0.1 --port 8788
59
59
 
60
60
  Keep Push on loopback behind the same TLS reverse proxy. Route only `/v1/push/*` to port 8788.
61
61
 
62
+ ### Combined mode (single port)
63
+
64
+ `openchamber-relay` can mount Push on the same listener. If any `OPENCHAMBER_PUSH_RELAY_APNS_*` variable is non-empty, Layer 1 loads Push and serves `/v1/push/*` on the Relay port. Partial APNs configuration fails startup; it does not silently disable Push. With no APNs variables set, Layer 1 behavior is unchanged and Push is not loaded.
65
+
66
+ In combined mode:
67
+
68
+ - Push routes share the Relay port at `/v1/push/*`.
69
+ - `/healthz` and `/readyz` remain Layer 1 endpoints.
70
+ - `OPENCHAMBER_PUSH_RELAY_HOST` and `OPENCHAMBER_PUSH_RELAY_PORT` are ignored.
71
+ - `openchamber-push-relay` is unchanged and remains the isolated two-process option.
72
+
73
+ ```sh
74
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
75
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
76
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
77
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
78
+ export OPENCHAMBER_PUSH_RELAY_DATABASE_PATH=/var/lib/openchamber/push-relay.sqlite
79
+ openchamber-relay --public-url wss://relay.example.com/ws
80
+ ```
81
+
82
+ Minimal Compose environment for combined mode:
83
+
84
+ ```yaml
85
+ services:
86
+ relay:
87
+ image: openchamber-relay:<version>
88
+ environment:
89
+ OPENCHAMBER_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
90
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID}
91
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID}
92
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.openchamber
93
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
94
+ OPENCHAMBER_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
95
+ ports:
96
+ - "127.0.0.1:8787:8787"
97
+ ```
98
+
62
99
  OpenChamber Hosts do not need a separate Push URL when they already have a Relay URL. The effective `wss://` or `ws://` Relay URL maps to the same host as `https://` or `http://` `/v1/push/send` (register is `/v1/push/register-token`). Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override that mapping. After a Relay switch, the Host re-registers persisted device tokens and binds them again before the first send.
63
100
 
64
101
  ### Build a standalone executable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.3-beta.8",
3
+ "version": "1.19.3",
4
4
  "description": "Self-hosted private relay server for OpenChamber",
5
5
  "private": false,
6
6
  "type": "module",
@@ -46,8 +46,8 @@
46
46
  "scripts": {
47
47
  "test": "vitest run",
48
48
  "test:node": "node test/node-smoke.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",
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 && node --check src/push/handler.js && node --check src/push/combined.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 && node --check src/push/handler.js && node --check src/push/combined.js",
51
51
  "build:standalone": "bun build --compile --outfile dist/openchamber-relay bin/openchamber-relay.js"
52
52
  },
53
53
  "dependencies": {
package/src/cli.js CHANGED
@@ -71,6 +71,16 @@ export const buildRelayConfig = (parsed = {}, env = process.env) => {
71
71
 
72
72
  const helpText = 'Usage: openchamber-relay [--host HOST] [--port PORT] [--path PATH] [--public-url WS_URL] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
73
73
  const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
74
+ const hasPushApnsEnv = (env) => {
75
+ for (const [key, value] of Object.entries(env ?? {})) {
76
+ if (key.startsWith('OPENCHAMBER_PUSH_RELAY_APNS_') && typeof value === 'string' && value.trim().length > 0) return true;
77
+ }
78
+ return false;
79
+ };
80
+ const loadCombinedPushMount = async () => {
81
+ // Computed specifier keeps bun --compile from bundling Push/SQLite into the Layer 1 binary.
82
+ return (await import(['.', 'push', 'combined.js'].join('/'))).createCombinedPushMount;
83
+ };
74
84
 
75
85
  export const runRelayServerCli = async (argv, dependencies = {}) => {
76
86
  const processLike = dependencies.process ?? process; const stdout = dependencies.stdout ?? process.stdout; const stderr = dependencies.stderr ?? process.stderr; const version = dependencies.version ?? '0.0.0';
@@ -87,15 +97,24 @@ export const runRelayServerCli = async (argv, dependencies = {}) => {
87
97
  let config;
88
98
  try { config = buildRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
89
99
  try {
100
+ const env = processLike.env ?? {};
101
+ const createPushMount = dependencies.createPushMount ?? (
102
+ hasPushApnsEnv(env) ? await loadCombinedPushMount() : () => null
103
+ );
104
+ const mount = createPushMount(env, dependencies.pushMountDeps ?? {});
105
+ if (mount) config.requestHandler = mount.requestHandler;
90
106
  const relay = await (dependencies.start ?? startPrivateRelayServer)(config);
107
+ if (mount) await mount.start();
91
108
  const url = config.publicUrl ?? relay.wsUrl;
92
- respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path } : { message: `Relay listening at ${url}` });
109
+ respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path, ...(mount ? { push: true } : {}) } : { message: `Relay listening at ${url}` });
110
+ if (mount && !json) respond({ message: 'Push relay mounted at /v1/push/*' });
93
111
  let stopping = false;
94
112
  const stop = async () => {
95
113
  if (stopping) return Promise.resolve();
96
114
  stopping = true;
97
115
  processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
98
116
  try {
117
+ if (mount) await mount.stop();
99
118
  await relay.stop();
100
119
  processLike.exit?.(0);
101
120
  } catch {
@@ -0,0 +1,34 @@
1
+ import { buildPushRelayConfig, ENV_PREFIX } from './config.js';
2
+ import { createPushRelayHandler } from './handler.js';
3
+
4
+ const APNS_ENV_PREFIX = `${ENV_PREFIX}APNS_`;
5
+
6
+ const hasPushApnsEnv = (env) => {
7
+ for (const [key, value] of Object.entries(env ?? {})) {
8
+ if (!key.startsWith(APNS_ENV_PREFIX)) continue;
9
+ if (typeof value === 'string' && value.trim().length > 0) return true;
10
+ }
11
+ return false;
12
+ };
13
+
14
+ export const createCombinedPushMount = (env = process.env, deps = {}) => {
15
+ if (!hasPushApnsEnv(env)) return null;
16
+ const config = buildPushRelayConfig({}, env);
17
+ // Combined mode ignores OPENCHAMBER_PUSH_RELAY_HOST / OPENCHAMBER_PUSH_RELAY_PORT;
18
+ // Push HTTP is mounted on the Layer 1 listener at /v1/push/* instead of opening its own port.
19
+ const handler = createPushRelayHandler({
20
+ databasePath: config.databasePath,
21
+ trustProxy: config.trustProxy,
22
+ limits: config.limits,
23
+ apns: config.apns,
24
+ apnsProvider: deps.apnsProvider,
25
+ clock: deps.clock,
26
+ claimHealthEndpoints: false,
27
+ });
28
+ return {
29
+ requestHandler: handler.handleRequest,
30
+ start: () => handler.activate(),
31
+ stop: () => handler.deactivate(),
32
+ getSnapshot: handler.getSnapshot,
33
+ };
34
+ };
@@ -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
+ };
@@ -1,258 +1,25 @@
1
1
  import http from 'node:http';
2
2
 
3
- import { createApnsProvider } from './apns.js';
4
- import { normalizePushRelayOptions, resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
5
- import { deriveServerId, verifyP1363 } from './crypto.js';
6
- import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
7
- import { JSON_BODY_BYTES, buildLiveActivityPayload, validateLiveActivityBody, validateLiveActivityRegisterBody, validateRegisterBody, validateSendBody } from './schema.js';
8
- import { createTokenStore } from './store.js';
3
+ import { normalizePushRelayOptions, formatPushRelayUrl } from './config.js';
4
+ import { createPushRelayHandler } from './handler.js';
9
5
 
10
- const WINDOW_MS = 60_000;
11
6
  const STOP_DEADLINE_MS = 5_500;
12
7
 
13
- const sendJson = (response, status, payload, method = 'GET') => {
14
- if (response.writableEnded) return;
15
- response.setHeader('cache-control', 'no-store');
16
- response.writeHead(status, { 'content-type': 'application/json' });
17
- response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
18
- };
19
-
20
- const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
21
- let done = false;
22
- const fail = (error) => { if (done) return; done = true; reject(error); };
23
- const succeed = (value) => { if (done) return; done = true; resolve(value); };
24
- const tooLarge = () => {
25
- const error = new Error('payload too large');
26
- error.code = 'PAYLOAD_TOO_LARGE';
27
- fail(error);
28
- };
29
- const drain = () => {
30
- request.removeListener('data', onData);
31
- request.resume();
32
- };
33
- const declared = Number(request.headers['content-length']);
34
- if (Number.isFinite(declared) && declared > maxBytes) {
35
- tooLarge();
36
- request.resume();
37
- return;
38
- }
39
- const chunks = [];
40
- let size = 0;
41
- const onData = (chunk) => {
42
- size += chunk.length;
43
- if (size > maxBytes) {
44
- drain();
45
- tooLarge();
46
- return;
47
- }
48
- chunks.push(chunk);
49
- };
50
- request.on('data', onData);
51
- request.on('end', () => succeed(Buffer.concat(chunks)));
52
- request.on('error', fail);
53
- });
54
-
55
8
  export const createPushRelayServer = (options = {}) => {
56
9
  const config = normalizePushRelayOptions(options);
57
- const limits = config.limits;
58
10
  const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
59
- const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
60
- const ownedStore = !config.store;
61
- const ownedApns = !config.apnsProvider;
62
- const openStore = () => config.store ?? createTokenStore(config.databasePath);
63
- const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
64
- let store = openStore();
65
- let apns;
66
- try {
67
- apns = openApns();
68
- } catch (error) {
69
- if (ownedStore) try { store.close(); } catch { /* ignore */ }
70
- throw error;
71
- }
72
- const liveStore = () => {
73
- if (!ownedStore) return store;
74
- if (state === 'stopping') return store;
75
- try { store.count(); return store; } catch {
76
- store = createTokenStore(config.databasePath);
77
- return store;
78
- }
79
- };
80
- const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
81
- const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
82
- const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
83
- const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
84
- const inFlight = createInFlightGate(limits.maxInFlight);
85
- const httpWork = createWorkTracker();
86
- const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
11
+ const handler = createPushRelayHandler({ ...options, claimHealthEndpoints: true });
87
12
  let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
88
- const snapshot = () => {
89
- let tokenCount = 0;
90
- try { tokenCount = store.count(); } catch { /* closed after stop */ }
91
- return { state, tokenCount, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
92
- };
93
-
94
- const authenticate = (jwk, message, signature, ts) => {
95
- if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
96
- if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
97
- return null;
98
- };
99
-
100
- const handleRegister = (parsed) => {
101
- const message = parsed.kind
102
- ? `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`
103
- : `${parsed.ts}.${parsed.token}.${parsed.platform}`;
104
- const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
105
- if (authError) return { status: 401, body: { error: authError } };
106
- const serverId = deriveServerId(parsed.publicKeyJwk);
107
- const replayKey = `${parsed.kind ? 'register-live-activity' : 'register'}.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
108
- if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
109
- const tokens = liveStore();
110
- const existing = tokens.get(parsed.token);
111
- if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
112
- if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
113
- tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
114
- return { status: 200, body: { ok: true } };
115
- };
116
-
117
- const handleUnregisterLiveActivity = (parsed) => {
118
- const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`, parsed.sig, parsed.ts);
119
- if (authError) return { status: 401, body: { error: authError } };
120
- const serverId = deriveServerId(parsed.publicKeyJwk);
121
- const replayKey = `unregister-live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
122
- if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
123
- const tokens = liveStore();
124
- const existing = tokens.get(parsed.token);
125
- if (existing && existing.serverId !== serverId) { reasons.authRejected += 1; return { status: 401, body: { error: 'invalid_signature' } }; }
126
- if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
127
- if (existing) tokens.delete(parsed.token);
128
- return { status: 200, body: { ok: true } };
129
- };
130
-
131
- const handleSend = async (parsed) => {
132
- const sorted = [...parsed.tokens].sort();
133
- const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
134
- if (authError) return { status: 401, body: { error: authError } };
135
- const serverId = deriveServerId(parsed.publicKeyJwk);
136
- const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
137
- if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
138
- if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
139
- if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
140
- const tokens = liveStore();
141
- const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
142
- const binding = tokens.get(token);
143
- if (!binding || binding.serverId !== serverId) return { token, ok: false };
144
- const acquired = await inFlight.acquire();
145
- if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
146
- try {
147
- const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
148
- if (outcome?.drop === true) {
149
- tokens.delete(token);
150
- return { token, ok: false, drop: true };
151
- }
152
- return { token, ok: outcome?.ok === true };
153
- } catch {
154
- return { token, ok: false };
155
- } finally {
156
- inFlight.release(acquired);
157
- }
158
- }));
159
- return { status: 200, body: { results } };
160
- };
161
-
162
- const handleLiveActivity = async (parsed) => {
163
- const sorted = [...parsed.tokens].sort();
164
- const contentState = parsed.contentState;
165
- const message = `${parsed.ts}.${sorted.join(',')}.${parsed.event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${parsed.dismissalDate ?? ''}.${parsed.staleDate ?? ''}`;
166
- const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
167
- if (authError) return { status: 401, body: { error: authError } };
168
- const serverId = deriveServerId(parsed.publicKeyJwk);
169
- const replayKey = `live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
170
- if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
171
- if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
172
- if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
173
- const payload = buildLiveActivityPayload({
174
- event: parsed.event,
175
- contentState,
176
- dismissalDate: parsed.dismissalDate,
177
- staleDate: parsed.staleDate,
178
- timestamp: Math.floor(clock.now() / 1000),
179
- });
180
- const tokens = liveStore();
181
- const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
182
- const binding = tokens.get(token);
183
- if (!binding || binding.serverId !== serverId) return { token, ok: false };
184
- const acquired = await inFlight.acquire();
185
- if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
186
- try {
187
- const outcome = await apns.send({ token, env: parsed.env, payload, pushType: 'liveactivity' });
188
- if (outcome?.drop === true) {
189
- tokens.delete(token);
190
- return { token, ok: false, drop: true };
191
- }
192
- if (outcome?.ok === true && parsed.event === 'end') tokens.delete(token);
193
- return { token, ok: outcome?.ok === true };
194
- } catch {
195
- return { token, ok: false };
196
- } finally {
197
- inFlight.release(acquired);
198
- }
199
- }));
200
- return { status: 200, body: { results } };
201
- };
202
-
203
- const onRequest = (request, response) => {
204
- let pathname;
205
- try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch { response.writeHead(404); response.end(); return; }
206
- const ready = pathname === '/readyz' && state === 'running';
207
- const healthy = pathname === '/healthz';
208
- if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
209
- sendJson(response, 200, { status: 'ok' }, request.method);
210
- return;
211
- }
212
- const isRegister = pathname === '/v1/push/register-token';
213
- const isRegisterLive = pathname === '/v1/push/register-live-activity-token';
214
- const isUnregisterLive = pathname === '/v1/push/unregister-live-activity-token';
215
- const isSend = pathname === '/v1/push/send';
216
- const isLiveActivity = pathname === '/v1/push/live-activity';
217
- if (request.method !== 'POST' || (!isRegister && !isRegisterLive && !isUnregisterLive && !isSend && !isLiveActivity) || state !== 'running') { response.writeHead(404); response.end(); return; }
218
- const ip = resolveClientIp(request);
219
- const limiter = (isRegister || isRegisterLive || isUnregisterLive) ? registerIpLimit : sendIpLimit;
220
- if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
221
- const endHttp = httpWork.begin();
222
- readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
223
- let body;
224
- try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
225
- const parsed = isRegister ? validateRegisterBody(body)
226
- : (isRegisterLive || isUnregisterLive) ? validateLiveActivityRegisterBody(body)
227
- : isLiveActivity ? validateLiveActivityBody(body)
228
- : validateSendBody(body);
229
- if (parsed.error) {
230
- reasons.policyRejected += 1;
231
- sendJson(response, 400, { error: parsed.error });
232
- return;
233
- }
234
- const result = (isRegister || isRegisterLive) ? handleRegister(parsed.value)
235
- : isUnregisterLive ? handleUnregisterLiveActivity(parsed.value)
236
- : isLiveActivity ? await handleLiveActivity(parsed.value)
237
- : await handleSend(parsed.value);
238
- sendJson(response, result.status, result.body);
239
- }).catch((error) => {
240
- if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
241
- sendJson(response, 500, { error: 'internal' });
242
- }).finally(endHttp);
243
- };
244
13
 
245
14
  const start = () => {
246
15
  if (state === 'running') return Promise.resolve();
247
16
  if (state === 'stopping') return stopPromise.then(() => start());
248
17
  if (startPromise) return startPromise;
249
- liveStore();
250
- inFlight.reset();
251
- httpWork.reset();
252
- if (ownedApns && state === 'stopped') apns = openApns();
253
18
  state = 'starting';
254
19
  const localGeneration = ++generation;
255
- const localServer = http.createServer(onRequest);
20
+ const localServer = http.createServer((request, response) => {
21
+ handler.handleRequest(request, response);
22
+ });
256
23
  server = localServer;
257
24
  startPromise = new Promise((resolve, rejectStart) => {
258
25
  const failStart = (error) => {
@@ -264,6 +31,7 @@ export const createPushRelayServer = (options = {}) => {
264
31
  const ready = () => {
265
32
  localServer.off('error', failStart);
266
33
  if (localGeneration !== generation || state !== 'starting') return;
34
+ handler.activate();
267
35
  state = 'running';
268
36
  resolve();
269
37
  };
@@ -290,11 +58,6 @@ export const createPushRelayServer = (options = {}) => {
290
58
  generation += 1;
291
59
  const localServer = server;
292
60
  stopPromise = Promise.resolve().then(async () => {
293
- inFlight.rejectWaiters();
294
- replay.clear();
295
- registerIpLimit.clear();
296
- sendIpLimit.clear();
297
- sendServerLimit.clear();
298
61
  let deadlineTimer = null;
299
62
  try {
300
63
  const closed = new Promise((resolve) => {
@@ -302,22 +65,19 @@ export const createPushRelayServer = (options = {}) => {
302
65
  localServer.close(() => resolve());
303
66
  try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
304
67
  });
305
- const httpIdle = httpWork.whenIdle().then(() => {
306
- try { localServer?.closeIdleConnections?.(); } catch { /* ignore */ }
307
- });
308
- const graceful = Promise.all([closed, inFlight.whenIdle(), httpIdle]);
68
+ const deactivated = handler.deactivate();
69
+ const graceful = Promise.all([closed, deactivated]);
309
70
  const deadline = new Promise((resolve) => {
310
71
  deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
311
72
  });
312
73
  const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
313
74
  if (winner === 'deadline') {
314
75
  try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
76
+ await deactivated;
315
77
  }
316
78
  } finally {
317
79
  if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
318
80
  }
319
- try { apns.close?.(); } catch { /* ignore */ }
320
- if (ownedStore) try { store.close(); } catch { /* ignore */ }
321
81
  if (server === localServer) { server = null; state = 'stopped'; }
322
82
  stopPromise = null;
323
83
  });
@@ -332,7 +92,7 @@ export const createPushRelayServer = (options = {}) => {
332
92
  const address = server?.address();
333
93
  return address && typeof address === 'object' ? formatPushRelayUrl(config.host, address.port) : null;
334
94
  },
335
- getSnapshot: snapshot,
95
+ getSnapshot: () => ({ ...handler.getSnapshot(), state }),
336
96
  };
337
97
  };
338
98