@openchambery/relay-server 1.19.3-beta.5 → 1.19.3-beta.6
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 +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/push/apns.js +4 -3
- package/src/push/index.d.ts +1 -0
- package/src/push/schema.js +84 -7
- package/src/push/server.js +74 -7
package/DOCUMENTATION.md
CHANGED
|
@@ -39,7 +39,7 @@ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
|
|
|
39
39
|
openchamber-push-relay --host 127.0.0.1 --port 8788
|
|
40
40
|
```
|
|
41
41
|
|
|
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.
|
|
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
44
|
### Caddy
|
|
45
45
|
|
package/README.md
CHANGED
|
@@ -261,7 +261,7 @@ These variables belong on the OpenChamber Host, not on the Push process:
|
|
|
261
261
|
| `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay `ws`/`wss` URL | Host override for `https://` or `http://` `…/v1/push/send` |
|
|
262
262
|
| `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | Host-only; `true` skips Push Relay and uses direct APNs |
|
|
263
263
|
|
|
264
|
-
The derived send URL always uses `/v1/push/send` on the same host and port as the Relay URL. `wss` maps to `https`; `ws` maps to `http`. Register is the same origin with `/v1/push/register-token`.
|
|
264
|
+
The derived send URL always uses `/v1/push/send` on the same host and port as the Relay URL. `wss` maps to `https`; `ws` maps to `http`. Register is the same origin with `/v1/push/register-token`. iOS Live Activity tokens use `/v1/push/register-live-activity-token`, `/v1/push/unregister-live-activity-token`, and `/v1/push/live-activity` on that same origin. Live Activity APNs requests use topic `{bundleId}.push-type.liveactivity` and never include session IDs, titles, alerts, or collapse IDs.
|
|
265
265
|
|
|
266
266
|
### Push process environment
|
|
267
267
|
|
package/package.json
CHANGED
package/src/push/apns.js
CHANGED
|
@@ -65,15 +65,16 @@ export const createApnsProvider = (options = {}) => {
|
|
|
65
65
|
const jwt = getJwt(forceJwt);
|
|
66
66
|
let client;
|
|
67
67
|
try { client = getSession(input.env); } catch { resolve({ ok: false }); return; }
|
|
68
|
+
const liveActivity = input.pushType === 'liveactivity';
|
|
68
69
|
const headers = {
|
|
69
70
|
':method': 'POST',
|
|
70
71
|
':path': `/3/device/${input.token}`,
|
|
71
72
|
authorization: `bearer ${jwt}`,
|
|
72
|
-
'apns-topic': bundleId,
|
|
73
|
-
'apns-push-type': 'alert',
|
|
73
|
+
'apns-topic': liveActivity ? `${bundleId}.push-type.liveactivity` : bundleId,
|
|
74
|
+
'apns-push-type': liveActivity ? 'liveactivity' : 'alert',
|
|
74
75
|
'apns-priority': '10',
|
|
75
76
|
};
|
|
76
|
-
if (input.collapseId) headers['apns-collapse-id'] = input.collapseId;
|
|
77
|
+
if (!liveActivity && input.collapseId) headers['apns-collapse-id'] = input.collapseId;
|
|
77
78
|
let req;
|
|
78
79
|
try { req = client.request(headers); } catch {
|
|
79
80
|
dropSession(input.env, client);
|
package/src/push/index.d.ts
CHANGED
package/src/push/schema.js
CHANGED
|
@@ -11,9 +11,14 @@ export const MAX_DATA_ENTRIES = 16;
|
|
|
11
11
|
export const MAX_DATA_KEY_BYTES = 64;
|
|
12
12
|
export const MAX_DATA_VALUE_BYTES = 256;
|
|
13
13
|
export const MAX_DATA_TOTAL_BYTES = 2048;
|
|
14
|
+
export const LIVE_ACTIVITY_KIND = 'liveactivity';
|
|
15
|
+
export const LIVE_ACTIVITY_EVENTS = new Set(['update', 'end']);
|
|
16
|
+
export const LIVE_ACTIVITY_STATUSES = new Set(['working', 'tool', 'retry', 'input', 'permission', 'stale', 'complete', 'error']);
|
|
17
|
+
export const LIVE_ACTIVITY_CONTENT_KEYS = new Set(['status', 'eventVersion', 'updatedAt', 'endedAt']);
|
|
14
18
|
|
|
15
19
|
const bytes = (value) => Buffer.byteLength(value, 'utf8');
|
|
16
20
|
const isSafeInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
|
|
21
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
17
22
|
|
|
18
23
|
export const isIosToken = (value) => typeof value === 'string' && IOS_TOKEN.test(value);
|
|
19
24
|
|
|
@@ -48,6 +53,37 @@ export const buildApnsPayload = ({ title, body, badge, collapseId, data }) => {
|
|
|
48
53
|
return Object.keys(data).length > 0 ? { aps, ...data } : { aps };
|
|
49
54
|
};
|
|
50
55
|
|
|
56
|
+
export const buildLiveActivityPayload = ({ event, contentState, dismissalDate, staleDate, timestamp }) => {
|
|
57
|
+
const aps = { timestamp, event, 'content-state': contentState };
|
|
58
|
+
if (event === 'end' && dismissalDate !== undefined) aps['dismissal-date'] = dismissalDate;
|
|
59
|
+
if (event === 'update' && staleDate !== undefined) aps['stale-date'] = staleDate;
|
|
60
|
+
return { aps };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const uniqueTokens = (tokens) => {
|
|
64
|
+
const unique = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const token of tokens) {
|
|
67
|
+
if (seen.has(token)) continue;
|
|
68
|
+
seen.add(token);
|
|
69
|
+
unique.push(token);
|
|
70
|
+
}
|
|
71
|
+
return unique;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const parseContentState = (value, event) => {
|
|
75
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
76
|
+
const keys = Object.keys(value);
|
|
77
|
+
if (keys.length === 0 || keys.some((key) => !LIVE_ACTIVITY_CONTENT_KEYS.has(key))) return null;
|
|
78
|
+
if (typeof value.status !== 'string' || !LIVE_ACTIVITY_STATUSES.has(value.status)) return null;
|
|
79
|
+
if (!isSafeInt(value.eventVersion) || !isFiniteNumber(value.updatedAt)) return null;
|
|
80
|
+
if (event === 'end' && !isFiniteNumber(value.endedAt)) return null;
|
|
81
|
+
if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return null;
|
|
82
|
+
const contentState = { status: value.status, eventVersion: value.eventVersion, updatedAt: value.updatedAt };
|
|
83
|
+
if (value.endedAt !== undefined) contentState.endedAt = value.endedAt;
|
|
84
|
+
return contentState;
|
|
85
|
+
};
|
|
86
|
+
|
|
51
87
|
export const validateRegisterBody = (body) => {
|
|
52
88
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
53
89
|
if (body.platform === 'android') return { error: 'unsupported_platform' };
|
|
@@ -60,6 +96,13 @@ export const validateRegisterBody = (body) => {
|
|
|
60
96
|
return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig } };
|
|
61
97
|
};
|
|
62
98
|
|
|
99
|
+
export const validateLiveActivityRegisterBody = (body) => {
|
|
100
|
+
const parsed = validateRegisterBody(body);
|
|
101
|
+
if (parsed.error) return parsed;
|
|
102
|
+
if (body.kind !== LIVE_ACTIVITY_KIND) return { error: 'invalid_request' };
|
|
103
|
+
return { value: { ...parsed.value, kind: LIVE_ACTIVITY_KIND } };
|
|
104
|
+
};
|
|
105
|
+
|
|
63
106
|
export const validateSendBody = (body) => {
|
|
64
107
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
65
108
|
const tokens = body.tokens;
|
|
@@ -75,13 +118,7 @@ export const validateSendBody = (body) => {
|
|
|
75
118
|
const sig = parseSig(body.sig);
|
|
76
119
|
const data = parseData(body.data);
|
|
77
120
|
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
|
-
}
|
|
121
|
+
const unique = uniqueTokens(tokens);
|
|
85
122
|
const payload = buildApnsPayload({
|
|
86
123
|
title: body.title,
|
|
87
124
|
body: body.body ?? '',
|
|
@@ -106,3 +143,43 @@ export const validateSendBody = (body) => {
|
|
|
106
143
|
},
|
|
107
144
|
};
|
|
108
145
|
};
|
|
146
|
+
|
|
147
|
+
export const validateLiveActivityBody = (body) => {
|
|
148
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
149
|
+
const tokens = body.tokens;
|
|
150
|
+
if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
|
|
151
|
+
if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
|
|
152
|
+
if (typeof body.event !== 'string' || !LIVE_ACTIVITY_EVENTS.has(body.event)) return { error: 'invalid_request' };
|
|
153
|
+
const contentState = parseContentState(body.contentState, body.event);
|
|
154
|
+
if (!contentState) return { error: 'invalid_request' };
|
|
155
|
+
if (body.dismissalDate !== undefined && !isSafeInt(body.dismissalDate)) return { error: 'invalid_request' };
|
|
156
|
+
if (body.staleDate !== undefined && !isSafeInt(body.staleDate)) return { error: 'invalid_request' };
|
|
157
|
+
if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
|
|
158
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
159
|
+
const ts = parseTs(body.ts);
|
|
160
|
+
const sig = parseSig(body.sig);
|
|
161
|
+
if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
|
|
162
|
+
const payload = buildLiveActivityPayload({
|
|
163
|
+
event: body.event,
|
|
164
|
+
contentState,
|
|
165
|
+
dismissalDate: body.dismissalDate,
|
|
166
|
+
staleDate: body.staleDate,
|
|
167
|
+
timestamp: 1_000_000_000,
|
|
168
|
+
});
|
|
169
|
+
if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
|
|
170
|
+
return {
|
|
171
|
+
value: {
|
|
172
|
+
tokens,
|
|
173
|
+
uniqueTokens: uniqueTokens(tokens),
|
|
174
|
+
event: body.event,
|
|
175
|
+
contentState,
|
|
176
|
+
dismissalDate: body.dismissalDate,
|
|
177
|
+
staleDate: body.staleDate,
|
|
178
|
+
env: body.env === 'production' ? 'production' : 'sandbox',
|
|
179
|
+
publicKeyJwk: jwk,
|
|
180
|
+
ts,
|
|
181
|
+
sig,
|
|
182
|
+
payload,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|
package/src/push/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createApnsProvider } from './apns.js';
|
|
|
4
4
|
import { normalizePushRelayOptions, resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
|
|
5
5
|
import { deriveServerId, verifyP1363 } from './crypto.js';
|
|
6
6
|
import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
|
|
7
|
-
import { JSON_BODY_BYTES, validateRegisterBody, validateSendBody } from './schema.js';
|
|
7
|
+
import { JSON_BODY_BYTES, buildLiveActivityPayload, validateLiveActivityBody, validateLiveActivityRegisterBody, validateRegisterBody, validateSendBody } from './schema.js';
|
|
8
8
|
import { createTokenStore } from './store.js';
|
|
9
9
|
|
|
10
10
|
const WINDOW_MS = 60_000;
|
|
@@ -98,10 +98,13 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
98
98
|
};
|
|
99
99
|
|
|
100
100
|
const handleRegister = (parsed) => {
|
|
101
|
-
const
|
|
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);
|
|
102
105
|
if (authError) return { status: 401, body: { error: authError } };
|
|
103
106
|
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
104
|
-
const replayKey =
|
|
107
|
+
const replayKey = `${parsed.kind ? 'register-live-activity' : 'register'}.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
105
108
|
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
106
109
|
const tokens = liveStore();
|
|
107
110
|
const existing = tokens.get(parsed.token);
|
|
@@ -111,6 +114,20 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
111
114
|
return { status: 200, body: { ok: true } };
|
|
112
115
|
};
|
|
113
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
|
+
|
|
114
131
|
const handleSend = async (parsed) => {
|
|
115
132
|
const sorted = [...parsed.tokens].sort();
|
|
116
133
|
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
|
|
@@ -142,6 +159,47 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
142
159
|
return { status: 200, body: { results } };
|
|
143
160
|
};
|
|
144
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
|
+
|
|
145
203
|
const onRequest = (request, response) => {
|
|
146
204
|
let pathname;
|
|
147
205
|
try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch { response.writeHead(404); response.end(); return; }
|
|
@@ -152,22 +210,31 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
152
210
|
return;
|
|
153
211
|
}
|
|
154
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';
|
|
155
215
|
const isSend = pathname === '/v1/push/send';
|
|
156
|
-
|
|
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; }
|
|
157
218
|
const ip = resolveClientIp(request);
|
|
158
|
-
const limiter = isRegister ? registerIpLimit : sendIpLimit;
|
|
219
|
+
const limiter = (isRegister || isRegisterLive || isUnregisterLive) ? registerIpLimit : sendIpLimit;
|
|
159
220
|
if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
|
|
160
221
|
const endHttp = httpWork.begin();
|
|
161
222
|
readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
|
|
162
223
|
let body;
|
|
163
224
|
try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
|
|
164
|
-
const parsed = isRegister ? validateRegisterBody(body)
|
|
225
|
+
const parsed = isRegister ? validateRegisterBody(body)
|
|
226
|
+
: (isRegisterLive || isUnregisterLive) ? validateLiveActivityRegisterBody(body)
|
|
227
|
+
: isLiveActivity ? validateLiveActivityBody(body)
|
|
228
|
+
: validateSendBody(body);
|
|
165
229
|
if (parsed.error) {
|
|
166
230
|
reasons.policyRejected += 1;
|
|
167
231
|
sendJson(response, 400, { error: parsed.error });
|
|
168
232
|
return;
|
|
169
233
|
}
|
|
170
|
-
const result = isRegister
|
|
234
|
+
const result = (isRegister || isRegisterLive) ? handleRegister(parsed.value)
|
|
235
|
+
: isUnregisterLive ? handleUnregisterLiveActivity(parsed.value)
|
|
236
|
+
: isLiveActivity ? await handleLiveActivity(parsed.value)
|
|
237
|
+
: await handleSend(parsed.value);
|
|
171
238
|
sendJson(response, result.status, result.body);
|
|
172
239
|
}).catch((error) => {
|
|
173
240
|
if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
|