From 4871e8ac3d83dd8a2289f214349cacded36f76e7 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 5 Jul 2026 13:34:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(ios):=20P1-A=20=E2=80=94=20server=20touch-?= =?UTF-8?q?points=20(lastOutputAt,=20APNs=20sender+token=20endpoint)=20+?= =?UTF-8?q?=20APIClient=20P1=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/PROGRESS_LOG.md | 8 + .../Sources/APIClient/APIClient.swift | 10 +- .../Sources/APIClient/ApnsToken.swift | 99 ++ .../Sources/APIClient/Endpoints.swift | 46 +- .../APIClient/HostClassification.swift | 87 ++ .../APIClient/Sources/APIClient/Models.swift | 31 +- .../Sources/APIClient/PairingError.swift | 46 +- .../APIClient/Sources/APIClient/Prefs.swift | 204 ++++ .../Sources/APIClient/Projects.swift | 297 +++++ .../Tests/APIClientTests/ApnsTokenTests.swift | 175 +++ .../HostClassificationTests.swift | 83 ++ .../APIClientTests/PrefsRoundTripTests.swift | 188 +++ .../Tests/APIClientTests/ProjectsTests.swift | 289 +++++ ios/project.yml | 6 + src/push/apns.ts | 576 +++++++++ src/server.ts | 66 +- src/session/manager.ts | 3 + src/types.ts | 6 + test/manager.test.ts | 63 + test/push-apns.test.ts | 1084 +++++++++++++++++ 20 files changed, 3319 insertions(+), 48 deletions(-) create mode 100644 ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/HostClassification.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/Prefs.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/Projects.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/HostClassificationTests.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/PrefsRoundTripTests.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift create mode 100644 src/push/apns.ts create mode 100644 test/push-apns.test.ts diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 71e42f2..559fdd8 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,14 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🚧 iOS 客户端 P1 实施(进行中 — 2026-07-05,分支 `feat/ios-client`) +- **[x] P1-A(W6+W7 首发: T-iOS-37 ∥ T-iOS-20 ∥ T-iOS-38,3 builders ∥ + verify 6/6 PASS;`/push/apns-token` 线形状由 orchestrator 预冻结,两侧并行编码零失配——verify 逐项对照 method/path/body key/hex 规则/状态码/守卫/限额全 match)**: + - **T-iOS-37 server lastOutputAt**: `LiveSessionInfo.lastOutputAt?: number` 可选增量 + `manager.list()` 一行映射 + 4 测;web 端 grep 零消费者,tsc+全量双证兼容。 + - **T-iOS-20 server APNs**: `src/push/apns.ts`(576 行)——env 三件套 all-or-disabled(缺失→路由不挂载,启动不 crash,密钥材料零日志)、token store 照抄 subscription-store 约定(幂等/上限/0600)、**手写 ES256 JWT(node:crypto ieee-p1363,零新依赖)**、NEEDS-INPUT(priority 10+WEBTERM_GATE+capability token)/DONE(priority 5)、payload 最小化结构性保证(构造函数只读 meta.id,cwd/命令内容不可能出现)、410/BadDeviceToken 逐出、`combineNotifyServices` 与 web-push 并联互不阻塞;`hasPushTarget` 扩展(仅 APNs token 也能 hold gate)。**65 测**含双 e2e(本地 h2c 假 APNs:held gate→双通道同发同 token→/hook/decision 放行)。新增 `APNS_HOST` env(sandbox 切换+测试零网络)。**npm test 53 files/1470 全绿,tsc 干净**。 + - **T-iOS-38 APIClient P1 契约**: APNs token builders(hex 校验镜像服务器、联网前拒绝、显式 ASCII 字符集防全角)、projects/detail(**证实线上无 namespace 字段**——纯 web 端分组概念;lossy 解码;percent-encode 单点+裸 + 规避)、prefs(**未知顶层键往返逐字节存活**——防 iOS 覆写 web 偏好,关键陷阱已测;JSONValue 独立 integer case 防 42→42.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。 + - 遗留(LOW/cosmetic): Swift doc comment 里 server 行号引用漂移;PairingVM 分类去重待后续 pass。 +- 待续: P1-B UI 九连链(22→24→25→27→26→23→29→28→21,串行,共享 App target)→ P1-C(T-iOS-30 验收)。**协调点**: `webterminal://` scheme 的 CFBundleURLTypes 由 orchestrator 预注册进 project.yml;T-iOS-23 获窄授权在 APIClient LiveSessionInfo 补 lastOutputAt 可选解码(38 已完工,串行无竞态)。 + ### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并) 按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。 - **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**: diff --git a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift index c989709..0005cf2 100644 --- a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift +++ b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift @@ -105,9 +105,9 @@ public struct APIClient: Sendable { } } - // MARK: - Internals + // MARK: - Internals (shared with the P1 feature files, T-iOS-38) - private func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) { + func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) { guard let request = route.urlRequest(for: endpoint) else { throw APIClientError.invalidRequest } @@ -115,7 +115,7 @@ public struct APIClient: Sendable { } /// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`. - private static func requireOK(_ response: HTTPURLResponse) throws { + static func requireOK(_ response: HTTPURLResponse) throws { switch response.statusCode { case HTTPStatus.ok: return @@ -128,10 +128,12 @@ public struct APIClient: Sendable { } /// Named HTTP status codes used by the client (no magic numbers, plan §4). -private enum HTTPStatus { +enum HTTPStatus { static let ok = 200 static let noContent = 204 + static let badRequest = 400 static let forbidden = 403 static let notFound = 404 static let tooManyRequests = 429 + static let internalServerError = 500 } diff --git a/ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift b/ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift new file mode 100644 index 0000000..7a0971d --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift @@ -0,0 +1,99 @@ +import Foundation +import WireProtocol + +/// APNs device-token registration (T-iOS-38 · frozen wire shape, orchestrator +/// ruling — mirrors the `POST /push/subscribe` conventions, src/server.ts:461-480): +/// +/// - `POST /push/apns-token` body `{"token":"<64-160 hex chars, lowercase>"}` +/// → **204** (idempotent upsert) +/// - `DELETE /push/apns-token` body 同形 → **204** (idempotent; unknown token +/// still 204) +/// - Both are **G endpoints**: `Origin` guard (`requireAllowedOrigin`) → 403. +/// - Server limits: 400 invalid token/shape · **429 rate limit 5/min/IP** · +/// `express.json` body limit **8 KB** (SUBSCRIBE_RATE_MAX, src/server.ts:73). +/// +/// The hex rule is enforced CLIENT-SIDE too (validate at the boundary, plan §4): +/// uppercase input is lowercase-normalized, anything not 64–160 hex chars is +/// rejected with `.invalidApnsToken` BEFORE any network I/O. +enum ApnsTokenRule { + /// APNs device tokens are ≥ 32 bytes (64 hex chars) today; the frozen + /// shape allows up to 160 hex chars of forward headroom. + static let minHexLength = 64 + static let maxHexLength = 160 + /// ASCII-only hex alphabet — deliberately NOT `Character.isHexDigit` + /// (which also accepts fullwidth digit forms the server regex rejects). + private static let hexDigits = Set("0123456789abcdef") + + /// Lowercase-normalize and validate; nil = not a valid wire token. + static func normalize(_ raw: String) -> String? { + let lowered = raw.lowercased() + guard (minHexLength...maxHexLength).contains(lowered.count), + lowered.allSatisfy({ hexDigits.contains($0) }) + else { return nil } + return lowered + } +} + +extension Endpoints { + static let apnsTokenPath = "/push/apns-token" + + /// `POST /push/apns-token` — G. `normalized` MUST come from + /// `ApnsTokenRule.normalize` (single validation point). + static func registerApnsToken(normalized: String) throws -> APIRoute { + APIRoute( + method: .post, path: apnsTokenPath, originPolicy: .guarded, + body: try JSONEncoder().encode(ApnsTokenBody(token: normalized)) + ) + } + + /// `DELETE /push/apns-token` — G, idempotent (unknown token still 204). + static func unregisterApnsToken(normalized: String) throws -> APIRoute { + APIRoute( + method: .delete, path: apnsTokenPath, originPolicy: .guarded, + body: try JSONEncoder().encode(ApnsTokenBody(token: normalized)) + ) + } + + private struct ApnsTokenBody: Encodable { + let token: String + } +} + +extension APIClient { + /// Register this device's APNs token (idempotent upsert → 204). + /// G endpoint — Origin byte-equal to `endpoint.originHeader`. + /// Server limits (documented, enforced server-side): body ≤ 8 KB, + /// ≤ 5 requests/min/IP → `.rateLimited`. Invalid hex is rejected + /// client-side (`.invalidApnsToken`) before any network I/O. + public func registerApnsToken(_ token: String) async throws { + try await sendApnsToken(token, build: Endpoints.registerApnsToken(normalized:)) + } + + /// Unregister a previously registered token (idempotent → 204 even for an + /// unknown token). Same G guard and limits as `registerApnsToken`. + public func unregisterApnsToken(_ token: String) async throws { + try await sendApnsToken(token, build: Endpoints.unregisterApnsToken(normalized:)) + } + + private func sendApnsToken( + _ token: String, + build: (String) throws -> APIRoute + ) async throws { + guard let normalized = ApnsTokenRule.normalize(token) else { + throw APIClientError.invalidApnsToken + } + let (_, response) = try await perform(try build(normalized)) + switch response.statusCode { + case HTTPStatus.noContent: + return + case HTTPStatus.badRequest: + throw APIClientError.invalidApnsToken + case HTTPStatus.forbidden: + throw APIClientError.forbidden + case HTTPStatus.tooManyRequests: + throw APIClientError.rateLimited + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift index f4f93a7..0a14f9a 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift @@ -1,10 +1,11 @@ import Foundation import WireProtocol -/// HTTP method whitelist for the six frozen endpoints (plan §3.4). +/// HTTP method whitelist for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). enum HTTPMethod: String, Sendable { case get = "GET" case post = "POST" + case put = "PUT" case delete = "DELETE" } @@ -36,10 +37,29 @@ struct APIRoute: Sendable, Equatable { let path: String let originPolicy: OriginPolicy let body: Data? + /// Pre-encoded query string (`key=`), or + /// nil for no query. Percent-encoding happens ONCE, in the route builder + /// (T-iOS-38: `/projects/detail?path=`) — never at call sites. + let percentEncodedQuery: String? + + init( + method: HTTPMethod, + path: String, + originPolicy: OriginPolicy, + body: Data?, + percentEncodedQuery: String? = nil + ) { + self.method = method + self.path = path + self.originPolicy = originPolicy + self.body = body + self.percentEncodedQuery = percentEncodedQuery + } /// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port: - /// the path is REPLACED and query/fragment/credentials are dropped — the - /// same derivation philosophy as `HostEndpoint.wsURL`. Origin stamping + /// the path is REPLACED, the query is REPLACED by `percentEncodedQuery` + /// (dropped when nil), fragment/credentials are dropped — the same + /// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping /// happens HERE and only here (single point; hand-stamping elsewhere is a /// review CRITICAL, plan §5.1). func urlRequest(for endpoint: HostEndpoint) -> URLRequest? { @@ -51,6 +71,9 @@ struct APIRoute: Sendable, Equatable { components.fragment = nil components.user = nil components.password = nil + if let percentEncodedQuery { + components.percentEncodedQuery = percentEncodedQuery + } guard let url = components.url else { return nil } var request = URLRequest(url: url) @@ -66,12 +89,25 @@ struct APIRoute: Sendable, Equatable { } } -/// Builders for the six frozen endpoints (plan §3.4). Route table -/// (verified against src/server.ts): +/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route +/// table (verified against src/server.ts): /// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314) /// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609) +/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293) +/// · `GET /prefs` (:273) /// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503) +/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20 +/// shape, mirrors `/push/subscribe` :461-498) +/// P1 builders live beside their feature models: `ApnsToken.swift`, +/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner). enum Endpoints { + /// Strict RFC 3986 unreserved set — everything else gets percent-encoded. + /// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is + /// decoded as a SPACE by Express's qs parser, and `&`/`=` would split the + /// parameter — strict encoding removes the whole ambiguity class. + static let unreservedCharacters = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) static func liveSessions() -> APIRoute { APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil) } diff --git a/ios/Packages/APIClient/Sources/APIClient/HostClassification.swift b/ios/Packages/APIClient/Sources/APIClient/HostClassification.swift new file mode 100644 index 0000000..326b8c4 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/HostClassification.swift @@ -0,0 +1,87 @@ +import Foundation +import WireProtocol + +/// The four network tiers of plan §5.4's pairing/warning table — PUBLIC so UI +/// layers (PairingViewModel's scheme+address 分层提示, T-iOS-12) consume ONE +/// classification instead of re-deriving it (the W3 dedup this type exists for). +/// +/// | tier | 匹配 | §5.4 提示 | +/// |---|---|---| +/// | `loopback` | localhost · 127.0.0.0/8 · ::1 | 无警告 | +/// | `privateLAN` | RFC1918 (10/8, 172.16/12, 192.168/16) · link-local 169.254/16 · mDNS `.local` | ws:// 非阻断明文提示 | +/// | `tailscale` | CGNAT 100.64.0.0/10 · MagicDNS `*.ts.net` | 无明文警告(WireGuard 已加密) | +/// | `public` | 其余一切(含越界/畸形地址——fail-safe:分不清就当公网,宁可多警告) | 最强阻断式警告 | +public enum HostNetworkTier: Sendable, Equatable, CaseIterable { + case loopback + case privateLAN + case tailscale + case `public` +} + +/// Pure, stateless classifier over a URL host string (plan §5.4 tiers). +/// IPv6 note: only loopback `::1` is tiered; other IPv6 literals (incl. ULA +/// fd00::/8) fall to `.public` — fail-safe over-warning, matching v1 scope +/// (the app dials IPv4 LAN / CGNAT / MagicDNS targets). +public enum HostClassifier { + private static let magicDNSSuffix = ".ts.net" + private static let mdnsSuffix = ".local" + private static let loopbackNames: Set = ["localhost", "::1", "[::1]"] + + /// Classify a bare host string (as returned by `URL.host` — no scheme, + /// no port). Unknown/malformed input is `.public` by design (fail-safe). + public static func classify(host: String) -> HostNetworkTier { + let lowered = host.lowercased() + if loopbackNames.contains(lowered) { + return .loopback + } + if lowered.hasSuffix(magicDNSSuffix) { + return .tailscale + } + if lowered.hasSuffix(mdnsSuffix) { + return .privateLAN + } + guard let octets = ipv4Octets(lowered) else { + return .public + } + return classifyIPv4(octets) + } + + /// Convenience for callers holding a `HostEndpoint` (the VM's shape). + public static func classify(endpoint: HostEndpoint) -> HostNetworkTier { + classify(host: endpoint.baseURL.host ?? "") + } + + // MARK: - IPv4 (shared with PairingError's Local-Network diagnosis) + + private static func classifyIPv4(_ octets: [Int]) -> HostNetworkTier { + switch (octets[0], octets[1]) { + case (127, _): // loopback 127/8 + return .loopback + case (10, _), (192, 168): // RFC1918 10/8 · 192.168/16 + return .privateLAN + case (172, 16...31): // RFC1918 172.16/12 + return .privateLAN + case (169, 254): // link-local + return .privateLAN + case (100, 64...127): // CGNAT 100.64/10 (Tailscale) + return .tailscale + default: + return .public + } + } + + private static let ipv4OctetCount = 4 + private static let ipv4OctetRange = 0...255 + + /// Strict dotted-quad parse; anything else (hostnames, IPv6, out-of-range + /// octets, wrong arity) → nil. + static func ipv4Octets(_ host: String) -> [Int]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == ipv4OctetCount else { return nil } + let octets = parts.compactMap { Int($0) } + guard octets.count == ipv4OctetCount, + octets.allSatisfy({ ipv4OctetRange.contains($0) }) + else { return nil } + return octets + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Models.swift b/ios/Packages/APIClient/Sources/APIClient/Models.swift index b9312bd..f4f8553 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Models.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Models.swift @@ -159,9 +159,26 @@ public enum APIClientError: Error, Equatable, Sendable { /// token is missing / mismatched / STALE — tokens are single-use and /// expiring by design (SEC-C1/M1). User-facing 话术 in `message`. case decisionRejected - /// 429: `POST /hook/decision` is limited to 10 requests/min/IP - /// (src/server.ts:72,504-508 — fixed server security policy). + /// 429: the endpoint is rate-limited per IP by fixed server policy — + /// `POST /hook/decision` ≤ 10/min (src/server.ts:72,504-508); + /// `POST|DELETE /push/apns-token` ≤ 5/min (mirrors `/push/subscribe`, + /// src/server.ts:73,461-466). Per-endpoint limits live in each call's + /// doc comment; the user copy stays limit-agnostic. case rateLimited + /// The APNs device token is not 64–160 hex chars (frozen T-iOS-20 wire + /// rule) — rejected client-side BEFORE any network I/O, or echoed by the + /// server as a 400. + case invalidApnsToken + /// 400 from `GET /projects/detail` — the `path` query parameter is + /// missing/empty (src/server.ts:295-298). Also raised client-side for an + /// empty path, before any network I/O. + case projectPathInvalid + /// 404 from `GET /projects/detail` — no project at that path + /// (src/server.ts:301-304; moved/deleted/not a directory). + case projectNotFound + /// 500 from `GET /projects/detail` — the server failed reading the repo + /// (src/server.ts:306-309, body `{error}`). + case projectDetailUnavailable /// Any other non-success status code. case unexpectedStatus(Int) @@ -179,7 +196,15 @@ public enum APIClientError: Error, Equatable, Sendable { case .decisionRejected: "审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。" case .rateLimited: - "操作过于频繁(服务器限 10 次/分钟),请稍后再试。" + "操作过于频繁,服务器已限流,请稍后再试。" + case .invalidApnsToken: + "推送注册令牌格式异常,请重启 App 重新注册推送。" + case .projectPathInvalid: + "项目路径为空或不合法。" + case .projectNotFound: + "项目不存在(路径可能已移动或删除)。" + case .projectDetailUnavailable: + "读取项目详情失败,请稍后再试。" case .unexpectedStatus(let status): "服务器返回了意外状态码 \(status)。" } diff --git a/ios/Packages/APIClient/Sources/APIClient/PairingError.swift b/ios/Packages/APIClient/Sources/APIClient/PairingError.swift index 1b5035f..6411bc8 100644 --- a/ios/Packages/APIClient/Sources/APIClient/PairingError.swift +++ b/ios/Packages/APIClient/Sources/APIClient/PairingError.swift @@ -146,42 +146,22 @@ extension PairingError { (error as NSError).localizedDescription } - /// LAN-class targets for the `localNetworkDenied` mapping: RFC1918, - /// link-local 169.254/16, loopback, CGNAT 100.64/10 (Tailscale), and - /// mDNS `.local` names. (T-iOS-12's warning TIERS are separate logic — - /// this predicate only gates the Local-Network-permission diagnosis.) + /// LAN-class targets for the `localNetworkDenied` mapping — a VIEW over + /// `HostClassifier` (T-iOS-38 dedup; plan §5.4 tiers are the single + /// classification source): + /// - `.loopback` / `.privateLAN` → true (Local-Network-permission gated); + /// - `.tailscale` → true only for CGNAT 100.64/10 IPs dialed on a local + /// interface; MagicDNS `*.ts.net` rides the WireGuard tunnel, so an + /// ENETDOWN there is NOT the Local Network permission → false; + /// - `.public` → false (never misdiagnose 权限被拒 for public targets). static func isPrivateOrLocalHost(_ host: String) -> Bool { - let lowered = host.lowercased() - if lowered == "localhost" || lowered.hasSuffix(".local") { + switch HostClassifier.classify(host: host) { + case .loopback, .privateLAN: return true - } - guard let octets = ipv4Octets(lowered) else { + case .tailscale: + return HostClassifier.ipv4Octets(host.lowercased()) != nil + case .public: return false } - switch (octets[0], octets[1]) { - case (10, _), (127, _): // RFC1918 10/8 · loopback 127/8 - return true - case (192, 168), (169, 254): // RFC1918 192.168/16 · link-local - return true - case (172, 16...31): // RFC1918 172.16/12 - return true - case (100, 64...127): // CGNAT 100.64/10 (Tailscale) - return true - default: - return false - } - } - - private static let ipv4OctetCount = 4 - private static let ipv4OctetRange = 0...255 - - private static func ipv4Octets(_ host: String) -> [Int]? { - let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == ipv4OctetCount else { return nil } - let octets = parts.compactMap { Int($0) } - guard octets.count == ipv4OctetCount, - octets.allSatisfy({ ipv4OctetRange.contains($0) }) - else { return nil } - return octets } } diff --git a/ios/Packages/APIClient/Sources/APIClient/Prefs.swift b/ios/Packages/APIClient/Sources/APIClient/Prefs.swift new file mode 100644 index 0000000..cc4075a --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/Prefs.swift @@ -0,0 +1,204 @@ +import Foundation +import WireProtocol + +// T-iOS-38 · cross-device Projects UI prefs (`GET /prefs` RO · `PUT /prefs` G): +// an OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web +// client loosely (public/prefs.ts sanitizePrefs / src/types.ts:493-496: +// `favourites: string[]`, `collapsed: {group-key: true}`); ALL OTHER top-level +// keys are preserved verbatim across decode → mutate → encode, so an iOS PUT +// can never clobber prefs written by the web client or a future server. +// (PUT replaces the WHOLE blob server-side, src/server.ts:278-288 — key +// preservation is therefore correctness, not politeness.) + +// MARK: - JSONValue (internal round-trip carrier) + +/// Minimal JSON tree — the storage format that lets unknown prefs keys +/// round-trip losslessly. Integers keep a dedicated case so `42` never +/// re-encodes as `42.0`. +enum JSONValue: Sendable, Equatable, Codable { + case null + case bool(Bool) + case integer(Int) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let int = try? container.decode(Int.self) { + self = .integer(int) + } else if let double = try? container.decode(Double.self) { + self = .number(double) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let array = try? container.decode([JSONValue].self) { + self = .array(array) + } else if let object = try? container.decode([String: JSONValue].self) { + self = .object(object) + } else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "not a JSON value" + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .integer(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} + +// MARK: - UiPrefs + +/// The prefs blob. Immutable snapshot: `withFavourites`/`withCollapsed` return +/// NEW copies that replace exactly one known key and carry every other key +/// through untouched (THE round-trip correctness trap — tested). +public struct UiPrefs: Sendable, Equatable { + /// Full decoded top-level object — known keys included, unknown keys opaque. + private let storage: [String: JSONValue] + + private enum Key { + static let favourites = "favourites" + static let collapsed = "collapsed" + } + + init(storage: [String: JSONValue]) { + self.storage = storage + } + + /// Fresh prefs (e.g. first write from a device that never fetched). + public init(favourites: [String] = [], collapsed: [String: Bool] = [:]) { + self.init(storage: [ + Key.favourites: .array(favourites.map(JSONValue.string)), + Key.collapsed: .object(collapsed.mapValues(JSONValue.bool)), + ]) + } + + // MARK: Known keys (sanitized views — mirror public/prefs.ts sanitizePrefs) + + /// Favourited project paths (★): non-empty strings, de-duplicated, + /// original order kept. Wrong-typed entries are dropped, not fatal. + public var favourites: [String] { + guard case .array(let items)? = storage[Key.favourites] else { return [] } + var seen = Set() + var out: [String] = [] + for case .string(let path) in items where !path.isEmpty && seen.insert(path).inserted { + out.append(path) + } + return out + } + + /// Namespace group-key → collapsed. Only literal `true` values count + /// (expanded is the default; both web and server sanitizers agree). + public var collapsed: [String: Bool] { + guard case .object(let entries)? = storage[Key.collapsed] else { return [:] } + var out: [String: Bool] = [:] + for (key, value) in entries where !key.isEmpty { + if case .bool(true) = value { out[key] = true } + } + return out + } + + // MARK: Immutable mutation (unknown keys carried through verbatim) + + /// New snapshot with `favourites` replaced; every other key untouched. + public func withFavourites(_ favourites: [String]) -> UiPrefs { + var next = storage + next[Key.favourites] = .array(favourites.map(JSONValue.string)) + return UiPrefs(storage: next) + } + + /// New snapshot with `collapsed` replaced; every other key untouched. + public func withCollapsed(_ collapsed: [String: Bool]) -> UiPrefs { + var next = storage + next[Key.collapsed] = .object(collapsed.mapValues(JSONValue.bool)) + return UiPrefs(storage: next) + } + + // MARK: Wire round-trip + + /// Decode a `/prefs` body. nil = top level is not a JSON object — callers + /// surface `.invalidResponseBody` LOUDLY instead of degrading to empty + /// prefs (an empty-based PUT would wipe the server blob). + public static func decode(from data: Data) -> UiPrefs? { + guard let storage = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + return nil + } + return UiPrefs(storage: storage) + } + + /// Encode the FULL blob (known + unknown keys) for `PUT /prefs`. + public func encodeBody() throws -> Data { + try JSONEncoder().encode(storage) + } +} + +// MARK: - Routes + client calls + +extension Endpoints { + static let prefsPath = "/prefs" + + /// `GET /prefs` — RO, no Origin (src/server.ts:273-275; no secrets, just + /// paths + booleans). + static func getPrefs() -> APIRoute { + APIRoute(method: .get, path: prefsPath, originPolicy: .readOnly, body: nil) + } + + /// `PUT /prefs` — G (CSRF Origin guard). Server-enforced body limit + /// **64 KB** (`express.json({limit:'64kb'})`, src/server.ts:278); the body + /// is sanitized server-side and echoed back as the 200 response. + static func putPrefs(_ prefs: UiPrefs) throws -> APIRoute { + APIRoute( + method: .put, path: prefsPath, originPolicy: .guarded, + body: try prefs.encodeBody() + ) + } +} + +extension APIClient { + /// `GET /prefs` — the cross-device favourites/collapse blob. RO → no + /// Origin. A non-object body throws `.invalidResponseBody` (never + /// silently degrades — see `UiPrefs.decode`). + public func prefs() async throws -> UiPrefs { + let (data, response) = try await perform(Endpoints.getPrefs()) + guard response.statusCode == HTTPStatus.ok else { + throw APIClientError.unexpectedStatus(response.statusCode) + } + guard let prefs = UiPrefs.decode(from: data) else { + throw APIClientError.invalidResponseBody + } + return prefs + } + + /// `PUT /prefs` — replace the whole blob (body ≤ 64 KB, server rule). + /// G → Origin byte-equal; 403 = Origin guard. Returns the server's + /// sanitized echo — treat IT as the new source of truth, not the input. + @discardableResult + public func putPrefs(_ prefs: UiPrefs) async throws -> UiPrefs { + let (data, response) = try await perform(try Endpoints.putPrefs(prefs)) + switch response.statusCode { + case HTTPStatus.ok: + guard let echoed = UiPrefs.decode(from: data) else { + throw APIClientError.invalidResponseBody + } + return echoed + case HTTPStatus.forbidden: + throw APIClientError.forbidden + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Projects.swift b/ios/Packages/APIClient/Sources/APIClient/Projects.swift new file mode 100644 index 0000000..dd4bb40 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/Projects.swift @@ -0,0 +1,297 @@ +import Foundation +import WireProtocol + +// T-iOS-38 · Projects contract increments (both RO — NO Origin header): +// - `GET /projects` (src/server.ts:262-269) → `[ProjectInfo]`, mirrors +// src/types.ts:273-281 field-for-field. The server replies `[]` even on +// internal failure — a non-array body is therefore "not web-terminal". +// - `GET /projects/detail?path=` (src/server.ts:293-310) → `ProjectDetail` +// (src/types.ts:296-306). `path` percent-encoding happens ONCE, in the +// builder. 400/404/500 `{error}` map to explicit typed errors. +// Tolerant decoding at the untrusted server boundary (plan §4): unknown +// fields ignored, malformed entries dropped one by one, never crash. + +// MARK: - ProjectSessionRef + +/// One running session belonging to a project (src/types.ts:262-269; the +/// server mirrors LiveSessionInfo fields into this ref). +public struct ProjectSessionRef: Sendable, Equatable { + public let id: UUID + /// Derived label (last cwd segment, e.g. repo dir name); optional. + public let title: String? + public let status: ClaudeStatus + public let clientCount: Int + public let createdAt: Int + public let exited: Bool + + public init( + id: UUID, title: String?, status: ClaudeStatus, + clientCount: Int, createdAt: Int, exited: Bool + ) { + self.id = id + self.title = title + self.status = status + self.clientCount = clientCount + self.createdAt = createdAt + self.exited = exited + } +} + +extension ProjectSessionRef: Decodable { + private enum CodingKeys: String, CodingKey { + case id, title, status, clientCount, createdAt, exited + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + clientCount = try container.decode(Int.self, forKey: .clientCount) + createdAt = try container.decode(Int.self, forKey: .createdAt) + exited = try container.decode(Bool.self, forKey: .exited) + // Same tolerance as LiveSessionInfo: a future status value must not + // drop the ref; a wrong-typed title degrades to nil. + let rawStatus = try? container.decode(String.self, forKey: .status) + status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown + title = try? container.decode(String.self, forKey: .title) + } +} + +// MARK: - ProjectInfo + +/// A discovered project (git repo or recently-used cwd) — src/types.ts:273-281. +/// Note: there is NO `namespace` field on the wire; namespace grouping is a +/// web-client concept that only surfaces as `UiPrefs.collapsed` group-keys. +public struct ProjectInfo: Sendable, Equatable { + public let name: String + public let path: String + public let isGit: Bool + public let branch: String? + /// Uncommitted changes; only present when the server runs the dirty check. + public let dirty: Bool? + /// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. + public let lastActiveMs: Int? + public let sessions: [ProjectSessionRef] + + public init( + name: String, path: String, isGit: Bool, branch: String?, + dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef] + ) { + self.name = name + self.path = path + self.isGit = isGit + self.branch = branch + self.dirty = dirty + self.lastActiveMs = lastActiveMs + self.sessions = sessions + } +} + +extension ProjectInfo: Decodable { + private enum CodingKeys: String, CodingKey { + case name, path, isGit, branch, dirty, lastActiveMs, sessions + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + path = try container.decode(String.self, forKey: .path) + isGit = try container.decode(Bool.self, forKey: .isGit) + branch = try? container.decode(String.self, forKey: .branch) + dirty = try? container.decode(Bool.self, forKey: .dirty) + lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs) + sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions) + } + + /// Decode a `/projects` response body — non-array top level → + /// `invalidResponseBody`; malformed elements dropped one by one (same + /// pattern as `LiveSessionInfo.decodeList`). + static func decodeList(from data: Data) throws -> [ProjectInfo] { + guard let entries = try? JSONDecoder().decode([LossyBox].self, from: data) else { + throw APIClientError.invalidResponseBody + } + return entries.compactMap(\.value) + } +} + +// MARK: - WorktreeInfo / ProjectDetail + +/// One `git worktree list --porcelain` entry (src/types.ts:284-292). +public struct WorktreeInfo: Sendable, Equatable { + public let path: String + /// Branch name; nil on detached HEAD. + public let branch: String? + public let head: String? + public let isMain: Bool + public let isCurrent: Bool + public let locked: Bool? + public let prunable: Bool? + + public init( + path: String, branch: String?, head: String?, + isMain: Bool, isCurrent: Bool, locked: Bool?, prunable: Bool? + ) { + self.path = path + self.branch = branch + self.head = head + self.isMain = isMain + self.isCurrent = isCurrent + self.locked = locked + self.prunable = prunable + } +} + +extension WorktreeInfo: Decodable { + private enum CodingKeys: String, CodingKey { + case path, branch, head, isMain, isCurrent, locked, prunable + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decode(String.self, forKey: .path) + branch = try? container.decode(String.self, forKey: .branch) + head = try? container.decode(String.self, forKey: .head) + isMain = (try? container.decode(Bool.self, forKey: .isMain)) ?? false + isCurrent = (try? container.decode(Bool.self, forKey: .isCurrent)) ?? false + locked = try? container.decode(Bool.self, forKey: .locked) + prunable = try? container.decode(Bool.self, forKey: .prunable) + } +} + +/// Detailed view of one project (src/types.ts:296-306). +public struct ProjectDetail: Sendable, Equatable { + public let name: String + public let path: String + public let isGit: Bool + public let branch: String? + public let dirty: Bool? + public let worktrees: [WorktreeInfo] + public let sessions: [ProjectSessionRef] + public let hasClaudeMd: Bool + /// CLAUDE.md content (server-truncated for display) when present. + public let claudeMd: String? + + public init( + name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?, + worktrees: [WorktreeInfo], sessions: [ProjectSessionRef], + hasClaudeMd: Bool, claudeMd: String? + ) { + self.name = name + self.path = path + self.isGit = isGit + self.branch = branch + self.dirty = dirty + self.worktrees = worktrees + self.sessions = sessions + self.hasClaudeMd = hasClaudeMd + self.claudeMd = claudeMd + } +} + +extension ProjectDetail: Decodable { + private enum CodingKeys: String, CodingKey { + case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + path = try container.decode(String.self, forKey: .path) + isGit = try container.decode(Bool.self, forKey: .isGit) + branch = try? container.decode(String.self, forKey: .branch) + dirty = try? container.decode(Bool.self, forKey: .dirty) + hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false + claudeMd = try? container.decode(String.self, forKey: .claudeMd) + worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees) + sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions) + } +} + +// MARK: - Lossy decoding helpers (shared per-element tolerance) + +/// Per-element tolerance shim: a malformed element becomes nil instead of +/// failing the whole array (same pattern as WireProtocol's TimelineEvent). +struct LossyBox: Decodable { + let value: Wrapped? + + init(from decoder: any Decoder) { + value = try? Wrapped(from: decoder) + } +} + +enum LossyList { + /// Decode `[Element]` at `key`, dropping malformed elements; a missing or + /// wrong-typed array degrades to `[]`. + static func decode( + _ type: Element.Type, + in container: KeyedDecodingContainer, + forKey key: Key + ) -> [Element] { + let boxes = (try? container.decode([LossyBox].self, forKey: key)) ?? [] + return boxes.compactMap(\.value) + } +} + +// MARK: - Routes + client calls + +extension Endpoints { + /// `GET /projects` — RO, no Origin (src/server.ts:262-269). + static func projects() -> APIRoute { + APIRoute(method: .get, path: "/projects", originPolicy: .readOnly, body: nil) + } + + /// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310). + /// The ONE place `path` gets percent-encoded (strict unreserved-only set — + /// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough). + /// nil = the path could not be encoded (surfaced as `.invalidRequest`). + static func projectDetail(path: String) -> APIRoute? { + guard let encoded = path.addingPercentEncoding( + withAllowedCharacters: unreservedCharacters + ) else { return nil } + return APIRoute( + method: .get, path: "/projects/detail", originPolicy: .readOnly, + body: nil, percentEncodedQuery: "path=\(encoded)" + ) + } +} + +extension APIClient { + /// `GET /projects` — the host's discovered projects with their running + /// sessions merged in. RO → no Origin. + public func projects() async throws -> [ProjectInfo] { + let (data, response) = try await perform(Endpoints.projects()) + guard response.statusCode == HTTPStatus.ok else { + throw APIClientError.unexpectedStatus(response.statusCode) + } + return try ProjectInfo.decodeList(from: data) + } + + /// `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one + /// project. RO → no Origin. An empty path is rejected client-side + /// (mirror of the server's 400 rule) before any network I/O; + /// 400/404/500 map to `.projectPathInvalid` / `.projectNotFound` / + /// `.projectDetailUnavailable`. + public func projectDetail(path: String) async throws -> ProjectDetail { + guard !path.isEmpty else { + throw APIClientError.projectPathInvalid + } + guard let route = Endpoints.projectDetail(path: path) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + switch response.statusCode { + case HTTPStatus.ok: + guard let detail = try? JSONDecoder().decode(ProjectDetail.self, from: data) else { + throw APIClientError.invalidResponseBody + } + return detail + case HTTPStatus.badRequest: + throw APIClientError.projectPathInvalid + case HTTPStatus.notFound: + throw APIClientError.projectNotFound + case HTTPStatus.internalServerError: + throw APIClientError.projectDetailUnavailable + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift new file mode 100644 index 0000000..5f065f7 --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// T-iOS-38 · APNs device-token 注册端点(orchestrator 冻结 wire shape): +/// - `POST /push/apns-token` body `{"token":"<64-160 位小写 hex>"}` → 204(幂等 upsert) +/// - `DELETE /push/apns-token` body 同形 → 204(幂等;未知 token 仍 204) +/// 两者均为 G 端点(Origin 逐字符等于 `endpoint.originHeader`);服务器约束: +/// 400 非法 token/形状 · 403 Origin 守卫 · 429 限频 5 次/分/IP · body ≤ 8 KB +///(对齐 POST /push/subscribe 约定,src/server.ts:461-480,73)。 +/// hex 校验镜像服务器规则并在**联网前**拒绝(客户端边界验证,plan §4)。 +struct ApnsTokenTests { + private static let base = "http://192.168.1.5:3000" + /// 64 位小写 hex —— 典型 APNs device token(32 字节)。 + private static let validToken = String(repeating: "0123456789abcdef", count: 4) + + private struct Fixture { + let endpoint: HostEndpoint + let http: FakeHTTPTransport + let client: APIClient + let url: URL + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture( + endpoint: endpoint, + http: http, + client: APIClient(endpoint: endpoint, http: http), + url: try #require(URL(string: Self.base + "/push/apns-token")) + ) + } + + // MARK: - 冻结 wire shape(方法/路径/Origin/body) + + @Test("register 为 POST /push/apns-token,G 端点:Origin 逐字符等于 endpoint.originHeader,body 恰为 {token}") + func registerBuildsFrozenWireShape() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204) + + // Act + try await fixture.client.registerApnsToken(Self.validToken) + + // Assert + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.httpMethod == "POST") + #expect(request.url == fixture.url) + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(Set(object.keys) == Set(["token"])) + #expect(object["token"] as? String == Self.validToken) + } + + @Test("unregister 为 DELETE /push/apns-token,同 G 守卫同 body 形状,204 → 成功(幂等)") + func unregisterBuildsFrozenWireShape() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 204) + + // Act + try await fixture.client.unregisterApnsToken(Self.validToken) + + // Assert + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.httpMethod == "DELETE") + #expect(request.url == fixture.url) + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + let body = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(object["token"] as? String == Self.validToken) + } + + // MARK: - 客户端 hex 校验(镜像服务器规则,联网前拒绝) + + @Test("大写 hex 输入 → 联网前归一化为小写(服务器按小写归一存储)") + func uppercaseTokenIsLowercaseNormalizedBeforeNetwork() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204) + + // Act + try await fixture.client.registerApnsToken(Self.validToken.uppercased()) + + // Assert + let request = try #require(await fixture.http.recordedRequests.first) + let body = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(object["token"] as? String == Self.validToken) + } + + @Test("非法 token(过短/过长/非 hex/空/含空格) → invalidApnsToken 且零网络请求") + func invalidTokensAreRejectedBeforeAnyNetworkRequest() async throws { + // Arrange — 63 位(短 1)、161 位(长 1)、非 hex 字符、空串、内嵌空格 + let fixture = try makeFixture() + let invalidTokens = [ + String(repeating: "a", count: 63), + String(repeating: "a", count: 161), + String(repeating: "g", count: 64), + "", + String(repeating: "a", count: 32) + " " + String(repeating: "a", count: 31), + ] + + // Act + Assert — register 与 unregister 两条路径都在联网前拒绝 + for token in invalidTokens { + await #expect(throws: APIClientError.invalidApnsToken) { + try await fixture.client.registerApnsToken(token) + } + await #expect(throws: APIClientError.invalidApnsToken) { + try await fixture.client.unregisterApnsToken(token) + } + } + #expect(await fixture.http.recordedRequests.isEmpty) + } + + @Test("边界长度 64 与 160 位 hex 均接受(服务器规则 64-160)") + func boundaryLengthTokensAreAccepted() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204) + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204) + + // Act + try await fixture.client.registerApnsToken(String(repeating: "f", count: 64)) + try await fixture.client.registerApnsToken(String(repeating: "f", count: 160)) + + // Assert + #expect(await fixture.http.recordedRequests.count == 2) + } + + // MARK: - 状态码映射 + + @Test("400(服务器判 token 非法) → invalidApnsToken;403(Origin 守卫) → forbidden") + func status400And403MapToTypedErrors() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 400) + await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 403) + + // Act + Assert + await #expect(throws: APIClientError.invalidApnsToken) { + try await fixture.client.registerApnsToken(Self.validToken) + } + await #expect(throws: APIClientError.forbidden) { + try await fixture.client.unregisterApnsToken(Self.validToken) + } + } + + @Test("429 → rateLimited(≤5 次/分/IP,对齐 push/subscribe 限频);500 → unexpectedStatus") + func status429And500MapToTypedErrors() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 429) + await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 500) + + // Act + Assert + await #expect(throws: APIClientError.rateLimited) { + try await fixture.client.registerApnsToken(Self.validToken) + } + await #expect(throws: APIClientError.unexpectedStatus(500)) { + try await fixture.client.registerApnsToken(Self.validToken) + } + } + + @Test("invalidApnsToken 有非空 UI 话术(plan §4 错误处理全面显式)") + func invalidApnsTokenHasNonEmptyMessage() { + #expect(!APIClientError.invalidApnsToken.message.isEmpty) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/HostClassificationTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/HostClassificationTests.swift new file mode 100644 index 0000000..449dc3f --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/HostClassificationTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +import WireProtocol +import APIClient + +/// T-iOS-38 · 四层 host 分级(plan §5.4 提示分层表),公开 API: +/// loopback / privateLAN / tailscale / public。W3 时 PairingViewModel 因 +/// `isPrivateOrLocalHost` 是 internal 单桶谓词而自行重写了分类——本类型 +/// 是给 VM 换用的单一出处(本任务不改 VM,见日志条目)。 +/// 注意本文件**不用 @testable**:证明 API 确实 public。 +struct HostClassificationTests { + @Test("loopback 层:localhost/127.0.0.0\u{2044}8/IPv6 ::1(裸与带括号)") + func loopbackTierMatchesLocalTargets() { + // Arrange + let hosts = ["localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]"] + + // Act + Assert + for host in hosts { + #expect(HostClassifier.classify(host: host) == .loopback, "\(host) 应为 loopback") + } + } + + @Test("privateLAN 层:RFC1918 三段 + link-local 169.254\u{2044}16 + mDNS .local") + func privateLANTierMatchesRFC1918AndLinkLocal() { + // Arrange + let hosts = [ + "10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255", + "169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL", + ] + + // Act + Assert + for host in hosts { + #expect(HostClassifier.classify(host: host) == .privateLAN, "\(host) 应为 privateLAN") + } + } + + @Test("tailscale 层:CGNAT 100.64.0.0\u{2044}10 与 MagicDNS *.ts.net") + func tailscaleTierMatchesCGNATAndMagicDNS() { + // Arrange + let hosts = [ + "100.64.0.1", "100.100.1.1", "100.127.255.255", + "mac.tailnet.ts.net", "foo.TS.NET", + ] + + // Act + Assert + for host in hosts { + #expect(HostClassifier.classify(host: host) == .tailscale, "\(host) 应为 tailscale") + } + } + + @Test("public 层:公网 IP/域名/越界 CIDR/畸形 IPv4 一律最强警告层") + func publicTierIsTheFailSafeDefault() { + // Arrange — 越界:172.32 超出 172.16/12;100.128 与 100.63 超出 100.64/10 + let hosts = [ + "8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com", + "172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "", + ] + + // Act + Assert — 分不清 → public(fail-safe:宁可多警告,plan §5.4) + for host in hosts { + #expect(HostClassifier.classify(host: host) == .public, "\(host) 应为 public") + } + } + + @Test("classify(endpoint:) 便捷入口与 host 字符串版一致(VM 只有 HostEndpoint 可用)") + func endpointConvenienceMatchesHostClassification() throws { + // Arrange + let vectors: [(url: String, tier: HostNetworkTier)] = [ + ("http://127.0.0.1:3000", .loopback), + ("http://192.168.1.5:3000", .privateLAN), + ("http://100.100.1.1:3000", .tailscale), + ("https://mac.tailnet.ts.net", .tailscale), + ("https://example.com", .public), + ] + + // Act + Assert + for vector in vectors { + let url = try #require(URL(string: vector.url)) + let endpoint = try #require(HostEndpoint(baseURL: url)) + #expect(HostClassifier.classify(endpoint: endpoint) == vector.tier, "\(vector.url)") + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/PrefsRoundTripTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/PrefsRoundTripTests.swift new file mode 100644 index 0000000..c71dc91 --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/PrefsRoundTripTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// T-iOS-38 · `GET /prefs`(RO,无 Origin) / `PUT /prefs`(G,Origin;body ≤ 64 KB, +/// src/server.ts:278)。prefs 是"不透明但已验证"的 JSON 对象往返:已知键 +/// favourites/collapsed 松散镜像 web 端(public/prefs.ts sanitizePrefs), +/// **未知键在往返中原样保留** —— 关键正确性陷阱:iOS 改一个已知键后 PUT, +/// 绝不能把 web/未来服务器写入的未知键清掉。 +struct PrefsRoundTripTests { + private static let base = "http://192.168.1.5:3000" + + /// 含未知键的服务器 blob:string/number/bool/null/嵌套 array/object 全类型。 + private static let blobWithUnknownKeys = """ + {"favourites":["/Users/dev/a"],"collapsed":{"grp":true},\ + "theme":"dark","pollSeconds":42,"beta":false,"legacy":null,\ + "futureList":[1,"two",{"deep":true}],"futureObj":{"x":{"y":[3]}}} + """ + + private struct Fixture { + let endpoint: HostEndpoint + let http: FakeHTTPTransport + let client: APIClient + let url: URL + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture( + endpoint: endpoint, + http: http, + client: APIClient(endpoint: endpoint, http: http), + url: try #require(URL(string: Self.base + "/prefs")) + ) + } + + private func decodePrefs(_ json: String) throws -> UiPrefs { + try #require(UiPrefs.decode(from: Data(json.utf8))) + } + + /// 重新解析 encode 产物为字典(键序无关的深度比较用)。 + private func reparse(_ data: Data) throws -> NSDictionary { + try #require(try JSONSerialization.jsonObject(with: data) as? NSDictionary) + } + + // MARK: - Origin iff-G + + @Test("Origin iff-G:GET /prefs 无 Origin;PUT /prefs 带 Origin(逐字符相等)+JSON Content-Type") + func prefsOriginAppearsIffPut() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.url, body: Data(Self.blobWithUnknownKeys.utf8)) + await fixture.http.queueSuccess( + method: "PUT", url: fixture.url, body: Data(#"{"favourites":[],"collapsed":{}}"#.utf8) + ) + + // Act + _ = try await fixture.client.prefs() + _ = try await fixture.client.putPrefs(UiPrefs()) + + // Assert + let requests = await fixture.http.recordedRequests + #expect(requests.count == 2) + let get = try #require(requests.first) + #expect(get.httpMethod == "GET") + #expect(get.value(forHTTPHeaderField: "Origin") == nil) + let put = try #require(requests.last) + #expect(put.httpMethod == "PUT") + #expect(put.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + #expect(put.value(forHTTPHeaderField: "Content-Type") == "application/json") + } + + // MARK: - 已知键的松散镜像(web sanitizePrefs 语义) + + @Test("已知键解码:favourites 只留非空字符串并去重;collapsed 只留 true 项") + func knownKeysAreSanitizedLikeWebClient() throws { + // Arrange — 混入非字符串/空串/重复/非 true 值 + let json = """ + {"favourites":["/a",42,"","/a","/b"],\ + "collapsed":{"g1":true,"g2":false,"g3":"yes","":true}} + """ + + // Act + let prefs = try decodePrefs(json) + + // Assert + #expect(prefs.favourites == ["/a", "/b"]) + #expect(prefs.collapsed == ["g1": true]) + } + + @Test("已知键缺失/类型错 → 空缺省;空构造与 memberwise 构造可用") + func missingKnownKeysDegradeToEmpty() throws { + // Act + let empty = try decodePrefs(#"{"favourites":"not-an-array"}"#) + let constructed = UiPrefs(favourites: ["/x"], collapsed: ["g": true]) + + // Assert + #expect(empty.favourites.isEmpty) + #expect(empty.collapsed.isEmpty) + #expect(constructed.favourites == ["/x"]) + #expect(constructed.collapsed == ["g": true]) + } + + // MARK: - 关键正确性陷阱:未知键在往返中存活 + + @Test("关键陷阱:解码含未知键的 blob → withFavourites 改已知键 → 重编码,未知键逐字节存活") + func unknownKeysSurviveFavouritesMutationRoundTrip() throws { + // Arrange + let original = try decodePrefs(Self.blobWithUnknownKeys) + + // Act — 只动 favourites + let mutated = original.withFavourites(["/Users/dev/a", "/Users/dev/b"]) + let reencoded = try reparse(try mutated.encodeBody()) + + // Assert — 已知键更新、另一已知键不动、未知键全类型深度相等 + #expect(mutated.favourites == ["/Users/dev/a", "/Users/dev/b"]) + #expect(reencoded["favourites"] as? [String] == ["/Users/dev/a", "/Users/dev/b"]) + #expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true]) + #expect(reencoded["theme"] as? String == "dark") + #expect(reencoded["pollSeconds"] as? Int == 42) + #expect(reencoded["beta"] as? Bool == false) + #expect(reencoded["legacy"] is NSNull) + #expect(reencoded["futureList"] as? NSArray == [1, "two", ["deep": true]] as NSArray) + #expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary) + // 原值不可变(plan §4 不可变铁律) + #expect(original.favourites == ["/Users/dev/a"]) + } + + @Test("withCollapsed 同样保留未知键与 favourites;往返不放大不丢失") + func unknownKeysSurviveCollapsedMutationRoundTrip() throws { + // Arrange + let original = try decodePrefs(Self.blobWithUnknownKeys) + + // Act + let mutated = original.withCollapsed(["grp": true, "new-group": true]) + let reencoded = try reparse(try mutated.encodeBody()) + + // Assert + #expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true, "new-group": true]) + #expect(reencoded["favourites"] as? [String] == ["/Users/dev/a"]) + #expect(reencoded["theme"] as? String == "dark") + #expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary) + // 未知键总数不变(8 个顶层键) + #expect(reencoded.count == 8) + } + + // MARK: - 客户端调用(echo/错误映射) + + @Test("putPrefs 200 → 返回服务器回显的(已净化)prefs;403(Origin 守卫) → forbidden") + func putPrefsReturnsServerEchoAndMapsForbidden() async throws { + // Arrange — 服务器回显是净化后的 blob(src/server.ts:283 res.json(prefsStore.get())) + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "PUT", url: fixture.url, + body: Data(#"{"favourites":["/kept"],"collapsed":{}}"#.utf8) + ) + await fixture.http.queueSuccess(method: "PUT", url: fixture.url, status: 403) + + // Act + let echoed = try await fixture.client.putPrefs(UiPrefs(favourites: ["/kept", ""])) + + // Assert + #expect(echoed.favourites == ["/kept"]) + await #expect(throws: APIClientError.forbidden) { + _ = try await fixture.client.putPrefs(UiPrefs()) + } + } + + @Test("GET /prefs 顶层非对象(数组/HTML) → invalidResponseBody(绝不静默降级为空——防止后续 PUT 清空服务器 prefs)") + func nonObjectPrefsBodyFailsLoudlyInsteadOfDegradingToEmpty() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: fixture.url, body: Data("[]".utf8)) + await fixture.http.queueSuccess(url: fixture.url, body: Data("".utf8)) + + // Act + Assert + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.prefs() + } + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.prefs() + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift new file mode 100644 index 0000000..09cfabb --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift @@ -0,0 +1,289 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// T-iOS-38 · Projects 契约增量(RO,无 Origin): +/// - `GET /projects`(src/server.ts:262-269)→ `[ProjectInfo]`(src/types.ts:273-281 实际形状: +/// name/path/isGit 必填,branch/dirty/lastActiveMs 可选,sessions 为 ProjectSessionRef[]; +/// 无 namespace 字段 —— namespace 是 web 端分组概念,只出现在 prefs.collapsed 的 key 里); +/// - `GET /projects/detail?path=`(src/server.ts:293-310)→ `ProjectDetail`; +/// path 的 percent-encoding **只在 builder 一处**处理;400/404/500 `{error}` → 显式类型化错误。 +/// 服务器是不可信输入源:未知字段忽略、畸形条目逐条丢弃、绝不 crash。 +struct ProjectsTests { + private static let base = "http://192.168.1.5:3000" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + + private static let fullProjectJSON = """ + {"name":"web-terminal","path":"/Users/dev/web-terminal","isGit":true,\ + "branch":"main","dirty":true,"lastActiveMs":1720000000000,\ + "sessions":[{"id":"\(sessionIdString)","title":"web-terminal","status":"working",\ + "clientCount":2,"createdAt":1719990000000,"exited":false}]} + """ + + private static let minimalProjectJSON = """ + {"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[]} + """ + + private struct Fixture { + let http: FakeHTTPTransport + let client: APIClient + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http)) + } + + private func routeURL(_ pathAndQuery: String) throws -> URL { + try #require(URL(string: Self.base + pathAndQuery)) + } + + private func fetchProjects(_ fixture: Fixture, body: String) async throws -> [ProjectInfo] { + await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data(body.utf8)) + return try await fixture.client.projects() + } + + // MARK: - Origin iff-G(两个新 RO 端点都不带 Origin) + + @Test("Origin iff-G(RO 侧):GET /projects 与 GET /projects/detail 均不带 Origin") + func projectsEndpointsAreReadOnlyWithoutOrigin() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data("[]".utf8)) + await fixture.http.queueSuccess( + url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"), + body: Data(Self.detailJSON.utf8) + ) + + // Act + _ = try await fixture.client.projects() + _ = try await fixture.client.projectDetail(path: "/Users/dev/notes") + + // Assert + let requests = await fixture.http.recordedRequests + #expect(requests.count == 2) + for request in requests { + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Origin") == nil) + } + } + + // MARK: - /projects 解码 + + @Test("ProjectInfo 全字段样本解码(src/types.ts:273-281 实际形状,含 sessions ref)") + func projectInfoDecodesFullSample() async throws { + // Act + let projects = try await fetchProjects(try makeFixture(), body: "[\(Self.fullProjectJSON)]") + + // Assert + let expected = ProjectInfo( + name: "web-terminal", + path: "/Users/dev/web-terminal", + isGit: true, + branch: "main", + dirty: true, + lastActiveMs: 1_720_000_000_000, + sessions: [ProjectSessionRef( + id: try #require(UUID(uuidString: Self.sessionIdString)), + title: "web-terminal", + status: .working, + clientCount: 2, + createdAt: 1_719_990_000_000, + exited: false + )] + ) + #expect(projects == [expected]) + } + + @Test("可选字段缺省(branch/dirty/lastActiveMs)与未知字段(向前兼容)均容忍,不丢条目") + func optionalFieldsDegradeAndUnknownFieldsAreIgnored() async throws { + // Arrange — 未知字段模拟未来服务器增量 + let body = """ + [{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[],\ + "futureField":{"nested":true},"favouriteRank":3}] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + let project = try #require(projects.first) + #expect(project.branch == nil) + #expect(project.dirty == nil) + #expect(project.lastActiveMs == nil) + #expect(project.sessions.isEmpty) + } + + @Test("畸形条目(缺必填/类型错/非对象)逐条丢弃,合法条目保留,不 crash") + func malformedProjectEntriesAreDroppedWhileValidOnesSurvive() async throws { + // Arrange + let body = """ + [\(Self.fullProjectJSON),42,{"name":"x"},\ + {"name":1,"path":"/p","isGit":true,"sessions":[]},\(Self.minimalProjectJSON)] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + #expect(projects.map(\.name) == ["web-terminal", "notes"]) + } + + @Test("session ref:未知 status → .unknown;畸形 ref 丢弃但项目保留;sessions 缺失 → 空数组") + func sessionRefsAreTolerantlyDecoded() async throws { + // Arrange + let body = """ + [{"name":"a","path":"/a","isGit":true,"sessions":[\ + {"id":"\(Self.sessionIdString)","status":"hyperdrive","clientCount":0,\ + "createdAt":1,"exited":false},\ + {"id":"not-a-uuid","status":"idle","clientCount":0,"createdAt":2,"exited":true}]},\ + {"name":"b","path":"/b","isGit":false}] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + #expect(projects.count == 2) + let first = try #require(projects.first) + #expect(first.sessions.count == 1) // 坏 UUID 的 ref 被丢弃 + #expect(first.sessions.first?.status == .unknown) + #expect(first.sessions.first?.title == nil) + #expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → [] + } + + @Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus") + func projectsRejectsNonArrayBodyAndBadStatus() async throws { + // Act + Assert — 非数组 + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await self.fetchProjects(try self.makeFixture(), body: #"{"error":"x"}"#) + } + // Act + Assert — 非 200 + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: try routeURL("/projects"), status: 500) + await #expect(throws: APIClientError.unexpectedStatus(500)) { + _ = try await fixture.client.projects() + } + } + + // MARK: - /projects/detail:percent-encoding 单点处理 + + @Test("detail 的 path percent-encoding 只在 builder 一处:空格/+/&/=/非 ASCII 全部严格编码") + func detailPathIsStrictlyPercentEncodedOnceInBuilder() async throws { + // Arrange — '+' 必须编码为 %2B(Express qs 会把裸 '+' 解成空格);α = %CE%B1 + let fixture = try makeFixture() + let rawPath = "/Users/dev/my proj+α&x=1" + let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1%26x%3D1" + let expectedURL = try routeURL("/projects/detail?path=\(encoded)") + await fixture.http.queueSuccess(url: expectedURL, body: Data(Self.detailJSON.utf8)) + + // Act + _ = try await fixture.client.projectDetail(path: rawPath) + + // Assert — FakeHTTPTransport 按整 URL 精确匹配,能走到这里即编码逐字节正确 + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.url == expectedURL) + } + + // MARK: - /projects/detail 解码与错误映射 + + private static let detailJSON = """ + {"name":"notes","path":"/Users/dev/notes","isGit":true,"branch":"main","dirty":false,\ + "worktrees":[{"path":"/Users/dev/notes","branch":"main","head":"abc1234",\ + "isMain":true,"isCurrent":true},\ + {"path":"/Users/dev/notes-wt","isMain":false,"isCurrent":false,"locked":true}],\ + "sessions":[],"hasClaudeMd":true,"claudeMd":"# Notes"} + """ + + @Test("ProjectDetail 全字段解码(worktrees 含可选字段缺省;src/types.ts:296-306)") + func projectDetailDecodesFullSample() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"), + body: Data(Self.detailJSON.utf8) + ) + + // Act + let detail = try await fixture.client.projectDetail(path: "/Users/dev/notes") + + // Assert + #expect(detail.name == "notes") + #expect(detail.isGit) + #expect(detail.branch == "main") + #expect(detail.dirty == false) + #expect(detail.hasClaudeMd) + #expect(detail.claudeMd == "# Notes") + #expect(detail.worktrees.count == 2) + let second = try #require(detail.worktrees.last) + #expect(second.branch == nil) // detached HEAD → branch 缺省 + #expect(second.locked == true) + #expect(second.prunable == nil) + #expect(!second.isMain) + } + + @Test("detail 容忍:hasClaudeMd 缺失 → false;畸形 worktree 条目丢弃;200 但 body 畸形 → invalidResponseBody") + func projectDetailToleratesDegradedShapes() async throws { + // Arrange — worktrees 里混入缺 path 的畸形条目 + let fixture = try makeFixture() + let degraded = """ + {"name":"a","path":"/a","isGit":false,\ + "worktrees":[{"isMain":true},{"path":"/a","isMain":true,"isCurrent":true}],"sessions":[]} + """ + let url = try routeURL("/projects/detail?path=%2Fa") + await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8)) + await fixture.http.queueSuccess(url: url, body: Data("[1,2]".utf8)) + + // Act + let detail = try await fixture.client.projectDetail(path: "/a") + + // Assert + #expect(detail.hasClaudeMd == false) + #expect(detail.claudeMd == nil) + #expect(detail.worktrees.count == 1) + // Act + Assert — 200 但整体形状不对 + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.projectDetail(path: "/a") + } + } + + @Test("detail 400/404/500 {error} → projectPathInvalid/projectNotFound/projectDetailUnavailable,各有非空话术") + func projectDetailMapsErrorStatusesToTypedErrors() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/detail?path=%2Fgone") + await fixture.http.queueSuccess(url: url, status: 400, body: Data(#"{"error":"path query parameter is required"}"#.utf8)) + await fixture.http.queueSuccess(url: url, status: 404, body: Data(#"{"error":"project not found"}"#.utf8)) + await fixture.http.queueSuccess(url: url, status: 500, body: Data(#"{"error":"failed to read project detail"}"#.utf8)) + + // Act + Assert + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.projectDetail(path: "/gone") + } + await #expect(throws: APIClientError.projectNotFound) { + _ = try await fixture.client.projectDetail(path: "/gone") + } + await #expect(throws: APIClientError.projectDetailUnavailable) { + _ = try await fixture.client.projectDetail(path: "/gone") + } + for error in [APIClientError.projectPathInvalid, .projectNotFound, .projectDetailUnavailable] { + #expect(!error.message.isEmpty) + } + } + + @Test("空 path → projectPathInvalid,联网前拒绝(镜像服务器 400 规则)") + func emptyDetailPathIsRejectedBeforeNetwork() async throws { + // Arrange + let fixture = try makeFixture() + + // Act + Assert + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.projectDetail(path: "") + } + #expect(await fixture.http.recordedRequests.isEmpty) + } +} diff --git a/ios/project.yml b/ios/project.yml index b3c65e6..feaadb4 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -63,6 +63,12 @@ targets: properties: CFBundleDisplayName: WebTerm UILaunchScreen: {} + # Deep-link scheme (P1 T-iOS-22): webterminal://open?host=&join= + # Registered here by the orchestrator (project.yml coordination point); + # all parsing/validation lives in DeepLinkRouter (untrusted input). + CFBundleURLTypes: + - CFBundleURLName: com.yaojia.webterm.deeplink + CFBundleURLSchemes: [webterminal] UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft diff --git a/src/push/apns.ts b/src/push/apns.ts new file mode 100644 index 0000000..deb473d --- /dev/null +++ b/src/push/apns.ts @@ -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; +} + +/** One APNs HTTP/2 exchange (headers exclude pseudo-headers except via path). */ +export interface ApnsRequest { + readonly path: string; + readonly headers: Readonly>; + 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; +} + +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, 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): 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; + 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 { + 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>; + 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 = { + alert: { title: isGate ? 'Needs input' : 'Done', body: `Session ${shortId}` }, + ...(isGate ? { category: GATE_CATEGORY, sound: 'default' } : {}), + }; + const payload: Record = { + 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)['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 { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void): void => { + if (!settled) { + settled = true; + fn(); + } + }; + let session: ReturnType; + 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; + 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 { + 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 { + 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 { + 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): 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 }; +} diff --git a/src/server.ts b/src/server.ts index cfa597e..d93227d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 } { // 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) + 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 } { // ≥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 } { // 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 } { 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)['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)['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). diff --git a/src/session/manager.ts b/src/session/manager.ts index e60ff9a..ab5516c 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -62,6 +62,8 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters[ * (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); } diff --git a/src/types.ts b/src/types.ts index bedbfeb..fd506c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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) ──────────────── */ diff --git a/test/manager.test.ts b/test/manager.test.ts index d5846d6..5e3c9db 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -1049,3 +1049,66 @@ describe('list — telemetry field', () => { expect(entry?.telemetry).toBeNull(); }); }); + +// ── list() carries lastOutputAt (T-iOS-37 unread watermark) ─────────────────── +// The runtime record (Session.lastOutputAt, refreshed on every pty.onData — M3) +// is serialized into LiveSessionInfo so clients can compute an unread dot +// (lastOutputAt > local last-seen). Additive OPTIONAL field: existing consumers +// (preview grid, projects panel) are unaffected. +describe('list — lastOutputAt field (T-iOS-37)', () => { + it('includes lastOutputAt matching the session runtime record after output flows', () => { + const mgr = createSessionManager(CFG); + nextPty = createMockPty(); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + // Output flows → session.ts pty.onData refreshes the runtime record with + // Date.now(); list() must serialize exactly that value. + const before = Date.now(); + (s.pty as MockIPty).emitData('hello from claude'); + + const entry = mgr.list().find((e) => e.id === s.meta.id); + expect(s.lastOutputAt).toBeGreaterThanOrEqual(before); + expect(entry?.lastOutputAt).toBe(s.lastOutputAt); + }); + + it('serializes a pinned runtime value verbatim (epoch ms, no transformation)', () => { + const mgr = createSessionManager(CFG); + nextPty = createMockPty(); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + // Pin the runtime record to a known cursor (same style as the M3 reap tests). + s.lastOutputAt = 123_456; + + const entry = mgr.list().find((e) => e.id === s.meta.id); + expect(entry?.lastOutputAt).toBe(123_456); + }); + + it('equals spawn time for a fresh session before any output (createSession inits lastOutputAt = now)', () => { + const mgr = createSessionManager(CFG); + nextPty = createMockPty(); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + // createSession initializes lastOutputAt: now (src/session/session.ts) — + // a fresh session's watermark IS its spawn time, never absent/zero. + const entry = mgr.list().find((e) => e.id === s.meta.id); + expect(entry?.lastOutputAt).toBe(1_000); + }); + + it('keeps the existing LiveSessionInfo shape intact (additive optional field)', () => { + const mgr = createSessionManager(CFG); + nextPty = createMockPty(); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + const entry = mgr.list().find((e) => e.id === s.meta.id); + expect(entry).toMatchObject({ + id: s.meta.id, + createdAt: 1_000, + clientCount: 1, + status: 'unknown', + exited: false, + cols: 80, + rows: 24, + telemetry: null, + }); + }); +}); diff --git a/test/push-apns.test.ts b/test/push-apns.test.ts new file mode 100644 index 0000000..f94bcae --- /dev/null +++ b/test/push-apns.test.ts @@ -0,0 +1,1084 @@ +/** + * test/push-apns.test.ts (T-iOS-20) — APNs sender + device-token registry. + * + * Unit layer (no network, fake ApnsHttp2Client, mocked clock): + * - loadApnsConfig: env group all-or-disabled, defaults, host validation + * - normalizeApnsToken: 64–160 hex, lowercase-normalized, everything else null + * - ApnsTokenStore: idempotent upsert/remove, FIFO cap, 0600 persist, tolerant load + * - buildApnsJwt: ES256 header/claims, ieee-p1363 signature verifies, mocked iat + * - createApnsService: payload shape NEEDS-INPUT vs DONE (category/priority/ + * minimization — cwd/command NEVER in the payload), DND/NOTIFY_DONE gates, + * JWT ~50min cache + 403 cache-drop, evict on 410/BadDeviceToken, + * connection errors never throw + * - combineNotifyServices: both paths fire, one failing never blocks the other + * + * Integration layer (real startServer on an ephemeral port): + * - disabled mode: no APNS_* env → routes absent (404), startup never crashes + * - POST/DELETE /push/apns-token: Origin guard 403, invalid 400, idempotent + * 204s, rate limit 429 (frozen wire shape) + * - coexistence (PTY): held gate → web-push AND APNs both fire, carrying the + * SAME capability token; /hook/decision with that token still works (204) + */ + +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' +import http2 from 'node:http2' +import type { IncomingHttpHeaders } from 'node:http2' +import { generateKeyPairSync, verify as cryptoVerify } from 'node:crypto' + +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import * as nodePty from 'node-pty' + +import { loadConfig } from '../src/config.js' +import { startServer } from '../src/server.js' +import type { Config, NotifyService, Session } from '../src/types.js' +import { + buildApnsJwt, + combineNotifyServices, + createApnsService, + initApns, + loadApnsConfig, + loadApnsKey, + loadApnsTokenStore, + normalizeApnsToken, + type ApnsConfig, + type ApnsHttp2Client, + type ApnsRequest, + type ApnsResponse, +} from '../src/push/apns.js' + +// ── web-push mock (captures every signed payload for coexistence tests) ─────── +const { sentWebPushPayloads } = vi.hoisted(() => ({ sentWebPushPayloads: [] as string[] })) +vi.mock('web-push', () => ({ + default: { + setVapidDetails: (): void => {}, + sendNotification: async (_sub: unknown, payload: string): Promise => { + sentWebPushPayloads.push(payload) + }, + }, +})) + +const PTY_AVAILABLE = (() => { + try { + const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) + p.kill() + return true + } catch { + return false + } +})() +const itPty = PTY_AVAILABLE ? it : it.skip + +// ── fixtures ─────────────────────────────────────────────────────────────────── + +const BASE_CFG: Config = { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 10_000, + scrollbackBytes: 2 * 1024 * 1024, + maxPayloadBytes: 1024 * 1024, + wsPath: '/term', + maxSessions: 50, + maxMsgsPerSec: 2000, + permTimeoutMs: 300_000, + reapIntervalMs: 60_000, + previewBytes: 24 * 1024, + useTmux: false, + allowedOrigins: [], + projectRoots: ['/home/tester'], + projectScanDepth: 4, + projectScanTtlMs: 10_000, + projectDirtyCheck: true, + editorCmd: 'code', + vapidPublicKey: 'PUBKEY', + vapidPrivateKey: 'PRIVKEY', + vapidSubject: 'mailto:admin@example.com', + pushStorePath: '/tmp/push-subs.json', + pushMaxSubs: 50, + notifyDone: true, + notifyDnd: false, + decisionTokenTtlMs: 300_000, + timelineMax: 200, + timelineEnabled: true, + stuckTtlMs: 600_000, + stuckAlert: true, + diffTimeoutMs: 2000, + diffMaxBytes: 2 * 1024 * 1024, + diffMaxFiles: 300, + statuslineTtlMs: 30_000, + worktreeEnabled: true, + worktreeRoot: undefined, + worktreeTimeoutMs: 10_000, + defaultPermissionMode: 'default', + allowAutoMode: false, +} + +function cfg(overrides: Partial = {}): Config { + return { ...BASE_CFG, ...overrides } +} + +const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' +const SECRET_CWD = '/home/tester/super-secret-project' + +function fakeSession(id = SID, cwd: string | null = SECRET_CWD): Session { + return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session +} + +/** A fresh EC P-256 keypair; private side exported as .p8 (PKCS#8 PEM). */ +function makeP8(): { pem: string; publicPem: string } { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + return { + pem: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string, + publicPem: publicKey.export({ type: 'spki', format: 'pem' }) as string, + } +} + +const tmpFiles: string[] = [] + +function tmpPath(name: string): string { + const p = path.join(os.tmpdir(), `webterm-apns-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`) + tmpFiles.push(p) + return p +} + +const HEX64 = 'a'.repeat(64) +const HEX64_B = 'b'.repeat(64) + +const APNS_ENV_OK = { + APNS_KEY_PATH: '/tmp/AuthKey_TEST.p8', + APNS_KEY_ID: 'ABCDEF1234', + APNS_TEAM_ID: 'TEAM123456', +} + +function apnsCfgFixture(overrides: Partial = {}): ApnsConfig { + return { + keyPath: '/unused/AuthKey.p8', + keyId: 'ABCDEF1234', + teamId: 'TEAM123456', + bundleId: 'com.yaojia.webterm', + storePath: tmpPath('svc-store'), + host: 'https://api.push.apple.com', + ...overrides, + } +} + +interface FakeClient extends ApnsHttp2Client { + requests: ApnsRequest[] +} + +/** Fake HTTP/2 seam: records requests; per-call responder decides the outcome. */ +function makeClient( + respond: (req: ApnsRequest, callIndex: number) => ApnsResponse | Promise = () => ({ status: 200, body: '' }), +): FakeClient { + const requests: ApnsRequest[] = [] + return { + requests, + async request(req: ApnsRequest): Promise { + requests.push(req) + return respond(req, requests.length - 1) + }, + } +} + +async function makeService(options: { + respond?: (req: ApnsRequest, i: number) => ApnsResponse | Promise + cfgOverrides?: Partial + apnsOverrides?: Partial + tokens?: string[] + now?: () => number +}): Promise<{ service: NotifyService; client: FakeClient; store: ReturnType; key: ReturnType }> { + const key = makeP8() + const keyPath = tmpPath('unit-key.p8') + await fs.writeFile(keyPath, key.pem) + const keyObject = loadApnsKey(keyPath) + if (keyObject === null) throw new Error('test fixture: key load failed') + const store = loadApnsTokenStore(tmpPath('unit-tokens.json'), 50) + for (const t of options.tokens ?? [HEX64]) store.add({ token: t, createdAt: 1 }) + const client = makeClient(options.respond) + const service = createApnsService( + cfg(options.cfgOverrides), + apnsCfgFixture(options.apnsOverrides), + keyObject, + store, + { client, now: options.now }, + ) + return { service, client, store, key } +} + +afterEach(async () => { + vi.restoreAllMocks() + sentWebPushPayloads.length = 0 + for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: loadApnsConfig +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('loadApnsConfig', () => { + it('returns ok with defaults when the critical trio is set', () => { + const r = loadApnsConfig(APNS_ENV_OK) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.config.keyPath).toBe('/tmp/AuthKey_TEST.p8') + expect(r.config.keyId).toBe('ABCDEF1234') + expect(r.config.teamId).toBe('TEAM123456') + expect(r.config.bundleId).toBe('com.yaojia.webterm') + expect(r.config.storePath.endsWith('.web-terminal-apns-tokens.json')).toBe(true) + expect(r.config.host).toBe('https://api.push.apple.com') + }) + + it('honors APNS_BUNDLE_ID / APNS_STORE_PATH / APNS_HOST overrides', () => { + const r = loadApnsConfig({ + ...APNS_ENV_OK, + APNS_BUNDLE_ID: 'com.example.app', + APNS_STORE_PATH: '/tmp/custom-tokens.json', + APNS_HOST: 'https://api.sandbox.push.apple.com', + }) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.config.bundleId).toBe('com.example.app') + expect(r.config.storePath).toBe('/tmp/custom-tokens.json') + expect(r.config.host).toBe('https://api.sandbox.push.apple.com') + }) + + it.each(['APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID'])( + 'disables the whole group when %s is missing (reason names the var)', + (missing) => { + const env: Record = { ...APNS_ENV_OK } + delete env[missing] + const r = loadApnsConfig(env) + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.reason).toContain(missing) + }, + ) + + it('treats an empty-string critical var as missing', () => { + const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_KEY_ID: '' }) + expect(r.ok).toBe(false) + }) + + it('rejects a malformed APNS_HOST instead of crashing', () => { + const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_HOST: 'not a url' }) + expect(r.ok).toBe(false) + }) + + it('rejects a non-http(s) APNS_HOST scheme', () => { + const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_HOST: 'ftp://api.push.apple.com' }) + expect(r.ok).toBe(false) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: normalizeApnsToken (frozen wire shape: 64–160 hex, lowercase-normalized) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('normalizeApnsToken', () => { + it('accepts 64 lowercase hex chars verbatim', () => { + expect(normalizeApnsToken(HEX64)).toBe(HEX64) + }) + + it('lowercase-normalizes uppercase hex', () => { + expect(normalizeApnsToken('A'.repeat(64))).toBe(HEX64) + }) + + it('accepts the 160-char upper bound', () => { + expect(normalizeApnsToken('f'.repeat(160))).toBe('f'.repeat(160)) + }) + + it.each([ + ['63 chars', 'a'.repeat(63)], + ['161 chars', 'a'.repeat(161)], + ['non-hex', 'g'.repeat(64)], + ['embedded whitespace', `${'a'.repeat(63)} `], + ['empty', ''], + ])('rejects %s', (_label, raw) => { + expect(normalizeApnsToken(raw)).toBeNull() + }) + + it.each([ + ['number', 42], + ['null', null], + ['undefined', undefined], + ['object', { token: HEX64 }], + ])('rejects non-string input (%s)', (_label, raw) => { + expect(normalizeApnsToken(raw)).toBeNull() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: ApnsTokenStore +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('loadApnsTokenStore', () => { + it('starts empty when the file does not exist', () => { + const store = loadApnsTokenStore(tmpPath('missing.json'), 50) + expect(store.list()).toEqual([]) + }) + + it('upserts idempotently by token (same token twice → one record)', () => { + const store = loadApnsTokenStore(tmpPath('upsert.json'), 50) + store.add({ token: HEX64, createdAt: 1 }) + store.add({ token: HEX64, createdAt: 2 }) + expect(store.list().length).toBe(1) + expect(store.list()[0]?.createdAt).toBe(2) + }) + + it('remove is idempotent (unknown token is a no-op)', () => { + const store = loadApnsTokenStore(tmpPath('remove.json'), 50) + store.add({ token: HEX64, createdAt: 1 }) + store.remove(HEX64_B) + expect(store.list().length).toBe(1) + store.remove(HEX64) + store.remove(HEX64) + expect(store.list()).toEqual([]) + }) + + it('FIFO-caps at maxTokens (oldest evicted)', () => { + const store = loadApnsTokenStore(tmpPath('cap.json'), 2) + store.add({ token: 'a'.repeat(64), createdAt: 1 }) + store.add({ token: 'b'.repeat(64), createdAt: 2 }) + store.add({ token: 'c'.repeat(64), createdAt: 3 }) + expect(store.list().map((r) => r.token)).toEqual(['b'.repeat(64), 'c'.repeat(64)]) + }) + + it('throws on a structurally invalid record', () => { + const store = loadApnsTokenStore(tmpPath('invalid.json'), 50) + expect(() => store.add({ token: 'not-hex', createdAt: 1 })).toThrow(TypeError) + }) + + it('persists with 0600 permissions and round-trips through load', async () => { + const file = tmpPath('persist.json') + const store = loadApnsTokenStore(file, 50) + store.add({ token: HEX64, createdAt: 7 }) + await store.persist() + const st = await fs.stat(file) + expect(st.mode & 0o777).toBe(0o600) + const reloaded = loadApnsTokenStore(file, 50) + expect(reloaded.list()).toEqual([{ token: HEX64, createdAt: 7 }]) + }) + + it('tolerates a malformed store file (starts empty, no throw)', async () => { + const file = tmpPath('malformed.json') + await fs.writeFile(file, 'not json at all') + const store = loadApnsTokenStore(file, 50) + expect(store.list()).toEqual([]) + }) + + it('filters invalid entries out of a persisted file', async () => { + const file = tmpPath('mixed.json') + await fs.writeFile( + file, + JSON.stringify([{ token: HEX64, createdAt: 1 }, { token: 'nope', createdAt: 1 }, 42]), + ) + const store = loadApnsTokenStore(file, 50) + expect(store.list()).toEqual([{ token: HEX64, createdAt: 1 }]) + }) + + it('prune removes a batch of dead tokens', () => { + const store = loadApnsTokenStore(tmpPath('prune.json'), 50) + store.add({ token: HEX64, createdAt: 1 }) + store.add({ token: HEX64_B, createdAt: 2 }) + store.prune([HEX64]) + expect(store.list().map((r) => r.token)).toEqual([HEX64_B]) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: loadApnsKey + buildApnsJwt (ES256, ieee-p1363, mocked clock) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('loadApnsKey', () => { + it('loads a valid EC P-256 .p8 file', async () => { + const { pem } = makeP8() + const file = tmpPath('good.p8') + await fs.writeFile(file, pem) + expect(loadApnsKey(file)).not.toBeNull() + }) + + it('returns null (never throws) for a missing file', () => { + expect(loadApnsKey('/nonexistent/AuthKey.p8')).toBeNull() + }) + + it('returns null for garbage key material', async () => { + const file = tmpPath('garbage.p8') + await fs.writeFile(file, 'this is not a key') + expect(loadApnsKey(file)).toBeNull() + }) + + it('returns null for a non-EC key', async () => { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const file = tmpPath('rsa.p8') + await fs.writeFile(file, privateKey.export({ type: 'pkcs8', format: 'pem' }) as string) + expect(loadApnsKey(file)).toBeNull() + }) +}) + +describe('buildApnsJwt', () => { + it('produces ES256 header/claims with the mocked clock, and the ieee-p1363 signature verifies', async () => { + const { pem, publicPem } = makeP8() + const file = tmpPath('jwt.p8') + await fs.writeFile(file, pem) + const key = loadApnsKey(file) + if (key === null) throw new Error('key load failed') + + const nowMs = 1_700_000_000_123 + const jwt = buildApnsJwt(key, 'ABCDEF1234', 'TEAM123456', nowMs) + const [h, c, s] = jwt.split('.') + expect(h).toBeDefined() + expect(c).toBeDefined() + expect(s).toBeDefined() + + const header = JSON.parse(Buffer.from(h as string, 'base64url').toString('utf8')) as Record + expect(header).toEqual({ alg: 'ES256', kid: 'ABCDEF1234' }) + + const claims = JSON.parse(Buffer.from(c as string, 'base64url').toString('utf8')) as Record + expect(claims).toEqual({ iss: 'TEAM123456', iat: 1_700_000_000 }) + + const valid = cryptoVerify( + 'sha256', + Buffer.from(`${h}.${c}`), + { key: publicPem, dsaEncoding: 'ieee-p1363' }, + Buffer.from(s as string, 'base64url'), + ) + expect(valid).toBe(true) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: createApnsService — payload shapes + minimization (security invariant) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('createApnsService payload shape', () => { + const NOW = 1_700_000_000_000 + + it('NEEDS-INPUT: priority 10, category WEBTERM_GATE, minimal alert, {sessionId, token} custom payload', async () => { + const { service, client } = await makeService({ now: () => NOW }) + await service.notify(fakeSession(), 'needs-input', 'cap-token-1') + + expect(client.requests.length).toBe(1) + const req = client.requests[0] as ApnsRequest + expect(req.path).toBe(`/3/device/${HEX64}`) + expect(req.headers['apns-priority']).toBe('10') + expect(req.headers['apns-push-type']).toBe('alert') + expect(req.headers['apns-topic']).toBe('com.yaojia.webterm') + expect(req.headers['apns-collapse-id']).toBe(SID.replace(/-/g, '')) + // apns-expiration = now + decisionTokenTtl (300 s) in epoch seconds. + expect(req.headers['apns-expiration']).toBe(String(1_700_000_000 + 300)) + expect(req.headers['authorization']).toMatch(/^bearer /) + + const body = JSON.parse(req.body) as Record + const aps = body['aps'] as Record + expect(aps['category']).toBe('WEBTERM_GATE') + const alert = aps['alert'] as Record + // Minimization: alert carries session short-prefix + status word ONLY. + expect(`${alert['title']} ${alert['body']}`).toContain(SID.slice(0, 8)) + expect(`${alert['title']} ${alert['body']}`).not.toContain(SID) + // Custom payload: what /hook/decision needs — full sessionId + capability token. + expect(body['sessionId']).toBe(SID) + expect(body['token']).toBe('cap-token-1') + expect(body['cls']).toBe('needs-input') + }) + + it('DONE: priority 5, no decision token, no gate category', async () => { + const { service, client } = await makeService({ now: () => NOW }) + await service.notify(fakeSession(), 'done') + + expect(client.requests.length).toBe(1) + const req = client.requests[0] as ApnsRequest + expect(req.headers['apns-priority']).toBe('5') + const body = JSON.parse(req.body) as Record + expect(body['cls']).toBe('done') + expect('token' in body).toBe(false) + const aps = body['aps'] as Record + expect('category' in aps).toBe(false) + }) + + it('NEVER leaks cwd, command content, or terminal output into the payload', async () => { + const { service, client } = await makeService({ now: () => NOW }) + await service.notify(fakeSession(SID, SECRET_CWD), 'needs-input', 'tok') + await service.notify(fakeSession(SID, SECRET_CWD), 'done') + + for (const req of client.requests) { + expect(req.body).not.toContain('super-secret-project') + expect(req.body).not.toContain(SECRET_CWD) + expect(req.body).not.toContain('cwd') + expect(req.body).not.toContain('command') + } + }) + + it('P1 signal list is NEEDS-INPUT + DONE only: stuck sends nothing over APNs', async () => { + const { service, client } = await makeService({}) + await service.notify(fakeSession(), 'stuck') + expect(client.requests.length).toBe(0) + }) + + it('fans out to every registered token', async () => { + const { service, client } = await makeService({ tokens: [HEX64, HEX64_B] }) + await service.notify(fakeSession(), 'done') + expect(client.requests.map((r) => r.path).sort()).toEqual([ + `/3/device/${HEX64}`, + `/3/device/${HEX64_B}`, + ]) + }) + + it('sends nothing when no tokens are registered', async () => { + const { service, client } = await makeService({ tokens: [] }) + await service.notify(fakeSession(), 'needs-input', 'tok') + expect(client.requests.length).toBe(0) + }) + + it('honors NOTIFY_DND (mirrors web-push semantics)', async () => { + const { service, client } = await makeService({ cfgOverrides: { notifyDnd: true } }) + await service.notify(fakeSession(), 'needs-input', 'tok') + expect(client.requests.length).toBe(0) + }) + + it('honors NOTIFY_DONE=false for done but still sends needs-input', async () => { + const { service, client } = await makeService({ cfgOverrides: { notifyDone: false } }) + await service.notify(fakeSession(), 'done') + expect(client.requests.length).toBe(0) + await service.notify(fakeSession(), 'needs-input', 'tok') + expect(client.requests.length).toBe(1) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: JWT caching (~50 min) + 403 cache drop +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('createApnsService JWT cache', () => { + it('reuses the cached JWT within the ~50 min window', async () => { + let nowMs = 1_700_000_000_000 + const { service, client } = await makeService({ now: () => nowMs }) + await service.notify(fakeSession(), 'done') + nowMs += 10 * 60_000 // +10 min: still cached + await service.notify(fakeSession(), 'done') + const [a, b] = client.requests.map((r) => r.headers['authorization']) + expect(a).toBe(b) + }) + + it('re-signs after the cache window expires', async () => { + let nowMs = 1_700_000_000_000 + const { service, client } = await makeService({ now: () => nowMs }) + await service.notify(fakeSession(), 'done') + nowMs += 51 * 60_000 // past the ~50 min cache + await service.notify(fakeSession(), 'done') + const [a, b] = client.requests.map((r) => r.headers['authorization']) + expect(a).not.toBe(b) + }) + + it('drops the cached JWT on a 403 so the next send re-signs', async () => { + const { service, client } = await makeService({ + respond: (_req, i) => + i === 0 ? { status: 403, body: '{"reason":"ExpiredProviderToken"}' } : { status: 200, body: '' }, + }) + await service.notify(fakeSession(), 'done') + await service.notify(fakeSession(), 'done') + const [a, b] = client.requests.map((r) => r.headers['authorization']) + // ECDSA signatures are randomized: a re-sign always yields a fresh string. + expect(a).not.toBe(b) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: response handling — evict on 410 / BadDeviceToken, resilience +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('createApnsService response handling', () => { + it('evicts a token on 410 Unregistered and keeps healthy ones', async () => { + const { service, store } = await makeService({ + tokens: [HEX64, HEX64_B], + respond: (req) => + req.path.endsWith(HEX64) ? { status: 410, body: '{"reason":"Unregistered"}' } : { status: 200, body: '' }, + }) + await service.notify(fakeSession(), 'done') + expect(store.list().map((r) => r.token)).toEqual([HEX64_B]) + }) + + it('evicts a token on 400 BadDeviceToken', async () => { + const { service, store } = await makeService({ + respond: () => ({ status: 400, body: '{"reason":"BadDeviceToken"}' }), + }) + await service.notify(fakeSession(), 'done') + expect(store.list()).toEqual([]) + }) + + it('keeps the token on other 400s and on 429 (transient)', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { service, store } = await makeService({ + respond: (_req, i) => + i === 0 ? { status: 400, body: '{"reason":"BadMessageId"}' } : { status: 429, body: '{"reason":"TooManyRequests"}' }, + }) + await service.notify(fakeSession(), 'done') + await service.notify(fakeSession(), 'done') + expect(store.list().length).toBe(1) + expect(errSpy).toHaveBeenCalled() + // Never log the device token or key material. + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain(HEX64) + } + }) + + it('never throws on connection errors and keeps the token', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { service, store } = await makeService({ + respond: () => { + throw new Error('ECONNREFUSED 127.0.0.1:443') + }, + }) + await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined() + expect(store.list().length).toBe(1) + expect(errSpy).toHaveBeenCalled() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: combineNotifyServices (coexistence with web-push at the seam) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('combineNotifyServices', () => { + function spyService(enabled: boolean): NotifyService & { calls: unknown[][] } { + const calls: unknown[][] = [] + return { + calls, + isEnabled: () => enabled, + async notify(session, cls, token?) { + calls.push([session.meta.id, cls, token]) + }, + } + } + + it('fans out to every service with identical arguments (both paths fire)', async () => { + const a = spyService(true) + const b = spyService(true) + const combined = combineNotifyServices(a, b) + await combined.notify(fakeSession(), 'needs-input', 'tok-9') + expect(a.calls).toEqual([[SID, 'needs-input', 'tok-9']]) + expect(b.calls).toEqual([[SID, 'needs-input', 'tok-9']]) + }) + + it('isEnabled is the OR of the children', () => { + expect(combineNotifyServices(spyService(false), spyService(true)).isEnabled()).toBe(true) + expect(combineNotifyServices(spyService(false), spyService(false)).isEnabled()).toBe(false) + }) + + it('one failing service never blocks the other (and never throws)', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const boom: NotifyService = { + isEnabled: () => true, + notify: async () => { + throw new Error('web-push exploded') + }, + } + const b = spyService(true) + await expect(combineNotifyServices(boom, b).notify(fakeSession(), 'done')).resolves.toBeUndefined() + expect(b.calls.length).toBe(1) + expect(errSpy).toHaveBeenCalled() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: initApns — disabled group semantics (startup never crashes, one log line) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('initApns', () => { + it('returns null with one log line when nothing is configured', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(initApns(cfg(), {})).toBeNull() + const apnsLines = errSpy.mock.calls.filter((c) => String(c[0]).includes('apns')) + expect(apnsLines.length).toBe(1) + }) + + it('returns null when the group is only partially configured', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(initApns(cfg(), { APNS_KEY_PATH: '/tmp/AuthKey.p8' })).toBeNull() + }) + + it('returns null (no crash) when the key file is unreadable or garbage', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = tmpPath('bad.p8') + await fs.writeFile(file, 'garbage') + expect( + initApns(cfg(), { ...APNS_ENV_OK, APNS_KEY_PATH: file, APNS_STORE_PATH: tmpPath('s1.json') }), + ).toBeNull() + }) + + it('returns a runtime with a working store when fully configured — never logging key material', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { pem } = makeP8() + const keyFile = tmpPath('init-good.p8') + await fs.writeFile(keyFile, pem) + const rt = initApns(cfg(), { + ...APNS_ENV_OK, + APNS_KEY_PATH: keyFile, + APNS_STORE_PATH: tmpPath('init-store.json'), + }) + expect(rt).not.toBeNull() + if (rt === null) return + expect(rt.service.isEnabled()).toBe(true) + rt.store.add({ token: HEX64, createdAt: 1 }) + expect(rt.store.list().length).toBe(1) + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain('PRIVATE KEY') + } + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Integration: real startServer on an ephemeral port +// ═══════════════════════════════════════════════════════════════════════════════ + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('bad addr')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +const handles: { close(): Promise }[] = [] + +const APNS_ENV_KEYS = [ + 'APNS_KEY_PATH', + 'APNS_KEY_ID', + 'APNS_TEAM_ID', + 'APNS_BUNDLE_ID', + 'APNS_STORE_PATH', + 'APNS_HOST', +] as const + +/** Run startServer with APNS_* temporarily injected into process.env (the server + * reads the group once at startup), restoring the previous values right after. */ +async function spawnServer(options: { + apnsEnv?: Partial> + cfgEnv?: Record +} = {}): Promise<{ port: number; origin: string; apnsStorePath: string }> { + const port = await getFreePort() + const pushStorePath = tmpPath(`web-subs-${port}.json`) + const apnsStorePath = options.apnsEnv?.APNS_STORE_PATH ?? tmpPath(`apns-tokens-${port}.json`) + + const saved = new Map() + for (const k of APNS_ENV_KEYS) saved.set(k, process.env[k]) + for (const k of APNS_ENV_KEYS) delete process.env[k] + if (options.apnsEnv !== undefined) { + for (const [k, v] of Object.entries(options.apnsEnv)) process.env[k] = v + if (options.apnsEnv.APNS_STORE_PATH === undefined) process.env['APNS_STORE_PATH'] = apnsStorePath + } + try { + const config = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + PUSH_STORE_PATH: pushStorePath, + ...options.cfgEnv, + }) + handles.push(startServer(config)) + } finally { + for (const [k, v] of saved) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + await new Promise((r) => setTimeout(r, 80)) + return { port, origin: `http://127.0.0.1:${port}`, apnsStorePath } +} + +async function makeApnsEnv(host?: string): Promise> { + const { pem } = makeP8() + const keyPath = tmpPath('server-key.p8') + await fs.writeFile(keyPath, pem) + return { + APNS_KEY_PATH: keyPath, + APNS_KEY_ID: 'ABCDEF1234', + APNS_TEAM_ID: 'TEAM123456', + // Point away from Apple: tests must NEVER touch the real APNs host. + APNS_HOST: host ?? 'http://127.0.0.1:9', + } +} + +function postToken(port: number, token: unknown, origin?: string, method: 'POST' | 'DELETE' = 'POST'): Promise { + return fetch(`http://127.0.0.1:${port}/push/apns-token`, { + method, + headers: { 'Content-Type': 'application/json', ...(origin !== undefined ? { Origin: origin } : {}) }, + body: JSON.stringify({ token }), + }) +} + +afterEach(async () => { + while (handles.length > 0) await handles.pop()?.close() + await new Promise((r) => setTimeout(r, 30)) +}) + +describe('disabled mode (no APNS_* env)', () => { + it('does not mount the token routes and the server still works', async () => { + const { port, origin } = await spawnServer() + const res = await postToken(port, HEX64, origin) + expect(res.status).toBe(404) + const del = await postToken(port, HEX64, origin, 'DELETE') + expect(del.status).toBe(404) + const alive = await fetch(`http://127.0.0.1:${port}/live-sessions`) + expect(alive.status).toBe(200) + }) + + it('stays disabled (no crash) when the group is partial or the key is garbage', async () => { + const badKey = tmpPath('garbage-server.p8') + await fs.writeFile(badKey, 'garbage') + const { port, origin } = await spawnServer({ + apnsEnv: { APNS_KEY_PATH: badKey, APNS_KEY_ID: 'ABCDEF1234', APNS_TEAM_ID: 'TEAM123456' }, + }) + expect((await postToken(port, HEX64, origin)).status).toBe(404) + expect((await fetch(`http://127.0.0.1:${port}/live-sessions`)).status).toBe(200) + }) +}) + +describe('POST/DELETE /push/apns-token (frozen wire shape)', () => { + it('rejects foreign and missing Origin with 403', async () => { + const { port } = await spawnServer({ apnsEnv: await makeApnsEnv() }) + expect((await postToken(port, HEX64, 'http://evil.example')).status).toBe(403) + expect((await postToken(port, HEX64)).status).toBe(403) + expect((await postToken(port, HEX64, 'http://evil.example', 'DELETE')).status).toBe(403) + }) + + it('rejects invalid tokens with 400', async () => { + const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv() }) + expect((await postToken(port, 'a'.repeat(63), origin)).status).toBe(400) + expect((await postToken(port, 'g'.repeat(64), origin)).status).toBe(400) + expect((await postToken(port, 42, origin)).status).toBe(400) + }) + + it('registers idempotently (204 twice), lowercase-normalizing the stored token', async () => { + const { port, origin, apnsStorePath } = await spawnServer({ apnsEnv: await makeApnsEnv() }) + expect((await postToken(port, 'A'.repeat(64), origin)).status).toBe(204) + expect((await postToken(port, 'a'.repeat(64), origin)).status).toBe(204) + const stored = JSON.parse(await fs.readFile(apnsStorePath, 'utf8')) as { token: string }[] + expect(stored.length).toBe(1) + expect(stored[0]?.token).toBe('a'.repeat(64)) + }) + + it('unregisters idempotently (204 even for an unknown token)', async () => { + const { port, origin, apnsStorePath } = await spawnServer({ apnsEnv: await makeApnsEnv() }) + expect((await postToken(port, HEX64, origin)).status).toBe(204) + expect((await postToken(port, 'A'.repeat(64), origin, 'DELETE')).status).toBe(204) + expect((await postToken(port, HEX64_B, origin, 'DELETE')).status).toBe(204) + const stored = JSON.parse(await fs.readFile(apnsStorePath, 'utf8')) as { token: string }[] + expect(stored.length).toBe(0) + }) + + it('rate-limits more than 5 calls/min from one IP (429)', async () => { + const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv() }) + const codes: number[] = [] + for (let i = 0; i < 6; i++) { + codes.push((await postToken(port, String(i).repeat(64), origin)).status) + } + expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true) + expect(codes[5]).toBe(429) + }) +}) + +// ── coexistence: held gate → BOTH web-push and APNs fire with the same token ── + +interface FakeApnsServer { + url: string + requests: { headers: IncomingHttpHeaders; body: string }[] + close(): Promise +} + +/** Local h2c stand-in for api.push.apple.com — captures requests, answers 200. */ +function startFakeApnsServer(): Promise { + return new Promise((resolve, reject) => { + const requests: { headers: IncomingHttpHeaders; body: string }[] = [] + const server = http2.createServer() + server.on('stream', (stream, headers) => { + let body = '' + stream.setEncoding('utf8') + stream.on('data', (chunk: string) => { + body += chunk + }) + stream.on('end', () => { + requests.push({ headers, body }) + stream.respond({ ':status': 200 }) + stream.end() + }) + }) + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr === null || typeof addr === 'string') { + reject(new Error('bad addr')) + return + } + resolve({ + url: `http://127.0.0.1:${addr.port}`, + requests, + close: () => + new Promise((r) => { + server.close(() => r()) + }), + }) + }) + }) +} + +function waitForOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('open timeout')), 3000) + ws.once('open', () => { + clearTimeout(t) + resolve() + }) + ws.once('error', (e) => { + clearTimeout(t) + reject(e) + }) + }) +} + +async function createDetachedSession(port: number, origin: string): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const sessionId = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('attach timeout')), 5000) + ws.on('message', (raw) => { + try { + const m = JSON.parse(String(raw)) as Record + if (m['type'] === 'attached') { + clearTimeout(t) + resolve(m['sessionId'] as string) + } + } catch { + /* ignore non-JSON frames */ + } + }) + }) + ws.close() + await new Promise((r) => setTimeout(r, 150)) + return sessionId +} + +describe('coexistence: web-push + APNs on a held gate', () => { + itPty('fires BOTH paths with the same capability token; /hook/decision accepts it', async () => { + const fakeApns = await startFakeApnsServer() + try { + const { port, origin } = await spawnServer({ + apnsEnv: await makeApnsEnv(fakeApns.url), + cfgEnv: { + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: 'test-private-key', + VAPID_SUBJECT: 'mailto:test@localhost', + PERM_TIMEOUT_MS: '4000', + }, + }) + + // Register a web-push subscription AND an APNs device token. + const sub = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ endpoint: 'https://push.example/ep-1', keys: { p256dh: 'p', auth: 'a' } }), + }) + expect(sub.status).toBe(204) + expect((await postToken(port, HEX64, origin)).status).toBe(204) + + const sessionId = await createDetachedSession(port, origin) + + // Fire the held permission hook (loopback); do NOT await — it must be HELD. + sentWebPushPayloads.length = 0 + let resolved = false + const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }).then(async (r) => { + resolved = true + return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } + }) + + await new Promise((r) => setTimeout(r, 400)) + expect(resolved).toBe(false) + + // Web-push path: unchanged. + expect(sentWebPushPayloads.length).toBe(1) + const webToken = (JSON.parse(sentWebPushPayloads[0] as string) as { token?: string }).token + expect(typeof webToken).toBe('string') + + // APNs path: same gate, same capability token, frozen shape. + expect(fakeApns.requests.length).toBe(1) + const apnsReq = fakeApns.requests[0] as { headers: IncomingHttpHeaders; body: string } + expect(apnsReq.headers[':path']).toBe(`/3/device/${HEX64}`) + expect(apnsReq.headers['apns-priority']).toBe('10') + expect(String(apnsReq.headers['authorization'])).toMatch(/^bearer /) + const apnsBody = JSON.parse(apnsReq.body) as Record + expect((apnsBody['aps'] as Record)['category']).toBe('WEBTERM_GATE') + expect(apnsBody['sessionId']).toBe(sessionId) + expect(apnsBody['token']).toBe(webToken) + expect(apnsReq.body).not.toContain('"cwd"') + + // The capability token semantics are UNCHANGED: single decision resolves it. + const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId, decision: 'allow', token: apnsBody['token'] }), + }) + expect(dec.status).toBe(204) + const decision = await permPromise + expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') + } finally { + await fakeApns.close() + } + }) + + itPty('an APNs token alone (zero web subs, zero clients) is enough to HOLD the gate', async () => { + const fakeApns = await startFakeApnsServer() + try { + const { port, origin } = await spawnServer({ + apnsEnv: await makeApnsEnv(fakeApns.url), + cfgEnv: { PERM_TIMEOUT_MS: '4000' }, + }) + expect((await postToken(port, HEX64, origin)).status).toBe(204) + const sessionId = await createDetachedSession(port, origin) + + let resolved = false + void fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }).then(() => { + resolved = true + }) + + await new Promise((r) => setTimeout(r, 400)) + expect(resolved).toBe(false) // held — not fallen through to Claude's own prompt + expect(fakeApns.requests.length).toBe(1) + + // Release it so the server can shut down cleanly. + const body = JSON.parse((fakeApns.requests[0] as { body: string }).body) as Record + const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId, decision: 'deny', token: body['token'] }), + }) + expect(dec.status).toBe(204) + } finally { + await fakeApns.close() + } + }) +})