eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import 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.
8public 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.
925final 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