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:
Yaojia Wang
2026-07-05 13:34:01 +02:00
parent aa956fcbb4
commit 4871e8ac3d
20 changed files with 3319 additions and 48 deletions

576
src/push/apns.ts Normal file
View 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
* 2060 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 2060 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 64160 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
* 64160 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 };
}

View File

@@ -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: 64160 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).

View File

@@ -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);
}

View File

@@ -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) ──────────────── */