| 1 | package main |
| 2 | |
| 3 | // Space provisioning: a signed-in user asks their home server for a new Eleven |
| 4 | // space, and home forwards the request to a cloud provisioner |
| 5 | // (cloud.elevenmessenger.com — see docs/cloud-provisioner.md) using its minted |
| 6 | // caller token. Home's role is deliberately thin: authenticate the user (device |
| 7 | // link token), forward their opaque user id as the quota key, and relay the |
| 8 | // result. The setup link comes back to the client, which claims it and then |
| 9 | // adds the space to its own encrypted backup — the blob stays content-blind. |
| 10 | // |
| 11 | // Privacy note (deliberate, documented trade-off): provisioning is the one |
| 12 | // place home learns a relationship between a user and an instance hostname — |
| 13 | // operational metadata held by the provisioner's ledger, never in the blob. |
| 14 | // A user who never provisions never appears there. |
| 15 | |
| 16 | import ( |
| 17 | "bytes" |
| 18 | "encoding/json" |
| 19 | "fmt" |
| 20 | "io" |
| 21 | "net/http" |
| 22 | "net/url" |
| 23 | "strings" |
| 24 | "time" |
| 25 | ) |
| 26 | |
| 27 | // cloudProvisioner is home's client for one cloud provisioner. nil when the |
| 28 | // deployment doesn't offer provisioning — home stays strictly additive. |
| 29 | type cloudProvisioner struct { |
| 30 | url string // e.g. https://cloud.elevenmessenger.com |
| 31 | token string // caller token minted by the provisioner's admin |
| 32 | client *http.Client |
| 33 | } |
| 34 | |
| 35 | func newCloudProvisioner(url, token string) *cloudProvisioner { |
| 36 | if url == "" || token == "" { |
| 37 | return nil |
| 38 | } |
| 39 | return &cloudProvisioner{ |
| 40 | url: strings.TrimRight(url, "/"), |
| 41 | token: token, |
| 42 | // Creation waits on a real instance start + health check downstream, so |
| 43 | // the timeout is generous. |
| 44 | client: &http.Client{Timeout: 45 * time.Second}, |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // cloudRegion is one provisioner home can place a space in, keyed by a short |
| 49 | // code that also appears as the second DNS label of the hosts it serves |
| 50 | // (e.g. "ie" in "8oe.ie.dev.11ch.at"). A home configured with more than one |
| 51 | // (LCHAT_CLOUD_REGIONS) provisions per country; with none, the single |
| 52 | // LCHAT_CLOUD_URL provisioner (s.cloud) is the only region. |
| 53 | type cloudRegion struct { |
| 54 | Code string // "ie" — matches the host's region label and the API `region` |
| 55 | Name string // "Ireland" — the only user-facing string (shown in the picker) |
| 56 | prov *cloudProvisioner |
| 57 | } |
| 58 | |
| 59 | // regionConfig is the JSON shape of one LCHAT_CLOUD_REGIONS entry. token stays |
| 60 | // server-side; only Code/Name are ever exposed (featuresHandler). |
| 61 | type regionConfig struct { |
| 62 | Code string `json:"code"` |
| 63 | Name string `json:"name"` |
| 64 | URL string `json:"url"` |
| 65 | Token string `json:"token"` |
| 66 | } |
| 67 | |
| 68 | // parseCloudRegions reads LCHAT_CLOUD_REGIONS (a JSON array of regionConfig) and |
| 69 | // LCHAT_CLOUD_DEFAULT_REGION. Empty JSON → no regions (single-region mode, the |
| 70 | // caller keeps its LCHAT_CLOUD_URL provisioner). The default code must name one |
| 71 | // of the configured regions; empty defaults to the first. |
| 72 | func parseCloudRegions(regionsJSON, defaultCode string) ([]cloudRegion, string, error) { |
| 73 | regionsJSON = strings.TrimSpace(regionsJSON) |
| 74 | if regionsJSON == "" { |
| 75 | return nil, "", nil |
| 76 | } |
| 77 | var cfgs []regionConfig |
| 78 | if err := json.Unmarshal([]byte(regionsJSON), &cfgs); err != nil { |
| 79 | return nil, "", fmt.Errorf("parse LCHAT_CLOUD_REGIONS: %w", err) |
| 80 | } |
| 81 | regions := make([]cloudRegion, 0, len(cfgs)) |
| 82 | seen := map[string]bool{} |
| 83 | for _, c := range cfgs { |
| 84 | if c.Code == "" { |
| 85 | return nil, "", fmt.Errorf("a region is missing its code") |
| 86 | } |
| 87 | if seen[c.Code] { |
| 88 | return nil, "", fmt.Errorf("region %q is listed twice", c.Code) |
| 89 | } |
| 90 | seen[c.Code] = true |
| 91 | prov := newCloudProvisioner(c.URL, c.Token) |
| 92 | if prov == nil { |
| 93 | return nil, "", fmt.Errorf("region %q needs both url and token", c.Code) |
| 94 | } |
| 95 | name := c.Name |
| 96 | if name == "" { |
| 97 | name = c.Code |
| 98 | } |
| 99 | regions = append(regions, cloudRegion{Code: c.Code, Name: name, prov: prov}) |
| 100 | } |
| 101 | if defaultCode == "" { |
| 102 | defaultCode = regions[0].Code |
| 103 | } else if !seen[defaultCode] { |
| 104 | return nil, "", fmt.Errorf("LCHAT_CLOUD_DEFAULT_REGION %q is not one of the configured regions", defaultCode) |
| 105 | } |
| 106 | return regions, defaultCode, nil |
| 107 | } |
| 108 | |
| 109 | // cloudForRegion returns the provisioner for a region code, falling back to the |
| 110 | // default provisioner (s.cloud) when the code is empty, unknown, or when home is |
| 111 | // running single-region. So an unrecognised owner choice quietly auto-assigns |
| 112 | // rather than failing. |
| 113 | func (s *Server) cloudForRegion(code string) *cloudProvisioner { |
| 114 | for i := range s.regions { |
| 115 | if s.regions[i].Code == code { |
| 116 | return s.regions[i].prov |
| 117 | } |
| 118 | } |
| 119 | return s.cloud |
| 120 | } |
| 121 | |
| 122 | // regionCodeForHost extracts the region a host lives in from its name — the |
| 123 | // label after the leftmost (space-id) label, e.g. "8oe.ie.dev.11ch.at" → "ie" — |
| 124 | // but only when that names a configured region. Single-region / prod hosts |
| 125 | // ("8oe.11ch.at") carry no region label, so this returns "" and callers use the |
| 126 | // default provisioner. |
| 127 | func (s *Server) regionCodeForHost(host string) string { |
| 128 | labels := strings.Split(host, ".") |
| 129 | if len(labels) < 2 { |
| 130 | return "" |
| 131 | } |
| 132 | for i := range s.regions { |
| 133 | if s.regions[i].Code == labels[1] { |
| 134 | return labels[1] |
| 135 | } |
| 136 | } |
| 137 | return "" |
| 138 | } |
| 139 | |
| 140 | // cloudForHost routes a host-specific operation (delete, checkout, portal) to |
| 141 | // the provisioner of the region the host lives in, defaulting to s.cloud. |
| 142 | func (s *Server) cloudForHost(host string) *cloudProvisioner { |
| 143 | return s.cloudForRegion(s.regionCodeForHost(host)) |
| 144 | } |
| 145 | |
| 146 | // allClouds is every distinct provisioner home can reach — all regions, or just |
| 147 | // the single default when unpartitioned — used to fan a listing out so a user |
| 148 | // sees the spaces they hold in every region. |
| 149 | func (s *Server) allClouds() []*cloudProvisioner { |
| 150 | if len(s.regions) == 0 { |
| 151 | if s.cloud == nil { |
| 152 | return nil |
| 153 | } |
| 154 | return []*cloudProvisioner{s.cloud} |
| 155 | } |
| 156 | out := make([]*cloudProvisioner, 0, len(s.regions)) |
| 157 | for i := range s.regions { |
| 158 | out = append(out, s.regions[i].prov) |
| 159 | } |
| 160 | return out |
| 161 | } |
| 162 | |
| 163 | // featuresHandler advertises which optional features this home server offers, |
| 164 | // so the page can hide UI it can't serve. Public: it reveals only a boolean, |
| 165 | // never a token or endpoint that would work without one. |
| 166 | func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) { |
| 167 | out := map[string]any{ |
| 168 | "spaces": s.cloud != nil, |
| 169 | "demo": s.demo != nil && s.cloud != nil, |
| 170 | } |
| 171 | // Which of the web-flow sign-in doors are lit — for the NATIVE apps, |
| 172 | // which can't read index.html's meta tag or the web's registry. Booleans |
| 173 | // plus GitHub's client id, the one value natives can't compile in (prod |
| 174 | // and dev are separate GitHub OAuth apps); a client id is a public |
| 175 | // identifier, not a secret — Google's and Microsoft's ship in client |
| 176 | // code on every platform. |
| 177 | doors := map[string]any{ |
| 178 | "google": s.oidc["google"] != nil, |
| 179 | "microsoft": s.oidc["microsoft"] != nil, |
| 180 | } |
| 181 | if s.github != nil { |
| 182 | doors["github_client_id"] = s.github.clientID |
| 183 | } |
| 184 | out["doors"] = doors |
| 185 | // When home provisions per country, advertise the regions — code + name |
| 186 | // only, never a url or token — and the default, so the create UI can offer |
| 187 | // the choice. Absent → the client shows no region control (single-region). |
| 188 | if len(s.regions) > 0 { |
| 189 | regs := make([]map[string]string, len(s.regions)) |
| 190 | for i, rg := range s.regions { |
| 191 | regs[i] = map[string]string{"code": rg.Code, "name": rg.Name} |
| 192 | } |
| 193 | out["regions"] = regs |
| 194 | out["default_region"] = s.defaultRegion |
| 195 | } |
| 196 | writeJSON(w, http.StatusOK, out) |
| 197 | } |
| 198 | |
| 199 | // challengeHandler: POST /api/spaces/challenge — mint a proof-of-work |
| 200 | // challenge for the signed-in account (pow.go). bits 0 = the gate is off and |
| 201 | // creation needs no proof; the client checks this instead of a feature flag. |
| 202 | func (s *Server) challengeHandler(w http.ResponseWriter, r *http.Request) { |
| 203 | uid, ok := s.linkUser(r) |
| 204 | if !ok { |
| 205 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 206 | return |
| 207 | } |
| 208 | if s.pow == nil { |
| 209 | writeJSON(w, http.StatusOK, map[string]any{"bits": 0}) |
| 210 | return |
| 211 | } |
| 212 | challenge, err := s.pow.mint(uid) |
| 213 | if err != nil { |
| 214 | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "challenge_failed"}) |
| 215 | return |
| 216 | } |
| 217 | writeJSON(w, http.StatusOK, map[string]any{"bits": s.pow.bits, "challenge": challenge}) |
| 218 | } |
| 219 | |
| 220 | // createSpaceHandler: POST /api/spaces — provision a new space for the |
| 221 | // signed-in user. Auth is the device link token (creating a space is additive, |
| 222 | // not destructive, so no fresh ceremony is demanded); the provisioner's |
| 223 | // per-user quota and this endpoint's rate limit bound abuse. |
| 224 | func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| 225 | uid, ok := s.linkUser(r) |
| 226 | if !ok { |
| 227 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 228 | return |
| 229 | } |
| 230 | if s.cloud == nil { |
| 231 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 232 | "error": "unavailable", "message": "this home server does not offer space creation", |
| 233 | }) |
| 234 | return |
| 235 | } |
| 236 | // Gated to existing members: you can only create a space once you already |
| 237 | // have one. A backup blob exists only after enrolling ≥1 space, and enrolling |
| 238 | // needs a space you were invited into — so this keeps creation invite-gated |
| 239 | // (not open to the world) without the server ever reading the E2E-encrypted |
| 240 | // list. The client hides the button too; this is the boundary behind it. |
| 241 | if _, _, hasBlob, err := s.store.getBlob(uid); err != nil { |
| 242 | writeJSON(w, http.StatusInternalServerError, map[string]any{ |
| 243 | "error": "create_failed", "message": "could not check your account", |
| 244 | }) |
| 245 | return |
| 246 | } else if !hasBlob { |
| 247 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 248 | "error": "not_eligible", |
| 249 | "message": "You need at least one Eleven space before you can create a new one.", |
| 250 | }) |
| 251 | return |
| 252 | } |
| 253 | // One read of the (optional) body: the region choice and, when the gate is |
| 254 | // on, the solved proof of work ride together. A missing or empty body |
| 255 | // decodes to the zero value — auto-assign the region, and (pow on) fall to |
| 256 | // pow_required below. |
| 257 | var req struct { |
| 258 | Region string `json:"region"` |
| 259 | Name string `json:"name"` |
| 260 | Challenge string `json:"challenge"` |
| 261 | Nonce string `json:"nonce"` |
| 262 | } |
| 263 | _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) |
| 264 | // The proof-of-work gate (pow.go): when enabled, creation carries a solved |
| 265 | // challenge. Checked AFTER the cheap gates so an ineligible caller never |
| 266 | // burns CPU for a refusal, and before the provisioner is ever dialed. |
| 267 | if s.pow != nil { |
| 268 | if req.Challenge == "" { |
| 269 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 270 | "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.", |
| 271 | }) |
| 272 | return |
| 273 | } |
| 274 | if err := s.pow.verify(uid, req.Challenge, req.Nonce); err != nil { |
| 275 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 276 | "error": "pow_failed", "message": "The proof of work didn't check out — try again.", |
| 277 | }) |
| 278 | return |
| 279 | } |
| 280 | } |
| 281 | // Owner override: an explicit region places the space there; empty |
| 282 | // auto-assigns to the default region. An unknown code quietly falls back to |
| 283 | // the default (cloudForRegion) rather than failing the create. |
| 284 | // The name rides through to the provisioner (→ the instance's seeded |
| 285 | // display name). Plaintext branding by design; capped here so a huge body |
| 286 | // never travels further. |
| 287 | name := strings.TrimSpace(req.Name) |
| 288 | if len(name) > 60 { |
| 289 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 290 | "error": "bad_request", "message": "The space name can be at most 60 characters.", |
| 291 | }) |
| 292 | return |
| 293 | } |
| 294 | status, body, err := s.cloudForRegion(req.Region).createSpace(uid, name) |
| 295 | if err != nil { |
| 296 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 297 | "error": "create_failed", "message": "could not reach the space provisioner", |
| 298 | }) |
| 299 | return |
| 300 | } |
| 301 | // Relay the provisioner's stable codes as-is (201 created, 409 quota, |
| 302 | // 503 at-capacity with its member-facing message); anything unexpected is |
| 303 | // flattened so a client never depends on an undocumented shape. |
| 304 | switch status { |
| 305 | case http.StatusCreated, http.StatusConflict, http.StatusServiceUnavailable: |
| 306 | writeJSON(w, status, body) |
| 307 | default: |
| 308 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 309 | "error": "create_failed", "message": "space creation failed", |
| 310 | }) |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // listSpacesHandler: GET /api/spaces — the hosts this user provisioned (from |
| 315 | // the provisioner's ledger). Link-token auth: it's a read, and the client uses |
| 316 | // it only to decide which rows may offer "Delete space". |
| 317 | func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) { |
| 318 | uid, ok := s.linkUser(r) |
| 319 | if !ok { |
| 320 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 321 | return |
| 322 | } |
| 323 | clouds := s.allClouds() |
| 324 | if len(clouds) == 0 { |
| 325 | writeJSON(w, http.StatusOK, map[string]any{"spaces": []any{}}) |
| 326 | return |
| 327 | } |
| 328 | // Fan the read out across every region and merge — a user's spaces may live |
| 329 | // in more than one. Each host encodes its own region, so the client needs no |
| 330 | // hint back; management calls route by host (cloudForHost). Best-effort per |
| 331 | // region: one region being unreachable must not blank the list, but if every |
| 332 | // region we tried failed, surface the gateway error rather than an empty list |
| 333 | // that would read as "you have no spaces". |
| 334 | path := "/api/instances?requester=" + url.QueryEscape(uid) |
| 335 | // billing carries the tier picker's price book. The default region's cloud |
| 336 | // is its source of truth (checkout still validates per host downstream); |
| 337 | // any region's book is the fallback so one unreachable region doesn't hide |
| 338 | // the Upgrade UI. (The fan-out rewrite in #1083 dropped this key entirely, |
| 339 | // which silently removed the Upgrade affordance — home.js reads r.billing.) |
| 340 | spaces, billing, gotOne, sawErr := []any{}, any(nil), false, false |
| 341 | for _, c := range clouds { |
| 342 | status, body, err := c.do("GET", path, nil) |
| 343 | if err != nil || status != http.StatusOK { |
| 344 | sawErr = true |
| 345 | continue |
| 346 | } |
| 347 | gotOne = true |
| 348 | if arr, ok := body["spaces"].([]any); ok { |
| 349 | spaces = append(spaces, arr...) |
| 350 | } |
| 351 | if b, ok := body["billing"]; ok && b != nil && b != false && (billing == nil || c == s.cloud) { |
| 352 | billing = b |
| 353 | } |
| 354 | } |
| 355 | if !gotOne && sawErr { |
| 356 | writeJSON(w, http.StatusBadGateway, map[string]any{"error": "list_failed"}) |
| 357 | return |
| 358 | } |
| 359 | out := map[string]any{"spaces": spaces} |
| 360 | if billing != nil { |
| 361 | out["billing"] = billing |
| 362 | } |
| 363 | writeJSON(w, http.StatusOK, out) |
| 364 | } |
| 365 | |
| 366 | // deleteSpaceHandler: DELETE /api/spaces/{host} — permanently deprovision a |
| 367 | // space this user created. DESTRUCTIVE, so it demands a FRESH passkey ceremony |
| 368 | // (X-Ceremony-Proof), never a link token — same rule as revoking devices or |
| 369 | // deleting the backup: a stolen link token must never be able to destroy a |
| 370 | // space. Ownership is enforced downstream by the provisioner's ledger. |
| 371 | func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| 372 | uid, ok := s.ceremonyUser(r) |
| 373 | if !ok { |
| 374 | http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized) |
| 375 | return |
| 376 | } |
| 377 | if s.cloud == nil { |
| 378 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 379 | "error": "unavailable", "message": "this home server does not offer space management", |
| 380 | }) |
| 381 | return |
| 382 | } |
| 383 | host := r.PathValue("host") |
| 384 | status, body, err := s.cloudForHost(host).do("DELETE", |
| 385 | "/api/instances/"+url.PathEscape(host)+"?requester="+url.QueryEscape(uid), nil) |
| 386 | if err != nil { |
| 387 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 388 | "error": "delete_failed", "message": "could not reach the space provisioner", |
| 389 | }) |
| 390 | return |
| 391 | } |
| 392 | switch status { |
| 393 | case http.StatusOK, http.StatusNotFound: |
| 394 | writeJSON(w, status, body) |
| 395 | default: |
| 396 | // Keep the provisioner's member-facing message when it has one — e.g. |
| 397 | // "couldn't cancel this space's subscription — nothing was deleted" is |
| 398 | // actionable in a way a flat "deletion failed" isn't. |
| 399 | msg, _ := body["message"].(string) |
| 400 | if msg == "" { |
| 401 | msg = "space deletion failed" |
| 402 | } |
| 403 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 404 | "error": "delete_failed", "message": msg, |
| 405 | }) |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | // checkoutCreateHandler: POST /api/spaces/checkout-create {name, tier, band, |
| 410 | // region, challenge, nonce} — start a PAID space: the provisioner mints a |
| 411 | // Stripe Checkout link bound to a pending allocation, and the space is only |
| 412 | // provisioned once the payment confirms (claimCreateHandler). Same gates as |
| 413 | // the free create — link token, the member gate, and the proof of work: a |
| 414 | // checkout mint is priced like a create, not like a read. |
| 415 | func (s *Server) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) { |
| 416 | uid, ok := s.linkUser(r) |
| 417 | if !ok { |
| 418 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 419 | return |
| 420 | } |
| 421 | if s.cloud == nil { |
| 422 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 423 | "error": "unavailable", "message": "this home server does not offer space creation", |
| 424 | }) |
| 425 | return |
| 426 | } |
| 427 | if _, _, hasBlob, err := s.store.getBlob(uid); err != nil { |
| 428 | writeJSON(w, http.StatusInternalServerError, map[string]any{ |
| 429 | "error": "create_failed", "message": "could not check your account", |
| 430 | }) |
| 431 | return |
| 432 | } else if !hasBlob { |
| 433 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 434 | "error": "not_eligible", |
| 435 | "message": "You need at least one Eleven space before you can create a new one.", |
| 436 | }) |
| 437 | return |
| 438 | } |
| 439 | var req struct { |
| 440 | Region string `json:"region"` |
| 441 | Name string `json:"name"` |
| 442 | Tier string `json:"tier"` |
| 443 | Band string `json:"band"` |
| 444 | Challenge string `json:"challenge"` |
| 445 | Nonce string `json:"nonce"` |
| 446 | } |
| 447 | _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) |
| 448 | if s.pow != nil { |
| 449 | if req.Challenge == "" { |
| 450 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 451 | "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.", |
| 452 | }) |
| 453 | return |
| 454 | } |
| 455 | if err := s.pow.verify(uid, req.Challenge, req.Nonce); err != nil { |
| 456 | writeJSON(w, http.StatusForbidden, map[string]any{ |
| 457 | "error": "pow_failed", "message": "The proof of work didn't check out — try again.", |
| 458 | }) |
| 459 | return |
| 460 | } |
| 461 | } |
| 462 | if strings.TrimSpace(req.Tier) == "" { |
| 463 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 464 | "error": "bad_request", "message": "tier required", |
| 465 | }) |
| 466 | return |
| 467 | } |
| 468 | name := strings.TrimSpace(req.Name) |
| 469 | if len(name) > 60 { |
| 470 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 471 | "error": "bad_request", "message": "The space name can be at most 60 characters.", |
| 472 | }) |
| 473 | return |
| 474 | } |
| 475 | // Stripe substitutes {CHECKOUT_SESSION_ID} into the redirect, which is how |
| 476 | // the landing page knows what to claim; the region rides along so the claim |
| 477 | // dials the same cloud that minted the session. |
| 478 | successURL := s.origin + "/?paidcreate={CHECKOUT_SESSION_ID}" |
| 479 | if region := strings.TrimSpace(req.Region); region != "" { |
| 480 | successURL += "&pcregion=" + url.QueryEscape(region) |
| 481 | } |
| 482 | payload, _ := json.Marshal(map[string]string{ |
| 483 | "requester": uid, |
| 484 | "tier": req.Tier, |
| 485 | "band": req.Band, |
| 486 | "name": name, |
| 487 | "success_url": successURL, |
| 488 | "cancel_url": s.origin + "/", |
| 489 | }) |
| 490 | status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/checkout-create", payload) |
| 491 | if err != nil { |
| 492 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 493 | "error": "checkout_failed", "message": "could not reach the space provisioner", |
| 494 | }) |
| 495 | return |
| 496 | } |
| 497 | switch status { |
| 498 | case http.StatusOK, http.StatusBadRequest, http.StatusNotImplemented, http.StatusServiceUnavailable: |
| 499 | writeJSON(w, status, body) |
| 500 | default: |
| 501 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 502 | "error": "checkout_failed", "message": "couldn't start the checkout — please try again", |
| 503 | }) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | // claimCreateHandler: POST /api/spaces/claim-create {session_id, region} — |
| 508 | // finish a pay-first create after the Stripe redirect (or any later visit: |
| 509 | // the claim is idempotent downstream). Link-token auth: the session id is |
| 510 | // worthless to anyone but the pending row's owner, which the provisioner |
| 511 | // re-checks against its ledger. |
| 512 | func (s *Server) claimCreateHandler(w http.ResponseWriter, r *http.Request) { |
| 513 | uid, ok := s.linkUser(r) |
| 514 | if !ok { |
| 515 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 516 | return |
| 517 | } |
| 518 | if s.cloud == nil { |
| 519 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 520 | "error": "unavailable", "message": "this home server does not offer space creation", |
| 521 | }) |
| 522 | return |
| 523 | } |
| 524 | var req struct { |
| 525 | Region string `json:"region"` |
| 526 | SessionID string `json:"session_id"` |
| 527 | } |
| 528 | _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) |
| 529 | if strings.TrimSpace(req.SessionID) == "" { |
| 530 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 531 | "error": "bad_request", "message": "session_id required", |
| 532 | }) |
| 533 | return |
| 534 | } |
| 535 | payload, _ := json.Marshal(map[string]string{ |
| 536 | "requester": uid, |
| 537 | "session_id": strings.TrimSpace(req.SessionID), |
| 538 | }) |
| 539 | status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/claim-create", payload) |
| 540 | if err != nil { |
| 541 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 542 | "error": "claim_failed", "message": "could not reach the space provisioner", |
| 543 | }) |
| 544 | return |
| 545 | } |
| 546 | switch status { |
| 547 | case http.StatusOK, http.StatusCreated, http.StatusPaymentRequired, http.StatusNotFound, http.StatusNotImplemented: |
| 548 | writeJSON(w, status, body) |
| 549 | default: |
| 550 | // Keep the provisioner's message when it has one — "payment confirmed, |
| 551 | // try again in a moment" must reach the person waiting. |
| 552 | msg, _ := body["message"].(string) |
| 553 | if msg == "" { |
| 554 | msg = "the space couldn't be set up just now — try again in a moment" |
| 555 | } |
| 556 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 557 | "error": "claim_failed", "message": msg, |
| 558 | }) |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | // checkoutSpaceHandler: POST /api/spaces/{host}/checkout {tier} — ask the |
| 563 | // provisioner for a Stripe Checkout link to upgrade a space this user created, |
| 564 | // at the chosen tier (validated downstream against the provisioner's price |
| 565 | // book). Link-token auth (paying is additive, not destructive — same bar as |
| 566 | // creating); ownership is enforced downstream by the provisioner's ledger. The |
| 567 | // success/cancel URLs land back on this page, so the checkout is a same-tab |
| 568 | // round trip. |
| 569 | func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| 570 | uid, ok := s.linkUser(r) |
| 571 | if !ok { |
| 572 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 573 | return |
| 574 | } |
| 575 | if s.cloud == nil { |
| 576 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 577 | "error": "unavailable", "message": "this home server does not offer upgrades", |
| 578 | }) |
| 579 | return |
| 580 | } |
| 581 | var req struct { |
| 582 | Tier string `json:"tier"` |
| 583 | Band string `json:"band"` |
| 584 | } |
| 585 | if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil || req.Tier == "" { |
| 586 | writeJSON(w, http.StatusBadRequest, map[string]any{ |
| 587 | "error": "bad_request", "message": "tier required", |
| 588 | }) |
| 589 | return |
| 590 | } |
| 591 | host := r.PathValue("host") |
| 592 | // The honor band (docs/pricing-bands.md) rides through verbatim; the |
| 593 | // provisioner's price book is the validator. Empty = base, downstream. |
| 594 | payload, _ := json.Marshal(map[string]string{ |
| 595 | "requester": uid, |
| 596 | "tier": req.Tier, |
| 597 | "band": req.Band, |
| 598 | "success_url": s.origin + "/?upgraded=" + url.QueryEscape(host), |
| 599 | "cancel_url": s.origin + "/", |
| 600 | }) |
| 601 | status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload) |
| 602 | if err != nil { |
| 603 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 604 | "error": "checkout_failed", "message": "could not reach the space provisioner", |
| 605 | }) |
| 606 | return |
| 607 | } |
| 608 | // Relay the provisioner's stable codes (200 link, 400 bad tier, 404 not |
| 609 | // yours, 409 already upgraded, 501 no billing); flatten anything else. |
| 610 | switch status { |
| 611 | case http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented: |
| 612 | writeJSON(w, status, body) |
| 613 | default: |
| 614 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 615 | "error": "checkout_failed", "message": "couldn't start the upgrade", |
| 616 | }) |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | // portalSpaceHandler: POST /api/spaces/{host}/portal — a Billing Portal link |
| 621 | // for a space this user created and pays for. Link-token auth (reading and |
| 622 | // managing your own subscription is not destructive of the SPACE; the portal |
| 623 | // itself is Stripe-hosted and Stripe-authenticated beyond this handoff). |
| 624 | func (s *Server) portalSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| 625 | uid, ok := s.linkUser(r) |
| 626 | if !ok { |
| 627 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 628 | return |
| 629 | } |
| 630 | if s.cloud == nil { |
| 631 | writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| 632 | "error": "unavailable", "message": "this home server does not offer upgrades", |
| 633 | }) |
| 634 | return |
| 635 | } |
| 636 | host := r.PathValue("host") |
| 637 | payload, _ := json.Marshal(map[string]string{ |
| 638 | "requester": uid, |
| 639 | "return_url": s.origin + "/", |
| 640 | }) |
| 641 | status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload) |
| 642 | if err != nil { |
| 643 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 644 | "error": "portal_failed", "message": "could not reach the space provisioner", |
| 645 | }) |
| 646 | return |
| 647 | } |
| 648 | switch status { |
| 649 | case http.StatusOK, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented: |
| 650 | writeJSON(w, status, body) |
| 651 | default: |
| 652 | writeJSON(w, http.StatusBadGateway, map[string]any{ |
| 653 | "error": "portal_failed", "message": "couldn't open the billing page", |
| 654 | }) |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | // createSpace forwards one provisioning request; the user id is the quota key |
| 659 | // (opaque to the provisioner — random, no email or name anywhere). |
| 660 | func (c *cloudProvisioner) createSpace(requester, name string) (int, map[string]any, error) { |
| 661 | body := map[string]string{"requester": requester} |
| 662 | if name != "" { |
| 663 | body["name"] = name |
| 664 | } |
| 665 | payload, _ := json.Marshal(body) |
| 666 | return c.do("POST", "/api/instances", payload) |
| 667 | } |
| 668 | |
| 669 | // do sends one request to the provisioner with the caller token, returning the |
| 670 | // status + decoded JSON body. |
| 671 | func (c *cloudProvisioner) do(method, path string, payload []byte) (int, map[string]any, error) { |
| 672 | var rd io.Reader |
| 673 | if payload != nil { |
| 674 | rd = bytes.NewReader(payload) |
| 675 | } |
| 676 | req, err := http.NewRequest(method, c.url+path, rd) |
| 677 | if err != nil { |
| 678 | return 0, nil, err |
| 679 | } |
| 680 | req.Header.Set("Authorization", "Bearer "+c.token) |
| 681 | if payload != nil { |
| 682 | req.Header.Set("Content-Type", "application/json") |
| 683 | } |
| 684 | resp, err := c.client.Do(req) |
| 685 | if err != nil { |
| 686 | return 0, nil, err |
| 687 | } |
| 688 | defer resp.Body.Close() |
| 689 | var body map[string]any |
| 690 | if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| 691 | body = map[string]any{} |
| 692 | } |
| 693 | return resp.StatusCode, body, nil |
| 694 | } |
| 695 | |