@yuriai/relay-server 0.1.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 +12 -0
- package/README.md +43 -0
- package/package.json +1 -1
- package/src/push/apns.js +19 -0
- package/src/push/cached-store.js +2 -2
- package/src/push/config.js +31 -2
- package/src/push/handler.js +9 -4
- package/src/push/index.d.ts +4 -1
- package/src/push/schema.js +2 -1
- package/src/push/store.js +11 -8
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
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);
|
package/src/push/cached-store.js
CHANGED
|
@@ -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
|
});
|
package/src/push/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/push/handler.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/push/index.d.ts
CHANGED
|
@@ -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>;
|
package/src/push/schema.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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();
|