| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | |
| 4 | // Thin REST client for the lchat HTTP API. Every data endpoint is token-gated; the |
| 5 | // device token rides in the query string (GET) or JSON body (POST), exactly as the |
| 6 | // web client sends it. The server only ever sees ciphertext, opaque blob ids, and |
| 7 | // wrapped key blobs it cannot open. |
| 8 | public struct LChatAPI { |
| 9 | public let baseURL: URL |
| 10 | public let token: String |
| 11 | let session = URLSession(configuration: .default) // internal: SealedBranding.swift shares it |
| 12 | |
| 13 | public init(baseURL: URL, token: String) { |
| 14 | self.baseURL = baseURL |
| 15 | self.token = token |
| 16 | } |
| 17 | |
| 18 | // MARK: Identity / roster |
| 19 | |
| 20 | /// Registers (idempotently) this device's user public key in the roster. |
| 21 | /// `bindMac` is the key-transparency binding (#573) — HMAC of the key under |
| 22 | /// the invite secret T, so the inviter can verify it. Empty for add-device / |
| 23 | /// pre-KT (the server treats "" as absent). |
| 24 | public func ensurePubkey(_ publicKey: String, bindMac: String = "") async { |
| 25 | await postJSON("/api/pubkey", ["token": token, "public_key": publicKey, "bind_mac": bindMac]) |
| 26 | } |
| 27 | |
| 28 | public func roster() async -> [RosterEntry] { |
| 29 | struct Resp: Codable { let roster: [RosterEntry]? } |
| 30 | return (try? await getJSON("/api/roster", Resp.self))?.roster ?? [] |
| 31 | } |
| 32 | |
| 33 | // MARK: Threads / keys |
| 34 | |
| 35 | public func threads() async -> [ServerThread] { |
| 36 | struct Resp: Codable { let threads: [ServerThread]? } |
| 37 | return (try? await getJSON("/api/threads", Resp.self))?.threads ?? [] |
| 38 | } |
| 39 | |
| 40 | /// The community directory: every open group, joined or not, each with its |
| 41 | /// instance-key-encrypted open_key (mirrors the web's showDiscover fetch). |
| 42 | public func openThreads() async -> [OpenThreadRow] { |
| 43 | struct Resp: Codable { let threads: [OpenThreadRow]? } |
| 44 | return (try? await getJSON("/api/threads/open", Resp.self))?.threads ?? [] |
| 45 | } |
| 46 | |
| 47 | /// The access levels, personalised (our wrapped key copies ride along). |
| 48 | public func tiers() async -> [TierRow] { |
| 49 | struct Resp: Codable { let tiers: [TierRow]? } |
| 50 | return (try? await getJSON("/api/tiers", Resp.self))?.tiers ?? [] |
| 51 | } |
| 52 | |
| 53 | /// Ask current holders to wrap an access level's key to us (keyless prod). |
| 54 | public func nudgeTier(_ tier: String) async { |
| 55 | _ = await postJSON("/api/tiers/\(tier)/nudge", ["token": token]) |
| 56 | } |
| 57 | |
| 58 | // MARK: Paid access levels (member side, subscriptions.go) |
| 59 | |
| 60 | /// A billing action's outcome: a hosted URL to send the member to (Stripe |
| 61 | /// checkout or the billing portal), a plain success (change/cancel), or a |
| 62 | /// failure. Mirrors the web's billingPost, which follows `url` when present. |
| 63 | public enum BillingResult { case url(URL), ok, failed } |
| 64 | |
| 65 | /// Start checkout for a sellable level. On success returns the hosted |
| 66 | /// payment URL; entitlement is granted by the webhook once payment clears. |
| 67 | public func subscribeTier(_ id: String) async -> BillingResult { |
| 68 | await billingPost("/api/tiers/\(id)/subscribe", ["token": token]) |
| 69 | } |
| 70 | |
| 71 | /// Switch an existing subscription to another sellable level (Stripe |
| 72 | /// prorates). Returns .ok / .failed — no redirect. |
| 73 | public func changeTier(from: String, to: String) async -> BillingResult { |
| 74 | await billingPost("/api/tiers/\(from)/change", ["token": token, "to_tier": to]) |
| 75 | } |
| 76 | |
| 77 | /// Cancel at period end — access continues until the paid period runs out. |
| 78 | public func cancelTierSub(_ id: String) async -> BillingResult { |
| 79 | await billingPost("/api/tiers/\(id)/cancel-subscription", ["token": token]) |
| 80 | } |
| 81 | |
| 82 | /// Resume an ending subscription: un-cancels the SAME subscription (no new |
| 83 | /// checkout, no new charge). Returns .ok / .failed. |
| 84 | public func resumeTier(_ id: String) async -> BillingResult { |
| 85 | await billingPost("/api/tiers/\(id)/resume", ["token": token]) |
| 86 | } |
| 87 | |
| 88 | /// Open the Stripe billing portal (card / invoices / cancellation). Returns |
| 89 | /// the portal URL to send the member to. |
| 90 | public func tierPortal(_ id: String) async -> BillingResult { |
| 91 | await billingPost("/api/tiers/\(id)/portal", ["token": token]) |
| 92 | } |
| 93 | |
| 94 | /// POST a billing action, checking the status and following a `{url}` body. |
| 95 | /// Strict about status (unlike postJSON) so a 4xx never reads as success. |
| 96 | private func billingPost(_ path: String, _ body: [String: Any]) async -> BillingResult { |
| 97 | guard let url = URL(string: path, relativeTo: baseURL), |
| 98 | let data = try? JSONSerialization.data(withJSONObject: body) else { return .failed } |
| 99 | var req = URLRequest(url: url) |
| 100 | req.httpMethod = "POST" |
| 101 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 102 | req.httpBody = data |
| 103 | guard let (respBody, resp) = try? await session.data(for: req), |
| 104 | (resp as? HTTPURLResponse)?.statusCode == 200 else { return .failed } |
| 105 | struct R: Codable { let url: String? } |
| 106 | if let s = (try? JSONDecoder().decode(R.self, from: respBody))?.url, |
| 107 | !s.isEmpty, let u = URL(string: s) { |
| 108 | return .url(u) |
| 109 | } |
| 110 | return .ok |
| 111 | } |
| 112 | |
| 113 | /// Self-serve join an open group. True only on a real 200 — a 403 (the |
| 114 | /// group closed while the sheet was up) must not read as joined. |
| 115 | public func joinThread(_ thread: String) async -> Bool { |
| 116 | guard let url = URL(string: "api/threads/\(thread)/join", relativeTo: baseURL), |
| 117 | let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false } |
| 118 | var req = URLRequest(url: url) |
| 119 | req.httpMethod = "POST" |
| 120 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 121 | req.httpBody = data |
| 122 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 123 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 124 | } |
| 125 | |
| 126 | /// The caller's wrapped instance key (for member avatars), or nil if not granted. |
| 127 | public func instanceKeyWrapped() async -> String? { |
| 128 | struct Resp: Codable { let wrapped: String? } |
| 129 | let wrapped = (try? await getJSON("/api/instance-key", Resp.self))?.wrapped |
| 130 | return (wrapped?.isEmpty ?? true) ? nil : wrapped |
| 131 | } |
| 132 | |
| 133 | /// Full instance-key state: our wrapped copy (if granted) plus who already |
| 134 | /// holds one — the web's setupInstanceKey inputs. `have` empty and no |
| 135 | /// wrapped copy means nobody has minted the key yet (a brand-new space). |
| 136 | public func instanceKeyStatus() async -> (wrapped: String?, have: [String])? { |
| 137 | struct Resp: Codable { let wrapped: String?; let have: [String]? } |
| 138 | guard let r = try? await getJSON("/api/instance-key", Resp.self) else { return nil } |
| 139 | return ((r.wrapped?.isEmpty ?? true) ? nil : r.wrapped, r.have ?? []) |
| 140 | } |
| 141 | |
| 142 | /// Grant the instance key to members: [{user_id, wrapped}] where each |
| 143 | /// wrapped blob is the key ECIES-wrapped to that member's pubkey — the |
| 144 | /// server stores blobs it can't open (mirrors the web's grantInstanceKeyTo). |
| 145 | /// True only on a REAL 200 — callers publish labels under this key, so a |
| 146 | /// swallowed 4xx/5xx must not read as success. |
| 147 | @discardableResult |
| 148 | public func grantInstanceKey(_ grants: [[String: String]]) async -> Bool { |
| 149 | guard let url = URL(string: "api/instance-key", relativeTo: baseURL), |
| 150 | let data = try? JSONSerialization.data(withJSONObject: |
| 151 | ["token": token, "grants": grants]) else { return false } |
| 152 | var req = URLRequest(url: url) |
| 153 | req.httpMethod = "POST" |
| 154 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 155 | req.httpBody = data |
| 156 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 157 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 158 | } |
| 159 | |
| 160 | /// Ask any online instance-key holder to wrap the key to us — the web's |
| 161 | /// keyless self-heal (shell.js setupInstanceKey → /api/instance-key/nudge). |
| 162 | /// The token rides the query string: that's the only place the handler |
| 163 | /// looks (main.go nudgeInstanceKey), unlike the JSON-body POSTs above. |
| 164 | public func nudgeInstanceKey() async { |
| 165 | var comps = URLComponents(url: baseURL.appending(path: "api/instance-key/nudge"), |
| 166 | resolvingAgainstBaseURL: false) |
| 167 | comps?.queryItems = [URLQueryItem(name: "token", value: token)] |
| 168 | guard let url = comps?.url else { return } |
| 169 | var req = URLRequest(url: url) |
| 170 | req.httpMethod = "POST" |
| 171 | _ = try? await session.data(for: req) |
| 172 | } |
| 173 | |
| 174 | /// The space's connected third-party services (connect.go): the set the |
| 175 | /// pickers gate service apps on (AppRegistry.updateConnected). nil on a |
| 176 | /// network error so callers keep what they hold. |
| 177 | public func connectedServices() async -> Set<String>? { |
| 178 | struct Resp: Codable { let services: [String]? } |
| 179 | guard let r = try? await getJSON("/api/connected", Resp.self) else { return nil } |
| 180 | return Set(r.services ?? []) |
| 181 | } |
| 182 | |
| 183 | /// The instance's hidden space thread backing space-wide app installs |
| 184 | /// (spacethread.go): (id, its open_key — the thread key wrapped under the |
| 185 | /// INSTANCE key) once created, or (nil, "") when no space thread exists |
| 186 | /// yet ({} on the wire). nil on a network error, so callers keep held |
| 187 | /// state instead of reading a blip as "no installs". |
| 188 | public func spaceThread() async -> (id: String?, openKey: String)? { |
| 189 | struct Resp: Codable { let id: String?; let open_key: String? } |
| 190 | guard let r = try? await getJSON("/api/space-thread", Resp.self) else { return nil } |
| 191 | guard let id = r.id, !id.isEmpty else { return (nil, "") } |
| 192 | return (id, r.open_key ?? "") |
| 193 | } |
| 194 | |
| 195 | /// Messages in a thread after a cursor id — used to count unread (the feed |
| 196 | /// itself is rendered by the webview). |
| 197 | public func messages(thread: String, after: Int64) async -> [ServerMessage] { |
| 198 | struct Resp: Codable { let messages: [ServerMessage]? } |
| 199 | let items = [ |
| 200 | URLQueryItem(name: "token", value: token), |
| 201 | URLQueryItem(name: "thread", value: thread), |
| 202 | URLQueryItem(name: "after", value: String(after)), |
| 203 | ] |
| 204 | return (try? await getJSON("/api/messages", Resp.self, query: items))?.messages ?? [] |
| 205 | } |
| 206 | |
| 207 | /// The instance's signed checkpoint chain past `after` (a checkpoint row id), |
| 208 | /// plus its current checkpoint public key — the tamper-evidence poll |
| 209 | /// (docs/tamper-evidence.md, #573). Returns nil on a network error (the |
| 210 | /// monitor treats that as "nothing to conclude" and retries). Verification |
| 211 | /// is on the body, so no HTTP-status check is needed here. |
| 212 | public func checkpoints(after: Int64) async -> CheckpointsResponse? { |
| 213 | let items = [ |
| 214 | URLQueryItem(name: "token", value: token), |
| 215 | URLQueryItem(name: "after", value: String(after)), |
| 216 | ] |
| 217 | return try? await getJSON("/api/checkpoints", CheckpointsResponse.self, query: items) |
| 218 | } |
| 219 | |
| 220 | /// Merkle inclusion proofs that a message is committed to the chain |
| 221 | /// (docs/tamper-evidence.md client sampling). Returns every committed |
| 222 | /// journal event of msgID with its audit path, plus committed_to. nil on a |
| 223 | /// network error (the monitor treats that as "nothing to conclude"). |
| 224 | public func checkpointProof(msgID: Int64) async -> CheckpointProofResponse? { |
| 225 | let items = [ |
| 226 | URLQueryItem(name: "token", value: token), |
| 227 | URLQueryItem(name: "msg", value: String(msgID)), |
| 228 | ] |
| 229 | return try? await getJSON("/api/checkpoint-proof", CheckpointProofResponse.self, query: items) |
| 230 | } |
| 231 | |
| 232 | /// The page of messages immediately older than `before` (ascending) — |
| 233 | /// scroll-back pagination, mirroring the web's loadOlder. |
| 234 | public func messagesBefore(thread: String, before: Int64) async -> [ServerMessage] { |
| 235 | struct Resp: Codable { let messages: [ServerMessage]? } |
| 236 | let items = [ |
| 237 | URLQueryItem(name: "token", value: token), |
| 238 | URLQueryItem(name: "thread", value: thread), |
| 239 | URLQueryItem(name: "before", value: String(before)), |
| 240 | ] |
| 241 | return (try? await getJSON("/api/messages", Resp.self, query: items))?.messages ?? [] |
| 242 | } |
| 243 | |
| 244 | /// Why createThread failed: .dmsClosed = the target's DM door is shut |
| 245 | /// (403 {"error":"dms closed"}, issue #578 — admins and bots pass |
| 246 | /// server-side), so callers can answer with honest copy; anything else |
| 247 | /// stays a generic failure. |
| 248 | public enum CreateThreadError: Error { case dmsClosed, failed } |
| 249 | |
| 250 | /// Creates a thread (self/dm/group) with the per-member wrapped keys. Returns the |
| 251 | /// new (or, for a deduped DM, existing) thread id. `members` is [{user_id, wrapped}]. |
| 252 | public func createThread(kind: String, nameBlob: String, members: [[String: String]], |
| 253 | openKey: String = "") async -> Result<String, CreateThreadError> { |
| 254 | struct Resp: Codable { let thread_id: String?; let error: String? } |
| 255 | var body: [String: Any] = ["token": token, "kind": kind, "name_blob": nameBlob, "members": members] |
| 256 | if !openKey.isEmpty { |
| 257 | // An open community group: the thread key encrypted under the |
| 258 | // instance key, so any member can discover + self-join later. |
| 259 | body["visibility"] = "open" |
| 260 | body["open_key"] = openKey |
| 261 | } |
| 262 | guard let url = URL(string: "/api/threads", relativeTo: baseURL), |
| 263 | let data = try? JSONSerialization.data(withJSONObject: body) else { return .failure(.failed) } |
| 264 | var req = URLRequest(url: url) |
| 265 | req.httpMethod = "POST" |
| 266 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 267 | req.httpBody = data |
| 268 | guard let (respData, _) = try? await session.data(for: req), |
| 269 | let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return .failure(.failed) } |
| 270 | if let tid = r.thread_id { return .success(tid) } |
| 271 | return .failure((r.error ?? "").contains("dms closed") ? .dmsClosed : .failed) |
| 272 | } |
| 273 | |
| 274 | /// Schedule an encrypted event for future delivery into a thread (the web's |
| 275 | /// ctx.schedule wire: POST /api/schedule). The server holds only ciphertext |
| 276 | /// plus (thread, time) — the accepted timing trade-off of real alerts — and |
| 277 | /// delivers the blob as a normal message at the due moment. `deliverAt` is |
| 278 | /// unix SECONDS (the wire's unit; callers hold epoch ms). Returns the |
| 279 | /// schedule id (for cancel), or nil — a failed schedule is the offline |
| 280 | /// fallback path, not an error. |
| 281 | public func schedule(thread: String, deliverAt: Int64, blob: String) async -> String? { |
| 282 | struct Resp: Codable { let id: String? } |
| 283 | let body: [String: Any] = ["token": token, "thread_id": thread, "deliver_at": deliverAt, "blob": blob] |
| 284 | guard let url = URL(string: "/api/schedule", relativeTo: baseURL), |
| 285 | let data = try? JSONSerialization.data(withJSONObject: body) else { return nil } |
| 286 | var req = URLRequest(url: url) |
| 287 | req.httpMethod = "POST" |
| 288 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 289 | req.httpBody = data |
| 290 | guard let (respData, resp) = try? await session.data(for: req), |
| 291 | (resp as? HTTPURLResponse)?.statusCode == 200, |
| 292 | let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return nil } |
| 293 | return r.id |
| 294 | } |
| 295 | |
| 296 | /// One thread's encrypted mini-app state snapshot (GET |
| 297 | /// /api/threads/{id}/state) — the slot-"apps" blob the web saves so a |
| 298 | /// client can seed its fold without re-reading the whole log. version 0 + |
| 299 | /// empty blob = nothing saved yet. |
| 300 | public func threadState(thread: String, slot: String) async -> (blob: String, version: Int64)? { |
| 301 | struct Resp: Codable { let blob: String?; let version: Int64? } |
| 302 | let items = [ |
| 303 | URLQueryItem(name: "token", value: token), |
| 304 | URLQueryItem(name: "slot", value: slot), |
| 305 | ] |
| 306 | guard let r = try? await getJSON("/api/threads/\(thread)/state", Resp.self, query: items) |
| 307 | else { return nil } |
| 308 | return (r.blob ?? "", r.version ?? 0) |
| 309 | } |
| 310 | |
| 311 | /// Post one encrypted message over REST — the socketless path (the share |
| 312 | /// extension, and thread-creation flows that write before any feed is |
| 313 | /// open). The hub still broadcasts to every member. Token in the query, |
| 314 | /// like sendViaREST — postMessage reads it there, not from the body. |
| 315 | @discardableResult |
| 316 | public func postMessage(thread: String, blob: String) async -> Bool { |
| 317 | var comps = URLComponents(url: baseURL.appending(path: "/api/messages"), |
| 318 | resolvingAgainstBaseURL: false)! |
| 319 | comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| 320 | guard let url = comps.url, |
| 321 | let data = try? JSONSerialization.data(withJSONObject: |
| 322 | ["thread_id": thread, "blob": blob]) else { return false } |
| 323 | var req = URLRequest(url: url) |
| 324 | req.httpMethod = "POST" |
| 325 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 326 | req.httpBody = data |
| 327 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 328 | return ((resp as? HTTPURLResponse)?.statusCode ?? 500) < 300 |
| 329 | } |
| 330 | |
| 331 | /// Cancel a pending schedule by the id schedule() returned (the web's |
| 332 | /// ctx.unschedule wire: DELETE /api/schedule/{id}). |
| 333 | @discardableResult |
| 334 | public func unschedule(_ sid: String) async -> Bool { |
| 335 | var comps = URLComponents(url: baseURL.appending(path: "/api/schedule/\(sid)"), |
| 336 | resolvingAgainstBaseURL: false)! |
| 337 | comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| 338 | guard let url = comps.url else { return false } |
| 339 | var req = URLRequest(url: url) |
| 340 | req.httpMethod = "DELETE" |
| 341 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 342 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 343 | } |
| 344 | |
| 345 | /// Update the admin group settings, merging over the current trio (the |
| 346 | /// server's setGroup clears any field it isn't sent — the web's |
| 347 | /// saveGroupSettings does the same GET+merge). Admin: the device token's |
| 348 | /// user holds the role. |
| 349 | public func setGroupSettings(name: String? = nil, adminEmail: String? = nil) async -> Bool { |
| 350 | // setGroup clears any field it isn't sent, so a change to ONE field |
| 351 | // must carry the current other two — which means the read has to work. |
| 352 | // It didn't: there is no GET /api/admin/group (main.go registers POST |
| 353 | // only), so `current` was always empty and every write went out with |
| 354 | // an empty name, which the server rejects outright ("name must be |
| 355 | // 1-60 characters"). Two bugs in one: setting the contact email could |
| 356 | // never succeed on native (Eleven's setup conversation answered every |
| 357 | // address with "Something went wrong saving that"), and a name change |
| 358 | // silently cleared any email already stored. |
| 359 | // |
| 360 | // The trio lives on the MEMBER read instead — /api/group?token=…, |
| 361 | // where the admin fields are member-gated (getGroup) — which is |
| 362 | // exactly what the web bridge reads (shell.js groupInfo). |
| 363 | struct Trio: Codable { |
| 364 | let name: String?; let name_set: Bool? |
| 365 | let admin_name: String?; let admin_email: String? |
| 366 | } |
| 367 | let current = try? await getJSON("/api/group", Trio.self) |
| 368 | // chosenName, not the raw field: an unnamed space reports the |
| 369 | // compiled-in default, and carrying that would silently adopt "lchat" |
| 370 | // as this space's chosen name. Empty fails the write instead — honest, |
| 371 | // and unreachable from the setup conversation, which names the space |
| 372 | // before it asks anything else. |
| 373 | let body: [String: Any] = [ |
| 374 | "name": name ?? Self.chosenName(current?.name, nameSet: current?.name_set), |
| 375 | "admin_name": current?.admin_name ?? "", |
| 376 | "admin_email": adminEmail ?? (current?.admin_email ?? ""), |
| 377 | ] |
| 378 | return await postAdmin("/api/admin/group", body) |
| 379 | } |
| 380 | |
| 381 | /// POST /api/admin/settings (user_invites, setup_complete, …). |
| 382 | public func setAdminSettings(_ fields: [String: Any]) async -> Bool { |
| 383 | await postAdmin("/api/admin/settings", fields) |
| 384 | } |
| 385 | |
| 386 | private func postAdmin(_ path: String, _ body: [String: Any]) async -> Bool { |
| 387 | guard let url = URL(string: path, relativeTo: baseURL), |
| 388 | let data = try? JSONSerialization.data(withJSONObject: body) else { return false } |
| 389 | var req = URLRequest(url: url) |
| 390 | req.httpMethod = "POST" |
| 391 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 392 | req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| 393 | req.httpBody = data |
| 394 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 395 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 396 | } |
| 397 | |
| 398 | /// Pin/unpin a chat for this user — a per-user marker the server syncs across |
| 399 | /// the user's devices and floats to the top of their list. |
| 400 | public func setPinned(thread: String, pinned: Bool) async { |
| 401 | await postJSON("/api/threads/\(thread)/pinned", ["token": token, "pinned": pinned]) |
| 402 | } |
| 403 | |
| 404 | /// Close (hidden=true) / reopen (hidden=false) a chat for this user — a per-user |
| 405 | /// marker synced across their devices; a closed chat drops off the list. |
| 406 | public func setHidden(thread: String, hidden: Bool) async { |
| 407 | await postJSON("/api/threads/\(thread)/hidden", ["token": token, "hidden": hidden]) |
| 408 | } |
| 409 | |
| 410 | /// Mute/unmute a chat for this user — a per-user marker synced across their |
| 411 | /// devices. While muted the SERVER skips every push for the thread; clients |
| 412 | /// drop the unread dot and show the slashed bell instead. |
| 413 | public func setMuted(thread: String, muted: Bool) async { |
| 414 | await postJSON("/api/threads/\(thread)/muted", ["token": token, "muted": muted]) |
| 415 | } |
| 416 | |
| 417 | /// Advance this user's read watermark for a thread — per-user, synced across |
| 418 | /// their devices ("read here, read everywhere"). Forward-only server-side, so |
| 419 | /// a stale write from a lagging device never regresses it. |
| 420 | public func setRead(thread: String, lastReadAt: Int64) async { |
| 421 | await postJSON("/api/threads/\(thread)/read", ["token": token, "last_read_at": lastReadAt]) |
| 422 | } |
| 423 | |
| 424 | /// The OpenGraph card for a posted URL — the ONE deliberate E2E exception |
| 425 | /// (unfurl.go): the server fetches the page to build the card, so it sees |
| 426 | /// the URL (never message text). Returns nil when the fetch failed or the |
| 427 | /// page offered nothing worth showing. |
| 428 | /// The shared fetch service this instance points at, if any — set from |
| 429 | /// FeatureGates by the store. Unfurls prefer it (identity-free: no token |
| 430 | /// rides along, and card images come back as its signed /img proxy URLs), |
| 431 | /// falling back to the instance's own unfurler on any transport failure. |
| 432 | public var fetchURL: String = "" |
| 433 | |
| 434 | public func unfurl(_ url: URL) async -> LinkPreview? { |
| 435 | struct Resp: Decodable { |
| 436 | let url: String? |
| 437 | let title: String? |
| 438 | let description: String? |
| 439 | let image: String? |
| 440 | let ok: Bool? |
| 441 | } |
| 442 | var r: Resp? |
| 443 | // The explicit field (tests) wins; otherwise the registry entry the |
| 444 | // store latched from FeatureGates — every LChatAPI copy for this |
| 445 | // instance routes alike, however it was constructed. |
| 446 | let svc = !fetchURL.isEmpty ? fetchURL |
| 447 | : FetchService.shared.url(forInstanceHost: baseURL.host ?? "") |
| 448 | if !svc.isEmpty, |
| 449 | let query = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics), |
| 450 | let data = await fetchServiceGET(svc, "/api/unfurl?url=" + query) { |
| 451 | // A decoded answer — even ok:false — is real (the page offered |
| 452 | // nothing); only transport failures fall through to the instance. |
| 453 | r = try? JSONDecoder().decode(Resp.self, from: data) |
| 454 | } |
| 455 | if r == nil { |
| 456 | let items = [ |
| 457 | URLQueryItem(name: "token", value: token), |
| 458 | URLQueryItem(name: "url", value: url.absoluteString), |
| 459 | ] |
| 460 | r = try? await getJSON("/api/unfurl", Resp.self, query: items) |
| 461 | } |
| 462 | guard let r, r.ok == true, let title = r.title, !title.isEmpty else { return nil } |
| 463 | return LinkPreview(url: URL(string: r.url ?? "") ?? url, title: title, |
| 464 | description: r.description ?? "", imageURL: URL(string: r.image ?? "")) |
| 465 | } |
| 466 | |
| 467 | /// One GET against the shared fetch service (docs/fetch.md), handling its |
| 468 | /// optional hashcash admission: a 401 carries {pow:{challenge,bits}} — |
| 469 | /// grind SHA-256(challenge ":" nonce) to `bits` leading zeros off-actor, |
| 470 | /// retry with the solution, and keep the returned ticket (process-wide, |
| 471 | /// FetchTickets) so it's one solve per ~hour, not per request. Query |
| 472 | /// params, matching the web client. nil = transport failure or non-200, |
| 473 | /// which callers treat as "fall back to the instance". |
| 474 | func fetchServiceGET(_ base: String, _ path: String) async -> Data? { |
| 475 | let sep = path.contains("?") ? "&" : "?" |
| 476 | func get(_ extra: String) async -> (Data, HTTPURLResponse)? { |
| 477 | guard let u = URL(string: base + path + extra), |
| 478 | let (data, resp) = try? await session.data(from: u), |
| 479 | let http = resp as? HTTPURLResponse else { return nil } |
| 480 | if let t = http.value(forHTTPHeaderField: "X-Fetch-Ticket"), !t.isEmpty { |
| 481 | FetchService.shared.setTicket(t, for: base) |
| 482 | } |
| 483 | return (data, http) |
| 484 | } |
| 485 | let ticket = FetchService.shared.ticket(for: base) |
| 486 | let extra = ticket.isEmpty ? "" : |
| 487 | sep + "ticket=" + (ticket.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "") |
| 488 | guard var (data, http) = await get(extra) else { return nil } |
| 489 | if http.statusCode == 401 { |
| 490 | struct Gate: Decodable { |
| 491 | struct Pow: Decodable { let challenge: String; let bits: Int } |
| 492 | let pow: Pow? |
| 493 | } |
| 494 | guard let pow = (try? JSONDecoder().decode(Gate.self, from: data))?.pow else { return nil } |
| 495 | let nonce = await Task.detached(priority: .userInitiated) { |
| 496 | var n = 0 |
| 497 | while !LChatAPI.powSolves(pow.challenge, String(n), pow.bits) { n += 1 } |
| 498 | return n |
| 499 | }.value |
| 500 | let solved = sep + "pow=" + |
| 501 | (pow.challenge.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "") + |
| 502 | "&nonce=\(nonce)" |
| 503 | guard let again = await get(solved) else { return nil } |
| 504 | (data, http) = again |
| 505 | } |
| 506 | return http.statusCode == 200 ? data : nil |
| 507 | } |
| 508 | |
| 509 | /// SHA-256(challenge ":" nonce) clears `bits` leading zero bits — the |
| 510 | /// same check as the server's (fetchpow.go) and home's space-creation gate. |
| 511 | static func powSolves(_ challenge: String, _ nonce: String, _ bits: Int) -> Bool { |
| 512 | var count = 0 |
| 513 | for b in SHA256.hash(data: Data((challenge + ":" + nonce).utf8)) { |
| 514 | if b == 0 { count += 8; continue } |
| 515 | var mask: UInt8 = 0x80 |
| 516 | while mask != 0 { |
| 517 | if b & mask != 0 { return count >= bits } |
| 518 | count += 1 |
| 519 | mask >>= 1 |
| 520 | } |
| 521 | } |
| 522 | return count >= bits |
| 523 | } |
| 524 | |
| 525 | /// The instance's display name, or "" when it was never named, so callers |
| 526 | /// fall back to the host instead of parroting the compiled-in default. |
| 527 | /// The server's `name_set` flag is the authority (an admin-chosen name — |
| 528 | /// even literally "lchat" — always shows); the literal check is only the |
| 529 | /// degraded path for older servers that don't send the flag. |
| 530 | public func groupName() async -> String { |
| 531 | struct Resp: Codable { let name: String?; let name_set: Bool?; let sealed: Bool? } |
| 532 | guard let r = try? await getJSON("/api/group", Resp.self, authed: false) else { return "" } |
| 533 | // Sealed branding (docs/no-plaintext.md): the readable name lives in a |
| 534 | // blob this build can't open yet — degrade to "" so callers use their |
| 535 | // host fallback rather than parroting the server's compiled-in default. |
| 536 | if r.sealed == true { return "" } |
| 537 | return Self.chosenName(r.name, nameSet: r.name_set) |
| 538 | } |
| 539 | |
| 540 | /// Public per-instance branding: the display name ("" when never named — |
| 541 | /// see groupName) plus whether a server photo is set. Both are public |
| 542 | /// (shown during add-a-chat, before any key exists). `sealed` tells a |
| 543 | /// key-holding caller to take the decrypting path instead |
| 544 | /// (SealedBranding.swift); keyless callers just render the fallbacks. |
| 545 | public func serverInfo() async -> (name: String, hasAvatar: Bool, sealed: Bool) { |
| 546 | struct Resp: Codable { let name: String?; let name_set: Bool?; let avatar: Bool?; let sealed: Bool? } |
| 547 | let r = try? await getJSON("/api/group", Resp.self, authed: false) |
| 548 | // Sealed: no readable name, and the photo endpoint serves ciphertext — |
| 549 | // report unnamed + photoless so keyless surfaces degrade gracefully. |
| 550 | if r?.sealed == true { return ("", false, true) } |
| 551 | return (Self.chosenName(r?.name, nameSet: r?.name_set), r?.avatar ?? false, false) |
| 552 | } |
| 553 | |
| 554 | static func chosenName(_ name: String?, nameSet: Bool?) -> String { |
| 555 | let n = name ?? "" |
| 556 | if let nameSet { return nameSet ? n : "" } |
| 557 | return n == "lchat" ? "" : n // pre-name_set servers: filter the default |
| 558 | } |
| 559 | |
| 560 | /// Per-instance feature gates (admin-set, default on): whether this space |
| 561 | /// offers bot creation, apps, and AI replies. They ride the member-gated |
| 562 | /// part of /api/group, so this read is authed; optionals in the decode so |
| 563 | /// servers that predate the gates (and failed fetches) read as enabled. |
| 564 | /// The server enforces each gate — these only hide entry points. |
| 565 | public struct FeatureGates: Sendable { |
| 566 | public let botsEnabled: Bool |
| 567 | public let appsEnabled: Bool |
| 568 | public let aiEnabled: Bool |
| 569 | /// The fetch service this instance prefers for unfurls (docs/fetch.md): |
| 570 | /// "" = the instance says unfurl locally; nil = the gates read itself |
| 571 | /// failed (keep whatever routing we already hold — a network blip must |
| 572 | /// not silently reroute unfurls). |
| 573 | public let fetchURL: String? |
| 574 | } |
| 575 | |
| 576 | public func featureGates() async -> FeatureGates { |
| 577 | struct Resp: Codable { |
| 578 | let bots_enabled: Bool?; let apps_enabled: Bool?; let ai_enabled: Bool? |
| 579 | let fetch_url: String? |
| 580 | } |
| 581 | let r = try? await getJSON("/api/group", Resp.self) |
| 582 | return FeatureGates(botsEnabled: r?.bots_enabled ?? true, |
| 583 | appsEnabled: r?.apps_enabled ?? true, |
| 584 | aiEnabled: r?.ai_enabled ?? true, |
| 585 | fetchURL: r == nil ? nil : (r?.fetch_url ?? "")) |
| 586 | } |
| 587 | |
| 588 | /// The instance's public branding photo bytes, or nil if none is set. Served |
| 589 | /// in the clear (no key exists yet at add-a-chat time), so this needs no token. |
| 590 | public func serverAvatar() async -> Data? { |
| 591 | guard let url = URL(string: "/api/server-avatar", relativeTo: baseURL), |
| 592 | let (data, resp) = try? await session.data(from: url), |
| 593 | let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| 594 | !data.isEmpty, |
| 595 | // Sealed-branding ciphertext, not an image — callers would just |
| 596 | // fail the decode; skip it explicitly. |
| 597 | http.value(forHTTPHeaderField: "Content-Type") != "application/vnd.lchat.sealed" |
| 598 | else { return nil } |
| 599 | return data |
| 600 | } |
| 601 | |
| 602 | // MARK: Plumbing |
| 603 | |
| 604 | func getJSON<T: Decodable>( |
| 605 | _ path: String, _ type: T.Type, query: [URLQueryItem]? = nil, authed: Bool = true |
| 606 | ) async throws -> T { |
| 607 | var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)! |
| 608 | comps.queryItems = query ?? (authed ? [URLQueryItem(name: "token", value: token)] : nil) |
| 609 | // Host + path — never the query (it carries the token). The host is |
| 610 | // what tells a multi-space burst apart from one store stampeding. |
| 611 | let span = Perf.begin("api.get", slowMs: 1000) |
| 612 | defer { span.end("\(baseURL.host ?? "?") \(path)") } |
| 613 | let (data, _) = try await session.data(from: comps.url!) |
| 614 | return try JSONDecoder().decode(T.self, from: data) |
| 615 | } |
| 616 | |
| 617 | /// Create a member invite (a pending DM + join link): all blobs are |
| 618 | /// opaque to the server. Returns (thread_id, nil) or (nil, error text — |
| 619 | /// e.g. the admin has inviting turned off). |
| 620 | public func createUserInvite(labelBlob: String, id: String, wrapped: String, |
| 621 | selfWrapped: String, claimHash: String) |
| 622 | async -> (threadID: String?, userID: String?, error: String?) { |
| 623 | guard let url = URL(string: "api/invites", relativeTo: baseURL), |
| 624 | let data = try? JSONSerialization.data(withJSONObject: [ |
| 625 | "token": token, "label_blob": labelBlob, "id": id, |
| 626 | "wrapped": wrapped, "self_wrapped": selfWrapped, |
| 627 | "claim_hash": claimHash, |
| 628 | ]) else { return (nil, nil, "bad request") } |
| 629 | var req = URLRequest(url: url) |
| 630 | req.httpMethod = "POST" |
| 631 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 632 | req.httpBody = data |
| 633 | guard let (body, resp) = try? await session.data(for: req), |
| 634 | let http = resp as? HTTPURLResponse else { return (nil, nil, "network error") } |
| 635 | struct R: Codable { var thread_id: String?; var user_id: String?; var error: String? } |
| 636 | let r = try? JSONDecoder().decode(R.self, from: body) |
| 637 | guard http.statusCode == 200, let tid = r?.thread_id else { |
| 638 | return (nil, nil, r?.error ?? "invite failed") |
| 639 | } |
| 640 | return (tid, r?.user_id, nil) |
| 641 | } |
| 642 | |
| 643 | /// Publish the caller's display name — encrypted under the instance key |
| 644 | /// (the server blanks the plaintext label once one is set this way). |
| 645 | @discardableResult |
| 646 | public func postLabel(labelBlob: String) async -> Bool { |
| 647 | await postJSON("api/label", ["token": token, "label_blob": labelBlob]) |
| 648 | } |
| 649 | |
| 650 | /// The caller's sealed per-viewer names blob (#577) and its version ("", |
| 651 | /// 0 when never set). nil when the fetch failed — distinct from empty, so |
| 652 | /// a network blip can never be mistaken for "no nicknames" and overwritten. |
| 653 | /// Hand-rolled rather than getJSON: the status must be checked, or a JSON |
| 654 | /// error body ({"error": …} on 403/500) decodes as an all-nil Resp and |
| 655 | /// masquerades as "never set" — the exact wipe this contract exists to stop. |
| 656 | public func nicknames() async -> (blob: String, version: Int64)? { |
| 657 | guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/nicknames"), |
| 658 | resolvingAgainstBaseURL: false) else { return nil } |
| 659 | comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| 660 | guard let url = comps.url, |
| 661 | let (data, resp) = try? await session.data(from: url), |
| 662 | (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil } |
| 663 | struct Resp: Codable { let nicknames_blob: String?; let version: Int64? } |
| 664 | guard let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil } |
| 665 | return (r.nicknames_blob ?? "", r.version ?? 0) |
| 666 | } |
| 667 | |
| 668 | public enum NicknamesPost { case ok(Int64), conflict, failed } |
| 669 | |
| 670 | /// Store the sealed nicknames blob with compare-and-swap: `version` is |
| 671 | /// what the caller last read. .conflict means another device wrote in |
| 672 | /// between — re-read, replay the pending ops, retry. |
| 673 | public func postNicknames(blob: String, version: Int64) async -> NicknamesPost { |
| 674 | guard let url = URL(string: "api/nicknames", relativeTo: baseURL), |
| 675 | let data = try? JSONSerialization.data(withJSONObject: |
| 676 | ["token": token, "nicknames_blob": blob, "version": version]) else { return .failed } |
| 677 | var req = URLRequest(url: url) |
| 678 | req.httpMethod = "POST" |
| 679 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 680 | req.httpBody = data |
| 681 | guard let (body, resp) = try? await session.data(for: req), |
| 682 | let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed } |
| 683 | struct Resp: Codable { let version: Int64? } |
| 684 | let v = (try? JSONDecoder().decode(Resp.self, from: body))?.version |
| 685 | if code == 200 { return .ok(v ?? version + 1) } |
| 686 | if code == 409 { return .conflict } |
| 687 | return .failed |
| 688 | } |
| 689 | |
| 690 | /// The caller's sealed moderation-prefs blob (muted people, #578) and its |
| 691 | /// version — the exact contract nicknames() has, including the nil-vs-empty |
| 692 | /// distinction (nil = fetch failed, never mistake it for "nothing muted"). |
| 693 | public func prefs() async -> (blob: String, version: Int64)? { |
| 694 | guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/prefs"), |
| 695 | resolvingAgainstBaseURL: false) else { return nil } |
| 696 | comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| 697 | guard let url = comps.url, |
| 698 | let (data, resp) = try? await session.data(from: url), |
| 699 | (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil } |
| 700 | struct Resp: Codable { let prefs_blob: String?; let version: Int64? } |
| 701 | guard let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil } |
| 702 | return (r.prefs_blob ?? "", r.version ?? 0) |
| 703 | } |
| 704 | |
| 705 | /// Store the sealed prefs blob — the same compare-and-swap contract (and |
| 706 | /// result shape) as postNicknames: .conflict means re-read and replay. |
| 707 | public func postPrefs(blob: String, version: Int64) async -> NicknamesPost { |
| 708 | guard let url = URL(string: "api/prefs", relativeTo: baseURL), |
| 709 | let data = try? JSONSerialization.data(withJSONObject: |
| 710 | ["token": token, "prefs_blob": blob, "version": version]) else { return .failed } |
| 711 | var req = URLRequest(url: url) |
| 712 | req.httpMethod = "POST" |
| 713 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 714 | req.httpBody = data |
| 715 | guard let (body, resp) = try? await session.data(for: req), |
| 716 | let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed } |
| 717 | struct Resp: Codable { let version: Int64? } |
| 718 | let v = (try? JSONDecoder().decode(Resp.self, from: body))?.version |
| 719 | if code == 200 { return .ok(v ?? version + 1) } |
| 720 | if code == 409 { return .conflict } |
| 721 | return .failed |
| 722 | } |
| 723 | |
| 724 | /// fileReport's outcome: .tooMany is the server's 5-reports-an-hour brake |
| 725 | /// (429) — the one refusal with its own inline copy. |
| 726 | public enum ReportResult { case ok, tooMany, failed } |
| 727 | |
| 728 | /// Report a member to the space's admins (POST /api/reports, issue #578). |
| 729 | /// Each blob is the report content ECIES-sealed by THIS client to one |
| 730 | /// admin's public key — the server keeps only queue metadata (who |
| 731 | /// reported whom, where); it cannot read a report. `blobs` is |
| 732 | /// [{admin_id, blob}]; the server requires at least one and checks every |
| 733 | /// target holds the admin role. |
| 734 | public func fileReport(reportedID: String, threadID: String, |
| 735 | blobs: [[String: String]]) async -> ReportResult { |
| 736 | guard let url = URL(string: "api/reports", relativeTo: baseURL), |
| 737 | let data = try? JSONSerialization.data(withJSONObject: [ |
| 738 | "token": token, "reported_id": reportedID, |
| 739 | "thread_id": threadID, "blobs": blobs, |
| 740 | ]) else { return .failed } |
| 741 | var req = URLRequest(url: url) |
| 742 | req.httpMethod = "POST" |
| 743 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 744 | req.httpBody = data |
| 745 | guard let (_, resp) = try? await session.data(for: req), |
| 746 | let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed } |
| 747 | if code == 200 { return .ok } |
| 748 | return code == 429 ? .tooMany : .failed |
| 749 | } |
| 750 | |
| 751 | /// Whether this space still owes its admin the first-run setup |
| 752 | /// conversation (GET /api/me): we hold the admin role and setup_complete |
| 753 | /// is not yet set — shell.js maybeStartSetup's gate, natively. False on |
| 754 | /// any failure, so a flaky answer never conjures a setup conversation in |
| 755 | /// a space that has long finished one. |
| 756 | public func setupPending() async -> Bool { |
| 757 | struct Resp: Codable { let role: String?; let setup_complete: Bool? } |
| 758 | guard let me = try? await getJSON("/api/me", Resp.self) else { return false } |
| 759 | return me.role == "admin" && me.setup_complete != true |
| 760 | } |
| 761 | |
| 762 | /// The caller's pinned Away flag (GET /api/me) — what the settings toggle |
| 763 | /// shows. nil when the fetch failed. |
| 764 | public func away() async -> Bool? { |
| 765 | struct Resp: Codable { let away: Bool? } |
| 766 | return (try? await getJSON("/api/me", Resp.self))?.away |
| 767 | } |
| 768 | |
| 769 | /// Pin (true) or unpin (false) your presence to Away — stored per user, so |
| 770 | /// it follows you across devices; everyone's dots update via the broadcast. |
| 771 | @discardableResult |
| 772 | public func setAway(_ away: Bool) async -> Bool { |
| 773 | await postJSON("api/presence", ["token": token, "away": away]) |
| 774 | } |
| 775 | |
| 776 | /// The caller's DM door (GET /api/me): "" (or "everyone") = anyone in the |
| 777 | /// space may start a DM with them; "nobody" = only admins and helper bots |
| 778 | /// (issue #578). nil when the fetch failed or the server predates the |
| 779 | /// setting — callers keep their picker gated, like the away toggle. |
| 780 | public func dmPrivacy() async -> String? { |
| 781 | struct Resp: Codable { let dm_privacy: String? } |
| 782 | return (try? await getJSON("/api/me", Resp.self))?.dm_privacy |
| 783 | } |
| 784 | |
| 785 | /// Set who may START a DM with you — "everyone" or "nobody" (POST |
| 786 | /// /api/dm-privacy). Existing chats keep working; admins and bots always |
| 787 | /// pass (the operator must stay reachable, Eleven's welcome DM is how |
| 788 | /// onboarding speaks). |
| 789 | @discardableResult |
| 790 | public func setDMPrivacy(_ value: String) async -> Bool { |
| 791 | await postJSON("api/dm-privacy", ["token": token, "dm_privacy": value]) |
| 792 | } |
| 793 | |
| 794 | /// End a DM for BOTH parties (POST close-dm, issue #578): the server hides |
| 795 | /// it on both sides and refuses new messages until someone reopens it. |
| 796 | /// Strict about the status — endConversation reverts its optimistic close |
| 797 | /// on anything but a real 200. |
| 798 | public func closeDM(thread: String) async -> Bool { |
| 799 | guard let url = URL(string: "api/threads/\(thread)/close-dm", relativeTo: baseURL), |
| 800 | let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false } |
| 801 | var req = URLRequest(url: url) |
| 802 | req.httpMethod = "POST" |
| 803 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 804 | req.httpBody = data |
| 805 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 806 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 807 | } |
| 808 | |
| 809 | /// reopenDM's outcome: .dmsClosed = the OTHER side's DM door is shut |
| 810 | /// (403 {"error":"dms closed"}) — the one refusal with its own copy. |
| 811 | public enum ReopenDM { case ok, dmsClosed, failed } |
| 812 | |
| 813 | /// Reopen an ended DM (POST reopen-dm) — for both parties, like ending it. |
| 814 | public func reopenDM(thread: String) async -> ReopenDM { |
| 815 | guard let url = URL(string: "api/threads/\(thread)/reopen-dm", relativeTo: baseURL), |
| 816 | let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return .failed } |
| 817 | var req = URLRequest(url: url) |
| 818 | req.httpMethod = "POST" |
| 819 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 820 | req.httpBody = data |
| 821 | guard let (body, resp) = try? await session.data(for: req), |
| 822 | let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed } |
| 823 | if code == 200 { return .ok } |
| 824 | struct R: Codable { let error: String? } |
| 825 | let err = (try? JSONDecoder().decode(R.self, from: body))?.error ?? "" |
| 826 | return err.contains("dms closed") ? .dmsClosed : .failed |
| 827 | } |
| 828 | |
| 829 | /// Who I've blocked (GET /api/blocks, issue #578) — uids. Unlike mutes |
| 830 | /// the record lives server-side (it's what ENFORCES no new contact: |
| 831 | /// DMs, group adds, pushes). nil when the fetch failed — distinct from |
| 832 | /// empty, so a network blip never reads as "blocked no one". Hand-rolled |
| 833 | /// like nicknames(): an error body decodes as an all-nil Resp otherwise. |
| 834 | public func blocks() async -> [String]? { |
| 835 | guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/blocks"), |
| 836 | resolvingAgainstBaseURL: false) else { return nil } |
| 837 | comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| 838 | struct Resp: Codable { let blocked: [String]? } |
| 839 | guard let url = comps.url, |
| 840 | let (data, resp) = try? await session.data(from: url), |
| 841 | (resp as? HTTPURLResponse)?.statusCode == 200, |
| 842 | let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil } |
| 843 | return r.blocked ?? [] |
| 844 | } |
| 845 | |
| 846 | /// Block a person (POST /api/blocks): the server records no-new-contact |
| 847 | /// both ways AND closes any existing DM (its dm_closed frame follows, |
| 848 | /// like a manual close). Strict about the status — the profile card's |
| 849 | /// inline "Couldn't save" hangs off a real refusal, not just a network |
| 850 | /// error. |
| 851 | public func block(uid: String) async -> Bool { |
| 852 | guard let url = URL(string: "api/blocks", relativeTo: baseURL), |
| 853 | let data = try? JSONSerialization.data(withJSONObject: ["token": token, "user_id": uid]) else { return false } |
| 854 | var req = URLRequest(url: url) |
| 855 | req.httpMethod = "POST" |
| 856 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 857 | req.httpBody = data |
| 858 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 859 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 860 | } |
| 861 | |
| 862 | /// Unblock (DELETE /api/blocks/{uid}). The DM stays closed (reopening is |
| 863 | /// its own step through reopen-dm) and any mute stays put. |
| 864 | public func unblock(uid: String) async -> Bool { |
| 865 | guard let url = URL(string: "api/blocks/\(uid)", relativeTo: baseURL), |
| 866 | let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false } |
| 867 | var req = URLRequest(url: url) |
| 868 | req.httpMethod = "DELETE" |
| 869 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 870 | req.httpBody = data |
| 871 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 872 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 873 | } |
| 874 | |
| 875 | /// Remove a member from the space, optionally banning their key from ever |
| 876 | /// re-registering (issue #578) — the same admin-gated endpoints the web |
| 877 | /// Admin app's report queue uses. Bearer = this device's token; the |
| 878 | /// server checks the member behind it holds the admin role. |
| 879 | public func adminRemoveUser(uid: String, ban: Bool) async -> Bool { |
| 880 | let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid |
| 881 | guard let url = URL(string: "api/admin/users/\(escaped)/\(ban ? "ban" : "remove")", |
| 882 | relativeTo: baseURL) else { return false } |
| 883 | var req = URLRequest(url: url) |
| 884 | req.httpMethod = "POST" |
| 885 | req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
| 886 | guard let (_, resp) = try? await session.data(for: req) else { return false } |
| 887 | return (resp as? HTTPURLResponse)?.statusCode == 200 |
| 888 | } |
| 889 | |
| 890 | /// Add a member to a thread: their copy of the thread key, wrapped to |
| 891 | /// their public key (the server stores a blob it can't open). |
| 892 | @discardableResult |
| 893 | public func addMember(thread: String, userID: String, wrapped: String) async -> Bool { |
| 894 | await postJSON("api/threads/\(thread)/members", |
| 895 | ["token": token, "user_id": userID, "wrapped": wrapped]) |
| 896 | } |
| 897 | |
| 898 | /// Rename a group: the encrypted {name} blob (thread key), like the web. |
| 899 | @discardableResult |
| 900 | public func setThreadName(thread: String, nameBlob: String) async -> Bool { |
| 901 | await postJSON("api/threads/\(thread)/name", |
| 902 | ["token": token, "name_blob": nameBlob]) |
| 903 | } |
| 904 | |
| 905 | @discardableResult |
| 906 | private func postJSON(_ path: String, _ body: [String: Any]) async -> Bool { |
| 907 | guard let url = URL(string: path, relativeTo: baseURL), |
| 908 | let data = try? JSONSerialization.data(withJSONObject: body) else { return false } |
| 909 | var req = URLRequest(url: url) |
| 910 | req.httpMethod = "POST" |
| 911 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 912 | req.httpBody = data |
| 913 | let span = Perf.begin("api.post", slowMs: 1000) |
| 914 | defer { span.end(path) } |
| 915 | let resp = try? await session.data(for: req) |
| 916 | return resp != nil |
| 917 | } |
| 918 | } |
| 919 | |
| 920 | /// Process-wide fetch-service state: LChatAPI is a value type constructed |
| 921 | /// freely all over (ThreadStore's computed property, ChatFeedModel's own |
| 922 | /// copy, the share extension), so the per-instance service URL learned from |
| 923 | /// FeatureGates — and the hashcash ticket earned against a service — live |
| 924 | /// here, keyed by instance host / service base respectively. |
| 925 | final class FetchService: @unchecked Sendable { |
| 926 | static let shared = FetchService() |
| 927 | private let lock = NSLock() |
| 928 | private var urlByInstanceHost: [String: String] = [:] |
| 929 | private var ticketByBase: [String: String] = [:] |
| 930 | |
| 931 | func url(forInstanceHost host: String) -> String { |
| 932 | lock.lock(); defer { lock.unlock() } |
| 933 | return urlByInstanceHost[host] ?? "" |
| 934 | } |
| 935 | func setURL(_ url: String, forInstanceHost host: String) { |
| 936 | lock.lock(); defer { lock.unlock() } |
| 937 | urlByInstanceHost[host] = url |
| 938 | } |
| 939 | func ticket(for base: String) -> String { |
| 940 | lock.lock(); defer { lock.unlock() } |
| 941 | return ticketByBase[base] ?? "" |
| 942 | } |
| 943 | func setTicket(_ ticket: String, for base: String) { |
| 944 | lock.lock(); defer { lock.unlock() } |
| 945 | ticketByBase[base] = ticket |
| 946 | } |
| 947 | } |
| 948 | |