carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's read-only console API: GET routes under /api/cli/ that let a
2// terminal list its own accounts/apps and read a channel ladder by talking
3// to the console over HTTP, rather than opening the deployment bucket
4// directly. Auth and account/app resolution are ONE helper (apiAccountApp)
5// for every route below that names an account and an app, because
6// resolution and membership proof are deliberately the same lookup: an
7// unknown account and an account you don't belong to must be the identical
8// 404, or a token holder learns something by probing names.
9package console
10
11import (
12 "bytes"
13 "context"
14 "crypto/sha256"
15 "encoding/hex"
16 "encoding/json"
17 "errors"
18 "fmt"
19 "io"
20 "log"
21 "net/http"
22 "net/url"
23 "regexp"
24 "strings"
25 "time"
26
27 "github.com/carlosframework/platform/internal/caps"
28 "github.com/carlosframework/platform/internal/registry"
29 "github.com/carlosframework/platform/internal/release"
30 "github.com/carlosframework/platform/internal/svcrole"
31 "github.com/carlosframework/platform/internal/version"
32)
33
34// apiAccountApp resolves and authorizes one CLI API request, for either kind
35// of caller. For a PERSON the account segment accepts a sqid or a name,
36// resolved against the caller's OWN memberships — resolution and membership
37// proof are deliberately the same lookup, so an unknown account and someone
38// else's account are the same 404. For a SERVICE it is the credential's own
39// binding, matched as a sqid only. apiPrincipalAccount holds both rules and
40// the reasoning behind them; apiAccountAppBy adds the app half.
41func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
42 return s.apiAccountAppBy(w, r, s.store.HasAppIn)
43}
44
45// apiTrashedApp is apiAccountApp for the ONE door whose subject is an app
46// that is not live: restore. Everything about it — the symmetric resolution,
47// the ambiguity 409's wording, the byte-identical 404 — is apiAccountApp's,
48// by construction rather than by copy, because the moment the two diverge
49// the restore door becomes an oracle: a caller could tell "no such account",
50// "not your account" and "that app is not in the trash" apart by their
51// answers. The ONLY difference is the final lookup.
52func (s *Server) apiTrashedApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
53 return s.apiAccountAppBy(w, r, s.store.HasTrashedAppIn)
54}
55
56// apiAccountAppBy is the shared body of the two resolvers above: the account
57// half resolves first, then `has` — the last gate and the only variable —
58// says whether THIS door's subject exists.
59func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error)) (string, *Account, string, bool) {
60 return s.apiAccountAppSaying(w, r, has, mainGateCopy())
61}
62
63// apiAccountAppSaying is apiAccountAppBy with the sentences of c.
64func (s *Server) apiAccountAppSaying(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error), c gateCopy) (string, *Account, string, bool) {
65 p, acct, ok := s.apiPrincipalAccountSaying(w, r, c)
66 if !ok {
67 return "", nil, "", false
68 }
69 app := r.PathValue("app")
70 // An app-scoped service reaches exactly one app. Checked BEFORE the
71 // existence lookup, so that "not your app" and "no such app" stay the
72 // same 404 rather than differing by which branch produced it — and an
73 // empty binding is never a wildcard, which apiPrincipalAccount has
74 // already refused outright for the one credential shape that can carry
75 // one (see serviceBinding).
76 if p.Kind == PrincipalService && p.App != "" && p.App != app {
77 http.NotFound(w, r)
78 return "", nil, "", false
79 }
80 found, err := has(acct.ID, app)
81 if err != nil {
82 log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err)
83 }
84 if err != nil || !found {
85 http.NotFound(w, r)
86 return "", nil, "", false
87 }
88 return auditActor(p, acct), acct, app, true
89}
90
91// auditActor renders a principal for the audit trail, for the log lines that
92// name a caller, and for the rate-limit key an address used to serve as. A
93// service is deliberately NOT a bare address: a row written by a machine
94// must never read as a row written by the human who minted its token.
95func auditActor(p Principal, acct *Account) string {
96 if p.Kind == PrincipalUser {
97 return p.Address
98 }
99 if p.Name == "" {
100 // A legacy kind='app' row has no name; keep the string the audit
101 // trail has always written for it, so old rows and new rows about
102 // the same credential still read as the same actor.
103 return "app:" + acct.Sqid + "/" + p.App
104 }
105 return "service:" + acct.Sqid + "/" + p.Name
106}
107
108// serviceBinding resolves the account a SERVICE principal is bound to. ok is
109// false for a binding this console cannot honour, and every one of those is
110// the family's 404 at the doors and an empty answer at the one door that
111// enumerates (handleAPIApps) — never a widening.
112//
113// Two of the three refusals guard the same failure mode, which is worth
114// naming because it is the one a collapse of two resolvers into one could
115// introduce: a credential bound to NOTHING must never read as a credential
116// bound to EVERYTHING. An unset account id is one shape of it. The other is
117// a legacy kind='app' row whose app column is empty — for that row the app
118// IS the whole scope, so an empty one is not an account-scoped credential,
119// it is a broken one. apiInstancePrincipal refused exactly this row and so
120// does this. A NAMED service principal, by contrast, may legitimately carry
121// an empty app: account-scoped is a scope its minter chose.
122//
123// The third is an account deleted out from under a live binding, which is a
124// 404 rather than an error for the same reason every other missing account
125// is.
126func (s *Server) serviceBinding(p Principal) (*Account, bool) {
127 if p.AccountID == 0 || (p.Name == "" && p.App == "") {
128 return nil, false
129 }
130 acct, err := s.store.AccountByID(p.AccountID)
131 if err != nil {
132 log.Printf("console: api principal account %d: %v", p.AccountID, err)
133 return nil, false
134 }
135 if acct == nil {
136 return nil, false
137 }
138 return acct, true
139}
140
141// apiPrincipalAccount is the account half of apiAccountAppBy: the principal
142// cliRoute already resolved, and the account the {account} segment names for
143// THAT principal.
144//
145// It is the SHARED BODY, not a convenience for some future constituency.
146// Three callers today: apiAccountAppBy and handleAPICreateApp, which both
147// need the typed principal to check an app binding, and apiAccount, which
148// renders the audit actor for everyone else. Task 5's rate limiters
149// (allowCreate/allowWrite/allowShip, svcprincipal.go's principalKey) are the
150// budget-key constituency this shape was built for — they read the
151// principal back via principalFrom on the request context instead of
152// calling this a fourth time, the same principal cliRoute already resolved,
153// cheaper than resolving the account segment all over again.
154//
155// A SERVICE's scope is a BINDING, not a membership set, so the segment is
156// matched as a sqid ONLY. It holds no memberships, so there is no set within
157// which to resolve a name; resolving globally and then comparing would be a
158// name-collision oracle for a credential that never needed names. The
159// binding is exact or it is a 404. This is apiInstancePrincipal's rule,
160// moved here rather than deleted, because this is now the only place it is
161// written down.
162//
163// Nothing in the service branch consults AccountsFor. That is the whole
164// point of issue #220: the reach is a property of the credential, fixed at
165// mint, and the memberships of the human who minted it are irrelevant —
166// including the ones that human gains next month.
167//
168// A PERSON's path below is unchanged. Resolution is SYMMETRIC
169// (matchAccounts, resolve.go): the segment matches as a sqid or as a name —
170// case-insensitively — with no precedence between the two spaces, exactly as
171// the browser's /a/{seg} resolves it. A sqid match no longer wins outright
172// over a name match, because precedence is how a reference silently changes
173// meaning the day a colliding account appears: the caller who typed a name
174// would start acting on someone else's tenant without the wording of their
175// command changing at all. Two candidates are refused (409) rather than
176// guessed at.
177//
178// `?exact=sqid` is the transport for the CLI's -account-id flag: it matches
179// the segment as a sqid ONLY, so the reference a person reaches for to
180// settle an ambiguity can never itself be ambiguous. It never widens access
181// — a non-member's probe meets the same 404 either way.
182func (s *Server) apiPrincipalAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) {
183 return s.apiPrincipalAccountSaying(w, r, mainGateCopy())
184}
185
186// gateCopy is what the shared CLI gates say when they refuse in a sentence
187// of their own. Every door speaks mainGateCopy(), main's bytes; a door with
188// its own reviewed copy passes its own table (the Phase 1 move door,
189// phase1MoveCopy()) and the gates' logic is unchanged.
190type gateCopy struct {
191 ambiguous func(seg, alts string) string // the account segment matches several of the caller's accounts
192 tooMany func(noun string) string // the instance verbs' rate limit
193 noBucket string // the console runs without a deployment bucket
194}
195
196// mainGateCopy returns main's sentences, byte for byte (the dashes are escaped
197// only in the source).
198func mainGateCopy() gateCopy {
199 return gateCopy{
200 ambiguous: func(seg, alts string) string {
201 return fmt.Sprintf("account %q is ambiguous \u2014 pass --account-id with one of: %s", seg, alts)
202 },
203 tooMany: func(noun string) string { return "too many " + noun + " requests \u2014 wait a minute and try again" },
204 noBucket: "no deployment bucket on this host \u2014 the console is running without one",
205 }
206}
207
208// apiPrincipalAccountSaying is apiPrincipalAccount with the sentences of c.
209func (s *Server) apiPrincipalAccountSaying(w http.ResponseWriter, r *http.Request, c gateCopy) (Principal, *Account, bool) {
210 p, ok := principalFrom(r.Context())
211 if !ok {
212 // Only reachable from a door that skipped cliRoute, which
213 // TestEveryCLIDoorNamesItsRoles says cannot happen under
214 // /api/cli/. Refuse rather than fall back to a second resolver: a
215 // silent fallback here would be the one path on which the role gate
216 // does not run.
217 http.NotFound(w, r)
218 return Principal{}, nil, false
219 }
220 seg := r.PathValue("account")
221
222 // Dispatched as a closed switch rather than an if/else, so a kind added
223 // later fails shut. An `else` here would send any third kind down the
224 // person path, where it would read as a member with an empty address —
225 // the one shape of bug this package's whole thesis is against.
226 var addr string
227 switch p.Kind {
228 case PrincipalService:
229 acct, ok := s.serviceBinding(p)
230 if !ok || seg != acct.Sqid {
231 http.NotFound(w, r)
232 return Principal{}, nil, false
233 }
234 return p, acct, true
235 case PrincipalUser:
236 addr = p.Address
237 default:
238 http.NotFound(w, r)
239 return Principal{}, nil, false
240 }
241 accts, err := s.store.AccountsFor(addr)
242 if err != nil {
243 jsonError(w, http.StatusInternalServerError, "could not load accounts")
244 return Principal{}, nil, false
245 }
246 var matches []*Account
247 if r.URL.Query().Get("exact") == "sqid" {
248 // -account-id: the reference that never guesses. Match the segment
249 // as a sqid only — a name, however unique, does not answer.
250 for i := range accts {
251 if accts[i].Sqid == seg {
252 matches = append(matches, &accts[i])
253 break
254 }
255 }
256 } else {
257 matches = matchAccounts(accts, seg)
258 }
259 var acct *Account
260 switch len(matches) {
261 case 1:
262 acct = matches[0]
263 case 0:
264 // acct stays nil — the same 404 an unknown or someone-else's
265 // account gets, below.
266 default:
267 alts := make([]string, len(matches))
268 for i, m := range matches {
269 alts[i] = fmt.Sprintf("%s (%q)", m.Sqid, m.Name)
270 }
271 jsonError(w, http.StatusConflict, c.ambiguous(seg, strings.Join(alts, ", ")))
272 return Principal{}, nil, false
273 }
274 if acct == nil {
275 http.NotFound(w, r)
276 return Principal{}, nil, false
277 }
278 return p, acct, true
279}
280
281// apiAccount is apiPrincipalAccount for the callers that need only the audit
282// actor and the account — every existing one, which is why the three-value
283// signature stays exactly as it was. The first return is WHO TO AUDIT AS
284// (auditActor), which for a service is deliberately not a bare address.
285func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Account, bool) {
286 p, acct, ok := s.apiPrincipalAccount(w, r)
287 if !ok {
288 return "", nil, false
289 }
290 return auditActor(p, acct), acct, true
291}
292
293// apiPerson refuses any caller that is not a PERSON, with the byte-identical
294// 401 cliUser has always given — before any account segment is resolved, so
295// the answer stays a constant function of the credential rather than telling
296// a service whether the path it named is its own.
297//
298// It is the INNER of two walls at the doors whose subject is a human act:
299// minting a credential (a credential may never mint a credential) and
300// enabling instances (the manifest is standing policy about what may be
301// brought into existence). Both are registered with the zero Set, so no role
302// reaches either — and THAT registration is the load-bearing wall. The role
303// table is the single source of truth for what a service credential may
304// reach, and a caller refused there never arrives here at all.
305//
306// This second wall is not redundancy. Before service principals, those
307// handlers refused a non-person IMPLICITLY: they resolved through
308// apiAccountApp, which resolved through cliUser, which answers for nobody but
309// a person. Collapsing that resolution into the principal-aware path removed
310// the refusal, and an invariant a handler relies on should not be left
311// depending solely on a line in another file staying right. So the
312// person-only semantics are stated where the handler is, not only where it
313// is registered.
314//
315// TWO DOORS AND NOT TWELVE, deliberately. The six fleets/* doors and the
316// four services/* doors are just as person-only and just as closed, and they
317// do NOT call this: adding a second wall to a door no request can reach is
318// ceremony, and ceremony is what gets pattern-matched into the next handler
319// that should not have it. They rely on the registration plus the end-to-end
320// pin (TestEveryClosedDoorRefusesAServiceCredential). These two get the extra
321// line because their handler semantics are person-only in a way a reader
322// editing them must not miss — one mints a credential, the other rewrites
323// the bounds an app runs inside. Recorded as a choice, not left as an
324// asymmetry to be read as an oversight.
325//
326// It reads the principal cliRoute already resolved, so it costs no second
327// lookup. Pinned directly by TestApiPersonIsTheInnerWall, because the closed
328// registrations mean no request can ever deliver a service principal to it
329// through the mux.
330func (s *Server) apiPerson(w http.ResponseWriter, r *http.Request) bool {
331 p, ok := principalFrom(r.Context())
332 if !ok || p.Kind != PrincipalUser {
333 s.cliNotSignedIn(w)
334 return false
335 }
336 return true
337}
338
339// handleAPICreateApp answers `carlos apps create`: the CLI door onto the
340// same claim the browser's New app form posts to. claimApp holds the checks
341// for both doors — including the ops account's legacy claim-proofing — so
342// lifting the parent spec's §7 exclusion changes where a claim can come
343// from, never what a claim will refuse
344// (docs/superpowers/specs/2026-08-04-cli-app-create-design.md).
345//
346// The rate limit is create's OWN budget, not the shared `write` one, and it
347// is spent per ATTEMPT, before the name is even read — a refused probe that
348// cost nothing would leave the limiter bounding nothing a stolen token's
349// holder cares about. For a non-ops account every refusal below reveals
350// only the caller's own account's state, so the 409 is not the cross-tenant
351// oracle the parent spec's §3 guards against.
352//
353// The optional "place" field (2026-08-06 ruling: `carlos apps create -place
354// fleet/<f>`) is validated with the EXACT SAME two checks
355// placeAppTarget applies to its target — grammar (registry.FleetPlacement)
356// and that the fleet belongs to the CALLING account — before claimApp is
357// ever called, and with the same refusal: an unknown-or-foreign fleet is
358// the byte-identical 404 apiFleet gives everywhere else, never a 400 that
359// would leak "that fleet exists, just not for you". Creating with an
360// invalid place must not be a weaker gate than `carlos apps place` run
361// straight after create — this is what keeps the two equal.
362func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
363 p, acct, ok := s.apiPrincipalAccount(w, r)
364 if !ok {
365 return
366 }
367 // An app-scoped credential's subject is its app. Claiming a NEW one is
368 // ACCOUNT authority it was deliberately not given — name-squatting
369 // inside the account, quota and billing surface, and a row the operator
370 // never authorised. This door has no {app} segment, so apiAccountAppBy's
371 // binding check never runs for it and the guard has to be here.
372 //
373 // The byte-identical 404, not a 403: this is a scope refusal like every
374 // other binding mismatch, not a role refusal. The door is Allow(publish)
375 // and stays that way — it is designed for an ACCOUNT-scoped hub (amadan
376 // Pages), which is exactly the credential it still serves.
377 if p.Kind == PrincipalService && p.App != "" {
378 http.NotFound(w, r)
379 return
380 }
381 addr := auditActor(p, acct)
382 // allowCreate picks the person or the service create budget from the
383 // principal (svcprincipal.go) — a person keyed on the ADDRESS as before
384 // (a token-keyed counter is one the holder of several tokens resets at
385 // will), a service on its NAME, own budget from the operator's.
386 if !s.allowCreate(p, time.Now()) {
387 jsonError(w, http.StatusTooManyRequests,
388 "app creation limit reached for today — use the console's New app form")
389 return
390 }
391 var req struct {
392 Name string `json:"name"`
393 Place string `json:"place"`
394 ControlPlane bool `json:"control_plane"`
395 BuildProbe string `json:"build_probe"`
396 Region string `json:"region"`
397 Timezone string `json:"timezone"`
398 }
399 if !readJSON(w, r, &req) {
400 return
401 }
402 name := normalizeAppName(req.Name)
403 place := strings.TrimSpace(req.Place)
404 // Validated BEFORE claimApp, like place: a refused flag refuses the WHOLE
405 // create rather than leaving a claimed name with the wrong shape, and a
406 // shape the server is certain to refuse costs no claim attempt.
407 if req.ControlPlane && acct.Role != RoleOwner {
408 jsonError(w, http.StatusForbidden,
409 "only an owner can mark an app as the control plane")
410 return
411 }
412 probe := strings.TrimSpace(req.BuildProbe)
413 if probe != "" && !validBuildProbe(probe) {
414 jsonError(w, http.StatusBadRequest,
415 `build_probe must be an absolute URL path with no query, e.g. "/healthz"`)
416 return
417 }
418 if place != "" {
419 fleetName, validShape := registry.FleetPlacement(place)
420 if !validShape {
421 jsonError(w, http.StatusBadRequest, `invalid place — want "" or "fleet/<name>"`)
422 return
423 }
424 reg, err := openRegistry(s.cfg.Registry)
425 if err != nil {
426 log.Printf("console: create app %s: open registry: %v", acct.Sqid, err)
427 jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry")
428 return
429 }
430 f, err := reg.GetFleet(fleetName)
431 if err != nil {
432 log.Printf("console: create app %s: get fleet %s: %v", acct.Sqid, fleetName, err)
433 jsonError(w, http.StatusInternalServerError, "could not check the fleet")
434 return
435 }
436 if f == nil || f.Account != acct.Sqid {
437 // Same byte-identical 404 apiFleet and placeAppTarget's target
438 // check both give an unknown-or-foreign fleet — see this
439 // handler's doc comment.
440 http.NotFound(w, r)
441 return
442 }
443 }
444 rq, ref := s.newAppRegionReq(r, req.Region, req.Timezone)
445 if ref != nil {
446 jsonError(w, ref.Status, ref.Msg)
447 return
448 }
449 // ownerClaim: a PERSON who owns this account may adopt objects already
450 // under its own prefix (#79). A service principal never may — it holds no
451 // account role, and handing a CI credential the power to adopt release
452 // history is exactly the widening svcrole exists to prevent.
453 ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner
454 defer s.planLock(acct)()
455 if err := s.checkCreateApp(r.Context(), acct, name); err != nil {
456 if isPlanCap(err) {
457 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
458 return
459 }
460 log.Printf("console: api create %s/%s: plan cap: %v", acct.Sqid, name, err)
461 jsonError(w, http.StatusInternalServerError, "could not create the app — try again")
462 return
463 }
464 outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq)
465 switch outcome {
466 case claimInvalid:
467 jsonError(w, http.StatusBadRequest,
468 "invalid app name — lowercase letters, digits, and dashes, starting with a letter")
469 case claimReserved:
470 jsonError(w, http.StatusConflict, "that name is reserved")
471 case claimTemporaryReserved:
472 jsonError(w, http.StatusConflict,
473 "names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name")
474 case claimTaken:
475 jsonError(w, http.StatusConflict, "that name is taken")
476 case claimApexHeld:
477 jsonError(w, http.StatusConflict, "that name is not free: "+apexHeldSentence(s.apexHost(acct.Sqid, name), held))
478 case claimCheckFailed, claimCreateFailed:
479 jsonError(w, http.StatusInternalServerError, "could not create the app — try again")
480 case claimStoreFailed:
481 // A bucket error, like handleCLINewAccount's: the detail stays on
482 // the box (key names and endpoints don't belong in API bodies).
483 jsonError(w, http.StatusBadGateway, "could not check the release store — try again")
484 case claimOK:
485 s.audit(acct, name, addr, "create", "created app "+name+" (cli)")
486 // Stamped in the SAME call that claims the name, after the claim has
487 // actually succeeded: claiming first and flagging second would leave
488 // a window in which the freshly-claimed row is an ordinary app any
489 // ops member — or a Publish service credential — could roll before
490 // the flag lands. Both setters are plain UPDATEs that no-op on a
491 // missing row (store.go), so they are safe here only because claimOK
492 // guarantees CreateAppIn just succeeded.
493 if req.ControlPlane {
494 if err := s.store.SetAppControlPlaneContext(r.Context(), acct.ID, name, true); err != nil {
495 log.Printf("console: claim %s/%s: set control_plane: %v", acct.Sqid, name, err)
496 }
497 }
498 if probe != "" {
499 if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, name, probe); err != nil {
500 log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err)
501 }
502 }
503 // "placement", "control_plane" and "build_probe" all echo what
504 // actually stuck, NOT the request's own values — so a caller (CLI's
505 // runAppsCreate, apps.go) can tell "claimed and flagged" apart from
506 // "claimed, flag silently didn't" (a swallowed store-write error) by
507 // comparing these fields to what it asked for, rather than trusting
508 // a 200 to mean everything it asked for happened.
509 gotCP, gotProbe, err := s.store.AppFlags(acct.ID, name)
510 if err != nil {
511 log.Printf("console: claim %s/%s: read back flags: %v", acct.Sqid, name, err)
512 }
513 out := map[string]any{
514 "account": acct.Sqid, "app": name,
515 "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
516 "placement": placed,
517 "control_plane": gotCP,
518 "build_probe": gotProbe,
519 }
520 s.echoCreatedRegion(r.Context(), acct, name, out)
521 writeJSON(w, http.StatusOK, out)
522 default:
523 // Any future claimOutcome lands here rather than silently 200ing
524 // with an empty body — see the reserved-name finding this guards.
525 jsonError(w, http.StatusInternalServerError, "could not create the app — try again")
526 }
527}
528
529// echoCreatedRegion adds the region a create door just stored to its
530// response: region, region_name, region_source and regions_available. Both
531// create doors (/new and /new-temporary) echo it, so the CLI can say what
532// region the app landed in with the same lines either way.
533func (s *Server) echoCreatedRegion(ctx context.Context, acct *Account, name string, out map[string]any) {
534 gotRegion, gotSource, err := s.store.AppRegion(acct.ID, name)
535 if err != nil {
536 log.Printf("console: claim %s/%s: read back region: %v", acct.Sqid, name, err)
537 }
538 files, ferr := s.liveEdges(ctx)
539 cands := homeOnlyCandidates()
540 if ferr == nil {
541 cands = s.regionCands(files)
542 }
543 // regions_available is what THIS create could use, not what the
544 // fleet has. With the switch off (or no named home) every create
545 // lands on the home and --region is ignored (C13b), so there is
546 // one; a CLI that prints C11 at two or more then never invites a
547 // flag this console ignores. Fixed here rather than in the CLI so
548 // older CLIs benefit (final review, C11 on a switch-off console).
549 available := eligibleCount(cands)
550 if !s.appRegionsOn() {
551 available = 1
552 }
553 out["region"] = s.homeRegionOut(gotRegion)
554 out["region_name"] = s.placeOf(gotRegion, cands)
555 out["region_source"] = gotSource
556 out["regions_available"] = available
557}
558
559// newAppRegionReq builds a create door's regionReq from its optional region
560// and timezone fields, validating an explicit region exactly as /new always
561// has. A non-nil refusal is the response; nothing was claimed.
562func (s *Server) newAppRegionReq(r *http.Request, region, timezone string) (regionReq, *doorRefusal) {
563 // The region is resolved inside claimApp, once it knows the claim is not
564 // an adoption. The timezone and the address only ever feed that guess.
565 rq := regionReq{tz: timezone, ip: s.personIP(r)}
566 // With the switch off an explicit region is neither validated nor
567 // refused (spec §9.6): resolveNewAppRegion stores the home as "default",
568 // and a refusal there would offer choices that would not be honoured.
569 if strings.TrimSpace(region) != "" && s.appRegionsOn() {
570 code, ref := s.validateExplicitRegion(r.Context(), region)
571 if ref != nil {
572 return regionReq{}, ref
573 }
574 rq.explicit, rq.hasExplicit = code, true
575 }
576 return rq, nil
577}
578
579// handleAPIApps answers `carlos apps`: every account the caller can see, and
580// the app names each one has claimed. Scoped to the caller's OWN
581// memberships — there is no "list every account" verb here, on the same
582// principle apiAccountApp enforces one call at a time.
583//
584// This is the ONE door with no {account} segment, and therefore the one door
585// whose ANSWER is scoped rather than whose ACCESS is gated (spec §3a-i). A
586// service principal has no membership set to enumerate: it answers with
587// EXACTLY its own scope — the one account it is bound to, or the single app
588// if it is app-scoped — and never enumerates anything else. Readable by
589// every role, because a credential seeing the thing it is already bound to
590// discloses nothing it does not already hold.
591//
592// The `role` field is the MEMBERSHIP role (owner/member) that AccountsFor's
593// join populates. A service holds no membership, so its account row carries
594// the empty string, which is the right answer and not a gap to fill: v1 adds
595// no field here, because the CLI in the field parses this response and the
596// wire shape stays byte-compatible.
597func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) {
598 p, ok := principalFrom(r.Context())
599 if !ok {
600 http.NotFound(w, r)
601 return
602 }
603 // A closed switch, for apiPrincipalAccount's reason: a kind added later
604 // must not fall through to AccountsFor and enumerate a person's whole
605 // membership set on behalf of something nobody has classified yet.
606 var accts []Account
607 switch p.Kind {
608 case PrincipalService:
609 // A binding this console cannot honour enumerates NOTHING. It is not
610 // an error — the credential's scope is simply empty — and above all
611 // it is not a fall-through to every account, which is the failure
612 // mode serviceBinding exists to refuse.
613 if acct, ok := s.serviceBinding(p); ok {
614 accts = []Account{*acct}
615 }
616 case PrincipalUser:
617 var err error
618 accts, err = s.store.AccountsFor(p.Address)
619 if err != nil {
620 log.Printf("console: api apps for %s: %v", p.Address, err)
621 jsonError(w, http.StatusInternalServerError, "could not load accounts")
622 return
623 }
624 default:
625 http.NotFound(w, r)
626 return
627 }
628 out := make([]map[string]any, 0, len(accts))
629 for _, a := range accts {
630 apps, err := s.store.AppsIn(a.ID)
631 if err != nil {
632 log.Printf("console: api apps in %s: %v", a.Sqid, err)
633 jsonError(w, http.StatusInternalServerError, "could not load apps")
634 return
635 }
636 if p.Kind == PrincipalService && p.App != "" {
637 // App-scoped: exactly its own app, and only if that app is still
638 // claimed. Filtering the real list rather than answering with
639 // the binding keeps a deleted app out of the answer.
640 var mine []string
641 for _, app := range apps {
642 if app == p.App {
643 mine = append(mine, app)
644 }
645 }
646 apps = mine
647 }
648 if apps == nil {
649 apps = []string{}
650 }
651 out = append(out, map[string]any{
652 "sqid": a.Sqid, "name": a.Name, "role": a.Role, "apps": apps,
653 })
654 }
655 writeJSON(w, http.StatusOK, map[string]any{"accounts": out})
656}
657
658// handleAPIChannels answers `carlos channels`: every channel pointer under
659// one app, verified the same way the CLI verifies them itself. A pointer
660// that fails verification becomes one erroring ROW rather than a failed
661// response — mirroring runChannels' per-row ERROR behaviour (cli.go), since
662// one bad pointer must not hide every other channel's answer from the
663// caller.
664//
665// The response also carries two ADDITIVE "pipeline_entry"/"pipeline_channels"
666// keys (release pipelines v2, Task 11) — see the comment at their assignment
667// below.
668func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) {
669 _, acct, app, ok := s.apiAccountApp(w, r)
670 if !ok {
671 return
672 }
673 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
674 defer cancel()
675 prefix := release.ChannelsPrefix(acct.Sqid, app)
676 keys, err := s.cfg.Releases.List(ctx, prefix)
677 if err != nil {
678 log.Printf("console: api channels list %s/%s: %v", acct.Sqid, app, err)
679 jsonError(w, http.StatusBadGateway, "could not list channels")
680 return
681 }
682 // The account's pointer digest marker (#253, spec §4.6), read once: a
683 // row's straggler flag is measured against it. An unreadable marker is
684 // logged and read as off; the rows still answer.
685 marker, merr := release.ReadDigestMarker(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid)
686 if merr != nil {
687 log.Printf("console: api channels %s/%s: digest marker: %v", acct.Sqid, app, merr)
688 }
689 out := make([]map[string]any, 0, len(keys))
690 for _, k := range keys {
691 name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
692 cp, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name)
693 if err != nil {
694 out = append(out, map[string]any{"channel": name, "error": err.Error()})
695 continue
696 }
697 // rung is always present, "" when the channel name isn't one of the
698 // ladder's rungs — a typed client (later tasks) can rely on the key
699 // existing rather than testing for its absence.
700 rung, _ := release.Rung(name)
701 st := release.DigestStateOf(cp, marker)
702 out = append(out, map[string]any{
703 "channel": name,
704 "version": cp.Version,
705 "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339),
706 "rung": rung,
707 "digests": st.HeadDigest,
708 "history_missing": st.HistoryMissing,
709 "straggler": st.Straggler,
710 })
711 }
712 resp := map[string]any{"channels": out}
713 if marker != nil {
714 resp["pointer_digests"] = map[string]any{"on": true, "since": marker.At.UTC().Format(time.RFC3339)}
715 }
716 // pipeline_entry/pipeline_channels: two ADDITIVE top-level keys (release
717 // pipelines v2, Task 11 — review fix round 1) — old CLIs decode
718 // "channels" and ignore the rest. They are a REDUCED view of the
719 // pipeline — the entry channel and the full order, nothing about
720 // bake/rules — not the full signed release.Pipeline object `carlos
721 // pipeline` GETs (Task 6). Deliberately NOT nested under a "pipeline"
722 // key: the `/pipeline` door already uses that JSON path name for the
723 // full object, and reusing it here for a differently-shaped value (an
724 // object there, these two flat fields here) would be the same key
725 // meaning two incompatible things depending on which door answered it —
726 // a trap for any future consumer, and one that freezes the moment a
727 // released CLI starts decoding it. Two new key NAMES sidestep the
728 // collision entirely. The LOAD is shared with handleAPIPipelineGet
729 // verbatim (release.GetPipeline) so the two doors can never disagree
730 // about what "this app's pipeline" is.
731 //
732 // Both keys are OMITTED — together, never one without the other — for
733 // BOTH a legacy app (no pipeline.json) AND a pipeline read failure.
734 // This channels door is a convenience summary for deploy resolution,
735 // not the authority on why a pipeline is absent: a caller that needs to
736 // tell "definitely no pipeline" apart from "the console could not read
737 // it" has the dedicated `/pipeline` door for that (its null-vs-502
738 // distinction, Task 6). Collapsing the two here costs nothing a caller
739 // of THIS door needs: deployTargets' entry-channel fallback treats an
740 // absent PipelineEntry as "no fallback" either way, which is the
741 // correct conservative behaviour for a read failure as much as for a
742 // genuinely legacy app.
743 if p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app); err != nil {
744 log.Printf("console: api channels %s/%s: pipeline read: %v", acct.Sqid, app, err)
745 } else if p != nil {
746 names := make([]string, len(p.Channels))
747 for i, c := range p.Channels {
748 names[i] = c.Name
749 }
750 resp["pipeline_entry"] = p.Entry()
751 resp["pipeline_channels"] = names
752 }
753 writeJSON(w, http.StatusOK, resp)
754}
755
756// handleAPIPromote answers `carlos promote`: the CLI's write door onto the
757// SAME ladder the console's own promote button enforces (handlePromote,
758// server.go), doors in the same order minus two the browser needs and this
759// caller cannot present: CSRF (a bearer request carries no session cookie to
760// forge in the first place — readJSON's JSON-content-type insistence is
761// already this door's cross-site defence) and the passkey step-up (there is
762// no browser here to redirect into Keymail).
763//
764// Stable's gate does not soften into a step-up here — it simply refuses,
765// pointing at the console, which DOES have a passkey to ask for. Refusing
766// before ever consulting role is what keeps a member's 403 and an owner's 403
767// byte-identical: a token holder must not learn their own role, let alone
768// anyone else's, from a door that isn't the one stable actually goes through.
769//
770// Which gate applies is the PIPELINE's answer (release pipelines v2 §5). An
771// app that has none meets the legacy stable refusal above, byte for byte, as
772// it always has. A pipelined app meets the target channel's own declared
773// rules instead: a passkey rule refuses here (there is no passkey at a bearer
774// token), an approvals rule turns the request into a PROPOSAL, and a channel
775// with neither promotes exactly as before.
776//
777// `hotfix` is read strictly AFTER that decision, never before it: -hotfix
778// bypasses ORDER only, never rules (spec §5), and reading the flag first is
779// precisely how a bypass of one quietly becomes a bypass of the other.
780func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
781 addr, acct, app, ok := s.apiAccountApp(w, r)
782 if !ok {
783 return
784 }
785 // ok discarded here, and at every other write/create/ship/domains call
786 // site in this package that reads a principal back off the context:
787 // apiAccountApp/apiAccount/apiFleet above already ran, and cliRoute —
788 // the ONLY writer of the context key — stashed the principal on the
789 // context before the handler ever started, so ok is already true by
790 // the time this line runs (Task 5 review, Risk A). apiPrincipalAccount
791 // is a reader, not the writer: it 404s if the key is ever absent,
792 // which cannot happen for a door that went through cliRoute.
793 // If that ordering were ever broken, the zero Principal reads as
794 // PrincipalUser with Address "", which keys the PERSON limiter on the
795 // empty string for every caller alike — turning, say, the create
796 // door's 10-a-day-per-address budget into ONE shared bucket of 10
797 // creates a day for the entire deployment, not a per-caller refusal.
798 p, _ := principalFrom(r.Context())
799 // allowWrite picks the person or the service write budget from the
800 // principal (svcprincipal.go). A person is keyed on the ADDRESS, not the
801 // token — the same key handleCLINewAccount already uses on this
802 // limiter: keying by token instead would give someone with several live
803 // tokens an independent budget per token, which is the limit doing less
804 // than it should, not more. A service is keyed on its NAME, its own
805 // budget separate from the operator who minted it (issue #220).
806 if !s.allowWrite(p, time.Now()) {
807 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
808 return
809 }
810 if s.cfg.Key == nil {
811 jsonError(w, http.StatusServiceUnavailable,
812 "no signing key on this host — the console is running read-only")
813 return
814 }
815 var req struct {
816 Version string `json:"version"`
817 Channel string `json:"channel"`
818 Message string `json:"message"`
819 // Hotfix is spec §5's order bypass, and it is read nowhere above the
820 // rules decision below — see this handler's doc comment.
821 Hotfix bool `json:"hotfix"`
822 // Retry renews the channel's current promotion of this version with a
823 // fresh PromotedAt. It goes through the same rules, scope and
824 // Authorize checks as any promote.
825 Retry bool `json:"retry"`
826 }
827 if !readJSON(w, r, &req) {
828 return
829 }
830 message, ok := checkedActivityMessage(w, req.Message)
831 if !ok {
832 return
833 }
834 channel := strings.TrimSpace(req.Channel)
835 if !release.ValidChannel(channel) {
836 jsonError(w, http.StatusBadRequest, "invalid channel name")
837 return
838 }
839 version := strings.TrimSpace(req.Version)
840 if !release.ValidVersion(version) {
841 jsonError(w, http.StatusBadRequest, "invalid version")
842 return
843 }
844 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
845 defer cancel()
846 man, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version)
847 if err != nil {
848 jsonError(w, http.StatusNotFound, "Version "+version+" is not a shipped release of "+app+".")
849 return
850 }
851 if err := s.checkDaemonRelease(acct, app, man); err != nil {
852 if isPlanCap(err) {
853 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
854 return
855 }
856 log.Printf("console: api promote %s/%s: plan: %v", acct.Sqid, app, err)
857 jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
858 return
859 }
860 // The pipeline is read ONCE, for the gate below and for the held answer
861 // further down. An unreadable one refuses outright: GetPipeline's error
862 // means "not safely nil", and treating it as "no pipeline" would drop
863 // this door back to the legacy ladder under exactly the app that opted
864 // out of it — the same line PromoteOnLadder itself takes.
865 pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
866 if perr != nil {
867 log.Printf("console: api promote %s/%s: read pipeline: %v", acct.Sqid, app, perr)
868 jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app)
869 return
870 }
871 if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil {
872 s.writeDeployRefusal(w, err)
873 return
874 }
875 // A person with ops membership may roll the control plane; a service
876 // credential may not. This is the flag's promote-side job — the whole of
877 // it. Gating a PERSON here would break `carlos deploy -app console`, the
878 // command this exists to make ordinary (controlplane.go).
879 if s.refuseServiceControlPlane(w, p, acct.ID, app, "promote it") {
880 return
881 }
882 // The channel's own gate, LAST — after auth, after the account/app 404,
883 // after the key check, and after every refusal that costs nothing to
884 // answer. Same load-bearing order handlePromote's comment explains: a
885 // non-member meets the byte-identical 404 in apiAccountApp before any of
886 // this is mentioned. Below it, membership (already proved) is the gate;
887 // PromoteOnLadder enforces the order itself.
888 //
889 // hotfix is honoured only where the pipeline can judge what it bypasses:
890 // a target INSIDE the app's declared order. Cleared for anything outside
891 // one, and the reason is a hole this door had for exactly one commit: a
892 // pipelined app's `stable` is not in its pipeline, so it carried no
893 // rules, and a hotfixed promote onto it would have skipped the order
894 // check and written the stable pointer FROM A BEARER TOKEN — a target
895 // that has been an unconditional 403 here since this door existed.
896 // Bypassing an order the pipeline never declared is not a bypass, it is
897 // an escape from the pipeline; the sanctioned escape hatch is a
898 // direct-bucket promote with the root key in hand (spec §6).
899 hotfix := req.Hotfix
900 rung, _ := release.Rung(channel)
901 switch {
902 case pipe == nil:
903 if rung == release.RungStable {
904 writeJSON(w, http.StatusForbidden, map[string]string{
905 "error": "stable needs a passkey — finish in the console",
906 "console_url": s.appConsoleURL(acct, app),
907 })
908 return
909 }
910 case pipe.Index(channel) >= 0:
911 switch rules := channelRules(pipe, channel); {
912 case rules.PromoteApprovals >= 1:
913 // Order is checked HERE, before a proposal that could never
914 // execute goes out for signatures — the same refusal, at the
915 // same 409, PromoteOnLadder would give below. This is the first
916 // line in the handler that acts on the hotfix flag, and it is
917 // strictly after the rules decision this switch is making.
918 // pipe is non-nil in this arm by construction, which matters
919 // because VersionStage dereferences the pipeline it is handed.
920 //
921 // A retry asks no order question (it moves nothing along the
922 // pipeline: PromoteOnLadder's repeat path skips order for the
923 // same reason); its precondition is checked against the head
924 // below instead.
925 if !hotfix && !req.Retry {
926 stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe)
927 if serr != nil {
928 log.Printf("console: api promote %s/%s: stage: %v", acct.Sqid, app, serr)
929 jsonError(w, http.StatusBadGateway, "could not work out where this build sits in the pipeline")
930 return
931 }
932 if aerr := release.PipelinePromotionAllowed(pipe, stage, channel); aerr != nil {
933 jsonError(w, http.StatusConflict, aerr.Error())
934 return
935 }
936 }
937 // Pin the target channel's head at propose time (spec §4: a
938 // head that moved underneath a proposal invalidates it); ""
939 // when the channel has no pointer yet. An unreadable head
940 // refuses — a pin that cannot be read cannot be recorded.
941 head, herr := s.channelHead(ctx, acct, app, channel)
942 if herr != nil {
943 log.Printf("console: api promote %s/%s: read channel %s: %v", acct.Sqid, app, channel, herr)
944 jsonError(w, http.StatusBadGateway, "could not read channel "+channel)
945 return
946 }
947 // A retry renews the head's promotion, so one whose channel does
948 // not serve this version could never execute: refused now, in
949 // PromoteOnLadder's own words, before it goes out for signatures.
950 if req.Retry && head != version {
951 names := head
952 if names == "" {
953 names = "nothing"
954 }
955 jsonError(w, http.StatusConflict, fmt.Sprintf("promote --retry renews a promotion: %s names %s, not %s", channel, names, version))
956 return
957 }
958 s.proposeOverAPI(w, acct, app, kindPromote,
959 promotePayload{Version: version, Channel: channel, Hotfix: hotfix, ObservedHead: head, Retry: req.Retry},
960 rules.PromoteApprovals, addr)
961 return
962 case rules.Passkey:
963 // A passkey rule with no approvals alongside it: there is nothing
964 // to propose — one person's presence IS the whole gate — so this
965 // door refuses and names the one that can ask for it. (An
966 // approvals rule above subsumes this: every confirmation counted
967 // toward a quorum is passkey-backed by construction.)
968 writeJSON(w, http.StatusForbidden, map[string]string{
969 "error": channel + " needs a passkey — finish in the console",
970 "console_url": s.appConsoleURL(acct, app),
971 })
972 return
973 }
974 default:
975 // A pipelined app, aimed at a channel its pipeline does not name.
976 // The stable RUNG keeps the byte-identical legacy refusal it has
977 // always had here — an app-defined pipeline is a reason to govern
978 // its own channels, never a way to reach one it left out. Everything
979 // else falls through with hotfix cleared, so
980 // PipelinePromotionAllowed's own refusal (409, naming the app's
981 // channels) answers it. canary/* lands here too and is deliberately
982 // NOT refused: spec §2 keeps it outside every pipeline and always
983 // allowed, and PipelinePromotionAllowed waves it through below.
984 if rung == release.RungStable {
985 writeJSON(w, http.StatusForbidden, map[string]string{
986 "error": "stable needs a passkey — finish in the console",
987 "console_url": s.appConsoleURL(acct, app),
988 })
989 return
990 }
991 hotfix = false
992 }
993 signer := release.RootSigner(s.cfg.Key)
994 var authorize func(*release.Pipeline) error
995 if p.Kind == PrincipalService && p.Role == svcrole.Deploy {
996 sg, serr := s.deploySigner(p)
997 if serr != nil {
998 log.Printf("console: deploy signer for %s/%s: %v", acct.Sqid, app, serr)
999 jsonError(w, http.StatusServiceUnavailable, "could not prepare a signing key for this deploy credential. Nothing was promoted.")
1000 return
1001 }
1002 signer = sg
1003 authorize = func(pp *release.Pipeline) error {
1004 return deployScopeAllows(p, pp != nil, channel, req.Hotfix)
1005 }
1006 }
1007 res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version,
1008 release.PromoteOptions{Actor: addr, Hotfix: hotfix, Retry: req.Retry, Authorize: authorize})
1009 if err != nil {
1010 var ae *release.AuthorizationError
1011 if errors.As(err, &ae) {
1012 s.writeDeployRefusal(w, ae.Err)
1013 return
1014 }
1015 log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err)
1016 // The ladder's own refusal text names the rungs — as useful over the
1017 // API as it is on the app page.
1018 jsonError(w, http.StatusConflict, err.Error())
1019 return
1020 }
1021 if res.NoOp {
1022 // held on the no-op too, and this is not decoration: re-running the
1023 // same deploy (a CI retry, a repeated `carlos deploy`) against a
1024 // production app whose beta head is mid-bake is a NO-OP THAT IS STILL
1025 // HELD. A no-op answer without the field decodes as held:false and
1026 // sends the client off to watch for an adopt that is a day away —
1027 // exactly the false failure this field exists to prevent. Nothing is
1028 // announced here (no bump): the pointer did not move.
1029 heldNow := s.channelHeld(ctx, pipe, acct.Sqid, app, channel)
1030 out := map[string]any{"result": "noop", "held": heldNow}
1031 s.addHeldUntil(ctx, out, heldNow, pipe, acct.Sqid, app, channel)
1032 writeJSON(w, http.StatusOK, out)
1033 return
1034 }
1035 s.auditActivity(acct, app, addr, Activity{
1036 Action: "promote", TargetType: "channel", Target: channel, Message: message,
1037 Detail: "promoted " + version + " to " + channel + " (cli)",
1038 })
1039 s.bumpSerial(ctx)
1040 heldNow := s.channelHeld(ctx, pipe, acct.Sqid, app, channel)
1041 out := map[string]any{"result": "promoted", "from": res.From, "to": res.To, "noop": false, "held": heldNow}
1042 s.addHeldUntil(ctx, out, heldNow, pipe, acct.Sqid, app, channel)
1043 writeJSON(w, http.StatusOK, out)
1044}
1045
1046// addHeldUntil says when a held promotion lifts (spec §9.1: "held: a bake
1047// hold, with the time it lifts"): the pointer's promotion time plus the
1048// channel's window. A box starts its window when it first sees the build, a
1049// few seconds later, so the CLI says "about". Omitted when nothing is held or
1050// the pointer will not read; the CLI then prints its old line.
1051func (s *Server) addHeldUntil(ctx context.Context, out map[string]any, held bool, p *release.Pipeline, acct, app, channel string) {
1052 if !held {
1053 return
1054 }
1055 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, channel)
1056 if err != nil {
1057 return
1058 }
1059 out["held_until"] = cur.PromotedAt.Add(holdWindow(p, channel)).UTC().Format(time.RFC3339)
1060}
1061
1062// channelHeld answers the CLI's next question without a second round trip: is
1063// what this channel now carries actually going to be adopted, or is it waiting
1064// out a bake window? Two conditions, exactly as the browser door's flash
1065// computes them (handlePromote, server.go): the CHANNEL has a window at all,
1066// and the APP has declared production — a non-production app's boxes adopt a
1067// held channel immediately, so a hold claim would be a lie.
1068//
1069// The window comes from holdWindow, not HoldFor: a pipelined channel carries
1070// its own, possibly for a name the legacy table has never heard of. And a
1071// PIPELINED app never consults the production flag at all — its bake policy
1072// is the pipeline's alone (spec §3), which is exactly what the boxes do
1073// (adopt.go's bakeIgnore returns false for a verified pipeline), so consulting
1074// the flag here would answer a question the fleet no longer asks.
1075//
1076// On the legacy path only beta can be both: canary/* and edge have
1077// zero-length windows, and stable never reaches it (the rung gate 403s it).
1078// An UNREADABLE flag reports held, matching what the boxes will actually do —
1079// the adopt path fails TOWARD baking (spec §4), so reporting "not held" there
1080// would be the one wrong answer.
1081func (s *Server) channelHeld(ctx context.Context, p *release.Pipeline, acct, app, channel string) bool {
1082 if holdWindow(p, channel) == 0 {
1083 return false
1084 }
1085 if p != nil {
1086 return true
1087 }
1088 flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app)
1089 if err != nil {
1090 log.Printf("console: api promote production flag %s: %v", app, err)
1091 return true
1092 }
1093 return flag != nil && flag.Production
1094}
1095
1096// handleAPIRollback answers `carlos rollback`: the CLI's write door onto the
1097// same swap-to-previous-version primitive as the console's own rollback
1098// button (handleRollback, server.go). Below stable there is no rung gate,
1099// matching the browser door — rolling back never climbs the ladder, so
1100// nothing about maturity has a say over it.
1101//
1102// Stable is the one exception, and it is Paul's ruling (2026-08-03): a
1103// rollback that lands on stable puts a build in front of every user of the
1104// app exactly as a promotion onto it would (§8's no-stable blast radius
1105// governs regardless of which verb got there), so it is refused with the
1106// SAME byte-identical 403 handleAPIPromote gives stable — before
1107// release.Rollback is ever called, and before the channel's current pointer
1108// is even read, so the refusal cannot vary with what that pointer contains.
1109// That refusal stays UNCONDITIONAL, pipeline or no pipeline: this door only
1110// ever adds gates below, never removes the one it already had.
1111//
1112// A PIPELINED channel's own rules gate it too (release pipelines v2 §2), for
1113// the reason above stated the other way round: a rollback is a pointer write,
1114// and what it puts in front of every user of the app is a build the channel's
1115// rules exist to govern. A channel demanding a passkey refuses here; one
1116// demanding approvals proposes, pinned to the version this rollback would
1117// land on and flagged hotfix — a rollback is out-of-order by nature, so the
1118// order re-check at execution is the one check it must not meet.
1119func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
1120 addr, acct, app, ok := s.apiAccountApp(w, r)
1121 if !ok {
1122 return
1123 }
1124 p, _ := principalFrom(r.Context())
1125 // allowWrite picks the person or the service write budget from the
1126 // principal (svcprincipal.go). A person is keyed on the ADDRESS, not the
1127 // token — the same key handleCLINewAccount already uses on this
1128 // limiter: keying by token instead would give someone with several live
1129 // tokens an independent budget per token, which is the limit doing less
1130 // than it should, not more. A service is keyed on its NAME, its own
1131 // budget separate from the operator who minted it (issue #220).
1132 if !s.allowWrite(p, time.Now()) {
1133 jsonError(w, http.StatusTooManyRequests, textTooManyRequests)
1134 return
1135 }
1136 if s.cfg.Key == nil {
1137 jsonError(w, http.StatusServiceUnavailable,
1138 "no signing key on this host — the console is running read-only")
1139 return
1140 }
1141 var req struct {
1142 Channel string `json:"channel"`
1143 Message string `json:"message"`
1144 }
1145 if !readJSON(w, r, &req) {
1146 return
1147 }
1148 message, ok := checkedActivityMessage(w, req.Message)
1149 if !ok {
1150 return
1151 }
1152 channel := strings.TrimSpace(req.Channel)
1153 if !release.ValidChannel(channel) {
1154 jsonError(w, http.StatusBadRequest, "invalid channel name")
1155 return
1156 }
1157 // A deploy credential never gets the legacy stable text below: it is not
1158 // a passkey it lacks, it is a rung it does not hold (spec §7.3, D22).
1159 // This early call only ever produces a refusal; for stable
1160 // deployScopeAllows refuses whatever the pipeline says, and the full check
1161 // after the pipeline read stays as it is. Every other principal gets nil.
1162 if s.refuseStableOverAPI(w, p, acct, app, channel) {
1163 return
1164 }
1165 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
1166 defer cancel()
1167 pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
1168 if perr != nil {
1169 log.Printf("console: api rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr)
1170 jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app)
1171 return
1172 }
1173 if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil {
1174 s.writeDeployRefusal(w, err)
1175 return
1176 }
1177 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
1178 if err != nil {
1179 jsonError(w, http.StatusNotFound, "could not read channel "+channel)
1180 return
1181 }
1182 if len(cur.History) == 0 {
1183 jsonError(w, http.StatusConflict, "channel "+channel+" has no previous version to roll back to")
1184 return
1185 }
1186 target := cur.History[0]
1187 if err := s.checkDaemonVersion(ctx, acct, app, target); err != nil {
1188 if isPlanCap(err) {
1189 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
1190 return
1191 }
1192 log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, err)
1193 jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
1194 return
1195 }
1196 // Rollback moves the same pointer promote moves, so it wears the same
1197 // service refusal — a credential barred from promoting the control plane
1198 // must not reach the identical effect through the other verb.
1199 //
1200 // It sits LATER in the handler than handleAPIPromote's does, and that
1201 // asymmetry is deliberate rather than drift. Promote's runs as soon as
1202 // the pipeline is in hand; this one runs after the pointer read and the
1203 // empty-history 409 as well, because rollback has no request-supplied
1204 // version — `target` only exists once the pointer has been read, and the
1205 // rules switch below has to pin it.
1206 //
1207 // Safe, on three counts. Nothing above this line WRITES: GetPipeline and
1208 // Channel are reads, so a refusal here still leaves the request with no
1209 // effect, which is the only property the gate owes. Nothing above it
1210 // leaks across a boundary either: the byte-identical membership 404 lives
1211 // far above in apiAccountApp, so a non-member never reaches any of this.
1212 // And the two facts the extra reads can disclose to a barred service —
1213 // that the channel resolves, and that it has history — are ones the same
1214 // credential can already read straight out of handleAPIChannels, which
1215 // carries no control-plane refusal at all because listing pointers is not
1216 // release authority.
1217 if s.refuseServiceControlPlane(w, p, acct.ID, app, "roll it back") {
1218 return
1219 }
1220 // The channel's declared rules, applied to the version this would land
1221 // on. Read after the pointer because a proposal must PIN that version;
1222 // nothing above it varies with what the pointer contains, so the
1223 // stable refusal is still answered before the bucket is touched.
1224 switch rules := channelRules(pipe, channel); {
1225 case rules.PromoteApprovals >= 1:
1226 s.proposeOverAPI(w, acct, app, kindPromote,
1227 promotePayload{Version: target, Channel: channel, Hotfix: true, Rollback: true,
1228 ObservedHead: cur.Version},
1229 rules.PromoteApprovals, addr)
1230 return
1231 case rules.Passkey:
1232 s.writePasskeyRefusal(w, acct, app, channel)
1233 return
1234 }
1235 signer := release.RootSigner(s.cfg.Key)
1236 var authorize func(*release.Pipeline) error
1237 if p.Kind == PrincipalService && p.Role == svcrole.Deploy {
1238 sg, serr := s.deploySigner(p)
1239 if serr != nil {
1240 log.Printf("console: deploy signer for %s/%s: %v", acct.Sqid, app, serr)
1241 jsonError(w, http.StatusServiceUnavailable, "could not prepare a signing key for this deploy credential. Nothing was rolled back.")
1242 return
1243 }
1244 signer = sg
1245 authorize = deployScopeAuthorize(p, channel)
1246 }
1247 guard, gerr := s.rollbackDaemonGuard(ctx, acct, app, channel)
1248 if gerr != nil {
1249 log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, gerr)
1250 jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
1251 return
1252 }
1253 if err := signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, bothAuthorize(authorize, guard)); err != nil {
1254 if isPlanCap(err) {
1255 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
1256 return
1257 }
1258 var ge *planGuardError
1259 if errors.As(err, &ge) {
1260 log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, ge.err)
1261 jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
1262 return
1263 }
1264 var ae *release.AuthorizationError
1265 if errors.As(err, &ae) {
1266 s.writeDeployRefusal(w, ae.Err)
1267 return
1268 }
1269 log.Printf("console: api rollback %s %s: %v", app, channel, err)
1270 jsonError(w, http.StatusConflict, err.Error())
1271 return
1272 }
1273 s.auditActivity(acct, app, addr, Activity{
1274 Action: "rollback", TargetType: "channel", Target: channel, Message: message,
1275 Detail: "rolled back " + channel + " to " + target + " (cli)",
1276 })
1277 s.bumpSerial(ctx)
1278 writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
1279}
1280
1281// textTooManyRequests is the write budget's refusal over the API, shared by
1282// the rollback door and the pointer digest doors so the text is defined once
1283// (Astra r1 #8). The wording is the shipped one, unchanged.
1284const textTooManyRequests = "too many requests — wait a minute and try again"
1285
1286// refuseStableOverAPI is the API's refusal of a pointer write onto stable
1287// that is not a promotion (rollback, resign; Paul's ruling 2026-08-03): a
1288// deploy credential gets its scope refusal, as it never holds the stable rung,
1289// and everyone else the passkey 403. It reads nothing, so the refusal cannot
1290// vary with the bucket. It returns true when it has written the response.
1291func (s *Server) refuseStableOverAPI(w http.ResponseWriter, p Principal, acct *Account, app, channel string) bool {
1292 if rung, _ := release.Rung(channel); rung != release.RungStable {
1293 return false
1294 }
1295 if err := deployScopeAllows(p, false, channel, false); err != nil {
1296 s.writeDeployRefusal(w, err)
1297 return true
1298 }
1299 s.writePasskeyRefusal(w, acct, app, release.RungStable)
1300 return true
1301}
1302
1303// writePasskeyRefusal is the 403 for a pointer write the channel reserves to
1304// a passkey in the browser, with the console page to finish it on.
1305func (s *Server) writePasskeyRefusal(w http.ResponseWriter, acct *Account, app, channel string) {
1306 writeJSON(w, http.StatusForbidden, map[string]string{
1307 "error": channel + " needs a passkey — finish in the console",
1308 "console_url": s.appConsoleURL(acct, app),
1309 })
1310}
1311
1312// deployScopeAuthorize is the deploy-scope check a pointer write asks again
1313// inside its admitted mutation, of the pipeline read there.
1314func deployScopeAuthorize(p Principal, channel string) func(*release.Pipeline) error {
1315 return func(pp *release.Pipeline) error {
1316 return deployScopeAllows(p, pp != nil, channel, false)
1317 }
1318}
1319
1320// handleAPIRestart answers `carlos restart`: touch the app's restart
1321// generation, then bump the serial so every box serving it cycles that app's
1322// processes within seconds (issue #113). No version moves, no config is
1323// written, no bake window applies — this is the member-facing way to cycle a
1324// wedged instance, which until now meant a box login the platform's own design
1325// rule forbids ("every carlos command works for a user with zero infra
1326// access").
1327//
1328// The BUMP is the delivery mechanism, not decoration. Boxes do not poll
1329// restart generations: the agent's existing 2s serial poll is what makes it
1330// look, exactly as with a promote or a config write (internal/host/restart.go).
1331// A touch that landed without a bump would still converge — on the hourly root
1332// pass, up to an hour later — which is the floor, not the promise this door
1333// makes.
1334//
1335// The doors, in order, and why this order — handleAPITrashApp's exactly:
1336//
1337// token — cliUser's one 401, before any name is resolved.
1338// account+app 404 — apiAccountApp: an unknown account, someone else's
1339// account and an app that isn't there are one answer, so
1340// probing this door teaches a stranger nothing. It also
1341// means a TRASHED app cannot be restarted: the live-apps
1342// lookup simply doesn't answer for it.
1343// rate limit — the shared `write` budget, keyed on the ADDRESS like
1344// promote's: a budget belongs to the person, not the
1345// terminal. This door needs it more than most — its whole
1346// effect is to make processes stop and start, so an
1347// unbudgeted one would be a restart loop anyone with a
1348// token could aim at their own app.
1349// Releases nil — 503. The request IS a bucket object; a console with no
1350// bucket has nowhere to put it and must not answer
1351// "requested".
1352// touch + bump — the object, then the announcement, in that order (a bump
1353// for a request that failed to land would wake the fleet
1354// for nothing).
1355//
1356// Audited, unlike a read: this stops and starts customer processes, which
1357// anyone reading audit rows must be able to see.
1358//
1359// No body is read. There is nothing to say beyond the app already named in the
1360// path — there is no restart "options" — and a bearer request carries no
1361// cookie for a cross-site form to forge.
1362//
1363// No per-host scoping HERE, and that is now a division of labour rather than
1364// a limit. This door's generation is per APP: every box serving it cycles its
1365// own routes for that app, which is what makes it the one form every box in
1366// the fleet understands, including the ones that predate narrowing. Narrowed
1367// requests (`--host`, `--environment`) live on their own path and their own
1368// object — apirestartscope.go, discussion #16 — precisely so that a box or a
1369// console that cannot understand a narrowing does NOTHING rather than falling
1370// back to this. Boxes skip their hibernating routes on their own
1371// (internal/host/restart.go) — nothing here wakes anything.
1372func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
1373 addr, acct, app, ok := s.apiAccountApp(w, r)
1374 if !ok {
1375 return
1376 }
1377 p, _ := principalFrom(r.Context())
1378 if !s.allowWrite(p, time.Now()) {
1379 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
1380 return
1381 }
1382 if s.cfg.Releases == nil {
1383 jsonError(w, http.StatusServiceUnavailable,
1384 "no deployment bucket on this host — this console cannot request a restart")
1385 return
1386 }
1387 var req struct {
1388 Message string `json:"message"`
1389 }
1390 // The original restart door had no request body. Keep accepting those
1391 // clients; a body is present only when a newer caller supplies context.
1392 if r.ContentLength != 0 && !readJSON(w, r, &req) {
1393 return
1394 }
1395 message, ok := checkedActivityMessage(w, req.Message)
1396 if !ok {
1397 return
1398 }
1399 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
1400 defer cancel()
1401 if err := s.requestAppRestart(ctx, acct.Sqid, app); err != nil {
1402 // Logged, not returned: a store error carries bucket names, endpoints
1403 // and request ids. The caller gets the fixed sentence every other
1404 // bucket failure here gets, and a retry is safe — the touch is
1405 // idempotent in the only sense that matters (one more request, one
1406 // more cycle).
1407 log.Printf("console: api restart %s/%s: %v", acct.Sqid, app, err)
1408 jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again")
1409 return
1410 }
1411 s.auditActivity(acct, app, addr, Activity{
1412 Action: "restart", TargetType: "app", Target: app, Message: message,
1413 Detail: "requested a restart (cli)",
1414 })
1415 // The request is in the bucket; this is what makes boxes LOOK at it — they
1416 // read restart generations only when the serial moves. The difference
1417 // between "seconds" and "up to an hour" for the one verb people type when
1418 // something is stuck.
1419 s.bumpSerial(ctx)
1420 writeJSON(w, http.StatusOK, map[string]any{
1421 "requested": true, "account": acct.Sqid, "app": app,
1422 })
1423}
1424
1425// ---- trash and restore ------------------------------------------------------
1426
1427// handleAPITrashApp answers `carlos apps delete`: it moves an app to the
1428// Trash by writing the bucket's trash marker, and marks the console row to
1429// match. Nothing is destroyed here — the 30-day cooloff (release.TrashCooloff)
1430// is what makes this a cheap, reversible verb any MEMBER may reach for with
1431// nothing but a bearer token. The irreversible half (purge) is console-only
1432// and ceremonial, and deliberately has no API at all.
1433//
1434// The doors, in order, and why this order:
1435//
1436// token — cliUser's one 401, before any name is resolved.
1437// account+app 404 — apiAccountApp: an unknown account, someone else's
1438// account and an app that isn't there are one answer, so
1439// probing this door teaches a stranger nothing.
1440// rate limit — keyed on the ADDRESS, like handleAPIPromote: a write
1441// budget belongs to the person, not the terminal.
1442// control plane — 403, outright. An app flagged as the control plane IS
1443// the platform; its release history is what the fleet is
1444// running. Clearing the flag is the separate, deliberate
1445// act that unlocks this door (controlplane.go).
1446// Releases nil — 503. A console with no bucket cannot trash at all: the
1447// marker IS the trash state, and a row-only trash would
1448// un-hide itself at the next materialize pass (which
1449// reconciles rows FROM markers), silently un-deleting the
1450// app the caller was told was gone.
1451// production — ON, or UNREADABLE, refuses (409). Spec §5: the
1452// production flag is the deletion protection — no
1453// checkbox, no break-glass — and when the bucket cannot
1454// say "not production", the platform acts as if it had
1455// said "production", the same direction bake.go fails.
1456// TrashApp — the BUCKET first, then the row. Same order as account
1457// deletion (ownership.go) and for the same reason: this
1458// console's rows are materialized from the bucket, so a
1459// row written first and a marker that never lands is an
1460// app that deletes itself from the console and comes back
1461// on the next pass. Marker-then-row fails the other way —
1462// visibly, recoverably, and self-healing.
1463// TrashAppRow — the row follows the marker, carrying the MARKER's
1464// timestamp, so both halves agree on when the clock
1465// started.
1466// audit — who trashed what, kept past the purge.
1467func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) {
1468 addr, acct, app, ok := s.apiAccountApp(w, r)
1469 if !ok {
1470 return
1471 }
1472 p, _ := principalFrom(r.Context())
1473 if !s.allowWrite(p, time.Now()) {
1474 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
1475 return
1476 }
1477 // The control plane is not trashable at all (controlplane.go): after the
1478 // byte-identical 404 above, so a stranger never learns the app exists,
1479 // and before anything is written.
1480 if s.refuseControlPlane(w, acct, app, "trashed") {
1481 return
1482 }
1483 if s.cfg.Releases == nil {
1484 jsonError(w, http.StatusServiceUnavailable,
1485 "no deployment bucket on this host — this console cannot delete an app")
1486 return
1487 }
1488 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
1489 defer cancel()
1490 // Every transition takes the app's lock and settles first (spec §2, §5).
1491 // The checks above ran before the lock: ask again. An app already in the
1492 // Trash with its marker falls through to the idempotent path below.
1493 defer s.lockApp(acct.Sqid, app)()
1494 if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil {
1495 switch ref.Kind {
1496 case lockedGone:
1497 http.NotFound(w, r) // what apiAccountApp's gate answers for this app now
1498 case lockedControlPlane:
1499 jsonError(w, ref.Status, ref.Msg)
1500 default:
1501 // Existing copy (below): the same answer an unreadable marker
1502 // already gets from this door, and the retry is safe.
1503 jsonError(w, http.StatusBadGateway, "could not confirm the trash window — try again")
1504 }
1505 return
1506 }
1507 // (nil, nil) is the flag genuinely ABSENT, which is the default and not
1508 // production — most apps never had one. Every other outcome is an error
1509 // and refuses.
1510 flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
1511 appURL := s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
1512 if err != nil {
1513 // Logged, not returned: a store error carries bucket names, endpoints
1514 // and request ids, and this caller gets the same fixed sentence every
1515 // other bucket failure in this console gets.
1516 log.Printf("console: api trash %s/%s: production flag: %v", acct.Sqid, app, err)
1517 writeJSON(w, http.StatusConflict, map[string]string{
1518 "error": "could not read the production setting for " + app +
1519 " — when in doubt it's treated as production, so deleting is refused." +
1520 " Set it again with the production switch on the app page, then try again.",
1521 "console_url": appURL,
1522 })
1523 return
1524 }
1525 if flag != nil && flag.Production {
1526 writeJSON(w, http.StatusConflict, map[string]string{
1527 "error": app + " is flagged production, so deleting is refused." +
1528 " Turn production off first — the off-switch on the app page (account owner," +
1529 " passkey, and a hold on every box) is the only way through — then try again.",
1530 "console_url": appURL,
1531 })
1532 return
1533 }
1534 now := time.Now()
1535 if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil {
1536 log.Printf("console: api trash %s/%s: %v", acct.Sqid, app, err)
1537 jsonError(w, http.StatusBadGateway, "could not write the trash marker — nothing was deleted; try again")
1538 return
1539 }
1540 // Re-read, because TrashApp is create-only: an app already in the trash —
1541 // trashed headless, or by a racing request — keeps its ORIGINAL clock,
1542 // and that marker is the ONLY clock in this feature. The auto-purge pass
1543 // destroys at the marker's DeletedAt + TrashCooloff, so a window invented
1544 // here would over-promise the deadline, and a row stamped with this
1545 // request's time would never self-heal: materialize's trash case
1546 // short-circuits on an already-trashed row, and TrashAppRow is guarded by
1547 // deleted_at = 0.
1548 //
1549 // So a marker that cannot be re-read is a 502 with NOTHING written to the
1550 // row. The marker is on disk either way — the app IS trashed — and the
1551 // retry takes the idempotent path and reads the true window out of it.
1552 // Answering "trashed, until <a date nobody will honour>" is the one
1553 // outcome a deletion feature must not produce.
1554 tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app)
1555 if err != nil || tomb == nil {
1556 if err != nil {
1557 log.Printf("console: api trash %s/%s: re-read marker: %v", acct.Sqid, app, err)
1558 } else {
1559 // Written and already gone: a restore raced this trash. Same
1560 // answer — this request cannot say what the window is.
1561 log.Printf("console: api trash %s/%s: marker vanished between write and read", acct.Sqid, app)
1562 }
1563 jsonError(w, http.StatusBadGateway, "could not confirm the trash window — try again")
1564 return
1565 }
1566 deletedAt := tomb.DeletedAt
1567 if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil {
1568 log.Printf("console: api trash row %s/%s: %v", acct.Sqid, app, err)
1569 jsonError(w, http.StatusInternalServerError,
1570 "the trash marker was written, but this console's own record did not update —"+
1571 " it will catch up on the next materialize pass")
1572 return
1573 }
1574 s.audit(acct, app, addr, "trash", "moved "+app+" to the trash (cli)")
1575 // Box-consumed (carlos-deploy §3), symmetric with the restore door below:
1576 // the trash marker is what stops instance minting and drops the app's
1577 // routes (provision/reconcile.go, domains/reconcile.go). Without the bump
1578 // a trash through this door sits invisible to the fleet until the hourly
1579 // floor comes round, while restore already announces in seconds.
1580 s.bumpSerial(ctx)
1581 // One deadline, everywhere (spec §5). If the row cannot be read back,
1582 // promise the SHORTER window: under-promising a restore deadline is
1583 // harmless, over-promising is the one outcome a deletion must not produce.
1584 until := deletedAt.Add(release.TemporaryCooloff)
1585 if life, lerr := s.store.AppLife(acct.ID, app); lerr == nil && life != nil {
1586 until = effectivePurgeAt(life.DeletedAt, life.PurgeAfter)
1587 } else if lerr != nil {
1588 log.Printf("console: api trash %s/%s: read back the deadline: %v", acct.Sqid, app, lerr)
1589 }
1590 writeJSON(w, http.StatusOK, map[string]any{
1591 "result": "trashed",
1592 "restorable_until": until.UTC().Format(time.RFC3339),
1593 })
1594}
1595
1596// handleAPIRestoreApp answers `carlos apps restore`: the undo button. Same
1597// doors and same order as trash, minus the production gate — an app in the
1598// trash cannot be flagged production (§3 refuses ship, promote and
1599// SetProduction to a trashed app), and refusing to UNDO a deletion is
1600// protecting nothing.
1601//
1602// It resolves through apiTrashedApp, so an app that is not in the trash is
1603// the byte-identical 404 an unknown one gets: this door must not become the
1604// way to ask whether somebody's app has been deleted.
1605//
1606// Restoring resurrects NO routes — not the platform apex, not custom domains
1607// (spec §3, the anti-ghost rule: reconcile's minting is route-driven, and
1608// re-minting from pointers would resurrect every deliberately-retired app
1609// forever). The response says so, because nothing else in the terminal will.
1610func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) {
1611 addr, acct, app, ok := s.apiTrashedApp(w, r)
1612 if !ok {
1613 return
1614 }
1615 p, _ := principalFrom(r.Context())
1616 if !s.allowWrite(p, time.Now()) {
1617 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
1618 return
1619 }
1620 if s.cfg.Releases == nil {
1621 jsonError(w, http.StatusServiceUnavailable,
1622 "no deployment bucket on this host — this console cannot restore an app")
1623 return
1624 }
1625 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
1626 defer cancel()
1627 // The bucket first, for trash's reason mirrored: the row is materialized
1628 // from the marker, so a row restored while the marker survives is an app
1629 // that comes back and then vanishes again on the next pass.
1630 // restoreApp (restore.go) is the one restore every door runs: it locks,
1631 // settles, checks the temporary cap and orders the row and marker writes.
1632 var planCap *planCapError
1633 var capErr *temporaryCapError
1634 var rf *restoreFailure
1635 switch err := s.restoreApp(ctx, acct, app, time.Now()); {
1636 case err == nil:
1637 case errors.Is(err, errNotInTrash):
1638 http.NotFound(w, r)
1639 return
1640 case errors.As(err, &planCap):
1641 writeJSON(w, http.StatusConflict, map[string]any{"error": planCap.Error(), "code": "cap"})
1642 return
1643 case errors.As(err, &capErr):
1644 writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"})
1645 return
1646 case errors.As(err, &rf) && rf.Marker:
1647 log.Printf("console: api restore %s/%s: %v", acct.Sqid, app, err)
1648 jsonError(w, http.StatusBadGateway, "could not remove the trash marker — nothing was restored; try again")
1649 return
1650 default:
1651 log.Printf("console: api restore row %s/%s: %v", acct.Sqid, app, err)
1652 // The marker is gone, so the bucket already says restored: tell the
1653 // fleet now rather than at the hourly floor. The row catches up on
1654 // the next settle.
1655 s.bumpSerial(ctx)
1656 jsonError(w, http.StatusInternalServerError,
1657 "the trash marker was removed, but this console's own record did not update —"+
1658 " it will catch up on the next materialize pass")
1659 return
1660 }
1661 s.audit(acct, app, addr, "restore", "restored "+app+" from the trash (cli)")
1662 // Box-consumed (carlos-deploy §3), same as the web door (handleRestoreApp,
1663 // ownership.go): removing the trash marker re-enables instance minting for
1664 // this app, and minting from records is leg 1 of the fast pass's reconcile.
1665 // Without the bump a restore through this door is invisible to the fleet
1666 // until the hourly floor comes round.
1667 s.bumpSerial(ctx)
1668 note := app + " is restored with its releases and channels intact, and with NO routes:" +
1669 " run `carlos add <host>` to serve it again."
1670 if release.IsTemporaryName(app) {
1671 note = restoredTemporaryNote(app)
1672 }
1673 writeJSON(w, http.StatusOK, map[string]any{"result": "restored", "note": note})
1674}
1675
1676// ---- ship (multipart upload) ------------------------------------------------
1677
1678// apiShipMaxBytes bounds one ship request across every part TOGETHER — meta
1679// plus all artifacts — not any single part on its own. A var, not a const,
1680// so a test can lower it without rebuilding the fixture. 256MiB comfortably
1681// covers today's binaries and a static site tarball in one request.
1682var apiShipMaxBytes int64 = 256 << 20
1683
1684// apiShipMaxPartsStatic and apiShipMaxPartsBinary bound the NUMBER of file
1685// parts a ship request may carry: one for a static release (the site.tar.gz
1686// name alone), eight for a binary release (comfortably above today's
1687// platform count). r.MultipartReader() has no stdlib cap on part count at all — that
1688// cap exists only on mime/multipart's ReadForm (1000 parts), which
1689// streaming deliberately opts out of. Without a check of our own, a body
1690// full of tiny, validly-named parts fits millions of them inside
1691// apiShipMaxBytes: each one a map entry on the console's heap, and — had it
1692// ever gotten that far — one sequential putImmutable call per part inside
1693// release.Ship until the ship context timed out, orphaning however many
1694// artifact objects had already landed (Ship writes the manifest LAST by
1695// design, so a killed run leaves bytes with no manifest naming them). This
1696// check IS the part-count cap; nothing upstream provides one.
1697const (
1698 apiShipMaxPartsStatic = 1
1699 apiShipMaxPartsBinary = 8
1700)
1701
1702// apiShipMetaMaxBytes bounds the "meta" part's own JSON — separately from
1703// apiShipMaxBytes, which bounds the whole request (meta plus every
1704// artifact). release.MaxNotesLen is the biggest single field meta carries;
1705// the extra 8KiB is headroom for everything else in the same object
1706// (version, kind, label, and a capability block). Still tiny next to
1707// apiShipMaxBytes, and still read inside the same semaphore-bounded
1708// handler, so this is a decode-shape bound, not a new resource exposure.
1709const apiShipMetaMaxBytes = release.MaxNotesLen + 8<<10
1710
1711// targetRe validates a binary artifact's part NAME before it is used to
1712// build a bucket key: release.ArtifactKey joins it straight into the object
1713// path, so this check is path-traversal defense, not tidiness — a part
1714// named "../../etc/passwd" must never reach release.Ship as a file name.
1715var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
1716
1717// shipMaxBytesRefused answers 413 if err is the http.MaxBytesReader wrapped
1718// around the request body cutting a read short, and reports whether it did
1719// — the caller returns immediately when this is true, and falls through to
1720// its own (more specific) 400 otherwise.
1721func shipMaxBytesRefused(w http.ResponseWriter, err error) bool {
1722 var tooLarge *http.MaxBytesError
1723 if errors.As(err, &tooLarge) {
1724 jsonError(w, http.StatusRequestEntityTooLarge, "upload too large")
1725 return true
1726 }
1727 return false
1728}
1729
1730// shipImmutableConflict reports whether err is release.Ship's refusal to
1731// overwrite an existing version's object with DIFFERENT bytes — a
1732// permanent conflict a retry can never fix, unlike every other error Ship
1733// returns (a store/IO failure, which a retry might actually resolve).
1734//
1735// release.Ship/putImmutable (internal/release/release.go) expose no
1736// sentinel or typed error for this specific case — the only signal
1737// available is the literal wording putImmutable formats ("... already
1738// exists with different content — refusing to overwrite an immutable
1739// release object"), so this is a STRING match against release.go's current
1740// wording, not a durable error-type check (errors.Is/As have nothing to
1741// grab onto here). If that wording ever changes in release.go, this
1742// silently stops firing and the conflict falls back to the 502 below —
1743// worth revisiting if release.go ever grows a proper sentinel for it.
1744//
1745// validateManifest's errors also return from Ship along this same
1746// unwrapped path, but none of its messages ("invalid app...", "invalid
1747// kind...", "manifest has no artifacts", etc) contain this wording, so they
1748// can never match here. They are unreachable in this branch for an
1749// unrelated reason: every field validateManifest checks (app, version,
1750// kind, artifact file names, sha256, size) was already validated by
1751// handleAPIShip itself, using the same rules, before Ship was ever called.
1752func shipImmutableConflict(err error) bool {
1753 return strings.Contains(err.Error(), "already exists with different content")
1754}
1755
1756// shipArtifactsMatch reports whether two artifact maps describe the exact
1757// same shipped CONTENT — same file set, same bytes (by sha256+size) for
1758// each. BuiltAt/BuiltBy are deliberately excluded: they are metadata, not
1759// content, and both handleAPIShip and the direct `carlos ship` CLI path
1760// stamp a fresh BuiltAt into every manifest they build, so comparing
1761// manifests wholesale (as release.Ship's own putImmutable does,
1762// byte-for-byte) would call a genuinely identical re-ship a conflict for a
1763// reason no caller could ever avoid.
1764func shipArtifactsMatch(a, b map[string]release.Artifact) bool {
1765 if len(a) != len(b) {
1766 return false
1767 }
1768 for k, av := range a {
1769 bv, ok := b[k]
1770 if !ok || av.File != bv.File || av.SHA256 != bv.SHA256 || av.Size != bv.Size {
1771 return false
1772 }
1773 }
1774 return true
1775}
1776
1777// capsRefusal turns a caps error into the caller-facing 400 body.
1778//
1779// caps writes its own refusals for a human, and every one of them opens with
1780// "capabilities: " — those pass through verbatim, because naming the offending
1781// capability and the fix is the whole point of them. What must NOT pass
1782// through is encoding/json's own message for a value that is not even an
1783// object ("json: cannot unmarshal number into Go value of type caps.Set"):
1784// it leaks an internal type name, carries no prefix, and tells the caller
1785// nothing they can act on. Those become one fixed line, in the same register
1786// as this handler's other 400s ("malformed meta part").
1787func capsRefusal(err error) string {
1788 if msg := err.Error(); strings.HasPrefix(msg, "capabilities: ") {
1789 return msg
1790 }
1791 return "malformed capabilities block — want the manifest's capabilities object"
1792}
1793
1794// shipCapabilitiesMatch reports whether two capability blocks are the SAME
1795// DECLARATION, comparing their CANONICAL JSON ENCODINGS rather than the
1796// structs.
1797//
1798// Same declaration, not "same meaning": list ORDER is significant, so
1799// ports [{25},{587}] and [{587},{25}] do not match and a re-ship between them
1800// is a 409. That is deliberate and it is the conservative direction — under
1801// immutability the question being asked is "are these the same bytes of
1802// declaration", and sorting here would be this function quietly deciding that
1803// two manifests an operator can see differ are the same release.
1804//
1805// Comparing ENCODINGS rather than structs is not a stylistic choice either.
1806// caps.Set carries an unexported notes slice (the
1807// unknown-field-inside-a-known-capability lines its decoder collected), so a
1808// decoded Set is neither ==-comparable nor reflect.DeepEqual-safe against one
1809// built any other way once those notes are non-empty — two blocks that reach
1810// the manifest as identical bytes would compare unequal because one of them
1811// was written with a field this binary ignored. The JSON encoding
1812// carries exactly the fields that reach the manifest and nothing else, and
1813// Set has no custom marshaller, so the encoding is deterministic (struct
1814// field order) and total. A nil block encodes as "null" on both sides, which
1815// is why nil == nil falls out with no special case.
1816func shipCapabilitiesMatch(a, b *caps.Set) bool {
1817 ja, aerr := json.Marshal(a)
1818 jb, berr := json.Marshal(b)
1819 if aerr != nil || berr != nil {
1820 // Unreachable for a Set (no marshaller to fail), and if it ever
1821 // happens the honest answer is "these are not known to match" — which
1822 // refuses the re-ship rather than waving it through.
1823 return false
1824 }
1825 return bytes.Equal(ja, jb)
1826}
1827
1828// handleAPIShip answers `carlos ship`: a multipart upload of one release's
1829// artifacts. Hashes and sizes are computed HERE, from the bytes that
1830// actually arrived on the wire, and handed to release.Ship as
1831// already-filled Artifact{SHA256,Size} — Ship itself recomputes and
1832// cross-checks them (release.go), so there is no way for a caller to lie
1833// about either even in principle: the wire shape below has no field to lie
1834// in (the meta part carries only version, kind, and the app's capability
1835// declaration — and that last one is re-validated here against caps's own
1836// closed vocabulary before an artifact byte is read).
1837//
1838// Streamed, never buffered to disk: r.MultipartReader() reads one part at a
1839// time. ParseMultipartForm was ruled out on purpose — it spills whatever
1840// doesn't fit its own in-memory threshold to a temp file under an
1841// INDEPENDENT size limit, one that would sit alongside apiShipMaxBytes
1842// rather than being governed by it. Each part is still read fully into
1843// memory here, because release.Ship's own shape (map[string][]byte) leaves
1844// no smaller option — the same buffering the CLI's own direct-ship path
1845// already does with os.ReadFile — but the MaxBytesReader wrapped around the
1846// whole request body below is what actually caps the total.
1847func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
1848 addr, acct, app, ok := s.apiAccountApp(w, r)
1849 if !ok {
1850 return
1851 }
1852 p, _ := principalFrom(r.Context())
1853 // allowShip picks the budget: a person stays keyed on the TOKEN, not the
1854 // address — a ship is heavy (bytes on the wire, an object written per
1855 // artifact), so a token is the natural principal for an upload, unlike
1856 // a write budget. (The other half of that reasoning, "ships are rare",
1857 // no longer holds and is no longer load-bearing here: see
1858 // cliShipWindow.) Keying by address instead would make a laptop and a
1859 // CI runner sharing one human's identity share one shipping budget
1860 // between them, which is the limit doing less than it should, not
1861 // more. A service gets its own budget, keyed on its NAME
1862 // (svcprincipal.go), separate from the operator who minted it.
1863 //
1864 // The refusal names the wait and stamps Retry-After. "Wait a while",
1865 // which is what this said while the window was an hour, is the answer
1866 // that wedges the caller who meets this budget most: an agent shipping
1867 // in a loop cannot tell a two-second pause from an hour-long lockout,
1868 // so it reports that shipping is broken. cliShipWindow's move to a
1869 // minute is what makes the number worth printing at all.
1870 if ok, wait := s.allowShip(p, r, time.Now()); !ok {
1871 retryAfter(w, wait)
1872 jsonError(w, http.StatusTooManyRequests, fmt.Sprintf(
1873 "too many uploads — try again in %ds", max(int(wait/time.Second), 1)))
1874 return
1875 }
1876 // The rate limiter bounds requests over TIME; it says nothing about how
1877 // many run AT ONCE. A ship holds a goroutine and a substantial buffer
1878 // open for as long as the client trickles bytes in, so a handful of
1879 // slow uploads in parallel is a resource-exhaustion path the counter
1880 // above does nothing to close. Non-blocking: a full semaphore means
1881 // "busy right now", answered immediately, rather than a request that
1882 // queues indefinitely behind someone else's slow upload.
1883 select {
1884 case s.shipSem <- struct{}{}:
1885 defer func() { <-s.shipSem }()
1886 default:
1887 // Seconds, not the budget window: this refusal clears as soon as an
1888 // upload in flight finishes, which is a different and much shorter
1889 // wait than the rate limiter's. Stamped for the same reason —
1890 // "shortly" is not a number a script can sleep on.
1891 retryAfter(w, 2*time.Second)
1892 jsonError(w, http.StatusTooManyRequests, "another ship is in progress — try again shortly")
1893 return
1894 }
1895 if s.shipStarted != nil {
1896 s.shipStarted <- struct{}{}
1897 }
1898
1899 // Bounds the read side in TIME, not just in bytes: with no deadline, a
1900 // client sending one byte a second could hold this goroutine (and
1901 // whatever it has already buffered) open for hours — the semaphore
1902 // above only bounds how many can do that at once, not for how long.
1903 // http.ErrNotSupported is tolerated because some transports (test
1904 // servers among them, in principle) don't implement the underlying
1905 // deadline setter; the semaphore is still there as a backstop on those.
1906 rc := http.NewResponseController(w)
1907 if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
1908 log.Printf("console: api ship set read deadline: %v", err)
1909 }
1910 defer func() {
1911 if err := rc.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) {
1912 log.Printf("console: api ship clear read deadline: %v", err)
1913 }
1914 }()
1915
1916 r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)
1917 mr, err := r.MultipartReader()
1918 if err != nil {
1919 jsonError(w, http.StatusBadRequest, "expected a multipart/form-data body")
1920 return
1921 }
1922
1923 first, err := mr.NextPart()
1924 if err != nil {
1925 if shipMaxBytesRefused(w, err) {
1926 return
1927 }
1928 jsonError(w, http.StatusBadRequest, "empty upload")
1929 return
1930 }
1931 if first.FormName() != "meta" {
1932 jsonError(w, http.StatusBadRequest, `the first part must be named "meta"`)
1933 return
1934 }
1935 var meta struct {
1936 Version string `json:"version"`
1937 Kind string `json:"kind"`
1938 // Label and Notes are the human half of a manifest (release
1939 // versioning spec): a table-cell summary and a changelog fragment,
1940 // both optional and both metadata rather than content — a re-ship of
1941 // the same bytes tolerates a different value for either, the same
1942 // way it already tolerates a different BuiltAt.
1943 Label string `json:"label,omitempty"`
1944 Notes string `json:"notes,omitempty"`
1945 Message string `json:"message,omitempty"`
1946 // Capabilities is the app's declaration (internal/caps), ADDITIVE:
1947 // absent from every request a CLI shipped before this field existed,
1948 // and absent from every request that does not pass -capabilities. It
1949 // is held raw so the refusal below carries caps's own message rather
1950 // than "malformed meta part" — an unknown capability name is a
1951 // specific, actionable thing to be told.
1952 Capabilities json.RawMessage `json:"capabilities,omitempty"`
1953 // ProgramChange is `carlos ship --program-change`: the operator's
1954 // statement that this release's Go main package really differs from
1955 // the app's previous release. Absent from every request a CLI shipped
1956 // before the flag existed, which then meets the refusal below.
1957 ProgramChange bool `json:"program_change,omitempty"`
1958 // Swap is the release's own demand (zero-downtime spec §4.2): "hold"
1959 // or absent. ADDITIVE: absent from every request without --swap.
1960 Swap string `json:"swap,omitempty"`
1961 }
1962 // The budget is release.MaxNotesLen — the biggest single field the meta
1963 // part now carries — plus 8KiB of headroom for everything else in it:
1964 // version, kind, label, and a capability block (a few hundred bytes, the
1965 // CLI's canonical re-marshal rather than a file verbatim). Before Notes
1966 // existed this reader was a flat 8KiB; capping it at that today would
1967 // truncate notes anywhere above ~8KiB mid-JSON, so a caller within
1968 // release.MaxNotesLen would meet "malformed meta part" instead of the
1969 // notes-specific refusal below.
1970 if err := json.NewDecoder(io.LimitReader(first, apiShipMetaMaxBytes)).Decode(&meta); err != nil {
1971 jsonError(w, http.StatusBadRequest, "malformed meta part")
1972 return
1973 }
1974 message, ok := checkedActivityMessage(w, meta.Message)
1975 if !ok {
1976 return
1977 }
1978 if meta.Kind != "binary" && meta.Kind != "static" {
1979 jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
1980 return
1981 }
1982 if meta.Kind != "static" && s.refuseTemporary(w, acct, app, "ship a binary release to it") {
1983 return
1984 }
1985 // Checked before the version is minted or validated: NextIteration below
1986 // lists the app's whole releases prefix, and a label/notes refusal should
1987 // not pay for that bucket round trip first.
1988 if err := release.ValidateLabelNotes(meta.Label, meta.Notes); err != nil {
1989 jsonError(w, http.StatusBadRequest, err.Error())
1990 return
1991 }
1992 ver := strings.TrimSpace(meta.Version)
1993 // Whether THIS console chose the version, rather than the caller. It
1994 // changes what a control-plane refusal can honestly advise below: a
1995 // version minted here cannot possibly be the one stamped into a binary
1996 // the operator built before the request.
1997 minted := ver == ""
1998 if ver == "" {
1999 // Mint from the app's version target (release versioning spec): no
2000 // target, no mint — and the refusal names the fix. This runs inside
2001 // the ship semaphore acquired above, which is what makes allocation
2002 // race-free on one console.
2003 vt, _, err := release.GetTarget(r.Context(), s.cfg.Releases, acct.Sqid, app)
2004 if err != nil {
2005 jsonError(w, http.StatusBadGateway, "could not read the version target")
2006 return
2007 }
2008 if vt == nil {
2009 jsonError(w, http.StatusBadRequest,
2010 "no version given and no version target set — pass --version, or set a target with `carlos version target`")
2011 return
2012 }
2013 t, ok := release.ParseSemVer(vt.Target)
2014 if !ok {
2015 jsonError(w, http.StatusConflict, "the stored version target "+vt.Target+" is not a semver — set it again")
2016 return
2017 }
2018 ver, err = release.NextIteration(r.Context(), s.cfg.Releases, acct.Sqid, app, t)
2019 if err != nil {
2020 jsonError(w, http.StatusBadGateway, "could not allocate the next iteration")
2021 return
2022 }
2023 } else if !release.ValidVersion(ver) || ver == "dev" {
2024 jsonError(w, http.StatusBadRequest, "invalid version")
2025 return
2026 }
2027 // Decoded and validated HERE — before a single artifact part is read, let
2028 // alone buffered — so a bad declaration costs the caller nothing but the
2029 // meta part. release.Ship would reach the same verdict through
2030 // validateManifest, but only after every byte had crossed the wire, and
2031 // not at all on the already-shipped short-circuit below.
2032 var declared *caps.Set
2033 if len(meta.Capabilities) > 0 {
2034 if err := json.Unmarshal(meta.Capabilities, &declared); err != nil {
2035 jsonError(w, http.StatusBadRequest, capsRefusal(err))
2036 return
2037 }
2038 if declared == nil {
2039 jsonError(w, http.StatusBadRequest, "capabilities: null — omit the key to ship no capabilities")
2040 return
2041 }
2042 if err := caps.ValidCapabilities(app, declared); err != nil {
2043 jsonError(w, http.StatusBadRequest, capsRefusal(err))
2044 return
2045 }
2046 // Plans 3b: a daemon never sleeps, and every plan's apps do. Before
2047 // the identical-reship short-circuit, so a re-ship cannot slip past.
2048 if declared.Daemon {
2049 if err := s.checkDaemon(acct, app); err != nil {
2050 if isPlanCap(err) {
2051 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
2052 return
2053 }
2054 log.Printf("console: api ship %s/%s: plan: %v", acct.Sqid, app, err)
2055 jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
2056 return
2057 }
2058 }
2059 }
2060 if refuseBadSwap(w, meta.Swap) {
2061 return
2062 }
2063 maxParts := apiShipMaxPartsBinary
2064 if meta.Kind == "static" {
2065 maxParts = apiShipMaxPartsStatic
2066 }
2067
2068 files := make(map[string][]byte)
2069 artifacts := make(map[string]release.Artifact)
2070 for {
2071 part, err := mr.NextPart()
2072 if err == io.EOF {
2073 break
2074 }
2075 if err != nil {
2076 if shipMaxBytesRefused(w, err) {
2077 return
2078 }
2079 jsonError(w, http.StatusBadRequest, "malformed multipart body")
2080 return
2081 }
2082 // Checked before anything else touches this part — including its
2083 // name, and certainly before io.ReadAll — because the cost this
2084 // guards against is per-PART, not per-byte: a body of nothing but
2085 // tiny, validly-named parts must be refused on the (maxParts+1)th
2086 // one, not after buffering it.
2087 if len(files) >= maxParts {
2088 jsonError(w, http.StatusBadRequest,
2089 fmt.Sprintf("too many file parts (max %d for a %s release)", maxParts, meta.Kind))
2090 return
2091 }
2092 name := part.FormName()
2093 if meta.Kind == "static" {
2094 if name != "site.tar.gz" {
2095 jsonError(w, http.StatusBadRequest, `a static release ships exactly one file part, named "site.tar.gz"`)
2096 return
2097 }
2098 } else if !targetRe.MatchString(name) {
2099 jsonError(w, http.StatusBadRequest, "invalid artifact name "+name)
2100 return
2101 }
2102 // A second part claiming a name already seen is refused rather than
2103 // silently overwriting the first: both would otherwise be charged
2104 // against apiShipMaxBytes and apiShipMaxPartsBinary/Static, but only
2105 // the last one's bytes would ever reach the manifest — a caller
2106 // deserves an error, not a release that quietly ships less than it
2107 // paid to upload.
2108 if _, dup := files[name]; dup {
2109 jsonError(w, http.StatusBadRequest, "duplicate artifact part "+name)
2110 return
2111 }
2112 b, err := io.ReadAll(part)
2113 if err != nil {
2114 if shipMaxBytesRefused(w, err) {
2115 return
2116 }
2117 jsonError(w, http.StatusBadRequest, "could not read part "+name)
2118 return
2119 }
2120 sum := sha256.Sum256(b)
2121 files[name] = b
2122 artifacts[name] = release.Artifact{
2123 File: name,
2124 SHA256: hex.EncodeToString(sum[:]),
2125 Size: int64(len(b)),
2126 }
2127 }
2128 if len(files) == 0 {
2129 jsonError(w, http.StatusBadRequest, "no artifact parts in the upload")
2130 return
2131 }
2132
2133 // The app's row is read ONCE for the rest of this handler: the
2134 // control-plane verdict immediately below and the build-probe echo on
2135 // both success paths are two columns of the same row, and a ship has no
2136 // reason to ask twice.
2137 //
2138 // It sits deliberately BEFORE release.Ship, and it FAILS THE SHIP CLOSED:
2139 // an unreadable row is answered 503 with nothing written.
2140 //
2141 // THIS SUPERSEDES AN EARLIER DESIGN, and the history holds both shapes,
2142 // so which reasoning won is written here. The build-probe echo used to
2143 // read this row for itself, AFTER release.Ship had stored the bytes, and
2144 // the ruling there was that a failed read must not fail the ship —
2145 // correct in that position, because the ship had already succeeded and
2146 // refusing bought nothing. Reading once moves the read ahead of the
2147 // write, and the premise goes with it: there is no stored release to be
2148 // honest about.
2149 //
2150 // The two policies cannot be reconciled from ONE read, so one has to
2151 // dominate, and it is the GUARD's. If the console cannot read this row it
2152 // does not know whether this app is the control plane, and shipping
2153 // anyway is exactly "a database blip is the way past the guard" — the
2154 // failure every other reader of this flag holds closed (controlplane.go).
2155 // The echo is a convenience; the guard is what stands between a
2156 // wrong-architecture binary and every box that runs the console. A
2157 // convenience does not get to set the failure policy of a guard.
2158 //
2159 // The exposure this buys is small: apiAccountApp has already read the
2160 // same table (HasAppIn) before this line, and migrate() runs at Open(),
2161 // so a row unreadable HERE but readable THERE is a column-level fault on
2162 // a console that started successfully — reachable in a test by dropping
2163 // one column, not by an ordinary outage.
2164 //
2165 // One asymmetry to know about, and accepted rather than overlooked: a
2166 // MISSING row falls open where an UNREADABLE row fails closed. AppFlags
2167 // maps sql.ErrNoRows to (false, "", nil) — "not the control plane, no
2168 // probe", which is the right reading of an app that no longer exists —
2169 // so an app purged between the gate and this line would ship unchecked.
2170 // It is acceptable for the same reason that mapping exists: apiAccountApp
2171 // has already answered its byte-identical 404 for an app that is gone, so
2172 // reaching here with no row means losing a race with a purge of the very
2173 // app being shipped to, and the release lands in a prefix nothing points
2174 // at. Fixing it belongs in AppFlags with its own callers considered, not
2175 // here.
2176 cp, probe, err := s.store.AppFlags(acct.ID, app)
2177 if err != nil {
2178 log.Printf("console: api ship %s/%s: read app flags: %v", acct.Sqid, app, err)
2179 jsonError(w, http.StatusServiceUnavailable,
2180 "could not check this app's control-plane status — nothing was shipped")
2181 return
2182 }
2183 // Read the bytes that actually arrived, before a single one is written.
2184 //
2185 // The static exemption is an ASSUMPTION, stated here because it is the
2186 // shape a bypass would take: both checks read an ELF (machine header,
2187 // embedded build info) and a static release ships one gzipped tarball,
2188 // which neither can parse — so `-kind static` skips them entirely. That
2189 // is sound only while no control-plane app ships static. Nothing refuses
2190 // one that does, deliberately: inventing constraints on what a
2191 // control-plane app may BE is not this guard's business. But anyone
2192 // making the console (or any control-plane app) a static release is
2193 // removing these two checks by doing so, and should replace them rather
2194 // than discover the gap later.
2195 if cp && meta.Kind != "static" {
2196 if err := checkControlPlaneArtifacts(files, ver); err != nil {
2197 msg := err.Error()
2198 // A MINTED version is a dead end for these checks, and the
2199 // refusal has to say so or it sends the operator in a circle:
2200 // the version was chosen here, after their binary was linked, so
2201 // no stamp could have matched it — and rebuilding with the stamp
2202 // the message names only loses to the NEXT mint. `carlos deploy`
2203 // never lands here (it defaults -version to the git short head),
2204 // but a flagged app with a version target shipped through
2205 // `carlos ship` does.
2206 if minted {
2207 msg += " — and this version was minted at the door, so no build could have carried it: pass --version explicitly for a control-plane app"
2208 }
2209 jsonError(w, http.StatusBadRequest, msg)
2210 return
2211 }
2212 }
2213
2214 m := release.Manifest{
2215 Schema: 1,
2216 App: app,
2217 Version: ver,
2218 Kind: meta.Kind,
2219 BuiltBy: "console-api " + version.Build,
2220 BuiltAt: time.Now().UTC().Format(time.RFC3339),
2221 Artifacts: artifacts,
2222 Capabilities: declared, // nil when the meta part carried no key
2223 Label: meta.Label,
2224 Notes: meta.Notes,
2225 Swap: meta.Swap,
2226 }
2227 // Longer than the other CLI doors' 15-20s: this one moves real bytes —
2228 // up to apiShipMaxBytes of them — into the bucket, not a handful of
2229 // short strings.
2230 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
2231 defer cancel()
2232
2233 // A version's objects are immutable once shipped, but BOTH callers that
2234 // build a manifest like this one (this handler and the direct `carlos
2235 // ship` CLI path) stamp BuiltAt with a fresh time.Now() on every
2236 // invocation. Comparing the freshly-marshaled manifest byte-for-byte
2237 // against whatever release.Ship's own putImmutable finds already
2238 // stored — which is exactly what it does — would make a genuinely
2239 // identical re-ship register as a "different content" conflict for a
2240 // reason no caller could ever avoid: its OWN timestamp. Comparing
2241 // ARTIFACTS here first — content, not metadata — is what makes
2242 // re-shipping the exact same bytes twice actually succeed instead of
2243 // permanently wedging on its own BuiltAt.
2244 // Label and Notes are checked NOWHERE below this point — deliberately.
2245 // They are metadata about a version, not content, the same tolerance
2246 // BuiltAt already gets: a re-ship of an existing version with a
2247 // DIFFERENT label or notes still succeeds through the short-circuit
2248 // below. The STORED manifest keeps its ORIGINAL label/notes — nothing
2249 // is written on this path, so a later ship can never rewrite them.
2250 if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil {
2251 if !shipArtifactsMatch(existing.Artifacts, artifacts) {
2252 jsonError(w, http.StatusConflict,
2253 "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
2254 return
2255 }
2256 // Artifacts alone are not the whole of what a version means any more.
2257 // The capability block decides the SHAPE OF THE UNIT the bytes run
2258 // in — which user, which privileged ports, which runtime dir — so a
2259 // re-ship of the same bytes with a different block is a different
2260 // release in every way that matters on a box. Without this check the
2261 // short-circuit below would answer 200 and silently keep the FIRST
2262 // block, which is the immutability promise broken for exactly the
2263 // field that changes the most about how the app runs.
2264 if !shipCapabilitiesMatch(existing.Capabilities, declared) {
2265 jsonError(w, http.StatusConflict,
2266 "version "+ver+" was already shipped with different capabilities — versions are immutable; ship under a new version")
2267 return
2268 }
2269 if refuseSwapConflict(w, existing.Swap, meta.Swap, ver) {
2270 return
2271 }
2272 // Identical content already shipped: answer success without
2273 // writing anything — calling release.Ship again would only
2274 // rediscover this the slow way, and would in fact trip its OWN
2275 // manifest-level putImmutable conflict (see above), since BuiltAt
2276 // drifts every call even when the artifacts don't. 200, not 201:
2277 // nothing was created by this request.
2278 out := make(map[string]any, len(existing.Artifacts))
2279 for name, a := range existing.Artifacts {
2280 out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size}
2281 }
2282 body := map[string]any{"result": "shipped", "version": ver, "artifacts": out}
2283 // The stored block, not `declared` — they are equal by the check just
2284 // above, and echoing what is ON RECORD is the honest answer to "what
2285 // did this version end up declaring".
2286 addCapabilitiesEcho(body, existing.Capabilities)
2287 addSwapEcho(body, existing.Swap)
2288 addBuildProbe(body, probe)
2289 writeJSON(w, http.StatusOK, body)
2290 return
2291 }
2292
2293 // The wrong-program rule, the same one the direct-bucket `carlos ship`
2294 // calls: a release whose Go main package differs from the app's previous
2295 // release is refused unless the caller said --program-change. It reads
2296 // the artifact bytes that arrived, so it runs after m exists and before
2297 // anything is written, but AFTER the identical-reship short-circuit
2298 // above: re-shipping a version that is already on record must stay
2299 // idempotent (a CI retry of an older version after a program change is
2300 // still a 200), so only a NEW version meets the rule. A mismatch is a 409 (the request is well
2301 // formed, the app's history disagrees); anything else is a 400, the
2302 // artifact or the app's releases being unreadable.
2303 if err := release.CheckShipProgram(ctx, s.cfg.Releases, acct.Sqid, &m, files, meta.ProgramChange); err != nil {
2304 var pm *release.ProgramMismatchError
2305 if errors.As(err, &pm) {
2306 jsonError(w, http.StatusConflict, err.Error())
2307 return
2308 }
2309 jsonError(w, http.StatusBadRequest, err.Error())
2310 return
2311 }
2312 if err := release.Ship(ctx, s.cfg.Releases, acct.Sqid, m, files); err != nil {
2313 log.Printf("console: api ship %s %s: %v", app, ver, err)
2314 if shipImmutableConflict(err) {
2315 // A permanent conflict, not a transient one: reachable when this
2316 // raced against another ship of the same version (the
2317 // GetManifest check above is not atomic with the Ship call
2318 // below), not through the common path above. Retrying this
2319 // exact request will never succeed, so it gets its own status
2320 // rather than the retry-flavored 502 below.
2321 jsonError(w, http.StatusConflict,
2322 "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
2323 return
2324 }
2325 // Anything else is the store failing, not the caller: a 502,
2326 // matching the other doors' convention that a bucket-side error is
2327 // never described to the caller in bucket-side terms.
2328 jsonError(w, http.StatusBadGateway, "could not store the release — try again")
2329 return
2330 }
2331 s.auditActivity(acct, app, addr, Activity{
2332 Action: "ship", TargetType: "release", Target: ver, Message: message,
2333 Detail: "shipped " + ver + " (cli)",
2334 })
2335
2336 out := make(map[string]any, len(artifacts))
2337 for name, a := range artifacts {
2338 out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size}
2339 }
2340 body := map[string]any{"result": "shipped", "version": ver, "artifacts": out}
2341 addCapabilitiesEcho(body, m.Capabilities)
2342 addSwapEcho(body, m.Swap)
2343 addBuildProbe(body, probe)
2344 writeJSON(w, http.StatusCreated, body)
2345}
2346
2347// addBuildProbe adds the app's build-probe path to a ship response, so
2348// `carlos deploy` learns it from the call it was already making. The probe is
2349// what the deploy watch polls for a build the app's own PROCESS reports
2350// instead of the edge's route-row header (deploy.go, watchTarget.Probe).
2351//
2352// It rides the ship response rather than a door of its own because deploy
2353// always ships before it watches: the value arrives with no extra round trip,
2354// and the ship response is already scoped to exactly the app whose probe this
2355// is. The value comes from the app ROW (Store.AppFlags) — never from
2356// provision.Record, whose handleAPIInstanceUpdate is a read-modify-write that
2357// would silently drop a field it does not know: the probe is app-level, not
2358// per-instance.
2359//
2360// It takes the probe rather than reading it because handleAPIShip already
2361// read that row, for the control-plane check that must happen before any
2362// bytes are written — so the read moved UP, ahead of release.Ship, and this
2363// function is now only the echo. With the move, this function's original
2364// "a failed read must not fail the ship" ruling is SUPERSEDED: the read now
2365// fails the ship closed, because the guard sharing it cannot fall through.
2366// The reasoning is recorded in full at the read site.
2367//
2368// Additive: a client that does not read the key is unaffected, and an app
2369// with no probe carries no key at all, so every ship the fleet does today
2370// answers byte for byte as before.
2371func addBuildProbe(body map[string]any, probe string) {
2372 if probe != "" {
2373 body["build_probe"] = probe
2374 }
2375}
2376
2377// addCapabilitiesEcho adds the "capabilities" key to a ship response: the
2378// canonical JSON of the block that was actually STORED, or nothing at all when
2379// the version declares none.
2380//
2381// It exists to close the removal signal (2026-08-03 review, I3). `capabilities`
2382// is an unknown JSON key to any console built before it existed, and Go's
2383// decoder ignores unknown keys — so an un-upgraded console meeting a `carlos
2384// ship -capabilities` request drops the block on the floor, writes a manifest
2385// that declares NOTHING, and answers 201. On the box that manifest is not
2386// "nothing new"; it is a manifest that resolves and declares no capabilities,
2387// which is the REMOVAL instruction — converge deletes the drop-ins, and :25
2388// comes off a running mail server because a binary somewhere was old. The
2389// deploy order in §3b exists to prevent exactly this, and an echo is what
2390// makes violating it loud instead of silent: a client that sent a block and
2391// gets no echo back knows it is talking to a console that did not understand
2392// it, before the release is ever promoted.
2393//
2394// Additive in the direction that costs nothing: a client that does not read
2395// the field is unaffected, and a version with no capabilities carries no key —
2396// so the response bytes for every ship the fleet does today are unchanged.
2397func addCapabilitiesEcho(body map[string]any, set *caps.Set) {
2398 if set == nil {
2399 return
2400 }
2401 b, err := json.Marshal(set)
2402 if err != nil {
2403 // Unreachable — caps.Set has no custom marshaller — and omitting the
2404 // key is the fail-closed direction: a client that sent capabilities
2405 // refuses on a missing echo, which is the same verdict it would reach
2406 // if this console were the old one.
2407 log.Printf("console: api ship: marshal capabilities echo: %v", err)
2408 return
2409 }
2410 body["capabilities"] = json.RawMessage(b)
2411}
2412