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
168 lines
6.4 KiB
Swift
168 lines
6.4 KiB
Swift
import Foundation
|
|
import WireProtocol
|
|
|
|
/// 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"
|
|
}
|
|
|
|
/// Whether a route mutates server state — THE security split (plan §3.4/§5.1):
|
|
/// `Origin` is stamped **iff** `.guarded`.
|
|
enum OriginPolicy: Sendable, Equatable {
|
|
/// Read-only GET — MUST NOT carry Origin. If the server ever reclassifies
|
|
/// a RO route as guarded, integration tests go red instead of passing by
|
|
/// coincidence (§3.4 铁律).
|
|
case readOnly
|
|
/// State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal;
|
|
/// the server rejects missing/foreign Origin with 403 (src/server.ts:332-339).
|
|
case guarded
|
|
}
|
|
|
|
/// Header/content-type names used by the builder (no magic strings inline).
|
|
enum HeaderName {
|
|
static let origin = "Origin"
|
|
static let contentType = "Content-Type"
|
|
}
|
|
|
|
enum ContentTypeValue {
|
|
static let json = "application/json"
|
|
}
|
|
|
|
/// One buildable API route — an immutable snapshot; building never mutates.
|
|
struct APIRoute: Sendable, Equatable {
|
|
let method: HTTPMethod
|
|
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, 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? {
|
|
guard var components = URLComponents(
|
|
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
|
) else { return nil }
|
|
components.path = path
|
|
components.query = nil
|
|
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)
|
|
request.httpMethod = method.rawValue
|
|
if originPolicy == .guarded {
|
|
request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
|
|
}
|
|
if let body {
|
|
request.httpBody = body
|
|
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
|
|
}
|
|
return request
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
static func preview(id: UUID) -> APIRoute {
|
|
APIRoute(
|
|
method: .get, path: "/live-sessions/\(pathId(id))/preview",
|
|
originPolicy: .readOnly, body: nil
|
|
)
|
|
}
|
|
|
|
static func events(id: UUID) -> APIRoute {
|
|
APIRoute(
|
|
method: .get, path: "/live-sessions/\(pathId(id))/events",
|
|
originPolicy: .readOnly, body: nil
|
|
)
|
|
}
|
|
|
|
static func uiConfig() -> APIRoute {
|
|
APIRoute(method: .get, path: "/config/ui", originPolicy: .readOnly, body: nil)
|
|
}
|
|
|
|
static func killSession(id: UUID) -> APIRoute {
|
|
APIRoute(
|
|
method: .delete, path: "/live-sessions/\(pathId(id))",
|
|
originPolicy: .guarded, body: nil
|
|
)
|
|
}
|
|
|
|
/// Body is exactly `{sessionId,decision,token}`. Server-enforced limits
|
|
/// (they are the server's, not ours — documented for callers):
|
|
/// body ≤ 4 KB (src/server.ts:503) and ≤ 10 requests/min/IP
|
|
/// (src/server.ts:72,504-508).
|
|
static func hookDecision(
|
|
sessionId: UUID, decision: HookDecision, token: String
|
|
) throws -> APIRoute {
|
|
let body = try JSONEncoder().encode(HookDecisionBody(
|
|
sessionId: pathId(sessionId), decision: decision.rawValue, token: token
|
|
))
|
|
return APIRoute(
|
|
method: .post, path: "/hook/decision", originPolicy: .guarded, body: body
|
|
)
|
|
}
|
|
|
|
/// Server session ids are lowercase `crypto.randomUUID()` strings and
|
|
/// `:id` route params are matched as EXACT strings — always serialize
|
|
/// lowercase (same rule as `MessageCodec`'s attach encoding).
|
|
private static func pathId(_ id: UUID) -> String {
|
|
id.uuidString.lowercased()
|
|
}
|
|
|
|
private struct HookDecisionBody: Encodable {
|
|
let sessionId: String
|
|
let decision: String
|
|
let token: String
|
|
}
|
|
}
|