feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
This commit is contained in:
131
ios/Packages/APIClient/Sources/APIClient/Endpoints.swift
Normal file
131
ios/Packages/APIClient/Sources/APIClient/Endpoints.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// HTTP method whitelist for the six frozen endpoints (plan §3.4).
|
||||
enum HTTPMethod: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
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?
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
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 six frozen endpoints (plan §3.4). 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)
|
||||
/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
|
||||
enum Endpoints {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user