eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package 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
16import (
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.
29type 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
35func 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.
53type 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).
61type 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.
72func 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.
113func (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.
127func (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.
142func (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.
149func (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.
166func (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.
202func (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.
224func (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".
317func (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.
371func (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.
415func (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.
512func (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.
569func (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).
624func (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).
660func (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.
671func (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