Files
Outpost/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift
T
Puranjay Savar Mattas 7216633299 feat(share): drop broken Published toggle, add real document permissions
Flipping "Published" 403'd with authorization_error, confirmed via a
raw curl (bypassing our client entirely, real token) to be a genuine
server-side restriction independent of this app — workspace public
sharing is enabled, token is full-scope, it's not document-specific.
Root cause is most likely Outline gating that action behind an
interactive session rather than API-token auth, but that's not
definitively confirmed server-side. Removed the toggle per explicit
instruction; kept link create/copy/revoke and title override (same
endpoint, not reported broken).

Replaced it with real per-document user permissions:
- OutlineMembership/OutlineDocumentMember models
- documents.add_user (confirmed shape from official docs),
  documents.remove_user/documents.users (speculative, same "best-effort
  until a live server confirms" treatment OutlinePin originally got),
  users.list for the invite search (standard, high-confidence)
- DocumentShareSheet gets a "People with access" section: search and
  invite with a Can-view/Can-edit picker, existing members listed with
  a remove button
- Reader toolbar's long-disabled "Permissions…" menu item now opens
  this same sheet instead of doing nothing

Sidebar's own disabled "Permissions…" stub has no sheet wired up to it
yet — comment updated to be accurate, not fixed (use the reader's menu
instead). documents.users/documents.remove_user are unverified against
a live server, same as every other speculative endpoint this session —
expect a correction round once tested.
2026-08-14 20:18:03 +01:00

426 lines
15 KiB
Swift

import Foundation
/// Talks to Outline's RPC-style REST API (`POST /api/<namespace>.<method>`, Bearer auth).
public actor LiveOutlineAPIClient: OutlineAPIClient {
private let baseURL: URL
private let httpClient: HTTPClient
private let tokenStore: TokenStoring
private let decoder: JSONDecoder
private let encoder: JSONEncoder
public init(
configuration: OutlineConfiguration,
tokenStore: TokenStoring,
httpClient: HTTPClient = URLSessionHTTPClient()
) {
self.baseURL = configuration.baseURL
self.tokenStore = tokenStore
self.httpClient = httpClient
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
self.encoder = encoder
}
public func authInfo() async throws -> OutlineAuthInfo {
try await post("auth.info", body: EmptyParams())
}
public func documentInfo(id: String) async throws -> OutlineDocument {
try await post("documents.info", body: DocumentInfoParams(id: id))
}
public func listDocuments(
collectionId: String?,
parentDocumentId: String?,
offset: Int,
limit: Int
) async throws -> [OutlineDocument] {
try await post(
"documents.list",
body: DocumentListParams(
collectionId: collectionId,
parentDocumentId: parentDocumentId,
offset: offset,
limit: limit
)
)
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await post("documents.list", body: request)
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request)
}
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
try await post("documents.search_titles", body: request)
}
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
try await post("documents.create", body: request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
try await post("documents.update", body: request)
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
try await post("stars.create", body: request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await post("documents.templatize", body: request)
}
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
let result: DuplicateDocumentResponse = try await post("documents.duplicate", body: request)
return result.documents
}
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
try await post("documents.unpublish", body: request)
}
public func archiveDocument(id: String) async throws -> OutlineDocument {
try await post("documents.archive", body: DocumentIDRequest(id: id))
}
public func moveDocument(_ request: MoveDocumentRequest) async throws {
let _: MoveDocumentResponse = try await post("documents.move", body: request)
}
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
try await postForSuccess("documents.delete", body: request)
}
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
try await post("documents.insights", body: request)
}
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
try await post("revisions.list", body: request)
}
public func exportDocument(id: String) async throws -> String {
try await post("documents.export", body: DocumentIDRequest(id: id))
}
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
try await post("shares.create", body: request)
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
// Real shape confirmed against a live server: `data` is
// `{ shares: [...] }`, not the bare share object the docs imply —
// same pattern as `pins.list`. A document could in principle have
// more than one share record; the first is what the reader's
// share sheet cares about.
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
return payload?.shares.first
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await post("shares.update", body: request)
}
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await post("shares.list", body: request)
}
public func revokeShare(id: String) async throws {
try await postForSuccess("shares.revoke", body: StarIDParams(id: id))
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
try await post("pins.create", body: request)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
// `data` here is `{ pins: [...], documents: [...] }`, not a bare
// array — confirmed against a live server. Decoding straight to
// `[OutlinePin]` throws on every call, which `try?` at call sites
// swallows silently, so pins never showed up anywhere.
let payload: PinsListPayload = try await post("pins.list", body: request)
return payload.pins
}
public func deletePin(id: String) async throws {
try await postForSuccess("pins.delete", body: StarIDParams(id: id))
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
try await post("subscriptions.create", body: request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await post("subscriptions.list", body: request)
}
public func deleteSubscription(id: String) async throws {
try await postForSuccess("subscriptions.delete", body: StarIDParams(id: id))
}
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
try await post("views.list", body: request)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request)
}
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
try await postForSuccess("documents.remove_user", body: request)
}
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
try await post("documents.users", body: request)
}
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
try await post("users.list", body: request)
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await post("collections.info", body: CollectionInfoParams(id: id))
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
try await post("collections.update", body: request)
}
public func deleteCollection(id: String) async throws {
try await postForSuccess("collections.delete", body: CollectionInfoParams(id: id))
}
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
let result: ExportCollectionResponse = try await post("collections.export", body: request)
return result.fileOperation
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
try await post("stars.create", body: request)
}
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
let result: ListStarsResponse = try await post("stars.list", body: request)
return result.stars
}
public func deleteStar(id: String) async throws {
try await postForSuccess("stars.delete", body: StarIDParams(id: id))
}
public func currentUser() async throws -> OutlineUser {
try await post("users.info", body: EmptyParams())
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try encoder.encode(body)
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
do {
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
} catch {
throw OutlineAPIError.decoding(error)
}
}
/// Like `post(_:body:)`, but for endpoints where "no result" comes back
/// as a 200 with a completely empty body instead of a real 404 —
/// confirmed against a live server for `shares.info` (no share yet for
/// a given document). A 404 is still treated as nil too.
private func postOptional<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response? {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try encoder.encode(body)
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
return nil
default:
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
guard !data.isEmpty else { return nil }
do {
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
} catch {
throw OutlineAPIError.decoding(error)
}
}
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try encoder.encode(body)
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
do {
let envelope = try decoder.decode(SuccessEnvelope.self, from: data)
guard envelope.success else {
throw OutlineAPIError.server(status: response.statusCode, message: nil)
}
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.decoding(error)
}
}
}
private struct ExportCollectionResponse: Decodable {
let fileOperation: OutlineFileOperation
}
private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument]
}
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct SharesInfoPayload: Decodable {
let shares: [OutlineShare]
}
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
private struct StarIDParams: Encodable {
let id: String
}
private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]?
let collections: [OutlineCollection]?
}
private struct SuccessEnvelope: Decodable {
let success: Bool
}
private struct DocumentInfoParams: Encodable {
let id: String
}
private struct DocumentListParams: Encodable {
let collectionId: String?
let parentDocumentId: String?
let offset: Int
let limit: Int
}
private struct PaginationParams: Encodable {
let offset: Int
let limit: Int
}
private struct CollectionListParams: Encodable {
let offset: Int
let limit: Int
}
private struct CollectionInfoParams: Encodable {
let id: String
}
private struct EmptyParams: Encodable {}