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
140 lines
5.8 KiB
Swift
140 lines
5.8 KiB
Swift
import Foundation
|
||
import WireProtocol
|
||
|
||
/// Typed client for the server's HTTP surface (frozen contract, plan §3.4).
|
||
///
|
||
/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing)
|
||
/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do.
|
||
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is
|
||
/// single-point derived by `HostEndpoint` (never hand-assembled).
|
||
///
|
||
/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies
|
||
/// are decoded tolerantly (malformed entries dropped), statuses are mapped to
|
||
/// explicit `APIClientError`s, and nothing here ever crashes on bad input.
|
||
public struct APIClient: Sendable {
|
||
public let endpoint: HostEndpoint
|
||
private let http: any HTTPTransport
|
||
|
||
public init(endpoint: HostEndpoint, http: any HTTPTransport) {
|
||
self.endpoint = endpoint
|
||
self.http = http
|
||
}
|
||
|
||
// MARK: - RO (read-only — NO Origin header)
|
||
|
||
/// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every
|
||
/// device polls (`Tunables.listPollInterval` cadence, T-iOS-13).
|
||
public func liveSessions() async throws -> [LiveSessionInfo] {
|
||
let (data, response) = try await perform(Endpoints.liveSessions())
|
||
guard response.statusCode == HTTPStatus.ok else {
|
||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||
}
|
||
return try LiveSessionInfo.decodeList(from: data)
|
||
}
|
||
|
||
/// `GET /live-sessions/:id/preview` (src/server.ts:314-326) — ring-buffer
|
||
/// tail for a read-only thumbnail; no attach, no client registered.
|
||
public func preview(id: UUID) async throws -> SessionPreview {
|
||
let (data, response) = try await perform(Endpoints.preview(id: id))
|
||
try Self.requireOK(response)
|
||
guard let preview = try? JSONDecoder().decode(SessionPreview.self, from: data) else {
|
||
throw APIClientError.invalidResponseBody
|
||
}
|
||
return preview
|
||
}
|
||
|
||
/// `GET /live-sessions/:id/events` (src/server.ts:528-540) — the A4
|
||
/// activity timeline. Decoded via WireProtocol's tolerant helper: unknown
|
||
/// `class` entries are dropped, a non-array body yields `[]` (the server
|
||
/// itself replies `[]` when timeline capture is disabled).
|
||
public func events(id: UUID) async throws -> [TimelineEvent] {
|
||
let (data, response) = try await perform(Endpoints.events(id: id))
|
||
try Self.requireOK(response)
|
||
return TimelineEvent.decodeList(from: data)
|
||
}
|
||
|
||
/// `GET /config/ui` (src/server.ts:609-612) — `{allowAutoMode}`. Reserved
|
||
/// for a future permission-mode switcher (plan §3.4); the plan-gate
|
||
/// three-way UI does not consume it.
|
||
public func uiConfig() async throws -> UiConfig {
|
||
let (data, response) = try await perform(Endpoints.uiConfig())
|
||
try Self.requireOK(response)
|
||
guard let config = try? JSONDecoder().decode(UiConfig.self, from: data) else {
|
||
throw APIClientError.invalidResponseBody
|
||
}
|
||
return config
|
||
}
|
||
|
||
// MARK: - G (state-changing — Origin required, byte-equal)
|
||
|
||
/// `DELETE /live-sessions/:id` (src/server.ts:354-358). 403 = the Origin
|
||
/// guard rejected us (src/server.ts:332-339); 404 = session already gone.
|
||
public func killSession(id: UUID) async throws {
|
||
let (_, response) = try await perform(Endpoints.killSession(id: id))
|
||
switch response.statusCode {
|
||
case HTTPStatus.noContent:
|
||
return
|
||
case HTTPStatus.notFound:
|
||
throw APIClientError.sessionNotFound
|
||
case HTTPStatus.forbidden:
|
||
throw APIClientError.forbidden
|
||
default:
|
||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||
}
|
||
}
|
||
|
||
/// `POST /hook/decision` (src/server.ts:503-525) — resolve a held remote
|
||
/// approval with a single-use capability `token` (arrives via push payload
|
||
/// only; NEVER persist it, plan §5.3). Server limits: body ≤ 4 KB
|
||
/// (src/server.ts:503), ≤ 10 requests/min/IP (src/server.ts:72,504-508).
|
||
/// 403 → `.decisionRejected` (token expired / mismatched / already used).
|
||
public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws {
|
||
let route = try Endpoints.hookDecision(
|
||
sessionId: sessionId, decision: decision, token: token
|
||
)
|
||
let (_, response) = try await perform(route)
|
||
switch response.statusCode {
|
||
case HTTPStatus.noContent:
|
||
return
|
||
case HTTPStatus.forbidden:
|
||
throw APIClientError.decisionRejected
|
||
case HTTPStatus.tooManyRequests:
|
||
throw APIClientError.rateLimited
|
||
default:
|
||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||
}
|
||
}
|
||
|
||
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
|
||
|
||
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
|
||
guard let request = route.urlRequest(for: endpoint) else {
|
||
throw APIClientError.invalidRequest
|
||
}
|
||
return try await http.send(request)
|
||
}
|
||
|
||
/// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`.
|
||
static func requireOK(_ response: HTTPURLResponse) throws {
|
||
switch response.statusCode {
|
||
case HTTPStatus.ok:
|
||
return
|
||
case HTTPStatus.notFound:
|
||
throw APIClientError.sessionNotFound
|
||
default:
|
||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
|
||
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
|
||
}
|