2026-07-01 09:55:41 +03:00
|
|
|
// 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.
|
|
|
|
|
|
2026-07-08 03:44:02 +03:00
|
|
|
import {
|
|
|
|
|
getOrCreateRelaySigningKeypair,
|
|
|
|
|
signRelayMessage as signRelayMessageShared,
|
|
|
|
|
} from '../relay/signing-key.js';
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
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,
|
2026-07-12 18:09:54 +03:00
|
|
|
// Strict settings reader gating identity regeneration (see signing-key.js).
|
|
|
|
|
readSettingsStrict,
|
2026-07-01 09:55:41 +03:00
|
|
|
} = 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.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-07-08 03:44:02 +03:00
|
|
|
// Key access lives in lib/relay/signing-key.js now (shared with the private
|
|
|
|
|
// relay identity — same keypair, same storage, same serverId derivation).
|
2026-07-01 09:55:41 +03:00
|
|
|
const getOrCreateRelayKeypair = async () => {
|
|
|
|
|
if (cachedRelayKey) return cachedRelayKey;
|
2026-07-12 18:09:54 +03:00
|
|
|
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
|
2026-07-01 09:55:41 +03:00
|
|
|
return cachedRelayKey;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-08 03:44:02 +03:00
|
|
|
const signRelayMessage = (privateKey, message) => signRelayMessageShared({ crypto }, privateKey, message);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
// 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',
|
2026-07-25 01:18:41 +03:00
|
|
|
// 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',
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.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');
|
|
|
|
|
|
2026-07-25 01:18:41 +03:00
|
|
|
const normalizeEnvironment = (environment) => (environment === 'sandbox' ? 'sandbox' : 'production');
|
|
|
|
|
|
|
|
|
|
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, environment) => {
|
2026-07-01 09:55:41 +03:00
|
|
|
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
|
|
|
|
|
const token = deviceToken.trim();
|
|
|
|
|
const tokenPlatform = normalizePlatform(platform);
|
2026-07-25 01:18:41 +03:00
|
|
|
const tokenEnvironment = normalizeEnvironment(environment);
|
2026-07-01 09:55:41 +03:00
|
|
|
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,
|
2026-07-25 01:18:41 +03:00
|
|
|
environment: tokenEnvironment,
|
2026-07-01 09:55:41 +03:00
|
|
|
});
|
|
|
|
|
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,
|
2026-07-25 01:18:41 +03:00
|
|
|
// 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,
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
2026-07-25 01:18:41 +03:00
|
|
|
const override = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
|
2026-07-01 09:55:41 +03:00
|
|
|
return {
|
|
|
|
|
url,
|
|
|
|
|
registerUrl: url.replace(/\/send$/, '/register-token'),
|
2026-07-25 01:18:41 +03:00
|
|
|
// 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,
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-25 01:18:41 +03:00
|
|
|
const sendViaRelay = async (deviceTokens, payload, relay, environment) => {
|
2026-07-01 09:55:41 +03:00
|
|
|
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,
|
2026-07-25 01:18:41 +03:00
|
|
|
env: environment,
|
2026-07-01 09:55:41 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-25 01:18:41 +03:00
|
|
|
const sendViaDirectApns = async (tokenGroups, payload) => {
|
2026-07-01 09:55:41 +03:00
|
|
|
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 };
|
|
|
|
|
|
2026-07-25 01:18:41 +03:00
|
|
|
// 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;
|
2026-07-01 09:55:41 +03:00
|
|
|
|
2026-07-25 01:18:41 +03:00
|
|
|
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);
|
2026-07-01 09:55:41 +03:00
|
|
|
});
|
2026-07-25 01:18:41 +03:00
|
|
|
}
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-07-25 01:18:41 +03:00
|
|
|
// 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();
|
2026-07-01 09:55:41 +03:00
|
|
|
const seen = new Set();
|
|
|
|
|
for (const record of Object.values(store.tokensBySession || {})) {
|
|
|
|
|
for (const entry of normalizeTokens(record)) {
|
2026-07-25 01:18:41 +03:00
|
|
|
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);
|
2026-07-01 09:55:41 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-25 01:18:41 +03:00
|
|
|
if (seen.size === 0) return;
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
const relay = resolveRelayConfig();
|
|
|
|
|
if (relay) {
|
2026-07-25 01:18:41 +03:00
|
|
|
for (const [environment, deviceTokens] of tokensByEnvironment) {
|
|
|
|
|
await sendViaRelay(deviceTokens, payload, relay, relay.environment ?? environment);
|
|
|
|
|
}
|
2026-07-01 09:55:41 +03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-07-25 01:18:41 +03:00
|
|
|
await sendViaDirectApns(tokensByEnvironment, payload);
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
addOrUpdateApnsToken,
|
|
|
|
|
removeApnsToken,
|
|
|
|
|
removeApnsTokenFromAllSessions,
|
|
|
|
|
sendApnsToAllUiSessions,
|
|
|
|
|
resolveApnsConfig,
|
|
|
|
|
// exposed for tests
|
|
|
|
|
signApnsJwt,
|
|
|
|
|
};
|
|
|
|
|
};
|