eleven / messenger Public

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

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 var current: [String: Any] = [:]
351 if let url = URL(string: "/api/admin/group", relativeTo: baseURL) {
352 var req = URLRequest(url: url)
353 req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
354 if let (data, resp) = try? await session.data(for: req),
355 (resp as? HTTPURLResponse)?.statusCode == 200,
356 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
357 current = obj
358 }
359 }
360 let body: [String: Any] = [
361 "name": name ?? (current["name"] as? String ?? ""),
362 "admin_name": current["admin_name"] as? String ?? "",
363 "admin_email": adminEmail ?? (current["admin_email"] as? String ?? ""),
364 ]
365 return await postAdmin("/api/admin/group", body)
366 }
367
368 /// POST /api/admin/settings (user_invites, setup_complete, …).
369 public func setAdminSettings(_ fields: [String: Any]) async -> Bool {
370 await postAdmin("/api/admin/settings", fields)
371 }
372
373 private func postAdmin(_ path: String, _ body: [String: Any]) async -> Bool {
374 guard let url = URL(string: path, relativeTo: baseURL),
375 let data = try? JSONSerialization.data(withJSONObject: body) else { return false }
376 var req = URLRequest(url: url)
377 req.httpMethod = "POST"
378 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
379 req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
380 req.httpBody = data
381 guard let (_, resp) = try? await session.data(for: req) else { return false }
382 return (resp as? HTTPURLResponse)?.statusCode == 200
383 }
384
385 /// Pin/unpin a chat for this user — a per-user marker the server syncs across
386 /// the user's devices and floats to the top of their list.
387 public func setPinned(thread: String, pinned: Bool) async {
388 await postJSON("/api/threads/\(thread)/pinned", ["token": token, "pinned": pinned])
389 }
390
391 /// Close (hidden=true) / reopen (hidden=false) a chat for this user — a per-user
392 /// marker synced across their devices; a closed chat drops off the list.
393 public func setHidden(thread: String, hidden: Bool) async {
394 await postJSON("/api/threads/\(thread)/hidden", ["token": token, "hidden": hidden])
395 }
396
397 /// Mute/unmute a chat for this user — a per-user marker synced across their
398 /// devices. While muted the SERVER skips every push for the thread; clients
399 /// drop the unread dot and show the slashed bell instead.
400 public func setMuted(thread: String, muted: Bool) async {
401 await postJSON("/api/threads/\(thread)/muted", ["token": token, "muted": muted])
402 }
403
404 /// Advance this user's read watermark for a thread — per-user, synced across
405 /// their devices ("read here, read everywhere"). Forward-only server-side, so
406 /// a stale write from a lagging device never regresses it.
407 public func setRead(thread: String, lastReadAt: Int64) async {
408 await postJSON("/api/threads/\(thread)/read", ["token": token, "last_read_at": lastReadAt])
409 }
410
411 /// The OpenGraph card for a posted URL — the ONE deliberate E2E exception
412 /// (unfurl.go): the server fetches the page to build the card, so it sees
413 /// the URL (never message text). Returns nil when the fetch failed or the
414 /// page offered nothing worth showing.
415 /// The shared fetch service this instance points at, if any — set from
416 /// FeatureGates by the store. Unfurls prefer it (identity-free: no token
417 /// rides along, and card images come back as its signed /img proxy URLs),
418 /// falling back to the instance's own unfurler on any transport failure.
419 public var fetchURL: String = ""
420
421 public func unfurl(_ url: URL) async -> LinkPreview? {
422 struct Resp: Decodable {
423 let url: String?
424 let title: String?
425 let description: String?
426 let image: String?
427 let ok: Bool?
428 }
429 var r: Resp?
430 // The explicit field (tests) wins; otherwise the registry entry the
431 // store latched from FeatureGates — every LChatAPI copy for this
432 // instance routes alike, however it was constructed.
433 let svc = !fetchURL.isEmpty ? fetchURL
434 : FetchService.shared.url(forInstanceHost: baseURL.host ?? "")
435 if !svc.isEmpty,
436 let query = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics),
437 let data = await fetchServiceGET(svc, "/api/unfurl?url=" + query) {
438 // A decoded answer — even ok:false — is real (the page offered
439 // nothing); only transport failures fall through to the instance.
440 r = try? JSONDecoder().decode(Resp.self, from: data)
441 }
442 if r == nil {
443 let items = [
444 URLQueryItem(name: "token", value: token),
445 URLQueryItem(name: "url", value: url.absoluteString),
446 ]
447 r = try? await getJSON("/api/unfurl", Resp.self, query: items)
448 }
449 guard let r, r.ok == true, let title = r.title, !title.isEmpty else { return nil }
450 return LinkPreview(url: URL(string: r.url ?? "") ?? url, title: title,
451 description: r.description ?? "", imageURL: URL(string: r.image ?? ""))
452 }
453
454 /// One GET against the shared fetch service (docs/fetch.md), handling its
455 /// optional hashcash admission: a 401 carries {pow:{challenge,bits}} —
456 /// grind SHA-256(challenge ":" nonce) to `bits` leading zeros off-actor,
457 /// retry with the solution, and keep the returned ticket (process-wide,
458 /// FetchTickets) so it's one solve per ~hour, not per request. Query
459 /// params, matching the web client. nil = transport failure or non-200,
460 /// which callers treat as "fall back to the instance".
461 func fetchServiceGET(_ base: String, _ path: String) async -> Data? {
462 let sep = path.contains("?") ? "&" : "?"
463 func get(_ extra: String) async -> (Data, HTTPURLResponse)? {
464 guard let u = URL(string: base + path + extra),
465 let (data, resp) = try? await session.data(from: u),
466 let http = resp as? HTTPURLResponse else { return nil }
467 if let t = http.value(forHTTPHeaderField: "X-Fetch-Ticket"), !t.isEmpty {
468 FetchService.shared.setTicket(t, for: base)
469 }
470 return (data, http)
471 }
472 let ticket = FetchService.shared.ticket(for: base)
473 let extra = ticket.isEmpty ? "" :
474 sep + "ticket=" + (ticket.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "")
475 guard var (data, http) = await get(extra) else { return nil }
476 if http.statusCode == 401 {
477 struct Gate: Decodable {
478 struct Pow: Decodable { let challenge: String; let bits: Int }
479 let pow: Pow?
480 }
481 guard let pow = (try? JSONDecoder().decode(Gate.self, from: data))?.pow else { return nil }
482 let nonce = await Task.detached(priority: .userInitiated) {
483 var n = 0
484 while !LChatAPI.powSolves(pow.challenge, String(n), pow.bits) { n += 1 }
485 return n
486 }.value
487 let solved = sep + "pow=" +
488 (pow.challenge.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "") +
489 "&nonce=\(nonce)"
490 guard let again = await get(solved) else { return nil }
491 (data, http) = again
492 }
493 return http.statusCode == 200 ? data : nil
494 }
495
496 /// SHA-256(challenge ":" nonce) clears `bits` leading zero bits — the
497 /// same check as the server's (fetchpow.go) and home's space-creation gate.
498 static func powSolves(_ challenge: String, _ nonce: String, _ bits: Int) -> Bool {
499 var count = 0
500 for b in SHA256.hash(data: Data((challenge + ":" + nonce).utf8)) {
501 if b == 0 { count += 8; continue }
502 var mask: UInt8 = 0x80
503 while mask != 0 {
504 if b & mask != 0 { return count >= bits }
505 count += 1
506 mask >>= 1
507 }
508 }
509 return count >= bits
510 }
511
512 /// The instance's display name, or "" when it was never named, so callers
513 /// fall back to the host instead of parroting the compiled-in default.
514 /// The server's `name_set` flag is the authority (an admin-chosen name —
515 /// even literally "lchat" — always shows); the literal check is only the
516 /// degraded path for older servers that don't send the flag.
517 public func groupName() async -> String {
518 struct Resp: Codable { let name: String?; let name_set: Bool?; let sealed: Bool? }
519 guard let r = try? await getJSON("/api/group", Resp.self, authed: false) else { return "" }
520 // Sealed branding (docs/no-plaintext.md): the readable name lives in a
521 // blob this build can't open yet — degrade to "" so callers use their
522 // host fallback rather than parroting the server's compiled-in default.
523 if r.sealed == true { return "" }
524 return Self.chosenName(r.name, nameSet: r.name_set)
525 }
526
527 /// Public per-instance branding: the display name ("" when never named —
528 /// see groupName) plus whether a server photo is set. Both are public
529 /// (shown during add-a-chat, before any key exists). `sealed` tells a
530 /// key-holding caller to take the decrypting path instead
531 /// (SealedBranding.swift); keyless callers just render the fallbacks.
532 public func serverInfo() async -> (name: String, hasAvatar: Bool, sealed: Bool) {
533 struct Resp: Codable { let name: String?; let name_set: Bool?; let avatar: Bool?; let sealed: Bool? }
534 let r = try? await getJSON("/api/group", Resp.self, authed: false)
535 // Sealed: no readable name, and the photo endpoint serves ciphertext —
536 // report unnamed + photoless so keyless surfaces degrade gracefully.
537 if r?.sealed == true { return ("", false, true) }
538 return (Self.chosenName(r?.name, nameSet: r?.name_set), r?.avatar ?? false, false)
539 }
540
541 static func chosenName(_ name: String?, nameSet: Bool?) -> String {
542 let n = name ?? ""
543 if let nameSet { return nameSet ? n : "" }
544 return n == "lchat" ? "" : n // pre-name_set servers: filter the default
545 }
546
547 /// Per-instance feature gates (admin-set, default on): whether this space
548 /// offers bot creation, apps, and AI replies. They ride the member-gated
549 /// part of /api/group, so this read is authed; optionals in the decode so
550 /// servers that predate the gates (and failed fetches) read as enabled.
551 /// The server enforces each gate — these only hide entry points.
552 public struct FeatureGates: Sendable {
553 public let botsEnabled: Bool
554 public let appsEnabled: Bool
555 public let aiEnabled: Bool
556 /// The fetch service this instance prefers for unfurls (docs/fetch.md):
557 /// "" = the instance says unfurl locally; nil = the gates read itself
558 /// failed (keep whatever routing we already hold — a network blip must
559 /// not silently reroute unfurls).
560 public let fetchURL: String?
561 }
562
563 public func featureGates() async -> FeatureGates {
564 struct Resp: Codable {
565 let bots_enabled: Bool?; let apps_enabled: Bool?; let ai_enabled: Bool?
566 let fetch_url: String?
567 }
568 let r = try? await getJSON("/api/group", Resp.self)
569 return FeatureGates(botsEnabled: r?.bots_enabled ?? true,
570 appsEnabled: r?.apps_enabled ?? true,
571 aiEnabled: r?.ai_enabled ?? true,
572 fetchURL: r == nil ? nil : (r?.fetch_url ?? ""))
573 }
574
575 /// The instance's public branding photo bytes, or nil if none is set. Served
576 /// in the clear (no key exists yet at add-a-chat time), so this needs no token.
577 public func serverAvatar() async -> Data? {
578 guard let url = URL(string: "/api/server-avatar", relativeTo: baseURL),
579 let (data, resp) = try? await session.data(from: url),
580 let http = resp as? HTTPURLResponse, http.statusCode == 200,
581 !data.isEmpty,
582 // Sealed-branding ciphertext, not an image — callers would just
583 // fail the decode; skip it explicitly.
584 http.value(forHTTPHeaderField: "Content-Type") != "application/vnd.lchat.sealed"
585 else { return nil }
586 return data
587 }
588
589 // MARK: Plumbing
590
591 func getJSON<T: Decodable>(
592 _ path: String, _ type: T.Type, query: [URLQueryItem]? = nil, authed: Bool = true
593 ) async throws -> T {
594 var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)!
595 comps.queryItems = query ?? (authed ? [URLQueryItem(name: "token", value: token)] : nil)
596 // Host + path — never the query (it carries the token). The host is
597 // what tells a multi-space burst apart from one store stampeding.
598 let span = Perf.begin("api.get", slowMs: 1000)
599 defer { span.end("\(baseURL.host ?? "?") \(path)") }
600 let (data, _) = try await session.data(from: comps.url!)
601 return try JSONDecoder().decode(T.self, from: data)
602 }
603
604 /// Create a member invite (a pending DM + join link): all blobs are
605 /// opaque to the server. Returns (thread_id, nil) or (nil, error text —
606 /// e.g. the admin has inviting turned off).
607 public func createUserInvite(labelBlob: String, id: String, wrapped: String,
608 selfWrapped: String, claimHash: String)
609 async -> (threadID: String?, userID: String?, error: String?) {
610 guard let url = URL(string: "api/invites", relativeTo: baseURL),
611 let data = try? JSONSerialization.data(withJSONObject: [
612 "token": token, "label_blob": labelBlob, "id": id,
613 "wrapped": wrapped, "self_wrapped": selfWrapped,
614 "claim_hash": claimHash,
615 ]) else { return (nil, nil, "bad request") }
616 var req = URLRequest(url: url)
617 req.httpMethod = "POST"
618 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
619 req.httpBody = data
620 guard let (body, resp) = try? await session.data(for: req),
621 let http = resp as? HTTPURLResponse else { return (nil, nil, "network error") }
622 struct R: Codable { var thread_id: String?; var user_id: String?; var error: String? }
623 let r = try? JSONDecoder().decode(R.self, from: body)
624 guard http.statusCode == 200, let tid = r?.thread_id else {
625 return (nil, nil, r?.error ?? "invite failed")
626 }
627 return (tid, r?.user_id, nil)
628 }
629
630 /// Publish the caller's display name — encrypted under the instance key
631 /// (the server blanks the plaintext label once one is set this way).
632 @discardableResult
633 public func postLabel(labelBlob: String) async -> Bool {
634 await postJSON("api/label", ["token": token, "label_blob": labelBlob])
635 }
636
637 /// The caller's sealed per-viewer names blob (#577) and its version ("",
638 /// 0 when never set). nil when the fetch failed — distinct from empty, so
639 /// a network blip can never be mistaken for "no nicknames" and overwritten.
640 /// Hand-rolled rather than getJSON: the status must be checked, or a JSON
641 /// error body ({"error": …} on 403/500) decodes as an all-nil Resp and
642 /// masquerades as "never set" — the exact wipe this contract exists to stop.
643 public func nicknames() async -> (blob: String, version: Int64)? {
644 guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/nicknames"),
645 resolvingAgainstBaseURL: false) else { return nil }
646 comps.queryItems = [URLQueryItem(name: "token", value: token)]
647 guard let url = comps.url,
648 let (data, resp) = try? await session.data(from: url),
649 (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
650 struct Resp: Codable { let nicknames_blob: String?; let version: Int64? }
651 guard let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil }
652 return (r.nicknames_blob ?? "", r.version ?? 0)
653 }
654
655 public enum NicknamesPost { case ok(Int64), conflict, failed }
656
657 /// Store the sealed nicknames blob with compare-and-swap: `version` is
658 /// what the caller last read. .conflict means another device wrote in
659 /// between — re-read, replay the pending ops, retry.
660 public func postNicknames(blob: String, version: Int64) async -> NicknamesPost {
661 guard let url = URL(string: "api/nicknames", relativeTo: baseURL),
662 let data = try? JSONSerialization.data(withJSONObject:
663 ["token": token, "nicknames_blob": blob, "version": version]) else { return .failed }
664 var req = URLRequest(url: url)
665 req.httpMethod = "POST"
666 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
667 req.httpBody = data
668 guard let (body, resp) = try? await session.data(for: req),
669 let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed }
670 struct Resp: Codable { let version: Int64? }
671 let v = (try? JSONDecoder().decode(Resp.self, from: body))?.version
672 if code == 200 { return .ok(v ?? version + 1) }
673 if code == 409 { return .conflict }
674 return .failed
675 }
676
677 /// The caller's sealed moderation-prefs blob (muted people, #578) and its
678 /// version — the exact contract nicknames() has, including the nil-vs-empty
679 /// distinction (nil = fetch failed, never mistake it for "nothing muted").
680 public func prefs() async -> (blob: String, version: Int64)? {
681 guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/prefs"),
682 resolvingAgainstBaseURL: false) else { return nil }
683 comps.queryItems = [URLQueryItem(name: "token", value: token)]
684 guard let url = comps.url,
685 let (data, resp) = try? await session.data(from: url),
686 (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
687 struct Resp: Codable { let prefs_blob: String?; let version: Int64? }
688 guard let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil }
689 return (r.prefs_blob ?? "", r.version ?? 0)
690 }
691
692 /// Store the sealed prefs blob — the same compare-and-swap contract (and
693 /// result shape) as postNicknames: .conflict means re-read and replay.
694 public func postPrefs(blob: String, version: Int64) async -> NicknamesPost {
695 guard let url = URL(string: "api/prefs", relativeTo: baseURL),
696 let data = try? JSONSerialization.data(withJSONObject:
697 ["token": token, "prefs_blob": blob, "version": version]) else { return .failed }
698 var req = URLRequest(url: url)
699 req.httpMethod = "POST"
700 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
701 req.httpBody = data
702 guard let (body, resp) = try? await session.data(for: req),
703 let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed }
704 struct Resp: Codable { let version: Int64? }
705 let v = (try? JSONDecoder().decode(Resp.self, from: body))?.version
706 if code == 200 { return .ok(v ?? version + 1) }
707 if code == 409 { return .conflict }
708 return .failed
709 }
710
711 /// fileReport's outcome: .tooMany is the server's 5-reports-an-hour brake
712 /// (429) — the one refusal with its own inline copy.
713 public enum ReportResult { case ok, tooMany, failed }
714
715 /// Report a member to the space's admins (POST /api/reports, issue #578).
716 /// Each blob is the report content ECIES-sealed by THIS client to one
717 /// admin's public key — the server keeps only queue metadata (who
718 /// reported whom, where); it cannot read a report. `blobs` is
719 /// [{admin_id, blob}]; the server requires at least one and checks every
720 /// target holds the admin role.
721 public func fileReport(reportedID: String, threadID: String,
722 blobs: [[String: String]]) async -> ReportResult {
723 guard let url = URL(string: "api/reports", relativeTo: baseURL),
724 let data = try? JSONSerialization.data(withJSONObject: [
725 "token": token, "reported_id": reportedID,
726 "thread_id": threadID, "blobs": blobs,
727 ]) else { return .failed }
728 var req = URLRequest(url: url)
729 req.httpMethod = "POST"
730 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
731 req.httpBody = data
732 guard let (_, resp) = try? await session.data(for: req),
733 let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed }
734 if code == 200 { return .ok }
735 return code == 429 ? .tooMany : .failed
736 }
737
738 /// The caller's pinned Away flag (GET /api/me) — what the settings toggle
739 /// shows. nil when the fetch failed.
740 public func away() async -> Bool? {
741 struct Resp: Codable { let away: Bool? }
742 return (try? await getJSON("/api/me", Resp.self))?.away
743 }
744
745 /// Pin (true) or unpin (false) your presence to Away — stored per user, so
746 /// it follows you across devices; everyone's dots update via the broadcast.
747 @discardableResult
748 public func setAway(_ away: Bool) async -> Bool {
749 await postJSON("api/presence", ["token": token, "away": away])
750 }
751
752 /// The caller's DM door (GET /api/me): "" (or "everyone") = anyone in the
753 /// space may start a DM with them; "nobody" = only admins and helper bots
754 /// (issue #578). nil when the fetch failed or the server predates the
755 /// setting — callers keep their picker gated, like the away toggle.
756 public func dmPrivacy() async -> String? {
757 struct Resp: Codable { let dm_privacy: String? }
758 return (try? await getJSON("/api/me", Resp.self))?.dm_privacy
759 }
760
761 /// Set who may START a DM with you — "everyone" or "nobody" (POST
762 /// /api/dm-privacy). Existing chats keep working; admins and bots always
763 /// pass (the operator must stay reachable, Eleven's welcome DM is how
764 /// onboarding speaks).
765 @discardableResult
766 public func setDMPrivacy(_ value: String) async -> Bool {
767 await postJSON("api/dm-privacy", ["token": token, "dm_privacy": value])
768 }
769
770 /// End a DM for BOTH parties (POST close-dm, issue #578): the server hides
771 /// it on both sides and refuses new messages until someone reopens it.
772 /// Strict about the status — endConversation reverts its optimistic close
773 /// on anything but a real 200.
774 public func closeDM(thread: String) async -> Bool {
775 guard let url = URL(string: "api/threads/\(thread)/close-dm", relativeTo: baseURL),
776 let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false }
777 var req = URLRequest(url: url)
778 req.httpMethod = "POST"
779 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
780 req.httpBody = data
781 guard let (_, resp) = try? await session.data(for: req) else { return false }
782 return (resp as? HTTPURLResponse)?.statusCode == 200
783 }
784
785 /// reopenDM's outcome: .dmsClosed = the OTHER side's DM door is shut
786 /// (403 {"error":"dms closed"}) — the one refusal with its own copy.
787 public enum ReopenDM { case ok, dmsClosed, failed }
788
789 /// Reopen an ended DM (POST reopen-dm) — for both parties, like ending it.
790 public func reopenDM(thread: String) async -> ReopenDM {
791 guard let url = URL(string: "api/threads/\(thread)/reopen-dm", relativeTo: baseURL),
792 let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return .failed }
793 var req = URLRequest(url: url)
794 req.httpMethod = "POST"
795 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
796 req.httpBody = data
797 guard let (body, resp) = try? await session.data(for: req),
798 let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed }
799 if code == 200 { return .ok }
800 struct R: Codable { let error: String? }
801 let err = (try? JSONDecoder().decode(R.self, from: body))?.error ?? ""
802 return err.contains("dms closed") ? .dmsClosed : .failed
803 }
804
805 /// Who I've blocked (GET /api/blocks, issue #578) — uids. Unlike mutes
806 /// the record lives server-side (it's what ENFORCES no new contact:
807 /// DMs, group adds, pushes). nil when the fetch failed — distinct from
808 /// empty, so a network blip never reads as "blocked no one". Hand-rolled
809 /// like nicknames(): an error body decodes as an all-nil Resp otherwise.
810 public func blocks() async -> [String]? {
811 guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/blocks"),
812 resolvingAgainstBaseURL: false) else { return nil }
813 comps.queryItems = [URLQueryItem(name: "token", value: token)]
814 struct Resp: Codable { let blocked: [String]? }
815 guard let url = comps.url,
816 let (data, resp) = try? await session.data(from: url),
817 (resp as? HTTPURLResponse)?.statusCode == 200,
818 let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil }
819 return r.blocked ?? []
820 }
821
822 /// Block a person (POST /api/blocks): the server records no-new-contact
823 /// both ways AND closes any existing DM (its dm_closed frame follows,
824 /// like a manual close). Strict about the status — the profile card's
825 /// inline "Couldn't save" hangs off a real refusal, not just a network
826 /// error.
827 public func block(uid: String) async -> Bool {
828 guard let url = URL(string: "api/blocks", relativeTo: baseURL),
829 let data = try? JSONSerialization.data(withJSONObject: ["token": token, "user_id": uid]) else { return false }
830 var req = URLRequest(url: url)
831 req.httpMethod = "POST"
832 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
833 req.httpBody = data
834 guard let (_, resp) = try? await session.data(for: req) else { return false }
835 return (resp as? HTTPURLResponse)?.statusCode == 200
836 }
837
838 /// Unblock (DELETE /api/blocks/{uid}). The DM stays closed (reopening is
839 /// its own step through reopen-dm) and any mute stays put.
840 public func unblock(uid: String) async -> Bool {
841 guard let url = URL(string: "api/blocks/\(uid)", relativeTo: baseURL),
842 let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false }
843 var req = URLRequest(url: url)
844 req.httpMethod = "DELETE"
845 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
846 req.httpBody = data
847 guard let (_, resp) = try? await session.data(for: req) else { return false }
848 return (resp as? HTTPURLResponse)?.statusCode == 200
849 }
850
851 /// Remove a member from the space, optionally banning their key from ever
852 /// re-registering (issue #578) — the same admin-gated endpoints the web
853 /// Admin app's report queue uses. Bearer = this device's token; the
854 /// server checks the member behind it holds the admin role.
855 public func adminRemoveUser(uid: String, ban: Bool) async -> Bool {
856 let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
857 guard let url = URL(string: "api/admin/users/\(escaped)/\(ban ? "ban" : "remove")",
858 relativeTo: baseURL) else { return false }
859 var req = URLRequest(url: url)
860 req.httpMethod = "POST"
861 req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
862 guard let (_, resp) = try? await session.data(for: req) else { return false }
863 return (resp as? HTTPURLResponse)?.statusCode == 200
864 }
865
866 /// Add a member to a thread: their copy of the thread key, wrapped to
867 /// their public key (the server stores a blob it can't open).
868 @discardableResult
869 public func addMember(thread: String, userID: String, wrapped: String) async -> Bool {
870 await postJSON("api/threads/\(thread)/members",
871 ["token": token, "user_id": userID, "wrapped": wrapped])
872 }
873
874 /// Rename a group: the encrypted {name} blob (thread key), like the web.
875 @discardableResult
876 public func setThreadName(thread: String, nameBlob: String) async -> Bool {
877 await postJSON("api/threads/\(thread)/name",
878 ["token": token, "name_blob": nameBlob])
879 }
880
881 @discardableResult
882 private func postJSON(_ path: String, _ body: [String: Any]) async -> Bool {
883 guard let url = URL(string: path, relativeTo: baseURL),
884 let data = try? JSONSerialization.data(withJSONObject: body) else { return false }
885 var req = URLRequest(url: url)
886 req.httpMethod = "POST"
887 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
888 req.httpBody = data
889 let span = Perf.begin("api.post", slowMs: 1000)
890 defer { span.end(path) }
891 let resp = try? await session.data(for: req)
892 return resp != nil
893 }
894}
895
896/// Process-wide fetch-service state: LChatAPI is a value type constructed
897/// freely all over (ThreadStore's computed property, ChatFeedModel's own
898/// copy, the share extension), so the per-instance service URL learned from
899/// FeatureGates — and the hashcash ticket earned against a service — live
900/// here, keyed by instance host / service base respectively.
901final class FetchService: @unchecked Sendable {
902 static let shared = FetchService()
903 private let lock = NSLock()
904 private var urlByInstanceHost: [String: String] = [:]
905 private var ticketByBase: [String: String] = [:]
906
907 func url(forInstanceHost host: String) -> String {
908 lock.lock(); defer { lock.unlock() }
909 return urlByInstanceHost[host] ?? ""
910 }
911 func setURL(_ url: String, forInstanceHost host: String) {
912 lock.lock(); defer { lock.unlock() }
913 urlByInstanceHost[host] = url
914 }
915 func ticket(for base: String) -> String {
916 lock.lock(); defer { lock.unlock() }
917 return ticketByBase[base] ?? ""
918 }
919 func setTicket(_ ticket: String, for base: String) {
920 lock.lock(); defer { lock.unlock() }
921 ticketByBase[base] = ticket
922 }
923}
924