@yuriai/relay-server 0.1.2-beta.2 → 0.1.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
@@ -302,6 +302,18 @@ Push process:
302
302
  | `YURI_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.yuri` | App bundle ID |
303
303
  | `YURI_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM |
304
304
  | `YURI_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file |
305
+ | `YURI_PUSH_RELAY_APNS_APPS_JSON` | unset | Up to 32 additional app configurations; see README Multi-app APNs push |
306
+
307
+ Registration accepts optional `bundleId`. If present it is appended as
308
+ `.${bundleId}` to the existing signed register message (including `kind` for Live
309
+ Activity). The Relay validates it against its configured applications, then stores
310
+ it with the token binding. Send bodies cannot override that identity. A token with
311
+ an explicit binding cannot be reassigned to another app; historical callers that
312
+ omit it preserve the binding. Unregister keeps its existing owner-authenticated
313
+ signature. Missing identities use the configured default only; unknown explicit
314
+ identities are never guessed. Both cached-store and SQLite persist the field,
315
+ including across worker/process restarts. APNs providers are isolated per app and
316
+ environment. See the README for migration, deployment order and rollback limits.
305
317
 
306
318
  TestFlight and App Store Hosts use `YURI_APNS_ENVIRONMENT=production`. Each send request carries `env`; the Push process does not pick sandbox vs production itself.
307
319
 
package/README.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Yuri Relay Server
2
2
 
3
+ ## Multi-app APNs push
4
+
5
+ One Push process can deliver to multiple installed apps. `YURI_PUSH_RELAY_APNS_BUNDLE_ID`
6
+ is the default topic for historical registrations without an app identity. Keep that
7
+ default unchanged when retaining old clients. Add explicitly allowed apps with
8
+ `YURI_PUSH_RELAY_APNS_APPS_JSON`:
9
+
10
+ ```yaml
11
+ environment:
12
+ YURI_PUSH_RELAY_APNS_BUNDLE_ID: com.example.legacy
13
+ YURI_PUSH_RELAY_APNS_KEY_ID: ${APNS_KEY_ID}
14
+ YURI_PUSH_RELAY_APNS_TEAM_ID: ${APPLE_TEAM_ID}
15
+ YURI_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_default
16
+ YURI_PUSH_RELAY_APNS_APPS_JSON: >-
17
+ [{"bundleId":"com.yee94.yuri"}]
18
+ ```
19
+
20
+ An entry with only `bundleId` shares the default APNs key (the key must authorize
21
+ that app and environment). For a separate key, provide **all** of `keyId`, `teamId`
22
+ and `p8Path` (or `p8`) in that entry. Mount key files read-only. This uses APNs
23
+ authentication `.p8` keys, not Apple Distribution / Developer ID `.p12` files.
24
+ Each app has isolated JWT and HTTP/2 connection state; alert and Live Activity
25
+ topics follow the stored app binding. Unknown apps fail registration; removed
26
+ configurations do not fall back to another app's key or invalidate their tokens.
27
+
28
+ Upgrade Relay first, then the Host and mobile web bundle. New native registrations
29
+ read the installed shell's Bundle ID and the Host signs it into the Relay binding.
30
+ An old Host cannot forward this identity, and old clients without it cannot be
31
+ distinguished automatically. Reopen upgraded clients to re-register. Existing
32
+ explicit bindings survive older registration requests without being downgraded.
33
+ No alternate-topic probing is performed. Single-app direct Host APNs remains
34
+ single-app and skips explicitly mismatched tokens without deleting them.
35
+
36
+ The SQLite migration adds a nullable `bundle_id` column without deleting existing
37
+ rows. Legacy rows retain default-topic behavior until re-registration. Back up the
38
+ database before rollout; an old writer does not understand explicit app bindings,
39
+ so rollbacks require the matching pre-upgrade database and single-app deployment.
40
+ Use one process per database and a persistent volume (not a temporary directory).
41
+
42
+ For a Docker-only release with an independent Relay package version, run the
43
+ `Relay Docker` workflow with `version` matching this package, `push=true`, and
44
+ `require_root_version_match=false`. The default remains `true` for full releases.
45
+
3
46
  `yuri-relay` is the self-hosted Layer 1 relay for Yuri remote access. It gives Yuri Hosts an outbound Relay connection and carries encrypted client traffic through that connection.
