// The Eleven home server: a tiny, anonymous, content-blind, passkey-gated blob // store (docs/onboarding-and-home-server.md). It holds, per user: WebAuthn // credential public keys, ONE opaque client-encrypted blob (padded client-side // so length leaks nothing), and hashed device link tokens. No email, no // username, no display name — the credential is the identity. It is strictly // additive to the messenger: clients with no enrolment never contact it. package main import ( "bytes" "crypto/sha256" "embed" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "log" "net" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" ) // maxBlobBytes caps a blob upload. The client pads the plaintext to a fixed // 16 KiB before encrypting (see web/static/crypto.js), so every blob's // ciphertext is the same size; the extra headroom here is the envelope header // (one wrapped DEK per enrolled passkey) plus AES-GCM overhead. The cap is a // hard abuse control, not a privacy boundary — sizes below it are already // uniform by construction. const maxBlobBytes = 18 << 10 //go:embed web/pages/*.html web/static/* var webFS embed.FS type Server struct { // store is the persistence seam (vaultStore, store.go) — SQLite today, an // object store later; handlers never know which. store vaultStore wa *waRelyingParty // keys mints/verifies home's stateless auth tokens (stateless.go): HMAC // ceremony proofs and AES-GCM-sealed in-flight WebAuthn sessions. Nothing // per-ceremony is held in this process — any replica can finish what // another began. keys *stateKeys // limiter rate-limits the ceremony begin endpoints per IP — the abuse // surface of an open, anonymous registration endpoint. limiter *rateLimiter // Sign in with Apple (siwa.go): the accepted token audiences (iOS bundle // id + web Services ID), the account-keying pepper, and Apple's JWKS // cache. Both empty/nil → the endpoints answer 404 (feature off). siwaClientIDs []string siwaPepper []byte siwa *jwksCache // oidc is the generalized provider table (oidc.go) — the Google and // Microsoft doors, keyed by route name. Each row gates like SiwA (client // ids + pepper, both or neither); empty/nil → every /api/oidc/* and // /oidc/return answers 404. Apple stays on the dedicated fields above. oidc map[string]*oidcProvider github *githubDoor // the code-exchange door (github.go); nil = dark // The email anchor (email.go): the SES mailer, the blind-index pepper, // and the in-memory sign-in codes. mail nil or pepper empty → off (404). mail *sesMailer emailPepper []byte emailCodes *emailCodes // cloud is the optional space provisioner client (see provision.go); nil // means this home server doesn't offer space creation. When regions is // non-empty (per-country provisioning), this points at the DEFAULT region's // provisioner, so demo and every host-agnostic fallback keep working. cloud *cloudProvisioner // regions, when LCHAT_CLOUD_REGIONS names more than one provisioner, lets a // signed-in user's new space be placed per country (docs/dev-mesh.md). Empty // means single-region: s.cloud is the only provisioner. defaultRegion is the // region code a create with no explicit choice lands in. regions []cloudRegion defaultRegion string // pow gates space creation behind hashcash when LCHAT_POW_BITS > 0 // (pow.go); nil = no gate. pow *powGate // demo is the account-free demo-space surface and its abuse gates // (demo.go); nil = demos not offered here (LCHAT_HOME_DEMO unset). demo *demoGate // fx caches the ECB reference rates behind the picker's ≈ display // (fx.go) — zero value ready (empty until the loop's first fetch). fx fxCache // pricebook caches the provisioner's public price book for the // unauthenticated /api/pricebook (pricebook.go) — zero value ready. pricebook pricebookCache // demoStatus caches the countdown banner's live status reads (demo.go) — // zero value ready. demoStatus demoStatusCache // origin is this server's public origin (also the WebAuthn RP origin) — // used to build the checkout return URLs. origin string page []byte pageHashes string // 'sha256-…' CSP sources for the page's inline scripts enrollPage []byte restorePage []byte // enrol remembers recently-completed enrolments by nonce (enrolstatus.go) // so the page that launched one can poll for the finish. In-memory: if this // server ever runs replicated, move the marks into the store. enrol *enrolMarks // infrastructureDir is an optional on-disk directory serving the // planet-scale operations guide (docs/planet-scale-guide/ in the repo) at // /infrastructure/ — read from disk, not embedded: go:embed can't reach // outside this package's own directory tree without duplicating the // guide's source, so a deploy step copies it here instead (see // docs/planet-scale-guide's own deploy note). Empty means the route is // off entirely — most self-hosted deployments have no reason to publish it. infrastructureDir string } func main() { addr := flag.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)") dbPath := flag.String("db", envOr("LCHAT_HOME_DB", "home.db"), "path to SQLite database file (env: LCHAT_HOME_DB)") origin := flag.String("origin", envOr("LCHAT_HOME_ORIGIN", "http://localhost:1748"), "public origin, e.g. https://home.elevenmessenger.com (env: LCHAT_HOME_ORIGIN) — WebAuthn RP identity") cloudURL := flag.String("cloud-url", os.Getenv("LCHAT_CLOUD_URL"), "space provisioner base URL, e.g. https://cloud.elevenmessenger.com (env: LCHAT_CLOUD_URL); empty disables space creation") cloudToken := flag.String("cloud-token", os.Getenv("LCHAT_CLOUD_TOKEN"), "caller token for the space provisioner (env: LCHAT_CLOUD_TOKEN)") flag.Parse() store, storeDesc, err := openStore(*dbPath) if err != nil { log.Fatalf("open store: %v", err) } // LCHAT_HOME_SESSION_KEY signs/seals home's stateless ceremony proofs and // in-flight WebAuthn sessions (stateless.go). It is auth plumbing, NOT a // decryption key for any vault. Any long random string works (e.g. // `openssl rand -base64 32`); it only needs setting when several replicas // must honour each other's tokens. Env-only, deliberately not a flag — // secrets don't belong in process listings. secret := []byte(os.Getenv("LCHAT_HOME_SESSION_KEY")) if len(secret) == 0 { log.Printf("LCHAT_HOME_SESSION_KEY not set; using an ephemeral key — in-flight ceremonies won't survive a restart, and multiple replicas need a shared key") } s, err := newServer(store, *origin, secret) if err != nil { log.Fatalf("server: %v", err) } // Sign in with Apple: LCHAT_HOME_SIWA_CLIENT_IDS (comma-separated token // audiences — the iOS bundle id and the web Services ID) turns the // feature on, and LCHAT_HOME_SIWA_PEPPER keys accounts as // HMAC(pepper, sub). The pepper is env-only (a secret) and, unlike the // session key, must be DURABLE — it names accounts, so an ephemeral // fallback would orphan every SiwA account on restart. Both or neither. siwaClients, siwaPepper := os.Getenv("LCHAT_HOME_SIWA_CLIENT_IDS"), os.Getenv("LCHAT_HOME_SIWA_PEPPER") if (siwaClients == "") != (siwaPepper == "") { log.Fatalf("Sign in with Apple needs BOTH LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER (or neither)") } if siwaClients != "" { for _, c := range strings.Split(siwaClients, ",") { if c = strings.TrimSpace(c); c != "" { s.siwaClientIDs = append(s.siwaClientIDs, c) } } s.siwaPepper = []byte(siwaPepper) s.siwa = &jwksCache{url: appleJWKSURL} log.Printf("Sign in with Apple on (%d client ids)", len(s.siwaClientIDs)) } // The email anchor: LCHAT_HOME_EMAIL_FROM + LCHAT_HOME_SES_REGION turn // it on (plus AWS creds in env); LCHAT_HOME_EMAIL_PEPPER blind-indexes // addresses and, like the SiwA pepper, names accounts — durable, env-only, // no ephemeral fallback. All three or none. emailFrom, sesRegion, emailPepper := os.Getenv("LCHAT_HOME_EMAIL_FROM"), os.Getenv("LCHAT_HOME_SES_REGION"), os.Getenv("LCHAT_HOME_EMAIL_PEPPER") if n := map[bool]int{true: 1}[emailFrom != ""] + map[bool]int{true: 1}[sesRegion != ""] + map[bool]int{true: 1}[emailPepper != ""]; n != 0 && n != 3 { log.Fatalf("the email anchor needs ALL of LCHAT_HOME_EMAIL_FROM, LCHAT_HOME_SES_REGION, LCHAT_HOME_EMAIL_PEPPER (or none)") } if emailFrom != "" { mailer, err := newSESMailer(sesRegion, emailFrom) if err != nil { log.Fatalf("email anchor: %v", err) } s.mail, s.emailPepper, s.emailCodes = mailer, []byte(emailPepper), newEmailCodes() log.Printf("email sign-in on (from %s via SES %s)", emailFrom, sesRegion) } // The OIDC provider table (oidc.go): each door rides the SiwA pattern — // LCHAT_HOME_
_CLIENT_IDS (comma-separated token audiences) turns it on // and LCHAT_HOME_
_PEPPER keys its accounts as HMAC(pepper, sub). The
// peppers are per provider, env-only, and DURABLE (they name accounts; no
// ephemeral fallback). Both or neither, per provider. Apple keeps its own
// LCHAT_HOME_SIWA_* pair above.
for _, pc := range []struct{ name, idsEnv, pepperEnv string }{
{"google", "LCHAT_HOME_GOOGLE_CLIENT_IDS", "LCHAT_HOME_GOOGLE_PEPPER"},
{"microsoft", "LCHAT_HOME_MICROSOFT_CLIENT_IDS", "LCHAT_HOME_MICROSOFT_PEPPER"},
} {
ids, pepper := os.Getenv(pc.idsEnv), os.Getenv(pc.pepperEnv)
if (ids == "") != (pepper == "") {
log.Fatalf("sign in with %s needs BOTH %s and %s (or neither)", pc.name, pc.idsEnv, pc.pepperEnv)
}
if ids == "" {
continue
}
p := newOIDCProvider(pc.name, splitCommaList(ids), []byte(pepper))
if s.oidc == nil {
s.oidc = map[string]*oidcProvider{}
}
s.oidc[pc.name] = p
log.Printf("sign in with %s on (%d client ids)", p.display, len(p.clientIDs))
}
// GitHub is the one code-flow door (github.go): a single client id +
// SECRET per instance (the prod and dev homes are separate GitHub OAuth
// apps), plus the usual durable pepper. All three or none.
ghID, ghSecret, ghPepper := os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID"),
os.Getenv("LCHAT_HOME_GITHUB_CLIENT_SECRET"), os.Getenv("LCHAT_HOME_GITHUB_PEPPER")
if (ghID == "") != (ghSecret == "") || (ghID == "") != (ghPepper == "") {
log.Fatal("sign in with GitHub needs LCHAT_HOME_GITHUB_CLIENT_ID, _CLIENT_SECRET, and _PEPPER together (or none)")
}
if ghID != "" {
s.github = &githubDoor{clientID: ghID, secret: ghSecret, pepper: []byte(ghPepper)}
log.Printf("sign in with GitHub on")
}
if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil {
log.Printf("space provisioning via %s", s.cloud.url)
}
// Per-country provisioning: LCHAT_CLOUD_REGIONS (a JSON array of
// {code,name,url,token}) plus LCHAT_CLOUD_DEFAULT_REGION. When set it
// supersedes the single LCHAT_CLOUD_URL — s.cloud becomes the default
// region's provisioner so demo and fallbacks are unchanged.
if regions, defaultCode, err := parseCloudRegions(os.Getenv("LCHAT_CLOUD_REGIONS"), os.Getenv("LCHAT_CLOUD_DEFAULT_REGION")); err != nil {
log.Fatalf("cloud regions: %v", err)
} else if len(regions) > 0 {
s.regions, s.defaultRegion = regions, defaultCode
s.cloud = s.cloudForRegion(defaultCode)
log.Printf("space provisioning across %d regions (default %s)", len(regions), defaultCode)
}
if bits, _ := strconv.Atoi(envOr("LCHAT_POW_BITS", "0")); bits > 0 {
s.pow = newPowGate(bits, s.keys)
log.Printf("space creation gated by proof of work (%d bits)", bits)
}
if os.Getenv("LCHAT_HOME_DEMO") == "1" {
bits, _ := strconv.Atoi(envOr("LCHAT_DEMO_POW_BITS", "18"))
gate, err := newDemoGate(bits, s.keys)
if err != nil {
log.Fatalf("demo gate: %v", err)
}
s.demo = gate
log.Printf("demo spaces on (mandatory proof of work, base %d bits, adaptive)", bits)
}
if s.infrastructureDir = os.Getenv("LCHAT_HOME_INFRASTRUCTURE_DIR"); s.infrastructureDir != "" {
log.Printf("planet-scale guide served from %s at /infrastructure/", s.infrastructureDir)
}
// The ≈ local-currency display's rate snapshot (fx.go) — only worth
// fetching where a price picker exists, i.e. where provisioning is wired.
if s.cloud != nil {
go s.fxLoop(envOr("LCHAT_HOME_FX_URL", ecbRatesURL))
}
log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin)
if err := serve(*addr, s.handler()); err != nil {
log.Fatal(err)
}
}
// openStore selects the vaultStore backend by LCHAT_HOME_STORE. Unset (or
// "sqlite") keeps today's zero-config self-hoster default; "s3" is the
// flagship-deployment backend (#550): LCHAT_HOME_S3_BUCKET + _REGION required,
// _PREFIX and _ENDPOINT optional, credentials from the standard AWS env.
// Misconfiguration fails fast — an s3-selected deployment must never limp
// along on an accidental empty local DB.
func openStore(dbPath string) (vaultStore, string, error) {
switch backend := os.Getenv("LCHAT_HOME_STORE"); backend {
case "", "sqlite":
st, err := openSQLiteStore(dbPath)
return st, "sqlite " + dbPath, err
case "s3":
bucket, region := os.Getenv("LCHAT_HOME_S3_BUCKET"), os.Getenv("LCHAT_HOME_S3_REGION")
st, err := newS3Store(bucket, region, os.Getenv("LCHAT_HOME_S3_PREFIX"), os.Getenv("LCHAT_HOME_S3_ENDPOINT"))
if err != nil {
return nil, "", fmt.Errorf("LCHAT_HOME_STORE=s3: %w (need LCHAT_HOME_S3_BUCKET, LCHAT_HOME_S3_REGION, AWS credentials in env)", err)
}
return st, fmt.Sprintf("s3 bucket %s (%s)", bucket, region), nil
default:
return nil, "", fmt.Errorf("unknown LCHAT_HOME_STORE %q (want \"sqlite\" or \"s3\")", backend)
}
}
// serve listens on addr and serves h. A path-like addr (starts with "/") is a
// unix socket — how the instance router fronts this service, reverse-proxying to
// a socket rather than a TCP port; anything else is a TCP address (the default,
// e.g. on a container platform that health-checks over HTTP). On the socket path
// it clobbers only a stale socket (nothing listening) and locks it to 0600,
// mirroring the main binary's listen().
func serve(addr string, h http.Handler) error {
// ReadHeaderTimeout caps how long a client may dribble request headers
// (Slowloris): the TCP branch binds straight to public TCP on a container
// platform. Same 10s the messenger's own listeners use (main.go).
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
if !strings.HasPrefix(addr, "/") {
srv.Addr = addr
return srv.ListenAndServe()
}
if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil {
c.Close()
return fmt.Errorf("socket %s is already in use by another process", addr)
}
if err := os.Remove(addr); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale socket %s: %w", addr, err)
}
ln, err := net.Listen("unix", addr)
if err != nil {
return err
}
defer ln.Close()
if err := os.Chmod(addr, 0o600); err != nil {
return fmt.Errorf("chmod socket %s: %w", addr, err)
}
return srv.Serve(ln)
}
// newServer builds the server. stateSecret keys the stateless ceremony/session
// tokens; empty means "ephemeral random key" (fine single-process — main logs
// the caveat).
func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, error) {
u, err := url.Parse(origin)
if err != nil {
return nil, err
}
wa, err := newRelyingParty(u.Hostname(), origin)
if err != nil {
return nil, err
}
keys, err := newStateKeys(stateSecret)
if err != nil {
return nil, err
}
page, err := webFS.ReadFile("web/pages/index.html")
if err != nil {
return nil, err
}
// The GitHub client id is per-instance (separate OAuth apps per home),
// so it rides the page as a meta tag; empty env leaves the door dark.
page = bytes.ReplaceAll(page, []byte("__GITHUB_CLIENT_ID__"),
[]byte(os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID")))
enrollPage, err := webFS.ReadFile("web/pages/enroll.html")
if err != nil {
return nil, err
}
restorePage, err := webFS.ReadFile("web/pages/restore.html")
if err != nil {
return nil, err
}
return &Server{
store: store,
enrollPage: enrollPage,
restorePage: restorePage,
wa: wa,
keys: keys,
limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
page: page,
pageHashes: inlineScriptHashes(page),
origin: strings.TrimRight(origin, "/"),
enrol: newEnrolMarks(),
}, nil
}
// inlineScriptHashes renders 'sha256-…' CSP sources for every inline
// "))
if j < 0 {
break
}
sum := sha256.Sum256(rest[:j])
out.WriteString(" 'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'")
rest = rest[j:]
}
return out.String()
}
// handler wires up all routes. It's a method so tests can exercise the full
// router via httptest without starting a real listener.
func (s *Server) handler() http.Handler {
staticFS, err := fs.Sub(webFS, "web/static")
if err != nil {
log.Fatalf("static fs: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.servePage)
// The enrolment popup: an instance opens this to add itself to the backup.
// The server-entry payload rides in the URL fragment (never reaches here);
// this just serves the page.
mux.HandleFunc("GET /enroll", s.serveEnroll)
// The restore page: a signed-out instance opens this to sign back in from the
// backup. Like /enroll, the request rides in the fragment; this just serves it.
mux.HandleFunc("GET /restore", s.serveRestore)
mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))))
// The planet-scale operations guide — optional (see the infrastructureDir
// doc comment); s.infrastructureDir == "" answers 404, checked at request
// time like every other optional feature here (s.cloud, s.pow). Read from
// disk so the guide's source stays exactly where it lives in the repo
// (docs/planet-scale-guide/), with nothing duplicated or re-embedded
// here. Public, unauthenticated, and read-only — the guide itself is
// careful to name no real hostname, IP, or box identity (see its own
// PRIVACY convention), so serving it alongside the home page adds no
// exposure beyond the guide's own content.
mux.HandleFunc("GET /infrastructure/", s.serveInfrastructure)
mux.HandleFunc("GET /infrastructure", func(w http.ResponseWriter, r *http.Request) {
if s.infrastructureDir == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/infrastructure/", http.StatusMovedPermanently)
})
// Apple app-site-association: lets the native Eleven apps run passkey
// ceremonies against this RP (the webcredentials service). Content-blind —
// it names only the apps' team+bundle ids, nothing user-specific.
mux.HandleFunc("GET /.well-known/apple-app-site-association", s.serveAASA)
// WebAuthn ceremonies. begin endpoints are rate-limited: they're the open,
// anonymous surface (finish requires a signed response, which is its own gate).
mux.HandleFunc("POST /api/register/begin", s.rateLimited(s.registerBegin))
mux.HandleFunc("POST /api/register/finish", s.registerFinish)
mux.HandleFunc("POST /api/login/begin", s.rateLimited(s.loginBegin))
mux.HandleFunc("POST /api/login/finish", s.loginFinish)
// Sign in with Apple (siwa.go): the one verifier every platform posts
// Apple's identity token to, and the web flow's form_post trampoline.
mux.HandleFunc("POST /api/siwa/verify", s.rateLimited(s.siwaVerify))
mux.HandleFunc("POST /siwa/return", s.rateLimited(s.siwaReturn))
// The generalized OIDC doors (oidc.go): per-provider verify on the same
// one-verifier contract as /api/siwa/verify, and the web flow's form_post
// trampoline mirroring /siwa/return. Apple keeps its dedicated routes.
mux.HandleFunc("POST /api/oidc/{provider}/verify", s.rateLimited(s.oidcVerify))
mux.HandleFunc("POST /oidc/return", s.rateLimited(s.oidcReturn))
// GitHub (the code-flow door): a literal pattern wins over the
// {provider} wildcard above, and the GET return is the code-flow half
// of the trampoline (form_post keeps the POST half).
mux.HandleFunc("POST /api/oidc/github/verify", s.rateLimited(s.githubVerify))
mux.HandleFunc("GET /oidc/return", s.rateLimited(s.oauthCodeReturn))
// The email anchor (email.go): request a sign-in code, redeem it.
mux.HandleFunc("POST /api/email/request", s.rateLimited(s.emailRequest))
mux.HandleFunc("POST /api/email/verify", s.rateLimited(s.emailVerify))
// Enrolment completion oracle (enrolstatus.go): /enroll reports a finished
// ceremony; the instance page that launched it polls for the answer. The
// status read is CORS-open and unlimited on purpose — it's a map lookup
// gated by an unguessable client nonce, and the poller may not share an IP
// budget with anyone (the write side is rate-limited like the begins).
mux.HandleFunc("POST /api/enroll-done", s.rateLimited(s.enrollDoneHandler))
mux.HandleFunc("GET /api/enroll-status", s.enrollStatusHandler)
// The public price book (pricebook.go): read by SPACE origins' browser JS
// (the setup conversation's plan step, #1374) — no auth, CORS-open.
mux.HandleFunc("GET /api/pricebook", s.pricebookHandler)
// The blob: read/write with a link token; delete only with a fresh ceremony.
mux.HandleFunc("GET /api/blob", s.getBlobHandler)
mux.HandleFunc("PUT /api/blob", s.putBlobHandler)
mux.HandleFunc("DELETE /api/blob", s.deleteBlobHandler)
// Space provisioning (optional: 501 unless a cloud provisioner is
// configured). Link-token auth + rate limit; the provisioner adds a quota.
// /api/features lets the page hide the "create a space" UI when this home
// server doesn't offer it (public — it reveals only whether the feature is on).
mux.HandleFunc("GET /api/features", s.featuresHandler)
mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler))
// Pay-before-create (provision.go): mint the Checkout for a paid space,
// then claim it into existence once Stripe confirms. Both rate-limited —
// one mints sessions, the other dials Stripe + can provision.
mux.HandleFunc("POST /api/spaces/checkout-create", s.rateLimited(s.checkoutCreateHandler))
mux.HandleFunc("POST /api/spaces/claim-create", s.rateLimited(s.claimCreateHandler))
// The proof-of-work challenge behind creation (cheap to mint; rate-limited
// with the same budget as the creates it gates).
mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler))
// Demo spaces (demo.go): the account-free try-it path. Deliberately
// unauthenticated — the demo gates (mandatory adaptive proof of work +
// per-connection cooldown) are the whole boundary, plus the shared
// per-IP rate limiter in front.
mux.HandleFunc("POST /api/demo/challenge", s.rateLimited(s.demoChallengeHandler))
mux.HandleFunc("POST /api/demo", s.rateLimited(s.demoCreateHandler))
// Claiming a demo as your free space — additive, so the link token
// suffices (same reasoning as creation).
mux.HandleFunc("POST /api/spaces/claim", s.rateLimited(s.claimSpaceHandler))
// The countdown banner's live "still a demo?" read — public + CORS-open
// (it renders on the demo space's origin, pre-account); discloses only
// demo-ness + expiry, never existence or ownership. Deliberately OUTSIDE
// the shared ceremony limiter: this read auto-fires on every demo-tab load
// and refocus, and behind the router every caller shares one RemoteAddr
// bucket — it would drain the tokens sign-in ceremonies need. A short
// per-host cache bounds the load instead (demoStatusCache).
mux.HandleFunc("GET /api/spaces/demo-status", s.demoStatusHandler)
// Which spaces this user provisioned (read; drives the delete affordance)…
mux.HandleFunc("GET /api/spaces", s.listSpacesHandler)
// …and permanent deletion of one — ceremony-gated like every destructive op.
mux.HandleFunc("DELETE /api/spaces/{host}", s.deleteSpaceHandler)
// Upgrading a space (a Stripe Checkout round trip) is additive, so it takes
// the link token; rate-limited because each call mints a checkout session.
mux.HandleFunc("POST /api/spaces/{host}/checkout", s.rateLimited(s.checkoutSpaceHandler))
// The picker's ≈ local-currency rates (fx.go) — public, cached, tiny.
mux.HandleFunc("GET /api/fx", s.fxHandler)
// Managing an existing subscription (Stripe's hosted Billing Portal).
mux.HandleFunc("POST /api/spaces/{host}/portal", s.rateLimited(s.portalSpaceHandler))
// Devices: list with a link token; revoke only with a fresh ceremony. The
// asymmetry is deliberate — a stolen link token must never reduce access.
mux.HandleFunc("GET /api/devices", s.listDevicesHandler)
mux.HandleFunc("DELETE /api/devices/{id}", s.deleteDeviceHandler)
// Credentials: removing a passkey reduces who can ever unlock the blob, so
// it's ceremony-gated like the other destructive ops.
mux.HandleFunc("DELETE /api/credentials/{id}", s.deleteCredentialHandler)
// Sign-in anchors (anchors.go): every way into the account as one list —
// read with a link token, remove with a fresh ceremony. The settings
// surface (#1098) builds on these.
mux.HandleFunc("GET /api/anchors", s.listAnchorsHandler)
mux.HandleFunc("DELETE /api/anchors/{kind}/{id}", s.deleteAnchorHandler)
// Account merge (merge.go, #1098): both-sides-proofs-gated. The client
// unions the vaults and empties the absorbed one FIRST; this then moves
// anchors/passkeys/devices and retires the emptied account.
mux.HandleFunc("POST /api/account/merge", s.mergeAccountHandler)
return securityHeaders(mux)
}
// securityHeaders adds defense-in-depth headers to every response (mirrors the
// messenger's). The blob download gets nosniff like any other response here.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Strict-Transport-Security", "max-age=31536000")
next.ServeHTTP(w, r)
})
}
func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
// "GET /" matches everything; only the exact root serves the page.
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
// The page's one inline script — the pre-paint boot gate — is allowed by
// its content hash (pageHashes, computed from the served bytes at boot).
// connect-src admits https: beyond 'self' for exactly one client-side
// move: the last-admin check asks a saved space's own /api/me before
// allowing "Delete your access" (home.js checkSoleAdmin). Space hosts are
// dynamic, so they can't be allowlisted by name — and the fetch happening
// in the browser is the point: this server stays blind to which spaces a
// user holds.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'"+s.pageHashes+"; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self' https:; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.page)
}
// serveAASA publishes the Apple app-site-association so the native Eleven apps
// can bind passkeys to this relying party (the `webcredentials` service). Only
// team+bundle ids appear — nothing user-identifying, consistent with this
// server's content-blind posture. Served at the exact well-known path Apple
// fetches, as application/json with no auth.
func (s *Server) serveAASA(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"webcredentials":{"apps":[` +
`"J5NDF5PCD8.com.elevenmessenger.messenger",` +
`"J5NDF5PCD8.com.elevenmessenger.messenger.mac"]}}`))
}
// serveEnroll serves the enrolment popup. Same CSP as the main page — no inline
// scripts, no cross-origin fetches; the only cross-origin move is a postMessage
// back to the opener, which CSP doesn't govern.
func (s *Server) serveEnroll(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.enrollPage)
}
// serveRestore serves the restore page (same CSP as the others).
func (s *Server) serveRestore(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.restorePage)
}
func noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
next.ServeHTTP(w, r)
})
}
// serveInfrastructure answers /infrastructure/* from an on-disk directory
// (infrastructureDir), or 404 if unconfigured. A strict, same-origin-only
// CSP: the guide's pages carry no inline scripts/styles and make no outside
// requests (unlike "/", which allows connect-src https: for one client-side
// admin check), so this can be tighter than the rest of the site. Never
// cached — like the embedded pages, the guide must never outlive a deploy.
func (s *Server) serveInfrastructure(w http.ResponseWriter, r *http.Request) {
if s.infrastructureDir == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; "+
"object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
http.StripPrefix("/infrastructure/", http.FileServer(http.Dir(s.infrastructureDir))).ServeHTTP(w, r)
}
// ---- auth helpers ---------------------------------------------------------
// bearerToken pulls the token out of "Authorization: Bearer