eleven / ocho Public

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

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 // Eleven's setup conversation is already standing (SpaceClaim creates
70 // its thread and arms the landing for any admin-granting link), so
71 // this is just the minimal server-side setup: name-less, and
72 // setup_complete as the reaper's not-abandoned signal.
73 await SpaceSetup.finish(origin: inst.origin, token: inst.deviceToken, name: "")
74 return inst
75 }
76}
77
78public enum SpaceClaim {
79 static let cantReach = "Couldn't reach your new space β€” check your connection and try again."
80
81 /// Claim any `/join/…#<T>` link natively and stand the space up for this
82 /// device β€” all three invite shapes join.js handles, no webview:
83 /// - setup / generic member invite (no wrapped payload): fresh keypair;
84 /// - DM invite ({threadKey}): fresh keypair, the thread key re-wrapped to
85 /// it and sent with the claim (the server stores our copy);
86 /// - add-device ({priv, pub}): the carried identity is adopted verbatim.
87 /// Returns a ready-to-add SavedInstance (myUserId resolved when the roster
88 /// already lists us; else on first thread-list load, like any instance).
89 public static func claim(setupLink: String, screenName: String) async throws -> SavedInstance {
90 guard let (origin, joinURL) = parseJoinLink(setupLink),
91 let base = URL(string: origin),
92 let t = inviteSecret(fromFragment: joinURL.fragment ?? ""),
93 let derived = try? InviteCrypto.derive(t) else {
94 throw SpaceClaimError("That link doesn't look right.")
95 }
96
97 // The invite must still be live (single-use; the server only ever sees
98 // the derived id, never T). A network failure is a network failure β€”
99 // never misreported as "expired" (the retry-able and the dead-end are
100 // different problems).
101 struct InviteInfo: Decodable { let ok: Bool?; let wrapped: String?; let grants_admin: Bool? }
102 let info: InviteInfo
103 do {
104 info = try await getJSON(base.appending(path: "/api/key-invite/\(derived.id)"))
105 } catch {
106 throw SpaceClaimError(cantReach)
107 }
108 guard info.ok == true else {
109 throw SpaceClaimError("This link has expired or was already used.")
110 }
111
112 // The wrapped payload (when present) decides the shape.
113 struct InvitePayload: Decodable { let priv: String?; let pub: String?; let threadKey: String? }
114 var keypair: (pub: String, priv: String)
115 var wrappedSelf = "" // a DM invite's thread key, re-wrapped to us
116 var suggested = "" // add-device links carry the name in the fragment
117 var addDevice = false // add-device re-registers an existing key: no new bind-MAC
118 if let w = info.wrapped, !w.isEmpty {
119 guard let payload = try? derived.wrapKey.decryptJSON(InvitePayload.self, blob: w) else {
120 throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).")
121 }
122 if let priv = payload.priv, let pub = payload.pub {
123 // Add-device: sign in as the existing identity.
124 keypair = (pub, priv)
125 addDevice = true
126 suggested = suggestedName(fromFragment: joinURL.fragment ?? "") ?? "Me"
127 } else if let tk = payload.threadKey, let raw = Data(base64urlEncoded: tk) {
128 // DM invite: fresh identity, our copy of the DM key rides with
129 // the claim (join.js wrapThreadKey(threadKey, kp.publicKey)).
130 keypair = ECIES.generateUserKeypair()
131 wrappedSelf = (try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub)) ?? ""
132 guard !wrappedSelf.isEmpty else {
133 throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).")
134 }
135 } else {
136 throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).")
137 }
138 } else {
139 keypair = ECIES.generateUserKeypair()
140 }
141
142 // Claim: proof-of-possession is the derived claim secret.
143 struct ClaimResp: Decodable { let ok: Bool?; let token: String? }
144 let claim: ClaimResp
145 do {
146 claim = try await postJSON(
147 base.appending(path: "/api/key-invite/\(derived.id)/claim"),
148 body: ["wrapped": wrappedSelf, "claim_secret": derived.claimSecret])
149 } catch {
150 throw SpaceClaimError(cantReach)
151 }
152 guard claim.ok == true, let token = claim.token, !token.isEmpty else {
153 throw SpaceClaimError("Couldn't claim this link β€” it may already be used.")
154 }
155
156 let screenName = screenName.isEmpty ? suggested : screenName
157 let api = LChatAPI(baseURL: base, token: token)
158 // Key transparency (#573): a fresh joiner MACs its key under the
159 // T-derived bind key so the inviter can verify it wasn't substituted.
160 // Add-device keeps its original binding (no new MAC).
161 let bindMac = addDevice ? "" : (InviteCrypto.bindMac(bindKeyB64: derived.bindKey, pubB64: keypair.pub) ?? "")
162 await api.ensurePubkey(keypair.pub, bindMac: bindMac)
163
164 var inst = SavedInstance(origin: origin, deviceToken: token,
165 screenName: screenName,
166 userPub: keypair.pub, userPriv: keypair.priv)
167 inst.myUserId = await api.roster()
168 .first(where: { $0.public_key == keypair.pub })?.user_id ?? ""
169
170 // First member of a brand-new space: bootstrap the instance key
171 // (shell.js setupInstanceKey's mint branch) so labels, profile photos
172 // and native invites work without the space ever being opened on web.
173 // Best-effort β€” a web visit heals a missing key via the nudge path β€”
174 // but the label publishes ONLY once the grant verifiably landed:
175 // a label under a key the server never kept decrypts for no one.
176 if !inst.myUserId.isEmpty,
177 let status = await api.instanceKeyStatus(),
178 status.wrapped == nil {
179 if status.have.isEmpty {
180 let raw = ECIES.randomAESKey()
181 if let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub),
182 await api.grantInstanceKey([["user_id": inst.myUserId, "wrapped": wrapped]]),
183 !screenName.isEmpty, let ik = try? GroupKey(rawKey: raw),
184 let blob = try? ik.encryptJSON(screenName) {
185 await api.postLabel(labelBlob: blob)
186 }
187 } else {
188 // Joining an existing space: the key exists but no copy is
189 // wrapped to us yet. Nudge any online holder right now (the
190 // web's setupInstanceKey keyless branch); ThreadStore keeps
191 // re-nudging on foreground + a slow timer until the grant
192 // lands β€” a keyless member renders everyone as "Someone".
193 await api.nudgeInstanceKey()
194 }
195 }
196
197 // Eleven's opening conversation, whichever side of the invite this is.
198 // Add-device is neither: an existing member's new device has already
199 // met Eleven.
200 //
201 // ADMIN (a setup link): the space's first screen is the SETUP
202 // conversation. Not only the natively-created space β€” a space created
203 // on the web and handed over with "Open in the app" arrives here too,
204 // and used to land its admin in a space with no threads at all: no
205 // Eleven, no name asked, no way to the Admin app. The gate is the
206 // web's own (shell.js maybeStartSetup): admin + setup not yet done,
207 // so an admin-granting invite into an already-set-up space (a
208 // co-admin) never resurfaces a finished conversation.
209 //
210 // MEMBER: the welcome conversation, where a fresh joiner is greeted
211 // and asked their name β€” the native twin of join.js
212 // markWelcomePending + shell.js maybeStartWelcome. Without it a
213 // native joiner stays "Someone" to everyone (#1376).
214 if !addDevice {
215 if info.grants_admin == true {
216 if await api.setupPending(), await SpaceSetup.createSetupThread(inst) {
217 await SetupLanding.markPending(inst)
218 }
219 } else if await SpaceSetup.createWelcomeThread(inst) {
220 await WelcomeLanding.markPending(inst)
221 }
222 }
223 return inst
224 }
225
226 /// The invite secret from a `#fragment`: strip the "e1." app-hint prefix
227 /// and any "~<suggested name>" tail (invite.js parseInviteFragment).
228 static func inviteSecret(fromFragment fragment: String) -> String? {
229 var f = fragment
230 if f.hasPrefix("e1.") { f = String(f.dropFirst(3)) }
231 if let tilde = f.firstIndex(of: "~") { f = String(f[..<tilde]) }
232 return f.isEmpty ? nil : f
233 }
234
235 /// The "~<encoded name>" tail some links carry (invite.js: the inviter's
236 /// suggestion / an add-device link's owner) β€” never sent to any server.
237 /// Sealed instances append a third "~<branding key>" slot
238 /// (docs/no-plaintext.md); it is not part of the name.
239 static func suggestedName(fromFragment fragment: String) -> String? {
240 var slots = fragmentSlots(fragment)
241 guard slots.count >= 2 else { return nil }
242 if slots.count >= 3, isBrandingKey(slots[slots.count - 1]) { slots.removeLast() }
243 let raw = slots[1...].joined(separator: "~") // legacy links may carry a raw "~" in the name
244 let name = raw.removingPercentEncoding ?? raw
245 return name.isEmpty ? nil : name
246 }
247
248 /// The branding key from a sealed instance's link (the third fragment
249 /// slot) β€” held for the sealed-branding rendering path; never sent to any
250 /// server. Nil when the link predates sealing or the tail isn't key-shaped.
251 static func brandingKey(fromFragment fragment: String) -> String? {
252 let slots = fragmentSlots(fragment)
253 guard slots.count >= 3, isBrandingKey(slots[slots.count - 1]) else { return nil }
254 return slots[slots.count - 1]
255 }
256
257 private static func fragmentSlots(_ fragment: String) -> [String] {
258 var f = fragment
259 if f.hasPrefix("e1.") { f = String(f.dropFirst(3)) }
260 return f.split(separator: "~", omittingEmptySubsequences: false).map(String.init)
261 }
262
263 /// invite.js BRANDING_KEY_RE: lowercase base32, 8–32 chars.
264 private static func isBrandingKey(_ s: String) -> Bool {
265 guard s.count >= 8, s.count <= 32 else { return false }
266 return s.allSatisfy { ("a"..."z").contains($0) || ("2"..."7").contains($0) }
267 }
268
269 private static func getJSON<T: Decodable>(_ url: URL) async throws -> T {
270 let (data, _) = try await URLSession.shared.data(from: url)
271 return try JSONDecoder().decode(T.self, from: data)
272 }
273
274 private static func postJSON<T: Decodable>(_ url: URL, body: [String: Any]) async throws -> T {
275 var req = URLRequest(url: url)
276 req.httpMethod = "POST"
277 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
278 req.httpBody = try JSONSerialization.data(withJSONObject: body)
279 let (data, _) = try await URLSession.shared.data(for: req)
280 return try JSONDecoder().decode(T.self, from: data)
281 }
282}
283
284// ---- minimal native setup (the conversation's side effects, sans script) ----
285
286public enum SpaceSetup {
287 /// Name the space and mark setup done β€” the two server-visible effects of
288 /// the web setup conversation a native creator needs (the rest β€” photo,
289 /// contact details β€” live in the Admin app / admin console). The claimed
290 /// device token authorises both: its user holds the admin role
291 /// (requireAdmin's second door), so no admin token ever touches the app.
292 /// Member invites default ON, matching the seeded LCHAT_SKIP_SETUP posture.
293 /// The name is plaintext branding by design (it must render where no key
294 /// exists yet); the member's own screen name is NOT sent β€” that stays E2E.
295 @discardableResult
296 public static func finish(origin: String, token: String, name: String) async -> Bool {
297 guard let base = URL(string: origin) else { return false }
298 let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
299 var ok = true
300 if !trimmed.isEmpty {
301 // setGroup clears any field it isn't sent; a fresh space has an
302 // empty trio, so sending empties alongside the name is faithful.
303 ok = await retrying { await post(base, "/api/admin/group", token: token,
304 json: ["name": String(trimmed.prefix(60)),
305 "admin_name": "", "admin_email": ""]) } && ok
306 }
307 // setup_complete is the reaper's not-abandoned signal β€” worth retries.
308 ok = await retrying { await post(base, "/api/admin/settings", token: token,
309 json: ["user_invites": true, "setup_complete": true]) } && ok
310 return ok
311 }
312
313 /// Create the "setup" app-thread β€” Eleven's admin setup conversation, the
314 /// thread the creator lands in (the web's resolveAppThread("setup"),
315 /// natively): a self thread whose encrypted name_blob is {app, title},
316 /// key wrapped only to us. Nil myUserId (roster lag) skips quietly.
317 static func createSetupThread(_ inst: SavedInstance) async -> Bool {
318 await createElevenThread(inst, app: "setup")
319 }
320
321 /// The setup thread's joiner-side twin (#1376): the "welcome" app-thread β€”
322 /// Eleven's member welcome conversation (the web's
323 /// resolveAppThread("welcome"), natively), where a fresh joiner is greeted
324 /// and asked their name. Created by SpaceClaim for member/DM-invite joins.
325 static func createWelcomeThread(_ inst: SavedInstance) async -> Bool {
326 await createElevenThread(inst, app: "welcome")
327 }
328
329 private static func createElevenThread(_ inst: SavedInstance, app: String) async -> Bool {
330 guard let base = inst.baseURL, !inst.myUserId.isEmpty else { return false }
331 let api = LChatAPI(baseURL: base, token: inst.deviceToken)
332 let userPub = inst.userPub
333 let built: (nameBlob: String, wrapped: String)? =
334 await Task.detached(priority: .userInitiated) {
335 let raw = ECIES.randomAESKey()
336 guard let key = try? GroupKey(rawKey: raw),
337 let nameBlob = try? key.encryptJSON(["app": app, "title": "Eleven"]),
338 let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub)
339 else { return nil }
340 return (nameBlob, wrapped)
341 }.value
342 guard let built else { return false }
343 if case .success = await api.createThread(kind: "self", nameBlob: built.nameBlob,
344 members: [["user_id": inst.myUserId,
345 "wrapped": built.wrapped]]) {
346 return true
347 }
348 return false
349 }
350
351 /// Up to three attempts with a short breath between β€” the call runs right
352 /// after the instance's first health check, when a hiccup is most likely.
353 private static func retrying(_ op: () async -> Bool) async -> Bool {
354 for attempt in 0..<3 {
355 if await op() { return true }
356 if attempt < 2 { try? await Task.sleep(nanoseconds: 400_000_000) }
357 }
358 return false
359 }
360
361 private static func post(_ base: URL, _ path: String, token: String,
362 json: [String: Any]) async -> Bool {
363 var req = URLRequest(url: base.appending(path: path))
364 req.httpMethod = "POST"
365 req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
366 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
367 req.httpBody = try? JSONSerialization.data(withJSONObject: json)
368 guard let (_, resp) = try? await URLSession.shared.data(for: req),
369 let http = resp as? HTTPURLResponse else { return false }
370 return (200..<300).contains(http.statusCode)
371 }
372}
373