Files
hyungi_document_server/clients/ds-app/Sources/DSKit/LiveDSClient.swift
hyungi f1dc2e1a8d feat(ds-app): 본 서버(GPU DS) 라이브 결선 — 앱 기본을 오프라인 스캐폴드에서 라이브로 전환
- AppModel: AuthPhase 상태기계(checking/loggedOut/ready) + live() 팩토리
  (LiveDSClient + realRouter, ask 토큰 = TokenProvider 단일 소스) + bootstrap
  (refresh 쿠키 무로그인 복귀, single-shot, 취소 시 재시도 복원) + login(TOTP
  개행·공백 정규화) + 사용 중 세션 만료 시 loggedOut 강등 + 401 회전 후
  다운로드 ?token= 사본 재동기화(guarded 깔때기)
- LoginView 신규(기능 셸, 서버 host 표시, 서버 detail 메시지 노출)
- RootView: 인증 게이트 + errorText 하단 배너(no-silent-fallback 가시화)
- DSApp: 기본 .live(publicTLS=document.hyungi.net/api), DSAPP_FIXTURE=1 /
  DSAPP_DS_URL env 스위치(파싱 실패 = fail-loud, prod silent fallback 금지)
- LiveDSClient.currentAccessToken() — realRouter ask 토큰 closure 용
- AppFeatureTests 신규 10건(인증 상태기계·single-shot·transport 사유·totp)

검증: swift test 82/82 green + xcodebuild .app BUILD SUCCEEDED + 라이브
negative-path(/auth/login 401·/auth/refresh 401, 본 서버 양 경로 도달).
3-렌즈 어드버서리얼 리뷰 반영(재진입 가드/transport 구분/env fail-loud/토큰
사본 동기화/만료 강등). Sources/AI 무수정(시그니처 동결 준수).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:55:59 +00:00

139 lines
8.3 KiB
Swift

