eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– Native "Create a new space": the pure fader math (a port of home's
4// web/static/fader.js β€” home/test/fader.test.mjs pins the same values on the
5// web side, FaderMathTests pins these) and the claim pipeline that turns the
6// provisioner's setup link into a working SavedInstance without any webview.
7//
8// The claim mirrors web/static/join.js's setup-invite branch exactly: derive
9// {id, wrapKey, claimSecret} from the fragment secret (the server never sees
10// T), claim for a device token, generate a fresh identity keypair, register
11// the pubkey β€” then, because we're the space's first member, bootstrap the
12// instance key (mirroring shell.js setupInstanceKey's first-member branch)
13// and publish our encrypted label under it. The server stores only blobs.
14
15// ---- fader math (see fader.js β€” keep the constants in lockstep) ------------
16
17public enum FaderPhase: Equatable {
18 case idle, slide, snap, build, done
19}
20
21public enum FaderMath {
22 /// 1..10 live in the first 86% of the slot; 11 sits past the gap.
23 public static let runEnd = 0.86
24 /// Continued pull at 10 before the snap to 11 (ms).
25 public static let holdMS = 900.0
26 /// The wild window between the snap and the building pane (ms).
27 public static let snapMS = 950.0
28 /// Base shake amplitude (points) at full level; multiplier at eleven.
29 public static let shake = 1.7
30 public static let cranked = 2.3
31
32 /// Position β†’ value: linear 1..10 across the run, clamped β€” eleven is
33 /// never reachable by position, only by the hold.
34 public static func value(fraction: Double) -> Double {
35 1 + min(1, max(0, fraction / runEnd)) * 9
36 }
37
38 /// The scene's "loudness" (shake/dust/LED drive): silent below 5, ramping
39 /// to 5/6 at 10, pinned PAST the scale (1.2) for the whole snap+build, and
40 /// cut dead at done (silence before the confetti).
41 public static func level(phase: FaderPhase, value: Double, pressure: Double) -> Double {
42 if phase == .snap || phase == .build { return 1.2 }
43 if phase == .done { return 0 }
44 return max(0, (value - 5) / 6) + pressure * 0.25
45 }
46}
47
48// ---- the claim pipeline -----------------------------------------------------
49
50public struct SpaceClaimError: LocalizedError {
51 public let message: String
52 public var errorDescription: String? { message }
53 init(_ m: String) { message = m }
54}
55
56/// The whole native pipeline, run under "Building your space" and finished
57/// BEFORE success is declared: provision via home, claim the setup link, and
58/// apply the minimal setup β€” so quitting at the confetti can't strand a
59/// half-born space, and setup_complete (the reaper's not-abandoned signal)
60/// never rides on a fire-and-forget call after the fact.
61public enum SpaceCreationFlow {
62 public static func run(screenName: String) async throws -> SavedInstance {
63 guard let session = HomeSessionStore.load() else {
64 throw SpaceClaimError("Sign in first β€” your new space is saved into your account.")
65 }
66 let creation = try await HomeClient().createSpace(token: session.token)
67 let inst = try await SpaceClaim.claim(setupLink: creation.setup_link,
68 screenName: screenName)
69 await SpaceSetup.finish(origin: inst.origin, token: inst.deviceToken, name: "")
70 // Eleven's setup conversation: create its app-thread now and flag the
71 // landing, so the creator's first screen is that chat (SetupLanding).
72 // Best-effort β€” without it they land on the (empty) list, like today.
73 if await SpaceSetup.createSetupThread(inst) {
74 await SetupLanding.markPending(inst)
75 }
76 return inst
77 }
78}
79
80public enum SpaceClaim {
81 static let cantReach = "Couldn't reach your new space β€” check your connection and try again."
82
83 /// Claim any `/join/…#<T>` link natively and stand the space up for this
84 /// device β€” all three invite shapes join.js handles, no webview:
85 /// - setup / generic member invite (no wrapped payload): fresh keypair;
86 /// - DM invite ({threadKey}): fresh keypair, the thread key re-wrapped to
87 /// it and sent with the claim (the server stores our copy);
88 /// - add-device ({priv, pub}): the carried identity is adopted verbatim.
89 /// Returns a ready-to-add SavedInstance (myUserId resolved when the roster
90 /// already lists us; else on first thread-list load, like any instance).
91 public static func claim(setupLink: String, screenName: String) async throws -> SavedInstance {
92 guard let (origin, joinURL) = parseJoinLink(setupLink),
93 let base = URL(string: origin),
94 let t = inviteSecret(fromFragment: joinURL.fragment ?? ""),
95 let derived = try? InviteCrypto.derive(t) else {
96 throw SpaceClaimError("That link doesn't look right.")
97 }
98
99 // The invite must still be live (single-use; the server only ever sees
100 // the derived id, never T). A network failure is a network failure β€”
101 // never misreported as "expired" (the retry-able and the dead-end are
102 // different problems).
103 struct InviteInfo: Decodable { let ok: Bool?; let wrapped: String?; let grants_admin: Bool? }
104 let info: InviteInfo
105 do {
106 info = try await getJSON(base.appending(path: "/api/key-invite/\(derived.id)"))
107 } catch {
108 throw SpaceClaimError(cantReach)
109 }
110 guard info.ok == true else {
111 throw SpaceClaimError("This link has expired or was already used.")
112 }
113
114 // The wrapped payload (when present) decides the shape.
115 struct InvitePayload: Decodable { let priv: String?; let pub: String?; let threadKey: String? }
116 var keypair: (pub: String, priv: String)
117 var wrappedSelf = "" // a DM invite's thread key, re-wrapped to us
118 var suggested = "" // add-device links carry the name in the fragment
119 var addDevice = false // add-device re-registers an existing key: no new bind-MAC
120 if let w = info.wrapped, !w.isEmpty {
121 guard let payload = try? derived.wrapKey.decryptJSON(InvitePayload.self, blob: w) else {
122 throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).")
123 }
124 if let priv = payload.priv, let pub = payload.pub {
125 // Add-device: sign in as the existing identity.
126 keypair = (pub, priv)
127 addDevice = true
128 suggested = suggestedName(fromFragment: joinURL.fragment ?? "") ?? "Me"
129 } else if let tk = payload.threadKey, let raw = Data(base64urlEncoded: tk) {
130 // DM invite: fresh identity, our copy of the DM key rides with
131 // the claim (join.js wrapThreadKey(threadKey, kp.publicKey)).
132 keypair = ECIES.generateUserKeypair()
133 wrappedSelf = (try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub)) ?? ""
134 guard !wrappedSelf.isEmpty else {
135 throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).")
136 }
137 } else {
138 throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).")
139 }
140 } else {
141 keypair = ECIES.generateUserKeypair()
142 }
143
144 // Claim: proof-of-possession is the derived claim secret.
145 struct ClaimResp: Decodable { let ok: Bool?; let token: String? }
146 let claim: ClaimResp
147 do {
148 claim = try await postJSON(
149 base.appending(path: "/api/key-invite/\(derived.id)/claim"),
150 body: ["wrapped": wrappedSelf, "claim_secret": derived.claimSecret])
151 } catch {
152 throw SpaceClaimError(cantReach)
153 }
154 guard claim.ok == true, let token = claim.token, !token.isEmpty else {
155 throw SpaceClaimError("Couldn't claim this link β€” it may already be used.")
156 }
157
158 let screenName = screenName.isEmpty ? suggested : screenName
159 let api = LChatAPI(baseURL: base, token: token)
160 // Key transparency (#573): a fresh joiner MACs its key under the
161 // T-derived bind key so the inviter can verify it wasn't substituted.
162 // Add-device keeps its original binding (no new MAC).
163 let bindMac = addDevice ? "" : (InviteCrypto.bindMac(bindKeyB64: derived.bindKey, pubB64: keypair.pub) ?? "")
164 await api.ensurePubkey(keypair.pub, bindMac: bindMac)
165
166 var inst = SavedInstance(origin: origin, deviceToken: token,
167 screenName: screenName,
168 userPub: keypair.pub, userPriv: keypair.priv)
169 inst.myUserId = await api.roster()
170 .first(where: { $0.public_key == keypair.pub })?.user_id ?? ""
171
172 // First member of a brand-new space: bootstrap the instance key
173 // (shell.js setupInstanceKey's mint branch) so labels, profile photos
174 // and native invites work without the space ever being opened on web.
175 // Best-effort β€” a web visit heals a missing key via the nudge path β€”
176 // but the label publishes ONLY once the grant verifiably landed:
177 // a label under a key the server never kept decrypts for no one.
178 if !inst.myUserId.isEmpty,
179 let status = await api.instanceKeyStatus(),
180 status.wrapped == nil {
181 if status.have.isEmpty {
182 let raw = ECIES.randomAESKey()
183 if let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub),
184 await api.grantInstanceKey([["user_id": inst.myUserId, "wrapped": wrapped]]),
185 !screenName.isEmpty, let ik = try? GroupKey(rawKey: raw),
186 let blob = try? ik.encryptJSON(screenName) {
187 await api.postLabel(labelBlob: blob)
188 }
189 } else {
190 // Joining an existing space: the key exists but no copy is
191 // wrapped to us yet. Nudge any online holder right now (the
192 // web's setupInstanceKey keyless branch); ThreadStore keeps
193 // re-nudging on foreground + a slow timer until the grant
194 // lands β€” a keyless member renders everyone as "Someone".
195 await api.nudgeInstanceKey()
196 }
197 }
198
199 // A fresh member/DM-invite joiner β€” not add-device (an existing
200 // member's new device) and not an admin-granting setup link (that
201 // path lands in Eleven's SETUP conversation via SpaceCreationFlow):
202 // create Eleven's welcome app-thread now and flag the landing, so
203 // their first screen is the welcome conversation asking their name β€”
204 // the native twin of join.js markWelcomePending + shell.js
205 // maybeStartWelcome. Without it a native joiner never meets Eleven,
206 // is never asked a name, and stays "Someone" to everyone (#1376).
207 if !addDevice, info.grants_admin != true,
208 await SpaceSetup.createWelcomeThread(inst) {
209 await WelcomeLanding.markPending(inst)
210 }
211 return inst
212 }
213
214 /// The invite secret from a `#fragment`: strip the "e1." app-hint prefix
215 /// and any "~<suggested name>" tail (invite.js parseInviteFragment).
216 static func inviteSecret(fromFragment fragment: String) -> String? {
217 var f = fragment
218 if f.hasPrefix("e1.") { f = String(f.dropFirst(3)) }
219 if let tilde = f.firstIndex(of: "~") { f = String(f[..<tilde]) }
220 return f.isEmpty ? nil : f
221 }
222
223 /// The "~<encoded name>" tail some links carry (invite.js: the inviter's
224 /// suggestion / an add-device link's owner) β€” never sent to any server.
225 /// Sealed instances append a third "~<branding key>" slot
226 /// (docs/no-plaintext.md); it is not part of the name.
227 static func suggestedName(fromFragment fragment: String) -> String? {
228 var slots = fragmentSlots(fragment)
229 guard slots.count >= 2 else { return nil }
230 if slots.count >= 3, isBrandingKey(slots[slots.count - 1]) { slots.removeLast() }
231 let raw = slots[1...].joined(separator: "~") // legacy links may carry a raw "~" in the name
232 let name = raw.removingPercentEncoding ?? raw
233 return name.isEmpty ? nil : name
234 }
235
236 /// The branding key from a sealed instance's link (the third fragment
237 /// slot) β€” held for the sealed-branding rendering path; never sent to any
238 /// server. Nil when the link predates sealing or the tail isn't key-shaped.
239 static func brandingKey(fromFragment fragment: String) -> String? {
240 let slots = fragmentSlots(fragment)
241 guard slots.count >= 3, isBrandingKey(slots[slots.count - 1]) else { return nil }
242 return slots[slots.count - 1]
243 }
244
245 private static func fragmentSlots(_ fragment: String) -> [String] {
246 var f = fragment
247 if f.hasPrefix("e1.") { f = String(f.dropFirst(3)) }
248 return f.split(separator: "~", omittingEmptySubsequences: false).map(String.init)
249 }
250
251 /// invite.js BRANDING_KEY_RE: lowercase base32, 8–32 chars.
252 private static func isBrandingKey(_ s: String) -> Bool {
253 guard s.count >= 8, s.count <= 32 else { return false }
254 return s.allSatisfy { ("a"..."z").contains($0) || ("2"..."7").contains($0) }
255 }
256
257 private static func getJSON<T: Decodable>(_ url: URL) async throws -> T {
258 let (data, _) = try await URLSession.shared.data(from: url)
259 return try JSONDecoder().decode(T.self, from: data)
260 }
261
262 private static func postJSON<T: Decodable>(_ url: URL, body: [String: Any]) async throws -> T {
263 var req = URLRequest(url: url)
264 req.httpMethod = "POST"
265 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
266 req.httpBody = try JSONSerialization.data(withJSONObject: body)
267 let (data, _) = try await URLSession.shared.data(for: req)
268 return try JSONDecoder().decode(T.self, from: data)
269 }
270}
271
272// ---- minimal native setup (the conversation's side effects, sans script) ----
273
274public enum SpaceSetup {
275 /// Name the space and mark setup done β€” the two server-visible effects of
276 /// the web setup conversation a native creator needs (the rest β€” photo,
277 /// contact details β€” live in the Admin app / admin console). The claimed
278 /// device token authorises both: its user holds the admin role
279 /// (requireAdmin's second door), so no admin token ever touches the app.
280 /// Member invites default ON, matching the seeded LCHAT_SKIP_SETUP posture.
281 /// The name is plaintext branding by design (it must render where no key
282 /// exists yet); the member's own screen name is NOT sent β€” that stays E2E.
283 @discardableResult
284 public static func finish(origin: String, token: String, name: String) async -> Bool {
285 guard let base = URL(string: origin) else { return false }
286 let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
287 var ok = true
288 if !trimmed.isEmpty {
289 // setGroup clears any field it isn't sent; a fresh space has an
290 // empty trio, so sending empties alongside the name is faithful.
291 ok = await retrying { await post(base, "/api/admin/group", token: token,
292 json: ["name": String(trimmed.prefix(60)),
293 "admin_name": "", "admin_email": ""]) } && ok
294 }
295 // setup_complete is the reaper's not-abandoned signal β€” worth retries.
296 ok = await retrying { await post(base, "/api/admin/settings", token: token,
297 json: ["user_invites": true, "setup_complete": true]) } && ok
298 return ok
299 }
300
301 /// Create the "setup" app-thread β€” Eleven's admin setup conversation, the
302 /// thread the creator lands in (the web's resolveAppThread("setup"),
303 /// natively): a self thread whose encrypted name_blob is {app, title},
304 /// key wrapped only to us. Nil myUserId (roster lag) skips quietly.
305 static func createSetupThread(_ inst: SavedInstance) async -> Bool {
306 await createElevenThread(inst, app: "setup")
307 }
308
309 /// The setup thread's joiner-side twin (#1376): the "welcome" app-thread β€”
310 /// Eleven's member welcome conversation (the web's
311 /// resolveAppThread("welcome"), natively), where a fresh joiner is greeted
312 /// and asked their name. Created by SpaceClaim for member/DM-invite joins.
313 static func createWelcomeThread(_ inst: SavedInstance) async -> Bool {
314 await createElevenThread(inst, app: "welcome")
315 }
316
317 private static func createElevenThread(_ inst: SavedInstance, app: String) async -> Bool {
318 guard let base = inst.baseURL, !inst.myUserId.isEmpty else { return false }
319 let api = LChatAPI(baseURL: base, token: inst.deviceToken)
320 let userPub = inst.userPub
321 let built: (nameBlob: String, wrapped: String)? =
322 await Task.detached(priority: .userInitiated) {
323 let raw = ECIES.randomAESKey()
324 guard let key = try? GroupKey(rawKey: raw),
325 let nameBlob = try? key.encryptJSON(["app": app, "title": "Eleven"]),
326 let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub)
327 else { return nil }
328 return (nameBlob, wrapped)
329 }.value
330 guard let built else { return false }
331 if case .success = await api.createThread(kind: "self", nameBlob: built.nameBlob,
332 members: [["user_id": inst.myUserId,
333 "wrapped": built.wrapped]]) {
334 return true
335 }
336 return false
337 }
338
339 /// Up to three attempts with a short breath between β€” the call runs right
340 /// after the instance's first health check, when a hiccup is most likely.
341 private static func retrying(_ op: () async -> Bool) async -> Bool {
342 for attempt in 0..<3 {
343 if await op() { return true }
344 if attempt < 2 { try? await Task.sleep(nanoseconds: 400_000_000) }
345 }
346 return false
347 }
348
349 private static func post(_ base: URL, _ path: String, token: String,
350 json: [String: Any]) async -> Bool {
351 var req = URLRequest(url: base.appending(path: path))
352 req.httpMethod = "POST"
353 req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
354 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
355 req.httpBody = try? JSONSerialization.data(withJSONObject: json)
356 guard let (_, resp) = try? await URLSession.shared.data(for: req),
357 let http = resp as? HTTPURLResponse else { return false }
358 return (200..<300).contains(http.statusCode)
359 }
360}
361