4
47
 
5
48
  The same `@yuriai/relay-server` package also ships `yuri-push-relay`, an isolated APNs push process. Layer 1 never receives Apple credentials. Push never terminates E2EE tunnels.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuriai/relay-server",
3
- "version": "0.1.2-beta.2",
3
+ "version": "0.1.3",
4
4
  "description": "Self-hosted private relay server for Yuri",
5
5
  "private": false,
6
6
  "type": "module",
package/src/push/apns.js CHANGED
@@ -14,6 +14,24 @@ export const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'De
14
14
  const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
15
15
 
16
16
  export const createApnsProvider = (options = {}) => {
17
+ if (options.apps?.length) {
18
+ const providers = new Map();
19
+ try {
20
+ for (const app of [{ ...options, apps: undefined }, ...options.apps]) {
21
+ providers.set(app.bundleId, createApnsProvider({ ...options, ...app, apps: undefined }));
22
+ }
23
+ } catch (error) {
24
+ for (const provider of providers.values()) provider.close();
25
+ throw error;
26
+ }
27
+ return {
28
+ send(input) {
29
+ const provider = providers.get(input.bundleId ?? options.bundleId);
30
+ return provider ? provider.send(input) : Promise.resolve({ ok: false });
31
+ },
32
+ close() { for (const provider of providers.values()) provider.close(); },
33
+ };
34
+ }
17
35
  const clock = { now: Date.now, setTimeout, clearTimeout, ...options.clock };
18
36
  const connect = options.http2?.connect ?? http2.connect;
19
37
  const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
@@ -120,6 +138,7 @@ export const createApnsProvider = (options = {}) => {
120
138
 
121
139
  return {
122
140
  async send(input) {
141
+ if (input.bundleId !== undefined && input.bundleId !== bundleId) return { ok: false };
123
142
  const first = await dispatch(input, false);
124
143
  if (first.expired) {
125
144
  const retry = await dispatch(input, true);
@@ -103,8 +103,8 @@ export const createCachedTokenStore = (databasePath, { maxTokens, maxPending = 6
103
103
  ready,
104
104
  get(token) { assertReady(); return records.get(token) ?? null; },
105
105
  count() { assertReady(); return records.size; },
106
- upsert(token, serverId, platform, updatedAt) {
107
- return write('upsert', [token, serverId, platform, updatedAt], () => records.set(token, { serverId, platform, updatedAt }), () => {
106
+ upsert(token, serverId, platform, updatedAt, bundleId) {
107
+ return write('upsert', [token, serverId, platform, updatedAt, bundleId], () => records.set(token, { serverId, platform, updatedAt, ...(bundleId ? { bundleId } : {}) }), () => {
108
108
  if (!records.has(token) && records.size >= maxTokens) throw overloaded();
109
109
  return false;
110
110
  });
@@ -61,6 +61,9 @@ export const validDatabasePath = (name, value) => {
61
61
 
62
62
  export const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
63
63
 
64
+ const isPushBundleId = (value) => typeof value === 'string'
65
+ && value.length <= 255 && /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value);
66
+
64
67
  export const validatePushRelayLimits = (limits) => {
65
68
  if (limits.replayMs < limits.timestampSkewMs * 2 || limits.maxReplayEntries < 1) throw new RangeError('invalid replay limits');
66
69
  if (limits.maxTokens < 1 || limits.maxInFlight < 1 || limits.registerLimitPerMinute < 1 || limits.sendLimitPerMinute < 1 || limits.serverSendLimitPerMinute < 1) {
@@ -79,7 +82,12 @@ export const normalizePushRelayOptions = (options = {}) => {
79
82
  validDatabasePath('databasePath', databasePath);
80
83
  if (!options.apnsProvider) {
81
84
  const apns = options.apns ?? {};
82
- if (!apns.keyId || !apns.teamId || !apns.p8 || !apns.bundleId) throw new Error('Invalid APNs configuration');
85
+ const apps = [apns, ...(apns.apps ?? [])];
86
+ const ids = new Set();
87
+ for (const app of apps) {
88
+ if (!app.keyId || !app.teamId || !app.p8 || !isPushBundleId(app.bundleId) || ids.has(app.bundleId)) throw new Error('Invalid APNs configuration');
89
+ ids.add(app.bundleId);
90
+ }
83
91
  }
84
92
  return {
85
93
  host,
@@ -134,12 +142,33 @@ export const buildPushRelayConfig = (parsed = {}, env = process.env) => {
134
142
  if (typeof teamId !== 'string' || teamId.trim().length === 0) fail(`${ENV_PREFIX}APNS_TEAM_ID`);
135
143
  const p8 = loadP8(env);
136
144
  if (!p8) fail(`${ENV_PREFIX}APNS_P8`);
145
+ const apps = [];
146
+ const appsJson = env[`${ENV_PREFIX}APNS_APPS_JSON`];
147
+ if (appsJson !== undefined) {
148
+ let entries;
149
+ try { entries = JSON.parse(appsJson); } catch { fail(`${ENV_PREFIX}APNS_APPS_JSON`); }
150
+ if (!Array.isArray(entries) || entries.length > 32) fail(`${ENV_PREFIX}APNS_APPS_JSON`);
151
+ const ids = new Set([bundleId]);
152
+ for (const app of entries) {
153
+ if (!app || !isPushBundleId(app.bundleId) || ids.has(app.bundleId)) fail(`${ENV_PREFIX}APNS_APPS_JSON`);
154
+ ids.add(app.bundleId);
155
+ // Omit all credential fields to share the default team's APNs key.
156
+ const ownKey = ['keyId', 'teamId', 'p8', 'p8Path'].some((key) => app[key] !== undefined);
157
+ if (ownKey && (typeof app.keyId !== 'string' || !app.keyId.trim() || typeof app.teamId !== 'string' || !app.teamId.trim())) fail(`${ENV_PREFIX}APNS_APPS_JSON`);
158
+ const appP8 = ownKey ? loadP8({
159
+ [`${ENV_PREFIX}APNS_P8`]: app.p8,
160
+ [`${ENV_PREFIX}APNS_P8_PATH`]: app.p8Path,
161
+ }) : p8;
162
+ apps.push({ bundleId: app.bundleId, keyId: ownKey ? app.keyId.trim() : keyId.trim(), teamId: ownKey ? app.teamId.trim() : teamId.trim(), p8: appP8 });
163
+ }
164
+ }
165
+ if (!isPushBundleId(bundleId)) fail(`${ENV_PREFIX}APNS_BUNDLE_ID`);
137
166
  return {
138
167
  host,
139
168
  port,
140
169
  databasePath,
141
170
  trustProxy: parsed.trustProxy ?? bool(envName('trustProxy'), envValue(env, 'trustProxy')),
142
171
  limits: merged,
143
- apns: { keyId: keyId.trim(), teamId: teamId.trim(), p8, bundleId: String(bundleId).trim() },
172
+ apns: { keyId: keyId.trim(), teamId: teamId.trim(), p8, bundleId: String(bundleId).trim(), ...(apps.length ? { apps } : {}) },
144
173
  };
145
174
  };
@@ -64,6 +64,7 @@ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
64
64
  export const createPushRelayHandler = (options = {}) => {
65
65
  const claimHealthEndpoints = options.claimHealthEndpoints !== false;
66
66
  const config = normalizePushRelayOptions(options);
67
+ const allowedBundleIds = new Set([config.apns?.bundleId, ...(config.apns?.apps ?? []).map((app) => app.bundleId)].filter(Boolean));
67
68
  const limits = config.limits;
68
69
  const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
69
70
  const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
@@ -114,9 +115,10 @@ export const createPushRelayHandler = (options = {}) => {
114
115
  };
115
116
 
116
117
  const handleRegister = (parsed) => mutate(async () => {
117
- const message = parsed.kind
118
+ let message = parsed.kind
118
119
  ? `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`
119
120
  : `${parsed.ts}.${parsed.token}.${parsed.platform}`;
121
+ if (parsed.bundleId !== undefined) message += `.${parsed.bundleId}`;
120
122
  const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
121
123
  if (authError) return { status: 401, body: { error: authError } };
122
124
  const serverId = deriveServerId(parsed.publicKeyJwk);
@@ -124,9 +126,12 @@ export const createPushRelayHandler = (options = {}) => {
124
126
  if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
125
127
  const tokens = store;
126
128
  const existing = tokens.get(parsed.token);
129
+ if (parsed.bundleId !== undefined && !allowedBundleIds.has(parsed.bundleId)) return { status: 400, body: { error: 'unsupported_app' } };
130
+ const bundleId = parsed.bundleId ?? existing?.bundleId;
131
+ if (existing?.bundleId && existing.bundleId !== bundleId) return { status: 409, body: { error: 'app_mismatch' } };
127
132
  if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
128
133
  if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
129
- try { await tokens.upsert(parsed.token, serverId, parsed.platform, clock.now()); } catch (error) { replay.forget(replayKey); throw error; }
134
+ try { await tokens.upsert(parsed.token, serverId, parsed.platform, clock.now(), bundleId); } catch (error) { replay.forget(replayKey); throw error; }
130
135
  return { status: 200, body: { ok: true } };
131
136
  });
132
137
 
@@ -160,7 +165,7 @@ export const createPushRelayHandler = (options = {}) => {
160
165
  const acquired = await inFlight.acquire();
161
166
  if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
162
167
  try {
163
- const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
168
+ const outcome = await apns.send({ token, bundleId: binding.bundleId ?? config.apns?.bundleId, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
164
169
  if (outcome?.drop === true) {
165
170
  await tokens.delete(token, binding);
166
171
  return { token, ok: false, drop: true };
@@ -200,7 +205,7 @@ export const createPushRelayHandler = (options = {}) => {
200
205
  const acquired = await inFlight.acquire();
201
206
  if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
202
207
  try {
203
- const outcome = await apns.send({ token, env: parsed.env, payload, pushType: 'liveactivity' });
208
+ const outcome = await apns.send({ token, bundleId: binding.bundleId ?? config.apns?.bundleId, env: parsed.env, payload, pushType: 'liveactivity' });
204
209
  if (outcome?.drop === true) {
205
210
  await tokens.delete(token, binding);
206
211
  return { token, ok: false, drop: true };
@@ -22,6 +22,7 @@ export interface PushRelayApnsConfig {
22
22
  teamId: string;
23
23
  p8: string;
24
24
  bundleId: string;
25
+ apps?: Array<Omit<PushRelayApnsConfig, 'apps'>>;
25
26
  }
26
27
 
27
28
  export interface PushRelayClock {
@@ -35,6 +36,7 @@ export interface PushRelayClock {
35
36
 
36
37
  export interface PushApnsSendInput {
37
38
  token: string;
39
+ bundleId?: string;
38
40
  env: PushRelayEnv;
39
41
  payload: unknown;
40
42
  collapseId?: string;
@@ -55,11 +57,12 @@ export interface PushTokenRecord {
55
57
  serverId: string;
56
58
  platform: string;
57
59
  updatedAt: number;
60
+ bundleId?: string;
58
61
  }
59
62
 
60
63
  export interface PushTokenStore {
61
64
  get(token: string): PushTokenRecord | null;
62
- upsert(token: string, serverId: string, platform: string, updatedAt: number): void | Promise<void>;
65
+ upsert(token: string, serverId: string, platform: string, updatedAt: number, bundleId?: string): void | Promise<void>;
63
66
  delete(token: string, expected?: PushTokenRecord): void | Promise<void>;
64
67
  count(): number;
65
68
  close(): void | Promise<void>;
@@ -134,11 +134,12 @@ export const validateRegisterBody = (body) => {
134
134
  if (body.platform === 'android') return { error: 'unsupported_platform' };
135
135
  if (body.platform !== 'ios') return { error: 'invalid_request' };
136
136
  if (!isIosToken(body.token)) return { error: 'invalid_request' };
137
+ if (body.bundleId !== undefined && (typeof body.bundleId !== 'string' || body.bundleId.length > 255 || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(body.bundleId))) return { error: 'invalid_request' };
137
138
  const jwk = parsePublicJwk(body.publicKeyJwk);
138
139
  const ts = parseTs(body.ts);
139
140
  const sig = parseSig(body.sig);
140
141
  if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
141
- return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig } };
142
+ return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig, ...(body.bundleId !== undefined ? { bundleId: body.bundleId } : {}) } };
142
143
  };
143
144
 
144
145
  export const validateLiveActivityRegisterBody = (body) => {
package/src/push/store.js CHANGED
@@ -20,9 +20,12 @@ export const createTokenStore = (databasePath, { busyTimeoutMs = 5_000 } = {}) =
20
20
  db.exec('PRAGMA journal_mode = WAL');
21
21
  db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
22
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`);
23
+ if (!db.prepare('PRAGMA table_info(push_tokens)').all().some((column) => column.name === 'bundle_id')) {
24
+ db.exec('ALTER TABLE push_tokens ADD COLUMN bundle_id TEXT');
25
+ }
26
+ const getStmt = db.prepare('SELECT server_id AS serverId, platform, updated_at AS updatedAt, bundle_id AS bundleId FROM push_tokens WHERE token = ?');
27
+ const upsertStmt = db.prepare(`INSERT INTO push_tokens (token, server_id, platform, updated_at, bundle_id) VALUES (?, ?, ?, ?, ?)
28
+ ON CONFLICT(token) DO UPDATE SET server_id = excluded.server_id, platform = excluded.platform, updated_at = excluded.updated_at, bundle_id = excluded.bundle_id`);
26
29
  const deleteStmt = db.prepare('DELETE FROM push_tokens WHERE token = ?');
27
30
  const countStmt = db.prepare('SELECT COUNT(*) AS n FROM push_tokens');
28
31
  let closed = false;
@@ -30,18 +33,18 @@ export const createTokenStore = (databasePath, { busyTimeoutMs = 5_000 } = {}) =
30
33
  return {
31
34
  entries(maxTokens) {
32
35
  assertOpen();
33
- const rows = db.prepare('SELECT token, server_id AS serverId, platform, updated_at AS updatedAt FROM push_tokens LIMIT ?').all(maxTokens + 1);
36
+ const rows = db.prepare('SELECT token, server_id AS serverId, platform, updated_at AS updatedAt, bundle_id AS bundleId FROM push_tokens LIMIT ?').all(maxTokens + 1);
34
37
  if (rows.length > maxTokens) throw new Error('token capacity exceeded');
35
- return rows;
38
+ return rows.map(({ bundleId, ...row }) => ({ ...row, ...(bundleId ? { bundleId } : {}) }));
36
39
  },
37
40
  get(token) {
38
41
  assertOpen();
39
42
  const row = getStmt.get(token);
40
- return row ? { serverId: row.serverId, platform: row.platform, updatedAt: row.updatedAt } : null;
43
+ return row ? { serverId: row.serverId, platform: row.platform, updatedAt: row.updatedAt, ...(row.bundleId ? { bundleId: row.bundleId } : {}) } : null;
41
44
  },
42
- upsert(token, serverId, platform, updatedAt) {
45
+ upsert(token, serverId, platform, updatedAt, bundleId = null) {
43
46
  assertOpen();
44
- upsertStmt.run(token, serverId, platform, updatedAt);
47
+ upsertStmt.run(token, serverId, platform, updatedAt, bundleId);
45
48
  },
46
49
  delete(token) {
47
50
  assertOpen();