feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected) T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT (node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode, single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22 Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
This commit is contained in:
576
src/push/apns.ts
Normal file
576
src/push/apns.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* src/push/apns.ts (T-iOS-20) — APNs sender + device-token registry for the
|
||||
* iOS client (PLAN_IOS_CLIENT §0.3/§7 declared server touch-point).
|
||||
*
|
||||
* Sits BESIDE the web-push `push-service` behind the same `NotifyService` seam:
|
||||
* the server combines both via `combineNotifyServices` so every existing hook
|
||||
* event point (held gate → needs-input, Stop/SessionEnd → done) fans out to
|
||||
* browsers AND iPhones with zero new event paths.
|
||||
*
|
||||
* Config is env-only and all-or-disabled: APNS_KEY_PATH + APNS_KEY_ID +
|
||||
* APNS_TEAM_ID are the critical trio; any piece missing (or an unreadable /
|
||||
* non-EC key) cleanly disables the feature — one log line, never a crash,
|
||||
* never any key material in logs.
|
||||
*
|
||||
* PAYLOAD MINIMIZATION (security invariant, mirrors push-service SP1/SEC-C5):
|
||||
* the visible alert carries ONLY a session short-prefix + status word; the
|
||||
* custom payload carries ONLY what the web-push payload already carries
|
||||
* (sessionId, cls, and — needs-input only — the single-use capability token
|
||||
* for /hook/decision). Never cwd, never command content, never terminal bytes.
|
||||
*
|
||||
* Transport: hand-rolled HTTP/2 + ES256 JWT over node:http2/node:crypto (zero
|
||||
* new npm deps, repo rule). The HTTP/2 layer hides behind the `ApnsHttp2Client`
|
||||
* seam so tests never touch the network. JWT is cached ~50 min (Apple wants
|
||||
* 20–60 min); a 403 drops the cache so the next send re-signs.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chmod, writeFile } from 'node:fs/promises';
|
||||
import { connect } from 'node:http2';
|
||||
import { createPrivateKey, sign as signBytes, type KeyObject } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Config, NotifyClass, NotifyService, Session } from '../types.js';
|
||||
|
||||
// ── constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 0600 — owner read/write only; the file holds device push tokens. */
|
||||
const FILE_MODE = 0o600;
|
||||
/** Default APNs topic when APNS_BUNDLE_ID is unset. */
|
||||
const DEFAULT_BUNDLE_ID = 'com.yaojia.webterm';
|
||||
/** Default token-store filename (under the home dir), mirroring pushStorePath. */
|
||||
const DEFAULT_STORE_FILENAME = '.web-terminal-apns-tokens.json';
|
||||
/** Production APNs host; APNS_HOST overrides (sandbox / tests). */
|
||||
const DEFAULT_APNS_HOST = 'https://api.push.apple.com';
|
||||
/** Refresh the provider JWT after ~50 min (Apple requires 20–60 min). */
|
||||
const JWT_MAX_AGE_MS = 50 * 60_000;
|
||||
/** Expiration window (seconds) for low-priority DONE pushes (mirrors web-push TTL). */
|
||||
const DONE_EXPIRATION_SECONDS = 600;
|
||||
/** Frozen wire shape: an APNs device token is 64–160 hex chars, lowercased. */
|
||||
const APNS_TOKEN_PATTERN = /^[0-9a-f]{64,160}$/;
|
||||
/** Notification category the iOS app registers Allow/Deny actions against. */
|
||||
const GATE_CATEGORY = 'WEBTERM_GATE';
|
||||
/** apns-priority: deliver immediately (held gate) vs. power-friendly (done). */
|
||||
const PRIORITY_IMMEDIATE = '10';
|
||||
const PRIORITY_CONSERVE = '5';
|
||||
/** Give a single HTTP/2 exchange this long before failing it (never hang). */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
/** Chars of the session id shown in the visible alert (minimization). */
|
||||
const ALERT_ID_PREFIX_LEN = 8;
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Frozen, validated APNs runtime config (loaded from the APNS_* env group). */
|
||||
export interface ApnsConfig {
|
||||
readonly keyPath: string;
|
||||
readonly keyId: string;
|
||||
readonly teamId: string;
|
||||
readonly bundleId: string;
|
||||
readonly storePath: string;
|
||||
readonly host: string;
|
||||
}
|
||||
|
||||
export type ApnsConfigResult =
|
||||
| { readonly ok: true; readonly config: ApnsConfig }
|
||||
| { readonly ok: false; readonly reason: string };
|
||||
|
||||
/** One registered iPhone (device token is hex, already lowercase-normalized). */
|
||||
export interface ApnsTokenRecord {
|
||||
readonly token: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
export interface ApnsTokenStore {
|
||||
/** Frozen snapshot of current records (immutable to callers). */
|
||||
list(): readonly ApnsTokenRecord[];
|
||||
/** Idempotent upsert by token (FIFO-capped). Throws on invalid input. */
|
||||
add(record: ApnsTokenRecord): void;
|
||||
/** Remove by token; no-op when absent (idempotent). */
|
||||
remove(token: string): void;
|
||||
/** Remove a batch of dead tokens (410 / BadDeviceToken evictions). */
|
||||
prune(deadTokens: readonly string[]): void;
|
||||
/** Persist to disk (0600). Best-effort: logs and resolves on failure. */
|
||||
persist(): Promise<void>;
|
||||
}
|
||||
|
||||
/** One APNs HTTP/2 exchange (headers exclude pseudo-headers except via path). */
|
||||
export interface ApnsRequest {
|
||||
readonly path: string;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface ApnsResponse {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** Transport seam over node:http2 (injectable for tests — no network in tests). */
|
||||
export interface ApnsHttp2Client {
|
||||
request(req: ApnsRequest): Promise<ApnsResponse>;
|
||||
}
|
||||
|
||||
export interface ApnsServiceDeps {
|
||||
client?: ApnsHttp2Client;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** What the server wires up when the feature is enabled. */
|
||||
export interface ApnsRuntime {
|
||||
readonly service: NotifyService;
|
||||
readonly store: ApnsTokenStore;
|
||||
}
|
||||
|
||||
// ── config loading ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read a required env var; empty string counts as missing. */
|
||||
function envOrNull(env: Record<string, string | undefined>, key: string): string | null {
|
||||
const value = env[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the APNS_* env group. All-or-disabled: any missing critical piece
|
||||
* (APNS_KEY_PATH / APNS_KEY_ID / APNS_TEAM_ID) or a malformed APNS_HOST yields
|
||||
* `{ok:false, reason}` — the caller disables the feature, never crashes.
|
||||
*/
|
||||
export function loadApnsConfig(env: Record<string, string | undefined>): ApnsConfigResult {
|
||||
const missing = ['APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID'].filter(
|
||||
(key) => envOrNull(env, key) === null,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
return { ok: false, reason: `${missing.join('/')} not set` };
|
||||
}
|
||||
const host = envOrNull(env, 'APNS_HOST') ?? DEFAULT_APNS_HOST;
|
||||
try {
|
||||
const parsed = new URL(host);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return { ok: false, reason: 'APNS_HOST must be an http(s) URL' };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, reason: 'APNS_HOST is not a valid URL' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: {
|
||||
keyPath: envOrNull(env, 'APNS_KEY_PATH') as string,
|
||||
keyId: envOrNull(env, 'APNS_KEY_ID') as string,
|
||||
teamId: envOrNull(env, 'APNS_TEAM_ID') as string,
|
||||
bundleId: envOrNull(env, 'APNS_BUNDLE_ID') ?? DEFAULT_BUNDLE_ID,
|
||||
storePath: envOrNull(env, 'APNS_STORE_PATH') ?? path.join(homedir(), DEFAULT_STORE_FILENAME),
|
||||
host,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and sanity-check the .p8 signing key. Returns null (logged, no key
|
||||
* material) on any failure: missing file, unparsable PEM, non-EC key.
|
||||
*/
|
||||
export function loadApnsKey(keyPath: string): KeyObject | null {
|
||||
let pem: string;
|
||||
try {
|
||||
pem = readFileSync(keyPath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`apns: cannot read key file at ${keyPath} (${(err as NodeJS.ErrnoException)?.code ?? 'error'})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const key = createPrivateKey(pem);
|
||||
if (key.asymmetricKeyType !== 'ec') {
|
||||
console.error('apns: key is not an EC key (APNs requires ES256 / P-256)');
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
} catch {
|
||||
// Never echo parse errors — they can quote fragments of the key material.
|
||||
console.error(`apns: key file at ${keyPath} is not a valid PKCS#8 EC key`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── token validation + store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Boundary validation for the frozen wire shape: a device token is a string of
|
||||
* 64–160 hex chars; case-insensitive input is normalized to lowercase.
|
||||
* Anything else → null (the route answers 400).
|
||||
*/
|
||||
export function normalizeApnsToken(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const token = raw.toLowerCase();
|
||||
return APNS_TOKEN_PATTERN.test(token) ? token : null;
|
||||
}
|
||||
|
||||
/** Type guard: a structurally valid, persistable token record. */
|
||||
function isValidApnsTokenRecord(x: unknown): x is ApnsTokenRecord {
|
||||
if (x === null || typeof x !== 'object') return false;
|
||||
const r = x as Record<string, unknown>;
|
||||
if (typeof r['token'] !== 'string' || !APNS_TOKEN_PATTERN.test(r['token'])) return false;
|
||||
if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Best-effort load (mirrors subscription-store): missing/malformed → empty. */
|
||||
function loadTokenRecords(filePath: string): ApnsTokenRecord[] {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.error('apns: token store load failed, starting empty', err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isValidApnsTokenRecord);
|
||||
} catch {
|
||||
console.error('apns: token store is malformed JSON, starting empty');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (or initialise empty) the device-token store backed by `filePath`.
|
||||
* Same conventions as the web-push subscription-store: immutable replacement
|
||||
* on every mutation, FIFO cap, 0600 persistence, tolerant load.
|
||||
* @param filePath absolute path to the JSON store (APNS_STORE_PATH)
|
||||
* @param maxTokens FIFO cap (mirrors cfg.pushMaxSubs)
|
||||
*/
|
||||
export function loadApnsTokenStore(filePath: string, maxTokens: number): ApnsTokenStore {
|
||||
let records: ApnsTokenRecord[] = loadTokenRecords(filePath);
|
||||
|
||||
return {
|
||||
list(): readonly ApnsTokenRecord[] {
|
||||
return Object.freeze(records.slice());
|
||||
},
|
||||
|
||||
add(record: ApnsTokenRecord): void {
|
||||
if (!isValidApnsTokenRecord(record)) {
|
||||
throw new TypeError('apns: invalid token record');
|
||||
}
|
||||
const deduped = records.filter((r) => r.token !== record.token);
|
||||
const next = [...deduped, record];
|
||||
records = next.length > maxTokens ? next.slice(next.length - maxTokens) : next;
|
||||
},
|
||||
|
||||
remove(token: string): void {
|
||||
records = records.filter((r) => r.token !== token);
|
||||
},
|
||||
|
||||
prune(deadTokens: readonly string[]): void {
|
||||
if (deadTokens.length === 0) return;
|
||||
const dead = new Set(deadTokens);
|
||||
records = records.filter((r) => !dead.has(r.token));
|
||||
},
|
||||
|
||||
async persist(): Promise<void> {
|
||||
try {
|
||||
await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE });
|
||||
// writeFile's mode only applies on creation; enforce 0600 if pre-existing.
|
||||
await chmod(filePath, FILE_MODE);
|
||||
} catch (err) {
|
||||
console.error('apns: token store persist failed', err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── ES256 provider JWT ────────────────────────────────────────────────────────
|
||||
|
||||
function base64urlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the APNs provider JWT: ES256 (ECDSA P-256 / SHA-256) with the raw
|
||||
* r||s signature Apple expects (dsaEncoding 'ieee-p1363', NOT ASN.1/DER).
|
||||
* @param nowMs injectable clock (tests) — iat is floor(nowMs/1000)
|
||||
*/
|
||||
export function buildApnsJwt(key: KeyObject, keyId: string, teamId: string, nowMs: number): string {
|
||||
const header = base64urlJson({ alg: 'ES256', kid: keyId });
|
||||
const claims = base64urlJson({ iss: teamId, iat: Math.floor(nowMs / 1000) });
|
||||
const signingInput = `${header}.${claims}`;
|
||||
const signature = signBytes('sha256', Buffer.from(signingInput), {
|
||||
key,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
return `${signingInput}.${signature.toString('base64url')}`;
|
||||
}
|
||||
|
||||
// ── message construction (payload minimization lives HERE) ───────────────────
|
||||
|
||||
interface ApnsMessage {
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** apns-collapse-id: session UUID sans dashes (32 chars — mirrors web-push topic). */
|
||||
function collapseId(sessionId: string): string {
|
||||
return sessionId.replace(/-/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers + body for one signal. Returns null for classes outside the P1
|
||||
* APNs signal list (needs-input / done). The alert is minimal by design: a
|
||||
* status word + the session id's short prefix — no cwd, no command content, no
|
||||
* full session id in the visible text. The custom payload mirrors the web-push
|
||||
* `PushPayload` minus the cwd-derived `detail`.
|
||||
*/
|
||||
function buildApnsMessage(
|
||||
cfg: Config,
|
||||
apnsCfg: ApnsConfig,
|
||||
session: Session,
|
||||
cls: NotifyClass,
|
||||
nowMs: number,
|
||||
token?: string,
|
||||
): ApnsMessage | null {
|
||||
if (cls !== 'needs-input' && cls !== 'done') return null;
|
||||
const sessionId = session.meta.id;
|
||||
const isGate = cls === 'needs-input';
|
||||
const shortId = sessionId.slice(0, ALERT_ID_PREFIX_LEN);
|
||||
const expirationSeconds = isGate
|
||||
? Math.ceil(cfg.decisionTokenTtlMs / 1000)
|
||||
: DONE_EXPIRATION_SECONDS;
|
||||
|
||||
const aps: Record<string, unknown> = {
|
||||
alert: { title: isGate ? 'Needs input' : 'Done', body: `Session ${shortId}` },
|
||||
...(isGate ? { category: GATE_CATEGORY, sound: 'default' } : {}),
|
||||
};
|
||||
const payload: Record<string, unknown> = {
|
||||
aps,
|
||||
sessionId,
|
||||
cls,
|
||||
...(isGate && token !== undefined ? { token } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'apns-topic': apnsCfg.bundleId,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': isGate ? PRIORITY_IMMEDIATE : PRIORITY_CONSERVE,
|
||||
'apns-collapse-id': collapseId(sessionId),
|
||||
'apns-expiration': String(Math.floor(nowMs / 1000) + expirationSeconds),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
// ── default HTTP/2 client ─────────────────────────────────────────────────────
|
||||
|
||||
/** Extract APNs' error `reason` from a response body, if parseable. */
|
||||
function reasonOf(body: string): string | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
const reason = (parsed as Record<string, unknown>)['reason'];
|
||||
if (typeof reason === 'string') return reason;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body — no reason */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal per-request node:http2 client. One connection per send (KISS): our
|
||||
* volume is a handful of pushes per gate event, so connection reuse machinery
|
||||
* (GOAWAY handling, keep-alive pings) isn't worth its failure modes here.
|
||||
* Every error path settles the promise — never an unhandled 'error' event.
|
||||
*/
|
||||
function createHttp2Client(authority: string): ApnsHttp2Client {
|
||||
return {
|
||||
request(req: ApnsRequest): Promise<ApnsResponse> {
|
||||
return new Promise<ApnsResponse>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
fn();
|
||||
}
|
||||
};
|
||||
let session: ReturnType<typeof connect>;
|
||||
try {
|
||||
session = connect(authority);
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
const fail = (err: Error): void => {
|
||||
session.close();
|
||||
settle(() => reject(err));
|
||||
};
|
||||
session.on('error', fail);
|
||||
session.setTimeout(REQUEST_TIMEOUT_MS, () => fail(new Error('apns: request timed out')));
|
||||
let stream: ReturnType<typeof session.request>;
|
||||
try {
|
||||
stream = session.request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
let status = 0;
|
||||
let body = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('response', (headers) => {
|
||||
status = Number(headers[':status'] ?? 0);
|
||||
});
|
||||
stream.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
stream.on('end', () => {
|
||||
session.close();
|
||||
settle(() => resolve({ status, body }));
|
||||
});
|
||||
stream.on('error', fail);
|
||||
stream.end(req.body);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── sender (NotifyService implementation) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the APNs NotifyService. Same seam as `createPushService`: the server
|
||||
* fans hook events out through `notify(session, cls, token?)`.
|
||||
* @param cfg frozen runtime config (DND/done toggles, decision-token TTL)
|
||||
* @param apnsCfg validated APNS_* env group
|
||||
* @param key the loaded .p8 EC private key (never logged)
|
||||
* @param store device-token store (read for fan-out, pruned on 410/BadDeviceToken)
|
||||
* @param deps injectable HTTP/2 client + clock (tests never touch the network)
|
||||
*/
|
||||
export function createApnsService(
|
||||
cfg: Config,
|
||||
apnsCfg: ApnsConfig,
|
||||
key: KeyObject,
|
||||
store: ApnsTokenStore,
|
||||
deps: ApnsServiceDeps = {},
|
||||
): NotifyService {
|
||||
const client = deps.client ?? createHttp2Client(apnsCfg.host);
|
||||
const now = deps.now ?? Date.now;
|
||||
let cachedJwt: { readonly value: string; readonly issuedAtMs: number } | null = null;
|
||||
|
||||
function currentJwt(nowMs: number): string {
|
||||
if (cachedJwt === null || nowMs - cachedJwt.issuedAtMs > JWT_MAX_AGE_MS) {
|
||||
cachedJwt = { value: buildApnsJwt(key, apnsCfg.keyId, apnsCfg.teamId, nowMs), issuedAtMs: nowMs };
|
||||
}
|
||||
return cachedJwt.value;
|
||||
}
|
||||
|
||||
/** Mirrors web-push shouldSend; additionally scopes APNs to its P1 signals. */
|
||||
function shouldSend(cls: NotifyClass): boolean {
|
||||
if (cls !== 'needs-input' && cls !== 'done') return false;
|
||||
if (cfg.notifyDnd) return false;
|
||||
if (cls === 'done' && !cfg.notifyDone) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendOne(record: ApnsTokenRecord, message: ApnsMessage, dead: string[]): Promise<void> {
|
||||
let res: ApnsResponse;
|
||||
try {
|
||||
res = await client.request({
|
||||
path: `/3/device/${record.token}`,
|
||||
headers: message.headers,
|
||||
body: message.body,
|
||||
});
|
||||
} catch (err) {
|
||||
// Connection-level failure (refused/reset/timeout): keep the token, log
|
||||
// without secrets, never crash the server.
|
||||
console.error(`apns: send failed (${err instanceof Error ? err.message : 'connection error'})`);
|
||||
return;
|
||||
}
|
||||
if (res.status === 200) return;
|
||||
const reason = reasonOf(res.body);
|
||||
if (res.status === 410 || (res.status === 400 && reason === 'BadDeviceToken')) {
|
||||
dead.push(record.token); // device gone / token invalid → evict
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
cachedJwt = null; // Expired/InvalidProviderToken → re-sign on the next send
|
||||
}
|
||||
// 429 and other transient statuses: keep the token, log (no token/key material).
|
||||
console.error(`apns: send failed (status ${res.status}${reason !== undefined ? ` ${reason}` : ''})`);
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled: () => true,
|
||||
|
||||
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
|
||||
if (!shouldSend(cls)) return;
|
||||
const records = store.list();
|
||||
if (records.length === 0) return;
|
||||
|
||||
const nowMs = now();
|
||||
const built = buildApnsMessage(cfg, apnsCfg, session, cls, nowMs, token);
|
||||
if (built === null) return;
|
||||
const message: ApnsMessage = {
|
||||
body: built.body,
|
||||
headers: { ...built.headers, authorization: `bearer ${currentJwt(nowMs)}` },
|
||||
};
|
||||
const dead: string[] = [];
|
||||
|
||||
await Promise.all(records.map((record) => sendOne(record, message, dead)));
|
||||
|
||||
if (dead.length > 0) {
|
||||
store.prune(dead);
|
||||
await store.persist();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── composition + server-facing init ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fan a notify out to several providers (web-push + APNs). One provider
|
||||
* failing must never block or hide the others (best-effort, logged).
|
||||
*/
|
||||
export function combineNotifyServices(...services: readonly NotifyService[]): NotifyService {
|
||||
return {
|
||||
isEnabled: () => services.some((service) => service.isEnabled()),
|
||||
|
||||
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
try {
|
||||
await service.notify(session, cls, token);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`apns: notify fan-out failed (${err instanceof Error ? err.message : 'error'})`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server entry point: read the APNS_* env group and build the runtime, or
|
||||
* return null when the feature is disabled. Exactly one log line either way;
|
||||
* startup NEVER crashes on APNs problems; key material is never logged.
|
||||
* @param maxTokens FIFO cap for the token store — pass cfg.pushMaxSubs
|
||||
*/
|
||||
export function initApns(cfg: Config, env: Record<string, string | undefined>): ApnsRuntime | null {
|
||||
const result = loadApnsConfig(env);
|
||||
if (!result.ok) {
|
||||
console.error(`apns: disabled (${result.reason})`);
|
||||
return null;
|
||||
}
|
||||
const key = loadApnsKey(result.config.keyPath);
|
||||
if (key === null) {
|
||||
// loadApnsKey already logged the specific (key-material-free) cause.
|
||||
return null;
|
||||
}
|
||||
const store = loadApnsTokenStore(result.config.storePath, cfg.pushMaxSubs);
|
||||
const service = createApnsService(cfg, result.config, key, store);
|
||||
console.error(
|
||||
`apns: enabled (topic ${result.config.bundleId}, store ${result.config.storePath})`,
|
||||
);
|
||||
return { service, store };
|
||||
}
|
||||
@@ -46,8 +46,10 @@ import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
import { loadSubscriptionStore } from './push/subscription-store.js'
|
||||
import { loadPrefsStore } from './http/prefs-store.js'
|
||||
import { createPushService } from './push/push-service.js'
|
||||
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
|
||||
import type {
|
||||
Config,
|
||||
NotifyService,
|
||||
PermissionGate,
|
||||
PermissionMode,
|
||||
PushSubscriptionRecord,
|
||||
@@ -188,7 +190,14 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// A1: persistent push-subscription store + VAPID-signed push sender, injected
|
||||
// into the manager (DI, M5) so the manager never imports push-service directly.
|
||||
const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs)
|
||||
const pushService = createPushService(cfg, subStore)
|
||||
const webPushService = createPushService(cfg, subStore)
|
||||
// T-iOS-20: optional APNs sender — enabled only when the APNS_* env group is
|
||||
// complete (else null + one log line, never a crash). It fans out beside
|
||||
// web-push through the SAME NotifyService seam, so every existing hook-event
|
||||
// call site below stays untouched: `pushService` is the combined fan-out.
|
||||
const apns = initApns(cfg, process.env as Record<string, string | undefined>)
|
||||
const pushService: NotifyService =
|
||||
apns === null ? webPushService : combineNotifyServices(webPushService, apns.service)
|
||||
const manager = createSessionManager(cfg, pushService)
|
||||
|
||||
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
|
||||
@@ -419,7 +428,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
|
||||
// approval); otherwise fall through to Claude's own prompt.
|
||||
const hasWatcher = session.clients.size > 0
|
||||
const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0
|
||||
// A web-push subscription OR a registered APNs device token counts as a
|
||||
// reachable "walked-away" device (T-iOS-20).
|
||||
const hasPushTarget =
|
||||
(webPushService.isEnabled() && subStore.list().length > 0) ||
|
||||
(apns !== null && apns.store.list().length > 0)
|
||||
if (!hasWatcher && !hasPushTarget) {
|
||||
res.json({})
|
||||
return
|
||||
@@ -450,7 +463,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
|
||||
// Public VAPID key for SW subscription. 503 when push is disabled (graceful).
|
||||
app.get('/push/vapid-key', (_req, res) => {
|
||||
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) {
|
||||
if (!webPushService.isEnabled() || cfg.vapidPublicKey === undefined) {
|
||||
res.status(503).end()
|
||||
return
|
||||
}
|
||||
@@ -497,6 +510,53 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// ── T-iOS-20: APNs device-token registry (iOS client) ─────────────────────
|
||||
// Mounted only when the APNS_* env group is complete (disabled ⇒ 404).
|
||||
// Frozen wire shape: POST/DELETE /push/apns-token {token: 64–160 hex chars,
|
||||
// lowercase-normalized} → 204 idempotent upsert/remove; 400 invalid; Origin
|
||||
// guard 403; 5/min/IP 429; body ≤ 8kb — mirrors /push/subscribe above.
|
||||
if (apns !== null) {
|
||||
const apnsStore = apns.store
|
||||
const apnsTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
app.post('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const token = normalizeApnsToken(((req.body ?? {}) as Record<string, unknown>)['token'])
|
||||
if (token === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
apnsStore.add({ token, createdAt: Date.now() })
|
||||
await apnsStore.persist()
|
||||
} catch {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
app.delete('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const token = normalizeApnsToken(((req.body ?? {}) as Record<string, unknown>)['token'])
|
||||
if (token === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
apnsStore.remove(token) // idempotent: unknown token still 204
|
||||
await apnsStore.persist()
|
||||
res.status(204).end()
|
||||
})
|
||||
}
|
||||
|
||||
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
|
||||
// loopback) → guard with Origin + a per-decision capability token (stale/
|
||||
// mismatch → 403).
|
||||
|
||||
@@ -62,6 +62,8 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
|
||||
* (add, delete) rather than in-place modification of the table structure.
|
||||
* Individual Session objects do carry mutable fields (attachedWs, detachedAt,
|
||||
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
|
||||
* lastOutputAt is no longer list()-omitted: since T-iOS-37 it is serialized
|
||||
* into LiveSessionInfo (unread watermark for /live-sessions consumers).
|
||||
*
|
||||
* `notifyService` is injected (DI, M5) so the manager never imports push-service
|
||||
* directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
|
||||
@@ -190,6 +192,7 @@ export function createSessionManager(
|
||||
cols: s.pty.cols,
|
||||
rows: s.pty.rows,
|
||||
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
|
||||
lastOutputAt: s.lastOutputAt, // T-iOS-37: unread watermark (M3 record)
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,12 @@ export interface LiveSessionInfo {
|
||||
cols: number; // current PTY size (for the manage page)
|
||||
rows: number;
|
||||
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
|
||||
/** T-iOS-37: last pty.onData timestamp (epoch ms; = createdAt until first
|
||||
* output — createSession inits it to spawn time). Serialized from the M3
|
||||
* runtime record so clients can derive an unread watermark
|
||||
* (lastOutputAt > local last-seen). Additive OPTIONAL: older consumers
|
||||
* that build or read LiveSessionInfo without it stay valid. */
|
||||
readonly lastOutputAt?: number;
|
||||
}
|
||||
|
||||
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
||||
|
||||
Reference in New Issue
Block a user