import Foundation
/// Real-network DSClient (FU-A). Same DTOs/decoder as FixtureDSClient, so swapping it in is
/// behavior-identical except for I/O. URLSession with shared cookie storage so the HttpOnly refresh
/// cookie is replayed on `/auth/refresh`. A 401 on a bearer request triggers a single-flight refresh
/// + ONE retry (never on login/refresh/logout, to avoid loops).
///
/// Immutable stored props (URLSession/decoder shared but never mutated) @unchecked Sendable.
public final class LiveDSClient: DSClient, @unchecked Sendable {
private let base: DSBaseURL
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private let tokens: TokenProvider
public init(base: DSBaseURL = .publicTLS, persistence: TokenPersistence = InMemoryTokenStore()) {
let baseURL = base
let config = URLSessionConfiguration.default
config.httpCookieStorage = .shared
config.httpShouldSetCookies = true
let session = URLSession(configuration: config)
let decoder = DSDecoder.make()
self.base = baseURL
self.session = session
self.decoder = decoder
self.encoder = DSEncoder.make()
// Refresh closure captures only Sendable values (no self): raw POST /auth/refresh via cookie.
self.tokens = TokenProvider(persistence: persistence) {
var request = URLRequest(url: baseURL.url.appendingPathComponent("auth/refresh"))
request.httpMethod = "POST"
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw DSError.unauthorized(message: "refresh failed")
}
return try decoder.decode(AccessTokenResponse.self, from: data).accessToken
}
}
public func setAccessToken(_ token: String) async { await tokens.set(token) }
/// realRouter ask closure TokenProvider (401 refresh ).
public func currentAccessToken() async -> String? { await tokens.current() }
// MARK: - Request building / sending
private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest {
// Build URL from the base string to preserve trailing slashes; URLComponents percent-encodes.
let raw = base.url.absoluteString + "/" + endpoint.path
guard var comps = URLComponents(string: raw) else {
throw DSError.transport(underlying: "bad URL \(raw)")
}
if !endpoint.queryItems.isEmpty { comps.queryItems = endpoint.queryItems }
guard let url = comps.url else { throw DSError.transport(underlying: "bad URL components") }
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
if endpoint.requiresBearer, let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body = try endpoint.httpBody(encoder) {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func perform(_ endpoint: DSEndpoint) async throws -> Data {
let request = try makeRequest(endpoint, token: await tokens.current())
let (data, response) = try await dataOrTransport(request)
guard let http = response as? HTTPURLResponse else {
throw DSError.transport(underlying: "no HTTP response")
}
if http.statusCode == 401, endpoint.requiresBearer {
// Single-flight refresh + one retry.
let newToken = try await tokens.refreshOnce()
let retry = try makeRequest(endpoint, token: newToken)
let (data2, response2) = try await dataOrTransport(retry)
guard let http2 = response2 as? HTTPURLResponse else {
throw DSError.transport(underlying: "no HTTP response")
}
guard (200..<300).contains(http2.statusCode) else { throw DSError.from(status: http2.statusCode, data: data2) }
return data2
}
guard (200..<300).contains(http.statusCode) else { throw DSError.from(status: http.statusCode, data: data) }
return data
}
private func dataOrTransport(_ request: URLRequest) async throws -> (Data, URLResponse) {
do { return try await session.data(for: request) }
catch { throw DSError.transport(underlying: "\(error)") }
}
private func send<T: Decodable>(_ endpoint: DSEndpoint, as type: T.Type) async throws -> T {
let data = try await perform(endpoint)
do { return try decoder.decode(T.self, from: data) }
catch { throw DSError.decoding("\(endpoint.path): \(error)") }
}
private func sendVoid(_ endpoint: DSEndpoint) async throws { _ = try await perform(endpoint) }
// MARK: - DSClient
public func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse {
let token: AccessTokenResponse = try await send(.login(username, password, totpCode), as: AccessTokenResponse.self)
await tokens.set(token.accessToken)
return token
}
public func me() async throws -> UserResponse { try await send(.me, as: UserResponse.self) }
public func refresh() async throws -> AccessTokenResponse {
let token: AccessTokenResponse = try await send(.refresh, as: AccessTokenResponse.self)
await tokens.set(token.accessToken)
return token
}
public func logout() async throws { try await sendVoid(.logout); await tokens.clear() }
public func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try await send(.documents(query), as: DocumentListResponse.self) }
public func document(id: Int) async throws -> DocumentDetailResponse { try await send(.document(id), as: DocumentDetailResponse.self) }
public func documentContent(id: Int) async throws -> DocumentContentResponse { try await send(.documentContent(id), as: DocumentContentResponse.self) }
public func documentTree() async throws -> [DomainTreeNode] { try await send(.documentTree, as: [DomainTreeNode].self) }
public func categoryCounts() async throws -> CategoryCounts { try await send(.categoryCounts, as: CategoryCounts.self) }
public func duplicates() async throws -> DuplicatesResponse { try await send(.duplicates, as: DuplicatesResponse.self) }
public func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await send(.patchDocument(id, update), as: DocumentResponse.self) }
public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) }
public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) }
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) }
public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) }
public func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await send(.memos(query), as: MemoListResponse.self) }
public func memo(id: Int) async throws -> MemoResponse { try await send(.memo(id), as: MemoResponse.self) }
public func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await send(.createMemo(create), as: MemoResponse.self) }
public func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await send(.patchMemo(id, update), as: MemoResponse.self) }
public func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await send(.pinMemo(id, pinned), as: MemoResponse.self) }
public func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await send(.archiveMemo(id, archived), as: MemoResponse.self) }
public func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await send(.toggleMemoTask(id, taskIndex, checked), as: MemoResponse.self) }
public func deleteMemo(id: Int) async throws { try await sendVoid(.deleteMemo(id)) }
public func digest(date: String?, country: String?) async throws -> DigestResponse { try await send(.digest(date, country), as: DigestResponse.self) }
}