feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
@@ -3,6 +3,7 @@ import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production dependency graph (composition root). One immutable
|
||||
@@ -12,12 +13,15 @@ import WireProtocol
|
||||
/// Assembly security audit (task 安全注, verified at this single point):
|
||||
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
|
||||
/// SessionListViewModel's client, probe's kill round-trip inside
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own
|
||||
/// EXCEPT the access-token `Cookie` (C1) — see its type doc for why that one
|
||||
/// belongs at the transport and cannot bypass the Origin rule.
|
||||
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
|
||||
/// HTTP: APIClient's route builder) — nothing here hand-assembles one.
|
||||
/// - Secrets: hosts in `KeychainHostStore`
|
||||
/// - Secrets: hosts (and their access tokens) in `KeychainHostStore`
|
||||
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId.
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId. A token
|
||||
/// never reaches a log line, a URL query, or an error description.
|
||||
/// - No debug ATS overrides exist (project.yml declares NO
|
||||
/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR
|
||||
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
|
||||
@@ -27,15 +31,38 @@ struct AppEnvironment: Sendable {
|
||||
let hostStore: any HostStore
|
||||
let lastSessionStore: any LastSessionStore
|
||||
let http: any HTTPTransport
|
||||
/// The WS transport used when no per-host factory is injected: it carries
|
||||
/// NO access token, so it is correct only for a host that has none. Every
|
||||
/// terminal goes through `makeTermTransport(for:)`, which prefers
|
||||
/// `termTransportFactory` — production always installs one.
|
||||
let termTransport: any TermTransport
|
||||
/// Injected into `PairingViewModel` — production is `runPairingProbe`
|
||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12).
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12), plus the
|
||||
/// `POST /auth` token probe and the host-removal side effect (C1).
|
||||
let probe: PairingViewModel.Probe
|
||||
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
||||
/// `var` + default so the memberwise init stays source-compatible for
|
||||
/// pre-P1 call sites while tests can inject an in-memory fake.
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
/// E1 · Builds the WS transport for ONE host, so the upgrade can carry that
|
||||
/// host's access token (§1.1). nil ⇒ `termTransport` for every host (the
|
||||
/// App-layer tests' `FakeTransport`); production installs the real factory.
|
||||
var termTransportFactory: (@Sendable (HostRegistry.Host) -> any TermTransport)?
|
||||
|
||||
/// The WS transport a terminal on `host` must dial.
|
||||
///
|
||||
/// E1 (HIGH) · This exists because the access-token cookie is PER HOST: the
|
||||
/// app previously shared one transport whose token was resolved
|
||||
/// host-independently, which returned nil for the mixed fleet the token
|
||||
/// feature is for (a tokened tunnel host beside an open LAN host) — the
|
||||
/// upgrade omitted the cookie, the server 401'd, and that failure is
|
||||
/// terminal with no in-app remedy. Android never had the bug because
|
||||
/// `OkHttpTermTransport` resolves `tokens.tokenFor(endpoint)`; this is the
|
||||
/// same rule.
|
||||
func makeTermTransport(for host: HostRegistry.Host) -> any TermTransport {
|
||||
termTransportFactory?(host) ?? termTransport
|
||||
}
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||
@@ -50,24 +77,181 @@ struct AppEnvironment: Sendable {
|
||||
let identityProvider: @Sendable () -> ClientIdentity? = {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
}
|
||||
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
|
||||
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
|
||||
let hostStore = KeychainHostStore()
|
||||
// C1 · ONE access-token source for the whole app, reading the same
|
||||
// Keychain records the pairing flow writes. Both transports consult it
|
||||
// per request/connect, so a token typed mid-run applies immediately —
|
||||
// no relaunch, and no token snapshot captured at composition.
|
||||
let tokens = AccessTokenSource(store: hostStore)
|
||||
let http = URLSessionHTTPTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
|
||||
)
|
||||
// E1 · ONE transport per host: the upgrade's Cookie is this host's token
|
||||
// and never another's. The provider is re-consulted on every connect
|
||||
// (rotation applies on the next reconnect, no relaunch).
|
||||
let termTransportFactory: @Sendable (HostRegistry.Host) -> any TermTransport = { host in
|
||||
URLSessionTermTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenProvider: { tokens.wsToken(for: host) }
|
||||
)
|
||||
}
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
hostStore: hostStore,
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
http: http,
|
||||
termTransport: termTransport,
|
||||
probe: { endpoint in
|
||||
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
|
||||
}
|
||||
termTransport: URLSessionTermTransport(identityProvider: identityProvider),
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, token in
|
||||
// The candidate token has to reach BOTH probe legs. HTTP
|
||||
// takes it as a parameter (APIClient stamps the Cookie); the
|
||||
// WS leg gets a PROBE-SCOPED transport carrying exactly this
|
||||
// candidate — the frozen `TermTransport.connect` has no
|
||||
// credential parameter, and the host is not paired yet, so
|
||||
// the shared transport could not resolve it from the store.
|
||||
let ws = URLSessionTermTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenProvider: { token?.rawValue }
|
||||
)
|
||||
return await runPairingProbe(
|
||||
endpoint: endpoint, http: http, ws: ws,
|
||||
accessToken: token?.rawValue
|
||||
)
|
||||
},
|
||||
validateToken: { endpoint, token in
|
||||
await probeAccessToken(endpoint: endpoint, http: http, token: token)
|
||||
},
|
||||
unregisterPush: { host in
|
||||
await PushHostDeregistration.run(for: host)
|
||||
}
|
||||
),
|
||||
termTransportFactory: termTransportFactory
|
||||
)
|
||||
}
|
||||
|
||||
/// Away-digest source for a session engine: wraps `APIClient.events` per
|
||||
/// host (the engine never holds an HTTP client — plan §3.2).
|
||||
/// host (the engine never holds an HTTP client — plan §3.2). The token rides
|
||||
/// along at the transport, so this stays token-free.
|
||||
func makeEventsSource(endpoint: HostEndpoint)
|
||||
-> @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return { id in try await client.events(id: id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Access-token source (C1 · ios-completion §1.1)
|
||||
|
||||
/// The app's single read path for per-host access tokens.
|
||||
///
|
||||
/// Reads the `HostStore` (Keychain) on demand rather than caching a value at
|
||||
/// composition: pairing writes a token into the same records, and a snapshot
|
||||
/// taken at launch would make "type the token, then connect" require a relaunch.
|
||||
/// A Keychain read is a sub-millisecond `SecItemCopyMatching` + JSON decode, so
|
||||
/// per-request resolution is affordable at the app's polling cadence.
|
||||
///
|
||||
/// SECURITY (§5.3): the token is returned as a `String` for exactly one use — a
|
||||
/// `Cookie` header value. It is never logged, never interpolated into a URL, and
|
||||
/// the values it returns are `AccessToken`-validated (§1.1 charset), which is
|
||||
/// what makes header injection impossible.
|
||||
final class AccessTokenSource: @unchecked Sendable {
|
||||
private let store: any HostStore
|
||||
private let lock = NSLock()
|
||||
/// Origin → token, rebuilt from the store on every `token(forOrigin:)`.
|
||||
/// See `wsToken(for:)`. Guarded by `lock`; `nonisolated` state is the reason
|
||||
/// this type is `@unchecked Sendable` (an actor cannot serve the WS
|
||||
/// provider, which is synchronous).
|
||||
private var snapshot: [String: String] = [:]
|
||||
|
||||
init(store: any HostStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// This host's token, matched by ORIGIN (`HostEndpoint.originHeader` — the
|
||||
/// single derivation point, plan §5.1), or nil when the host has none / is
|
||||
/// unknown / the store read fails. A failed read degrades to "no token"
|
||||
/// rather than throwing: the request then gets the server's 401, which the
|
||||
/// UI already explains, instead of the app breaking outright.
|
||||
func token(forOrigin origin: String) async -> String? {
|
||||
let hosts = (try? await store.loadAll()) ?? []
|
||||
updateSnapshot(from: hosts)
|
||||
return hosts.first { $0.endpoint.originHeader == origin }?.accessToken?.rawValue
|
||||
}
|
||||
|
||||
/// The token a WS upgrade to `host` must present (§1.1) — for the one caller
|
||||
/// that can be given neither an `await` nor a parameter: SessionCore's
|
||||
/// frozen `tokenProvider: @Sendable () -> String?`, which the per-host
|
||||
/// transport closes over (`AppEnvironment.makeTermTransport(for:)`).
|
||||
///
|
||||
/// Two reads, in this order, and both are THIS host's own value — no answer
|
||||
/// derived from any other host can ever be returned (§5.3):
|
||||
/// 1. the latest value the store returned for this origin (so a token
|
||||
/// rotated mid-run applies on the next connect, no relaunch);
|
||||
/// 2. the `host` record itself, which the coordinator read from the same
|
||||
/// Keychain store when the session was opened — this is what makes the
|
||||
/// FIRST connect correct even before any HTTP request has warmed the
|
||||
/// snapshot (a cold-start terminal must not depend on that race), and
|
||||
/// what keeps a failed store read from silently dropping the cookie.
|
||||
///
|
||||
/// Consequence of (2), stated plainly: a token DELETED from the store keeps
|
||||
/// riding until this terminal is reopened. That is still this host's own
|
||||
/// value — the server just answers 401 if it is no longer valid — and it is
|
||||
/// the price of never dropping the cookie on a cold connect.
|
||||
func wsToken(for host: HostRegistry.Host) -> String? {
|
||||
let origin = host.endpoint.originHeader
|
||||
return lock.withLock { snapshot[origin] } ?? host.accessToken?.rawValue
|
||||
}
|
||||
|
||||
private func updateSnapshot(from hosts: [HostRegistry.Host]) {
|
||||
let resolved = hosts.reduce(into: [String: String]()) { map, host in
|
||||
guard let token = host.accessToken?.rawValue else { return }
|
||||
map[host.endpoint.originHeader] = token
|
||||
}
|
||||
lock.withLock { snapshot = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /auth` (§1.1) as the pairing flow needs it: the four documented
|
||||
/// outcomes, or a typed transport failure. Lives here (composition root) because
|
||||
/// it is the seam between `APIClient`'s throwing API and the VM's total switch.
|
||||
private func probeAccessToken(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
token: AccessToken
|
||||
) async -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
|
||||
do {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return .success(try await client.probeAccessToken(token.rawValue))
|
||||
} catch APIClientError.malformedToken {
|
||||
return .failure(.malformed)
|
||||
} catch let apiError as APIClientError {
|
||||
// `.message` is user-facing copy about the STATUS, never about the token.
|
||||
return .failure(.unreachable(apiError.message))
|
||||
} catch {
|
||||
return .failure(.unreachable((error as NSError).localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host removal → APNs de-registration (C1 · fixes the dead hook)
|
||||
|
||||
/// Bridge from "the user removed a host" to `PushRegistrar.handleHostRemoved`.
|
||||
///
|
||||
/// The registrar owns the in-memory APNs device token (deliberately never
|
||||
/// persisted — iOS re-delivers it on every registration), so the de-registration
|
||||
/// can only be done by the LIVE instance. That instance is created and held by
|
||||
/// `PushAppDelegate` (`makePushWiring`), which the system builds after this
|
||||
/// composition root — hence the resolution happens at CALL time through
|
||||
/// `UIApplication.shared.delegate`, the platform's own object, rather than any
|
||||
/// singleton of ours.
|
||||
///
|
||||
/// nil delegate / nil registrar (unit tests, XCUITest, a build where push was
|
||||
/// never wired) is a deliberate no-op: removal must never depend on push.
|
||||
enum PushHostDeregistration {
|
||||
@MainActor
|
||||
static func run(for host: HostRegistry.Host) async {
|
||||
guard let delegate = UIApplication.shared.delegate as? PushAppDelegate,
|
||||
let registrar = delegate.registrar else {
|
||||
return
|
||||
}
|
||||
await registrar.handleHostRemoved(host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,28 @@ import SwiftUI
|
||||
/// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它
|
||||
/// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层
|
||||
/// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。
|
||||
/// T-iOS-34 · 主题(`ThemeStore`)就挂在这一层:它是 `@main` 唯一的实例化点,
|
||||
/// 所以「一个 store、一次注入、一处 `preferredColorScheme`」在这里成立,
|
||||
/// 不会出现两份主题真值。
|
||||
struct RootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
/// 主题设置的单一真值(`UserDefaults` 持久化)。`@State` ⇒ 与根视图同寿命。
|
||||
@State private var themeStore = ThemeStore()
|
||||
|
||||
var body: some View {
|
||||
AdaptiveRootView(coordinator: coordinator)
|
||||
// DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的
|
||||
// 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。
|
||||
.tint(DS.Palette.accent)
|
||||
// 深色优先 —— 对齐桌面 web 主题(DEFAULT_SETTINGS.theme = 'dark')。
|
||||
// 琥珀金强调色是为暖深色背景设计的(桌面 --bg #100F0D);浅色底上
|
||||
// 金字对比不足。深色下 accent/status 全部高对比,且与桌面观感一致。
|
||||
.preferredColorScheme(.dark)
|
||||
// 主题:默认仍是深色(对齐桌面 web `DEFAULT_SETTINGS.theme='dark'`,
|
||||
// 也等于此前硬锁 `.preferredColorScheme(.dark)` 的观感 ⇒ 升级零变化)。
|
||||
// 「跟随系统」时 `colorScheme` 为 nil = 不表态,交给 iOS。
|
||||
// 曾经硬锁深色的理由是「金色在浅底对比不足」;那是 token 问题而不是
|
||||
// 主题问题,已在 `Tokens.swift` 逐色补了浅色值(WCAG 3:1 起)。
|
||||
.preferredColorScheme(themeStore.theme.colorScheme)
|
||||
// 设置页与终端预览都从环境取同一个 store(不传参穿透整棵树)。
|
||||
.environment(themeStore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +204,16 @@ struct CertRenewalWarningBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects toolbar item (shared stack + split, DRY)
|
||||
// MARK: - Root leading toolbar (shared stack + split, DRY)
|
||||
|
||||
/// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同
|
||||
/// `presentProjects` 动作、同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
/// 会话列表的 leading 工具栏组 —— stack 与 split 共用(同 disabled 条件、同动作、
|
||||
/// 同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
///
|
||||
/// 组里现在有两项:「项目」(原有)+「设置」(T-iOS-34 主题入口)。
|
||||
/// **命名保留** `ProjectsToolbarItem`:`SplitRootView.swift` 按此名引用它,而该
|
||||
/// 文件不在 C4 的 Owns 里;把设置项加进这个共用组,是让 iPhone(stack) 与
|
||||
/// iPad(split) 同时拿到入口且不越界编辑的唯一办法。改名 →
|
||||
/// `RootLeadingToolbar` 留给拥有 `SplitRootView.swift` 的后续任务(纯机械重命名)。
|
||||
struct ProjectsToolbarItem: ToolbarContent {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
@@ -211,6 +227,41 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
.disabled(coordinator.sessionList.activeHost == nil)
|
||||
.accessibilityIdentifier("sessions.projectsButton")
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SettingsToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置入口(齿轮)+ 它自己的 sheet。自持 `@State` 表示态,所以不需要在
|
||||
/// `AppCoordinator` 里再开一个 `isSettingsPresented` —— 设置页与会话生命周期
|
||||
/// 完全无关,没有理由进协调器的状态机。
|
||||
///
|
||||
/// `ThemeStore` 从环境取,且是**可选**读取:若某个预览/测试没注入 store,
|
||||
/// 这里就不渲染齿轮,而不是崩(环境非可选读取在缺注入时会 crash)。
|
||||
struct SettingsToolbarButton: View {
|
||||
@Environment(ThemeStore.self) private var themeStore: ThemeStore?
|
||||
@State private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
if let themeStore {
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
Label(RootCopy.settings, systemImage: "gearshape")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.settingsButton")
|
||||
.sheet(isPresented: $isPresented) {
|
||||
NavigationStack {
|
||||
SettingsScreen(themeStore: themeStore)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(RootCopy.done) { isPresented = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +269,7 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
static let settings = "设置"
|
||||
static let done = "完成"
|
||||
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
|
||||
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
|
||||
|
||||
@@ -197,7 +197,10 @@ final class TerminalSessionController: Identifiable {
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) -> Stack {
|
||||
let engine = SessionEngine(
|
||||
transport: environment.termTransport,
|
||||
// E1 · The transport is built for THIS host, so the WS upgrade
|
||||
// carries this host's access token (§1.1) — a shared, host-blind
|
||||
// transport could not resolve a token for a mixed fleet at all.
|
||||
transport: environment.makeTermTransport(for: host),
|
||||
clock: ContinuousClock(),
|
||||
endpoint: host.endpoint,
|
||||
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
||||
|
||||
@@ -4,12 +4,32 @@ import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
||||
/// the URLSession wrapper for the production side; no package owns it, so the
|
||||
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
|
||||
/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this
|
||||
/// type only performs the exchange. Adding ANY header/URL logic here would
|
||||
/// bypass that single audited point (review CRITICAL).
|
||||
/// assembly layer provides it). Deliberately logic-free about ROUTING:
|
||||
/// `APIClient` builds every request — including the Origin-iff-G rule (plan §3.4
|
||||
/// 铁律) — and this type only performs the exchange. Adding URL or Origin logic
|
||||
/// here would bypass that single audited point (review CRITICAL).
|
||||
///
|
||||
/// C1 · The ONE exception is the access-token `Cookie` (ios-completion §1.1),
|
||||
/// and it is deliberate:
|
||||
/// - the token is **unconditional** — every request, RO and G alike — so it has
|
||||
/// no interaction with the conditional Origin rule it must never replace;
|
||||
/// - it is **per host**, resolved from the request's own origin, whereas
|
||||
/// `APIClient` instances are built ad hoc all over the App layer (list poll,
|
||||
/// previews, projects, diffs, push registration) with no access to the
|
||||
/// Keychain. Stamping at the shared transport is what makes "every request
|
||||
/// carries the token" true by construction instead of per-call-site;
|
||||
/// - it is resolved LAZILY per request from `tokenForOrigin` — the same
|
||||
/// no-relaunch pattern as the mTLS `identityProvider` below — so a token typed
|
||||
/// mid-run applies to the very next request.
|
||||
///
|
||||
/// A request that ALREADY carries a `Cookie` is left untouched: the pairing probe
|
||||
/// authenticates with a *candidate* token that is not in the store yet, and
|
||||
/// overwriting it here would silently unauthenticate the probe.
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
/// Resolves the stored access token for a request's origin (see type doc).
|
||||
/// `@Sendable`, async: the store is an actor over the Keychain.
|
||||
private let tokenForOrigin: @Sendable (String) async -> String?
|
||||
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||
/// until invalidated, but this ephemeral session is never explicitly
|
||||
/// invalidated, so holding it here documents the ownership and keeps the
|
||||
@@ -18,6 +38,7 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
/// No token source ⇒ no `Cookie` is ever added (LAN zero-config default).
|
||||
init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
@@ -37,16 +58,20 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||
/// transport and the privacy-shade posture.
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
init(
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenForOrigin: @escaping @Sendable (String) async -> String? = { _ in nil }
|
||||
) {
|
||||
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||
self.tlsDelegate = delegate
|
||||
self.tokenForOrigin = tokenForOrigin
|
||||
self.session = URLSession(
|
||||
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||
)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
let (data, response) = try await session.data(for: await authenticated(request))
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
// http(s)-only endpoints (HostEndpoint validates) always produce
|
||||
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
||||
@@ -54,6 +79,50 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
}
|
||||
return (data, httpResponse)
|
||||
}
|
||||
|
||||
/// Stamp `Cookie: webterm_auth=<t>` for the request's own origin (C1).
|
||||
/// Immutable style: returns a NEW request, never mutates the caller's.
|
||||
///
|
||||
/// `internal`, not private: this is the whole behaviour `send` adds, and it
|
||||
/// is testable with zero network — the alternative would be leaving the one
|
||||
/// line that carries a credential unverified.
|
||||
func authenticated(_ request: URLRequest) async -> URLRequest {
|
||||
guard let origin = AccessTokenCookie.origin(of: request),
|
||||
let token = await tokenForOrigin(origin) else {
|
||||
return request
|
||||
}
|
||||
return AccessTokenCookie.stamped(request, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single point where an access token becomes a request header (App layer).
|
||||
/// Pure and static so the rules are unit-testable without any network.
|
||||
enum AccessTokenCookie {
|
||||
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30) — the same literal the packages
|
||||
/// pin; both of their `AuthCookie` helpers are package-internal, so the App
|
||||
/// layer needs its own single definition rather than a fourth ad-hoc string.
|
||||
static let name = "webterm_auth"
|
||||
static let header = "Cookie"
|
||||
|
||||
/// The request's origin in `HostEndpoint.originHeader` form, via the frozen
|
||||
/// single derivation point (default ports omitted, scheme/host lowercased) —
|
||||
/// never hand-assembled here. nil for a non-http(s) or host-less URL.
|
||||
static func origin(of request: URLRequest) -> String? {
|
||||
guard let url = request.url, let endpoint = HostEndpoint(baseURL: url) else {
|
||||
return nil
|
||||
}
|
||||
return endpoint.originHeader
|
||||
}
|
||||
|
||||
/// A copy of `request` carrying the token cookie — unless it already carries
|
||||
/// a `Cookie`, in which case the existing one wins (the pairing probe's
|
||||
/// candidate token must not be overwritten by the stored one).
|
||||
static func stamped(_ request: URLRequest, token: String) -> URLRequest {
|
||||
guard request.value(forHTTPHeaderField: header) == nil else { return request }
|
||||
var authenticated = request
|
||||
authenticated.setValue("\(name)=\(token)", forHTTPHeaderField: header)
|
||||
return authenticated
|
||||
}
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||
|
||||
Reference in New Issue
Block a user