carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The console's HTTP server: hand-written HTML from Go templates, no
2// JavaScript, no build step, no external resources. Auth model: OAuth at
3// Keymail is the ONE-TIME sign-up ceremony; returning sign-ins are a
4// single-use confirmation link the console delivers into the user's Keymail
5// Apps inbox with its stored grant.
6package console
7
8import (
9 "context"
10 "crypto/ed25519"
11 "crypto/subtle"
12 "crypto/x509"
13 "embed"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "html/template"
18 "log"
19 "math"
20 mrand "math/rand/v2"
21 "net"
22 "net/http"
23 netmail "net/mail"
24 "net/url"
25 "os"
26 "regexp"
27 "slices"
28 "sort"
29 "strings"
30 "sync"
31 "sync/atomic"
32 "time"
33
34 "github.com/aws/aws-sdk-go-v2/service/costexplorer"
35 "github.com/carlosframework/platform/internal/appconfig"
36 "github.com/carlosframework/platform/internal/applog"
37 "github.com/carlosframework/platform/internal/boxid"
38 "github.com/carlosframework/platform/internal/dbreads"
39 "github.com/carlosframework/platform/internal/dnsrec"
40 "github.com/carlosframework/platform/internal/domains"
41 "github.com/carlosframework/platform/internal/economics"
42 "github.com/carlosframework/platform/internal/edgesfile"
43 "github.com/carlosframework/platform/internal/geo"
44 "github.com/carlosframework/platform/internal/hostclaim"
45 "github.com/carlosframework/platform/internal/hostops"
46 "github.com/carlosframework/platform/internal/infraview"
47 "github.com/carlosframework/platform/internal/lease"
48 "github.com/carlosframework/platform/internal/mail"
49 "github.com/carlosframework/platform/internal/moves"
50 "github.com/carlosframework/platform/internal/objstore"
51 "github.com/carlosframework/platform/internal/plans"
52 "github.com/carlosframework/platform/internal/provision"
53 "github.com/carlosframework/platform/internal/publicrelease"
54 "github.com/carlosframework/platform/internal/registry"
55 "github.com/carlosframework/platform/internal/release"
56 "github.com/carlosframework/platform/internal/status"
57 "github.com/carlosframework/platform/internal/svcrole"
58 "github.com/carlosframework/platform/internal/telemetry"
59 "github.com/carlosframework/platform/internal/usage"
60 "github.com/carlosframework/platform/internal/version"
61)
62
63//go:embed templates/*.html
64var tmplFS embed.FS
65
66var templates = template.Must(template.New("").Funcs(template.FuncMap{
67 "fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") },
68 // until renders a future horizon the way the cards speak: "~4h", never a
69 // timestamp — the safety-delay strip's "goes live ~4h" and Task 6's
70 // summary strip both read it this way.
71 "until": untilText,
72 // ago renders a past instant the way a person scanning a dashboard reads
73 // one — "2h4m ago" — at humanAge's deliberately coarse resolution. The
74 // absolute stamp stays available where precision matters (title attrs,
75 // the Activity tab); a zero time is unknown, so it dashes out.
76 "ago": func(t time.Time) string {
77 if t.IsZero() {
78 return "—"
79 }
80 return humanAge(time.Since(t)) + " ago"
81 },
82 // trashCooloffDays is the deletion policy's restore window, in the unit
83 // the Settings page speaks. One source: release.TrashCooloff.
84 "trashCooloffDays": func() int { return int(release.TrashCooloff.Hours() / 24) },
85 // temporaryCooloffDays is a temporary app's restore window, in days: the
86 // delete confirmation shows it for an app with an expiry (temporary static
87 // sites spec §5, "the delete confirmation in the browser").
88 "temporaryCooloffDays": func() int { return int(release.TemporaryCooloff.Hours() / 24) },
89 // The fleet template's em-dash helpers: fleet.json's null-propagation
90 // rule (internal/status/fleet.go) means these *string/*int/*bool fields
91 // are unknown, not zero, and unknown must render as a titled em-dash —
92 // never a blank cell or a guessed value — same rule index.html's JS
93 // enforces client-side (see asText/asBool there).
94 "strOrDash": func(v *string) string {
95 if v == nil {
96 return "—"
97 }
98 return *v
99 },
100 "intOrDash": func(v *int) string {
101 if v == nil {
102 return "—"
103 }
104 return fmt.Sprint(*v)
105 },
106 "boolWord": func(v *bool, whenTrue, whenFalse string) string {
107 if v == nil {
108 return "—"
109 }
110 if *v {
111 return whenTrue
112 }
113 return whenFalse
114 },
115 "emptyOrDash": func(s string) string {
116 if s == "" {
117 return "—"
118 }
119 return s
120 },
121 // pct renders a fraction (0..1, as CPU/Mem/Disk readings and unit CPU
122 // fractions are stored — internal/metrics.HostSample/UnitSample) as a
123 // whole-percent string; nil means no reading, never zero, so it renders
124 // the same em-dash every other "unknown" field on this console does.
125 "pct": func(v *float64) string {
126 if v == nil {
127 return "—"
128 }
129 return fmt.Sprintf("%.0f%%", *v*100)
130 },
131 // alertVal renders one AlertRow's value in ITS OWN rule's unit —
132 // see alertValueText. LoadMetrics only ever returns firing rules, so an
133 // alert row always carries a value; there is no nil case to dash out.
134 "alertVal": alertValueText,
135 // healthSlug: the Running tab's health-filter links and the hidden
136 // carried-filter field both need a health word's URL form (listquery.go).
137 "healthSlug": healthSlug,
138 "healthTone": healthTone,
139 "instanceRowPage": func(p page, row InstanceRow) any {
140 return struct {
141 Page page
142 Row InstanceRow
143 }{p, row}
144 },
145 // logLines: the Logs tab's window folded into the terminal pane's lines
146 // (logsview.go). The padding is computed across the whole window, so it
147 // has to be one call over all the records, not one per row.
148 "logLines": logLines,
149 // sortedKV: the Analytics tab's three top-key tables (analyticsview.go).
150 // A Go template ranges a map in key order, which would sort a top-pages
151 // table alphabetically — the one order that hides which page is the
152 // popular one. This puts the biggest first and leaves the overflow
153 // bucket last, wherever its count happens to land.
154 "sortedKV": sortedKV,
155 // geoCountries and geoAttributions: the Analytics tab's Countries table
156 // and the credit its sources require (geoip spec §9, §13).
157 "geoCountries": geoCountries,
158 "geoLabel": geoLabel,
159 "geoAttributions": geoAttributions,
160 // bytesOrDash renders a byte count in the smallest unit that keeps it
161 // readable; nil (no reading, from a unit that reported CPU but not
162 // memory/IO that tick) is "—", never a fabricated 0 B.
163 "bytesOrDash": func(v *int64) string {
164 if v == nil {
165 return "—"
166 }
167 b := float64(*v)
168 switch {
169 case b >= 1<<30:
170 return fmt.Sprintf("%.1f GB", b/(1<<30))
171 case b >= 1<<20:
172 return fmt.Sprintf("%.1f MB", b/(1<<20))
173 case b >= 1<<10:
174 return fmt.Sprintf("%.1f KB", b/(1<<10))
175 default:
176 return fmt.Sprintf("%d B", *v)
177 }
178 },
179 // loadOrDash renders a 1-minute load average: an absolute number of
180 // runnable tasks, not a fraction, so pct (which multiplies by 100 and
181 // stamps a %) would be a lie about the same *float64. nil is the same
182 // em-dash every unknown on this console gets.
183 "loadOrDash": func(v *float64) string {
184 if v == nil {
185 return "—"
186 }
187 return fmt.Sprintf("%.2f", *v)
188 },
189 // mapGet reads one named field out of Fleet.Instances — a map so a
190 // template range would iterate it key-sorted, alphabetically, which
191 // scrambles the static/resident/hibernating/awake/asleep order the
192 // public page and the design spec both fix; indexing by name keeps that
193 // order explicit in the template instead.
194 "mapGet": func(m map[string]*int, k string) *int { return m[k] },
195 // icon inlines one vendored Lucide SVG by slug — see icons.go for the
196 // set, the licence, and why template.HTML is safe there and nowhere
197 // else. Chrome is drawn; emoji stay content.
198 "icon": icon,
199 // join renders fleetPageBox.Tags back into the tag form's editable
200 // text input, comma-separated — the exact grammar SetFleetBoxTags
201 // expects back, so an owner opening the form sees the same string they'd
202 // type to leave it unchanged.
203 "join": strings.Join,
204 // fleetName reads the fleet out of a placement target ("fleet/pi" -> "pi")
205 // so the Placement section can NAME the fleet and link to its page rather
206 // than printing the raw Region value at a customer. A target that does not
207 // parse yields "", which the template's own {{if}} already handles as
208 // "not placed" — the same reading appPlacement itself takes.
209 "fleetName": func(target string) string { f, _ := registry.FleetPlacement(target); return f },
210
211 // ---- /fleet/economics (Task 13): server-side arithmetic mirroring
212 // internal/economics/compute.go, so the page's initial render needs no
213 // JS to show real numbers — see economics.html's inline script for the
214 // live-recompute twin of every function below (same names, JS-cased).
215
216 // usd renders a signed dollar figure — negative (a box or account
217 // running at a loss under the active scenario) as "-$N.NN", never a
218 // bare minus glued to the sign the reader expects at the front.
219 "usd": func(v float64) string {
220 if math.IsNaN(v) || math.IsInf(v, 0) {
221 return "—"
222 }
223 if v < 0 {
224 return fmt.Sprintf("-$%.2f", -v)
225 }
226 return fmt.Sprintf("$%.2f", v)
227 },
228 // pctf is pct (above) for a plain, never-nil float64 — margin and
229 // utilization fractions (economics.AccountEconomics.MarginFrac,
230 // BoxEconomics.Utilization) are always populated, never a *float64
231 // "no reading yet" case pct exists to dash out.
232 "pctf": func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) },
233 // marginMultiple renders cost-plus's margin knob the way an operator
234 // thinks about markup, not the accounting fraction it's stored as:
235 // multiple = 1/(1-margin), so the default 0.8 reads "5.0× cost" per the
236 // brief. Out of Validate's legal [0,1) range is "—", never a divide by
237 // zero or a negative multiple.
238 "marginMultiple": func(margin float64) string {
239 if margin < 0 || margin >= 1 {
240 return "—"
241 }
242 return fmt.Sprintf("%.1f×", 1/(1-margin))
243 },
244 // marginCell renders MarginFrac as pctf normally, but an em-dash for a
245 // pooled row (FreeTierPos=="pooled") — Task 6's carried ruling: a
246 // pool/try/""-tier account's revenue is pooled, not individually priced
247 // (buildEconomicsView's tier override), so its MarginFrac is genuinely
248 // 0 — but "0%" reads as an account LOSING money, not one that simply
249 // isn't priced on its own. The JS twin (econ_maths_js's marginCell)
250 // mirrors this exactly.
251 "marginCell": func(frac float64, pos string) string {
252 if pos == "pooled" {
253 return "—"
254 }
255 return fmt.Sprintf("%.0f%%", frac*100)
256 },
257 // tierWord renders a fleet-pool tier (store.go's validAccountTiers) for
258 // display: "" (never assigned) reads "unassigned" rather than a blank
259 // chip label; every other value (try/pool/hosted/fleet/dedicated)
260 // passes through unchanged. Feeds both a tier chip's visible text and
261 // its "chip-tier-<word>" class (economics_accounts.html, Task 6).
262 "tierWord": func(tier string) string {
263 if tier == "" {
264 return "unassigned"
265 }
266 return tier
267 },
268 // tierKnown reports whether acct has a real console account row at all
269 // (present as a key in EconomicsView.Tiers, via the comma-ok form —
270 // html/template's "index" can't distinguish absent from an explicit ""
271 // tier, which is exactly the distinction this exists for). A usage-only
272 // account (fleet or edge traffic with no matching accounts-table row —
273 // e.g. a fleet-only account whose owner never signed up) is absent from
274 // the map entirely: economics_accounts.html renders its tier chip but
275 // WITHOUT the per-row tier-set form, since POSTing that form's account
276 // sqid to /fleet/economics/tier can only ever fail with "No such
277 // account." (handleEconomicsTierSave's AccountBySqid lookup) — fix
278 // round, Task 6 review, IMPORTANT 2.
279 "tierKnown": func(tiers map[string]string, acct string) bool {
280 _, ok := tiers[acct]
281 return ok
282 },
283 "add": func(vs ...float64) float64 {
284 var sum float64
285 for _, v := range vs {
286 sum += v
287 }
288 return sum
289 },
290 "sub": func(a, b float64) float64 { return a - b },
291 "mul": func(a, b float64) float64 { return a * b },
292 "div": func(a, b float64) float64 {
293 if b == 0 {
294 return 0
295 }
296 return a / b
297 },
298 // project is economics.Project (compute.go), exposed so the headline
299 // tiles' month-to-date figures can carry a "projected for the month"
300 // second line — the spec's "month-to-date with projection" promise. Only
301 // ever called from economics.html's tiles when Period=="mtd" and
302 // CoveredDays>0; the JS twin (economics.html's econ_maths_js define) is
303 // the exact transliteration a knob change re-runs.
304 "project": func(mtd float64, coveredDays, periodDays int) float64 {
305 return economics.Project(mtd, coveredDays, periodDays)
306 },
307 // marginOf is economics.PriceAccount's margin formula
308 // ((revenue-cost)/revenue, 0 when revenue is 0), applied to the
309 // dashboard's period TOTALS rather than one account — the "achieved
310 // margin" headline tile.
311 "marginOf": func(revenue, cost float64) float64 {
312 if revenue <= 0 {
313 return 0
314 }
315 return (revenue - cost) / revenue
316 },
317 // sumAcctCost/sumAcctRevenue/sumBoxIdle fold EconomicsView.Accounts /
318 // .Boxes into the headline tiles' period totals: total cost is every
319 // account's allocated+metered cost, PLUS every box's unallocated idle
320 // headroom (never smeared across tenants — economics.AllocateBox's own
321 // doc comment), PLUS fixed overheads (overheadUSD below); total revenue
322 // is simply the sum of what every account would owe.
323 "sumAcctCost": func(accts []economics.AccountEconomics) float64 {
324 var sum float64
325 for _, a := range accts {
326 sum += a.CostUSD
327 }
328 return sum
329 },
330 "sumAcctRevenue": func(accts []economics.AccountEconomics) float64 {
331 var sum float64
332 for _, a := range accts {
333 sum += a.RevenueUSD
334 }
335 return sum
336 },
337 "sumBoxIdle": func(boxes []BoxEconomics) float64 {
338 var sum float64
339 for _, b := range boxes {
340 sum += b.IdleUSD
341 }
342 return sum
343 },
344 // overheadUSD prorates the rate card's fixed monthly LineItems
345 // (domains, DNS, ...) over the period being viewed, by the same
346 // hours-over-HoursPerMonth proration economics.AllocateBox uses for a
347 // box's EBS line — periodDays rather than CoveredDays, deliberately:
348 // overhead is a standing monthly bill that runs whether or not usage
349 // was fully sampled, so it prorates by calendar days in the period, not
350 // by how much of it the sweep actually covered.
351 "overheadUSD": func(sc economics.Scenario, periodDays int) float64 {
352 var monthly float64
353 for _, li := range sc.RateCard.Overheads {
354 monthly += li.USDMonth
355 }
356 return monthly * float64(periodDays) * 24 / economics.HoursPerMonth
357 },
358 // sumMeter totals one named meter (usage.Meter* — "egress_gb",
359 // "requests_1k", "storage_gb_month", ...) across every account in
360 // EconomicsView.Meters, for the model-side breakdown's S3/egress lines.
361 "sumMeter": func(meters map[string]map[string]float64, meter string) float64 {
362 var sum float64
363 for _, mm := range meters {
364 sum += mm[meter]
365 }
366 return sum
367 },
368 // sumBoxesEC2/sumBoxesEBS split each box's total cost (BoxEconomics
369 // carries the sum, not the two components) back into the rate card's
370 // two box-cost lines for the model-side breakdown, using the same
371 // box-then-default spec lookup economics.AllocateBox performs.
372 "sumBoxesEC2": func(boxes []BoxEconomics, rc economics.RateCard) float64 {
373 var sum float64
374 for _, b := range boxes {
375 spec, ok := rc.Boxes[b.Box]
376 if !ok {
377 spec = rc.Boxes[""]
378 }
379 sum += spec.HourUSD * b.Hours
380 }
381 return sum
382 },
383 "sumBoxesEBS": func(boxes []BoxEconomics, rc economics.RateCard) float64 {
384 var sum float64
385 for _, b := range boxes {
386 spec, ok := rc.Boxes[b.Box]
387 if !ok {
388 spec = rc.Boxes[""]
389 }
390 sum += spec.EBSGB * rc.EBSGBMonthUSD * (b.Hours / economics.HoursPerMonth)
391 }
392 return sum
393 },
394 // periodMonth/billForMonth resolve which of EconomicsView.Bills (this
395 // month + last, always both if present regardless of period —
396 // loadRecentBills's doc comment) corresponds to the period on screen,
397 // so the breakdown reconciles against the RIGHT bill: "last" looks at
398 // last calendar month's, "mtd"/"7d" both look at this one.
399 "periodMonth": func(period string) string {
400 now := time.Now().UTC()
401 if period == "last" {
402 firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
403 return usage.MonthOf(firstOfThisMonth.AddDate(0, -1, 0))
404 }
405 return usage.MonthOf(now)
406 },
407 "billForMonth": func(bills []economics.Bill, month string) *economics.Bill {
408 for i := range bills {
409 if bills[i].Month == month {
410 return &bills[i]
411 }
412 }
413 return nil
414 },
415 // billServiceChecked is the "counts as CARLOS" default rule, ONE place
416 // for both the checkbox render and the delta sum below: an EMPTY
417 // Scenario.BillServices counts every service (an old scenario, or one
418 // nobody has ever unchecked anything on), a NON-empty one counts only
419 // what's named. The inline JS mirrors this exact rule (billServiceChecked)
420 // when a knob or checkbox changes.
421 "billServiceChecked": func(billServices []string, service string) bool {
422 if len(billServices) == 0 {
423 return true
424 }
425 for _, s := range billServices {
426 if s == service {
427 return true
428 }
429 }
430 return false
431 },
432 // sumBillServicesChecked totals a bill's Services map, restricted to the
433 // ones billServiceChecked keeps — the breakdown's bill-side total.
434 "sumBillServicesChecked": func(services map[string]float64, billServices []string) float64 {
435 var sum float64
436 for name, v := range services {
437 if len(billServices) == 0 {
438 sum += v
439 continue
440 }
441 for _, s := range billServices {
442 if s == name {
443 sum += v
444 break
445 }
446 }
447 }
448 return sum
449 },
450 // scenarioJSON marshals one economics.Scenario for the save-scenario
451 // form's hidden field — the value the door at /fleet/economics/scenarios
452 // parses back (handleEconomicsScenarioSave). html/template still
453 // attribute-escapes this like any other string substitution; that's
454 // correct and wanted here (a form value), unlike DataJSON's <script>
455 // embed which needs the OPPOSITE (unescaped) treatment — see
456 // economicsPayload's doc comment for why that one stays a raw
457 // template.JS.
458 "scenarioJSON": func(sc economics.Scenario) (string, error) {
459 raw, err := json.Marshal(sc)
460 return string(raw), err
461 },
462 // econPeriodURL: /fleet/economics/accounts's period-switch nav
463 // (economics_accounts.html) — see econlistquery.go's doc comment on the
464 // func itself for why it preserves Q/Pos but drops Cursor.
465 "econPeriodURL": econPeriodURL,
466}).ParseFS(tmplFS, "templates/*.html"))
467
468const (
469 sessionCookie = "carlos_console_session"
470 authCookie = "carlos_console_auth"
471 welcomeCookie = "carlos_console_welcome"
472 authFlowTTL = 10 * time.Minute
473)
474
475// Config is everything the server reads its world through.
476type Config struct {
477 HostClaimOps *hostops.Controller
478 HostClaimRecovery *hostops.Recovery
479 ClaimMoveRecovery func(context.Context, *hostops.MoveRecoveryGrant) error
480 ClaimAuthority *hostops.ProductionAuthority
481 ClaimPurge *ClaimPurgeCoordinator
482 ClaimPlacement *ClaimPlacementCoordinator
483 HostConsoleID string
484
485 HostClaim bool
486 WakeRetryReset *WakeRetryResetDoor
487 // RunKeysEnabled admits keyed run creates (CARLOS_RUN_KEYS=on; service
488 // runs spec § 3). Off, a create that carries an Idempotency-Key is
489 // refused 503 keys_not_ready before anything is written, because a box
490 // binary older than the record's run_keys would drop the key on its
491 // next record write. It is turned on once every box runs a build that
492 // carries run_keys through.
493 RunKeysEnabled bool
494 // SeedScratchDir is <console data>/scratch/seeds: --from restores and
495 // import spools. Start-up removes the pool's own leftovers from it.
496 // Empty: the seed doors answer 507.
497 SeedScratchDir string
498 // SeedData is the home region's data bucket, where seeds/ lives (the
499 // bucket HibernateBucket names, at its root). nil: the seed doors
500 // answer 503.
501 SeedData release.Store
502 DBVersions lease.VersionedStore
503 Origin string // the console's public origin — the OAuth client_id
504 Keymail string // keymail base URL
505 // Registry is the path to the platform registry DB. Read-mostly (routes,
506 // domains, alerts), but fleets.go's fleet/box/placement doors (customer
507 // fleets plan, Task 7) genuinely write through it via openRegistry
508 // (view.go) — the same cached handle every reader here already uses.
509 Registry string
510 Releases release.Store // the deployment bucket
511 // SystemFeed is where `carlos system update` fetches a public release
512 // from. Zero value = the vendor feed; tests point it at a stub.
513 SystemFeed publicrelease.Feed
514 // Bucket is that same deployment bucket's NAME — CARLOS_DEPLOYMENT_BUCKET,
515 // the one bucket relationship this console can state as fact.
516 // /fleet/architecture draws the box -> bucket edge from it (archgraph.go's
517 // ArchInput.Bucket). It rides on Config rather than being read from the
518 // environment in the handler because it is a deployment fact, and a
519 // handler reaching for os.Getenv would hide it from every reader of this
520 // struct. "" is legal — a local CARLOS_DEPLOYMENT_DIR deployment has no
521 // bucket name to draw — and simply draws no bucket node.
522 Bucket string
523 // RegionHA is CARLOS_REGION_HA as strconv.ParseBool reads it (region HA
524 // spec 1 §8 step 2): with it off the console chooses no executor at
525 // create and relocation has no console half. A deployment fact, on
526 // Config for the same reason Bucket is.
527 RegionHA bool
528 // HomeRegion NAMES the deployment's home placement (CONSOLE_HOME_REGION,
529 // e.g. "ie") for consumer pickers (issue #346). It is a NAME, not a
530 // scope: records for the home keep region "" (the unconstrained value
531 // reconcile has always matched) and the create door normalizes this
532 // code to "" on the way in — so the fleet's boxes never see the code
533 // and nothing about placement changes when it is set. "" is legal: the
534 // regions endpoint then reports no home row and an empty default.
535 HomeRegion string
536 // AppRegions is CONSOLE_APP_REGIONS=on (nearest-region spec §5.6). Off
537 // never stops the create door's fill for rows already holding a region.
538 AppRegions bool
539 // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil = IP step off.
540 GeoIP geo.Locator
541 // GeoIPReader is the same reader as GeoIP when it is a Swappable (the
542 // CONSOLE_GEOIP_DB override, or the follower of this box's edge, geoip
543 // spec §11.3): the /geo endpoint and attribution need its snapshots,
544 // which a bare Locator cannot give. nil = no GeoIP on this console.
545 GeoIPReader *geo.Swappable
546 // GeoipStagingDir is where `carlos system geoip publish` uploads are
547 // staged (geoip spec §4.2): cleared when the server is constructed, so a
548 // crash never leaves an upload behind for good. "" = the OS temp dir,
549 // not cleared (tests).
550 GeoipStagingDir string
551 // The GeoIP feed (geoip spec §10.1): CONSOLE_GEOIP_FEED and, for
552 // GeoLite2, the MaxMind account and key. BoxLabel is this console's
553 // box, named in feed.json so only that box's mirror alerts on feed
554 // failure.
555 GeoipFeed string
556 GeoipMaxMindAccount string
557 GeoipMaxMindKey string
558 BoxLabel string
559 // HibernateBucket/Region/Prefix name the region data bucket the
560 // `carlos db` door reads (apidb.go; spec 2026-09-02 §6, Option A): the
561 // same CARLOS_HIBERNATE_* triple the activator on this box writes
562 // instances/<host>/ with, read through activator.ConfigFromEnv so the
563 // two cannot parse it differently. Read-only, in-region, LTX files by
564 // coordinate only. An empty bucket disables the door (503) rather than
565 // failing it open.
566 HibernateBucket string
567 HibernateRegion string
568 HibernatePrefix string
569 // Region is this console's own placement (CARLOS_REGION, the value the
570 // box agent beside it reconciles with), compared against an instance
571 // record's Region before any S3 call. Empty on the home region, whose
572 // records keep region "" too (see HomeRegion).
573 Region string
574 // DBForward is CARLOS_DB_FORWARD=1: a carlos db read of an instance in
575 // another region is forwarded to a capable box there (db region
576 // forward spec). It also needs Key to be the deployment root's.
577 DBForward bool
578 ReleasePub ed25519.PublicKey // pinned channel-pointer verification key
579 // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY).
580 // nil is a legal deployment: the console runs read-only, the promotion
581 // UI renders disabled, and the mutation handlers refuse with 503.
582 Key ed25519.PrivateKey
583 // AppClient fetches app-internal instance reports (INSTANCES.md); nil
584 // uses a default client with the contract's short timeout.
585 AppClient *http.Client
586 // AppScheme is the scheme apps' public hosts are reached on — "https"
587 // (the default) everywhere but tests.
588 AppScheme string
589 // EdgeIPs is this deployment's edge address list (PLATFORM_EDGE_IPS,
590 // read at console start) — surfaced in the Domains section's
591 // point-your-DNS-here guidance. nil is legal: guidance then says
592 // "this deployment's edge IP" instead of inventing an address.
593 EdgeIPs []string
594 // SecretsPub is the deployment's CARLOS_SECRETS_PUBKEY, served (never
595 // used) by the CLI config door so `carlos secrets set` can seal
596 // client-side with `carlos auth login` alone. Public material by
597 // design — it only encrypts; the console cannot decrypt anything with
598 // or without it. Optional: "" omits the field from the API response
599 // and the CLI falls back to requiring the env var.
600 SecretsPub string
601 // AppsDomain is the deployment's platform apps domain
602 // (PLATFORM_APPS_DOMAIN, the same variable the edge and the host
603 // agent read; flagship: "oncarlos.com"). The CLI's instances-manifest
604 // door pins a member-written manifest's domain to
605 // <account-sqid>.<AppsDomain> — never a domain the request names. ""
606 // follows the litestream convention: the member-facing enable door
607 // does not exist, and the handler refuses with a pointer at the
608 // operator's store-mode `carlos instances enable`.
609 AppsDomain string
610 AppsDomainAliasAccounts []string
611 // RedirectHosts are request hosts this console answers with a permanent
612 // redirect to Origin (CONSOLE_REDIRECT_HOSTS, comma-separated) — retired
613 // hostnames after a canonical-origin move. Opt-in per host: hostnames
614 // that must keep serving the API in place (CLI bearer tokens are
615 // host-agnostic, and POSTs through an unexpected redirect are how
616 // clients break) simply aren't listed.
617 RedirectHosts []string
618 // DenyDomains are hostnames (and every host under them) the /domains
619 // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The
620 // deployment's own names, in other words: the edge's reserved tunnel
621 // hostname, retired console origins, anything platform-managed that is
622 // not already derivable from this config. Empty is legal. The console's
623 // own hostnames are refused regardless (consoleHosts, apidomains.go).
624 DenyDomains []string
625 // TunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST).
626 // The /domains door refuses it, and everything under it, to everyone.
627 // It is kept apart from DenyDomains because the operator's platform
628 // door (#209) lifts DenyDomains and must never lift this: the edge
629 // Allows the name and no registry route backs it, so a claim on it
630 // would take the fleet's own address.
631 TunnelHost string
632 // ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) is the
633 // opt-in for a deployment that hosts apps for other people, such as the
634 // flagship or carloku. With it set, the /domains door also refuses
635 // everything under AppsDomain and under the console's own hostnames,
636 // because on a shared platform those names belong to the operator and
637 // not to a tenant. It is off by default. A self-hosted deployment
638 // usually keeps its console, its apps and its custom domains in one
639 // zone that one person owns, and the reserved rules refused ordinary
640 // names there. See deniedDomains.
641 ReservePlatformDomains bool
642 // MovesEnabled is the Phase 1 moves switch (moves.Enabled, from
643 // CARLOS_MOVES_PHASE1). Off, the console behaves exactly as it did
644 // before Phase 1: the legacy move door answers and nothing new is
645 // reachable.
646 MovesEnabled bool
647 // AWSAccount (CARLOS_AWS_ACCOUNT) is the deployment's AWS account id,
648 // the trust anchor the move door checks every box's signed identity
649 // record against (boxid.Verifier.Account). Unset, no box verifies and
650 // no region can take a move. Only the Phase 1 move door reads it.
651 AWSAccount string
652 // DomainProof (CONSOLE_DOMAIN_PROOF) is the opt-in for proof of control
653 // on custom hostnames (#215). With it set, a member's attach is stored
654 // pending until a TXT record at _carlos-challenge.<host> carries the
655 // account's token, and a pending claim is neither routed nor certified.
656 // Off by default, and off means exactly the behaviour before #215:
657 // every claim is written verified and no TXT lookup is ever made. That
658 // default keeps the 2026-08-26/27 scope ruling (first-writer-wins plus
659 // DNS-pointing-as-evidence) until a deployment chooses otherwise, and it
660 // keeps a fleet that still runs pre-#215 boxes safe: such a box would
661 // read a pending claim as live. Roll the fleet before turning it on.
662 DomainProof bool
663 // LookupTXT resolves the ownership-proof records the /domains doors
664 // check (#215: domains.ChallengeName). nil means net.DefaultResolver;
665 // tests set it so no attach ever reaches a real resolver.
666 LookupTXT func(ctx context.Context, name string) ([]string, error)
667 // StoreProvisioner is the backend the object-storage doors provision
668 // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an
669 // ordinary deployment shape, not a broken one: members may still DECLARE
670 // a store — a declaration is a request an operator fulfils, and a member
671 // cannot see whether a backend exists — while the operator doors that
672 // create buckets and mint keys refuse. The console holds the identity
673 // that MINTS app credentials and never an app's own.
674 StoreProvisioner objstore.Provisioner
675 // StoreScanner is the virus-scanning engine, built at boot from
676 // CARLOS_STORE_SCANNER. nil is the ordinary shape — scanning is opt-in and
677 // off by default — and it means the sweep never runs and the operator
678 // doors that enable an engine refuse. It does NOT stop a member RECORDING
679 // a request, for the same reason a nil StoreProvisioner does not stop a
680 // declaration: a member cannot see whether their deployment has one.
681 //
682 // Teardown needs it too. A store with scanning enabled must have its
683 // engine released before its bucket goes, so a nil scanner over an enabled
684 // record is a refusal rather than a skip.
685 StoreScanner objstore.Scanner
686 // ScanSeedFromDeprecatedEnv says the retired CARLOS_STORE_SCANNER supplied
687 // this deployment's clamd address. The first scan-pass tick then seeds
688 // system/antivirus.json enabled, once, carrying forward an intent the
689 // operator already expressed.
690 //
691 // Read at the first tick rather than at boot because cmd/console builds
692 // the Scanner before it opens the release store, so there is nowhere to
693 // write when the decision is made.
694 ScanSeedFromDeprecatedEnv bool
695 // ScanBudget bounds how much scanning ONE app gets per tick
696 // (CARLOS_STORE_SCAN_BUDGET, default 60s). The arithmetic matters and is
697 // stated in the spec: an hourly tick at 60s is 24 minutes of scanning per
698 // app per day, so at a few hundred objects a minute over INSTREAM a
699 // million-object bucket takes months. The default is honest for small and
700 // medium stores; a deployment with large ones raises it or shortens the
701 // tick.
702 ScanBudget time.Duration
703 // ScanInterval is the floor before a COMPLETED sweep is started again
704 // (CARLOS_STORE_SCAN_INTERVAL, default 24h). A sweep still in flight
705 // ignores it — that is how a bucket bigger than one budget finishes.
706 ScanInterval time.Duration
707 // ScanMaxSigAge is how old an engine's signature database may be before
708 // `store scan status` warns (CARLOS_CLAMD_MAX_SIG_AGE, default 168h). A
709 // clean verdict from three-month-old signatures is not the same claim as
710 // a fresh one, and an audit answer that cannot tell them apart is worth
711 // less than it looks.
712 ScanMaxSigAge time.Duration
713 // CallbackSigner retains the deployment master because callbacks are signed
714 // after grant. nil is a supported deployment shape; callback enrollment and
715 // key delivery refuse rather than ever falling back to unsigned delivery.
716 CallbackSigner *CallbackSigner
717 // CallbackTransport is the scan callback test seam. nil uses the production
718 // transport, including ordinary TLS certificate and hostname verification.
719 CallbackTransport http.RoundTripper
720 // StoreDeployment is the deployment-wide half of every store: which
721 // backend, which region and endpoint buckets are made in, and the prefix
722 // minted bucket names carry. Threaded into every walk so this config is
723 // the single place that knows where this deployment's object estate
724 // lives — a walk that guessed would create the next bucket somewhere
725 // else, and the record would say so afterwards.
726 StoreDeployment objstore.Deployment
727 // StoreMaxPerApp caps how many ENVIRONMENT stores one app may have
728 // (CARLOS_STORE_MAX_PER_APP; 0 = storeMaxPerAppDefault). The app's own
729 // store is not one of them and is never capped.
730 //
731 // It exists because #412 made a store something a signup path can mint:
732 // a bucket plus an IAM user per instance is a bigger footprint than an
733 // instance, and IAM's limits are per cloud account, so the automatic
734 // path needs a bar it stops at. Enforced on the MANUAL door too
735 // (handleAPIStoreCreate) — a ceiling only the automatic path respected
736 // would be a ceiling anyone could step over by hand.
737 StoreMaxPerApp int
738 // PlanCatalog is the deployment's plan catalog (CARLOS_PLAN_CATALOG,
739 // plans-caps-packs spec §4.1). nil means plans are off: no account has
740 // limits, no limits record is published, and the plan door answers 409.
741 PlanCatalog *plans.Catalog
742 // TemporaryApps turns on POST /api/cli/apps/{account}/new-temporary
743 // (temporary static sites spec §3). Off by default, and only switched on
744 // after every box runs a binary that carries the tmp- reservation and
745 // every registry has been scanned clean (the rollout steps). The expiry
746 // pass runs either way, so apps created while it was on still expire.
747 TemporaryApps bool
748 // StoreOperators are the addresses allowed to drive the GRANT door, and
749 // only that one (CARLOS_STORE_OPERATORS). Its own list rather than
750 // console ownership on purpose: granting CREATES backend estate and
751 // spends real money in the DEPLOYMENT's cloud account, so the authority
752 // belongs to whoever runs the deployment, not to a tenant who owns an
753 // account inside it. Empty is fail-closed — nobody may grant.
754 //
755 // The other two store verbs are deliberately NOT gated on this list.
756 // Rotate and finish are the member's: they re-key an estate that already
757 // exists, cost nothing, and only the member knows when every host has
758 // picked the new pair up. Destroy is the account OWNER's, in the browser,
759 // behind the typed bucket name and a passkey — it spends no money and
760 // destroys only that account's own data.
761 StoreOperators []string
762 // DelegationZone is CARLOS_ACME_DELEGATION_ZONE — this deployment's own
763 // zone that a wildcard-enabled claim's _acme-challenge CNAME must
764 // delegate to (the edge's newWildcardFromEnv reads the same variable for
765 // the same reason: claim-derived-wildcards spec §4). Deliberately
766 // independent of AppsDomain — #167's blind spot was conflating the two.
767 // "" is a legal deployment: wildcard enable then refuses every request
768 // with "deployment has no delegation zone configured" rather than
769 // guessing. Normalized (lowercase, trimmed) into Server.delegationZone at
770 // construction, same as RedirectHosts is normalized into `redirects`.
771 DelegationZone string
772 // SteeringHost is the deployment's DNS-steering hostname — the
773 // edge.<apps-domain> name the steering converger keeps one latency A
774 // record per armed edge on (multi-region ingress spec, Part 4). Set only
775 // where the deployment actually steers: cmd/console derives it from
776 // CARLOS_STEERING_ZONE_ID (the same variable the converger reads, so the
777 // console cannot advertise a hostname no pass maintains) plus
778 // PLATFORM_APPS_DOMAIN. "" — every deployment today — leaves custom-domain
779 // guidance byte-for-byte what it has always been. Normalized (lowercase,
780 // trimmed, no trailing dot) into Server.steeringHost at construction.
781 SteeringHost string
782 // MailProvisioner is the backend the email doors provision against. nil
783 // disables every email door — a deployment with no SES estate is a valid
784 // deployment, and the doors must 404 rather than panic.
785 MailProvisioner mail.Provisioner
786 // DNSWriter publishes durable records for platform sending domains. nil
787 // means platform domains behave like custom ones: records are emitted for
788 // the member instead of published.
789 DNSWriter dnsrec.Writer
790 // MailDeployment carries the region, the platform suffix, the DMARC rua
791 // and the SNS topic.
792 MailDeployment mail.Deployment
793 // MailOperators are the addresses allowed to drive the email pause/cap
794 // door (CARLOS_MAIL_OPERATORS, apimail.go): pausing or resuming a
795 // tenant's sending, and raising its daily cap above what its tier
796 // grants automatically. Its own list rather than StoreOperators' or
797 // console ownership, for the same reason StoreOperators is its own
798 // list (that field's doc comment): the authority here is specific to
799 // this deployment's shared SENDING REPUTATION, not to spending its
800 // cloud bill, and the two should be grantable to different people.
801 // Empty is fail-closed — nobody may pause, resume or raise a cap
802 // through this door. Automatic abuse enforcement (mailcounts.go) is
803 // unaffected either way: it calls mail.SetPaused directly, never
804 // through this door.
805 MailOperators []string
806 // ConsoleMail is the console's own email sender (consolemail.go), the
807 // invite fallback when Keymail cannot deliver. nil: no email fallback.
808 ConsoleMail ConsoleMailer
809 // ConsoleName is how this console names itself in the emails it sends
810 // (CARLOS_CONSOLE_NAME). Empty reads "the CARLOS platform".
811 ConsoleName string
812 // RelayRegions are the AWS regions this deployment runs a STUN/TURN
813 // relay in (CARLOS_RELAY_REGIONS, comma-separated), IN THE ORDER an
814 // enabled app's CARLOS_RELAY lists them — the order every browser will
815 // try them in, so it is the operator's list verbatim, not a set. Empty
816 // is a legal deployment and the one this platform has had until now:
817 // `carlos relay enable` refuses with a sentence naming the variable,
818 // and no relay background loop starts at all.
819 //
820 // These are AWS region names (eu-west-1), not placement codes (ie,
821 // fleet/x): they are the vocabulary of the relay hostnames
822 // (relay.<region>.<platform domain>) and of the per-region master
823 // secrets, and an app may sit in several placements at once (relay
824 // spec §5, step 3).
825 RelayRegions []string
826 // RelayOperators are the addresses allowed to drive `carlos relay
827 // rotate` (CARLOS_RELAY_OPERATORS) — the operator verb that moves a
828 // region's issuer pointer to a freshly staged master and re-issues
829 // every enabled app's key. Its own list for the same reason
830 // StoreOperators is: rotating a region's master is an authority over
831 // the DEPLOYMENT's key material, not over any one tenant's app, and a
832 // deployment may want to grant it to different people. Empty is
833 // fail-closed — nobody may rotate.
834 RelayOperators []string
835 // SystemOperators are the addresses allowed to roll the platform's own
836 // binary (CARLOS_SYSTEM_OPERATORS, discussion #176): `carlos system
837 // status`, `update` from the signed release feed, its job, and
838 // `rollback`. Owners of the ops account have all of that already; this
839 // list is for a person who should roll releases without owning the
840 // account, which would also hand them member removal and ownership
841 // transfer. Its own list rather than StoreOperators',
842 // because that one reads tenant bytes through grants and scanning, and
843 // rolling releases should not.
844 //
845 // The upload door stays the owner's. The console signs whatever it is
846 // uploaded with the deployment key and every box runs it, so a listed
847 // operator chooses WHICH upstream-signed release the fleet runs, and
848 // only an owner can put an unreleased build on it. Empty is the
849 // owner-only console that existed before the list.
850 SystemOperators []string
851 // WorkerHeads lists the deployment's maintenance worker heads (#132)
852 // for the layout door's revert interlock (layoutRevertBlocked). Nil
853 // means no reader is wired, which is every console today: the head
854 // store has no key or reader on main yet.
855 WorkerHeads func(ctx context.Context) ([]release.WorkerHead, error)
856 // PlatformDomain is the domain relay hostnames are built under
857 // (CARLOS_PLATFORM_DOMAIN; flagship: "oncarlos.com"), so an enabled
858 // app is handed stun:relay.<region>.<PlatformDomain>:3478 and its two
859 // TURN siblings. Optional: unset falls back to AppsDomain, with a
860 // leading "apps." label stripped when the apps domain is a subdomain
861 // of the platform's own — see relayDomain(). Set it explicitly
862 // wherever the two differ by anything but that label; the relay's DNS
863 // names are operator-managed and this console cannot discover them.
864 PlatformDomain string
865 // SidecarAccounts is the operator allowlist of account sqids that may
866 // still enable and create native sidecars (CARLOS_SIDECAR_ACCOUNTS,
867 // normalized by cmd/console: trimmed, lower-cased, each a valid sqid).
868 // Empty is no gate at all, every deployment's shape before the list
869 // existed. Set, every other account meets sidecarsPaused on the two
870 // doors that bring a sidecar into being (sidecarsgate.go).
871 SidecarAccounts []string
872}
873
874// Server is the console's http.Handler.
875type Server struct {
876 claimLossPageMu sync.Mutex
877 claimLossPageDone chan struct{}
878
879 // noCapacity limits the no-capacity alert to once per region an hour
880 // in this process (region HA spec 2 §7, H27, nocapacity.go).
881 noCapacity noCapacityAlerts
882 // Enabled claim runtime (ConfigureClaimRuntime). Unset flag-off.
883 claimRuntimeConfigured bool
884 claimOwner hostops.RegistrationOwner
885 claimMutationObserver func(hostops.MutationEvent) // fixture recorder only
886 claimBackendObserver func(backendEvent) // fixture recorder only
887 claimEntries claimInventory
888 claimWorkers map[string]func(context.Context)
889 // The console's move pass (movesrecovery.go): its clock (nil is
890 // time.Now; a TEST SEAM) and when each close was first seen due.
891 movesClock func() time.Time
892 moveCloseMu sync.Mutex
893 moveCloseSeen map[string]time.Time
894 // moveReleaseFault, when set, fails the pass's claim release just before
895 // it is made (a TEST SEAM: a release that fails after its log entry is
896 // queued, movesrecovery.go).
897 moveReleaseFault func() error
898 // moveLogSettleHook, when set, runs as the drainer settles an
899 // unconfirmed declared_lost row, after it read the claim (a TEST SEAM:
900 // a declaration that commits in between, movesrecovery.go).
901 moveLogSettleHook func()
902 // moveFenceCall, when set, runs as the pass calls the grant's
903 // ReleaseFencedTarget, with the fence it names (a TEST SEAM: which
904 // passes call the target's fence, movesrecovery.go).
905 moveFenceCall func(hostclaim.FenceRef)
906 dbHandleKey []byte
907 seedScratch *seedScratch
908 // seedCopies counts --from copies and import uploads running detached
909 // from their request (apiinstanceseed.go, apiinstanceimport.go), so a
910 // test can wait for one to finish.
911 seedCopies sync.WaitGroup
912 cfg Config
913 store *Store
914 // appRegionOf and beforeClaimInsert are TEST SEAMS (nil in
915 // production): a store failure on the app-region read (C20), and a
916 // competing insert landing after the region was resolved.
917 appRegionOf func(accountID int64, app string) (string, string, error)
918 beforeClaimInsert func()
919 // claims folds the deployment's domain claims for the read paths — the
920 // Settings render and the domains list door — reading only the claims
921 // whose bucket stamp moved since the last fold (#173). Process-lifetime
922 // and safe for concurrent handlers; see domains.ClaimCache.
923 claims domains.ClaimCache
924 km *Keymail
925 mux *http.ServeMux
926 instances *instanceFetcher
927 secure bool // Secure cookies iff the origin is https
928 cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go)
929 // boxCerts are AWS's instance-identity signing certificates by AWS
930 // region (boxid.EmbeddedCerts), loaded once at construction. The Phase
931 // 1 move door verifies each box's identity record with them; nil (a
932 // load failure, logged) verifies nothing.
933 boxCerts map[string]*x509.Certificate
934 // dbReplica opens the read side of one instance's replica for the
935 // `carlos db` door (apidb.go). NewServer points it at a small cache of
936 // lease.ReadReplicaClient values; tests point it at a file replica.
937 dbReplica func(ctx context.Context, prefix string) (replicaReader, error)
938 dbClients dbReplicaCache
939 // dbStreams bounds the db door's active file streams, and dbStreamIdle
940 // is how long one may go without moving a byte (apidb.go).
941 dbStreams *dbStreamLimits
942 dbStreamIdle time.Duration
943 // dbReads bounds open forwarded reads per person and per console, held
944 // by each read's request (spec §5.7, J5); dbf is its timing.
945 dbReads *dbStreamLimits
946 dbf dbForwardTimes
947 // cliRoles is pattern -> allowed roles, recorded by cliRoute as it
948 // registers each /api/cli/ door (authcli.go). Written once, during
949 // registerCLIRoutes, before the server serves anything, and only read
950 // afterwards. It exists for two readers: the coverage tests, which
951 // assert that every door names its roles and that the closed ones stay
952 // closed, and the services API, which hands an operator the door list
953 // for the role they just granted so they can see what they gave away
954 // without reading a spec.
955 cliRoles map[string]svcrole.Set
956 // redirects is cfg.RedirectHosts, normalized (lowercase, no port) into a
957 // lookup set — ServeHTTP checks it before the mux sees the request.
958 redirects map[string]bool
959 // cliNotified, when non-nil, is signalled after each CLI-token issuance
960 // notification attempt. Tests only — the send is off the request
961 // goroutine, and a test that asserted on it without this would be racing.
962 cliNotified chan struct{}
963 // storeWalks serialises the object-storage walks per app (storelock.go).
964 // objstore's own teardown reasoning assumes this exists — "The console
965 // door serialises these verbs per app" (objstore/walk.go:560) — and until
966 // #381 nothing did.
967 storeWalks *storeWalkLocks
968 // doorPublish is the door-key publication lock per app, keyed
969 // "<sqid>/<app>" (apidoorkeys.go): one publisher per app at a time, so a
970 // renewal can never land an older key set after a revoke. Built lazily
971 // under doorPublishMu, so the zero Server needs no setup.
972 doorPublishMu sync.Mutex
973 doorPublish map[string]*sync.Mutex
974 // sendApp is how the alert watcher (alertwatch.go) delivers keymail:
975 // km.SendApp in production, a capture in tests. A field rather than a
976 // direct call so an alertPass assertion needs no keymail server.
977 sendApp func(ctx context.Context, token, to, subject, body string) (string, error)
978 // Invite delivery (handleInvite): Keymail federation first, then the
979 // console's own email. Fields so tests can substitute and shrink them.
980 sendFederated func(ctx context.Context, to, subject, body string) (string, error)
981 consoleMail ConsoleMailer
982 consoleNameCfg string
983 // limits are email sign-in's rate limits (emailsignin_limits.go): in
984 // memory, one console process per deployment.
985 limits emailLimits
986 // flowLocks serialise /auth/continue per sign-in flow (flowLock).
987 flowLocks [256]sync.Mutex
988 // challengeSem bounds the sign-in and step-up sends in flight
989 // (sendChallengeAsync). challengeSent, when non-nil, is signalled after
990 // each one finishes or is dropped. Tests only, as cliNotified.
991 challengeSem chan struct{}
992 challengeSent chan struct{}
993 inviteTimeout time.Duration
994 inviteKeymailShare time.Duration
995 // inviteRate is each account's invites created in the last hour
996 // (maxInvitesPerHour). In memory: a restart forgets it, and the pending
997 // cap still holds.
998 inviteRate inviteWindow
999 // metricsCache holds the result of LoadMetrics's last successful bucket
1000 // sweep (metricsview.go). Concurrent callers within metricsCacheTTL share
1001 // it outright; callers arriving later still get it immediately (stale-
1002 // while-revalidate, up to metricsStaleServeCeiling) so the operator
1003 // dashboard and every owner's app-metrics page don't each re-sweep 48h of
1004 // chunks per request.
1005 metricsCache metricsCache
1006 // economicsCache holds LoadEconomics's last successful sweep per period
1007 // ("mtd", "last", "7d" — economicsview.go). Same shape as metricsCache,
1008 // keyed by period so a cold sweep for one period never blocks a caller
1009 // asking for another.
1010 economicsCache economicsCache
1011 // storeSizer is the storage pass's test seam for sizing a provisioned
1012 // tenant object store: nil means "use the real S3 sizer"
1013 // (sizeProvisionedStore, economicspass.go); tests inject a fake so a
1014 // sweep can be proven to degrade ("unswept") without live AWS.
1015 storeSizer func(ctx context.Context, p *objstore.Provisioned) (int64, error)
1016
1017 // storeListerFor is the scan pass's test seam for listing a provisioned
1018 // bucket, the same shape and for the same reason as storeSizer above: the
1019 // sweep is driven against a MemProvisioner's buckets with no S3 and no
1020 // credentials.
1021 storeListerFor func(ctx context.Context, p *objstore.Provisioned) (objstore.ObjectLister, error)
1022
1023 // scanQueue is which apps have on-demand scan work waiting (spec §7.1).
1024 //
1025 // SOFT STATE, and never the queue itself: the items are objects in each
1026 // app's own prefix, and this only says where to look. Losing it costs
1027 // latency, not items — the boot rebuild finds anything stranded and the
1028 // hourly sweep reaches every object regardless.
1029 //
1030 // Built in NewServer rather than lazily, because both writers reach it
1031 // without coordination: the enqueue door bumps it from a request
1032 // goroutine while the drain reads it from its own. A nil map assigned on
1033 // first use would be the race, and a Server that always has one cannot
1034 // have it.
1035 scanQueue *objstore.QueueIndex
1036 scanDelivery *scanDeliveryPool
1037 // scanWaits is the in-process wake the status door's long-poll selects on
1038 // (scandrain.go). Zero value is usable, which is why it is a value rather
1039 // than a pointer: a Server built by a test that never starts the drain
1040 // still answers scanWait and scanNotify rather than panicking.
1041 scanWaits scanWaiters
1042 // ceCosts is billPass's test/fake seam for Cost Explorer (cepull.go,
1043 // economicspass.go): nil means "use the real, lazily-built CE client"
1044 // (s.realCECosts); tests inject a fake so a pull can be proven to
1045 // succeed, or to degrade quietly, without live AWS billing IAM.
1046 ceCosts func(ctx context.Context, start, end time.Time) (map[string]float64, error)
1047 // ceClientOnce/ceClient/ceWarnOnce/ceDisabled cache s.realCECosts'
1048 // Cost Explorer client and its process-lifetime disabled state — see
1049 // cepull.go's doc comment for why a mutex isn't needed here.
1050 ceClientOnce sync.Once
1051 ceClient *costexplorer.Client
1052 ceWarnOnce sync.Once
1053 ceDisabled bool
1054 // The infrastructure inventory seam (infrapass.go), built exactly like
1055 // the Cost Explorer seam above it: tests set inventory to a fake, the
1056 // real path lazily constructs an awsinv.Provider once. infraDisabled
1057 // latches an auth-shaped failure off for the process — a deployment
1058 // with no inventory IAM grant answers "ungranted" once, not hourly.
1059 // Unlike ceDisabled above, this one IS reached from more than the
1060 // single-goroutine hourly ticker: loadInventory reads it to serve an
1061 // HTTP handler, and the refresh door (a later task) calls infraPass
1062 // directly from another — so it is atomic.Bool, not a plain bool.
1063 inventory func(ctx context.Context) (infraview.Inventory, error)
1064 infraOnce sync.Once
1065 infraProvider infraview.Provider
1066 infraDisabled atomic.Bool
1067 // infraRefreshing single-flights concurrent refresh-door callers
1068 // against EACH OTHER only — it says nothing about the ticker, which is
1069 // why infraDisabled above needs its own atomicity and this field
1070 // cannot stand in for that fix.
1071 infraRefreshing atomic.Bool
1072 // shipSem bounds how many `carlos ship` uploads (apicli.go,
1073 // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which
1074 // bounds them over TIME. A rate limiter is a counter; it says nothing
1075 // about concurrency, and a ship holds a goroutine plus a substantial
1076 // buffer open for as long as the client trickles bytes in — a handful
1077 // of slow uploads run in parallel is a resource-exhaustion path the
1078 // counter alone does nothing to close. Always initialized (never nil):
1079 // unlike cliNotified below this is load-bearing in production, not a
1080 // test hook.
1081 shipSem chan struct{}
1082 // shipStarted, when non-nil, is signalled after a ship request
1083 // acquires shipSem, before it does any (possibly slow, possibly
1084 // blocking) reading of the request body. Tests only, same reasoning as
1085 // cliNotified: without this a test racing to fill shipSem would have no
1086 // way to know the earlier requests actually hold it yet.
1087 shipStarted chan struct{}
1088 // delegationZone and lookupCNAME are the wildcard-capability door's two
1089 // seams (apidomains.go). delegationZone is cfg.DelegationZone, shape-
1090 // validated and normalized once here (normalizeDelegationZone);
1091 // lookupCNAME takes the request's own ctx — the same ctx-first idiom
1092 // internal/edge/wildcard.go already uses for its DNS lookups — and
1093 // defaults to net.DefaultResolver.LookupCNAME. Tests stub it directly on
1094 // a constructed Server, the same way they override fx.srv.cfg.DenyDomains
1095 // post-construction.
1096 delegationZone string
1097 lookupCNAME func(ctx context.Context, host string) (string, error)
1098 // lookupTXT is cfg.LookupTXT, defaulted to net.DefaultResolver: the
1099 // one TXT seam for both ownership proofs, an instance catch-all's
1100 // (domains.CatchallProofName) and a claim's (#215, domainProver).
1101 // Stubbed by tests the same way lookupCNAME is.
1102 lookupTXT func(ctx context.Context, name string) ([]string, error)
1103 // steeringHost is cfg.SteeringHost, normalized once here — see
1104 // dnsTargets, the one reader.
1105 steeringHost string
1106 // snsVerify is the SES ingest door's test seam (sesevents.go) — nil
1107 // means "do the real signature verification", which fetches the signing
1108 // certificate over the network. The seam covers signature verification
1109 // ONLY: it never substitutes for the TopicArn or SigningCertURL checks,
1110 // which every caller — production and test alike — runs for real.
1111 snsVerify func(snsEnvelope) error
1112 // mailPushTarget is the email-events push's test seam (mailpush.go):
1113 // nil means "the app's own platform host, over https", which is the
1114 // only place production ever sends. A test points it at an httptest
1115 // server instead. The path still goes through the same validation.
1116 mailPushTarget func(acct, app, path string) string
1117 // snsCerts caches a verified signing certificate's RSA public key by URL
1118 // (sesevents.go) — otherwise every request bearing the deployment's
1119 // TopicArn, which is not a secret, triggers a fresh outbound HTTPS fetch
1120 // and full chain verification. Zero value is ready to use.
1121 snsCerts snsCertCache
1122 // refusedTopics bounds the SES ingest door's refusal log (sesevents.go's
1123 // logRefusedTopic): the door is unauthenticated and internet-reachable,
1124 // so the diagnostic has to be rate-limited rather than omitted. Zero
1125 // value is ready to use.
1126 refusedTopicsMu sync.Mutex
1127 refusedTopics map[string]time.Time
1128 // edgeFold caches the deployment's folded live edge addresses — every
1129 // box's control/edges/<box>.json joined against its status snapshot —
1130 // so the guidance strings can name every armed edge rather than only
1131 // the ones this console's own PLATFORM_EDGE_IPS lists. Cached because
1132 // dnsTargets is on the request path and is called once PER CLAIM inside
1133 // the domains-list loop: an uncached fold would put a LoadBoxes sweep
1134 // plus one Get per edges file behind every row of every listing. Zero
1135 // value is ready to use.
1136 edgeFold edgeFoldCache
1137 // edgeProof is the mesh-key cache and the seen latch behind clientIP's
1138 // loopback check (edgeproof.go). Zero value is ready to use.
1139 edgeProof edgeProofState
1140 // foldEdgeIPs is edgeFoldCache's test seam: nil means the real fold
1141 // over cfg.Releases (s.realFoldEdgeIPs). Tests inject a counter so the
1142 // TTL can be proven without a bucket, the same way storeSizer and
1143 // ceCosts stand in for AWS above.
1144 foldEdgeIPs func(ctx context.Context) []string
1145 // The `carlos system` promote's in-memory registries (apisystem.go):
1146 // systemJobs is the running/finished update jobs an operator polls,
1147 // systemRunning is "<component>/<channel>" -> the id of the job holding
1148 // that channel's one in-flight slot, and systemUploads is the single
1149 // upload slot a `--file` promote parks its bytes in between the PUT that
1150 // carries them and the job that consumes them. All three are
1151 // process-lifetime and deliberately NOT durable: a console restart loses
1152 // a job's step list, and re-running the promote is the recovery, because
1153 // every step of it is idempotent.
1154 systemMu sync.Mutex
1155 systemJobs map[string]*systemJob
1156 // geoip is the single writer of control/geoip/ (geoip spec §3); nil
1157 // when this console has no deployment bucket.
1158 geoip *geoipPublisher
1159 systemRunning map[string]string
1160 systemUploads map[string]systemUpload
1161
1162 // The relay writer's three fields (relayops.go). relayHolder is this
1163 // PROCESS's identity in control/relay-writer.json, drawn once at
1164 // construction; relayHeld is whether the lease is currently ours, read
1165 // before every relay bucket write by requireRelayLease; relayAppLocks
1166 // serialises operations on one app ("<sqid>/<app>" -> *sync.Mutex)
1167 // inside the holder, which is the second half of spec §5's one-writer
1168 // rule — the lease keeps two CONSOLES apart, this keeps two handlers
1169 // of the same console apart.
1170 relayHolder string
1171 relayHeld atomic.Bool
1172 relayAppLocks sync.Map
1173 // appLocks and acctLocks are the temporary-app lifecycle locks
1174 // (applocks.go). Zero values are ready.
1175 appLocks keyedLocks
1176 acctLocks keyedLocks
1177 // relayNow is the relay paths' clock seam: nil means time.Now. Tests
1178 // drive lease expiry with it; nothing in production sets it.
1179 relayNow func() time.Time
1180
1181 // recordView is the record view the run and seed sweeps read through
1182 // (recordview.go), set by SetRecordView when CARLOS_CHANGE_INDEX=read
1183 // arms it; nil (disarmed) is every sweep walking the bucket as before
1184 // the index.
1185 recordView *provision.RecordView
1186 // viewUse is the last recordWalkStore outcome, so a switch between the
1187 // view and the bucket is logged once, not on every pass.
1188 viewUse atomic.Int32
1189 // lastOrphanFull is when the run sweep's orphan housekeeping last swept
1190 // every app with records (runsweep.go); zero until the first, which is
1191 // therefore full. Only the run sweep's one goroutine touches it.
1192 lastOrphanFull time.Time
1193}
1194
1195// edgeFoldTTL is how long one fold of the deployment's edges files stands
1196// for. Edges arm and retire on the scale of a deployment, not a request, and
1197// every box republishes its own file on a 5min+jitter loop — so a minute is
1198// far below the rate at which the answer can actually change while still
1199// keeping the domains-list door off the bucket for all but one request a
1200// minute.
1201const edgeFoldTTL = time.Minute
1202
1203// edgeFoldTimeout bounds one fold, mirroring the agent's own closure.
1204// dnsTargets takes no context of its own (it is called from deep inside view
1205// assembly), so without this a hung store would stall the domains-list door
1206// indefinitely rather than degrading to the configured list.
1207const edgeFoldTimeout = 30 * time.Second
1208
1209// edgeFoldCache is the folded set plus the instant it was folded. The mutex
1210// is held ACROSS the refresh deliberately: it is what makes concurrent
1211// callers share one fold instead of stampeding the bucket, and the fold is
1212// bounded by edgeFoldTimeout so the worst case is a bounded wait, not a
1213// wedge.
1214type edgeFoldCache struct {
1215 mu sync.Mutex
1216 at time.Time
1217 ips []string
1218}
1219
1220// consoleName is what the console's emails call it: the operator's
1221// CARLOS_CONSOLE_NAME, else today's wording.
1222func (s *Server) consoleName() string {
1223 if n := strings.TrimSpace(s.consoleNameCfg); n != "" {
1224 return n
1225 }
1226 return "the CARLOS platform"
1227}
1228
1229// brandName is the console's own name in its page chrome (the header, the
1230// tab title, the front page): CARLOS_CONSOLE_NAME when the deployment sets
1231// one, so carloku.com reads "Carloku" throughout, else "CARLOS console".
1232func (s *Server) brandName() string {
1233 if n := strings.TrimSpace(s.consoleNameCfg); n != "" {
1234 return n
1235 }
1236 return "CARLOS console"
1237}
1238
1239// chrome fills the deployment's names on any page before it renders.
1240func (s *Server) chrome(p *page) {
1241 p.Brand = s.brandName()
1242 p.ConsoleName = s.consoleName()
1243}
1244
1245// dnsTargets is what every custom-domain guidance string in this console is
1246// built from (view.go's domainGuidance): the deployment's edge addresses and
1247// its steering hostname, read from ONE place so the app page, the Settings
1248// page and the /domains API can never disagree about where a member should
1249// point DNS.
1250func (s *Server) dnsTargets() DNSTargets {
1251 return DNSTargets{EdgeIPs: s.edgeIPs(), SteeringHost: s.steeringHost}
1252}
1253
1254// edgeIPs is the deployment's edge addresses as this console will tell a
1255// member to point at: the operator's configured list UNIONED with the live
1256// fold, configured addresses first.
1257//
1258// Union, not replacement, in both directions. A console whose bucket read
1259// fails still offers PLATFORM_EDGE_IPS rather than none — an empty guidance
1260// string is worse than a partial one. And a multi-region deployment's other
1261// edges appear even though this console's env var predates them, which is
1262// the whole point: the same set the domains reconcile pass now judges
1263// "pointing" against, so guidance and verdict cannot disagree.
1264func (s *Server) edgeIPs() []string {
1265 out := append([]string(nil), s.cfg.EdgeIPs...)
1266 for _, ip := range s.foldedEdgeIPs() {
1267 if ip != "" && !slices.Contains(out, ip) {
1268 out = append(out, ip)
1269 }
1270 }
1271 return out
1272}
1273
1274// foldedEdgeIPs answers the cached fold, refreshing it when it has aged past
1275// edgeFoldTTL.
1276//
1277// A refresh that comes back with NOTHING leaves the previous answer standing:
1278// an empty fold is a bucket that could not be read this minute far more often
1279// than it is a deployment that lost every edge, and emptying the set would
1280// silently narrow the guidance every member is shown. The timestamp still
1281// moves, so a persistent outage costs one bounded attempt per TTL rather than
1282// one per request.
1283func (s *Server) foldedEdgeIPs() []string {
1284 c := &s.edgeFold
1285 c.mu.Lock()
1286 defer c.mu.Unlock()
1287 if !c.at.IsZero() && time.Since(c.at) < edgeFoldTTL {
1288 return c.ips
1289 }
1290 fold := s.foldEdgeIPs
1291 if fold == nil {
1292 fold = s.realFoldEdgeIPs
1293 }
1294 ctx, cancel := context.WithTimeout(context.Background(), edgeFoldTimeout)
1295 defer cancel()
1296 ips := fold(ctx)
1297 c.at = time.Now()
1298 if len(ips) > 0 {
1299 c.ips = ips
1300 }
1301 return c.ips
1302}
1303
1304// realFoldEdgeIPs folds the deployment bucket's edges files. env is nil here,
1305// not cfg.EdgeIPs: edgeIPs above does the union, so the configured list is
1306// added exactly once and the cache holds only what was actually read from the
1307// bucket — which is what lets an empty fold be recognised as a failed read.
1308// Sorted so a listing's guidance sentence does not reshuffle between calls on
1309// the store's arbitrary iteration order.
1310func (s *Server) realFoldEdgeIPs(ctx context.Context) []string {
1311 if s.cfg.Releases == nil {
1312 return nil
1313 }
1314 ips := edgesfile.EdgeIPs(ctx, s.cfg.Releases, nil, time.Now())
1315 slices.Sort(ips)
1316 return ips
1317}
1318
1319// normalizeSteeringHost lowercases, trims and shape-validates the steering
1320// hostname (Config.SteeringHost). Same posture as normalizeDelegationZone
1321// below and for the same reason: a value that is not a legal hostname
1322// degrades to "" — guidance keeps the A-record form, which always works —
1323// rather than being pasted verbatim into a sentence every member of this
1324// deployment is shown. Loud, because a deployment that armed steering and
1325// typo'd the variable would otherwise mislead silently.
1326func normalizeSteeringHost(raw string) string {
1327 h := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), ".")))
1328 if h != "" && !provision.ValidHostShape(h) {
1329 log.Printf("console: steering hostname %q is not a valid domain — custom-domain guidance keeps the A-record form", raw)
1330 return ""
1331 }
1332 return h
1333}
1334
1335// normalizeDelegationZone lowercases, trims, and shape-validates raw (from
1336// CARLOS_ACME_DELEGATION_ZONE): a value that isn't a legal hostname degrades
1337// to "" — delegation off — rather than being handed to lookupCNAME verbatim
1338// or failing boot. Mirrors the edge's own validation of the same variable
1339// (internal/edge/wildcard.go's newWildcardFromEnv), so a zone the edge would
1340// refuse never gets ACCEPTED here only to make every wildcard-enable request
1341// dial a garbage DNS query. "" already has a well-defined, honest answer —
1342// every enable request refuses with "deployment has no delegation zone
1343// configured" — so a badly-typed zone gets exactly that, not a crash or a
1344// distinguishable error.
1345func normalizeDelegationZone(raw string) string {
1346 zone := strings.ToLower(strings.TrimSpace(raw))
1347 if zone != "" && !provision.ValidHostShape(zone) {
1348 log.Printf("console: CARLOS_ACME_DELEGATION_ZONE %q is not a valid domain — delegation off, wildcard-enable requests refuse with \"no delegation zone configured\" until it's fixed", raw)
1349 return ""
1350 }
1351 return zone
1352}
1353
1354// NewServer wires a server over an opened Store, loading (or minting) the
1355// console's Keymail signing key.
1356func NewServer(cfg Config, st *Store) (*Server, error) {
1357 if cfg.HostClaim && !st.claimGated() {
1358 return nil, fmt.Errorf("CARLOS_HOST_CLAIM: %s", "the console database must be opened gated (console.OpenGated)")
1359 }
1360 if cfg.HostClaim && cfg.HibernateBucket != "" && cfg.dbClaimRegion() == "" {
1361 return nil, fmt.Errorf("CARLOS_HOST_CLAIM: %s", "region is required")
1362 }
1363 cfg.Origin = strings.TrimSuffix(cfg.Origin, "/")
1364 // originHost is what a RedirectHosts entry must never equal: redirecting
1365 // the console's own origin to itself is a loop, not a retirement.
1366 originHost := ""
1367 if u, err := url.Parse(cfg.Origin); err == nil {
1368 originHost = normalizeHost(u.Host)
1369 }
1370 // An origin with no host in it is fatal ONCE there are hosts to retire,
1371 // never before: it is both the loop guard's only reference — an origin
1372 // that parsed to nothing silently permits the very entry the guard exists
1373 // to refuse — and the prefix every Location is built on, where "" would
1374 // hand back the request's own path (a redirect to the retired host) or a
1375 // scheme-relative "//host/…" whose destination the request chooses.
1376 // Deployments with nothing to retire keep whatever origin they had.
1377 if len(cfg.RedirectHosts) > 0 && originHost == "" {
1378 return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS is set, but CONSOLE_ORIGIN %q has no host to redirect to — it needs a scheme, e.g. https://console.example.com", cfg.Origin)
1379 }
1380 redirects := make(map[string]bool, len(cfg.RedirectHosts))
1381 for _, h := range cfg.RedirectHosts {
1382 h = normalizeHost(h)
1383 if h == "" {
1384 continue
1385 }
1386 if h == originHost {
1387 return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS lists %q, which is this console's own origin host — that config would redirect-loop", h)
1388 }
1389 redirects[h] = true
1390 }
1391 km := NewKeymail(cfg.Keymail, cfg.Origin)
1392 key, err := st.SigningKey()
1393 if err != nil {
1394 return nil, err
1395 }
1396 km.SetSigningKey(key)
1397 s := &Server{
1398 cfg: cfg,
1399 store: st,
1400 km: km,
1401 mux: http.NewServeMux(),
1402 instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme),
1403 secure: strings.HasPrefix(cfg.Origin, "https://"),
1404 cli: newCLILimits(),
1405 redirects: redirects,
1406 // Two concurrent ships at a time: roomy for one operator's CI plus a
1407 // human running `carlos ship` by hand, tight enough that a fleet of
1408 // slow-loris uploads against a stolen or shared token can't pile up
1409 // goroutines and buffers without bound.
1410 shipSem: make(chan struct{}, 2),
1411 delegationZone: normalizeDelegationZone(cfg.DelegationZone),
1412 steeringHost: normalizeSteeringHost(cfg.SteeringHost),
1413 lookupCNAME: net.DefaultResolver.LookupCNAME,
1414 lookupTXT: cfg.LookupTXT,
1415 storeWalks: newStoreWalkLocks(),
1416 consoleMail: cfg.ConsoleMail,
1417 consoleNameCfg: cfg.ConsoleName,
1418 challengeSem: make(chan struct{}, challengeSendSlots),
1419 inviteTimeout: 20 * time.Second,
1420 inviteKeymailShare: 12 * time.Second,
1421 // This process's identity in the relay writer lease. Random rather
1422 // than derived from the host: two consoles on one box during a
1423 // deploy overlap are two writers, and they must be able to tell
1424 // each other apart (relayops.go).
1425 relayHolder: relayHolderID(),
1426 scanQueue: objstore.NewQueueIndex(),
1427 }
1428 s.seedScratch = startSeedScratch(cfg.SeedScratchDir)
1429 if s.lookupTXT == nil {
1430 s.lookupTXT = net.DefaultResolver.LookupTXT
1431 }
1432 if certs, err := boxid.EmbeddedCerts(); err != nil {
1433 log.Printf("console: AWS identity certificates did not load (%v), so no box verifies for a move", err)
1434 } else {
1435 s.boxCerts = certs
1436 }
1437 if s.km != nil {
1438 s.sendApp = s.km.SendApp
1439 s.sendFederated = s.km.SendFederated
1440 }
1441 // No goroutine is started here: StartAlertWatcher is serve()'s job, so a
1442 // test that constructs a Server gets a Server and nothing else.
1443 s.registerRoute("GET /{$}", s.handleHome)
1444 s.registerRoute("POST /auth/continue", s.handleContinue)
1445 s.registerRoute("GET /auth/email/link", s.handleEmailLinkPage)
1446 s.registerRoute("POST /auth/email/link", s.handleEmailLink)
1447 s.registerRoute("POST /auth/email/code", s.handleEmailCode)
1448 s.registerRoute("POST /auth/email/stepup/code", s.handleStepUpEmailCode)
1449 s.registerRoute("GET /auth/signin", s.handleSignin)
1450 s.registerRoute("GET /auth/callback", s.handleCallback)
1451 // Passkey step-up (stepup.go): where a re-authentication lands, on its own
1452 // route so a step-up code and a sign-in code are never interchangeable.
1453 s.registerRoute("GET /auth/stepup/callback", s.handleStepUpCallback)
1454 s.registerRoute("GET /auth/confirm", s.handleConfirm)
1455 s.registerRoute("POST /auth/signout", s.handleSignout)
1456 s.registerRoute("GET /fleet", s.handleFleet)
1457 s.registerRoute("GET /fleet/store", s.handleStoreApprovalsPage)
1458 s.registerRoute("POST /fleet/store/{account}/{decision}", s.handleStoreDecisionPage)
1459 s.registerRoute("POST /a/{sqid}/store/apply", s.handleStoreApplyPage)
1460 s.registerRoute("GET /fleet/metrics", s.handleFleetMetrics)
1461 s.registerRoute("GET /fleet/economics", s.handleEconomics)
1462 // The deployment's map (archpage.go), same operatorOnly gate. BOTH are
1463 // registered WITHOUT a method prefix, for the reason spelled out below
1464 // the economics doors: a "GET /path" pattern answers a POST with 405 +
1465 // Allow, which distinguishes this route from an unrouted one to anyone
1466 // who merely guessed the URL — the exact probe the gate exists to
1467 // prevent. Each handler checks r.Method itself, first thing.
1468 s.registerRoute("/fleet/architecture", s.handleArchitecture)
1469 s.registerRoute("/fleet/architecture/refresh", s.handleArchitectureRefresh)
1470 // Per-account table (Task 14): same gate, its own search/filter/cursor
1471 // controls (econlistquery.go) over the same LoadEconomics rows.
1472 s.registerRoute("GET /fleet/economics/accounts", s.handleEconomicsAccounts)
1473 // Unit economics (Task 12): scenario/bill writes, operator-only, same
1474 // gate shape as handleFleet above. GET /fleet/economics is handled
1475 // above by handleEconomics (Task 13). Registered WITHOUT a method
1476 // prefix, deliberately: a
1477 // "POST /path" pattern answers a GET with 405 + Allow, which leaks that
1478 // the route exists to someone who merely guessed the URL — the exact
1479 // probe handleFleet's 404-to-everyone gate exists to prevent. Each
1480 // handler below checks r.Method itself, before anything else, so a
1481 // wrong-method request is byte-identical to an unknown route.
1482 s.registerRoute("/fleet/economics/scenarios", s.handleEconomicsScenarioSave)
1483 s.registerRoute("/fleet/economics/bill", s.handleEconomicsBillSave)
1484 s.registerRoute("/api/economics/bill", s.handleAPIEconomicsBill)
1485 s.registerRoute("/api/economics/backhaul", s.handleAPIEconomicsBackhaul)
1486 // Account tier (economics-v2, Task 4): same bare-registration/method-
1487 // first reasoning as the two doors above.
1488 s.registerRoute("/fleet/economics/tier", s.handleEconomicsTierSave)
1489 // CLI identity: the device-code login `carlos auth login` drives, the
1490 // approval page, and the tokens an owner can see and kill (authcli.go).
1491 // The db door's production replica reader (apidb.go); tests repoint it.
1492 if cfg.HostClaim {
1493 s.dbHandleKey = dbHandleSecret(key)
1494 }
1495 s.dbReplica = s.cachedReadReplica
1496 s.dbStreams = newDBStreamLimits(dbMaxStreams, dbMaxStreamsPerPrincipal)
1497 s.dbStreamIdle = dbStreamIdleDefault
1498 s.dbReads = newDBStreamLimits(dbreads.OpenPerConsole, dbreads.OpenPerPerson)
1499 s.dbf = defaultDBForwardTimes()
1500 s.registerCLIRoutes()
1501 // The email API's ten doors (apimail.go, tenant-email task 17) —
1502 // registered separately from registerCLIRoutes because this task's file
1503 // scope is server.go and apimail.go only.
1504 s.registerEmailRoutes()
1505 // Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
1506 // account-by-session, so a link a teammate opens lands where you meant.
1507 s.registerRoute("GET /a/{sqid}/{$}", s.handleAccountHome)
1508 s.registerRoute("GET /a/{sqid}/fleet", s.handleAccountFleetTab)
1509 s.registerRoute("GET /a/{sqid}/fleet/byo", s.handleFleetBYO)
1510 s.registerRoute("GET /a/{sqid}/team", s.handleTeam)
1511 s.registerRoute("GET /a/{sqid}/settings", s.handleAccountSettings)
1512 s.registerRoute("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel)
1513 s.registerRoute("POST /a/{sqid}/analytics/setting", s.handleAccountAnalyticsSetting)
1514 // Go 1.22's mux prefers this literal over apps/{app} below — exactly why
1515 // "new" must be a reserved app name (claimApp): an app so named could
1516 // never be viewed.
1517 s.registerRoute("GET /a/{sqid}/apps/new", s.handleNewAppForm)
1518 s.registerRoute("POST /a/{sqid}/apps/new", s.handleNewApp)
1519 s.registerRoute("POST /a/{sqid}/apps/new/region", s.handleNewAppRegion)
1520 s.registerRoute("GET /a/{sqid}/apps/{app}", s.handleApp)
1521 s.registerRoute("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
1522 s.registerRoute("GET /a/{sqid}/apps/{app}/deployments", s.handleAppReleases)
1523 s.registerRoute("GET /a/{sqid}/apps/{app}/account-review", s.handleAppAccountReview)
1524 s.registerRoute("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
1525 s.registerRoute("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance)
1526 s.registerRoute("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart)
1527 s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart)
1528 s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/retry", s.handleInstanceRetry)
1529 s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/updates", s.handleBrowserUpdates)
1530 s.registerRoute("POST /a/{sqid}/apps/{app}/instances/{host}/updates/install", s.handleBrowserUpdateNow)
1531 s.registerRoute("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
1532 s.registerRoute("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData)
1533 // The Errors tab and its two setting doors (errorsview.go): the tab is
1534 // member-readable like every other app tab; the level is the owner's,
1535 // at app and account level, and both POSTs go through the console gate
1536 // (CSRF, then owner, then the byte-identical 404).
1537 s.registerRoute("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors)
1538 s.registerRoute("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel)
1539 // Availability (errorsview.go): the Errors tab's sibling for the
1540 // failures that are the instance's, not the request's — edge 502/503/504
1541 // by cause and the platform's lifecycle events. Member-readable, no
1542 // setting of its own.
1543 s.registerRoute("GET /a/{sqid}/apps/{app}/availability", s.handleAppAvailability)
1544 // The Analytics tab (analyticsview.go) and the switch beside it. The tab
1545 // is member-readable like every other app tab; the switch rides the same
1546 // gate the errors level does at both levels, and bumps the serial where
1547 // the errors level does not: a box acts on whether the edge counts.
1548 s.registerRoute("GET /a/{sqid}/apps/{app}/analytics", s.handleAppAnalytics)
1549 s.registerRoute("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting)
1550 s.registerRoute("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
1551 s.registerRoute("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
1552 s.registerRoute("GET /a/{sqid}/apps/{app}/settings/{section}", s.handleAppSettings)
1553 // The pipeline settings page (pipelinepage.go, release pipelines v2 §5):
1554 // GET renders the current (or default) channel list as an editable
1555 // form; POST goes through the same guarded path the API PUT does.
1556 s.registerRoute("GET /a/{sqid}/apps/{app}/pipeline", s.handlePipelineSettings)
1557 s.registerRoute("POST /a/{sqid}/apps/{app}/pipeline", s.handlePipelineSettingsSave)
1558 s.registerRoute("POST /a/{sqid}/apps/{app}/promote", s.handlePromote)
1559 s.registerRoute("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback)
1560 s.registerRoute("POST /a/{sqid}/apps/{app}/production", s.handleProduction)
1561 s.registerRoute("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack)
1562 // The pending-change doors (pendinghandlers.go, release pipelines v2 §4):
1563 // confirm is member-gated with the passkey step-up LAST, reject is the
1564 // proposer's or an owner's and deliberately carries no step-up — refusing
1565 // authority is not exercising it.
1566 s.registerRoute("POST /a/{sqid}/apps/{app}/pending/{id}/confirm", s.handlePendingConfirm)
1567 s.registerRoute("POST /a/{sqid}/apps/{app}/pending/{id}/reject", s.handlePendingReject)
1568 s.registerRoute("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary)
1569 s.registerRoute("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary)
1570 // The app Settings writes (appsettings.go): owner-only, and delete/move
1571 // behind passkey step-up besides.
1572 s.registerRoute("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace)
1573 // The password-gate form (appsettings.go's handleAppGate): member-gated,
1574 // unlike the owner-only writes above it.
1575 s.registerRoute("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)
1576 // The Deploys card's form (appswap.go): member-gated, like the gate.
1577 s.registerRoute("POST /a/{sqid}/apps/{app}/swap", s.handleAppSwap)
1578 s.registerRoute("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
1579 s.registerRoute("POST /a/{sqid}/apps/{app}/keep", s.handleAppKeep)
1580 s.registerRoute("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
1581 s.registerRoute("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
1582 // The control-plane flag's two directions, both browser-only and both
1583 // owner-only. Clearing is passkey-gated, setting is not — the asymmetry
1584 // is spec §6's, and there is deliberately no API sibling for either
1585 // (controlplane.go).
1586 s.registerRoute("POST /a/{sqid}/apps/{app}/control-plane/clear", s.handleAppControlPlaneClear)
1587 s.registerRoute("POST /a/{sqid}/apps/{app}/control-plane/set", s.handleAppControlPlaneSet)
1588 // Custom domain attach/detach (appdomains.go, #82): owner-only, no
1589 // passkey step-up (spec §6) — detach's guard is the typed-host confirm.
1590 s.registerRoute("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach)
1591 s.registerRoute("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach)
1592
1593 // Customer fleets (Task 9): the owner's own remote boxes and what's
1594 // placed on them — member-readable, owner-gated for rotate/detach
1595 // (fleets.go).
1596 s.registerRoute("GET /a/{sqid}/fleets/{fleet}", s.handleFleetPage)
1597 s.registerRoute("POST /a/{sqid}/fleets/{fleet}/rotate", s.handleFleetRotateBoxPage)
1598 s.registerRoute("POST /a/{sqid}/fleets/{fleet}/detach", s.handleFleetDetachBoxPage)
1599 s.registerRoute("POST /a/{sqid}/fleets/{fleet}/tags", s.handleFleetTagBoxPage)
1600 s.registerRoute("POST /a/{sqid}/fleets/{fleet}/place", s.handleFleetPlacePage)
1601 // The object store's destroy door (appsettings.go, issue #55): the account
1602 // owner, the bucket name typed out, and a passkey. It is the only console
1603 // route that destroys a customer's objects outside the app trash, and
1604 // unlike the trash there is no cooloff behind it.
1605 s.registerRoute("POST /a/{sqid}/apps/{app}/store/destroy", s.handleStoreDestroy)
1606 s.registerRoute("POST /a/{sqid}/invite", s.handleInvite)
1607 s.registerRoute("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
1608 // Owner-only account administration, every one of them behind passkey
1609 // step-up as well as ownership and CSRF (ownership.go, spec §7a).
1610 s.registerRoute("POST /a/{sqid}/members/remove", s.handleRemoveMember)
1611 s.registerRoute("POST /a/{sqid}/members/transfer", s.handleTransferOwnership)
1612 s.registerRoute("POST /a/{sqid}/delete", s.handleDeleteAccount)
1613 // The service-credential pane's writes (svcpage.go, issue #220):
1614 // owner-only, and create/rotate behind passkey step-up besides. Revoke
1615 // deliberately is NOT — it is the emergency control, and it must not sit
1616 // behind a round trip that fails closed. See svcpage.go's doc comment.
1617 s.registerRoute("POST /a/{sqid}/services/new", s.handleServiceCreatePage)
1618 s.registerRoute("POST /a/{sqid}/services/rotate", s.handleServiceRotatePage)
1619 s.registerRoute("POST /a/{sqid}/services/revoke", s.handleServiceRevokePage)
1620 s.registerRoute("GET /a/{sqid}/trash", s.handleTrash)
1621 s.registerRoute("POST /a/{sqid}/trash/restore", s.handleRestoreApp)
1622 s.registerRoute("POST /a/{sqid}/trash/delete", s.handlePurgeApp)
1623 // GET only shows the invitation; joining is the POST its button sends
1624 // (discussion #219, item 4).
1625 s.registerRoute("GET /invite/{token}", s.handleInvitePage)
1626 s.registerRoute("POST /invite/{token}", s.handleAcceptInvite)
1627 s.registerRoute("GET /accounts/new", s.handleNewAccountForm)
1628 s.registerRoute("POST /accounts/new", s.handleNewAccount)
1629 // The pre-accounts URLs, kept as redirects so old links and bookmarks
1630 // still land somewhere sensible rather than 404ing.
1631 s.registerRoute("GET /apps", s.handleLegacyApps)
1632 s.registerRoute("GET /apps/{app}", s.handleLegacyApp)
1633 // "ok <build>" — the build comes from THIS PROCESS (ldflags, at link
1634 // time), which is the whole point: X-Carlos-Version is stamped by the
1635 // edge from the route row, so it reports the version adoption INTENDED
1636 // from the moment the symlink moves, whether or not anything restarted.
1637 // `carlos deploy` polls this instead for any app declaring a build probe.
1638 //
1639 // "ok" stays the first token: internal/vet and the edge health probe read
1640 // this endpoint and check status, not body, but keeping the prefix means
1641 // a human or a script eyeballing it sees what it always saw.
1642 s.registerRoute("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
1643 fmt.Fprintln(w, "ok", version.Build)
1644 })
1645 // SES event ingest (sesevents.go, tenant-email spec §9): UNAUTHENTICATED,
1646 // like healthz above — SNS is not a signed-in member, so there is no
1647 // session, no CSRF and no owner check ahead of it. Its whole security
1648 // model lives inside handleSESEvents: a TopicArn allowlist, a
1649 // label-aware SigningCertURL host check, and a verified signature,
1650 // all run before any field of the message is trusted.
1651 s.registerRoute("POST /api/ses/events", s.handleSESEvents)
1652 // Catch up on accounts minted outside this database — `carlos accounts
1653 // create` writes the bucket manifest and the registry row and never opens
1654 // console state. Startup is the natural place: the account switcher, the
1655 // membership gate and every /a/<sqid>/… route resolve through the accounts
1656 // table, so an account the console has not materialized is invisible to
1657 // all of them. Best-effort; a bucket hiccup must not stop the console
1658 // booting.
1659 if cfg.HostClaim {
1660 if err := st.migrateClaimAppIdentity(); err != nil {
1661 return nil, err
1662 }
1663 }
1664 materializeQuietly(context.Background(), cfg.Releases, st, cfg.HostClaim)
1665 // The pre-accounts backfill: operators become owners of ops, every granted
1666 // app name is claimed there, and non-operator grant holders are left out
1667 // and REPORTED — a per-app grant was never authority over ops. Additive and
1668 // idempotent, so it is cheap enough to run on every start. The report is
1669 // logged rather than acted on: only an operator can decide who gets an
1670 // account of their own, but nobody can decide it if nobody is told.
1671 needAccounts, err := st.BackfillOpsMemberships(time.Now())
1672 if err != nil {
1673 return nil, err
1674 }
1675 if advice := grantHolderAdvice(needAccounts); advice != "" {
1676 log.Print(advice)
1677 }
1678 logRegionConfig(cfg)
1679 s.registerClaimWorkers()
1680 if cfg.Releases != nil {
1681 s.geoip = newGeoipPublisher(cfg.Releases, cfg.GeoipStagingDir)
1682 }
1683 return s, nil
1684}
1685
1686// PublishMovesConsole writes control/moves-console.json: the moves
1687// protocol this console speaks and the fingerprint of its
1688// CARLOS_SECRETS_PUBKEY (empty when unset), so a box can check the console
1689// it is moving for (moves Task 13). Only with the moves switch on (index
1690// D13: off publishes nothing). Called once per start, by the console's
1691// main, never by NewServer, so constructing a Server in a test writes
1692// nothing. A later start replaces the record. Best-effort: a failure is
1693// logged and returned, and the console serves regardless.
1694func (s *Server) PublishMovesConsole(ctx context.Context) error {
1695 if !s.cfg.MovesEnabled || s.cfg.Releases == nil {
1696 return nil
1697 }
1698 fp, _ := appconfig.PublicFingerprint(s.cfg.SecretsPub)
1699 body, err := json.Marshal(moves.ConsoleRecord{V: 1, Protocol: moves.ProtocolVersion, SecretsKeyFP: fp, At: time.Now().UTC()})
1700 if err == nil {
1701 err = moves.PutReplace(ctx, s.cfg.Releases, moves.ConsoleKey, body)
1702 }
1703 if err != nil {
1704 log.Printf("console: could not publish %s: %v", moves.ConsoleKey, err)
1705 return err
1706 }
1707 return nil
1708}
1709
1710// StartGeoipJobs runs the orphan sweep and D1 expiry at startup and then
1711// daily (geoip spec §3, D1), until ctx ends. It is the recorded worker
1712// "StartGeoipJobs" (claim_workers.go) and, like every worker, blocks: cmd/console
1713// starts it through RunClaimWorker, never NewServer, so constructing a Server in
1714// a test never starts a goroutine. Every goroutine it starts is joined before it
1715// returns, so a test that bounds it with a context leaves nothing behind. No
1716// publisher (no bucket) means GeoIP is off here and it returns at once.
1717func (s *Server) StartGeoipJobs(ctx context.Context) {
1718 if s.geoip == nil {
1719 return
1720 }
1721 var wg sync.WaitGroup
1722 defer wg.Wait()
1723 if f, ok := newGeoipFeeder(s.cfg, s.geoip); ok {
1724 log.Printf("console: geoip feed %s armed (first check in 5 minutes, then daily)", f.spec.Name)
1725 wg.Add(1)
1726 go func() { defer wg.Done(); f.run(ctx) }()
1727 } else {
1728 // Off the startup path, and bounded: a stalled bucket must not
1729 // hold the console's start (code review R4-1).
1730 wg.Add(1)
1731 go func() { defer wg.Done(); retryClearFeedStatus(ctx, s.cfg.Releases, 30*time.Second, 10*time.Minute) }()
1732 }
1733 for {
1734 s.geoipPass(ctx)
1735 t := time.NewTimer(24*time.Hour + mrand.N(time.Hour))
1736 select {
1737 case <-ctx.Done():
1738 t.Stop()
1739 return
1740 case <-t.C:
1741 }
1742 }
1743}
1744
1745// geoipPass is one synchronous orphan sweep and D1 expiry check. Each is
1746// bounded: these hold the publish slot, and a stalled bucket must not leave
1747// publish and off answering 409 forever.
1748func (s *Server) geoipPass(ctx context.Context) {
1749 sctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
1750 if err := s.geoip.Sweep(sctx); err != nil {
1751 log.Printf("console: geoip sweep: %v", err)
1752 }
1753 cancel()
1754 ectx, cancel := context.WithTimeout(ctx, 2*time.Minute)
1755 if _, err := s.geoip.ExpireIfDue(ectx, time.Now()); err != nil {
1756 log.Printf("console: geoip expiry check: %v", err)
1757 }
1758 cancel()
1759}
1760
1761// ServeHTTP checks the host-based redirect BEFORE the mux on purpose: it's
1762// purely host-based, touches no session/account state, so it sits in front
1763// of the console's security gates without weakening them (the gates
1764// ordering rule concerns account-existence leaks; a host-level redirect
1765// reveals nothing).
1766func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1767 if s.redirects[normalizeHost(r.Host)] {
1768 code := http.StatusMovedPermanently
1769 if r.Method != http.MethodGet && r.Method != http.MethodHead {
1770 code = http.StatusPermanentRedirect // 308 keeps the method
1771 }
1772 http.Redirect(w, r, s.cfg.Origin+redirectTarget(r.URL), code)
1773 return
1774 }
1775 s.mux.ServeHTTP(w, r)
1776}
1777
1778// redirectTarget is the path and query to hang off Origin: the request's own
1779// RequestURI, but only where that is the rooted path a browser sends. A
1780// request target may legally be rootless — `GET mailto:x@evil.com HTTP/1.1`
1781// parses to an opaque URL whose RequestURI is "x@evil.com" — and pasting that
1782// onto the origin spells https://console.example.com@evil.com, an open
1783// redirect wearing the console's own hostname in the userinfo. The console
1784// serves nothing at a rootless URL, so anything unrooted goes to the front
1785// door instead.
1786func redirectTarget(u *url.URL) string {
1787 if uri := u.RequestURI(); strings.HasPrefix(uri, "/") {
1788 return uri
1789 }
1790 return "/"
1791}
1792
1793// normalizeHost reduces a request Host header (or a configured origin/
1794// redirect host) to the bare, lowercase hostname the RedirectHosts
1795// comparison keys on. net.SplitHostPort errors when there's no port to
1796// strip — a bare hostname, or an unbracketed IPv6 literal — and then the
1797// only thing left to remove is the brackets it would have taken off with the
1798// port, so that "[::1]" and "[::1]:8080" key the same.
1799func normalizeHost(host string) string {
1800 host = strings.ToLower(strings.TrimSpace(host))
1801 if h, _, err := net.SplitHostPort(host); err == nil {
1802 return h
1803 }
1804 return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
1805}
1806
1807// ---- pages ------------------------------------------------------------------
1808
1809// page is the one bag every template reads from; unused fields are ignored.
1810type page struct {
1811 RegionPicker *regionPickerView // new-app page: nil = no picker
1812
1813 AccountStore *accountStoreView
1814 StoreQueue *storeQueueView
1815 StorePending *int
1816 StoreQueueUnknown bool
1817
1818 // RegionLine is the Instances tab's app-region note (C7 or C8, spec
1819 // §10); "" shows nothing. RegionLineWarn marks C8.
1820 RegionLine string
1821 RegionLineWarn bool
1822
1823 InstanceDetail *instanceDetail
1824 InstanceGroups []instanceGroup
1825 InstanceChannels []string
1826 InstanceUnknownChannel bool
1827 InstanceListURL string
1828 DeploymentTargets []deploymentTarget
1829
1830 Title string
1831 Address string // signed-in address, "" when signed out
1832 CSRF string // the session token, double-submitted on the signout POST
1833 Error string
1834 Welcome bool // one-shot "account created" banner
1835 BrowserRestartCopy BrowserRestartCopy
1836 StatusCopy StatusCopy
1837
1838 AccountReview AccountReview
1839 MismatchCopy MismatchCopy
1840
1841 ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings"
1842
1843 ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
1844 AppName string // app pages: the {app} segment, for the tab bar and eyebrow
1845
1846 // The signed-out front page and "Check your email" (emailsignin.go).
1847 // Prefill is the address the form starts with; Next the safe local path
1848 // carried as a hidden field; Banner a note above the form (an invite, a
1849 // terminal); NoMail shows home.no_mail; CheckSent is check.sent, filled;
1850 // CodeError a refusal of a typed code. SigninCopy is the approved copy,
1851 // set by render.
1852 Prefill, Next, Banner string
1853 NoMail bool
1854 CheckSent, CodeError string
1855 SigninCopy signinView
1856 // Brand and ConsoleName are the deployment's names (render fills them):
1857 // Brand in the page chrome, ConsoleName in prose ("Welcome to ...").
1858 Brand string
1859 ConsoleName string
1860 // The emailed link's confirm page: the token and the login-CSRF nonce
1861 // its form POSTs; StepUpLink when the link re-confirms a session
1862 // rather than signing in.
1863 LinkToken, LinkNonce string
1864 StepUpLink bool
1865 // ConfirmNote is the team page's note on actions that ask you to
1866 // confirm it's you (team.confirm_note).
1867 ConfirmNote string
1868 // InvitePlaceholder and InviteNote are the team page's invite form
1869 // hints (team.invite_placeholder, team.invite_note).
1870 InvitePlaceholder, InviteNote string
1871
1872 Msg string // generic message page body
1873
1874 Flash string // one-shot success notice (cookie-carried across a redirect)
1875 FlashErr string // one-shot error notice
1876
1877 Apps []appRow // account home
1878 Detail *AppView // /a/{sqid}/apps/{app}
1879 Versions []VersionView // app page: shipped releases, newest first
1880 Tags []release.Tag // releases tab: every tag this app has cut, changelog and all
1881 RetentionKeep int // releases tab: >0 when an ambient retention policy is set (spec 2026-08-10-releases-retention-design.md §7)
1882 // ReleaseSummary is the Releases tab's lede strip: what is live and
1883 // what is next, folded from Detail.Channels once the tab has them.
1884 ReleaseSummary ReleaseSummary
1885 // PromoteChannels is the per-row promote select's option list: the
1886 // ladder rungs (edge, beta, stable) so a fresh app with zero channels
1887 // still has somewhere to promote its first release, unioned with any
1888 // channels (including canary/*) the app already has.
1889 PromoteChannels []string
1890 Instances *InstanceReport // app page: the app's self-report, nil when unpolled
1891 Events []Event // app page: recent console actions, newest first
1892 ActivityCopy activityCopy // app activity: reviewed labels and validation copy
1893 CanPromote bool // a signing key is configured on this host
1894
1895 // Pending is the app page's pending-changes card (release pipelines v2
1896 // §4, pendinghandlers.go): every unexpired, unexecuted change this app
1897 // has, oldest first — a proposed promote, policy edit or fast-track,
1898 // with its quorum progress and the confirm/reject buttons that post to
1899 // Task 8's routes.
1900 Pending []PendingChangeView
1901
1902 // The pipeline settings page (pipelinepage.go), GET/POST
1903 // /a/{sqid}/apps/{app}/pipeline: PipelineRows is the current (or
1904 // mid-edit draft) channel list as editable form rows, indexed ch.<i>.*;
1905 // PipelineIsDefault is whether it's still the untouched one-channel
1906 // default a fresh app is created with, which is when the two starter
1907 // templates (Edge → Production, Full ladder) are worth offering.
1908 PipelineRows []PipelineFormRow
1909 PipelineIsDefault bool
1910 SettingsSection string // app Settings sub-page selected by the URL
1911 SettingsRenameTo string // retained draft after a General form refusal
1912 SettingsDomain string // retained Domains attach draft
1913 SettingsChannel string // retained Domains attach draft
1914 SettingsNav []SettingsSectionLink
1915
1916 // App page: how a canary channel gets a URL, rendered as commands the
1917 // reader can actually run from where they are reading. Computed
1918 // server-side (attachCommands) because the host shape comes from the
1919 // app's instances manifest. AttachEnable is non-empty only when
1920 // instances are PROVEN off, so the copy leads with the enable step.
1921 AttachCmd string
1922 AttachEnable string
1923
1924 // The account chrome every /a/{sqid}/… page carries.
1925 Account *Account // the account being viewed
1926 Role string // the viewer's role in it: owner | member
1927 Base string // "/a/<sqid>" — what every form on the page posts under
1928 Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base
1929 Accounts []Account // the switcher: every account the viewer belongs to
1930 Members []Account // team page: memberships (Name carries the address)
1931 Invites []Invite // team page, owners only: pending invitations
1932 HasApps bool // account settings: an account with apps cannot be deleted
1933
1934 // The Trash (ownership.go): the account's deleted-but-restorable apps on
1935 // /a/{sqid}/trash, and the count the account home links with. Zero hides
1936 // the link entirely — an empty trash is not a place worth sending anyone.
1937 Trashed []TrashedAppView
1938 TrashN int
1939
1940 // ExpiresAt is when a temporary app moves to the Trash; zero for a
1941 // permanent one. appnav shows it with the Keep button.
1942 ExpiresAt time.Time
1943
1944 // The picker: /a/{seg}/ where seg matches more than one of the viewer's
1945 // accounts, as a name, a sqid, or one of each.
1946 PickerSeg string // the ambiguous segment the picker is disambiguating
1947 PickerAccounts []Account // every account of the viewer's that segment matches
1948
1949 Fleet *status.Fleet // /fleet: nil means no snapshot published yet
1950
1951 // /fleet, below the published boxes table: the per-host PRIVATE detail
1952 // read straight out of status/boxes/ (HostRows, view.go). Raw numbers
1953 // render here and nowhere public — this page is session-gated to
1954 // operators, fleet.json is not. Nil renders no section at all: no boxes
1955 // yet, no bucket handle, or a boxes read that failed are all "no detail
1956 // to show", never a reason to fail the page.
1957 Hosts []HostRow
1958
1959 // /fleet/metrics: the operator dashboard (Task 11), both from LoadMetrics
1960 // (metricsview.go) and both nil-safe to render — no boxes and no firing
1961 // alerts are the normal, healthy, empty state, not an error.
1962 Metrics []BoxMetricsView
1963 MetricAlerts []AlertRow
1964
1965 // /fleet/architecture (archgraph.go): the deployment's map. Arch is nil
1966 // on every other page, and the template's {{with .Arch}} is what keeps
1967 // this page's blocks off them.
1968 //
1969 // There is no pre-rendered SVG here: an estate is drawn as one picture
1970 // per region band and the number of bands is a property of the estate,
1971 // so the template ranges over Arch.CARLOS.Bands / Arch.Rest.Bands and
1972 // each band renders itself.
1973 //
1974 // ArchBoxesUnread is why the box list is empty: an empty Boxes with a
1975 // failed fleet/host read behind it must say "could not be read", never
1976 // "no box has reported". The distinction cannot be made from Boxes
1977 // alone — both cases are len 0 — so the handler carries it here.
1978 Arch *ArchView
1979 ArchBoxesUnread bool
1980
1981 // /fleet/economics: the operator unit-economics dashboard (Task 13),
1982 // from LoadEconomics (economicsview.go). nil only on a rendering path
1983 // this handler never takes (handleEconomics always loads one before
1984 // rendering) — every field on it is itself nil-safe, matching Metrics
1985 // above.
1986 Economics *EconomicsView
1987
1988 // /fleet/economics/accounts: the per-account table (Task 14) —
1989 // EconAccounts is the computed page (rows, counts, links, pager),
1990 // EconQuery is the parsed request so the search box and filter links
1991 // re-render what the viewer asked for. Economics (above) is ALSO set on
1992 // this page: the template embeds the same econ-data payload so the
1993 // shared knob drawer recomputes this page's rows too.
1994 EconAccounts econAccountsPage
1995 EconQuery econAccountQuery
1996
1997 // App page, "Resources": this app's OWN instances' unit readings (Task
1998 // 12), one row per route host. Nil renders no section at all — metrics
1999 // are decoration on this page and must never be the reason it fails.
2000 AppMetrics []InstanceMetrics
2001
2002 // App Overview: the dashboard's vital statistics (overview.go), derived
2003 // from reads the page does anyway.
2004 Overview OverviewStats
2005
2006 // App Overview: the same reads drawn as the pipeline they describe
2007 // (flow.go). Zero-valued (Show false) renders nothing at all.
2008 Flow FlowView
2009
2010 // App Instances: the one table — routes joined with their readings.
2011 InstanceRows []InstanceRow
2012 CanRestartApp bool
2013
2014 // App Instances: the search/health-filter/cursor controls (Task 8) —
2015 // InstancePage is the computed page (rows, counts, links, pager),
2016 // InstanceQuery is the parsed request so the form and links re-render
2017 // what the viewer asked for.
2018 InstancePage instancePage
2019 InstanceQuery instanceQuery
2020
2021 // App Logs (apilogs.go's sibling page): the merged timeline plus the
2022 // filter state the form re-renders with. Empty LogRows with LogsErr ""
2023 // is the normal nothing-logged-yet state, never an error.
2024 LogRows []applog.Record
2025 LogStream string
2026 LogSince string
2027 LogHost string
2028 LogLevel string
2029 LogGrep string
2030 LogLegacyGrep string
2031 LogFollow bool
2032 LogHosts []string
2033 LogCopy appLogsCopy
2034 LogWidenURL string
2035 LogCursor string
2036 LogTruncated bool
2037 LogsErr string
2038
2039 // App Errors (errorsview.go's page, apierrors.go's sibling): the
2040 // assembled window plus the filter state the form re-renders with. Empty
2041 // ErrWin with ErrsErr "" is the normal nothing-failed-yet state, never an
2042 // error.
2043 ErrWin errorsWindow
2044 ErrSince string // "1h" | "24h" | "168h", or any duration a URL named
2045 ErrSource string
2046 ErrLevel string // "" = the setting
2047 ErrHost string
2048 ErrFP string // expanded group
2049 ErrsErr string
2050 // ErrLevelSetting is the resolved log level for the two SETTINGS pages
2051 // (app and account), which is where the level is changed rather than
2052 // read past. Zero everywhere else.
2053 //
2054 // ErrLevelUnread says the resolve FAILED, so the zero Resolved beside it
2055 // must not be shown as if the defaults were the setting: a form
2056 // pre-selecting "error" over a level that is really "warn" invites an
2057 // owner to save a change they did not mean to make. The section then
2058 // says it could not be read, and offers no form at all.
2059 ErrLevelSetting telemetry.Resolved
2060 ErrLevelUnread bool
2061
2062 // AnalyticsSetting is the same resolved object, under the name the
2063 // Analytics section of those two pages reads it by — telemetry.Resolve
2064 // returns both halves at once, so the pages fill these from the ONE call
2065 // they already make. AnalyticsUnread carries the same refusal to show
2066 // defaults as if they were the setting.
2067 AnalyticsSetting telemetry.Resolved
2068 AnalyticsUnread bool
2069
2070 // SwapPolicy is the app's swap policy for the settings page's Deploys
2071 // card (zero-downtime spec §4.1, §9.2). SwapUnread says the read failed,
2072 // and the card then offers no form, for ErrLevelUnread's reason.
2073 SwapPolicy appconfig.SwapPolicy
2074 SwapUnread bool
2075
2076 // App Analytics (analyticsview.go's tab, apianalytics.go's sibling): the
2077 // assembled window plus the range the form re-renders with. A zero
2078 // AnalyticsWin with AnalyticsErr "" is the normal nothing-counted-yet
2079 // state, never an error — and the window carries its own resolved
2080 // setting, because the tab has to say whether counting is on.
2081 AnalyticsWin analyticsWindow
2082 AnalyticsRange string // "today" | "7d" | "30d"
2083 AnalyticsErr string
2084
2085 // Action confirm interstitial (confirm.go): what is about to happen,
2086 // carried fields and all. Nil everywhere except the confirm page.
2087 Confirm *ConfirmView
2088
2089 // App Settings (appsettings.go): what the tab knows beyond the AppView.
2090 Settings *SettingsView
2091
2092 // /a/{sqid}/fleets/{fleet} (Task 9): the owner's own remote boxes,
2093 // joined to their heartbeats, and the account's apps placed on this
2094 // fleet. FleetDetail carries only Name/Account/Bucket — registry.Fleet
2095 // has no TokenHash on it at all (that lives on FleetBox, never echoed
2096 // here — see fleets.go's package doc comment), so putting it straight
2097 // on the page is safe.
2098 FleetDetail *registry.Fleet
2099 FleetBoxes []fleetPageBox
2100 FleetApps []fleetPagePlacedApp
2101 // FleetPlaceableApps is the fleet page's "Place an app here" form: the
2102 // account's apps that are not already placed on THIS fleet
2103 // (fleetPlaceableApps). Empty means the form renders nothing to choose,
2104 // so the form itself is withheld — never an empty select.
2105 FleetPlaceableApps []string
2106
2107 // App Instances tab, "Placement" (Task 6): where this app runs
2108 // ("fleet/<name>", or "" for the shared fleet), and the account's own
2109 // fleets as the owner's move options. AppPlacementKnown false means the
2110 // read FAILED — the section then says so instead of claiming the shared
2111 // fleet, and the move form is withheld rather than pre-selecting a guess.
2112 AppPlacement string
2113 AppPlacementKnown bool
2114 OwnedFleetNames []string
2115
2116 // /a/{sqid}/fleet (accountfleet.go): the account-level Fleet TAB, which
2117 // is neither of the two pages above — the shared platform fleet as this
2118 // account sees it (built from the PUBLIC status/fleet.json aggregate,
2119 // never the raw per-box snapshots) and an index of the account's own
2120 // fleets. Set only by handleAccountFleetTab; every other page leaves
2121 // SharedFleet nil and renders no such section.
2122 // FleetsKnown says the AccountFleets read SUCCEEDED, so an empty slice
2123 // may be rendered as "no fleets yet" rather than "unknown" — the twin of
2124 // sharedFleetCard.PlacementKnown, and load-bearing for the same reason:
2125 // nil is otherwise both answers at once.
2126 SharedFleet *sharedFleetCard
2127 AccountFleets []accountFleetCard
2128 FleetsKnown bool
2129 // RotatedLabel/RotatedToken are set only on the response to a rotate
2130 // POST, to show the fresh bearer token exactly once — never stored,
2131 // never re-derivable from a later view of this same page.
2132 RotatedLabel string
2133 RotatedToken string
2134
2135 // The service-credential pane on /a/{sqid}/settings (svcpage.go, issue
2136 // #220). Filled ONLY for an owner, which is why the pane needs no new
2137 // template condition — it sits inside the {{if .IsOwner}} block that
2138 // already exists. A member therefore sees the page WITHOUT the section
2139 // rather than with an empty one: an empty section reads as "this account
2140 // has no service credentials", a different and possibly false statement.
2141 //
2142 // Services carries no secret and never can: only a credential's sha256 is
2143 // stored, so there is nothing in a listed row to leak. ServicesUnknown
2144 // says the listing READ FAILED, so the pane can say so instead of
2145 // rendering an empty table as "none" — nil is otherwise both answers at
2146 // once.
2147 Services []CLIToken
2148 ServicesUnknown bool
2149 ServiceRoles []string // the mint form's role choices, from svcrole.All
2150 ServiceRungs []string // the mint form's rung choices, deploy only
2151 ServiceApps []string // the mint form's scope choices: this account's apps
2152 // ServiceSecret is the once-only block, set ONLY on the response to the
2153 // create or rotate POST that produced it — never redirected to, never
2154 // flashed (a flash is a cookie, and a credential does not belong in one).
2155 ServiceSecret *ServiceSecretView
2156}
2157
2158type SettingsSectionLink struct {
2159 Key, Label, Href string
2160 Current bool
2161}
2162
2163type appLogsCopy struct {
2164 Instance, AllInstances, Search, AllLevels, Follow, Pause, Widen, FollowError, Refreshed string
2165}
2166
2167// IsOwner is the template's one permission question.
2168func (p page) IsOwner() bool { return p.Role == RoleOwner }
2169
2170// appRow is one line of the "your apps" table: the app, where it's running,
2171// whether anything the REGISTRY tracks needs attention, and what its owned
2172// routes report serving.
2173// The verdict here is registry-scope by design — process liveness only
2174// exists in the per-app metrics sweep, which is too heavy to run across a
2175// whole account's list. So the healthy word is "Deployed", never "All
2176// good": the list claims exactly what it knows, and the app's Overview
2177// (which has the sweep) gives the full verdict. Publishing per-app liveness
2178// into the aggregate feed so this list can say more is a filed follow-up.
2179type appRow struct {
2180 App string
2181 URL string // apex host, "" when no routes
2182 Verdict string // ok | attention | notdeployed | unknown
2183 VerdictLabel string // Deployed | Needs attention | Not deployed yet | Can't tell right now
2184 Deployment DeploymentStatus
2185 CanaryN int // count of canary/* channels
2186}
2187
2188func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
2189 p.StatusCopy = approvedStatusCopy
2190 p.BrowserRestartCopy = approvedBrowserRestartCopy
2191 p.MismatchCopy = approvedMismatchCopy
2192 p.SigninCopy = approvedSigninView
2193 s.chrome(&p)
2194 w.Header().Set("Content-Type", "text/html; charset=utf-8")
2195 w.WriteHeader(status)
2196 if err := templates.ExecuteTemplate(w, name, p); err != nil {
2197 log.Printf("console: render %s: %v", name, err)
2198 }
2199}
2200
2201func (s *Server) renderMessage(w http.ResponseWriter, r *http.Request, status int, title, msg string) {
2202 p := s.basePage(r)
2203 p.Title = title
2204 p.Msg = msg
2205 s.render(w, status, "message", p)
2206}
2207
2208// basePage fills the fields the layout chrome needs (signed-in address and
2209// the signout CSRF token).
2210func (s *Server) basePage(r *http.Request) page {
2211 addr, raw, ok := s.session(r)
2212 if !ok {
2213 return page{}
2214 }
2215 return page{Address: addr, CSRF: raw}
2216}
2217
2218// ---- session plumbing -------------------------------------------------------
2219
2220func (s *Server) session(r *http.Request) (address, rawToken string, ok bool) {
2221 c, err := r.Cookie(sessionCookie)
2222 if err != nil || c.Value == "" {
2223 return "", "", false
2224 }
2225 addr, ok := s.store.SessionAddress(c.Value, time.Now())
2226 if !ok {
2227 return "", "", false
2228 }
2229 return addr, c.Value, true
2230}
2231
2232func (s *Server) setCookie(w http.ResponseWriter, name, value string, maxAge int) {
2233 http.SetCookie(w, &http.Cookie{
2234 Name: name, Value: value, Path: "/",
2235 MaxAge: maxAge, HttpOnly: true, Secure: s.secure,
2236 SameSite: http.SameSiteLaxMode,
2237 })
2238}
2239
2240func (s *Server) clearCookie(w http.ResponseWriter, name string) {
2241 s.setCookie(w, name, "", -1)
2242}
2243
2244// ---- flash notices ----------------------------------------------------------
2245
2246const flashCookie = "carlos_console_flash"
2247
2248// setFlash parks a one-shot notice ("ok" or "err") in a cookie for the page
2249// after the redirect — no query-string flashes an outsider could spoof into
2250// a link.
2251func (s *Server) setFlash(w http.ResponseWriter, kind, msg string) {
2252 s.setCookie(w, flashCookie, url.QueryEscape(kind+"|"+msg), 300)
2253}
2254
2255// takeFlash reads and clears the notice.
2256func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) (flash, flashErr string) {
2257 c, err := r.Cookie(flashCookie)
2258 if err != nil || c.Value == "" {
2259 return "", ""
2260 }
2261 s.clearCookie(w, flashCookie)
2262 v, err := url.QueryUnescape(c.Value)
2263 if err != nil {
2264 return "", ""
2265 }
2266 kind, msg, _ := strings.Cut(v, "|")
2267 if kind == "err" {
2268 return "", msg
2269 }
2270 return msg, ""
2271}
2272
2273// ---- auth handlers ----------------------------------------------------------
2274
2275func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
2276 if addr, _, ok := s.session(r); ok {
2277 s.redirectToCurrentAccount(w, r, addr, http.StatusFound)
2278 return
2279 }
2280 s.renderHome(w, r, homeForm{Next: r.URL.Query().Get("next")})
2281}
2282
2283// redirectToCurrentAccount sends a signed-in user to the account they were
2284// last in — else their first membership by creation order, else a personal
2285// account minted on the spot. Nobody signed in ever has nowhere to land.
2286func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request, addr string, code int) {
2287 sqid, err := s.currentAccount(r.Context(), addr)
2288 if err != nil {
2289 log.Printf("console: landing for %s: %v", addr, err)
2290 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not open your account. Please try again.")
2291 return
2292 }
2293 seg := sqid
2294 if accts, err := s.store.AccountsFor(addr); err == nil {
2295 for i := range accts {
2296 if accts[i].Sqid == sqid {
2297 seg = preferredSeg(accts, &accts[i])
2298 break
2299 }
2300 }
2301 }
2302 http.Redirect(w, r, "/a/"+url.PathEscape(seg)+"/", code)
2303}
2304
2305// currentAccount resolves the three landing cases in order: MRU history (only
2306// if still a member — an account you were removed from must not strand you),
2307// first membership, or mint.
2308func (s *Server) currentAccount(ctx context.Context, addr string) (string, error) {
2309 a, err := s.memberAccount(addr)
2310 if err != nil {
2311 return "", err
2312 }
2313 if a != nil {
2314 return a.Sqid, nil
2315 }
2316 m, err := s.mintAccount(ctx, addr, addr)
2317 if err != nil {
2318 return "", err
2319 }
2320 return m.Sqid, nil
2321}
2322
2323// memberAccount is currentAccount's first two cases WITHOUT the mint: the MRU
2324// account if the viewer is still a member of it, else their first membership
2325// by creation order, else nil. Split out because pages that merely want the
2326// header's account context — /accounts/new — must not create an account as a
2327// side effect of being looked at. Only landing mints.
2328func (s *Server) memberAccount(addr string) (*Account, error) {
2329 if id, err := s.store.LastAccount(addr); err != nil {
2330 return nil, err
2331 } else if id != 0 {
2332 if role, err := s.store.Role(id, addr); err != nil {
2333 return nil, err
2334 } else if role != "" {
2335 a, err := s.store.AccountByID(id)
2336 if err != nil {
2337 return nil, err
2338 }
2339 if a != nil {
2340 a.Role = role
2341 return a, nil
2342 }
2343 }
2344 }
2345 accts, err := s.store.AccountsFor(addr)
2346 if err != nil {
2347 return nil, err
2348 }
2349 if len(accts) > 0 {
2350 return &accts[0], nil
2351 }
2352 return nil, nil
2353}
2354
2355// mintAccount creates an account owned by addr: the next free id allocated in
2356// the bucket, its frozen sqid, and the console DB rows.
2357//
2358// The bucket write comes FIRST and is no longer best-effort, because it is now
2359// the allocator: the id is claimed by a create-only manifest write, which is
2360// what stops two boxes minting the same integer at the same moment. A console
2361// row whose manifest never landed would be an account that vanishes on the
2362// next rebuild — worse than a "new account" button that reports the outage.
2363//
2364// The name is NOT validated here: auto-mint names personal accounts by
2365// keymail address, and landing must never refuse to mint (spec §1).
2366func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Account, error) {
2367 now := time.Now()
2368 id, sqid, err := domains.MintAccount(ctx, s.cfg.Releases, name, ownerAddr, now)
2369 if err != nil {
2370 return nil, err
2371 }
2372 if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil {
2373 // The manifest already landed, so the id is claimed and there is now an
2374 // account in the bucket with no console row and no owner. It is
2375 // unreachable rather than dangerous (no membership means no access, and
2376 // materialization gives it no members either), and the next mint simply
2377 // takes the id after it. But without this line the only trace is a 500
2378 // in the user's browser: name the sqid so the stranded account can be
2379 // found and either adopted with `console member -add … -role owner` or
2380 // deleted from the bucket.
2381 log.Printf("console: minted account %s (id %d, %q) into the bucket but could not record it "+
2382 "locally: %v — accounts/%s/account.json is stranded with no owner", sqid, id, name, err, sqid)
2383 return nil, err
2384 }
2385 if err := s.store.SetLastAccount(ownerAddr, id); err != nil {
2386 log.Printf("console: set last account for %s: %v", ownerAddr, err)
2387 }
2388 return &Account{ID: id, Sqid: sqid, Name: name, Role: RoleOwner, CreatedAt: now}, nil
2389}
2390
2391// account is the gate on every /a/{seg}/… route: a session, and a segment
2392// that resolves — as a sqid OR a name, symmetrically — to an account the
2393// viewer belongs to. Resolution and membership proof are deliberately the
2394// same lookup (the membership set IS the resolution scope), so an unknown
2395// account, someone else's account, and a name that resolves to nothing all
2396// answer with the BYTE-IDENTICAL http.NotFound an unrouted URL gets —
2397// account existence is not probeable, matching /fleet's operator gate.
2398// Several matches (a shared name, or a name colliding with a sqid) render
2399// the picker on GET and the same 404 otherwise — forms always post to sqid
2400// paths, so an ambiguous mutation has no legitimate author.
2401//
2402// Signed-out users are sent to the front page instead, which reveals nothing
2403// either way because it happens for every segment, real or not.
2404func (s *Server) account(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, ok bool) {
2405 addr, raw, sok := s.session(r)
2406 if !sok {
2407 http.Redirect(w, r, "/", http.StatusFound)
2408 return "", "", nil, "", false
2409 }
2410 accts, err := s.store.AccountsFor(addr)
2411 if err != nil {
2412 log.Printf("console: account lookup: %v", err)
2413 http.NotFound(w, r)
2414 return "", "", nil, "", false
2415 }
2416 seg := r.PathValue("sqid")
2417 matches := matchAccounts(accts, seg)
2418 switch len(matches) {
2419 case 1:
2420 a := matches[0]
2421 return addr, raw, a, a.Role, true
2422 case 0:
2423 http.NotFound(w, r)
2424 return "", "", nil, "", false
2425 default:
2426 if r.Method != http.MethodGet {
2427 http.NotFound(w, r)
2428 return "", "", nil, "", false
2429 }
2430 p := s.basePage(r)
2431 p.Title = "Which " + seg + "?"
2432 p.PickerSeg = seg
2433 for _, m := range matches {
2434 p.PickerAccounts = append(p.PickerAccounts, *m)
2435 }
2436 s.render(w, http.StatusOK, "picker", p)
2437 return "", "", nil, "", false
2438 }
2439}
2440
2441// owner is account plus the owner-only gate. A member who probes an
2442// owner-only action gets the same 404 a non-member does: the difference
2443// between "not yours" and "not allowed" is not information this hands out.
2444func (s *Server) owner(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, ok bool) {
2445 addr, raw, acct, role, ok := s.account(w, r)
2446 if !ok {
2447 return "", "", nil, false
2448 }
2449 if role != RoleOwner {
2450 http.NotFound(w, r)
2451 return "", "", nil, false
2452 }
2453 return addr, raw, acct, true
2454}
2455
2456// handleLegacyApps / handleLegacyApp keep the pre-accounts URLs alive as
2457// redirects into the current account.
2458func (s *Server) handleLegacyApps(w http.ResponseWriter, r *http.Request) {
2459 addr, _, ok := s.session(r)
2460 if !ok {
2461 http.Redirect(w, r, "/", http.StatusFound)
2462 return
2463 }
2464 s.redirectToCurrentAccount(w, r, addr, http.StatusFound)
2465}
2466
2467func (s *Server) handleLegacyApp(w http.ResponseWriter, r *http.Request) {
2468 addr, _, ok := s.session(r)
2469 if !ok {
2470 http.Redirect(w, r, "/", http.StatusFound)
2471 return
2472 }
2473 sqid, err := s.currentAccount(r.Context(), addr)
2474 if err != nil {
2475 http.NotFound(w, r)
2476 return
2477 }
2478 http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/apps/"+url.PathEscape(r.PathValue("app")), http.StatusFound)
2479}
2480
2481// handleNewAccountForm is the "New account" screen: its own gated page at its
2482// own URL, linked from the switcher, rather than a text box wedged into the
2483// header. It takes no account parameter of any kind — there is nothing here
2484// to point at another account, so it cannot become a way to probe one — and
2485// it deliberately does NOT record an MRU: you are not "in" an account here,
2486// and moving the pointer would send the next sign-in somewhere never chosen.
2487func (s *Server) handleNewAccountForm(w http.ResponseWriter, r *http.Request) {
2488 addr, raw, ok := s.session(r)
2489 if !ok {
2490 http.Redirect(w, r, "/", http.StatusFound)
2491 return
2492 }
2493 p := page{Title: "New account", Address: addr, CSRF: raw}
2494 p.Flash, p.FlashErr = s.takeFlash(w, r)
2495 if accts, err := s.store.AccountsFor(addr); err != nil {
2496 log.Printf("console: switcher: %v", err)
2497 } else {
2498 p.Accounts = accts
2499 }
2500 for i := range p.Accounts {
2501 p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i])
2502 }
2503 // The header chrome wants an account to name in the switcher's trigger.
2504 // memberAccount, not currentAccount: looking at this page must never mint.
2505 // A user with no memberships at all simply gets the wordmark header.
2506 if a, err := s.memberAccount(addr); err != nil {
2507 log.Printf("console: new-account chrome: %v", err)
2508 } else if a != nil {
2509 p.Account = a
2510 p.Role = a.Role
2511 p.Base = "/a/" + url.PathEscape(a.Sqid)
2512 if len(p.Accounts) == 0 {
2513 p.Nav = p.Base
2514 } else {
2515 p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, a))
2516 }
2517 }
2518 s.render(w, http.StatusOK, "newaccount", p)
2519}
2520
2521// handleNewAccount creates an account for any signed-in user.
2522func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
2523 addr, raw, ok := s.session(r)
2524 if !ok {
2525 http.Redirect(w, r, "/", http.StatusFound)
2526 return
2527 }
2528 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2529 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2530 return
2531 }
2532 name := strings.TrimSpace(r.FormValue("name"))
2533 if !domains.ValidAccountName(name) {
2534 // Back to the screen the mistake was made on, not off to some other
2535 // account: the form is a page now, and a page can show its own error.
2536 s.setFlash(w, "err", "Account names appear in URLs: 1–63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen.")
2537 http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
2538 return
2539 }
2540 // Plans 3a: one free account per person, checked at creation only (spec
2541 // §8.1). A person's first account, minted on landing, is never refused.
2542 defer s.planPersonLock(addr)()
2543 if err := s.checkNewFreeAccount(addr); err != nil {
2544 var planCap *planCapError
2545 if errors.As(err, &planCap) {
2546 s.setFlash(w, "err", planCap.Browser())
2547 http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
2548 return
2549 }
2550 log.Printf("console: create account for %s: plan cap: %v", addr, err)
2551 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.")
2552 return
2553 }
2554 a, err := s.mintAccount(r.Context(), name, addr)
2555 if err != nil {
2556 log.Printf("console: create account for %s: %v", addr, err)
2557 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.")
2558 return
2559 }
2560 s.setFlash(w, "ok", "Created account "+name+" — you're its owner.")
2561 http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther)
2562}
2563
2564// handleInvite creates a single-use invitation and delivers its link:
2565// Keymail federation first (into the invitee's main inbox, as the console
2566// itself, no grant involved), then the console's own email, then the link
2567// shown to the owner to pass on. The invite row exists whichever way it
2568// went, so a failed delivery is a flash, not a rollback.
2569func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
2570 addr, raw, acct, ok := s.owner(w, r)
2571 if !ok {
2572 return
2573 }
2574 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2575 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2576 return
2577 }
2578 back := func(kind, msg string) {
2579 s.setFlash(w, kind, msg)
2580 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
2581 }
2582 to := strings.ToLower(strings.TrimSpace(r.FormValue("address")))
2583 // ValidAddress alone lets through "a@x.com,b@y.com" and "x<v@y.com>",
2584 // which an email To header reads as two recipients or a display name.
2585 // One bare address only.
2586 if parsed, err := netmail.ParseAddress(to); !ValidAddress(to) || err != nil || parsed.Address != to {
2587 back("err", copyText("team.bad_address"))
2588 return
2589 }
2590 if role, err := s.store.Role(acct.ID, to); err != nil {
2591 back("err", "Could not check that address. Please try again.")
2592 return
2593 } else if role != "" {
2594 back("err", to+" is already a member of this account.")
2595 return
2596 }
2597 // Volume limits (each invite is a send as the platform): first the
2598 // account's waiting invites, then its last hour. Both refuse before a row
2599 // exists or any delivery is tried.
2600 if n, err := s.store.CountPendingInvites(acct.ID, time.Now()); err != nil {
2601 log.Printf("console: count pending invites: %v", err)
2602 back("err", "Could not create the invitation. Please try again.")
2603 return
2604 } else if n >= maxPendingInvites {
2605 back("err", inviteFlashTooManyPending())
2606 return
2607 }
2608 if !s.inviteRate.take(acct.ID) {
2609 back("err", inviteFlashTooManyPerHour())
2610 return
2611 }
2612 tok, err := s.store.CreateInvite(acct.ID, to, addr, time.Now())
2613 if err != nil {
2614 s.inviteRate.giveBack(acct.ID)
2615 log.Printf("console: create invite: %v", err)
2616 back("err", "Could not create the invitation. Please try again.")
2617 return
2618 }
2619 link := s.cfg.Origin + "/invite/" + url.PathEscape(tok)
2620 subject, body := inviteMessage(addr, acct.Name, to, link, s.consoleName())
2621 ctx, cancel := context.WithTimeout(r.Context(), s.inviteTimeout)
2622 defer cancel()
2623 switch ch, err := s.deliver(ctx, to, subject, body, s.inviteKeymailShare); {
2624 case err != nil:
2625 log.Printf("console: invite to %s not delivered: %v", to, err)
2626 back("err", inviteFlashFailed(to, link))
2627 case ch == deliveredKeymail:
2628 back("ok", inviteFlashKeymail(to))
2629 default:
2630 back("ok", inviteFlashEmail(to))
2631 }
2632}
2633
2634// inviteMessage is the subject and body for an invitation, whichever channel
2635// carries it (Keymail federation or the console's own email).
2636func inviteMessage(inviter, account, to, link, console string) (subject, body string) {
2637 return "You've been invited to an account on " + console,
2638 copyText("email.invite.body", "inviter", inviter, "account", account,
2639 "console", console, "to", to, "link", link)
2640}
2641
2642func inviteFlashKeymail(to string) string {
2643 return "Invited " + to + ". The link is in their Keymail inbox."
2644}
2645func inviteFlashEmail(to string) string { return "Invited " + to + ". We emailed them the link." }
2646func inviteFlashFailed(to, link string) string {
2647 return "Invited " + to + ", but we couldn't deliver the link. Send it to them yourself: " + link
2648}
2649
2650// The two volume refusals. DRAFTS under copy review: the approved text goes
2651// here and in TestInviteCopyHelpers.
2652func inviteFlashTooManyPending() string {
2653 return "This account already has 20 invites waiting. Revoke some before you send more."
2654}
2655func inviteFlashTooManyPerHour() string {
2656 return "This account has sent 20 invites in the last hour. Try again later."
2657}
2658
2659// Invite volume limits. Every invite is a message sent as the platform, to
2660// an address of the owner's choosing, from an account anyone signed in can
2661// create.
2662const (
2663 maxPendingInvites = 20
2664 maxInvitesPerHour = 20
2665)
2666
2667// inviteWindow is a per-account sliding hour of invite creations. The zero
2668// value is ready to use.
2669type inviteWindow struct {
2670 mu sync.Mutex
2671 now func() time.Time // nil: time.Now
2672 sent map[int64][]time.Time
2673}
2674
2675// take records one invite for acct and reports true, or reports false
2676// without recording when acct already has maxInvitesPerHour in the last hour.
2677// Check and record are one step, so concurrent invites cannot both slip
2678// under the limit.
2679func (w *inviteWindow) take(acct int64) bool {
2680 w.mu.Lock()
2681 defer w.mu.Unlock()
2682 now := time.Now()
2683 if w.now != nil {
2684 now = w.now()
2685 }
2686 cutoff := now.Add(-time.Hour)
2687 kept := w.sent[acct][:0]
2688 for _, t := range w.sent[acct] {
2689 if t.After(cutoff) {
2690 kept = append(kept, t)
2691 }
2692 }
2693 if len(kept) >= maxInvitesPerHour {
2694 w.sent[acct] = kept
2695 return false
2696 }
2697 if w.sent == nil {
2698 w.sent = map[int64][]time.Time{}
2699 }
2700 w.sent[acct] = append(kept, now)
2701 return true
2702}
2703
2704// giveBack undoes the latest take for acct: the row it was for was never
2705// created.
2706func (w *inviteWindow) giveBack(acct int64) {
2707 w.mu.Lock()
2708 defer w.mu.Unlock()
2709 if ts := w.sent[acct]; len(ts) > 0 {
2710 w.sent[acct] = ts[:len(ts)-1]
2711 }
2712}
2713
2714func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
2715 _, raw, acct, ok := s.owner(w, r)
2716 if !ok {
2717 return
2718 }
2719 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2720 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2721 return
2722 }
2723 tok := r.FormValue("token")
2724 // Scoped to THIS account: an owner of one account must not be able to
2725 // revoke another account's invite by pasting its token.
2726 pending, err := s.store.PendingInvites(acct.ID)
2727 if err != nil {
2728 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the invitations.")
2729 return
2730 }
2731 for _, inv := range pending {
2732 if inv.Token == tok {
2733 if err := s.store.RevokeInvite(tok); err != nil {
2734 log.Printf("console: revoke invite: %v", err)
2735 } else {
2736 s.setFlash(w, "ok", "Revoked the invitation to "+inv.Address+".")
2737 }
2738 break
2739 }
2740 }
2741 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
2742}
2743
2744// invitationGone is every refusal of an invite link, on the page and on the
2745// accept alike.
2746func (s *Server) invitationGone(w http.ResponseWriter, r *http.Request) {
2747 s.renderMessage(w, r, http.StatusGone, "Invitation unavailable",
2748 "That invitation has expired, was already used, or was sent to a different address. Ask for a fresh one.")
2749}
2750
2751// handleInvitePage is where an invite link lands: it says which account the
2752// invitation is for and offers a button that joins it. It changes nothing.
2753// Joining used to happen right here, on the GET, which made any page able to
2754// add a signed-in person to the inviter's account and switch them into it
2755// with a link or a redirect: SameSite=Lax sends the session cookie on a
2756// top-level navigation (discussion #219, item 4). Links already sent keep
2757// working; they land on this page.
2758//
2759// It requires a session as the invited address: the link reaches one
2760// address and is worthless to anyone else. Signed out, it is the front page
2761// with the invited address filled in and next set back to this link, so
2762// either sign-in door returns here (spec 8). An unknown or spent token gets
2763// the plain front page.
2764func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
2765 token := r.PathValue("token")
2766 addr, raw, ok := s.session(r)
2767 if !ok {
2768 invited, found := s.store.PendingInviteAddress(token, time.Now())
2769 if !found {
2770 s.renderHome(w, r, homeForm{})
2771 return
2772 }
2773 s.renderHome(w, r, homeForm{
2774 Prefill: invited,
2775 Next: "/invite/" + url.PathEscape(token),
2776 Banner: copyText("home.invite_prompt", "address", invited),
2777 })
2778 return
2779 }
2780 inv, err := s.store.PendingInviteFor(token, addr, time.Now())
2781 if err != nil {
2782 s.invitationGone(w, r)
2783 return
2784 }
2785 a, err := s.store.AccountByID(inv.AccountID)
2786 if err != nil || a == nil {
2787 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the invitation. Try again.")
2788 return
2789 }
2790 p := s.basePage(r)
2791 p.Title = "Join " + a.Name + "?"
2792 p.AppName = a.Name
2793 p.Confirm = &ConfirmView{
2794 Title: "Join " + a.Name + "?",
2795 Lines: []string{inv.InvitedBy + " invited you to join " + a.Name + " as a member."},
2796 Action: "/invite/" + url.PathEscape(token),
2797 Fields: [][2]string{{"csrf", raw}},
2798 Button: "Join " + a.Name,
2799 CancelURL: "/",
2800 }
2801 s.render(w, http.StatusOK, "confirm_action", p)
2802}
2803
2804// handleAcceptInvite is the confirm page's button: it consumes the invite and
2805// makes the account the person's current one. Session, then CSRF, then the
2806// invite's own gates (AcceptInvite: unused, addressed to this session,
2807// unexpired), which play the owner check's part. No passkey step: joining
2808// gives the joiner nothing over anybody else, and it is their own choice.
2809func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
2810 addr, raw, ok := s.session(r)
2811 if !ok {
2812 http.Redirect(w, r, "/", http.StatusFound)
2813 return
2814 }
2815 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2816 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2817 return
2818 }
2819 now := time.Now()
2820 accountID, err := s.store.AcceptInvite(r.PathValue("token"), addr, now)
2821 if err != nil {
2822 s.invitationGone(w, r)
2823 return
2824 }
2825 a, err := s.store.AccountByID(accountID)
2826 if err != nil || a == nil {
2827 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Joined, but could not open the account. Try the front page.")
2828 return
2829 }
2830 if err := s.store.SetLastAccount(addr, accountID); err != nil {
2831 log.Printf("console: set last account: %v", err)
2832 }
2833 s.setFlash(w, "ok", "You've joined "+a.Name+".")
2834 http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther)
2835}
2836
2837// handleContinue is the front page's one form (spec 4): it finds the door
2838// from the address alone. A Keymail address goes to Keymail's sign-in; any
2839// other gets an emailed link and code. Every email-bound outcome renders the
2840// same "Check your email" page and sets the same flow cookie, whether a
2841// message was sent or the request was refused, so neither the page nor any
2842// later code typed on it reveals an account or a rate limit.
2843//
2844// The order is load-bearing: the per-IP submission cap is noted first; the
2845// Keymail host's own addresses go to Keymail whatever the cap (that costs no
2846// lookup); Keymail's lookup is asked only under the cap; an email-bound
2847// address with no ready mailer is told so before any send limit (spec 4.2);
2848// and a request over the submission cap or the send limits is refused: no
2849// email, and no challenge the mailbox could redeem (signinChallenge).
2850func (s *Server) handleContinue(w http.ResponseWriter, r *http.Request) {
2851 if !s.sameOrigin(r) {
2852 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2853 return
2854 }
2855 now := time.Now()
2856 s.store.PurgeExpired(now) // housekeeping on the write path
2857 flow := s.requestFlow(r)
2858 if flow == "" {
2859 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
2860 return
2861 }
2862 typed := strings.TrimSpace(r.FormValue("address"))
2863 next := safeNext(r.FormValue("next"))
2864 addr, ok := canonicalAddress(typed)
2865 if !ok {
2866 s.renderHome(w, r, homeForm{Prefill: typed, Next: next, Error: copyText("home.bad_address"), flow: flow})
2867 return
2868 }
2869 ip := s.clientIP(r)
2870 underCap := s.limits.submit(ip)
2871 if !underCap {
2872 log.Printf("console: sign-in submissions from %s over the hourly cap; no lookup, no send", ip)
2873 }
2874 if s.keymailAddress(r.Context(), addr, underCap) {
2875 s.setFlow(w, flow)
2876 target := "/auth/signin"
2877 if next != "" {
2878 target += "?next=" + url.QueryEscape(next)
2879 }
2880 http.Redirect(w, r, target, http.StatusSeeOther)
2881 return
2882 }
2883 if !s.mailReady(r.Context()) {
2884 s.renderHome(w, r, homeForm{Prefill: addr, Next: next, NoMail: true, flow: flow})
2885 return
2886 }
2887 link, code, sent, err := s.signinChallenge(flow, addr, next, ip, underCap, now)
2888 if err != nil {
2889 log.Printf("console: sign-in challenge for %s: %v", addr, err)
2890 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
2891 return
2892 }
2893 if sent {
2894 console := s.consoleName()
2895 s.sendChallengeAsync(addr,
2896 copyText("email.signin.subject", "console", console),
2897 copyText("email.signin.body", "console", console, "address", addr,
2898 "link", s.cfg.Origin+"/auth/email/link?t="+link, "code", code))
2899 }
2900 s.renderCheck(w, flow, addr, next)
2901}
2902
2903// signinChallenge is the front door's limits and challenge write for one
2904// flow, under that flow's lock, so overlapping submissions from one browser
2905// take turns. Accepted (under the submission cap and every send limit), it
2906// records a new sign-in challenge for the flow, replacing the flow's earlier
2907// ones, and returns its link and code to send. Refused, it sends nothing and
2908// leaves the flow's live challenge, if any, untouched, so the email already
2909// sent keeps working. Over the submission cap it writes nothing at all: that
2910// throttle is the requester's own IP and says nothing about anyone else, so
2911// the code box may answer "used up" there. Refused by the send limits (which
2912// the submission cap bounds to 30 an hour per IP), a flow with no live
2913// challenge gets one nobody is ever told the link or code of, so a code
2914// typed next answers exactly as it would against a sent one.
2915func (s *Server) signinChallenge(flow, addr, next, ip string, underCap bool, now time.Time) (link, code string, sent bool, err error) {
2916 mu := s.flowLock(flow)
2917 mu.Lock()
2918 defer mu.Unlock()
2919 if !underCap {
2920 return "", "", false, nil
2921 }
2922 c := EmailChallenge{Purpose: ChallengeSignIn, Address: addr, Next: next, FlowHash: hashToken(flow)}
2923 if !s.limits.signinSend(addr, ip) {
2924 log.Printf("console: sign-in email to %s from %s refused by its limits; not sent", addr, ip)
2925 return "", "", false, s.store.HoldEmailChallenge(c, now)
2926 }
2927 link, code, err = s.store.CreateEmailChallenge(c, now)
2928 if err != nil {
2929 return "", "", false, err
2930 }
2931 return link, code, true, nil
2932}
2933
2934// ValidAddress is the console's one shape check on a keymail address:
2935// <local>@<host>, no whitespace. Exported because the box-side verbs
2936// (`console member`) must reject a typo with the SAME rule the web surface
2937// uses — a membership created from the command line for an address the sign-in
2938// form would refuse is a grant nobody can ever claim.
2939//
2940// The rule itself now lives in internal/domains, next to the account manifest's
2941// `owner` field, so `carlos accounts create -owner` can apply it too. The carlos
2942// binary must never open the console's database, but it must not invent a
2943// second definition of a valid address either: an address one tool accepts and
2944// another refuses is exactly how an owner ends up recorded and unable to sign
2945// in. Delegating keeps one rule with two doors, not two rules.
2946func ValidAddress(a string) bool { return domains.ValidAddress(a) }
2947
2948// handleSignin starts the one-time OAuth ceremony: state + PKCE verifier in
2949// a short-lived cookie, browser off to Keymail's consent page.
2950func (s *Server) handleSignin(w http.ResponseWriter, r *http.Request) {
2951 state, err := NewVerifier()
2952 if err != nil {
2953 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
2954 return
2955 }
2956 verifier, err := NewVerifier()
2957 if err != nil {
2958 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
2959 return
2960 }
2961 s.setCookie(w, authCookie, state+"."+verifier, int(authFlowTTL.Seconds()))
2962 // Where to land afterwards. Only a safe local path is kept; a stale one
2963 // from an earlier attempt must not leak into this one.
2964 if n := safeNext(r.URL.Query().Get("next")); n != "" {
2965 s.setCookie(w, nextCookie, n, int(authFlowTTL.Seconds()))
2966 } else {
2967 s.clearCookie(w, nextCookie)
2968 }
2969 http.Redirect(w, r, s.km.AuthorizeURL(state, ChallengeS256(verifier)), http.StatusSeeOther)
2970}
2971
2972// handleCallback finishes the ceremony: verify state, exchange the code
2973// (with PKCE verifier and the console's sign_pub), and either sign the user
2974// up (new address) or in. The grant token is stored per user — it is the
2975// instrument of future confirmation-link sign-ins.
2976func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
2977 q := r.URL.Query()
2978 if e := q.Get("error"); e != "" {
2979 s.renderHome(w, r, homeForm{Error: "Sign-in was cancelled at Keymail (" + e + ")."})
2980 return
2981 }
2982 c, err := r.Cookie(authCookie)
2983 if err != nil || c.Value == "" {
2984 s.renderMessage(w, r, http.StatusBadRequest, "Sign-in flow expired",
2985 "This sign-in attempt is stale or was started elsewhere. Please start again.")
2986 return
2987 }
2988 state, verifier, ok := strings.Cut(c.Value, ".")
2989 if !ok || state == "" ||
2990 subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 {
2991 s.renderMessage(w, r, http.StatusBadRequest, "Sign-in flow mismatch",
2992 "The sign-in response didn't match this browser's sign-in attempt. Please start again.")
2993 return
2994 }
2995 code := q.Get("code")
2996 if code == "" {
2997 s.renderMessage(w, r, http.StatusBadRequest, "Sign-in failed", "Keymail sent no authorization code. Please start again.")
2998 return
2999 }
3000 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
3001 defer cancel()
3002 address, token, err := s.km.Exchange(ctx, code, verifier)
3003 if err != nil {
3004 log.Printf("console: token exchange: %v", err)
3005 s.renderMessage(w, r, http.StatusBadGateway, "Sign-in failed",
3006 "Keymail did not accept the sign-in. Please start again.")
3007 return
3008 }
3009 var next string
3010 if nc, err := r.Cookie(nextCookie); err == nil {
3011 next = nc.Value
3012 }
3013 s.clearCookie(w, nextCookie)
3014 s.finishSignin(w, r, address, token, MethodKeymail, next)
3015}
3016
3017// handleConfirm is the retired Apps-inbox confirmation link (spec 11):
3018// nothing mints those links any more, so every token gets the expired
3019// message and nothing is read or spent. The route stays so an old email still
3020// lands somewhere that explains itself.
3021//
3022// It answers 200, not the 410 of an expired email link: the claim census
3023// accepts only a success from a read's probe, and this route is now a read.
3024func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
3025 s.renderMessage(w, r, http.StatusOK, "Link expired", copyText("link.expired"))
3026}
3027
3028// handleSignout deletes the console session. The Keymail grant deliberately
3029// survives — it is how the next sign-in reaches the user; revoking it lives
3030// in Keymail's own Connected-apps settings.
3031func (s *Server) handleSignout(w http.ResponseWriter, r *http.Request) {
3032 _, raw, ok := s.session(r)
3033 if !ok {
3034 http.Redirect(w, r, "/", http.StatusSeeOther)
3035 return
3036 }
3037 // CSRF: the session token double-submitted as a hidden form field.
3038 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
3039 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
3040 return
3041 }
3042 s.store.DeleteSession(raw)
3043 // Presence dies with the session it was proved for (stepup.go). Belt and
3044 // braces — the step-up row is keyed by the session hash, which no future
3045 // session can reproduce — but leaving proof-of-presence lying around after
3046 // a deliberate sign-out is not a thing to be relaxed about.
3047 s.store.ClearStepUp(raw)
3048 s.clearCookie(w, sessionCookie)
3049 http.Redirect(w, r, "/", http.StatusSeeOther)
3050}
3051
3052// ---- fleet (operator-only) ---------------------------------------------------
3053
3054// operatorOnly is the gate every /fleet page and door shares: signed in,
3055// and an operator. Every failure path — signed out, signed in but not an
3056// operator, or a store error reading the operator bit — gets exactly
3057// http.NotFound, the identical response an unregistered URL gets from this
3058// mux. Role existence is not probeable: a styled "not found" page (like
3059// ownedApp's) would itself reveal that /fleet is a real, gated route, so
3060// this deliberately skips renderMessage.
3061//
3062// One copy rather than one per handler, because the promise being kept is
3063// byte-identity with a missing route — a promise that only survives being
3064// written once. ok=false means the response is already written and the
3065// caller must return immediately.
3066func (s *Server) operatorOnly(w http.ResponseWriter, r *http.Request) (address, rawToken string, ok bool) {
3067 addr, raw, ok := s.session(r)
3068 if !ok {
3069 http.NotFound(w, r)
3070 return "", "", false
3071 }
3072 isOp, err := s.store.IsOperator(addr)
3073 if err != nil {
3074 log.Printf("console: IsOperator %s: %v", addr, err)
3075 http.NotFound(w, r)
3076 return "", "", false
3077 }
3078 if !isOp {
3079 http.NotFound(w, r)
3080 return "", "", false
3081 }
3082 return addr, raw, true
3083}
3084
3085// handleFleet serves the private fleet view: everything status/fleet.json
3086// carries, server-rendered, for signed-in operators only — behind
3087// operatorOnly's 404-to-everyone gate above.
3088func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) {
3089 addr, raw, ok := s.operatorOnly(w, r)
3090 if !ok {
3091 return
3092 }
3093 f, err := FleetView(r.Context(), s.cfg.Releases)
3094 if err != nil {
3095 log.Printf("console: fleet view: %v", err)
3096 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read fleet status. Please try again.")
3097 return
3098 }
3099 // The private host detail is an EXTRA read on a page that already has its
3100 // answer: a bucket that won't list the boxes costs the operator the
3101 // per-host block, not the whole fleet page, so this degrades with a log
3102 // line rather than turning a readable page into a 500.
3103 hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now())
3104 if err != nil {
3105 log.Printf("console: fleet hosts: %v", err)
3106 hosts = nil
3107 }
3108 p := page{Title: "Fleet status", Address: addr, CSRF: raw, Fleet: f, Hosts: hosts}
3109 if s.storeOperator(addr) {
3110 p.StoreQueueUnknown = true
3111 if s.cfg.Releases != nil {
3112 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
3113 applications, err := s.pendingStoreApplications(ctx)
3114 cancel()
3115 if err == nil {
3116 n := len(applications)
3117 p.StorePending = &n
3118 p.StoreQueueUnknown = false
3119 }
3120 }
3121 }
3122 s.render(w, http.StatusOK, "fleet", p)
3123}
3124
3125// handleFleetMetrics serves the operator host/process monitoring dashboard —
3126// same gate as handleFleet, same reasoning: every non-operator path is a
3127// plain http.NotFound so the route's existence is not probeable.
3128func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) {
3129 addr, raw, ok := s.operatorOnly(w, r)
3130 if !ok {
3131 return
3132 }
3133 boxes, alerts, err := s.LoadMetrics(r.Context())
3134 if err != nil {
3135 log.Printf("console: fleet metrics: %v", err)
3136 s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Metrics could not be read.")
3137 return
3138 }
3139 s.render(w, http.StatusOK, "metrics", page{Title: "Fleet metrics", Address: addr, CSRF: raw, Metrics: boxes, MetricAlerts: alerts})
3140}
3141
3142// handleEconomics serves the operator unit-economics dashboard (Task 13) —
3143// handleFleetMetrics's shape verbatim: same gate (session -> IsOperator ->
3144// bare http.NotFound, so a non-operator's probe is byte-identical to a
3145// missing route), same read-only LoadEconomics call keyed off the period
3146// query param, same render-and-done body. Flash is threaded through because
3147// this page is also where the scenario-save and bill-save POST doors
3148// (economicspass.go's handleEconomicsScenarioSave/handleEconomicsBillSave)
3149// redirect back to.
3150func (s *Server) handleEconomics(w http.ResponseWriter, r *http.Request) {
3151 addr, raw, ok := s.operatorOnly(w, r)
3152 if !ok {
3153 return
3154 }
3155 view, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period"))
3156 if err != nil {
3157 log.Printf("console: economics: %v", err)
3158 s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Economics could not be read.")
3159 return
3160 }
3161 p := page{Title: "Fleet economics", Address: addr, CSRF: raw, Economics: view}
3162 p.Flash, p.FlashErr = s.takeFlash(w, r)
3163 s.render(w, http.StatusOK, "economics", p)
3164}
3165
3166// handleEconomicsAccounts serves the per-account economics table (Task 14)
3167// — handleEconomics's gate and LoadEconomics call verbatim, joined with
3168// econlistquery.go's search/free-tier-position/cursor controls. view.Accounts
3169// (priced, CostUSD-desc from buildEconomicsView) and view.Meters (raw
3170// per-account readings, same account keys) are joined into econAccountRow
3171// here; pageEconAccounts re-sorts by account name for a stable cursor order,
3172// per its own doc comment.
3173func (s *Server) handleEconomicsAccounts(w http.ResponseWriter, r *http.Request) {
3174 addr, raw, ok := s.session(r)
3175 if !ok {
3176 http.NotFound(w, r)
3177 return
3178 }
3179 isOp, err := s.store.IsOperator(addr)
3180 if err != nil {
3181 log.Printf("console: economics accounts: is-operator: %v", err)
3182 http.NotFound(w, r)
3183 return
3184 }
3185 if !isOp {
3186 http.NotFound(w, r)
3187 return
3188 }
3189 view, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period"))
3190 if err != nil {
3191 log.Printf("console: economics accounts: %v", err)
3192 s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Economics could not be read.")
3193 return
3194 }
3195 rows := make([]econAccountRow, 0, len(view.Accounts))
3196 for _, ae := range view.Accounts {
3197 rows = append(rows, econAccountRow{Account: ae.Account, Name: view.Names[ae.Account], Meters: view.Meters[ae.Account], Econ: ae})
3198 }
3199 q := parseEconAccountQuery(r.URL.Query())
3200 // Period is the NORMALIZED value LoadEconomics actually priced rows
3201 // under (view.Period), not the raw query param — every link this page
3202 // builds (search form, pos chips, cursor pager, period nav) must carry
3203 // the period that produced what's on screen, or "Next 50" on a non-mtd
3204 // period would resume a cursor computed against a different period's
3205 // row order. See econAccountQuery.Period's doc comment.
3206 q.Period = view.Period
3207 pg := pageEconAccounts(rows, q, econAccountPageSize)
3208 p := page{Title: "Fleet economics: accounts", Address: addr, CSRF: raw, Economics: view, EconAccounts: pg, EconQuery: q}
3209 p.Flash, p.FlashErr = s.takeFlash(w, r)
3210 s.render(w, http.StatusOK, "economics_accounts", p)
3211}
3212
3213// ---- app pages --------------------------------------------------------------
3214
3215// handleAccountHome is the account's app list and switcher — the page /
3216// lands you on. Members and pending invitations live on /team (handleTeam).
3217func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
3218 addr, raw, acct, role, ok := s.account(w, r)
3219 if !ok {
3220 return
3221 }
3222 p := s.accountPage(w, r, addr, raw, acct, role)
3223 p.Title = acct.Name
3224 p.ActiveNav = "apps"
3225 if c, err := r.Cookie(welcomeCookie); err == nil && c.Value == "1" {
3226 p.Welcome = true
3227 s.clearCookie(w, welcomeCookie)
3228 }
3229 apps, err := s.store.AppsIn(acct.ID)
3230 if err != nil {
3231 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
3232 return
3233 }
3234 if len(apps) > 0 {
3235 views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.dnsTargets())
3236 if err != nil {
3237 log.Printf("console: assemble: %v", err)
3238 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
3239 return
3240 }
3241 for _, v := range views {
3242 p.Apps = append(p.Apps, summarize(v, acct.Sqid))
3243 }
3244 }
3245 // The Trash link, and only when there is something in it. Best-effort: a
3246 // failed count costs a link, never the page.
3247 if trashed, err := s.store.TrashedAppsIn(acct.ID); err != nil {
3248 log.Printf("console: trashed apps: %v", err)
3249 } else {
3250 p.TrashN = len(trashed)
3251 }
3252 s.render(w, http.StatusOK, "apps", p)
3253}
3254
3255// handleTeam lists the account's members and, for owners, the invite
3256// machinery. The mutations it hosts all POST to their existing paths.
3257func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
3258 addr, raw, acct, role, ok := s.account(w, r)
3259 if !ok {
3260 return
3261 }
3262 p := s.accountPage(w, r, addr, raw, acct, role)
3263 p.Title = "Team"
3264 p.ActiveNav = "team"
3265 p.ConfirmNote = copyText("team.confirm_note")
3266 p.InvitePlaceholder = copyText("team.invite_placeholder")
3267 p.InviteNote = copyText("team.invite_note")
3268 if members, err := s.store.Members(acct.ID); err != nil {
3269 log.Printf("console: members: %v", err)
3270 } else {
3271 p.Members = members
3272 }
3273 if role == RoleOwner {
3274 if invites, err := s.store.PendingInvites(acct.ID); err != nil {
3275 log.Printf("console: pending invites: %v", err)
3276 } else {
3277 p.Invites = invites
3278 }
3279 }
3280 s.render(w, http.StatusOK, "team", p)
3281}
3282
3283// handleAccountSettings is the account itself — identity, and its end.
3284// Deletion stays owner-only and zero-apps-only; the POST is unchanged.
3285func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) {
3286 addr, raw, acct, role, ok := s.account(w, r)
3287 if !ok {
3288 return
3289 }
3290 p := s.accountPage(w, r, addr, raw, acct, role)
3291 p.Title = "Account settings"
3292 p.ActiveNav = "settings"
3293 n, err := s.store.AppCount(acct.ID)
3294 if err != nil {
3295 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
3296 return
3297 }
3298 p.HasApps = n > 0
3299 p.AccountStore = s.accountStorePane(r.Context(), acct)
3300 // The service-credential pane (svcpage.go), owners only — see page's
3301 // Services field for why a member gets no section rather than an empty
3302 // one.
3303 if role == RoleOwner {
3304 s.servicePane(&p, acct)
3305 }
3306 // The account's own Errors log level. Resolved with an EMPTY app name on
3307 // purpose: AppKey(sqid, "") names a key no app ever writes, so the app
3308 // layer contributes nothing and what comes back is the account setting
3309 // or the default — which is exactly what this page sets. A failed read
3310 // is said rather than defaulted (see ErrLevelUnread), and never fails
3311 // the page.
3312 if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, ""); err != nil {
3313 log.Printf("console: account telemetry %s: %v", acct.Sqid, err)
3314 p.ErrLevelUnread = true
3315 } else {
3316 p.ErrLevelSetting = res
3317 }
3318 // The analytics switch is the OTHER half of that same resolved object —
3319 // one settings file, one read — under the name its own section uses.
3320 p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread
3321 s.render(w, http.StatusOK, "account_settings", p)
3322}
3323
3324// accountPage fills the chrome every account-scoped page shares — the
3325// switcher, the base URL its forms post to, the viewer's role — and records
3326// the MRU, so signing in lands you back on the account you were last in.
3327func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role string) page {
3328 if err := s.store.SetLastAccount(addr, acct.ID); err != nil {
3329 log.Printf("console: set last account: %v", err)
3330 }
3331 p := page{
3332 Address: addr, CSRF: raw,
3333 Account: acct, Role: role, Base: "/a/" + url.PathEscape(acct.Sqid),
3334 CanPromote: s.cfg.Key != nil,
3335 }
3336 p.Flash, p.FlashErr = s.takeFlash(w, r)
3337 if accts, err := s.store.AccountsFor(addr); err != nil {
3338 log.Printf("console: switcher: %v", err)
3339 } else {
3340 p.Accounts = accts
3341 }
3342 // The dropdown's trigger names the current account, so the menu must
3343 // contain it — a list that lost it (a failed query) would open onto a
3344 // menu with no "you are here" row at all. The gate already proved the
3345 // membership, so this is a display fallback, not a permission decision.
3346 if len(p.Accounts) == 0 {
3347 p.Accounts = []Account{*acct}
3348 }
3349 for i := range p.Accounts {
3350 p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i])
3351 }
3352 p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, acct))
3353 return p
3354}
3355
3356// accountApp gates the per-app pages and mutations: membership in the
3357// account (any role — a member can deploy), and an app that account actually
3358// claims. An app that is not this account's and one that does not exist look
3359// identical on purpose.
3360func (s *Server) accountApp(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role, app string, ok bool) {
3361 addr, raw, acct, role, ok = s.account(w, r)
3362 if !ok {
3363 return "", "", nil, "", "", false
3364 }
3365 app = r.PathValue("app")
3366 has, err := s.store.HasAppIn(acct.ID, app)
3367 if err != nil {
3368 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
3369 return "", "", nil, "", "", false
3370 }
3371 if !has {
3372 s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
3373 return "", "", nil, "", "", false
3374 }
3375 return addr, raw, acct, role, app, true
3376}
3377
3378// appPage gates an app page, assembles its platform view, and fills the
3379// chrome every app tab shares. Callers add their own tab's data on top.
3380func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool) {
3381 addr, raw, acct, role, app, ok := s.accountApp(w, r)
3382 if !ok {
3383 return page{}, nil, false
3384 }
3385 // The account manifest is re-attempted on view: minting writes it, but a
3386 // bucket hiccup there is deliberately non-fatal, and this is where it gets
3387 // another chance. Best-effort — a page view never fails on it.
3388 s.ensureAccountManifest(r.Context(), acct)
3389 views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.dnsTargets())
3390 if err != nil || len(views) != 1 {
3391 log.Printf("console: assemble %s: %v", app, err)
3392 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
3393 return page{}, nil, false
3394 }
3395 p := s.accountPage(w, r, addr, raw, acct, role)
3396 p.Title = app
3397 p.AppName = app
3398 p.ActiveTab = tab
3399 p.ActiveNav = "apps"
3400 p.Detail = &views[0]
3401 if life, err := s.store.AppLife(acct.ID, app); err != nil {
3402 log.Printf("console: app lifecycle %s/%s: %v", acct.Sqid, app, err)
3403 } else if life != nil && life.Temporary() {
3404 p.ExpiresAt = time.Unix(life.ExpiresAt, 0).UTC()
3405 }
3406 if review, all, err := s.appAccountReview(addr, acct, app); err != nil {
3407 log.Printf("console: account review %s/%s: %v", acct.Sqid, app, err)
3408 } else {
3409 p.AccountReview = review
3410 p.AccountReview.NoInstances = review.Owned == 0 && hasPublishedChannel(p.Detail.Channels)
3411 for _, group := range review.Groups {
3412 for _, host := range group.Hosts {
3413 p.Detail.Mismatched = append(p.Detail.Mismatched, MismatchedRoute{Host: host, Account: group.Account.Sqid})
3414 }
3415 }
3416 p.Detail.AllMismatched = all
3417 if all {
3418 p.Detail.RouteAccount = review.Groups[0].Account.Sqid
3419 }
3420 }
3421 s.applyPipelineView(r.Context(), p.Detail, acct)
3422 return p, acct, true
3423}
3424
3425func hasPublishedChannel(channels []ChannelView) bool {
3426 for _, channel := range channels {
3427 if channel.Err == "" && channel.Version != "" {
3428 return true
3429 }
3430 }
3431 return false
3432}
3433
3434// applyPipelineView is the one place a single app's view becomes
3435// pipeline-aware (release pipelines v2 §2, §5) — deliberately not inside
3436// Assemble (AppView.Pipeline's own doc comment: the account home page calls
3437// Assemble for a whole app list and never reads Ladder or Pipeline, so a
3438// pipeline GET per app there would be a bucket read nobody looks at; appPage
3439// is the one place every app-scoped handler funnels through).
3440//
3441// It corrects each ChannelView's BakedUntil from the pipeline's OWN window
3442// (holdWindow) in place of the legacy per-app table Assemble already
3443// computed — the fix for the Task 9 review's disagreement between the
3444// Channels table and the pipeline card, both of which read Channels — and
3445// then rebuilds Ladder from those corrected channels, so the two can never
3446// disagree again by construction. Best-effort and view-only, like every
3447// other read in this family: an unreadable pipeline degrades the render to
3448// the legacy ladder rather than failing the page.
3449func (s *Server) applyPipelineView(ctx context.Context, view *AppView, acct *Account) {
3450 ctx, cancel := context.WithTimeout(ctx, pipelineStoreTimeout)
3451 defer cancel()
3452 pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, view.App)
3453 if err != nil {
3454 log.Printf("console: app page %s/%s: read pipeline: %v", acct.Sqid, view.App, err)
3455 return
3456 }
3457 if pipe == nil {
3458 return
3459 }
3460 view.Pipeline = pipe
3461 now := time.Now()
3462 for i := range view.Channels {
3463 view.Channels[i].BakedUntil = time.Time{}
3464 if view.Channels[i].Err != "" || view.Channels[i].FastTrack {
3465 continue
3466 }
3467 if d := holdWindow(pipe, view.Channels[i].Name); d > 0 {
3468 if until := view.Channels[i].PromotedAt.Add(d); now.Before(until) {
3469 view.Channels[i].BakedUntil = until
3470 }
3471 }
3472 }
3473 view.Ladder = assembleLadder(view.Channels, view.Routes, pipe, now)
3474}
3475
3476// appResourceMetrics sweeps the app's own instances only. The allowlist is
3477// built HERE, from the routes this account's app owns and nothing else —
3478// that slice is the entire tenant boundary on the metrics read, so it must
3479// never be widened to "all routes" or taken from the request. A failed
3480// sweep costs the section, never the page.
3481//
3482// The account comparison is load-bearing and not redundant with the
3483// accountApp gate above: Assemble groups routes by app NAME, and app
3484// names are unique per ACCOUNT, not globally (Store.AppAccounts) — two
3485// accounts may each own an app called "api", and both accounts' routes
3486// arrive in this one AppView. Comparing each route's own account is what
3487// keeps one tenant's cgroup readings off the other's page. It fails
3488// closed: a route stamped with an account the console record disagrees
3489// with (the half-migrated state AccountMismatch describes) yields no
3490// metrics rather than someone else's.
3491func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics {
3492 hosts := make([]string, 0, len(view.Routes))
3493 hib := make(map[string]bool, len(view.Routes))
3494 unitByHost := make(map[string]string, len(view.Routes))
3495 for _, rt := range view.Routes {
3496 if rt.Account != acct.Sqid {
3497 continue
3498 }
3499 hosts = append(hosts, rt.Host)
3500 hib[rt.Host] = rt.Hibernate
3501 if rt.Unit != "" {
3502 unitByHost[rt.Host] = rt.Unit
3503 }
3504 }
3505 am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
3506 if err != nil {
3507 log.Printf("console: app metrics %s: %v", view.App, err)
3508 return nil
3509 }
3510 for i := range am {
3511 am[i].Hibernate = hib[am[i].Host]
3512 }
3513 return am
3514}
3515
3516// handleApp is the Overview dashboard: the vital statistics, from reads the
3517// app pages already do — no self-report poll here, so the landing tab never
3518// waits on a tenant's own HTTP endpoint.
3519func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
3520 p, acct, ok := s.appPage(w, r, "overview")
3521 if !ok {
3522 return
3523 }
3524 app := p.Detail.App
3525 versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
3526 if err != nil {
3527 log.Printf("console: versions %s: %v", app, err)
3528 p.FlashErr = "Could not list this app's releases."
3529 }
3530 p.Versions = versions
3531 metrics := s.appResourceMetrics(r.Context(), p.Detail, acct)
3532 // The Database tile's sizes are best-effort: a slow status read costs the
3533 // tile its number (the em-dash), never the page.
3534 joinCtx, cancelJoin := context.WithTimeout(r.Context(), instanceStoreTimeout)
3535 dbJoin := loadBoxJoin(joinCtx, s.cfg.Releases, time.Now())
3536 cancelJoin()
3537 p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions, dbJoin)
3538 // The diagram is drawn from the SAME tenant-filtered rows the Running tab
3539 // renders, so the two can never disagree about which addresses exist or
3540 // how they are doing — see flow.go's package comment for why it is not
3541 // built from Detail.Routes.
3542 p.InstanceRows = assembleInstanceRows(p.Detail, acct, metrics)
3543 var latest, latestAt string
3544 if len(versions) > 0 {
3545 latest, latestAt = versions[0].Version, versions[0].BuiltAt
3546 }
3547 p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt, p.Nav+"/apps/"+p.AppName+"/instances")
3548 if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil {
3549 log.Printf("console: events %s: %v", app, err)
3550 } else {
3551 p.Events = events
3552 }
3553 if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now()); err != nil {
3554 log.Printf("console: pending changes %s/%s: %v", acct.Sqid, app, err)
3555 } else if len(pcs) > 0 {
3556 // One pipeline read for the whole card: the rows show the quorum
3557 // EXECUTION will require, not the one they were proposed under.
3558 p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, app))
3559 }
3560 s.render(w, http.StatusOK, "app_overview", p)
3561}
3562
3563// overviewActivityCap bounds the Overview's recent-activity strip; the
3564// Activity tab has the room this strip never will.
3565const overviewActivityCap = 5
3566
3567// handleAppReleases is the deploy surface: every shipped release, the
3568// channels they promote onto, and the canaries riding ahead.
3569func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
3570 p, acct, ok := s.appPage(w, r, "releases")
3571 if !ok {
3572 return
3573 }
3574 if strings.HasSuffix(r.URL.Path, "/deployments") {
3575 p.DeploymentTargets = assembleDeploymentTargets(p.Detail, acct.Sqid)
3576 if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, p.AppName, time.Now()); err != nil {
3577 log.Printf("console: pending changes %s/%s: %v", acct.Sqid, p.AppName, err)
3578 } else if len(pcs) > 0 {
3579 p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, p.AppName))
3580 }
3581 s.render(w, http.StatusOK, "app_deployments", p)
3582 return
3583 }
3584 p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels, p.Detail.Routes, acct.Sqid)
3585 p.PromoteChannels = promoteChannelOptions(p.Detail.Channels, p.Detail.Pipeline)
3586 versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
3587 if err != nil {
3588 log.Printf("console: versions %s: %v", p.AppName, err)
3589 p.FlashErr = "Could not list this app's releases."
3590 }
3591 // Versions itself never reads ListTags (view.go), and neither does
3592 // assembleChannels any more — a full tag sweep thrown away by every
3593 // OTHER app-page tab and the account home page's whole app list would
3594 // be a needless read on every one of those renders (release versioning
3595 // spec, 2026-08-08; view.go's tagByRelease doc comment has the full
3596 // story). This tab is the one place that sweep pays for itself: ONE
3597 // ListTags read serves the Tags section below (raw records, changelog
3598 // and all, capped at tagsCap — ListTags' own semver-descending order),
3599 // the Releases table's pill (view.go's tagIndex, the pure fold with no
3600 // read of its own), and the Channels table's pill, backfilled onto
3601 // Assemble's already-built ChannelView rows since assembleChannels
3602 // itself leaves Tag "". A failed read degrades this tab's tag
3603 // decoration only; the page still renders.
3604 if tags, terr := release.ListTags(context.Background(), s.cfg.Releases, acct.Sqid, p.AppName); terr != nil {
3605 log.Printf("console: tags %s: %v", p.AppName, terr)
3606 } else {
3607 byRelease := tagIndex(tags)
3608 for i := range versions {
3609 versions[i].Tag = byRelease[versions[i].Version]
3610 }
3611 for i := range p.Detail.Channels {
3612 p.Detail.Channels[i].Tag = byRelease[p.Detail.Channels[i].Version]
3613 }
3614 if len(tags) > tagsCap {
3615 tags = tags[:tagsCap]
3616 }
3617 p.Tags = tags
3618 }
3619 p.Versions = versions
3620 // Read-only in v1: the policy is set via `carlos releases retention`;
3621 // this line just keeps the page honest about why old versions vanish.
3622 if ret, err := release.GetRetention(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil {
3623 log.Printf("console: retention %s: %v", p.AppName, err)
3624 } else if ret != nil {
3625 p.RetentionKeep = ret.Keep
3626 }
3627 // The "how do I give this a URL" copy — not shared chrome (it's computed
3628 // per app page, not carried by appPage), but the same fill the Instances
3629 // tab's empty state shows: attachHint below.
3630 p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "canary/<name>")
3631 s.render(w, http.StatusOK, "app_releases", p)
3632}
3633
3634// attachHint is the "give this channel a URL" advice shown on both the
3635// Releases tab (the canary form's copy, channel "canary/<name>") and the
3636// Instances tab's empty state (channel "stable" — the Running tab's empty
3637// state should not be steering members onto a canary) — one bucket read and
3638// one command shape per caller. Bounded and best-effort like every other
3639// store read behind an app page: an unreadable instances manifest costs the
3640// concrete host shape, never the page.
3641func (s *Server) attachHint(ctx context.Context, sqid, app, channel string) (cmd, enable string) {
3642 mctx, mcancel := context.WithTimeout(ctx, instanceStoreTimeout)
3643 defer mcancel()
3644 domain, absent := s.instanceDomainFor(mctx, sqid, app)
3645 return attachCommands(app, channel, domain, s.cfg.AppsDomain, sqid, absent)
3646}
3647
3648// handleAppInstances is what the app is actually running: one row per
3649// instance — host, the channel it follows, what it is running right now, and
3650// its readings — plus the app's own self-report below, for apps that manage
3651// internal instances of their own.
3652func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
3653 p, acct, ok := s.appPage(w, r, "instances")
3654 if !ok {
3655 return
3656 }
3657 p.Instances = s.instances.Report(r.Context(), ownedInstanceRoutes(p.Detail.Routes, acct.Sqid))
3658 p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
3659 q := parseInstanceQuery(r.URL.Query())
3660 rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics)
3661 for _, row := range rows {
3662 if row.Restartable {
3663 p.CanRestartApp = true
3664 break
3665 }
3666 }
3667 pg := pageInstanceRows(rows, q, instancePageSize)
3668 p.InstanceRows = pg.Rows
3669 p.InstancePage = pg
3670 p.InstanceQuery = q
3671 p.InstanceChannels = instanceChannels(rows)
3672 p.InstanceUnknownChannel = q.Channel != "" && !hasInstanceChannel(p.InstanceChannels, q.Channel)
3673 p.InstanceGroups = groupInstanceRows(pg.Rows, p.Detail.Channels, pg.Matching)
3674 p.InstanceListURL = p.Nav + "/apps/" + p.AppName + "/instances" + instanceQuerySuffix(q)
3675 // Placement (Task 6): where this app runs, and where else it could. Soft
3676 // like everything else on this tab — a failed read costs the section's
3677 // certainty (AppPlacementKnown) and a flash, never the page.
3678 placement, fleets, known, warn := s.appPlacementSection(acct, p.AppName)
3679 p.AppPlacement = placement
3680 p.OwnedFleetNames = fleets
3681 p.AppPlacementKnown = known
3682 if warn != "" && p.FlashErr == "" {
3683 p.FlashErr = warn
3684 }
3685 // The app-region note (spec §10). Available is nil for a fleet app and
3686 // for a policy, edges or stored region that could not be read, so those
3687 // show nothing: unreadable never reads as available. Pooled is excluded
3688 // by name.
3689 if d := s.appDefault(r.Context(), acct, p.AppName); d.Available != nil && d.Reason != "pooled" {
3690 switch {
3691 case !*d.Available:
3692 p.RegionLine, p.RegionLineWarn = fmt.Sprintf(regionCopy.CantStart, d.Name), true
3693 case d.Region != s.cfg.HomeRegion:
3694 p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name)
3695 default:
3696 // A home app is told where it starts only when the home has a
3697 // sibling to be told apart from.
3698 if files, err := s.liveEdges(r.Context()); err == nil && eligibleCount(s.regionCands(files)) >= 2 {
3699 p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name)
3700 }
3701 }
3702 }
3703 // The empty state keys off the app's UNFILTERED rows, not the filtered
3704 // page: a filter that matches nothing shows "no matches" (below), never
3705 // the attach-onboarding copy — that copy is only true when the app has
3706 // no instances at all.
3707 if len(rows) == 0 {
3708 p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable")
3709 }
3710 s.render(w, http.StatusOK, "app_instances", p)
3711}
3712
3713// appLogsPageCap bounds the Logs tab to what a page can usefully show; the
3714// CLI (`carlos logs`) is the door for more, and the page says so.
3715const appLogsPageCap = 200
3716
3717// appErrorsPageCap bounds the Errors tab the same way, on both of its
3718// shapes: the number of GROUPS in the table, and the number of OCCURRENCE
3719// cards in an expanded group. readErrors collects up to logsCollectCap
3720// records per family, so without this a busy app's tab is a page nobody can
3721// read and a render nobody asked for. `carlos errors` is the door for more.
3722const appErrorsPageCap = 200
3723
3724// handleAppLogs renders the app's merged log timeline — the same window the
3725// CLI reads (apilogs.go), tailed to the newest appLogsPageCap records, with
3726// stream and window filters as plain GET params so a view is a URL.
3727func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) {
3728 p, acct, ok := s.appPage(w, r, "logs")
3729 if !ok {
3730 return
3731 }
3732 q := r.URL.Query()
3733 p.LogCopy = appLogsCopy{
3734 Instance: "Instances", AllInstances: "All instances", Search: "Search logs",
3735 AllLevels: "All levels", Follow: "Follow live", Pause: "Pause",
3736 Widen: "Show the last 24 hours",
3737 FollowError: "Live updates failed. Follow live again to retry.", Refreshed: "Updated",
3738 }
3739 p.LogStream = q.Get("stream")
3740 p.LogSince = q.Get("since")
3741 if p.LogSince == "" {
3742 p.LogSince = "1h"
3743 }
3744 p.LogHost = q.Get("host")
3745 p.LogLevel = q.Get("level")
3746 p.LogGrep = q.Get("text")
3747 p.LogLegacyGrep = q.Get("grep")
3748 p.LogFollow = q.Get("follow") == "1"
3749 widen, _ := url.ParseQuery(q.Encode())
3750 widen.Set("since", "24h")
3751 widen.Del("cursor")
3752 widen.Del("follow")
3753 p.LogWidenURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/logs?" + widen.Encode()
3754 now := time.Now()
3755 f, _, bad := browserLogsFilterFromQuery(r, now)
3756 // Cursor is a data-endpoint concern. A hand-written page URL carrying one
3757 // must not turn the page's normal one-hour default into an all-history read.
3758 if f.Since.IsZero() {
3759 f.Since = now.Add(-logsDefaultWindow)
3760 }
3761 if bad != "" {
3762 p.LogsErr = bad
3763 }
3764 seenHosts := map[string]bool{}
3765 if p.LogHost != "" {
3766 seenHosts[p.LogHost] = true
3767 p.LogHosts = append(p.LogHosts, p.LogHost)
3768 }
3769 for _, rt := range p.Detail.Routes {
3770 if rt.Account == acct.Sqid && !seenHosts[rt.Host] {
3771 seenHosts[rt.Host] = true
3772 p.LogHosts = append(p.LogHosts, rt.Host)
3773 }
3774 }
3775 if s.cfg.Releases != nil {
3776 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
3777 recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, p.AppName, instanceListMax)
3778 cancel()
3779 if err == nil {
3780 for _, rec := range recs {
3781 if !seenHosts[rec.Host] {
3782 seenHosts[rec.Host] = true
3783 p.LogHosts = append(p.LogHosts, rec.Host)
3784 }
3785 }
3786 }
3787 }
3788 sort.Strings(p.LogHosts)
3789
3790 if s.cfg.Releases != nil && bad == "" {
3791 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
3792 defer cancel()
3793 recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, p.AppName, f, logsCollectCap)
3794 if err != nil {
3795 log.Printf("console: app logs %s/%s: %v", acct.Sqid, p.AppName, err)
3796 p.LogsErr = "The logs could not be read just now — try again in a moment."
3797 }
3798 p.LogTruncated = trimmed
3799 if len(recs) > appLogsPageCap {
3800 recs = recs[len(recs)-appLogsPageCap:]
3801 p.LogTruncated = true
3802 }
3803 p.LogRows = recs
3804 if n := len(recs); n > 0 {
3805 p.LogCursor = applog.EncodeCursor(recs[n-1])
3806 }
3807 }
3808 s.render(w, http.StatusOK, "app_logs", p)
3809}
3810
3811type appLogsDataLine struct {
3812 Time string `json:"time"`
3813 Stream string `json:"stream"`
3814 Host string `json:"host,omitempty"`
3815 Msg string `json:"msg"`
3816 Fields string `json:"fields,omitempty"`
3817 Tone string `json:"tone,omitempty"`
3818}
3819
3820type appLogsDataResponse struct {
3821 Lines []appLogsDataLine `json:"lines"`
3822 Truncated bool `json:"truncated"`
3823 Gap bool `json:"gap,omitempty"`
3824 Cursor string `json:"cursor,omitempty"`
3825}
3826
3827// handleAppLogsData is the browser's follow endpoint. It uses the session and
3828// app-membership gate, then the API reader's filter/cursor semantics; browser
3829// JavaScript never receives or manufactures a bearer credential.
3830func (s *Server) handleAppLogsData(w http.ResponseWriter, r *http.Request) {
3831 w.Header().Set("Cache-Control", "no-store")
3832 _, _, acct, _, app, ok := s.accountApp(w, r)
3833 if !ok {
3834 return
3835 }
3836 now := time.Now()
3837 f, limit, bad := browserLogsFilterFromQuery(r, now)
3838 if bad != "" {
3839 jsonError(w, http.StatusBadRequest, bad)
3840 return
3841 }
3842 if s.cfg.Releases == nil {
3843 jsonError(w, http.StatusServiceUnavailable, "could not read the logs")
3844 return
3845 }
3846 cursorText := r.URL.Query().Get("cursor")
3847 cur, hasCursor := applog.DecodeCursor(cursorText)
3848 if cursorText != "" && !hasCursor {
3849 writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true})
3850 return
3851 }
3852 if hasCursor {
3853 if !f.Since.IsZero() && cur.TS.Before(f.Since) {
3854 writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true})
3855 return
3856 }
3857 cursorSince := cur.TS.Add(-time.Second)
3858 if f.Since.IsZero() || cursorSince.After(f.Since) {
3859 f.Since = cursorSince
3860 }
3861 }
3862 limit = min(limit, appLogsPageCap)
3863 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
3864 defer cancel()
3865 recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap)
3866 if err != nil {
3867 log.Printf("console: app logs data %s/%s: %v", acct.Sqid, app, err)
3868 jsonError(w, http.StatusBadGateway, "could not read the logs")
3869 return
3870 }
3871 resp := appLogsDataResponse{Gap: trimmed && hasCursor}
3872 if hasCursor {
3873 var after []applog.Record
3874 for _, rec := range recs {
3875 if cur.Before(rec) {
3876 after = append(after, rec)
3877 }
3878 }
3879 if len(after) > limit {
3880 after = after[:limit]
3881 resp.Truncated = true
3882 }
3883 recs = after
3884 } else if len(recs) > limit {
3885 recs = recs[len(recs)-limit:]
3886 resp.Truncated = true
3887 }
3888 for _, line := range logLines(recs) {
3889 resp.Lines = append(resp.Lines, appLogsDataLine{
3890 Time: line.Time, Stream: line.Stream, Host: line.Host,
3891 Msg: line.Msg, Fields: line.Fields, Tone: line.Tone,
3892 })
3893 }
3894 if n := len(recs); n > 0 {
3895 resp.Cursor = applog.EncodeCursor(recs[n-1])
3896 } else {
3897 resp.Cursor = cursorText
3898 }
3899 if resp.Lines == nil {
3900 resp.Lines = []appLogsDataLine{}
3901 }
3902 writeJSON(w, http.StatusOK, resp)
3903}
3904
3905// browserLogsFilterFromQuery keeps the CLI's filter parser as the one source
3906// of validation while making the console's search box literal. A stale
3907// console URL carrying grep continues to use its original regular expression.
3908func browserLogsFilterFromQuery(r *http.Request, now time.Time) (applog.Filter, int, string) {
3909 text := r.URL.Query().Get("text")
3910 if text == "" {
3911 return logsFilterFromQuery(r, now)
3912 }
3913 r2 := r.Clone(r.Context())
3914 u := *r.URL
3915 q := u.Query()
3916 q.Set("grep", regexp.QuoteMeta(text))
3917 q.Del("text")
3918 u.RawQuery = q.Encode()
3919 r2.URL = &u
3920 return logsFilterFromQuery(r2, now)
3921}
3922
3923// handleAppActivity is the console's own audit trail for this app.
3924func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
3925 p, acct, ok := s.appPage(w, r, "activity")
3926 if !ok {
3927 return
3928 }
3929 p.ActivityCopy = activityText
3930 if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil {
3931 log.Printf("console: events %s: %v", p.AppName, err)
3932 } else {
3933 // Only link targets this account's assembled view proves still exist.
3934 // Deleted instances and same-named routes owned by another account stay
3935 // plain text; an activity link must never cross the membership boundary.
3936 instances := make(map[string]bool)
3937 for _, route := range p.Detail.Routes {
3938 if route.Account == acct.Sqid && !route.Platform {
3939 instances[route.Host] = true
3940 }
3941 }
3942 for i := range events {
3943 if events[i].TargetType == "release" && events[i].Target != "" {
3944 events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/releases#version-" + url.QueryEscape(events[i].Target)
3945 }
3946 if events[i].TargetType == "channel" && events[i].Target != "" {
3947 events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/deployments"
3948 }
3949 if events[i].TargetType == "instance" && instances[events[i].Target] {
3950 events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) +
3951 "/instances/" + url.PathEscape(events[i].Target)
3952 }
3953 }
3954 p.Events = events
3955 }
3956 s.render(w, http.StatusOK, "app_activity", p)
3957}
3958
3959// handleAppSettings is the app's own record and the controls over it:
3960// identity (and renaming it, while that is still only a record), domains,
3961// the account-move repair, and deletion.
3962func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
3963 p, acct, ok := s.appPage(w, r, "settings")
3964 if !ok {
3965 return
3966 }
3967 section := r.PathValue("section")
3968 if section == "" {
3969 section = "general"
3970 }
3971 if !appSettingsSections[section] {
3972 http.NotFound(w, r)
3973 return
3974 }
3975 if section == "pipeline" {
3976 s.handlePipelineSettingsPage(w, r, p, acct)
3977 return
3978 }
3979 setAppSettingsSection(&p, section)
3980 s.populateAppSettings(r, &p, acct)
3981 s.render(w, http.StatusOK, "app_settings", p)
3982}
3983
3984func setAppSettingsSection(p *page, section string) {
3985 p.SettingsSection = section
3986 for _, item := range []struct{ key, label string }{
3987 {"general", "General"},
3988 {"domains", "Domains"},
3989 {"pipeline", "Release pipeline"},
3990 {"environments", "Environments"},
3991 {"storage", "Storage"},
3992 {"email", "Email"},
3993 {"features", "Feature flags"},
3994 {"advanced", "Advanced"},
3995 {"delete", "Delete"},
3996 } {
3997 href := p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/settings"
3998 if item.key != "general" {
3999 href += "/" + item.key
4000 }
4001 p.SettingsNav = append(p.SettingsNav, SettingsSectionLink{
4002 Key: item.key, Label: item.label, Href: href, Current: item.key == section,
4003 })
4004 }
4005}
4006
4007func (s *Server) populateAppSettings(r *http.Request, p *page, acct *Account) {
4008 p.Settings = s.assembleSettings(r.Context(), p, acct)
4009 // The Errors log level this app resolves to, and where it comes from —
4010 // the one setting the tab itself cannot change. A failed read is said
4011 // rather than defaulted (see ErrLevelUnread), and never fails a page
4012 // that is mostly about something else.
4013 if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil {
4014 log.Printf("console: telemetry %s/%s: %v", acct.Sqid, p.AppName, err)
4015 p.ErrLevelUnread = true
4016 } else {
4017 p.ErrLevelSetting = res
4018 }
4019 // The analytics switch is the OTHER half of that same resolved object —
4020 // one settings file, one read — under the name its own section uses.
4021 p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread
4022 s.loadSwapPolicy(r.Context(), p, acct)
4023}
4024
4025func (s *Server) renderAppSettingsForm(w http.ResponseWriter, r *http.Request, status int, section, msg string, drafts map[string]string) {
4026 p, acct, ok := s.appPage(w, r, "settings")
4027 if !ok {
4028 return
4029 }
4030 setAppSettingsSection(&p, section)
4031 p.FlashErr = msg
4032 p.SettingsRenameTo = drafts["rename"]
4033 p.SettingsDomain = drafts["domain"]
4034 p.SettingsChannel = drafts["channel"]
4035 s.populateAppSettings(r, &p, acct)
4036 s.render(w, status, "app_settings", p)
4037}
4038
4039// activityCap bounds the activity tab's own listing.
4040const activityCap = 50 // the activity tab has the room the overview strip never did
4041
4042// audit best-effort-records an owner action: a failed audit line is logged,
4043// never surfaced, and never fails the action it records.
4044func (s *Server) audit(acct *Account, app, addr, action, detail string) {
4045 if err := s.store.RecordEvent(acct.ID, app, addr, action, detail, time.Now()); err != nil {
4046 log.Printf("console: audit %s %s: %v", action, app, err)
4047 }
4048}
4049
4050// auditActivity records server-derived structure separately from optional
4051// caller context. Authorisation and mutation must already have succeeded.
4052func (s *Server) auditActivity(acct *Account, app, addr string, activity Activity) {
4053 if err := s.store.RecordActivity(acct.ID, app, addr, activity, time.Now()); err != nil {
4054 log.Printf("console: audit %s %s: %v", activity.Action, app, err)
4055 }
4056}
4057
4058// ensureAccountManifest is the view-time backfill for account identity: write
4059// accounts/<sqid>/account.json only when none exists. An existing manifest is
4060// what every box has already materialized — a page view must never clobber
4061// it, because rewriting a different id there would re-home every platform
4062// domain the account owns. It also covers the accounts that predate this
4063// feature (the built-in ops account, and any minted while the bucket was
4064// unreachable). Best-effort: failures are logged, never fail the page — which
4065// a bucketless console (s.cfg.Releases == nil) is the extreme case of: there
4066// is nothing to read OR backfill, so every appPage view on such a console
4067// panicked here until #82 Task 5's Settings-without-a-bucket coverage caught
4068// it; nil is now just another "nothing to do" the caller never sees.
4069func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) {
4070 if s.cfg.Releases == nil {
4071 return
4072 }
4073 key := AccountManifestKey(acct.Sqid)
4074 _, _, err := s.cfg.Releases.Get(ctx, key)
4075 if err == nil {
4076 return // already there — nothing to backfill
4077 }
4078 if !errors.Is(err, os.ErrNotExist) {
4079 log.Printf("console: account manifest %s: %v", key, err)
4080 return
4081 }
4082 if err := WriteAccountManifest(ctx, s.cfg.Releases, acct.ID, acct.Sqid, acct.Name, acct.CreatedAt); err != nil {
4083 log.Printf("console: backfill account manifest %s: %v", key, err)
4084 return
4085 }
4086 log.Printf("console: backfilled account manifest for %s (%s)", acct.Sqid, acct.Name)
4087}
4088
4089// claimOutcome is how one claim attempt ended: the shared vocabulary of the
4090// two claim doors (the browser form's handleNewApp, the CLI API's
4091// handleAPICreateApp), each of which maps every outcome onto its own wire
4092// shape. The distinct failure outcomes exist because the browser door has
4093// always named which step failed ("that name" / "the release store" / "the
4094// app") and collapsing them here would change its copy.
4095type claimOutcome int
4096
4097const (
4098 claimOK claimOutcome = iota
4099 claimInvalid // not a ValidApp name
4100 claimReserved // the literal name the console's own routes shadow (e.g. "new")
4101 claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race
4102 claimCheckFailed // a database lookup failed
4103 claimStoreFailed // the release-store listing failed
4104 claimCreateFailed // the insert itself failed
4105 claimApexHeld // another app already holds the name's platform apex (apex.go)
4106 claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7)
4107)
4108
4109// normalizeAppName is the one normalization both claim doors apply before
4110// claimApp sees the name.
4111func normalizeAppName(name string) string {
4112 return strings.ToLower(strings.TrimSpace(name))
4113}
4114
4115// validBuildProbe bounds the deploy watch's probe path to what it actually
4116// dials: an absolute path, no query, no fragment, no protocol-relative
4117// "//host/..." escape, and printable ASCII only (no control characters, no
4118// bytes a Go net/http client would refuse or mangle). The watch appends its
4119// own cache-busting query parameter (deploy.go), so a probe carrying one
4120// would produce a URL with two — and a probe starting "//" (or a full URL)
4121// would silently point the watch at another host entirely, which is the one
4122// mistake here that could report a DIFFERENT deployment's build as this
4123// one's. The client mirror (validBuildProbe, apps.go) must stay
4124// byte-identical to this rule — a typo the server refuses must cost nothing
4125// against the create verb's own scarce per-day budget, and a typo the
4126// server ACCEPTS but the client refuses would just be a confusing false
4127// refusal.
4128//
4129// This is no longer only a create-time gate: the SAME rule is what keeps the
4130// deploy watch on-host. runDeploy re-checks the echoed probe with the client
4131// mirror before dialing it (deploy.go), so loosening this "just for creates"
4132// silently loosens the watch too — and the shape that matters there is a
4133// value not starting with "/": "https://" + host + "@evil.example/x" parses
4134// host as USERINFO and evil.example as the HOST, which is this comment's
4135// "a DIFFERENT deployment's build" failure arriving by a second route.
4136func validBuildProbe(p string) bool {
4137 if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "?#") {
4138 return false
4139 }
4140 if len(p) > 256 {
4141 return false
4142 }
4143 for i := 0; i < len(p); i++ {
4144 if p[i] < 0x21 || p[i] > 0x7e {
4145 return false
4146 }
4147 }
4148 return true
4149}
4150
4151// ownerExempt reports whether the #79 owner exemption covers prefix, given
4152// the account's own app prefix. It is an allowlist of exactly one prefix: a
4153// prefix it has never been told about is NOT exempt, so a third prefix added
4154// to claimApp's list is refused for an owner until someone deliberately names
4155// it here. See the call site for why that default matters.
4156func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix }
4157
4158// reservedAppName reports whether no app in acct may take name, by create or
4159// by rename.
4160//
4161// "new" is the literal path segment GET /a/{sqid}/apps/new resolves to —
4162// Go 1.22's mux prefers it over apps/{app}, so an app named "new" could
4163// never be viewed.
4164//
4165// The console's own mail identity hangs off consoleMailApp in the ops
4166// account (consolemail.go). The mail layer refuses to tear that domain down
4167// whatever app holds the name, but an app wearing it would still read as
4168// the domain's owner everywhere else.
4169func reservedAppName(acct *Account, name string) bool {
4170 return name == "new" || (acct.Sqid == release.OpsAccount && name == consoleMailApp)
4171}
4172
4173// claimApp is the permanent claim every existing door makes: claimAppWith
4174// with a zero expiresAt.
4175func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) {
4176 return s.claimAppWith(ctx, acct, name, addr, placement, ownerClaim, rq, time.Time{})
4177}
4178
4179// claimAppWith claims an app name for acct — once, ever. A name is refused when
4180// the apps table has it, when ANY owner exists for it (CLI-granted apps stay
4181// claim-proof), or when the release store already holds anything under
4182// apps/<name>/. This is the ONLY place the claim checks live: both doors
4183// call it, so they cannot drift apart. Authentication, rate limits, the
4184// audit row and the response are the callers' business.
4185//
4186// placement is an ALREADY-VALIDATED "" or "fleet/<name>" string — the
4187// caller (handleAPICreateApp) checks grammar and fleet ownership itself,
4188// the same inline checks placeAppTarget's target uses, BEFORE calling this,
4189// so an invalid or foreign placement never reaches here at all and never
4190// costs a claim attempt. handleNewApp (the browser door) has no placement
4191// concept and always passes "".
4192//
4193// placed, the second return, is what actually ended up stored: placement
4194// itself on a successful write, "" on a failed one (or when placement was
4195// "" to begin with — nothing was ever asked for). On a successful claim the
4196// placement is stamped as a SEPARATE store write, after CreateAppIn — not
4197// threaded into that INSERT — because CreateAppIn has many other, unrelated
4198// callers; a failure of this second write is logged and does NOT undo the
4199// claim (the same best-effort shape ensureAccountManifest below already
4200// uses): an app that exists but isn't placed is recoverable with `carlos
4201// apps place`, while failing the whole request after the row is already
4202// committed would leave the caller unable to tell whether retrying `apps
4203// create` means "taken" or "try again". `placed` is what lets the CALLER
4204// (handleAPICreateApp, echoed to the wire as "placement") make that failure
4205// OBSERVABLE instead of silently trusting the request — a caller that never
4206// checks the echo cannot tell "placed" from "claim succeeded, placement
4207// silently didn't".
4208//
4209// ownerClaim exempts the ACCOUNT-QUALIFIED prefix check below, and nothing
4210// else. The guard exists so a stranger cannot inherit objects somebody else
4211// shipped — but apps/<sqid>/<app>/ is namespaced BY ACCOUNT, so objects under
4212// a given sqid can only be that account's. An OWNER of that account claiming
4213// a name under its own prefix is adopting their own release history, which is
4214// the whole of issue #79: an app first shipped direct-to-bucket (direct mode
4215// skips the claim) could otherwise never be given a row, and so stayed
4216// invisible to the console forever with no recovery but deleting its history.
4217//
4218// It is OWNER, not member: adopting existing release history is an ownership
4219// act. It is FALSE for every service principal — a service has no role at
4220// all, and an account-scoped one legitimately reaches this function, so
4221// without an explicit pin the exemption would hand every CI credential the
4222// same power.
4223//
4224// held is set only with claimApexHeld: who holds the name's platform apex, so
4225// a door can say so without asking again.
4226//
4227// A zero expiresAt is the permanent claim every existing door makes, and it
4228// refuses a tmp- name: nothing permanent may be created in that namespace
4229// (spec ruling 7). A non-zero one is /new-temporary's claim: it refuses any
4230// other name, runs with ownerClaim false whatever the caller's role (spec
4231// §3, so any object under the prefix refuses), and inserts the row with its
4232// expiry in the same statement that claims the name.
4233func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq, expiresAt time.Time) (outcome claimOutcome, placed string, held *apexHold) {
4234 if !release.ValidApp(name) {
4235 return claimInvalid, "", nil
4236 }
4237 // Reserved before any store write, same as an invalid name.
4238 if reservedAppName(acct, name) {
4239 return claimReserved, "", nil
4240 }
4241 temporary := !expiresAt.IsZero()
4242 if release.IsTemporaryName(name) != temporary {
4243 if temporary {
4244 return claimInvalid, "", nil
4245 }
4246 return claimTemporaryReserved, "", nil
4247 }
4248 if temporary {
4249 ownerClaim = false
4250 }
4251 // Taken means taken IN THIS ACCOUNT — app names are unique per account,
4252 // never globally, so another account's "blog" is not a conflict.
4253 if has, err := s.store.HasAppIn(acct.ID, name); err != nil {
4254 log.Printf("console: claim check failed: account=%q app=%q op=%q err=%v", acct.Sqid, name, "HasAppIn", err)
4255 return claimCheckFailed, "", nil
4256 } else if has {
4257 return claimTaken, "", nil
4258 }
4259 // The pre-accounts checks still apply to the OPS account, where every
4260 // CLI-granted and pre-accounts app lives: a name with grants or bucket
4261 // objects there stays claim-proof.
4262 if acct.ID == OpsAccountID {
4263 if has, err := s.store.HasOwners(name); err != nil {
4264 log.Printf("console: claim check failed: account=%q app=%q op=%q err=%v", acct.Sqid, name, "HasOwners", err)
4265 return claimCheckFailed, "", nil
4266 } else if has {
4267 return claimTaken, "", nil
4268 }
4269 }
4270 // The account's own prefix, plus — for ops only — the legacy unqualified
4271 // one an unmigrated app's objects are still at. This one legacy read
4272 // OUTLIVES the retired read-fallback, and deliberately: it is explicit
4273 // rather than a resolution path, and a name whose legacy objects still
4274 // exist is not free. Handing it out would put a stranger's app in the way
4275 // of the `accounts migrate -from legacy` that is still owed to those
4276 // objects. For a non-ops account the legacy prefix belongs to somebody
4277 // else's app and must NOT block the name.
4278 ownPrefix := release.AppPrefix(acct.Sqid, name)
4279 adoptingExistingApp := false
4280 prefixes := []string{ownPrefix}
4281 if acct.ID == OpsAccountID {
4282 prefixes = append(prefixes, release.LegacyAppPrefix(name))
4283 }
4284 // The release-store deadline lives HERE, not in the doors, so both doors
4285 // give the bucket the same ten seconds the browser form always has —
4286 // and the database checks above stay outside it, as they always were.
4287 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
4288 defer cancel()
4289 // A name whose platform apex another app of the account already holds is
4290 // not free either, whatever the tables say: the new app would never get
4291 // its own URL (apex.go).
4292 if hold, err := s.apexHolder(ctx, acct, name); err != nil {
4293 if apexBucketFailure(err) {
4294 return claimStoreFailed, "", nil
4295 }
4296 log.Printf("console: claim check failed: account=%q app=%q op=%q err=%v", acct.Sqid, name, "apexHolder", err)
4297 return claimCheckFailed, "", nil
4298 } else if hold != nil {
4299 return claimApexHeld, "", hold
4300 }
4301 for _, prefix := range prefixes {
4302 keys, err := s.cfg.Releases.List(ctx, prefix)
4303 if err != nil {
4304 return claimStoreFailed, "", nil
4305 }
4306 if s.cfg.HostClaim && prefix == ownPrefix {
4307 free, err := s.claimAdmissionNameFree(ctx, acct.Sqid, name)
4308 if err != nil {
4309 return claimStoreFailed, "", nil
4310 }
4311 if free {
4312 continue
4313 }
4314 }
4315 if len(keys) == 0 {
4316 continue
4317 }
4318 // The owner exemption is an ALLOWLIST: it NAMES the one prefix it
4319 // covers — the account's own, apps/<sqid>/<app>/ — rather than
4320 // excluding the one it doesn't. The two forms are identical over
4321 // today's two prefixes; they differ in what happens to a THIRD one
4322 // added later. Written as an exclusion, any prefix appended to the
4323 // slice above would be exempted for an owner by default, fail-open
4324 // in a guard whose whole job is refusing. Written this way a new
4325 // prefix is refused until someone deliberately adds it here.
4326 //
4327 // The legacy unqualified prefix (ops only) is therefore outside the
4328 // exemption and still refuses whoever is asking: those objects are
4329 // owed an `accounts migrate -from legacy`, and a name handed out
4330 // over them puts an app in that migration's way.
4331 if ownerClaim && ownerExempt(prefix, ownPrefix) {
4332 adoptingExistingApp = true
4333 continue
4334 }
4335 return claimTaken, "", nil
4336 }
4337 region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp)
4338 if s.beforeClaimInsert != nil {
4339 s.beforeClaimInsert()
4340 }
4341 created, err := s.createClaimAppRow(ctx, acct, name, func(st *Store) (bool, error) {
4342 if temporary {
4343 return st.CreateTemporaryAppInRegion(acct.ID, name, addr, region, source, time.Now(), expiresAt)
4344 }
4345 return st.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now())
4346 })
4347 if err != nil {
4348 return claimCreateFailed, "", nil
4349 }
4350 if !created { // lost a claim race after the checks
4351 return claimTaken, "", nil
4352 }
4353 // Spec §6.5: one line per creation, in every switch state (codex
4354 // plan-R6 1). Only reached once the row is ours.
4355 log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source)
4356 if placement != "" {
4357 if err := s.store.SetAppPlacementContext(ctx, acct.ID, name, placement); err != nil {
4358 // Best-effort, like ensureAccountManifest below: the claim itself
4359 // already succeeded and must not be undone by this — see this
4360 // function's doc comment for why. `placed` stays "" so the
4361 // caller's echo tells the truth about it.
4362 log.Printf("console: claim %s/%s: set placement %s: %v", acct.Sqid, name, placement, err)
4363 } else {
4364 placed = placement
4365 }
4366 }
4367 // The app→account linkage needs no manifest of its own any more: it IS the
4368 // bucket key path (apps/<sqid>/<app>/…), written by the first ship. The
4369 // account's own manifest is what the agent materializes, and minting
4370 // already wrote it.
4371 s.ensureAccountManifest(ctx, acct)
4372 // Existing objects belong to an app whose console record is being
4373 // restored. Preserve its pipeline, including absence (the legacy ladder).
4374 // Only a new app gets the default edge pipeline (pipelines v2 spec §2).
4375 if !adoptingExistingApp {
4376 if _, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, name, release.DefaultPipelineChannels(), addr); err != nil {
4377 log.Printf("console: claim %s/%s: set default pipeline: %v", acct.Sqid, name, err)
4378 }
4379 }
4380 return claimOK, placed, nil
4381}
4382
4383// handleNewAppForm is the create-app page; the claim itself stays on
4384// POST /a/{sqid}/apps/new (handleNewApp).
4385func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) {
4386 addr, raw, acct, role, ok := s.account(w, r)
4387 if !ok {
4388 return
4389 }
4390 p := s.accountPage(w, r, addr, raw, acct, role)
4391 p.Title = "Create an app"
4392 p.ActiveNav = "apps"
4393 p.RegionPicker = s.newAppPicker(r.Context(), s.personIP(r))
4394 s.render(w, http.StatusOK, "app_new", p)
4395}
4396
4397// handleNewApp is the browser door onto claimApp: the account page's New app
4398// form. Any member may claim a FREE name — creation grants nothing promotion
4399// doesn't already gate — and unlike the CLI door it carries no rate limit: a
4400// session behind CSRF is not the stolen-credential shape the API budget
4401// exists for, and this form is the escape hatch when that budget is honestly
4402// spent.
4403//
4404// An OWNER may additionally claim a name whose objects already exist under
4405// this account's own prefix (#79, claimApp's ownerClaim). A plain member
4406// cannot: adopting existing release history is an ownership act.
4407func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
4408 addr, raw, acct, role, ok := s.account(w, r)
4409 if !ok {
4410 return
4411 }
4412 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4413 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
4414 return
4415 }
4416 home := "/a/" + url.PathEscape(acct.Sqid) + "/"
4417 formPage := home + "apps/new"
4418 fail := func(msg string) {
4419 s.setFlash(w, "err", msg)
4420 http.Redirect(w, r, formPage, http.StatusSeeOther)
4421 }
4422 name := normalizeAppName(r.FormValue("name"))
4423 // The region is resolved inside claimApp, once it knows the claim is not
4424 // an adoption; the timezone and the address only ever feed that guess.
4425 rq, regionOK := s.pickedRegion(r.Context(), r)
4426 if !regionOK {
4427 fail(regionCopy.Unavailable)
4428 return
4429 }
4430 // The browser door never places at create time, so the second return
4431 // (what actually stuck) is always "" here and there is nothing to echo
4432 // it to — this form has no placement concept at all.
4433 defer s.planLock(acct)()
4434 if err := s.checkCreateApp(r.Context(), acct, name); err != nil {
4435 var planCap *planCapError
4436 if errors.As(err, &planCap) {
4437 fail(planCap.Browser())
4438 return
4439 }
4440 log.Printf("console: create %s/%s: plan cap: %v", acct.Sqid, name, err)
4441 fail("Could not create the app. Please try again.")
4442 return
4443 }
4444 outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
4445 switch outcome {
4446 case claimInvalid:
4447 fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
4448 case claimReserved:
4449 fail("That name is reserved.")
4450 case claimTemporaryReserved:
4451 fail("Names starting with tmp- are kept for temporary sites. Choose another name.")
4452 case claimTaken:
4453 fail("That name is taken.")
4454 case claimApexHeld:
4455 fail("That name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held) + ".")
4456 case claimCheckFailed:
4457 fail("Could not check that name. Please try again.")
4458 case claimStoreFailed:
4459 fail("Could not check the release store. Please try again.")
4460 case claimCreateFailed:
4461 fail("Could not create the app. Please try again.")
4462 case claimOK:
4463 detail := "created app " + name
4464 if s.appRegionsOn() {
4465 region, _, _ := s.store.AppRegion(acct.ID, name)
4466 files, _ := s.liveEdges(r.Context())
4467 detail = fmt.Sprintf(regionCopy.AuditCreated, name, s.placeOf(region, s.regionCands(files)))
4468 }
4469 s.audit(acct, name, addr, "create", detail)
4470 s.setFlash(w, "ok", "Created app "+name+" — it's yours.")
4471 http.Redirect(w, r, home+"apps/"+url.PathEscape(name), http.StatusSeeOther)
4472 }
4473}
4474
4475// tagSuffix names what a stable promotion's tag ended up as, or why it
4476// carries none (release versioning spec, 2026-08-08) — shared between the
4477// fresh-promote and no-op flashes in handlePromote below it, because
4478// PromoteOnLadder's stableTag runs on EVERY landing on stable, no-op
4479// included: a repeat promotion can heal a release that ended up nameless
4480// from a previous attempt's failed write, and the flash must say so exactly
4481// as loudly as the promotion that cut the tag the first time would have.
4482// "" off stable and for a version that carries no tag either way.
4483func tagSuffix(res release.PromoteResult) string {
4484 suffix := ""
4485 if res.Tag != "" {
4486 suffix += " — tagged " + res.Tag
4487 }
4488 if res.TagWarning != "" {
4489 suffix += " (" + res.TagWarning + ")"
4490 }
4491 return suffix
4492}
4493
4494// handlePromote points a channel at a shipped version. It is the app's ONE
4495// promote door — the releases table posts to it, and so does every step of the
4496// ladder section — which is what makes the ladder and the passkey gate below
4497// worth anything: a hand-written POST meets exactly the doors a button does.
4498func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
4499 addr, raw, acct, role, app, ok := s.accountApp(w, r)
4500 if !ok {
4501 return
4502 }
4503 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4504 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
4505 return
4506 }
4507 if s.cfg.Key == nil {
4508 s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
4509 "No signing key on this host — the console is running read-only.")
4510 return
4511 }
4512 back := func(kind, msg string) {
4513 s.setFlash(w, kind, msg)
4514 dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
4515 if tab := releaseBackTab(r); tab != "" {
4516 dest += "/" + tab
4517 }
4518 http.Redirect(w, r, dest, http.StatusSeeOther)
4519 }
4520 channel := strings.TrimSpace(r.FormValue("new_channel"))
4521 if channel == "" {
4522 channel = strings.TrimSpace(r.FormValue("channel"))
4523 }
4524 if !release.ValidChannel(channel) {
4525 back("err", "Invalid channel name.")
4526 return
4527 }
4528 version := strings.TrimSpace(r.FormValue("version"))
4529 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
4530 defer cancel()
4531 if !release.ValidVersion(version) {
4532 back("err", "Invalid version.")
4533 return
4534 }
4535 if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil {
4536 back("err", "Version "+version+" is not a shipped release of "+app+".")
4537 return
4538 }
4539 // Which gate this promotion meets is the pipeline's answer (release
4540 // pipelines v2 §5), so the pipeline is read before any of them. An
4541 // unreadable one refuses: "not safely nil" must never fall back to the
4542 // legacy ladder under the app that opted out of it.
4543 pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
4544 if perr != nil {
4545 log.Printf("console: promote %s/%s: read pipeline: %v", acct.Sqid, app, perr)
4546 back("err", "Could not read the pipeline for "+app+". Nothing has been changed.")
4547 return
4548 }
4549 // The stable rung's owner gate fires BEFORE the confirm card: a member
4550 // probing an owner-only promote must meet the byte-identical 404, never a
4551 // card that concedes the action would otherwise proceed. promoteGate
4552 // re-checks this for owners on the confirmed POST; the duplication is the
4553 // point — the confirm step must sit strictly between owner 404 and
4554 // requireFreshAuth without weakening either.
4555 //
4556 // Legacy path only. A PIPELINED app's channel named "stable" is an
4557 // app-defined channel that happens to share a rung's name, and its gates
4558 // are the ones its pipeline declares (spec §7: the unconditional
4559 // stable-passkey becomes legacy-path-only) — reading the rung table for
4560 // it would impose an owner rule the account never asked for and cannot
4561 // remove.
4562 if rung, _ := release.Rung(channel); pipe == nil && rung == release.RungStable && role != RoleOwner {
4563 http.NotFound(w, r)
4564 return
4565 }
4566 if !confirmed(r) {
4567 s.confirmPromote(w, r, addr, raw, acct, role, app, channel, version, pipe)
4568 return
4569 }
4570 // The channel's own gate, LAST — after membership, after the app check,
4571 // after CSRF, and after every refusal that costs nothing. Order is
4572 // load-bearing: a non-member must meet the byte-identical 404 before
4573 // step-up is ever mentioned, or the redirect into Keymail would confirm
4574 // what the 404 is there to hide.
4575 if !s.promoteGate(w, r, addr, raw, role, channel, pipe) {
4576 return
4577 }
4578 // An approvals rule turns the promotion into a proposal — but only now,
4579 // with the proposer's presence just proved by promoteGate's step-up,
4580 // which is what earns their own confirmation its place in the quorum
4581 // (spec §4). Order is checked first, so a proposal that could never
4582 // execute never goes out for signatures.
4583 //
4584 // `pipe != nil` is already implied by a nonzero rule — channelRules
4585 // answers the zero ChannelRules for a nil pipeline — and is written out
4586 // anyway, because VersionStage below DEREFERENCES the pipeline it is
4587 // handed and would panic on nil.
4588 if rules := channelRules(pipe, channel); pipe != nil && rules.PromoteApprovals >= 1 {
4589 stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe)
4590 if serr != nil {
4591 log.Printf("console: promote %s/%s: stage: %v", acct.Sqid, app, serr)
4592 back("err", "Could not work out where "+version+" sits in the pipeline. Nothing has been changed.")
4593 return
4594 }
4595 if aerr := release.PipelinePromotionAllowed(pipe, stage, channel); aerr != nil {
4596 back("err", "Promote refused: "+aerr.Error())
4597 return
4598 }
4599 // Pin the target channel's head at propose time (spec §4: a head
4600 // that moved underneath a proposal invalidates it); "" when the
4601 // channel has no pointer yet. An unreadable head refuses — a pin
4602 // that cannot be read cannot be recorded.
4603 head, herr := s.channelHead(ctx, acct, app, channel)
4604 if herr != nil {
4605 log.Printf("console: promote %s/%s: read channel %s: %v", acct.Sqid, app, channel, herr)
4606 back("err", "Could not read channel "+channel+". Nothing has been changed.")
4607 return
4608 }
4609 kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote,
4610 promotePayload{Version: version, Channel: channel, ObservedHead: head}, rules.PromoteApprovals, addr)
4611 back(kind, msg)
4612 return
4613 }
4614 // PromoteOnLadder, not Promote: the ladder is enforced here, not merely
4615 // drawn. It also records the promotion on the pointer it moves — what
4616 // moved, from which rung to which, and which member said so.
4617 res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version,
4618 release.PromoteOptions{Actor: addr})
4619 if err != nil {
4620 log.Printf("console: promote %s %s -> %s: %v", app, version, channel, err)
4621 // The ladder's own refusals are the useful text here — they name the
4622 // rung this build is on and the one it would have to come through.
4623 back("err", "Promote refused: "+err.Error())
4624 return
4625 }
4626 if res.NoOp {
4627 back("ok", channel+" already serves "+version+tagSuffix(res)+" — nothing to do.")
4628 return
4629 }
4630 s.auditActivity(acct, app, addr, Activity{Action: "promote", TargetType: "channel", Target: channel, Detail: "promoted " + version + " to " + channel})
4631 msg := "Promoted " + version + " to " + channel + tagSuffix(res) + "."
4632 // Whether this app bakes at all is the production flag's business (bake
4633 // controls spec §2): a non-production app's boxes adopt a held channel
4634 // immediately, so the window note below would be a lie for them. Read the
4635 // flag before the checkbox, which may still turn it on in this same
4636 // request.
4637 flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
4638 if ferr != nil {
4639 log.Printf("console: production flag %s: %v", app, ferr)
4640 }
4641 // An unreadable flag fails TOWARD baking, exactly as the adopt path does
4642 // (spec §4) — so the note is still the truth about what the boxes will do.
4643 baking := ferr != nil || (flag != nil && flag.Production)
4644 // The spec §7 prompt: promote-to-stable asked "is this app in production
4645 // now?" and the human said yes — in the same passkey-confirmed action.
4646 // Turning ON is any member's anyway, so no extra gate; flips only when
4647 // the flag is actually off, so a stray field on a repeat is a no-op.
4648 // Both failure paths SAY so. The human ticked a box in a passkey-confirmed
4649 // action; a log line they will never read is not an answer, and a silent
4650 // skip leaves them believing the app bakes when it does not.
4651 if r.FormValue("enable_bake") == "1" {
4652 switch {
4653 case ferr != nil:
4654 // The read failed, so the flip was never attempted: writing over a
4655 // flag nobody could read is the repair path's job (the app page's
4656 // re-declare control), not a promote's side effect. baking is true
4657 // here only because an unreadable flag fails TOWARD baking — which
4658 // settles nothing about what this app has declared.
4659 msg += " The production flag could not be read, so the safety delay was not enabled — use the toggle on the app's Versions tab."
4660 case baking:
4661 // Already on: a stray checkbox on a repeat is a no-op, as before.
4662 default:
4663 if tmsg, _ := s.temporaryRefusal(acct, app, "turn its safety delay on"); tmsg != "" {
4664 msg += " " + tmsg
4665 break
4666 }
4667 if err := s.claimAppMutation(ctx, acct, app, "protection", func(ctx context.Context) error {
4668 return release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr)
4669 }); err != nil {
4670 log.Printf("console: enable_bake %s: %v", app, err)
4671 msg += " Turning the safety delay on failed — use the toggle on the app's Versions tab."
4672 } else {
4673 s.audit(acct, app, addr, "production", "enabled the safety delay (production: on, at stable promote)")
4674 msg += " Safety delay enabled."
4675 baking = true
4676 }
4677 }
4678 }
4679 // One announcement (carlos-deploy §3), placed after the optional flag flip
4680 // rather than beside the promote's own audit row above, so it covers BOTH
4681 // of this handler's box-consumed writes — the pointer and, when the
4682 // checkbox was ticked, the production flag — in a single bump.
4683 s.bumpSerial(ctx)
4684 // Whether the boxes will actually hold this, said the same way the CLI
4685 // door's `held` field says it (channelHeld, apicli.go): the window comes
4686 // from the pipeline when there is one, and a PIPELINED app never consults
4687 // the production flag at all — its bake policy is the pipeline's alone
4688 // (spec §3), which is exactly what adopt.go does.
4689 if holdWindow(pipe, channel) > 0 && (pipe != nil || baking) {
4690 msg += " It goes live everywhere after the safety delay — rollbacks skip it."
4691 }
4692 back("ok", msg)
4693}
4694
4695// promoteGate applies the gate the TARGET CHANNEL carries. Which table it
4696// reads is the pipeline's answer, and the two are deliberately exclusive:
4697//
4698// pipe == nil — the legacy ladder (pipelines spec §3's gate table). Below
4699// stable, membership — which the caller has already proved — is the gate,
4700// and this waves the promotion through. Stable is different in kind: it
4701// puts a build in front of every user of the app, so it takes an account
4702// OWNER, and a live session is not sufficient proof that the owner is the
4703// one asking.
4704// pipe != nil — the channel's own declared rules (release pipelines v2 §2).
4705// A passkey rule, or an approvals rule (whose every confirmation is
4706// passkey-backed, the proposer's included), demands the step-up; a channel
4707// with neither — and every channel outside the pipeline, canary/* included
4708// — is waved through on membership. There is no owner rule on this path:
4709// the pipeline says what a channel costs, and ownership is not one of the
4710// things it can say (spec §2). That places the weight on WHO MAY WRITE A
4711// PIPELINE — any member of the account, or an admin-role service
4712// credential (handleAPIPipelinePut) — so an account that wants a channel
4713// only its owner can promote onto expresses that as a passkey plus
4714// approvals, not by naming the channel after a rung.
4715//
4716// The ORDER is the same on both paths and is the load-bearing part: the
4717// byte-identical 404 first, requireFreshAuth strictly last. It returns false
4718// when it has already written the response — either the 404 or the step-up
4719// redirect — and the caller must simply return.
4720func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw, role, channel string, pipe *release.Pipeline) bool {
4721 if pipe != nil {
4722 rules := channelRules(pipe, channel)
4723 if !rules.Passkey && rules.PromoteApprovals < 1 {
4724 return true
4725 }
4726 return s.requireFreshAuth(w, r, addr, raw)
4727 }
4728 if rung, _ := release.Rung(channel); rung != release.RungStable {
4729 return true
4730 }
4731 if role != RoleOwner {
4732 http.NotFound(w, r)
4733 return false
4734 }
4735 return s.requireFreshAuth(w, r, addr, raw)
4736}
4737
4738// handleRollback swaps a channel back to its previous version. On a pipelined
4739// channel it meets that channel's own rules first (see the gate below the
4740// confirm card): the same passkey and the same quorum a promotion onto it
4741// would meet, because it is the same pointer and the same blast radius.
4742func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
4743 addr, raw, acct, role, app, ok := s.accountApp(w, r)
4744 if !ok {
4745 return
4746 }
4747 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4748 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
4749 return
4750 }
4751 if s.cfg.Key == nil {
4752 s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
4753 "No signing key on this host — the console is running read-only.")
4754 return
4755 }
4756 back := func(kind, msg string) {
4757 s.setFlash(w, kind, msg)
4758 dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
4759 if tab := releaseBackTab(r); tab != "" {
4760 dest += "/" + tab
4761 }
4762 http.Redirect(w, r, dest, http.StatusSeeOther)
4763 }
4764 channel := strings.TrimSpace(r.FormValue("channel"))
4765 if !release.ValidChannel(channel) {
4766 back("err", "Invalid channel name.")
4767 return
4768 }
4769 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
4770 defer cancel()
4771 // Which gate this rollback meets is the pipeline's answer, so it is read
4772 // before the confirm card — the same reasoning, and the same order, as
4773 // handlePromote.
4774 pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
4775 if perr != nil {
4776 log.Printf("console: rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr)
4777 back("err", "Could not read the pipeline for "+app+". Nothing has been changed.")
4778 return
4779 }
4780 // The stable rung's owner gate fires BEFORE the confirm card (Task 9
4781 // review, IMPORTANT 1 — mirroring handlePromote's own comment on this
4782 // exact point): a non-owner member probing a legacy stable rollback must
4783 // meet the byte-identical 404, never a card that concedes the action
4784 // would otherwise proceed. Legacy path only — a PIPELINED app's channel
4785 // named "stable" is governed by its own declared rules below, not this
4786 // rung table (pre-existing gap fix, Task 8/9 review).
4787 if rung, _ := release.Rung(channel); pipe == nil && rung == release.RungStable && role != RoleOwner {
4788 http.NotFound(w, r)
4789 return
4790 }
4791 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
4792 if err != nil {
4793 back("err", "Could not read channel "+channel+".")
4794 return
4795 }
4796 if len(cur.History) == 0 {
4797 back("err", "Channel "+channel+" has no previous version to roll back to.")
4798 return
4799 }
4800 target := cur.History[0]
4801 if !confirmed(r) {
4802 s.confirmRollback(w, r, addr, raw, acct, role, app, channel, cur.Version, target)
4803 return
4804 }
4805 // The card pinned where it promised to go; a channel whose history moved
4806 // since then must be looked at again, not rolled to wherever it points
4807 // now. Checked only when the pin is present: a pinless POST (the CLI
4808 // shape) rolls back the current head exactly as it always has.
4809 if pinned := strings.TrimSpace(r.FormValue("target")); pinned != "" && pinned != target {
4810 back("err", "Channel "+channel+" changed while you were confirming — review the rollback again.")
4811 return
4812 }
4813 // A pipelined channel's own rules gate a rollback exactly as they gate a
4814 // promotion (release pipelines v2 §2): a rollback is a pointer write, and
4815 // the build it puts in front of every user of the app is one the
4816 // channel's rules exist to govern. requireFreshAuth stays LAST, after the
4817 // confirm card and the pin — and a channel with no rules, or an app with
4818 // no pipeline, behaves exactly as it did.
4819 if rules := channelRules(pipe, channel); rules.Passkey || rules.PromoteApprovals >= 1 {
4820 if !s.requireFreshAuth(w, r, addr, raw) {
4821 return
4822 }
4823 if rules.PromoteApprovals >= 1 {
4824 // Pinned to the version this rollback lands on, and flagged
4825 // hotfix: a rollback is out-of-order by nature, so the order
4826 // re-check at execution is the one check it must not meet. The
4827 // pointer ends up serving the pinned build either way; what
4828 // differs from release.Rollback is the shape of the trail it
4829 // leaves — a promotion of the older build, confirmed by a
4830 // quorum, rather than a history pop.
4831 kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote,
4832 promotePayload{Version: target, Channel: channel, Hotfix: true, Rollback: true,
4833 ObservedHead: cur.Version},
4834 rules.PromoteApprovals, addr)
4835 back(kind, msg)
4836 return
4837 }
4838 } else if pipe == nil {
4839 // Pre-existing gap (Task 8 review): the legacy ladder's stable rung
4840 // carried NO gate at all on this door, while promoteGate takes
4841 // owner+passkey for a PROMOTION onto it and the API door 403s a
4842 // rollback of it outright — a rollback lands the same pointer write
4843 // in front of the same users. Mirror promoteGate's legacy-path shape
4844 // exactly: byte-identical 404 for a non-owner (already refused
4845 // above, before the confirm card), requireFreshAuth last here — the
4846 // re-check below is deliberate duplication of the owner condition
4847 // (handlePromote's own comment: the confirm step must sit strictly
4848 // between owner 404 and requireFreshAuth without weakening either),
4849 // not a second gate. Every other legacy rung is unchanged —
4850 // membership alone, as before.
4851 if rung, _ := release.Rung(channel); rung == release.RungStable {
4852 if !s.requireFreshAuth(w, r, addr, raw) {
4853 return
4854 }
4855 }
4856 }
4857 if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
4858 log.Printf("console: rollback %s %s: %v", app, channel, err)
4859 back("err", "Rollback failed: "+err.Error())
4860 return
4861 }
4862 s.auditActivity(acct, app, addr, Activity{Action: "rollback", TargetType: "channel", Target: channel, Detail: "rolled back " + channel + " to " + target})
4863 s.bumpSerial(ctx)
4864 back("ok", "Rolled back "+channel+" to "+target+".")
4865}
4866
4867// handleProduction flips the app's production flag (2026-08-04 console bake
4868// controls spec §7). ON is any member's to do, instantly — the safe
4869// direction stays cheap. OFF removes a supply-chain guard from every
4870// subscriber of the app, so it is the owner's, behind the passkey, and the
4871// fleet ratchets it in on each box's own clock.
4872//
4873// Gate order is the mandated one, and the two owner-side gates apply to the
4874// OFF direction ONLY: session/membership (accountApp) → CSRF → signing-key
4875// availability → value parse → for OFF: owner (byte-identical 404) →
4876// requireFreshAuth LAST. The refusals that cost nothing and reveal nothing
4877// come first; the 404 comes before any step-up redirect, which would
4878// otherwise confirm to a member-probing-as-stranger that the account exists.
4879func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) {
4880 addr, raw, acct, role, app, ok := s.accountApp(w, r)
4881 if !ok {
4882 return
4883 }
4884 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4885 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
4886 return
4887 }
4888 if s.cfg.Key == nil {
4889 s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
4890 "No signing key on this host — the console is running read-only.")
4891 return
4892 }
4893 back := func(kind, msg string) {
4894 s.setFlash(w, kind, msg)
4895 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
4896 }
4897 var on bool
4898 switch r.FormValue("production") {
4899 case "on":
4900 on = true
4901 case "off":
4902 on = false
4903 default:
4904 back("err", "Invalid production value.")
4905 return
4906 }
4907 if on {
4908 defer s.lockApp(acct.Sqid, app)()
4909 if msg, _ := s.temporaryRefusal(acct, app, "mark it as production"); msg != "" {
4910 back("err", msg)
4911 return
4912 }
4913 }
4914 if !on {
4915 if role != RoleOwner {
4916 http.NotFound(w, r)
4917 return
4918 }
4919 if !s.requireFreshAuth(w, r, addr, raw) {
4920 return
4921 }
4922 }
4923 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
4924 defer cancel()
4925 if err := s.claimAppMutation(ctx, acct, app, "protection", func(ctx context.Context) error {
4926 return release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, on, addr)
4927 }); err != nil {
4928 log.Printf("console: production %s -> %v: %v", app, on, err)
4929 back("err", "Could not change the safety delay: "+err.Error())
4930 return
4931 }
4932 // The flag is read on every box's adopt path (it decides whether a channel
4933 // bakes at all), so both directions are box-consumed. OFF especially: it
4934 // releases held versions, and that is the direction people wait on.
4935 s.bumpSerial(ctx)
4936 if on {
4937 s.audit(acct, app, addr, "production", "enabled the safety delay (production: on)")
4938 back("ok", "Safety delay enabled — new versions now wait on beta and stable before going live.")
4939 } else {
4940 s.audit(acct, app, addr, "production", "disabled the safety delay (production: off)")
4941 back("ok", "Safety delay turning off — takes effect after ~72h.")
4942 }
4943}
4944
4945// handleFastTrack waives the bake window for one channel's CURRENT head —
4946// owner + passkey, always: this is the loud instant path past the window
4947// (spec §5, §9), and the pointer wears the badge forever.
4948func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
4949 addr, raw, acct, role, app, ok := s.accountApp(w, r)
4950 if !ok {
4951 return
4952 }
4953 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4954 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
4955 return
4956 }
4957 if s.cfg.Key == nil {
4958 s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
4959 "No signing key on this host — the console is running read-only.")
4960 return
4961 }
4962 back := func(kind, msg string) {
4963 s.setFlash(w, kind, msg)
4964 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
4965 }
4966 channel := strings.TrimSpace(r.FormValue("channel"))
4967 if !release.ValidChannel(channel) {
4968 back("err", "Invalid channel name.")
4969 return
4970 }
4971 if role != RoleOwner {
4972 http.NotFound(w, r)
4973 return
4974 }
4975 // The confirm card sits strictly between the owner 404 and the passkey:
4976 // only owners ever see it, and the confirmed replay still meets
4977 // requireFreshAuth below.
4978 if !confirmed(r) {
4979 s.confirmFastTrack(w, r, addr, raw, acct, role, app, channel)
4980 return
4981 }
4982 if !s.requireFreshAuth(w, r, addr, raw) {
4983 return
4984 }
4985 // A channel with no bake window has nothing to waive, and the waiver is
4986 // not inert on it: FastTrack re-signs the pointer, which re-stamps
4987 // PromotedAt — and PromotedAt is what CanaryExpired reads, so waiving a
4988 // canary would silently renew its TTL lease and badge a channel that
4989 // never bakes as "fast-tracked past the bake window", forever.
4990 //
4991 // Placed HERE, after both owner gates, and not with the cheap refusals
4992 // above: the window is pure vocabulary and leaks nothing on its own, but a
4993 // refusal that fires before the owner check would make this endpoint's
4994 // response vary with the channel name — and now with the app's PIPELINE —
4995 // for a NON-OWNER, who must see the byte-identical 404 and nothing else.
4996 // The mandated order (session → CSRF → owner 404 → requireFreshAuth LAST)
4997 // then leaves only this spot. The cost is a passkey tap spent on a
4998 // refusal; the alternative is an oracle.
4999 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
5000 defer cancel()
5001 // holdWindow, not HoldFor: a pipelined channel's window is its own, for a
5002 // name the legacy table may never have heard of — and an app that
5003 // declared a 0s bake on a channel has nothing to waive there either.
5004 // An unreadable pipeline refuses rather than falling back to the legacy
5005 // table, which could offer a waiver on a channel the pipeline gives no
5006 // window at all.
5007 pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
5008 if perr != nil {
5009 log.Printf("console: fast-track %s/%s: read pipeline: %v", acct.Sqid, app, perr)
5010 back("err", "Could not read the pipeline for "+app+". Nothing has been changed.")
5011 return
5012 }
5013 if holdWindow(pipe, channel) == 0 {
5014 back("err", "Channel "+channel+" has no safety delay running — there is nothing to skip.")
5015 return
5016 }
5017 // The head is read here rather than only when a pin is present, because a
5018 // proposal has to PIN the build it waives: what gets confirmed must be a
5019 // specific version, not "whatever this channel carries whenever somebody
5020 // gets round to it".
5021 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
5022 if err != nil {
5023 back("err", "Could not read channel "+channel+".")
5024 return
5025 }
5026 // The card pinned the version whose window it offered to waive; if the
5027 // channel has moved since, the waiver must not land on the newcomer.
5028 // Pin-present-only, like the rollback pin: a pinless POST waives the
5029 // current head, which is FastTrack's own contract.
5030 if pinned := strings.TrimSpace(r.FormValue("version")); pinned != "" && cur.Version != pinned {
5031 back("err", "Channel "+channel+" changed while you were confirming — review skipping the delay again.")
5032 return
5033 }
5034 // change_approvals covers overriding a channel's bake as well as editing
5035 // its entry (spec §2: "one field covers both changing and overriding").
5036 // The step-up above already ran, so this proposal counts its proposer.
5037 if rules := channelRules(pipe, channel); rules.ChangeApprovals >= 1 {
5038 kind, msg := s.proposeThenExecute(ctx, acct, app, kindFastTrack,
5039 fastTrackPayload{Channel: channel, Version: cur.Version}, rules.ChangeApprovals, addr)
5040 back(kind, msg)
5041 return
5042 }
5043 ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr)
5044 if err != nil {
5045 log.Printf("console: fast-track %s %s: %v", app, channel, err)
5046 back("err", "Skipping the delay failed: "+err.Error())
5047 return
5048 }
5049 s.audit(acct, app, addr, "fast-track", "skipped the safety delay for "+ver+" on "+channel)
5050 // A waiver re-signs the pointer to say "adopt this now" — the one write
5051 // whose entire point is that boxes act on it immediately.
5052 s.bumpSerial(ctx)
5053 back("ok", "Skipped the delay for "+ver+" on "+channel+" — it rolls out within a couple of minutes.")
5054}
5055
5056// ---- canaries ---------------------------------------------------------------
5057
5058// canaryNameRe pins canary names to the same boring slugs as app names — the
5059// name becomes a channel path segment and an on-box state file name.
5060var canaryNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
5061
5062// channelHosts lists the registry hosts whose routes follow app's channel,
5063// and reports whether the registry could be read at all. The two call sites
5064// deliberately treat that error differently: handleNewCanary's use is
5065// cosmetic (which URL to show in a success flash) and downgrades an error to
5066// "no hosts", but handleRetireCanary's use is load-bearing — a canary with
5067// followers must never be deleted — so it must fail CLOSED on a registry
5068// hiccup rather than silently reading the error as "safe to retire".
5069func (s *Server) channelHosts(app, channel string) ([]string, error) {
5070 reg, err := openRegistry(s.cfg.Registry)
5071 if err != nil {
5072 return nil, err
5073 }
5074 routes, err := reg.List()
5075 if err != nil {
5076 return nil, err
5077 }
5078 var out []string
5079 for _, rt := range routes {
5080 if rt.App == app && rt.Channel == channel {
5081 out = append(out, rt.Host)
5082 }
5083 }
5084 return out, nil
5085}
5086
5087// handleNewCanary creates a canary: a brand-new canary/<name> channel
5088// pointed at a chosen shipped version. Canary channels bypass bake by
5089// design, so the canary serves the moment a host follows its channel.
5090func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
5091 addr, raw, acct, _, app, ok := s.accountApp(w, r)
5092 if !ok {
5093 return
5094 }
5095 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
5096 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
5097 return
5098 }
5099 if s.cfg.Key == nil {
5100 s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
5101 "No signing key on this host — the console is running read-only.")
5102 return
5103 }
5104 back := func(kind, msg string) {
5105 s.setFlash(w, kind, msg)
5106 dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
5107 if tab := releaseBackTab(r); tab != "" {
5108 dest += "/" + tab
5109 }
5110 http.Redirect(w, r, dest, http.StatusSeeOther)
5111 }
5112 name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
5113 if !canaryNameRe.MatchString(name) {
5114 back("err", "Invalid canary name — lowercase letters, digits, and dashes, starting with a letter.")
5115 return
5116 }
5117 channel := "canary/" + name
5118 version := strings.TrimSpace(r.FormValue("version"))
5119 if !release.ValidVersion(version) {
5120 back("err", "Invalid version.")
5121 return
5122 }
5123 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
5124 defer cancel()
5125 if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil {
5126 back("err", "Version "+version+" is not a shipped release of "+app+".")
5127 return
5128 }
5129 // New means new: an existing canary is promoted to (releases table) or
5130 // retired, never quietly re-created.
5131 if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(acct.Sqid, app, channel)); err == nil {
5132 back("err", "Canary "+name+" already exists — promote to it from the releases table, or retire it first.")
5133 return
5134 } else if !errors.Is(err, os.ErrNotExist) {
5135 back("err", "Could not check the release store. Please try again.")
5136 return
5137 }
5138 // Plain Promote, deliberately, where handlePromote uses PromoteOnLadder:
5139 // the target is always canary/<name>, the one rung the ladder never
5140 // refuses in either direction, so enforcement here could only turn the
5141 // "new means new" refusal above into a less useful message. Nothing
5142 // reachable from this handler can name another rung.
5143 //
5144 // Creating a canary is still a promotion onto the canary rung — recorded
5145 // like any other, so "who put this build in front of users" has an answer
5146 // for the rung most likely to be wired to production.
5147 rec := release.Promotion{To: release.RungCanary, Actor: addr}
5148 if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, rec); err != nil {
5149 log.Printf("console: canary %s %s -> %s: %v", app, version, channel, err)
5150 back("err", "Creating the canary failed: "+err.Error())
5151 return
5152 }
5153 s.auditActivity(acct, app, addr, Activity{Action: "canary-create", TargetType: "channel", Target: "canary/" + name, Detail: "created canary " + name + " at " + version})
5154 // A channel pointer like any other: a route already following canary/<name>
5155 // (attach-then-create is a real order) adopts it on the announced pass.
5156 s.bumpSerial(ctx)
5157 // Best-effort here: which URL to show in the success flash is cosmetic,
5158 // so a registry hiccup just falls back to the generic "attach one" copy
5159 // rather than failing the create that already succeeded.
5160 if hosts, _ := s.channelHosts(app, channel); len(hosts) > 0 {
5161 back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (test versions have no safety delay).")
5162 return
5163 }
5164 domain, absent := s.instanceDomainFor(ctx, acct.Sqid, app)
5165 create, enable := attachCommands(app, channel, domain, s.cfg.AppsDomain, acct.Sqid, absent)
5166 if enable != "" {
5167 create = enable + " && " + create
5168 }
5169 back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment an address follows it, no safety delay. Attach one: "+create)
5170}
5171
5172// instanceDomainFor answers "where do this app's instance hosts live?" — the
5173// app's instances-manifest domain when one exists, and whether its absence is
5174// PROVEN. Both doors that tell a reader how to give a channel a URL ask this,
5175// so both name the same command.
5176//
5177// The three-way answer is load-bearing: a manifest that merely could not be
5178// READ is not "instances are off". The enable door writes guarded
5179// (PutManifestIfMatch) and answers 409 over a standing manifest under another
5180// domain, so telling someone to run `carlos instances enable` on the strength
5181// of a transient bucket error sends them at a door that will refuse them.
5182// Unreadable therefore renders the neutral form, never the enable step.
5183func (s *Server) instanceDomainFor(ctx context.Context, sqid, app string) (domain string, absent bool) {
5184 m, err := provision.GetManifest(ctx, s.cfg.Releases, sqid, app)
5185 switch {
5186 case err == nil:
5187 return m.Domain, false
5188 case errors.Is(err, os.ErrNotExist):
5189 return "", true
5190 default:
5191 log.Printf("console: instances manifest %s/%s: %v", sqid, app, err)
5192 return "", false
5193 }
5194}
5195
5196// attachCommands renders the "give this channel a URL" advice for one app:
5197// the create command, plus the enable command that has to precede it when
5198// instances are proven off ("" otherwise).
5199//
5200// It names `carlos instances create`, deliberately NOT `carlos add`. Both
5201// place a route, but `carlos add` opens THIS box's registry on local disk
5202// (cli.go's defaultRegistry) — it cannot be run from wherever a dashboard is
5203// being read, and an operator who followed that hint from a browser landed on
5204// a box-local verb that could not work for them (2026-08-07). The API-backed
5205// path is the one a login credential can drive: spec §12a, and the record it
5206// writes materializes as a route on the box's next provision pass, carrying
5207// this channel through (provision.Reconcile's mint).
5208func attachCommands(app, channel, domain, appsDomain, sqid string, absent bool) (create, enable string) {
5209 // An instance host is exactly one label under the manifest's domain
5210 // (provision.ValidHost), so the placeholder stands in for the label only.
5211 host := "<host>"
5212 switch {
5213 case domain != "":
5214 host = "<name>." + domain
5215 case absent && appsDomain != "":
5216 // Not enabled yet: the member-facing enable door pins the manifest to
5217 // the account's own platform subdomain (§12a), so that is where the
5218 // host will have to live once they run it.
5219 host = "<name>." + sqid + "." + appsDomain
5220 }
5221 create = "carlos instances create --app " + app + " --host " + host + " --channel " + channel
5222 if absent {
5223 enable = "carlos instances enable --app " + app
5224 }
5225 return create, enable
5226}
5227
5228// handleRetireCanary removes a canary's channel pointer. Shipped releases
5229// are untouched — retirement un-points, it never deletes bits.
5230func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
5231 addr, raw, acct, role, app, ok := s.accountApp(w, r)
5232 if !ok {
5233 return
5234 }
5235 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
5236 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
5237 return
5238 }
5239 if s.cfg.Key == nil {
5240 s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
5241 "No signing key on this host — the console is running read-only.")
5242 return
5243 }
5244 back := func(kind, msg string) {
5245 s.setFlash(w, kind, msg)
5246 dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
5247 if tab := releaseBackTab(r); tab != "" {
5248 dest += "/" + tab
5249 }
5250 http.Redirect(w, r, dest, http.StatusSeeOther)
5251 }
5252 name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
5253 if !canaryNameRe.MatchString(name) {
5254 back("err", "Invalid canary name.")
5255 return
5256 }
5257 channel := "canary/" + name
5258 // A canary with followers is load-bearing: retiring its pointer would
5259 // strand every host on it. Fail CLOSED on a registry error — an "I
5260 // couldn't check" must never be read as "nobody follows this channel".
5261 hosts, err := s.channelHosts(app, channel)
5262 if err != nil {
5263 log.Printf("console: retire %s %s: check followers: %v", app, channel, err)
5264 s.renderMessage(w, r, http.StatusInternalServerError, "Error",
5265 "Could not verify no hosts follow this channel — try again.")
5266 return
5267 }
5268 if len(hosts) > 0 {
5269 back("err", "Canary "+name+" still has a host ("+hosts[0]+") following it — remove or repoint that route first.")
5270 return
5271 }
5272 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
5273 defer cancel()
5274 key := release.ChannelKey(acct.Sqid, app, channel)
5275 if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) {
5276 back("err", "No canary named "+name+" here.")
5277 return
5278 } else if err != nil {
5279 back("err", "Could not read the release store. Please try again.")
5280 return
5281 }
5282 if !confirmed(r) {
5283 s.confirmRetireCanary(w, r, addr, raw, acct, role, app, name)
5284 return
5285 }
5286 if err := s.appMutation(ctx, acct, app, "canary-retire", func(ctx context.Context) error {
5287 return s.cfg.Releases.Delete(ctx, key)
5288 }); err != nil {
5289 log.Printf("console: retire %s %s: %v", app, channel, err)
5290 back("err", "Retiring the canary failed: "+err.Error())
5291 return
5292 }
5293 s.auditActivity(acct, app, addr, Activity{Action: "canary-retire", TargetType: "channel", Target: "canary/" + name, Detail: "retired canary " + name})
5294 back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.")
5295}
5296
5297// promoteChannelOptions is the per-row promote select's option list.
5298//
5299// For a pipelined app (pipe != nil), it's the pipeline's own declared
5300// channels, IN PIPELINE ORDER — the app's promotion order comes from the
5301// pipeline now (spec §5), not the frozen rungs, and offering a rung name a
5302// pipelined app never declared would let a member promote somewhere the
5303// pipeline itself refuses. Any existing channel outside the pipeline
5304// (canary/*, a -hotfix target) is still offered after them, in existing
5305// order, for the same reason the legacy path offers canary/*: it is a real,
5306// already-published channel, whatever governs promoting onto it.
5307//
5308// For a legacy app (pipe == nil), it's the ladder rungs first (so a fresh
5309// app with zero channels — nothing in existing — still has somewhere to
5310// promote its first release), then any of the app's existing channels not
5311// already a rung (canary/* and the like), in their existing order. Ladder
5312// rungs are included whether or not the app has them yet — promoting onto a
5313// rung that doesn't exist is exactly how it comes to exist.
5314func promoteChannelOptions(existing []ChannelView, pipe *release.Pipeline) []string {
5315 var first []string
5316 if pipe != nil {
5317 first = make([]string, len(pipe.Channels))
5318 for i, c := range pipe.Channels {
5319 first[i] = c.Name
5320 }
5321 } else {
5322 first = []string{release.RungEdge, release.RungBeta, release.RungStable}
5323 }
5324 seen := make(map[string]bool, len(first))
5325 options := make([]string, 0, len(first)+len(existing))
5326 for _, name := range first {
5327 options = append(options, name)
5328 seen[name] = true
5329 }
5330 for _, ch := range existing {
5331 if seen[ch.Name] {
5332 continue
5333 }
5334 seen[ch.Name] = true
5335 options = append(options, ch.Name)
5336 }
5337 return options
5338}
5339
5340// summarize folds one AppView into its "your apps" table row. Routes can
5341// legitimately belong to another account (RouteView.Account's doc comment)
5342// so both the apex-host pick and the failed-route scan are filtered to the
5343// viewing account first — otherwise a foreign route can pick this app's URL
5344// or trip its "needs attention" verdict.
5345func summarize(v AppView, acct string) appRow {
5346 var mine []RouteView
5347 for _, rt := range v.Routes {
5348 if rt.Account == acct {
5349 mine = append(mine, rt)
5350 }
5351 }
5352 deployment := observedDeployment(v.Routes, acct)
5353 row := appRow{App: v.App, URL: apexHost(mine), Deployment: deployment}
5354 attention := false
5355 for _, rt := range mine {
5356 if !rt.Platform && rt.Status == "failed" {
5357 attention = true
5358 }
5359 }
5360 for _, ch := range v.Channels {
5361 if ch.Err == "" && strings.HasPrefix(ch.Name, "canary/") {
5362 row.CanaryN++
5363 }
5364 }
5365 switch {
5366 case !deployment.HasRoutes():
5367 row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet"
5368 case attention:
5369 row.Verdict, row.VerdictLabel = "attention", "Needs attention"
5370 case deployment.ReportableCount == 0:
5371 row.Verdict, row.VerdictLabel = "ok", "Deployed"
5372 case !deployment.HasReportedVersion():
5373 row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
5374 case deployment.UnknownCount > 0:
5375 row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
5376 default:
5377 row.Verdict, row.VerdictLabel = "ok", "Deployed"
5378 }
5379 return row
5380}
5381