Files
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

413 lines
12 KiB
JavaScript

const parsePushSubscribeBody = (body) => {
if (!body || typeof body !== 'object') return null;
const endpoint = body.endpoint;
const keys = body.keys;
const p256dh = keys?.p256dh;
const auth = keys?.auth;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null;
if (typeof auth !== 'string' || auth.trim().length === 0) return null;
return {
endpoint: endpoint.trim(),
keys: { p256dh: p256dh.trim(), auth: auth.trim() },
};
};
const parsePushUnsubscribeBody = (body) => {
if (!body || typeof body !== 'object') return null;
const endpoint = body.endpoint;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
return { endpoint: endpoint.trim() };
};
export const NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
export const registerNotificationRoutes = (app, dependencies) => {
const {
uiAuthController,
ensurePushInitialized,
ensureGlobalWatcherStarted,
getOrCreateVapidKeys,
getUiSessionTokenFromRequest,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
addOrUpdateApnsToken,
removeApnsToken,
updateUiVisibility,
clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
getSessionActivitySnapshot,
getSessionStateSnapshot,
getSessionAttentionSnapshot,
getSessionState,
getSessionAttentionState,
markSessionViewed,
markSessionUnviewed,
markUserMessageSent,
setPushInitialized,
setAutoAcceptSession,
} = dependencies;
const ensureSessionWatcher = async () => {
if (typeof ensureGlobalWatcherStarted !== 'function') {
return;
}
try {
await ensureGlobalWatcherStarted();
} catch (error) {
console.warn('[OpenCodeWatcher] lazy start failed:', error?.message ?? error);
}
};
app.get('/api/push/vapid-public-key', async (_req, res) => {
try {
await ensurePushInitialized();
const keys = await getOrCreateVapidKeys();
res.json({ publicKey: keys.publicKey });
} catch (error) {
console.warn('[Push] Failed to load VAPID key:', error);
res.status(500).json({ error: 'Failed to load push key' });
}
});
app.post('/api/push/subscribe', async (req, res) => {
await ensurePushInitialized();
await ensureSessionWatcher();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const parsed = parsePushSubscribeBody(req.body);
if (!parsed) {
return res.status(400).json({ error: 'Invalid body' });
}
const { endpoint, keys } = parsed;
const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : '';
if (origin.startsWith('http://') || origin.startsWith('https://')) {
try {
const settings = await readSettingsFromDiskMigrated();
if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) {
await writeSettingsToDisk({
...settings,
publicOrigin: origin,
});
setPushInitialized(false);
}
} catch {
}
}
const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined;
await addOrUpdatePushSubscription(
uiToken,
{
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
},
req.headers['user-agent'],
platform
);
return res.json({ ok: true });
});
app.delete('/api/push/subscribe', async (req, res) => {
await ensurePushInitialized();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const parsed = parsePushUnsubscribeBody(req.body);
if (!parsed) {
return res.status(400).json({ error: 'Invalid body' });
}
await removePushSubscription(uiToken, parsed.endpoint);
return res.json({ ok: true });
});
// Native iOS APNs device token registration (mirrors /api/push/subscribe). The token
// is a hex APNs device token from @capacitor/push-notifications, scoped to the UI
// session like web-push subscriptions.
app.post('/api/push/apns-token', async (req, res) => {
await ensureSessionWatcher();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
if (!deviceToken) {
return res.status(400).json({ error: 'Invalid body' });
}
const platform = req.body?.platform === 'android' ? 'android' : 'ios';
// APNs environment the token belongs to: Xcode/dev-signed installs report 'sandbox',
// TestFlight/App Store report 'production'. Absent (older clients, Android) → production.
const environment = req.body?.environment === 'sandbox' ? 'sandbox' : 'production';
if (typeof addOrUpdateApnsToken === 'function') {
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform, environment);
}
return res.json({ ok: true });
});
app.delete('/api/push/apns-token', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
if (!deviceToken) {
return res.status(400).json({ error: 'Invalid body' });
}
if (typeof removeApnsToken === 'function') {
await removeApnsToken(uiToken, deviceToken);
}
return res.json({ ok: true });
});
app.post('/api/push/visibility', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const platform = typeof body.platform === 'string' ? body.platform : undefined;
updateUiVisibility(uiToken, body.visible === true, platform);
return res.json({ ok: true });
});
app.get('/api/push/visibility', (req, res) => {
const uiToken = getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
return res.json({
ok: true,
visible: isUiVisible(uiToken),
});
});
app.get('/api/notifications/stream', async (req, res) => {
await ensureSessionWatcher();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return;
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const clients = getUiNotificationClients();
clients.add(res);
let closed = false;
let heartbeatTimer = null;
const cleanup = () => {
if (closed) {
return;
}
closed = true;
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
clients.delete(res);
};
req.on('close', cleanup);
res.on('error', cleanup);
const flushSse = () => {
res.flush?.();
};
heartbeatTimer = setInterval(() => {
if (closed || res.writableEnded || res.destroyed) {
cleanup();
return;
}
try {
res.write(':heartbeat\n\n');
flushSse();
} catch {
cleanup();
}
}, NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS);
try {
writeSseEvent(res, {
type: 'openchamber:notification-stream-ready',
properties: { uiToken },
});
flushSse();
} catch {
cleanup();
}
});
app.get('/api/session-activity', (_req, res) => {
void ensureSessionWatcher();
res.json(getSessionActivitySnapshot());
});
app.get('/api/sessions/snapshot', async (_req, res) => {
await ensureSessionWatcher();
res.json({
statusSessions: getSessionStateSnapshot(),
attentionSessions: getSessionAttentionSnapshot(),
serverTime: Date.now(),
});
});
app.get('/api/sessions/status', async (_req, res) => {
await ensureSessionWatcher();
const snapshot = getSessionStateSnapshot();
res.json({
sessions: snapshot,
serverTime: Date.now(),
});
});
app.get('/api/sessions/:id/status', async (req, res) => {
await ensureSessionWatcher();
const sessionId = req.params.id;
const state = getSessionState(sessionId);
if (!state) {
return res.status(404).json({
error: 'Session not found or no state available',
sessionId,
});
}
return res.json({
sessionId,
...state,
});
});
app.get('/api/sessions/attention', async (_req, res) => {
await ensureSessionWatcher();
const snapshot = getSessionAttentionSnapshot();
res.json({
sessions: snapshot,
serverTime: Date.now(),
});
});
app.get('/api/sessions/:id/attention', async (req, res) => {
await ensureSessionWatcher();
const sessionId = req.params.id;
const state = getSessionAttentionState(sessionId);
if (!state) {
return res.status(404).json({
error: 'Session not found or no attention state available',
sessionId,
});
}
return res.json({
sessionId,
...state,
});
});
app.post('/api/sessions/:id/view', (req, res) => {
const sessionId = req.params.id;
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionViewed(sessionId, clientId);
// The user is engaging with the app, so the native push badge no longer
// applies — reset it here too (not only on the visibility beacon), since
// opening the app reliably marks the opened session viewed.
if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
return res.json({
success: true,
sessionId,
viewed: true,
});
});
app.post('/api/sessions/:id/unview', (req, res) => {
const sessionId = req.params.id;
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionUnviewed(sessionId, clientId);
return res.json({
success: true,
sessionId,
viewed: false,
});
});
app.post('/api/sessions/:id/message-sent', (req, res) => {
const sessionId = req.params.id;
markUserMessageSent(sessionId);
// Sending a message means the user is active in the app; reset the native
// push badge so it counts only notifications since this engagement.
if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
return res.json({
success: true,
sessionId,
messageSent: true,
});
});
// Mirror client-side Permission Auto-Accept state to the server so it can
// suppress permission notifications at the source (the 500ms debounce race
// otherwise leaks notifications for auto-accepted permissions).
app.post('/api/notifications/auto-accept', (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
const enabled = body.enabled === true;
if (!sessionId) {
return res.status(400).json({ error: 'sessionId required' });
}
if (typeof setAutoAcceptSession === 'function') {
setAutoAcceptSession(sessionId, enabled);
}
return res.json({ success: true, sessionId, enabled });
});
};