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

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).