eleven / messenger Public

Onboarding phase 1: deferred-claim welcome page, invite fingerprint, open-in-app (#111)

- /join renders a welcome card and claims NOTHING until an explicit choice —
  a rendered link (hidden webview, second tab, preview) no longer burns the
  one-time secret. Add-device links, previously auto-claimed on load, now
  wait for the click too. The iOS native embed flow skips the card (consent
  already given in-app).
- New links carry a versioned fragment fingerprint (#e1.<T>) via the pure
  web/static/invite.js codec (Node-tested); all minters route through it and
  all parsers (web + both apps) accept old and new formats.
- "Open in the app" hands the full link to elevenmessenger:// (registered by
  both apps; onOpenURL routes into the existing add flows). Instances serve
  /.well-known/apple-app-site-association so invites open the installed iOS
  app directly (applinks:*.luan.chat entitlement).
- "Get the app" button per instance via LCHAT_APP_STORE_URL (hidden unless set).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6489be53d9f3bcfd01419b805546e665dd01a7ee parent e684b54
21 files changed, +540 −91
  • changelog/2026-07-03-welcome-page.md +1 −0
  • handlers_test.go +61 −0
  • ios/LChat/Info.plist +11 −0
  • ios/LChat/InviteLink.swift +47 −0
  • ios/LChat/LChat.entitlements +6 −0
  • ios/LChat/LChatApp.swift +18 −2
  • ios/LChat/Views/JoinView.swift +12 −18
  • ios/LChatMac/AddChatView.swift +18 −16
  • ios/LChatMac/Info.plist +11 −0
  • ios/LChatMac/LChatMacApp.swift +12 −0
  • ios/LChatTests/InviteLinkTest.swift +65 −0
  • ios/project.yml +14 −0
  • main.go +27 −4
  • package.json +1 −1
  • test/invite.test.mjs +88 −0
  • web/pages/join.html +17 −0
  • web/static/admin.js +2 −1
  • web/static/chat.js +4 −11
  • web/static/invite.js +39 −0
  • web/static/join.js +84 −38
  • web/static/style.css +2 −0
diff --git a/changelog/2026-07-03-welcome-page.md b/changelog/2026-07-03-welcome-page.md
new file mode 100644
index 0000000..3ebf2e3
--- /dev/null
+++ b/changelog/2026-07-03-welcome-page.md
@@ -0,0 +1 @@
+Improved: invite links now open a welcome screen first — you choose whether to join in the browser or open the chat in the app, instead of the link signing you in the moment it loads. Links no longer get "used up" by just being previewed, and on iPhone an invite can open straight into the app when it's installed.
diff --git a/handlers_test.go b/handlers_test.go
index f167e40..ba4bef7 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -794,3 +794,64 @@ func TestAPNsSubscribe(t *testing.T) {
t.Fatalf("not removed: %v", rows)
}
}
+
+// The AASA file must be public (Apple's CDN fetches it anonymously) and name the
+// app over the /join/* pattern, so invite links open in the installed iOS app.
+func TestAppleAppSiteAssociation(t *testing.T) {
+ ts, _ := newTestServer(t)
+ resp, err := http.Get(ts.URL + "/.well-known/apple-app-site-association")
+ if err != nil {
+ t.Fatalf("get aasa: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("aasa status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
+ t.Fatalf("aasa content-type = %q", ct)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ var doc struct {
+ Applinks struct {
+ Details []struct {
+ AppIDs []string `json:"appIDs"`
+ Components []map[string]string `json:"components"`
+ } `json:"details"`
+ } `json:"applinks"`
+ }
+ if err := json.Unmarshal(b, &doc); err != nil {
+ t.Fatalf("aasa not valid JSON: %v\n%s", err, b)
+ }
+ if len(doc.Applinks.Details) != 1 || len(doc.Applinks.Details[0].AppIDs) != 1 {
+ t.Fatalf("aasa shape wrong: %s", b)
+ }
+ if got := doc.Applinks.Details[0].AppIDs[0]; got != appleAppID {
+ t.Fatalf("aasa appID = %q, want %q", got, appleAppID)
+ }
+ if got := doc.Applinks.Details[0].Components[0]["/"]; got != "/join/*" {
+ t.Fatalf("aasa join pattern = %q", got)
+ }
+}
+
+// The welcome page's "Get the app" URL is injected per instance; unset config
+// must substitute to empty (the page hides the button) — and the placeholder
+// must never leak to the client.
+func TestJoinPageAppStoreURL(t *testing.T) {
+ ts, s := newTestServer(t)
+ get := func() string {
+ resp, err := http.Get(ts.URL + "/join/join")
+ if err != nil {
+ t.Fatalf("get join: %v", err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return string(b)
+ }
+ if body := get(); strings.Contains(body, "__APP_STORE_URL__") {
+ t.Fatal("join page leaks the __APP_STORE_URL__ placeholder")
+ }
+ s.appStoreURL = "https://testflight.apple.com/join/xyz"
+ if body := get(); !strings.Contains(body, `data-store-url="https://testflight.apple.com/join/xyz"`) {
+ t.Fatal("join page missing configured app store URL")
+ }
+}
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist
index 86f31d6..2ced245 100644
--- a/ios/LChat/Info.plist
+++ b/ios/LChat/Info.plist
@@ -18,6 +18,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>com.elevenmessenger.messenger</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>elevenmessenger</string>
+ </array>
+ </dict>
+ </array>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
diff --git a/ios/LChat/InviteLink.swift b/ios/LChat/InviteLink.swift
new file mode 100644
index 0000000..813323b
--- /dev/null
+++ b/ios/LChat/InviteLink.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+// Invite-link parsing, shared by the iOS and Mac targets (and standalone-testable
+// like Crypto.swift — Foundation only, no UI imports).
+//
+// Two kinds of URL reach the apps from outside:
+// - the custom scheme the web welcome page fires:
+// elevenmessenger://add?url=<urlencoded https invite link>
+// - a universal link: the raw https invite link itself, #fragment included.
+// `inviteLink(fromOpenedURL:)` reduces either to the https invite-link string;
+// validating that string is the caller's job (each platform has its own parser).
+func inviteLink(fromOpenedURL url: URL) -> String? {
+ switch url.scheme {
+ case "elevenmessenger":
+ // elevenmessenger://add?url=… — URLComponents percent-decodes the value.
+ guard let c = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ c.host == "add",
+ let link = c.queryItems?.first(where: { $0.name == "url" })?.value
+ else { return nil }
+ return link
+ case "http", "https":
+ return url.absoluteString
+ default:
+ return nil
+ }
+}
+
+// Validate a new-format `/join/<slug>#<T>` link and reduce it to (origin, joinURL).
+// The fragment secret may carry the "e1." app-hint prefix or not (older links) —
+// both pass; join.js strips the prefix. Legacy `#k=` links are rejected — this
+// client targets only the keypair model.
+func parseJoinLink(_ raw: String) -> (origin: String, joinURL: URL)? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let c = URLComponents(string: trimmed),
+ let scheme = c.scheme, let host = c.host,
+ scheme == "http" || scheme == "https",
+ c.path.contains("/join/"),
+ let frag = c.fragment, !frag.isEmpty, !frag.contains("k="),
+ let joinURL = URL(string: trimmed)
+ else { return nil }
+ var o = URLComponents()
+ o.scheme = scheme
+ o.host = host
+ o.port = c.port
+ guard let origin = o.url else { return nil }
+ return (origin.absoluteString, joinURL)
+}
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements
index c7a3a2a..81e6b87 100644
--- a/ios/LChat/LChat.entitlements
+++ b/ios/LChat/LChat.entitlements
@@ -10,6 +10,12 @@
<true/>
<key>aps-environment</key>
<string>development</string>
+ <!-- Universal links: tapping an https invite link on any instance subdomain
+ opens the app directly (delivered to onOpenURL, fragment included). -->
+ <key>com.apple.developer.associated-domains</key>
+ <array>
+ <string>applinks:*.luan.chat</string>
+ </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.elevenmessenger.messenger.shared</string>
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index b5970e0..8558a0a 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -79,6 +79,9 @@ struct RootView: View {
// mid-flight with hit-testing disabled, wedging the whole screen.
@GestureState private var drawerDrag: CGFloat?
@State private var showAdd = false
+ // An invite link the OS opened us with (custom scheme or universal link),
+ // waiting to pre-fill the add sheet.
+ @State private var pendingAddLink = ""
private let drawerWidth: CGFloat = 300
@@ -101,8 +104,21 @@ struct RootView: View {
}
.overlay { drawer }
.simultaneousGesture(drawerDragGesture)
- .sheet(isPresented: $showAdd) {
- AddInstanceView(onAdded: select)
+ .sheet(isPresented: $showAdd, onDismiss: { pendingAddLink = "" }) {
+ AddInstanceView(initialLink: pendingAddLink, onAdded: select)
+ // Keyed on the link: a fresh OS-opened link rebuilds (and
+ // re-seeds) the sheet even if it was already up.
+ .id(pendingAddLink)
+ }
+ // The OS hands us invite links two ways: the welcome page's
+ // elevenmessenger://add?url=<link> and universal links (the raw https
+ // invite). Either routes into the add sheet pre-filled, which runs the
+ // normal validate → JoinWebView flow.
+ .onOpenURL { url in
+ guard let raw = inviteLink(fromOpenedURL: url), parseJoinLink(raw) != nil else { return }
+ pendingAddLink = raw
+ showServers = false
+ showAdd = true
}
.onAppear {
if nav.instanceID == nil { nav.selectInstance(app.instances.first) }
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift
index 34e8746..73b1c77 100644
--- a/ios/LChat/Views/JoinView.swift
+++ b/ios/LChat/Views/JoinView.swift
@@ -28,6 +28,11 @@ struct AddInstanceView: View {
@State private var showScanner = false
#endif
+ // A link handed to us by the OS (custom scheme / universal link) — seeded into
+ // the paste field on appear so it runs the exact same validate/debounce/start
+ // path as a manual paste.
+ var initialLink: String = ""
+
// Called after the new chat is added, so the caller can select it (and show
// its threads straight away).
var onAdded: (SavedInstance) -> Void = { _ in }
@@ -50,6 +55,11 @@ struct AddInstanceView: View {
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
}
}
+ // Assigning fires the field's onChange, so an OS-opened link takes the
+ // same debounce → start() route as a paste (no second code path).
+ .onAppear {
+ if link.isEmpty, !initialLink.isEmpty { link = initialLink }
+ }
}
private var pasteForm: some View {
@@ -161,24 +171,8 @@ struct JoinedIdentity {
let userPriv: String
}
-// Validate a new-format `/join/<slug>#<T>` link and reduce it to (origin, joinURL).
-// Legacy `#k=` links are rejected — this client targets only the keypair model.
-func parseJoinLink(_ raw: String) -> (origin: String, joinURL: URL)? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let c = URLComponents(string: trimmed),
- let scheme = c.scheme, let host = c.host,
- scheme == "http" || scheme == "https",
- c.path.contains("/join/"),
- let frag = c.fragment, !frag.isEmpty, !frag.contains("k="),
- let joinURL = URL(string: trimmed)
- else { return nil }
- var o = URLComponents()
- o.scheme = scheme
- o.host = host
- o.port = c.port
- guard let origin = o.url else { return nil }
- return (origin.absoluteString, joinURL)
-}
+// parseJoinLink (validation + origin extraction) lives in InviteLink.swift,
+// shared with the Mac target and the standalone parser test.
// Hosts join.js with window.__lchatEmbed set, so it posts the resolved identity back
// on the "lchat" handler instead of navigating to "/".
diff --git a/ios/LChatMac/AddChatView.swift b/ios/LChatMac/AddChatView.swift
index 77a5461..13b4a49 100644
--- a/ios/LChatMac/AddChatView.swift
+++ b/ios/LChatMac/AddChatView.swift
@@ -62,21 +62,23 @@ struct AddChatView: View {
private func md(_ s: String) -> AttributedString {
(try? AttributedString(markdown: s)) ?? AttributedString(s)
}
+}
- // A valid invite link for either format: https(s)://host/join/<token>#<secret>.
- // (Legacy links carry `#k=<key>`; new-format ones a one-time secret — we accept
- // both, so this can't rely on parseInvite, which only matches legacy `#k=`.)
- private func isValidInvite(_ raw: String) -> Bool {
- let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let c = URLComponents(string: s),
- let scheme = c.scheme, scheme == "http" || scheme == "https",
- let host = c.host, !host.isEmpty,
- let frag = c.fragment, !frag.isEmpty
- else { return false }
- let segments = c.path.split(separator: "/").map(String.init)
- guard let j = segments.firstIndex(of: "join"), j + 1 < segments.count,
- !segments[j + 1].isEmpty
- else { return false }
- return true
- }
+// A valid invite link for either format: https(s)://host/join/<token>#<secret>.
+// The secret may carry the "e1." app-hint prefix or not (older links) — both pass.
+// (Legacy links carry `#k=<key>`; new-format ones a one-time secret — we accept
+// both, so this can't rely on parseInvite, which only matches legacy `#k=`.)
+// File-scope so the app's onOpenURL handler validates with the same logic.
+func isValidInvite(_ raw: String) -> Bool {
+ let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let c = URLComponents(string: s),
+ let scheme = c.scheme, scheme == "http" || scheme == "https",
+ let host = c.host, !host.isEmpty,
+ let frag = c.fragment, !frag.isEmpty
+ else { return false }
+ let segments = c.path.split(separator: "/").map(String.init)
+ guard let j = segments.firstIndex(of: "join"), j + 1 < segments.count,
+ !segments[j + 1].isEmpty
+ else { return false }
+ return true
}
diff --git a/ios/LChatMac/Info.plist b/ios/LChatMac/Info.plist
index 20a0a16..f6bc6fc 100644
--- a/ios/LChatMac/Info.plist
+++ b/ios/LChatMac/Info.plist
@@ -18,6 +18,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>com.elevenmessenger.messenger</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>elevenmessenger</string>
+ </array>
+ </dict>
+ </array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 97244a0..0552dae 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -22,6 +22,18 @@ struct LChatMacApp: App {
.environmentObject(app)
.frame(minWidth: 820, minHeight: 560)
.onAppear { appDelegate.appState = app }
+ // Invite links from outside: the welcome page's
+ // elevenmessenger://add?url=<link> (universal links can't
+ // validate against an ad-hoc-signed app, but a pasted/clicked
+ // https invite arriving here still works). add(url:) de-dupes
+ // and selects, so re-opening an existing chat just jumps to it.
+ .onOpenURL { url in
+ guard let raw = inviteLink(fromOpenedURL: url),
+ isValidInvite(raw) else { return }
+ app.add(url: raw.trimmingCharacters(in: .whitespacesAndNewlines))
+ app.showAdd = false // an open add sheet is now moot
+ NSApp.activate(ignoringOtherApps: true)
+ }
}
// Hide the title bar so the feed runs to the top; the web header is made
// draggable via an injected script (see ChatFeedWebView), and the top of the
diff --git a/ios/LChatTests/InviteLinkTest.swift b/ios/LChatTests/InviteLinkTest.swift
new file mode 100644
index 0000000..4e36e55
--- /dev/null
+++ b/ios/LChatTests/InviteLinkTest.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+// Standalone gate for the invite-link parsing shared by both apps. Compiles the
+// real InviteLink.swift and asserts the two entry points — OS-opened URLs and
+// pasted links — accept/reject the right shapes, in particular that both the
+// new "e1."-prefixed fragments and older unprefixed ones parse. Run via:
+//
+// cd ios && swiftc -parse-as-library LChat/InviteLink.swift \
+// LChatTests/InviteLinkTest.swift -o /tmp/invitelink && /tmp/invitelink
+//
+@main
+struct InviteLinkTest {
+ static func main() {
+ // parseJoinLink: new-format links, with and without the e1. prefix.
+ expect(parseJoinLink("https://family.luan.chat/join/join#e1.abc123") != nil,
+ "e1.-prefixed fragment parses")
+ expect(parseJoinLink("https://family.luan.chat/join/join#abc123") != nil,
+ "unprefixed fragment parses (older links)")
+ expect(parseJoinLink(" https://h.luan.chat/join/x#e1.T \n") != nil,
+ "whitespace is trimmed")
+ expect(parseJoinLink("http://192.168.1.5:8080/join/x#e1.T")?.origin == "http://192.168.1.5:8080",
+ "origin keeps scheme/host/port, drops path+fragment")
+ expect(parseJoinLink("https://h.luan.chat/join/x#k=legacykey") == nil,
+ "legacy #k= links are rejected")
+ expect(parseJoinLink("https://h.luan.chat/join/x") == nil,
+ "missing fragment is rejected")
+ expect(parseJoinLink("https://h.luan.chat/other/x#e1.T") == nil,
+ "non-/join/ path is rejected")
+ expect(parseJoinLink("elevenmessenger://add?url=x") == nil,
+ "custom scheme is not itself a join link")
+
+ // inviteLink(fromOpenedURL:): the welcome page's custom-scheme form.
+ let encoded = "https%3A%2F%2Ffamily.luan.chat%2Fjoin%2Fjoin%23e1.abc123"
+ expect(opened("elevenmessenger://add?url=\(encoded)")
+ == "https://family.luan.chat/join/join#e1.abc123",
+ "elevenmessenger://add unwraps + percent-decodes the url param")
+ expect(opened("elevenmessenger://add") == nil,
+ "custom scheme without a url param is rejected")
+ expect(opened("elevenmessenger://other?url=\(encoded)") == nil,
+ "custom scheme with an unknown host is rejected")
+
+ // …and the universal-link form: the https invite passes straight through,
+ // fragment intact.
+ expect(opened("https://family.luan.chat/join/join#e1.abc123")
+ == "https://family.luan.chat/join/join#e1.abc123",
+ "https universal link passes through with its fragment")
+ expect(opened("ftp://h/join/x#e1.T") == nil,
+ "other schemes are rejected")
+
+ print("InviteLinkTest OK (\(passed) checks)")
+ }
+
+ static func opened(_ s: String) -> String? {
+ URL(string: s).flatMap { inviteLink(fromOpenedURL: $0) }
+ }
+
+ static var passed = 0
+ static func expect(_ ok: Bool, _ name: String) {
+ guard ok else {
+ FileHandle.standardError.write(Data("FAIL: \(name)\n".utf8))
+ exit(1)
+ }
+ passed += 1
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
index 3c53b14..dee2119 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -41,6 +41,12 @@ targets:
CFBundleDisplayName: Eleven
NSCameraUsageDescription: Camera access is needed to scan QR codes for joining chats
UILaunchScreen: {}
+ # The welcome page's "Open the app" button fires
+ # elevenmessenger://add?url=<invite link> (see web/pages/join.html).
+ CFBundleURLTypes:
+ - CFBundleURLName: com.elevenmessenger.messenger
+ CFBundleURLSchemes:
+ - elevenmessenger
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad:
@@ -65,6 +71,7 @@ targets:
- path: LChatMac
- path: LChat/Models.swift
- path: LChat/LChatAPI.swift
+ - path: LChat/InviteLink.swift
settings:
base:
PRODUCT_NAME: Eleven
@@ -78,6 +85,13 @@ targets:
CFBundleDisplayName: Eleven
CFBundleName: Eleven
LSApplicationCategoryType: public.app-category.social-networking
+ # Same custom scheme as the iOS app, so the welcome page's "Open the app"
+ # button works on the Mac too. (No associated domains here — the Mac app
+ # is ad-hoc signed, so universal links can't validate.)
+ CFBundleURLTypes:
+ - CFBundleURLName: com.elevenmessenger.messenger
+ CFBundleURLSchemes:
+ - elevenmessenger
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
# Notification service extension: decrypts pushed ciphertext on-device (the
diff --git a/main.go b/main.go
index 42360ac..86970aa 100644
--- a/main.go
+++ b/main.go
@@ -45,9 +45,12 @@ type Server struct {
pages map[string][]byte
changelog []ChangelogEntry
version string // frontend build hash; clients reload when it changes (see frontendVersion)
- vapidPub string
- vapidPriv string
- apns *apnsClient // nil unless LCHAT_APNS_* env is configured
+ // Where the welcome page's "Get the app" button points (TestFlight / App
+ // Store link). Empty hides the button — set LCHAT_APP_STORE_URL per instance.
+ appStoreURL string
+ vapidPub string
+ vapidPriv string
+ apns *apnsClient // nil unless LCHAT_APNS_* env is configured
// inflight de-duplicates concurrent unfurls of the same URL.
inflight sync.Map
// active tracks the last time each device token reported being open + focused
@@ -98,7 +101,8 @@ func main() {
}
hub := newHub()
go hub.run()
- s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion()}
+ s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(),
+ appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL"))}
// Web Push: self-generated VAPID keypair (no third-party account).
if pub, priv, err := db.ensureVAPID(); err != nil {
@@ -206,6 +210,10 @@ func (s *Server) handler() http.Handler {
// PWA: the service worker must be served from the root scope; manifest for install
mux.HandleFunc("GET /sw.js", s.serveSW)
mux.HandleFunc("GET /manifest.webmanifest", s.serveManifest)
+ // Universal links: lets invite links on this instance's domain open straight
+ // into the iOS app when it's installed (every instance is its own domain, and
+ // every instance is this same binary, so each serves its own association file).
+ mux.HandleFunc("GET /.well-known/apple-app-site-association", serveAASA)
return securityHeaders(mux)
}
@@ -280,11 +288,26 @@ func (s *Server) servePage(name string) http.HandlerFunc {
// Whether ordinary members may invite people (gates the invite UI; the server
// enforces it regardless). Injected so the client needs no extra round trip.
page = strings.ReplaceAll(page, "__USER_INVITES__", boolStr(s.db.usersCanInvite()))
+ // The welcome page's "Get the app" destination; empty hides the button.
+ page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL))
page = strings.ReplaceAll(page, "__CSP_NONCE__", nonce)
w.Write([]byte(page))
}
}
+// The iOS app this deployment's instances associate with (team + bundle id) —
+// public by definition (it ships in every AASA file and in the app itself).
+const appleAppID = "J5NDF5PCD8.com.elevenmessenger.messenger"
+
+// serveAASA answers Apple's app-site-association probe. Apple's CDN fetches it
+// when the app installs/updates; matching /join/* links then open in the app
+// instead of Safari, with the URL (fragment included) handed to the app. Public,
+// unauthenticated, and identical on every instance by design.
+func serveAASA(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"applinks":{"details":[{"appIDs":[%q],"components":[{"/":"/join/*"}]}]}}`, appleAppID)
+}
+
// frontendVersion is a short hash of the embedded frontend (web/), computed once at
// startup. It changes whenever any UI file changes, so a redeploy that ships new
// frontend code yields a new version — clients notice and reload (see getVersion +
diff --git a/package.json b/package.json
index df551fe..bd1d115 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs"
}
}
diff --git a/test/invite.test.mjs b/test/invite.test.mjs
new file mode 100644
index 0000000..dda4cff
--- /dev/null
+++ b/test/invite.test.mjs
@@ -0,0 +1,88 @@
+// Tests for the invite-link fragment codec. node test/invite.test.mjs
+import assert from "node:assert/strict";
+import {
+ parseInviteFragment,
+ buildInviteFragment,
+ hasInviteFingerprint,
+ INVITE_PREFIX,
+} from "../web/static/invite.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+await test("parse: new format without name", () => {
+ assert.deepEqual(parseInviteFragment("e1.abc123"), { T: "abc123", name: "" });
+});
+
+await test("parse: new format with name", () => {
+ assert.deepEqual(parseInviteFragment("e1.abc123~Paul"), { T: "abc123", name: "Paul" });
+});
+
+await test("parse: legacy unprefixed without name", () => {
+ assert.deepEqual(parseInviteFragment("abc123"), { T: "abc123", name: "" });
+});
+
+await test("parse: legacy unprefixed with name", () => {
+ assert.deepEqual(parseInviteFragment("abc123~Paul"), { T: "abc123", name: "Paul" });
+});
+
+await test("parse: URL-encoded name", () => {
+ assert.deepEqual(parseInviteFragment("e1.abc~Paul%20Campbell"), { T: "abc", name: "Paul Campbell" });
+});
+
+await test("parse: URL-encoded unicode name", () => {
+ assert.deepEqual(parseInviteFragment("e1.abc~Zo%C3%AB%20%E2%9C%A8"), { T: "abc", name: "Zoë ✨" });
+});
+
+await test("parse: undecodable percent sequence -> empty name", () => {
+ assert.deepEqual(parseInviteFragment("e1.abc~%E0%A4%A"), { T: "abc", name: "" });
+});
+
+await test("parse: empty input", () => {
+ assert.deepEqual(parseInviteFragment(""), { T: "", name: "" });
+});
+
+await test("parse: null/undefined input", () => {
+ assert.deepEqual(parseInviteFragment(null), { T: "", name: "" });
+ assert.deepEqual(parseInviteFragment(undefined), { T: "", name: "" });
+});
+
+await test("build: carries the fingerprint", () => {
+ assert.equal(buildInviteFragment("abc123"), INVITE_PREFIX + "abc123");
+ assert.ok(hasInviteFingerprint(buildInviteFragment("abc123", "Paul")));
+});
+
+await test("build: round-trips without name", () => {
+ assert.deepEqual(parseInviteFragment(buildInviteFragment("abc123")), { T: "abc123", name: "" });
+});
+
+await test("build: round-trips with name", () => {
+ assert.deepEqual(parseInviteFragment(buildInviteFragment("abc123", "Paul Campbell")), {
+ T: "abc123",
+ name: "Paul Campbell",
+ });
+});
+
+await test("build: round-trips a unicode name (incl. ~ in the name)", () => {
+ // "~" in a name must survive: encodeURIComponent leaves it bare, but the
+ // parser splits on the FIRST tilde, so the separator still wins.
+ assert.deepEqual(parseInviteFragment(buildInviteFragment("T", "Zoë ~ ✨")), { T: "T", name: "Zoë ~ ✨" });
+});
+
+await test("hasInviteFingerprint: new links yes, legacy and junk no", () => {
+ assert.equal(hasInviteFingerprint("e1.abc123"), true);
+ assert.equal(hasInviteFingerprint("abc123"), false);
+ assert.equal(hasInviteFingerprint(""), false);
+ assert.equal(hasInviteFingerprint(null), false);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/join.html b/web/pages/join.html
index 07823c7..aca7c97 100644
--- a/web/pages/join.html
+++ b/web/pages/join.html
@@ -80,4 +80,21 @@
<p id="error-msg">This invite link is invalid or has been turned off. Ask for a new one.</p>
</template>
+<!-- The welcome card (deferred claim — see docs/onboarding-and-home-server.md).
+ Nothing is claimed until a button is pressed; the one-time secret stays in
+ the URL fragment. data-store-url is per-instance config (LCHAT_APP_STORE_URL),
+ empty when no app link is configured. -->
+<template id="welcome-tpl" data-store-url="__APP_STORE_URL__">
+ <div class="onboard-icon">💬</div>
+ <h1 id="welcome-title">You're invited to __GROUP_NAME__</h1>
+ <p id="welcome-sub">Private, end-to-end encrypted chat.</p>
+ <div class="row"><button id="welcome-app">Open in the app</button></div>
+ <div class="row"><button id="welcome-store" class="secondary">Get the app</button></div>
+ <div class="row"><button id="welcome-web" class="secondary">Continue in the browser</button></div>
+ <p id="welcome-caveat" class="welcome-caveat">
+ Joining in the browser keeps your access in this browser only — if it's
+ cleared or lost, you'll need a new invite.
+ </p>
+</template>
+
<script type="module" src="/static/join.js"></script>
diff --git a/web/static/admin.js b/web/static/admin.js
index 1c2c9ae..ba76314 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -1,4 +1,5 @@
import { randomSecret, deriveInvite } from "/static/crypto.js";
+import { buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
const ADMIN_KEY = "lchat_admin_token";
@@ -157,7 +158,7 @@ async function mintInvite(label, userId, agent, config) {
}
// Neutral slug (the path reaches the server); a person's suggested name rides
// in the #fragment, which browsers never send to the server.
- const frag = !agent && label ? `${T}~${encodeURIComponent(label)}` : T;
+ const frag = buildInviteFragment(T, agent ? "" : label);
return { link: `${location.origin}/join/join#${frag}` };
}
diff --git a/web/static/chat.js b/web/static/chat.js
index d8bd06f..447126a 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -36,6 +36,7 @@ import {
import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
import { MESSAGE_TYPE_TYPING } from "/static/message.js";
+import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
import {
needsHomeScreen,
@@ -4306,8 +4307,7 @@ function updatePendingBanner(t) {
function inviteLink(name, T) {
// Neutral slug: the invitee's name rides in the #fragment (never sent to the
// server), not the path — so opening a join link reveals no name to the server.
- const frag = name ? `${T}~${encodeURIComponent(name)}` : T;
- return `${location.origin}/join/join#${frag}`;
+ return `${location.origin}/join/join#${buildInviteFragment(T, name)}`;
}
function selectAllText(el) {
@@ -4345,7 +4345,7 @@ async function addDevice() {
});
if (!res.ok) throw new Error("server");
// Neutral slug; the name rides in the #fragment (never sent to the server).
- link = `${location.origin}/join/join#${T}~${encodeURIComponent(session.name)}`;
+ link = `${location.origin}/join/join#${buildInviteFragment(T, session.name)}`;
} catch {
return alert("Couldn't create a device link. Try again.");
}
@@ -4671,14 +4671,7 @@ function parseLink(text) {
const u = new URL(text.trim());
const frag = u.hash.slice(1);
if (!frag) return null;
- const i = frag.indexOf("~");
- const T = i < 0 ? frag : frag.slice(0, i);
- let name = "";
- if (i >= 0) {
- try {
- name = decodeURIComponent(frag.slice(i + 1));
- } catch {}
- }
+ const { T, name } = parseInviteFragment(frag);
const slug = decodeURIComponent(u.pathname.split("/").filter(Boolean).pop() || "");
return { T, name, slug };
} catch {
diff --git a/web/static/invite.js b/web/static/invite.js
new file mode 100644
index 0000000..f2fa44f
--- /dev/null
+++ b/web/static/invite.js
@@ -0,0 +1,39 @@
+// Invite-link fragment codec (see docs/onboarding-and-home-server.md).
+// New links carry a versioned fingerprint — /join/join#e1.<T> or #e1.<T>~<name> —
+// so clients (web paste-to-join, the iOS and Mac apps) can recognise an Eleven
+// invite deterministically and offline, and future format changes can bump the
+// version. Old unprefixed #<T> links parse identically. Pure and framework-free
+// so it's unit-testable in Node (test/invite.test.mjs).
+
+export const INVITE_PREFIX = "e1.";
+
+// parseInviteFragment("e1.<T>~<name>") -> { T, name }, name URL-decoded ("" if
+// absent or undecodable). Accepts unprefixed legacy fragments.
+export function parseInviteFragment(frag) {
+ let f = frag || "";
+ if (f.startsWith(INVITE_PREFIX)) f = f.slice(INVITE_PREFIX.length);
+ const tilde = f.indexOf("~");
+ const T = tilde < 0 ? f : f.slice(0, tilde);
+ const name = tilde < 0 ? "" : safeDecode(f.slice(tilde + 1));
+ return { T, name };
+}
+
+// buildInviteFragment(T, name?) -> "e1.<T>" or "e1.<T>~<encoded name>". All
+// minters use this, so every new link carries the fingerprint.
+export function buildInviteFragment(T, name = "") {
+ return INVITE_PREFIX + T + (name ? "~" + encodeURIComponent(name) : "");
+}
+
+// True when the fragment carries the versioned fingerprint — the deterministic
+// "this is an Eleven invite" signal (an arbitrary URL with /join/ in it isn't).
+export function hasInviteFingerprint(frag) {
+ return (frag || "").startsWith(INVITE_PREFIX);
+}
+
+function safeDecode(s) {
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return "";
+ }
+}
diff --git a/web/static/join.js b/web/static/join.js
index b3c756a..0ba5df1 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -4,16 +4,14 @@
// person, or unwrapped from the link for an added device — registers the public
// key in the roster, claims a device token, and stores the session.
import { decrypt, generateUserKeypair, deriveInvite, wrapThreadKey } from "/static/crypto.js";
+import { parseInviteFragment } from "/static/invite.js";
const SESSION_KEY = "lchat_session";
const card = document.getElementById("card");
const isStandalone =
window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
-const frag = location.hash.slice(1);
-const tilde = frag.indexOf("~");
-const T = tilde < 0 ? frag : frag.slice(0, tilde);
-const fragName = tilde < 0 ? "" : safeDecode(frag.slice(tilde + 1));
+const { T, name: fragName } = parseInviteFragment(location.hash.slice(1));
init();
@@ -44,47 +42,102 @@ async function start() {
return showError("This invite has been used or is no longer valid. Ask for a new one.");
}
+ // Everything below only *decides* what continuing means for this link — the
+ // claim itself waits for an explicit choice on the welcome card. Claiming on
+ // page-load burned the one-time secret in whatever context rendered the link
+ // first (a hidden webview, a second tab) — see docs/onboarding-and-home-server.md.
+ let payload = null;
if (info.wrapped) {
- let payload;
try {
payload = await decrypt(wrapKey, info.wrapped);
} catch {
return showError("Couldn't unlock this link (wrong or corrupt secret).");
}
- if (payload.priv) {
- // Add-device link: it carries this user's keypair (and name) — just sign in.
+ }
+
+ if (payload && payload.priv) {
+ // Add-device link: it carries this user's keypair (and name) — signing in
+ // is the only possible action, but it still waits for the click.
+ return showWelcome("device", () => {
showStatus("Signing you in…");
- return finish(id, fragName || "Me", payload.pub, payload.priv);
- }
- if (payload.threadKey) {
- // User invite: it drops the joiner into a DM with the person who invited them.
- // In the native embed the shell owns identities (Keychain) — never adopt a
- // stray webview session; always run the fresh-person flow and hand back.
- const existing = window.__lchatEmbed ? null : loadSession();
- if (existing && existing.token && existing.userPub && existing.userPriv) {
- // Already a member on this device → join that DM as your existing self.
+ finish(id, fragName || "Me", payload.pub, payload.priv);
+ });
+ }
+ if (payload && payload.threadKey) {
+ // User invite: it drops the joiner into a DM with the person who invited them.
+ // In the native embed the shell owns identities (Keychain) — never adopt a
+ // stray webview session; always run the fresh-person flow and hand back.
+ const existing = window.__lchatEmbed ? null : loadSession();
+ if (existing && existing.token && existing.userPub && existing.userPriv) {
+ // Already a member on this device → join that DM as your existing self.
+ return showWelcome("adopt", () => {
showStatus("Opening your chat…");
- return adoptExisting(id, payload.threadKey, existing);
- }
- // New person: pick a screen name (pre-filled with the inviter's suggestion),
- // generate a keypair, join into the DM.
- return showForm(fragName, async (name) => {
+ adoptExisting(id, payload.threadKey, existing);
+ });
+ }
+ // New person: pick a screen name (pre-filled with the inviter's suggestion),
+ // generate a keypair, join into the DM.
+ return showWelcome("join", () =>
+ showForm(fragName, async (name) => {
showStatus("Setting up…");
const kp = await generateUserKeypair();
const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf);
- });
- }
- // Unknown payload shape — fall through and treat as a fresh join.
+ }),
+ );
}
- // New person: pick a screen name (pre-filled if the inviter suggested one), then
- // generate a fresh keypair.
- showForm(fragName, async (name) => {
- showStatus("Setting up…");
- const kp = await generateUserKeypair();
- finish(id, name, kp.publicKey, kp.privateKey);
- });
+ // New person (or unknown payload shape): pick a screen name (pre-filled if the
+ // inviter suggested one), then generate a fresh keypair.
+ showWelcome("join", () =>
+ showForm(fragName, async (name) => {
+ showStatus("Setting up…");
+ const kp = await generateUserKeypair();
+ finish(id, name, kp.publicKey, kp.privateKey);
+ }),
+ );
+}
+
+// The welcome card: the invite is inspected (a non-consuming GET) but nothing is
+// claimed until a button is pressed. In the native add-chat flow (__lchatEmbed)
+// the person already chose explicitly in the app, so continue straight on.
+function showWelcome(kind, onContinue) {
+ if (window.__lchatEmbed) return onContinue();
+ card.classList.add("onboard");
+ card.classList.remove("status");
+ card.innerHTML = "";
+ const tpl = document.getElementById("welcome-tpl");
+ card.appendChild(tpl.content.cloneNode(true));
+ if (kind === "device") {
+ // document.title carries the substituted group name.
+ card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`;
+ card.querySelector("#welcome-sub").textContent = "This link signs this device in as you.";
+ card.querySelector("#welcome-caveat").remove();
+ card.querySelector("#welcome-web").textContent = "Sign in here in the browser";
+ } else if (kind === "adopt") {
+ card.querySelector("#welcome-sub").textContent =
+ "You're already in this chat on this device — continue to open the new conversation.";
+ card.querySelector("#welcome-caveat").remove();
+ card.querySelector("#welcome-web").textContent = "Continue";
+ }
+ // "Open in the app" hands the full link — fragment included — to the installed
+ // app via its scheme; the scheme URL never reaches any server. There's no way
+ // to detect the app from here: if nothing claims the scheme this is a no-op
+ // and the other buttons stand. Inside the Mac app's own webview it's noise.
+ const appBtn = card.querySelector("#welcome-app");
+ if (document.documentElement.classList.contains("native")) {
+ appBtn.closest(".row").remove();
+ } else {
+ appBtn.onclick = () => {
+ location.href = "elevenmessenger://add?url=" + encodeURIComponent(location.href);
+ };
+ }
+ // "Get the app" is per-instance config (LCHAT_APP_STORE_URL); hidden until set.
+ const storeURL = tpl.dataset.storeUrl || "";
+ const storeBtn = card.querySelector("#welcome-store");
+ if (storeURL) storeBtn.onclick = () => window.open(storeURL, "_blank", "noopener");
+ else storeBtn.closest(".row").remove();
+ card.querySelector("#welcome-web").onclick = () => onContinue();
}
async function finish(id, name, pub, priv, wrappedSelf = "") {
@@ -199,10 +252,3 @@ function showError(msg) {
card.querySelector("#error-msg").textContent = msg;
}
-function safeDecode(s) {
- try {
- return decodeURIComponent(s);
- } catch {
- return "";
- }
-}
diff --git a/web/static/style.css b/web/static/style.css
index a6f81f2..1c5ad5a 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1120,6 +1120,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.card.onboard .row { justify-content: center; }
.card.onboard p { margin-bottom: 14px; }
.onboard-icon { font-size: 46px; line-height: 1; margin-bottom: 10px; }
+/* the browser-only caveat on the welcome card: present but quiet */
+.welcome-caveat { font-size: 13px; margin: 12px 0 0; }
.steps {
text-align: left;
color: var(--muted);