@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.3-beta.5",
3
+ "version": "1.19.3-beta.6",
4
4
  "description": "Self-hosted private relay server for OpenChamber",
5
5
  "private": false,
6
6
  "type": "module",
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);
@@ -38,6 +38,7 @@ export interface PushApnsSendInput {
38
38
  env: PushRelayEnv;
39
39
  payload: unknown;
40
40
  collapseId?: string;
41
+ pushType?: 'alert' | 'liveactivity';
41
42
  }
42
43
 
43
44
  export interface PushApnsSendResult {
@@ -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
+ };
@@ -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 authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}`, parsed.sig, parsed.ts);
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 = `register.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
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
- if (request.method !== 'POST' || (!isRegister && !isSend) || state !== 'running') { response.writeHead(404); response.end(); return; }
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) : validateSendBody(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 ? handleRegister(parsed.value) : await handleSend(parsed.value);
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; }