feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract

T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
This commit is contained in:
Yaojia Wang
2026-07-05 13:34:01 +02:00
parent aa956fcbb4
commit 4871e8ac3d
20 changed files with 3319 additions and 48 deletions

View File

@@ -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=<strictly-percent-encoded value>`), 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)
}