package main // Space provisioning: a signed-in user asks their home server for a new Eleven // space, and home forwards the request to a cloud provisioner // (cloud.elevenmessenger.com — see docs/cloud-provisioner.md) using its minted // caller token. Home's role is deliberately thin: authenticate the user (device // link token), forward their opaque user id as the quota key, and relay the // result. The setup link comes back to the client, which claims it and then // adds the space to its own encrypted backup — the blob stays content-blind. // // Privacy note (deliberate, documented trade-off): provisioning is the one // place home learns a relationship between a user and an instance hostname — // operational metadata held by the provisioner's ledger, never in the blob. // A user who never provisions never appears there. import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // cloudProvisioner is home's client for one cloud provisioner. nil when the // deployment doesn't offer provisioning — home stays strictly additive. type cloudProvisioner struct { url string // e.g. https://cloud.elevenmessenger.com token string // caller token minted by the provisioner's admin client *http.Client } func newCloudProvisioner(url, token string) *cloudProvisioner { if url == "" || token == "" { return nil } return &cloudProvisioner{ url: strings.TrimRight(url, "/"), token: token, // Creation waits on a real instance start + health check downstream, so // the timeout is generous. client: &http.Client{Timeout: 45 * time.Second}, } } // cloudRegion is one provisioner home can place a space in, keyed by a short // code that also appears as the second DNS label of the hosts it serves // (e.g. "ie" in "8oe.ie.dev.11ch.at"). A home configured with more than one // (LCHAT_CLOUD_REGIONS) provisions per country; with none, the single // LCHAT_CLOUD_URL provisioner (s.cloud) is the only region. type cloudRegion struct { Code string // "ie" — matches the host's region label and the API `region` Name string // "Ireland" — the only user-facing string (shown in the picker) prov *cloudProvisioner } // regionConfig is the JSON shape of one LCHAT_CLOUD_REGIONS entry. token stays // server-side; only Code/Name are ever exposed (featuresHandler). type regionConfig struct { Code string `json:"code"` Name string `json:"name"` URL string `json:"url"` Token string `json:"token"` } // parseCloudRegions reads LCHAT_CLOUD_REGIONS (a JSON array of regionConfig) and // LCHAT_CLOUD_DEFAULT_REGION. Empty JSON → no regions (single-region mode, the // caller keeps its LCHAT_CLOUD_URL provisioner). The default code must name one // of the configured regions; empty defaults to the first. func parseCloudRegions(regionsJSON, defaultCode string) ([]cloudRegion, string, error) { regionsJSON = strings.TrimSpace(regionsJSON) if regionsJSON == "" { return nil, "", nil } var cfgs []regionConfig if err := json.Unmarshal([]byte(regionsJSON), &cfgs); err != nil { return nil, "", fmt.Errorf("parse LCHAT_CLOUD_REGIONS: %w", err) } regions := make([]cloudRegion, 0, len(cfgs)) seen := map[string]bool{} for _, c := range cfgs { if c.Code == "" { return nil, "", fmt.Errorf("a region is missing its code") } if seen[c.Code] { return nil, "", fmt.Errorf("region %q is listed twice", c.Code) } seen[c.Code] = true prov := newCloudProvisioner(c.URL, c.Token) if prov == nil { return nil, "", fmt.Errorf("region %q needs both url and token", c.Code) } name := c.Name if name == "" { name = c.Code } regions = append(regions, cloudRegion{Code: c.Code, Name: name, prov: prov}) } if defaultCode == "" { defaultCode = regions[0].Code } else if !seen[defaultCode] { return nil, "", fmt.Errorf("LCHAT_CLOUD_DEFAULT_REGION %q is not one of the configured regions", defaultCode) } return regions, defaultCode, nil } // cloudForRegion returns the provisioner for a region code, falling back to the // default provisioner (s.cloud) when the code is empty, unknown, or when home is // running single-region. So an unrecognised owner choice quietly auto-assigns // rather than failing. func (s *Server) cloudForRegion(code string) *cloudProvisioner { for i := range s.regions { if s.regions[i].Code == code { return s.regions[i].prov } } return s.cloud } // regionCodeForHost extracts the region a host lives in from its name — the // label after the leftmost (space-id) label, e.g. "8oe.ie.dev.11ch.at" → "ie" — // but only when that names a configured region. Single-region / prod hosts // ("8oe.11ch.at") carry no region label, so this returns "" and callers use the // default provisioner. func (s *Server) regionCodeForHost(host string) string { labels := strings.Split(host, ".") if len(labels) < 2 { return "" } for i := range s.regions { if s.regions[i].Code == labels[1] { return labels[1] } } return "" } // cloudForHost routes a host-specific operation (delete, checkout, portal) to // the provisioner of the region the host lives in, defaulting to s.cloud. func (s *Server) cloudForHost(host string) *cloudProvisioner { return s.cloudForRegion(s.regionCodeForHost(host)) } // allClouds is every distinct provisioner home can reach — all regions, or just // the single default when unpartitioned — used to fan a listing out so a user // sees the spaces they hold in every region. func (s *Server) allClouds() []*cloudProvisioner { if len(s.regions) == 0 { if s.cloud == nil { return nil } return []*cloudProvisioner{s.cloud} } out := make([]*cloudProvisioner, 0, len(s.regions)) for i := range s.regions { out = append(out, s.regions[i].prov) } return out } // featuresHandler advertises which optional features this home server offers, // so the page can hide UI it can't serve. Public: it reveals only a boolean, // never a token or endpoint that would work without one. func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) { out := map[string]any{ "spaces": s.cloud != nil, "demo": s.demo != nil && s.cloud != nil, } // Which of the web-flow sign-in doors are lit — for the NATIVE apps, // which can't read index.html's meta tag or the web's registry. Booleans // plus GitHub's client id, the one value natives can't compile in (prod // and dev are separate GitHub OAuth apps); a client id is a public // identifier, not a secret — Google's and Microsoft's ship in client // code on every platform. doors := map[string]any{ "google": s.oidc["google"] != nil, "microsoft": s.oidc["microsoft"] != nil, } if s.github != nil { doors["github_client_id"] = s.github.clientID } out["doors"] = doors // When home provisions per country, advertise the regions — code + name // only, never a url or token — and the default, so the create UI can offer // the choice. Absent → the client shows no region control (single-region). if len(s.regions) > 0 { regs := make([]map[string]string, len(s.regions)) for i, rg := range s.regions { regs[i] = map[string]string{"code": rg.Code, "name": rg.Name} } out["regions"] = regs out["default_region"] = s.defaultRegion } writeJSON(w, http.StatusOK, out) } // challengeHandler: POST /api/spaces/challenge — mint a proof-of-work // challenge for the signed-in account (pow.go). bits 0 = the gate is off and // creation needs no proof; the client checks this instead of a feature flag. func (s *Server) challengeHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.pow == nil { writeJSON(w, http.StatusOK, map[string]any{"bits": 0}) return } challenge, err := s.pow.mint(uid) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "challenge_failed"}) return } writeJSON(w, http.StatusOK, map[string]any{"bits": s.pow.bits, "challenge": challenge}) } // createSpaceHandler: POST /api/spaces — provision a new space for the // signed-in user. Auth is the device link token (creating a space is additive, // not destructive, so no fresh ceremony is demanded); the provisioner's // per-user quota and this endpoint's rate limit bound abuse. func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer space creation", }) return } // Gated to existing members: you can only create a space once you already // have one. A backup blob exists only after enrolling ≥1 space, and enrolling // needs a space you were invited into — so this keeps creation invite-gated // (not open to the world) without the server ever reading the E2E-encrypted // list. The client hides the button too; this is the boundary behind it. if _, _, hasBlob, err := s.store.getBlob(uid); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{ "error": "create_failed", "message": "could not check your account", }) return } else if !hasBlob { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "not_eligible", "message": "You need at least one Eleven space before you can create a new one.", }) return } // One read of the (optional) body: the region choice and, when the gate is // on, the solved proof of work ride together. A missing or empty body // decodes to the zero value — auto-assign the region, and (pow on) fall to // pow_required below. var req struct { Region string `json:"region"` Name string `json:"name"` Challenge string `json:"challenge"` Nonce string `json:"nonce"` } _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) // The proof-of-work gate (pow.go): when enabled, creation carries a solved // challenge. Checked AFTER the cheap gates so an ineligible caller never // burns CPU for a refusal, and before the provisioner is ever dialed. if s.pow != nil { if req.Challenge == "" { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.", }) return } if err := s.pow.verify(uid, req.Challenge, req.Nonce); err != nil { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "pow_failed", "message": "The proof of work didn't check out — try again.", }) return } } // Owner override: an explicit region places the space there; empty // auto-assigns to the default region. An unknown code quietly falls back to // the default (cloudForRegion) rather than failing the create. // The name rides through to the provisioner (→ the instance's seeded // display name). Plaintext branding by design; capped here so a huge body // never travels further. name := strings.TrimSpace(req.Name) if len(name) > 60 { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "The space name can be at most 60 characters.", }) return } status, body, err := s.cloudForRegion(req.Region).createSpace(uid, name) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "create_failed", "message": "could not reach the space provisioner", }) return } // Relay the provisioner's stable codes as-is (201 created, 409 quota, // 503 at-capacity with its member-facing message); anything unexpected is // flattened so a client never depends on an undocumented shape. switch status { case http.StatusCreated, http.StatusConflict, http.StatusServiceUnavailable: writeJSON(w, status, body) default: writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "create_failed", "message": "space creation failed", }) } } // listSpacesHandler: GET /api/spaces — the hosts this user provisioned (from // the provisioner's ledger). Link-token auth: it's a read, and the client uses // it only to decide which rows may offer "Delete space". func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } clouds := s.allClouds() if len(clouds) == 0 { writeJSON(w, http.StatusOK, map[string]any{"spaces": []any{}}) return } // Fan the read out across every region and merge — a user's spaces may live // in more than one. Each host encodes its own region, so the client needs no // hint back; management calls route by host (cloudForHost). Best-effort per // region: one region being unreachable must not blank the list, but if every // region we tried failed, surface the gateway error rather than an empty list // that would read as "you have no spaces". path := "/api/instances?requester=" + url.QueryEscape(uid) // billing carries the tier picker's price book. The default region's cloud // is its source of truth (checkout still validates per host downstream); // any region's book is the fallback so one unreachable region doesn't hide // the Upgrade UI. (The fan-out rewrite in #1083 dropped this key entirely, // which silently removed the Upgrade affordance — home.js reads r.billing.) spaces, billing, gotOne, sawErr := []any{}, any(nil), false, false for _, c := range clouds { status, body, err := c.do("GET", path, nil) if err != nil || status != http.StatusOK { sawErr = true continue } gotOne = true if arr, ok := body["spaces"].([]any); ok { spaces = append(spaces, arr...) } if b, ok := body["billing"]; ok && b != nil && b != false && (billing == nil || c == s.cloud) { billing = b } } if !gotOne && sawErr { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "list_failed"}) return } out := map[string]any{"spaces": spaces} if billing != nil { out["billing"] = billing } writeJSON(w, http.StatusOK, out) } // deleteSpaceHandler: DELETE /api/spaces/{host} — permanently deprovision a // space this user created. DESTRUCTIVE, so it demands a FRESH passkey ceremony // (X-Ceremony-Proof), never a link token — same rule as revoking devices or // deleting the backup: a stolen link token must never be able to destroy a // space. Ownership is enforced downstream by the provisioner's ledger. func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer space management", }) return } host := r.PathValue("host") status, body, err := s.cloudForHost(host).do("DELETE", "/api/instances/"+url.PathEscape(host)+"?requester="+url.QueryEscape(uid), nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "delete_failed", "message": "could not reach the space provisioner", }) return } switch status { case http.StatusOK, http.StatusNotFound: writeJSON(w, status, body) default: // Keep the provisioner's member-facing message when it has one — e.g. // "couldn't cancel this space's subscription — nothing was deleted" is // actionable in a way a flat "deletion failed" isn't. msg, _ := body["message"].(string) if msg == "" { msg = "space deletion failed" } writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "delete_failed", "message": msg, }) } } // checkoutCreateHandler: POST /api/spaces/checkout-create {name, tier, band, // region, challenge, nonce} — start a PAID space: the provisioner mints a // Stripe Checkout link bound to a pending allocation, and the space is only // provisioned once the payment confirms (claimCreateHandler). Same gates as // the free create — link token, the member gate, and the proof of work: a // checkout mint is priced like a create, not like a read. func (s *Server) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer space creation", }) return } if _, _, hasBlob, err := s.store.getBlob(uid); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{ "error": "create_failed", "message": "could not check your account", }) return } else if !hasBlob { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "not_eligible", "message": "You need at least one Eleven space before you can create a new one.", }) return } var req struct { Region string `json:"region"` Name string `json:"name"` Tier string `json:"tier"` Band string `json:"band"` Challenge string `json:"challenge"` Nonce string `json:"nonce"` } _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) if s.pow != nil { if req.Challenge == "" { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.", }) return } if err := s.pow.verify(uid, req.Challenge, req.Nonce); err != nil { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "pow_failed", "message": "The proof of work didn't check out — try again.", }) return } } if strings.TrimSpace(req.Tier) == "" { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "tier required", }) return } name := strings.TrimSpace(req.Name) if len(name) > 60 { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "The space name can be at most 60 characters.", }) return } // Stripe substitutes {CHECKOUT_SESSION_ID} into the redirect, which is how // the landing page knows what to claim; the region rides along so the claim // dials the same cloud that minted the session. successURL := s.origin + "/?paidcreate={CHECKOUT_SESSION_ID}" if region := strings.TrimSpace(req.Region); region != "" { successURL += "&pcregion=" + url.QueryEscape(region) } payload, _ := json.Marshal(map[string]string{ "requester": uid, "tier": req.Tier, "band": req.Band, "name": name, "success_url": successURL, "cancel_url": s.origin + "/", }) status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/checkout-create", payload) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "checkout_failed", "message": "could not reach the space provisioner", }) return } switch status { case http.StatusOK, http.StatusBadRequest, http.StatusNotImplemented, http.StatusServiceUnavailable: writeJSON(w, status, body) default: writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "checkout_failed", "message": "couldn't start the checkout — please try again", }) } } // claimCreateHandler: POST /api/spaces/claim-create {session_id, region} — // finish a pay-first create after the Stripe redirect (or any later visit: // the claim is idempotent downstream). Link-token auth: the session id is // worthless to anyone but the pending row's owner, which the provisioner // re-checks against its ledger. func (s *Server) claimCreateHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer space creation", }) return } var req struct { Region string `json:"region"` SessionID string `json:"session_id"` } _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) if strings.TrimSpace(req.SessionID) == "" { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "session_id required", }) return } payload, _ := json.Marshal(map[string]string{ "requester": uid, "session_id": strings.TrimSpace(req.SessionID), }) status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/claim-create", payload) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "claim_failed", "message": "could not reach the space provisioner", }) return } switch status { case http.StatusOK, http.StatusCreated, http.StatusPaymentRequired, http.StatusNotFound, http.StatusNotImplemented: writeJSON(w, status, body) default: // Keep the provisioner's message when it has one — "payment confirmed, // try again in a moment" must reach the person waiting. msg, _ := body["message"].(string) if msg == "" { msg = "the space couldn't be set up just now — try again in a moment" } writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "claim_failed", "message": msg, }) } } // checkoutSpaceHandler: POST /api/spaces/{host}/checkout {tier} — ask the // provisioner for a Stripe Checkout link to upgrade a space this user created, // at the chosen tier (validated downstream against the provisioner's price // book). Link-token auth (paying is additive, not destructive — same bar as // creating); ownership is enforced downstream by the provisioner's ledger. The // success/cancel URLs land back on this page, so the checkout is a same-tab // round trip. func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer upgrades", }) return } var req struct { Tier string `json:"tier"` Band string `json:"band"` } if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil || req.Tier == "" { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "bad_request", "message": "tier required", }) return } host := r.PathValue("host") // The honor band (docs/pricing-bands.md) rides through verbatim; the // provisioner's price book is the validator. Empty = base, downstream. payload, _ := json.Marshal(map[string]string{ "requester": uid, "tier": req.Tier, "band": req.Band, "success_url": s.origin + "/?upgraded=" + url.QueryEscape(host), "cancel_url": s.origin + "/", }) status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "checkout_failed", "message": "could not reach the space provisioner", }) return } // Relay the provisioner's stable codes (200 link, 400 bad tier, 404 not // yours, 409 already upgraded, 501 no billing); flatten anything else. switch status { case http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented: writeJSON(w, status, body) default: writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "checkout_failed", "message": "couldn't start the upgrade", }) } } // portalSpaceHandler: POST /api/spaces/{host}/portal — a Billing Portal link // for a space this user created and pays for. Link-token auth (reading and // managing your own subscription is not destructive of the SPACE; the portal // itself is Stripe-hosted and Stripe-authenticated beyond this handoff). func (s *Server) portalSpaceHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if s.cloud == nil { writeJSON(w, http.StatusNotImplemented, map[string]any{ "error": "unavailable", "message": "this home server does not offer upgrades", }) return } host := r.PathValue("host") payload, _ := json.Marshal(map[string]string{ "requester": uid, "return_url": s.origin + "/", }) status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "portal_failed", "message": "could not reach the space provisioner", }) return } switch status { case http.StatusOK, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented: writeJSON(w, status, body) default: writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "portal_failed", "message": "couldn't open the billing page", }) } } // createSpace forwards one provisioning request; the user id is the quota key // (opaque to the provisioner — random, no email or name anywhere). func (c *cloudProvisioner) createSpace(requester, name string) (int, map[string]any, error) { body := map[string]string{"requester": requester} if name != "" { body["name"] = name } payload, _ := json.Marshal(body) return c.do("POST", "/api/instances", payload) } // do sends one request to the provisioner with the caller token, returning the // status + decoded JSON body. func (c *cloudProvisioner) do(method, path string, payload []byte) (int, map[string]any, error) { var rd io.Reader if payload != nil { rd = bytes.NewReader(payload) } req, err := http.NewRequest(method, c.url+path, rd) if err != nil { return 0, nil, err } req.Header.Set("Authorization", "Bearer "+c.token) if payload != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.client.Do(req) if err != nil { return 0, nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { body = map[string]any{} } return resp.StatusCode, body, nil }