From e3ad8650c6575def2b2e302de8ef8e9b0ec9607a Mon Sep 17 00:00:00 2001 From: psmattas Date: Tue, 18 Aug 2026 14:17:41 +0100 Subject: [PATCH] feat(outlinekit): add apiKeys.list and installation.info plumbing Read-only for now, per instruction to defer key creation/revocation. Both confirmed against live network captures. --- .../Caching/CachingOutlineAPIClient.swift | 8 +++ .../OutlineKit/Core/OutlineAPIClient.swift | 7 +++ .../OutlineKit/LiveOutlineAPIClient.swift | 8 +++ .../OutlineKit/Models/OutlineAPIKey.swift | 33 +++++++++++ .../Models/OutlineInstallationInfo.swift | 11 ++++ .../Requests/ListApiKeysRequest.swift | 14 +++++ .../CachingOutlineAPIClientTests.swift | 2 + .../LiveOutlineAPIClientTests.swift | 57 +++++++++++++++++++ 8 files changed, 140 insertions(+) create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index e745355..116be67 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -375,6 +375,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.unsubscribeFromNotifications(eventType: eventType) } + public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { + try await live.listApiKeys(request) + } + + public func installationInfo() async throws -> OutlineInstallationInfo { + try await live.installationInfo() + } + // MARK: - Sync management (Settings surface) public func pendingOperations() async -> [PendingOperationSummary] { diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 2130431..56d1f29 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -94,4 +94,11 @@ public protocol OutlineAPIClient: Sendable { /// `nil` targets every notification event. See `NotificationEventType`. func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser + + /// Settings → API & Access. Creation/revocation aren't wrapped yet — + /// read-only for this pass. + func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] + + /// Settings → Installation. Self-hosted server version info. + func installationInfo() async throws -> OutlineInstallationInfo } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 7af9df1..48ec0fa 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -310,6 +310,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue)) } + public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { + try await post("apiKeys.list", body: request) + } + + public func installationInfo() async throws -> OutlineInstallationInfo { + try await post("installation.info", body: EmptyParams()) + } + private func post(_ path: String, body: Body) async throws -> Response { guard let token = try? tokenStore.token() else { throw OutlineAPIError.tokenUnavailable diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift new file mode 100644 index 0000000..85593f2 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A personal API key (Settings → API & Access). Only the last 4 characters +/// of the actual token are ever returned by the server — there's no way to +/// see a full key again after creation, matching every other API-key UI +/// convention. +public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let last4: String? + public let scope: [String]? + public let createdAt: Date + public let expiresAt: Date? + public let lastActiveAt: Date? + + public init( + id: String, + name: String, + last4: String? = nil, + scope: [String]? = nil, + createdAt: Date, + expiresAt: Date? = nil, + lastActiveAt: Date? = nil + ) { + self.id = id + self.name = name + self.last4 = last4 + self.scope = scope + self.createdAt = createdAt + self.expiresAt = expiresAt + self.lastActiveAt = lastActiveAt + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift new file mode 100644 index 0000000..7980e2a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift @@ -0,0 +1,11 @@ +import Foundation + +/// `installation.info` — the self-hosted server's own version, confirmed +/// live. `policies` (a separate top-level array alongside `data` in the raw +/// response) isn't modeled here — not used by this app's Installation +/// settings page. +public struct OutlineInstallationInfo: Decodable, Sendable { + public let version: String + public let latestVersion: String + public let versionsBehind: Int +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift new file mode 100644 index 0000000..239fedd --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift @@ -0,0 +1,14 @@ +import Foundation + +/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat +/// offset/limit convention (e.g. `ListSharesRequest`), not the nested +/// `pagination` object that only appears in list *responses*. +public struct ListApiKeysRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + + public init(offset: Int = 0, limit: Int = 25) { + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 0b685a0..c4be353 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -99,6 +99,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func deleteAccount() async throws { throw NotStubbed() } func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() } func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() } + func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() } + func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() } } private struct StubTransportError: Error {} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 7b0ae69..c058441 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -1229,6 +1229,63 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertNil(sentBody?["eventType"]) } + func testListApiKeysDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "pagination": { "limit": 25, "offset": 0 }, + "data": [ + { + "id": "c3eec545-6d38-4065-90dc-b6c96a551445", + "name": "Outpost", + "scope": null, + "last4": "dl1h", + "createdAt": "2026-08-12T18:44:32.467Z", + "updatedAt": "2026-08-12T18:44:32.467Z", + "expiresAt": null, + "lastActiveAt": "2026-08-18T01:01:36.553Z" + } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let keys = try await client.listApiKeys(ListApiKeysRequest()) + + XCTAssertEqual(keys.count, 1) + XCTAssertEqual(keys.first?.name, "Outpost") + XCTAssertEqual(keys.first?.last4, "dl1h") + XCTAssertNil(keys.first?.expiresAt) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list") + } + + func testInstallationInfoDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 }, + "policies": [] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let info = try await client.installationInfo() + + XCTAssertEqual(info.version, "1.9.2") + XCTAssertEqual(info.versionsBehind, 0) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info") + } + func testDeleteAccountSendsRequest() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """