eleven / messenger Public

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>
Paul Campbell 675ad779e96f2241e6511142711efa83b61ba8dd parent 39d66aa
10 files changed, +97 −8
  • CLAUDE.md +4 −1
  • ios/LChat/LChat-Debug.entitlements +4 −1
  • ios/LChatCore/Sources/LChatCore/HomeAuth.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/HomeBase.swift +69 −0
  • ios/LChatCore/Sources/LChatCore/HomeClient.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/HomeSync.swift +5 −1
  • ios/LChatMac/LChatMac-Dev.entitlements +4 −0
  • ios/LChatMac/MacHomeSignIn.swift +2 −2
  • ios/LChatMac/Sidebar.swift +1 −1
  • tools/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) {