diff --git a/Sources/DSKit/Auth/KeychainStore.swift b/Sources/DSKit/Auth/KeychainStore.swift new file mode 100644 index 0000000..45754e6 --- /dev/null +++ b/Sources/DSKit/Auth/KeychainStore.swift @@ -0,0 +1,71 @@ +import Foundation +import Security + +/// Access-token persistence. The scaffold/preview/unsigned-build path uses InMemoryTokenStore +/// (Keychain on an unsigned binary can prompt or return errSecMissingEntitlement); the signed app +/// uses KeychainStore. Only the ACCESS token is stored — refresh rides the HttpOnly cookie. +public protocol TokenPersistence: Sendable { + func read() -> String? + func save(_ token: String) throws + func delete() throws +} + +public final class InMemoryTokenStore: TokenPersistence, @unchecked Sendable { + private let lock = NSLock() + private var token: String? + public init() {} + public func read() -> String? { lock.withLock { token } } + public func save(_ token: String) throws { lock.withLock { self.token = token } } + public func delete() throws { lock.withLock { token = nil } } +} + +public struct KeychainStore: TokenPersistence, Sendable { + private let service: String + private let account: String + + public init(service: String = "net.hyungi.ds-app", account: String = "access_token") { + self.service = service + self.account = account + } + + public func read() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let token = String(data: data, encoding: .utf8) + else { return nil } + return token + } + + public func save(_ token: String) throws { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(base as CFDictionary) // upsert + var add = base + add[kSecValueData as String] = Data(token.utf8) + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { + throw DSError.transport(underlying: "keychain save failed (\(status))") + } + } + + public func delete() throws { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(base as CFDictionary) + } +} diff --git a/Sources/DSKit/Auth/TokenProvider.swift b/Sources/DSKit/Auth/TokenProvider.swift new file mode 100644 index 0000000..63fdeb1 --- /dev/null +++ b/Sources/DSKit/Auth/TokenProvider.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Concurrency-safe access-token holder with SINGLE-FLIGHT refresh: concurrent 401s must not each +/// fire `/auth/refresh`. The coalescing is via a stored `Task` handle (not a bool) — entrants await +/// the same in-flight task; it's cleared (success OR failure) when complete. Clearing too early lets a +/// follower re-refresh; never clearing means the next expiry won't refresh. +public actor TokenProvider { + private var cached: String? + private let persistence: TokenPersistence + private let refresh: @Sendable () async throws -> String + private var inFlightRefresh: Task? + + public init(persistence: TokenPersistence, refresh: @escaping @Sendable () async throws -> String) { + self.persistence = persistence + self.refresh = refresh + self.cached = persistence.read() + } + + public func current() -> String? { cached ?? persistence.read() } + + public func set(_ token: String) { + cached = token + try? persistence.save(token) + } + + public func clear() { + cached = nil + try? persistence.delete() + } + + public func refreshOnce() async throws -> String { + if let task = inFlightRefresh { + return try await task.value + } + let task = Task { try await self.refresh() } + inFlightRefresh = task + defer { inFlightRefresh = nil } + let token = try await task.value + cached = token + try? persistence.save(token) + return token + } +} diff --git a/Sources/DSKit/Endpoint.swift b/Sources/DSKit/Endpoint.swift new file mode 100644 index 0000000..f2c895b --- /dev/null +++ b/Sources/DSKit/Endpoint.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Single source of truth mapping each DSClient call to HTTP method + path + query + body. +/// Trailing slashes are significant (e.g. `documents/`, `search/`) and are preserved by building the +/// URL from the base string rather than appendingPathComponent (which strips them). +enum DSEndpoint { + case login(String, String, String?) + case me + case refresh + case logout + case documents(DocumentListQuery) + case document(Int) + case documentContent(Int) + case documentTree + case categoryCounts + case duplicates + case patchDocument(Int, DocumentUpdate) + case putContent(Int, String) + case deleteDocument(Int) + case search(String, SearchMode?, Int?, Bool?) + case ask(String, Int?, String?, Bool?) + case memos(MemoListQuery) + case memo(Int) + case createMemo(MemoCreate) + case patchMemo(Int, MemoUpdate) + case pinMemo(Int, Bool) + case archiveMemo(Int, Bool) + case toggleMemoTask(Int, Int, Bool) + case deleteMemo(Int) + case digest(String?, String?) + + var method: String { + switch self { + case .login, .refresh, .logout, .createMemo: return "POST" + case .patchDocument, .patchMemo, .pinMemo, .archiveMemo, .toggleMemoTask: return "PATCH" + case .putContent: return "PUT" + case .deleteDocument, .deleteMemo: return "DELETE" + default: return "GET" + } + } + + var path: String { + switch self { + case .login: return "auth/login" + case .me: return "auth/me" + case .refresh: return "auth/refresh" + case .logout: return "auth/logout" + case .documents: return "documents/" + case .document(let id): return "documents/\(id)" + case .documentContent(let id): return "documents/\(id)/content" + case .documentTree: return "documents/tree" + case .categoryCounts: return "documents/stats/category-counts" + case .duplicates: return "documents/duplicates" + case .patchDocument(let id, _): return "documents/\(id)" + case .putContent(let id, _): return "documents/\(id)/content" + case .deleteDocument(let id): return "documents/\(id)" + case .search: return "search/" + case .ask: return "search/ask" + case .memos: return "memos/" + case .memo(let id): return "memos/\(id)" + case .createMemo: return "memos/" + case .patchMemo(let id, _): return "memos/\(id)" + case .pinMemo(let id, _): return "memos/\(id)/pin" + case .archiveMemo(let id, _): return "memos/\(id)/archive" + case .toggleMemoTask(let id, let idx, _): return "memos/\(id)/tasks/\(idx)" + case .deleteMemo(let id): return "memos/\(id)" + case .digest: return "digest" + } + } + + /// Bearer header applies to everything except login/refresh (refresh rides the HttpOnly cookie). + var requiresBearer: Bool { + switch self { + case .login, .refresh: return false + default: return true + } + } + + var queryItems: [URLQueryItem] { + var items: [URLQueryItem] = [] + func add(_ name: String, _ value: String?) { if let value { items.append(URLQueryItem(name: name, value: value)) } } + switch self { + case .documents(let q): + add("page", String(q.page)); add("page_size", String(q.pageSize)) + add("domain", q.domain); add("sub_group", q.subGroup); add("source", q.source) + add("format", q.format); add("review_status", q.reviewStatus); add("category", q.category) + case .search(let qq, let mode, let page, let debug): + add("q", qq); add("mode", mode?.rawValue); add("page", page.map(String.init)); add("debug", debug.map(String.init)) + case .ask(let qq, let limit, let backend, let debug): + add("q", qq); add("limit", limit.map(String.init)); add("backend", backend); add("debug", debug.map(String.init)) + case .memos(let q): + add("page", String(q.page)); add("page_size", String(q.pageSize)) + add("pinned", q.pinned.map(String.init)); add("archived", q.archived.map(String.init)) + case .digest(let date, let country): + add("date", date); add("country", country) + default: + break + } + return items + } + + func httpBody(_ encoder: JSONEncoder) throws -> Data? { + switch self { + case .login(let u, let p, let totp): + return try encoder.encode(LoginBody(username: u, password: p, totpCode: totp)) + case .patchDocument(_, let update): + return try encoder.encode(update) + case .putContent(_, let content): + return try encoder.encode(ContentBody(content: content)) + case .createMemo(let create): + return try encoder.encode(create) + case .patchMemo(_, let update): + return try encoder.encode(update) + case .pinMemo(_, let pinned): + return try encoder.encode(PinnedBody(pinned: pinned)) + case .archiveMemo(_, let archived): + return try encoder.encode(ArchivedBody(archived: archived)) + case .toggleMemoTask(_, _, let checked): + return try encoder.encode(CheckedBody(checked: checked)) + default: + return nil + } + } +} + +private struct LoginBody: Encodable { + let username: String + let password: String + let totpCode: String? + enum CodingKeys: String, CodingKey { case username, password; case totpCode = "totp_code" } +} +private struct ContentBody: Encodable { let content: String } +private struct PinnedBody: Encodable { let pinned: Bool } +private struct ArchivedBody: Encodable { let archived: Bool } +private struct CheckedBody: Encodable { let checked: Bool } diff --git a/Sources/DSKit/LiveDSClient.swift b/Sources/DSKit/LiveDSClient.swift new file mode 100644 index 0000000..9af4531 --- /dev/null +++ b/Sources/DSKit/LiveDSClient.swift @@ -0,0 +1,135 @@ +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) } + + // 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(_ 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) } +} diff --git a/Tests/DSKitTests/ClientPlumbingTests.swift b/Tests/DSKitTests/ClientPlumbingTests.swift new file mode 100644 index 0000000..61693a6 --- /dev/null +++ b/Tests/DSKitTests/ClientPlumbingTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import DSKit + +/// Offline tests for the live-client plumbing (the live HTTP path itself needs a real backend, FU-A). +final class ClientPlumbingTests: XCTestCase { + + func testEndpointPathsAndMethods() { + XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).path, "documents/") + XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).method, "GET") + XCTAssertEqual(DSEndpoint.document(4912).path, "documents/4912") + XCTAssertEqual(DSEndpoint.documentTree.path, "documents/tree") + XCTAssertEqual(DSEndpoint.ask("q", nil, nil, nil).path, "search/ask") + XCTAssertEqual(DSEndpoint.pinMemo(5, true).method, "PATCH") + XCTAssertEqual(DSEndpoint.pinMemo(5, true).path, "memos/5/pin") + XCTAssertEqual(DSEndpoint.toggleMemoTask(5, 2, true).path, "memos/5/tasks/2") + XCTAssertEqual(DSEndpoint.putContent(7, "x").method, "PUT") + XCTAssertEqual(DSEndpoint.deleteMemo(9).method, "DELETE") + } + + func testEndpointBearerRule() { + XCTAssertFalse(DSEndpoint.login("u", "p", nil).requiresBearer) + XCTAssertFalse(DSEndpoint.refresh.requiresBearer) + XCTAssertTrue(DSEndpoint.me.requiresBearer) + XCTAssertTrue(DSEndpoint.documents(DocumentListQuery()).requiresBearer) + } + + func testEndpointQueryItems() { + let items = DSEndpoint.ask("충격시험", 5, "gemma-macmini", nil).queryItems + let dict = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value) }) + XCTAssertEqual(dict["q"], "충격시험") + XCTAssertEqual(dict["backend"], "gemma-macmini") + XCTAssertEqual(dict["limit"], "5") + XCTAssertNil(dict["debug"]) // nil params are skipped, not sent as "nil" + } + + func testEndpointBodies() throws { + let encoder = DSEncoder.make() + let pinBody = try DSEndpoint.pinMemo(5, true).httpBody(encoder) + XCTAssertEqual(String(data: pinBody ?? Data(), encoding: .utf8), #"{"pinned":true}"#) + let loginBody = try XCTUnwrap(DSEndpoint.login("hyungi", "pw", nil).httpBody(encoder)) + let s = String(data: loginBody, encoding: .utf8) ?? "" + XCTAssertTrue(s.contains("\"username\":\"hyungi\"")) + XCTAssertFalse(s.contains("totp_code")) // nil optional omitted + XCTAssertNil(try DSEndpoint.me.httpBody(encoder)) // GET has no body + } + + /// Single-flight refresh: N concurrent refreshOnce() calls must fire the refresh closure exactly once. + func testSingleFlightRefresh() async throws { + let counter = CallCounter() + let provider = TokenProvider(persistence: InMemoryTokenStore()) { + try? await Task.sleep(nanoseconds: 50_000_000) + let n = await counter.inc() + return "token-\(n)" + } + let results = await withTaskGroup(of: String.self) { group -> [String] in + for _ in 0..<8 { group.addTask { (try? await provider.refreshOnce()) ?? "ERR" } } + var collected: [String] = [] + for await value in group { collected.append(value) } + return collected + } + let calls = await counter.value + XCTAssertEqual(calls, 1, "refresh closure must run exactly once under concurrency") + XCTAssertTrue(results.allSatisfy { $0 == "token-1" }) + } + + func testTokenProviderCacheAndPersistence() async { + let store = InMemoryTokenStore() + let provider = TokenProvider(persistence: store) { "x" } + await provider.set("abc") + let current = await provider.current() + XCTAssertEqual(current, "abc") + XCTAssertEqual(store.read(), "abc") + await provider.clear() + let cleared = await provider.current() + XCTAssertNil(cleared) + } +} + +actor CallCounter { + var value = 0 + func inc() -> Int { value += 1; return value } +}