diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 116be67..1ce7639 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -379,6 +379,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.listApiKeys(request) } + public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { + try await live.createApiKey(request) + } + + public func deleteApiKey(id: String) async throws { + try await live.deleteApiKey(id: id) + } + public func installationInfo() async throws -> OutlineInstallationInfo { try await live.installationInfo() } diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 56d1f29..6ebf225 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -95,9 +95,13 @@ public protocol OutlineAPIClient: Sendable { 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. + /// Settings → API & Access. func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] + /// The returned `OutlineAPIKey.value` is the only time the full + /// plaintext key is ever available — the caller is responsible for + /// displaying it once and then discarding it. + func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey + func deleteApiKey(id: String) async throws /// 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 48ec0fa..5caaad1 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -314,6 +314,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("apiKeys.list", body: request) } + public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { + try await post("apiKeys.create", body: request) + } + + public func deleteApiKey(id: String) async throws { + try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id)) + } + public func installationInfo() async throws -> OutlineInstallationInfo { try await post("installation.info", body: EmptyParams()) } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift index 85593f2..beaca80 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift @@ -12,6 +12,11 @@ public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable { public let createdAt: Date public let expiresAt: Date? public let lastActiveAt: Date? + /// The full plaintext key — present *only* in `apiKeys.create`'s + /// response, confirmed live: `apiKeys.list` never includes it, matching + /// "shown once at creation" being enforced server-side, not just a + /// client-side UI convention this app has to uphold on its own. + public let value: String? public init( id: String, @@ -20,7 +25,8 @@ public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable { scope: [String]? = nil, createdAt: Date, expiresAt: Date? = nil, - lastActiveAt: Date? = nil + lastActiveAt: Date? = nil, + value: String? = nil ) { self.id = id self.name = name @@ -29,5 +35,6 @@ public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable { self.createdAt = createdAt self.expiresAt = expiresAt self.lastActiveAt = lastActiveAt + self.value = value } } diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateApiKeyRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateApiKeyRequest.swift new file mode 100644 index 0000000..33fe4b4 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateApiKeyRequest.swift @@ -0,0 +1,21 @@ +import Foundation + +/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as +/// literal `null`) confirmed live to mean no expiration — Swift's +/// synthesized `Encodable` already omits `nil` optionals via +/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way +/// `UpdateUserAvatarRequest` needed one for the opposite case. +public struct CreateApiKeyRequest: Encodable, Sendable { + public let name: String + public let expiresAt: Date? + /// `nil`/omitted grants full access — confirmed live (every key created + /// without a scope came back with unrestricted access). A specific + /// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`. + public let scope: [String]? + + public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) { + self.name = name + self.expiresAt = expiresAt + self.scope = scope + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index c4be353..e14af0f 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -100,6 +100,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable 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 createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() } + func deleteApiKey(id: String) async throws { throw NotStubbed() } func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() } } diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index c058441..bfee9c3 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -1286,6 +1286,89 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info") } + func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683", + "name": "test", + "scope": null, + "value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv", + "last4": "0DGv", + "createdAt": "2026-08-18T15:39:29.872Z", + "expiresAt": null, + "lastActiveAt": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let key = try await client.createApiKey(CreateApiKeyRequest(name: "test")) + + XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv") + XCTAssertNil(key.expiresAt) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create") + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertEqual(sentBody?["name"] as? String, "test") + XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key") + XCTAssertNil(sentBody?["scope"]) + } + + func testCreateApiKeySendsExpiresAtWhenProvided() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "04e204fb-51a4-4b54-aed1-2056dfd576d7", + "name": "Test", + "scope": null, + "value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts", + "last4": "cLts", + "createdAt": "2026-08-18T15:38:22.928Z", + "expiresAt": "2026-11-16T23:59:59.999Z", + "lastActiveAt": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let expiresAt = Date(timeIntervalSince1970: 1_795_000_000) + _ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt)) + + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertNotNil(sentBody?["expiresAt"]) + } + + func testDeleteApiKeySendsRequest() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete") + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683") + } + func testDeleteAccountSendsRequest() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """