| 1 | import 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 | |
| 17 | public enum FaderPhase: Equatable { |
| 18 | case idle, slide, snap, build, done |
| 19 | } |
| 20 | |
| 21 | public 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 | |
| 50 | public 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. |
| 61 | public 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 | |
| 80 | public 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 | |
| 274 | public 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 | |