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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user