native: home base becomes resolvable — Debug builds can point at the dev home (#1131)
LChatCore gains HomeBase: production (https://home.elevenmessenger.com) by default, overridable ONLY in Debug builds — LCHAT_HOME_BASE in the process environment first, then a home_base_override string in standard UserDefaults (per-bundle-id on Mac dev alts). Release compiles the override out. HomeClient's default base, HomeAuth's default rpID, the Mac browser-handoff /restore link and the sidebar's Manage Spaces link all resolve through it. HomeSessionStore scopes its Keychain account by the resolved base (suffix on non-production only, so existing prod sessions keep their account name) — a dev-home session can never be replayed against production accounts or vice versa. Debug entitlements gain the dev clone's webcredentials association; tools/dev + CLAUDE.md document the alt one-liner. Unblocks PR #1054's testing and native sign-in device testing.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+97
−8
CLAUDE.md+4 −1ios/LChat/LChat-Debug.entitlements+4 −1ios/LChatCore/Sources/LChatCore/HomeAuth.swift+1 −1ios/LChatCore/Sources/LChatCore/HomeBase.swift+69 −0ios/LChatCore/Sources/LChatCore/HomeClient.swift+1 −1ios/LChatCore/Sources/LChatCore/HomeSync.swift+5 −1ios/LChatMac/LChatMac-Dev.entitlements+4 −0ios/LChatMac/MacHomeSignIn.swift+2 −2ios/LChatMac/Sidebar.swift+1 −1tools/dev+6 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 04e2b10..44a32aa 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -665,7 +665,10 @@ instead of replacing it. The keychain access group is unchanged, so an alt sees |
| the signed-in spaces: **it's the real account, not a sandbox** (and while it |
| runs you'll get doubled notification banners — quit it when done). One running |
| app per slug; parallel sessions pick different slugs (`dev`, `dev2`, …) the way |
| -web canaries pick subdomains. Never install an alt (or any Debug build) to |
| +web canaries pick subdomains. Debug builds can aim at the dev home server: |
| +`defaults write <bundle id> home_base_override https://home.dev.elevenmessenger.com` |
| +(or env `LCHAT_HOME_BASE`; LChatCore `HomeBase.swift` — Release compiles it out). |
| +Never install an alt (or any Debug build) to |
| /Applications — that's how a stale dev build ends up masquerading as the |
| released app. |
diff --git a/ios/LChat/LChat-Debug.entitlements b/ios/LChat/LChat-Debug.entitlements| index 4ca8c90..8f165dd 100644 |
| --- a/ios/LChat/LChat-Debug.entitlements |
| +++ b/ios/LChat/LChat-Debug.entitlements |
| @@ -25,8 +25,11 @@ |
| time. Server side: linkscmd.go (lchat links run). --> |
| <string>applinks:11ch.at</string> |
| <!-- "Sign in with Eleven": native passkeys bind to the home server, |
| - which serves the matching apple-app-site-association. --> |
| + which serves the matching apple-app-site-association. The dev |
| + clone rides along so a Debug build pointed there (HomeBase's |
| + LCHAT_HOME_BASE / home_base_override) can run the ceremony too. --> |
| <string>webcredentials:home.elevenmessenger.com?mode=developer</string> |
| + <string>webcredentials:home.dev.elevenmessenger.com?mode=developer</string> |
| </array> |
| <key>com.apple.security.application-groups</key> |
| <array> |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeAuth.swift b/ios/LChatCore/Sources/LChatCore/HomeAuth.swift| index 2e9d44f..f54729c 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeAuth.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeAuth.swift |
| @@ -30,7 +30,7 @@ public final class HomeAuth: NSObject { |
| private var continuation: CheckedContinuation<ASAuthorization, Error>? |
| public init(client: HomeClient = HomeClient(), |
| - rpID: String = "home.elevenmessenger.com", |
| + rpID: String = HomeBase.rpID, |
| anchor: HomeAuthAnchor) { |
| self.client = client |
| self.rpID = rpID |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeBase.swift b/ios/LChatCore/Sources/LChatCore/HomeBase.swift| new file mode 100644 |
| index 0000000..5b01847 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeBase.swift |
| @@ -0,0 +1,69 @@ |
| +import Foundation |
| + |
| +// 🤖 Where "home" is. Production (https://home.elevenmessenger.com) is the only |
| +// answer a Release build can give — the override below compiles out entirely — |
| +// but Debug builds (device installs, simulators, Mac dev alts) may aim at the |
| +// dev clone (https://home.dev.elevenmessenger.com) so native sign-in and vault |
| +// sync can be exercised without touching real accounts. Resolution order: |
| +// |
| +// 1. LCHAT_HOME_BASE in the process environment (an Xcode scheme, a |
| +// simctl/devicectl launch, a shell `open` of a Mac build), then |
| +// 2. the `home_base_override` string in standard UserDefaults — on Mac alts |
| +// that's per-bundle-id, which is exactly right: each alt keeps its own |
| +// aim (`defaults write com.elevenmessenger.messenger.mac.alt-<slug> |
| +// home_base_override https://home.dev.elevenmessenger.com`), then |
| +// 3. production. |
| +// |
| +// Resolved ONCE per launch so every surface — HomeClient, the ceremony's rpID, |
| +// the browser-handoff links, HomeSessionStore's scoping — agrees for the whole |
| +// run; changing the override means relaunching (matching how the ClickLab |
| +// default is read). |
| +public enum HomeBase { |
| + public static let production = URL(string: "https://home.elevenmessenger.com")! |
| + |
| + /// The resolved base every home surface builds URLs from. |
| + public static let url: URL = resolve() |
| + |
| + /// The WebAuthn relying-party id — the base's host, which is what the |
| + /// production default always was. An overridden base's ceremonies bind to |
| + /// ITS host (the dev clone serves its own AASA; the Debug entitlements |
| + /// carry the extra webcredentials association). |
| + public static var rpID: String { url.host ?? "home.elevenmessenger.com" } |
| + |
| + /// A per-base suffix for scoping stored home state ("" on production, so |
| + /// existing sessions keep their account name). The dev home has entirely |
| + /// separate accounts, so a session minted against one base must never be |
| + /// replayed against another — HomeSessionStore appends this to its |
| + /// Keychain account to keep them apart. |
| + public static var storageSuffix: String { |
| + guard url != production else { return "" } |
| + let host = url.host ?? "?" |
| + return url.port.map { "#\(host):\($0)" } ?? "#\(host)" |
| + } |
| + |
| + private static func resolve() -> URL { |
| + #if DEBUG |
| + if let url = normalize(ProcessInfo.processInfo.environment["LCHAT_HOME_BASE"]) { |
| + return url |
| + } |
| + if let url = normalize(UserDefaults.standard.string(forKey: "home_base_override")) { |
| + return url |
| + } |
| + #endif |
| + return production |
| + } |
| + |
| + #if DEBUG |
| + /// Accept a full URL or a bare host (https assumed); reject anything a |
| + /// URLRequest couldn't use, so a typo'd default falls back to production |
| + /// rather than wedging every home call. |
| + private static func normalize(_ raw: String?) -> URL? { |
| + guard let raw else { return nil } |
| + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard !trimmed.isEmpty else { return nil } |
| + let withScheme = trimmed.contains("://") ? trimmed : "https://" + trimmed |
| + guard let url = URL(string: withScheme), url.host != nil else { return nil } |
| + return url |
| + } |
| + #endif |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeClient.swift b/ios/LChatCore/Sources/LChatCore/HomeClient.swift| index 1272f3f..45370cf 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeClient.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeClient.swift |
| @@ -10,7 +10,7 @@ import Foundation |
| public struct HomeClient { |
| public let base: URL |
| - public init(base: URL = URL(string: "https://home.elevenmessenger.com")!) { |
| + public init(base: URL = HomeBase.url) { |
| self.base = base |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift| index 4237601..d1c5865 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| @@ -43,7 +43,11 @@ public struct HomeSession: Codable { |
| } |
| public enum HomeSessionStore { |
| - static let account = "lchat.home.session" |
| + // Scoped by the resolved home base: production keeps the account name it |
| + // has always had, an overridden base (Debug-only, HomeBase) gets its own — |
| + // the dev home's accounts are entirely separate, so a token/DEK minted |
| + // against one base must never be loaded against another. |
| + static var account: String { "lchat.home.session" + HomeBase.storageSuffix } |
| public static func load() -> HomeSession? { |
| Keychain.load(HomeSession.self, account: account) |
diff --git a/ios/LChatMac/LChatMac-Dev.entitlements b/ios/LChatMac/LChatMac-Dev.entitlements| index 025e83c..d0eb2e6 100644 |
| --- a/ios/LChatMac/LChatMac-Dev.entitlements |
| +++ b/ios/LChatMac/LChatMac-Dev.entitlements |
| @@ -12,6 +12,10 @@ |
| <key>com.apple.developer.associated-domains</key> |
| <array> |
| <string>webcredentials:home.elevenmessenger.com</string> |
| + <!-- The dev clone, for builds pointed there via HomeBase's |
| + LCHAT_HOME_BASE / home_base_override. Plain association like the |
| + one above (?mode=developer never engaged on macOS). --> |
| + <string>webcredentials:home.dev.elevenmessenger.com</string> |
| </array> |
| <!-- Everything below mirrors LChatMac.entitlements (the ship set) so dev |
| builds run under the same sandbox they'll ship with. --> |
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index 8135d35..2d86cc8 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -126,8 +126,8 @@ final class MacHomeSignIn: ObservableObject { |
| "ephPub": Data(priv.publicKey.x963Representation).base64urlEncodedString(), |
| ] |
| guard let json = try? JSONSerialization.data(withJSONObject: payload), |
| - let url = URL(string: "https://home.elevenmessenger.com/restore#" |
| - + json.base64urlEncodedString()) else { return } |
| + let url = URL(string: HomeBase.url.appending(path: "/restore").absoluteString |
| + + "#" + json.base64urlEncodedString()) else { return } |
| phase = .browser |
| NSWorkspace.shared.open(url) |
| } |
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift| index d00558a..25c62e3 100644 |
| --- a/ios/LChatMac/Sidebar.swift |
| +++ b/ios/LChatMac/Sidebar.swift |
| @@ -154,7 +154,7 @@ struct Sidebar: View { |
| } |
| Divider() |
| Button("Manage Spaces on Eleven…") { |
| - NSWorkspace.shared.open(URL(string: "https://home.elevenmessenger.com/")!) |
| + NSWorkspace.shared.open(HomeBase.url) |
| } |
| // Gated on a LIVE Keychain check, not the published flag: menu |
| // content evaluates at open, and the flag went stale when sign-in |
diff --git a/tools/dev b/tools/dev| index 6130c9a..78552b8 100755 |
| --- a/tools/dev |
| +++ b/tools/dev |
| @@ -376,6 +376,12 @@ const commands = { |
| // REAL account, not a sandbox. One running app per slug; parallel sessions |
| // pick different slugs (dev, dev2, …) like web canaries. First build of a |
| // new slug mints its App ID/profile via -allowProvisioningUpdates. |
| +// |
| +// Point an alt at the DEV home server (Debug-only, LChatCore HomeBase; the |
| +// per-bundle-id defaults domain means each alt keeps its own aim — takes |
| +// effect on next launch, delete to go back to production): |
| +// defaults write com.elevenmessenger.messenger.mac.alt-<slug> \ |
| +// home_base_override https://home.dev.elevenmessenger.com |
| async function altMac(rawSlug) { |
| const slug = (rawSlug || "dev").toLowerCase().replace(/[^a-z0-9-]/g, ""); |
| if (!slug) { |