Files
openchamber/packages/web/server/lib/notifications/apns-runtime.js
T
Bohdan Triapitsyn 9f1bd0dfa0 fix: route APNs delivery per-token by registered environment
Issue: after defaulting APNs delivery to production (#2381), development
builds installed from Xcode stopped receiving notifications entirely:
their sandbox device tokens were sent to the production APNs endpoint,
rejected as BadDeviceToken, and dropped as dead.

Fix: the iOS shell reads the aps-environment entitlement from the
embedded provisioning profile and exposes it to the web layer as a
document-start user script (added in capacitorDidLoad, since Capacitor
replaces the userContentController after webViewConfiguration(for:)).
Token registration reports the environment to the server, which stores
it per token and groups delivery by environment for both relay and
direct APNs sends. OPENCHAMBER_APNS_ENVIRONMENT remains as an explicit
override forcing every send to one environment.

TestFlight/App Store builds and older clients without the field default
to production, preserving released behavior; the relay already accepts
env per send request.
2026-07-25 01:18:41 +03:00

531 lines
22 KiB
JavaScript

// APNs (Apple Push Notification service) runtime for the native iOS mobile app.
//
// Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two
// modes, chosen at send time:
// - Relay (default): POST tokens + generic text to the central Cloudflare relay, which
// holds the single project APNs key and signs+sends — so users configure nothing.
// - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves,
// for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true.
// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
// generic, model-based text (no session content) — see APNS.md.
import {
getOrCreateRelaySigningKeypair,
signRelayMessage as signRelayMessageShared,
} from '../relay/signing-key.js';
const APNS_TOKENS_VERSION = 1;
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
// APNs rejects auth tokens older than 1h; refresh well inside that window.
const JWT_TTL_MS = 50 * 60 * 1000;
const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send';
const MAX_TOKENS_PER_SESSION = 10;
// APNs reasons that mean the token is permanently invalid → drop it.
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
const trimmedEnv = (name) => {
const value = process.env[name];
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
};
// Env vars commonly store the .p8 with literal "\n" sequences; restore real newlines.
const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
export const createApnsRuntime = (deps) => {
const {
fsPromises,
path,
crypto,
http2,
APNS_TOKENS_FILE_PATH,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
// Strict settings reader gating identity regeneration (see signing-key.js).
readSettingsStrict,
} = deps;
let persistLock = Promise.resolve();
let cachedJwt = null; // { token, issuedAtMs, keyId }
let cachedRelayKey = null; // { privateKey, publicJwk }
let warnedUnconfigured = false;
// ---------------------------------------------------------------------------
// Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
// (mirrors getOrCreateVapidKeys). The relay derives serverId = SHA-256(publicKey), verifies
// each request's signature, and only delivers to tokens this server registered — so a leaked
// device token alone can't be used to push. Zero-config: the keypair generates on first use.
// ---------------------------------------------------------------------------
// Key access lives in lib/relay/signing-key.js now (shared with the private
// relay identity — same keypair, same storage, same serverId derivation).
const getOrCreateRelayKeypair = async () => {
if (cachedRelayKey) return cachedRelayKey;
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
return cachedRelayKey;
};
const signRelayMessage = (privateKey, message) => signRelayMessageShared({ crypto }, privateKey, message);
// Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
const relayPublicJwk = (publicJwk) => ({
kty: publicJwk.kty,
crv: publicJwk.crv,
x: publicJwk.x,
y: publicJwk.y,
});
const registerTokenWithRelay = async (token, platform = 'ios') => {
const relay = resolveRelayConfig();
if (!relay) return; // direct mode — no relay binding needed
try {
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
const ts = Date.now();
// platform is part of the signed message so it can't be tampered en route.
const sig = signRelayMessage(privateKey, `${ts}.${token}.${platform}`);
const res = await fetch(relay.registerUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
});
if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
} catch (error) {
console.warn('[Push relay] register-token request failed:', error?.message ?? error);
}
};
// ---------------------------------------------------------------------------
// Token persistence (same shape + write-lock pattern as push-runtime.js)
// ---------------------------------------------------------------------------
const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} });
const readTokensFromDisk = async () => {
try {
const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) {
return emptyStore();
}
const tokensBySession =
parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
return { version: APNS_TOKENS_VERSION, tokensBySession };
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return emptyStore();
}
console.warn('Failed to read APNs tokens file:', error);
return emptyStore();
}
};
const writeTokensToDisk = async (data) => {
await fsPromises.mkdir(path.dirname(APNS_TOKENS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(APNS_TOKENS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
};
const persistTokenUpdate = async (mutate) => {
persistLock = persistLock.then(async () => {
const current = await readTokensFromDisk();
const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} });
await writeTokensToDisk(next);
return next;
});
return persistLock;
};
const normalizeTokens = (record) => {
if (!Array.isArray(record)) return [];
return record
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const deviceToken = entry.deviceToken;
if (typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return null;
return {
deviceToken: deviceToken.trim(),
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
lastSeenAt: typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : null,
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
platform: entry.platform === 'android' ? 'android' : 'ios',
// APNs delivery environment for this token. Xcode/dev-signed installs produce
// sandbox tokens, TestFlight/App Store produce production ones; the client reports
// which at registration. Older entries without one default to production (matches
// released builds).
environment: entry.environment === 'sandbox' ? 'sandbox' : 'production',
};
})
.filter(Boolean);
};
// Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
// was the only registrant before Android/FCM existed.
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
const normalizeEnvironment = (environment) => (environment === 'sandbox' ? 'sandbox' : 'production');
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, environment) => {
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
const token = deviceToken.trim();
const tokenPlatform = normalizePlatform(platform);
const tokenEnvironment = normalizeEnvironment(environment);
const now = Date.now();
await persistTokenUpdate((current) => {
const tokensBySession = { ...(current.tokensBySession || {}) };
const existing = normalizeTokens(tokensBySession[uiSessionToken]);
const filtered = existing.filter((entry) => entry.deviceToken !== token);
filtered.unshift({
deviceToken: token,
createdAt: now,
lastSeenAt: now,
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
platform: tokenPlatform,
environment: tokenEnvironment,
});
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
return { version: APNS_TOKENS_VERSION, tokensBySession };
});
// (Re)bind this token to our server on the relay so only we can push to it. The device
// re-sends its token on each launch; this is an idempotent upsert relay-side, and binding
// every time (not just for new tokens) keeps existing tokens bound after a relay/server
// upgrade rather than silently going unbound. Platform is bound too so the relay routes
// it to APNs vs FCM.
await registerTokenWithRelay(token, tokenPlatform);
};
const removeApnsToken = async (uiSessionToken, deviceToken) => {
if (!uiSessionToken || !deviceToken) return;
await persistTokenUpdate((current) => {
const tokensBySession = { ...(current.tokensBySession || {}) };
const filtered = normalizeTokens(tokensBySession[uiSessionToken]).filter(
(entry) => entry.deviceToken !== deviceToken,
);
if (filtered.length === 0) delete tokensBySession[uiSessionToken];
else tokensBySession[uiSessionToken] = filtered;
return { version: APNS_TOKENS_VERSION, tokensBySession };
});
};
const removeApnsTokenFromAllSessions = async (deviceToken) => {
if (!deviceToken) return;
await persistTokenUpdate((current) => {
const tokensBySession = { ...(current.tokensBySession || {}) };
for (const [session, entries] of Object.entries(tokensBySession)) {
const filtered = normalizeTokens(entries).filter((entry) => entry.deviceToken !== deviceToken);
if (filtered.length === 0) delete tokensBySession[session];
else tokensBySession[session] = filtered;
}
return { version: APNS_TOKENS_VERSION, tokensBySession };
});
};
// ---------------------------------------------------------------------------
// Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
// ---------------------------------------------------------------------------
const resolveApnsConfig = async () => {
let keyId = trimmedEnv('OPENCHAMBER_APNS_KEY_ID');
let teamId = trimmedEnv('OPENCHAMBER_APNS_TEAM_ID');
let bundleId = trimmedEnv('OPENCHAMBER_APNS_BUNDLE_ID');
let environment = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
let p8 = normalizePem(process.env.OPENCHAMBER_APNS_P8 || '');
const p8Path = trimmedEnv('OPENCHAMBER_APNS_P8_PATH');
if (!p8 && p8Path) {
try {
p8 = (await fsPromises.readFile(p8Path, 'utf8')).trim();
} catch (error) {
console.warn('[APNs] Failed to read OPENCHAMBER_APNS_P8_PATH:', error?.message ?? error);
}
}
if (!keyId || !teamId || !p8) {
try {
const settings = await readSettingsFromDiskMigrated();
const stored = settings?.apnsConfig;
if (stored && typeof stored === 'object') {
keyId = keyId || (typeof stored.keyId === 'string' ? stored.keyId.trim() : null);
teamId = teamId || (typeof stored.teamId === 'string' ? stored.teamId.trim() : null);
bundleId = bundleId || (typeof stored.bundleId === 'string' ? stored.bundleId.trim() : null);
environment = environment || (typeof stored.environment === 'string' ? stored.environment.toLowerCase() : '');
if (!p8 && typeof stored.p8 === 'string') p8 = normalizePem(stored.p8);
}
} catch {
// settings unavailable — fall through to the unconfigured result
}
}
if (!keyId || !teamId || !p8) return null;
return {
keyId,
teamId,
p8,
bundleId: bundleId || DEFAULT_BUNDLE_ID,
// Explicit env/settings value forces every send to that environment; when unset (null),
// each token is delivered to the environment it registered with.
environment: environment === 'sandbox' ? 'sandbox' : environment === 'production' ? 'production' : null,
};
};
// ---------------------------------------------------------------------------
// JWT (ES256, JOSE/raw signature) + HTTP/2 send
// ---------------------------------------------------------------------------
const signApnsJwt = (config) => {
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: config.keyId })).toString('base64url');
const claims = Buffer.from(
JSON.stringify({ iss: config.teamId, iat: Math.floor(Date.now() / 1000) }),
).toString('base64url');
const signingInput = `${header}.${claims}`;
const signature = crypto
.sign('sha256', Buffer.from(signingInput), { key: config.p8, dsaEncoding: 'ieee-p1363' })
.toString('base64url');
return `${signingInput}.${signature}`;
};
const getJwt = (config) => {
const now = Date.now();
if (cachedJwt && cachedJwt.keyId === config.keyId && now - cachedJwt.issuedAtMs < JWT_TTL_MS) {
return cachedJwt.token;
}
const token = signApnsJwt(config);
cachedJwt = { token, issuedAtMs: now, keyId: config.keyId };
return token;
};
const buildBody = (payload) => {
const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {};
return JSON.stringify({
aps: {
alert: {
title: typeof payload?.title === 'string' ? payload.title : undefined,
body: typeof payload?.body === 'string' ? payload.body : undefined,
},
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
sound: 'default',
'thread-id': typeof payload?.tag === 'string' ? payload.tag : undefined,
// Wakes the Notification Service Extension so it can refresh the home/lock-screen
// widgets (attention count + unread dot) from the push, even when the app is closed.
// No extra network call — just an extra key on the push we already send.
'mutable-content': 1,
},
...data,
});
};
const sendOne = (client, deviceToken, body, jwt, config) =>
new Promise((resolve) => {
const headers = {
':method': 'POST',
':path': `/3/device/${deviceToken}`,
authorization: `bearer ${jwt}`,
'apns-topic': config.bundleId,
'apns-push-type': 'alert',
'apns-priority': '10',
};
// collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined;
if (collapseId) headers['apns-collapse-id'] = collapseId;
let req;
try {
req = client.request(headers);
} catch (error) {
console.warn('[APNs] request open failed:', error?.message ?? error);
resolve();
return;
}
let status = 0;
let responseBody = '';
req.on('response', (resHeaders) => {
status = Number(resHeaders[':status']) || 0;
});
req.setEncoding('utf8');
req.on('data', (chunk) => {
responseBody += chunk;
});
req.on('end', async () => {
if (status === 200) {
resolve();
return;
}
let reason = '';
try {
reason = JSON.parse(responseBody)?.reason || '';
} catch {
// non-JSON error body
}
if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
await removeApnsTokenFromAllSessions(deviceToken);
} else {
console.warn(`[APNs] push failed status=${status} reason=${reason || 'unknown'}`);
}
resolve();
});
req.on('error', (error) => {
console.warn('[APNs] request error:', error?.message ?? error);
resolve();
});
req.end(body);
});
// Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
// each user's server — so users configure nothing. The server just POSTs device tokens +
// generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
// is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
const resolveRelayConfig = () => {
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
const override = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
return {
url,
registerUrl: url.replace(/\/send$/, '/register-token'),
// Explicit OPENCHAMBER_APNS_ENVIRONMENT forces every send to that environment; when
// unset (null), each token is delivered to the environment it registered with.
environment: override === 'sandbox' ? 'sandbox' : override === 'production' ? 'production' : null,
};
};
const sendViaRelay = async (deviceTokens, payload, relay, environment) => {
const tokens = deviceTokens.slice(0, 100);
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
const ts = Date.now();
// Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
const requestBody = JSON.stringify({
tokens,
title,
body: typeof payload?.body === 'string' ? payload.body : '',
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
env: environment,
data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
publicKeyJwk: relayPublicJwk(publicJwk),
ts,
sig,
});
try {
const res = await fetch(relay.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: requestBody,
});
if (!res.ok) {
console.warn(`[APNs relay] send failed status=${res.status}`);
return;
}
const data = await res.json().catch(() => null);
const results = Array.isArray(data?.results) ? data.results : [];
for (const result of results) {
if (result && result.drop === true && typeof result.token === 'string') {
await removeApnsTokenFromAllSessions(result.token);
}
}
} catch (error) {
console.warn('[APNs relay] request failed:', error?.message ?? error);
}
};
const sendViaDirectApns = async (tokenGroups, payload) => {
const config = await resolveApnsConfig();
if (!config) {
if (!warnedUnconfigured) {
warnedUnconfigured = true;
console.warn(
'[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.',
);
}
return;
}
const jwt = getJwt(config);
const body = buildBody(payload);
const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
// One HTTP/2 session per APNs environment; a sandbox token sent to the production host
// (or vice versa) gets BadDeviceToken and would be wrongly dropped as dead.
for (const [environment, deviceTokens] of tokenGroups) {
const effectiveEnvironment = config.environment ?? environment;
const host = effectiveEnvironment === 'sandbox' ? APNS_HOST_SANDBOX : APNS_HOST_PRODUCTION;
let client;
try {
client = http2.connect(host);
} catch (error) {
console.warn('[APNs] connect failed:', error?.message ?? error);
continue;
}
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
try {
client.close();
} catch {
// ignore close errors
}
resolve();
};
client.on('error', (error) => {
console.warn('[APNs] session error:', error?.message ?? error);
finish();
});
Promise.all(
deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)),
).finally(finish);
});
}
};
// NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
// report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
// background push for short responses. Instead we always send, and rely on iOS to NOT
// display the alert while the app is foreground (presentationOptions: [] in
// capacitor.config) — so there is no notification when the app is active, with no race.
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
const store = await readTokensFromDisk();
// Tokens are grouped by their registered APNs environment so each batch goes to the
// endpoint that actually knows the token (Xcode builds → sandbox, TestFlight/App Store
// → production). Mixing them gets BadDeviceToken and the token wrongly dropped as dead.
const tokensByEnvironment = new Map();
const seen = new Set();
for (const record of Object.values(store.tokensBySession || {})) {
for (const entry of normalizeTokens(record)) {
if (seen.has(entry.deviceToken)) continue;
seen.add(entry.deviceToken);
const group = tokensByEnvironment.get(entry.environment) || [];
group.push(entry.deviceToken);
tokensByEnvironment.set(entry.environment, group);
}
}
if (seen.size === 0) return;
const relay = resolveRelayConfig();
if (relay) {
for (const [environment, deviceTokens] of tokensByEnvironment) {
await sendViaRelay(deviceTokens, payload, relay, relay.environment ?? environment);
}
return;
}
await sendViaDirectApns(tokensByEnvironment, payload);
};
return {
addOrUpdateApnsToken,
removeApnsToken,
removeApnsTokenFromAllSessions,
sendApnsToAllUiSessions,
resolveApnsConfig,
// exposed for tests
signApnsJwt,
};
};