// CLI identity, HTTP half: the endpoints `carlos auth login` speaks to, and // the two pages a human uses — approve a terminal, and see (or kill) every // terminal that speaks as them. // // WHY THE CONSOLE AND NOT A SECOND KEYMAIL CLIENT. The console is already a // registered Keymail OAuth client and already the deployment's identity // authority: it knows who you are, which accounts you belong to, and what // role you hold in each. Minting a SECOND OAuth client for the CLI would mean // a second consent screen, a second grant to revoke, a second client_id to // register per self-hosted deployment — and a CLI that trusts keymail.dev // directly rather than trusting the console the operator actually runs. So // the CLI authenticates against the deployment's own console, and the console // authenticates the human with the Keymail sign-in it already has. One OAuth // client, one place to revoke, and a self-hoster's CLI trusts their own box. // // WHY A TYPED CODE AND NOT A LOCALHOST CALLBACK. The terminal that needs a // credential is very often an SSH session on a box, a container, or a CI // shell. A browser-redirect flow assumes the browser can reach a listener on // the machine running the CLI, which in all three cases it cannot. The typed // code crosses that gap by hand: it is short enough to retype from a phone, // and the credential is delivered back over the CLI's own polling channel. package console import ( "context" "crypto/subtle" "encoding/json" "errors" "fmt" "log" "net" "net/http" "net/url" "strings" "sync" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/svcrole" ) // Rate limits. Deliberately generous for humans and tight for scripts: the // numbers only have to make brute force and database-stuffing pointless, not // police normal use. const ( cliRateWindow = time.Minute // cliStartBurst bounds unauthenticated login starts per client IP. Each // start writes a row, so this is the anti-stuffing limit. cliStartBurst = 10 // cliPollBurst is per device code — a polite CLI polls every 2s, so 60 a // minute is twice what the honest client needs and nowhere near a spin. // It is NOT a defence on its own: the device code is chosen by the // caller, so a flood simply presents a fresh key every time. cliPollIPBurst // is the limit that actually bounds a flood. cliPollBurst = 60 // cliPollIPBurst bounds polling per client IP, whatever device codes it // claims to hold. Roomy enough for a shared NAT with a dozen developers // mid-login at once. cliPollIPBurst = 240 // cliGuessBurst bounds WRONG typed codes, counted against the signed-in // address and against the client IP. The user code is ~39 bits with a // ten-minute life; at 10 guesses a minute it is not findable in that // window by any margin that matters. cliGuessBurst = 10 // cliWriteBurst bounds authenticated mutations per address. cliWriteBurst = 30 // cliPollInterval is the wait the console asks the CLI to keep, seconds. cliPollInterval = 2 // cliShipWindow is the minute window every other CLI limiter uses. It // was an HOUR until 2026-08-23, on the premise that "a ship is a // deliberate release cut, not a poll" — a premise a fixed-window // counter turns into the worst refusal on this surface. Spend the hour // budget in five minutes and the token is dead for the remaining // fifty-five, with nothing in the answer to say so; the caller that // actually does this is an agent iterating ship-test-ship, and what it // learns is that shipping stopped working. A minute window bounds the // same burst (see cliShipBurst) and bounds the WAIT too: the worst a // refused caller can be asked for is one minute, which is a sleep, not // an outage. cliShipWindow = time.Minute // cliShipBurst bounds ship uploads per TOKEN (see handleAPIShip) within // that minute. Two, deliberately: a build-and-ship takes longer than // half a minute to produce anything worth shipping, so two a minute is // past what an honest iterating caller can generate — while a stolen // token bursting flat out is held to a TIGHTER instantaneous rate than // the old ten-in-an-hour allowed (which permitted ten back to back). // The hourly ceiling rises 10 -> 120; the release retention sweep, not // this counter, is what bounds what those 120 cost in storage. cliShipBurst = 2 // cliInstanceBurst bounds the instance verbs (apiinstances.go) per // TOKEN within the ordinary minute window. Keyed by token hash, not by // address, because the caller here is usually not a person at all: an // app token holds no identity to charge a person's budget against, and // a burst of signups — several instances created in one minute — is the // legitimate workload this API exists for. Sharing the address-keyed // write budget would couple the two in both directions: a stolen user // token's noise would starve the app's own signups, and a busy signup // hour would refuse its minter's promotes. cliInstanceBurst = 60 // cliRunsBurst is the follow routes' per-token budget per window: a // once-a-second poll, its acks and cancels, with room. cliRunsBurst = 600 // cliCreateWindow is app creation's own budget window — a DAY, the // longest here, because claiming an app name is the rarest deliberate // act on this surface and the one whose spam is permanent: a claimed // name is a row an operator has to clean up, not a pointer the next // promote overwrites. The number is a ceiling on a stolen token (ten // inert names a day, per address), not a policing of humans — the // browser's New app form has no limit and is the escape hatch // (2026-08-04-cli-app-create-design.md §3). cliCreateWindow = 24 * time.Hour // cliCreateBurst bounds app-claim ATTEMPTS per address within that day // — attempts, not successes, or refused probes would be free. One caveat // the limiter's maxRateKeys note doesn't cover: create's keys are real // member addresses accumulating for a whole day, not attacker-minted // strings, so a deployment with >8192 members claiming apps in one day // would trip the wholesale map drop and reset every budget. Orders of // magnitude beyond this fleet; recorded, not defended. cliCreateBurst = 10 // cliTemporaryCreateBurst bounds temporary-app claim ATTEMPTS per // principal per day (spec ruling 8, Paul 2026-09-26). Its own budget, // not cliCreateBurst's: the scratchpad use is an agent in a loop, and // ten a day would refuse it by lunchtime. The live cap (20) bounds // storage while apps live; this bounds churn, since a trashed // temporary app still holds storage for its 3-day window, so the // worst case is about 150 trashed-but-unpurged apps per principal. cliTemporaryCreateBurst = 50 // Service-principal budgets (Task 5, issue #220 consequence 1). A hub is // not a person, and each number below is its own judgement calibrated // for what a legitimate AUTOMATED caller does — not a derivation from // the person numbers above, and not one shared story: read each // comment on its own rather than assuming the others generalize it. // // All three are keyed on the principal NAME (principalKey), not the // token hash: rotating a credential must not hand it a fresh budget, or // a rotation mid-flood would be the limit doing nothing. // svcCreateBurst: 100 repos enabling Pages (or otherwise claiming an // app) in one day — ten times cliCreateBurst's per-person ceiling, // because one hub credential legitimately claims on behalf of many // repos where one person claims only for themselves. svcCreateBurst = 100 // per day, the cliCreateWindow // svcWriteBurst: a mutation roughly every half second sustained for a // full minute — four times cliWriteBurst's per-person ceiling, roomy // for a CI fleet's shared credential running many promotes, rollbacks // and restarts back to back. svcWriteBurst = 120 // per minute, the cliRateWindow // svcShipBurst: ten ships a minute sustained — five times // cliShipBurst's per-token ceiling, for a busy CI fleet cutting many // releases at once rather than one human's occasional deploy. It kept // its multiple over the person budget when cliShipWindow went from an // hour to a minute; the number moved (60 -> 10) only because the window // under it did. svcShipBurst = 10 // per minute, the cliShipWindow // wildFailBurst bounds FAILED wildcard enables per principal per // minute — PEEKED before the DNS round trip and CONSUMED only after it // fails (rateLimiter.tripped / .allow, C33 review), so a caller whose // CNAME is correct never accumulates a failure and never meets it, // while a retrying caller stops costing the console a live outbound // DNS lookup once it has tripped. The successful path is exempt // entirely (spec §4a): with wildcard DNS a Pages hub makes no per-repo // hostname call at all, and an enable is genuinely once-per-domain. // Roomy enough for a resolver blip, closed enough that an exempt door // is not a way to make the console do unbounded outbound DNS. wildFailBurst = 20 // domainsCeilingBurst is NOT a workload budget — attach/detach are // exempt from the write/instance budgets by design (spec §4a: a // wildcard hub makes near-zero per-repo calls, so there is no // legitimate workload to bound). It is a CONVERGENCE-RATE governor // instead: every EFFECTIVE attach or detach audits and bumps // control/serial, and boxes poll that serial at 2s and run a full pass // on every move, so an unlimited exempt door would let one // authenticated credential drive fleet-wide convergence at request // rate — an alternating attach/detach loop on one host, for instance. // 180/min is deliberately clear of svcWriteBurst (120) rather than // merely above it: the two used to sit at the same number, which meant // no test volume could land strictly between them, so a regression that // put the write budget back onto this door (re-adding allowWrite to // domainsGate) would refuse at the SAME threshold this ceiling does — // nothing would fail. A real gap between the two numbers is what makes // TestWildcardEnableIsNotRateLimited able to pick a volume that proves // the write-budget exemption specifically, not just "some limiter or // other let this through." domainsCeilingBurst = 180 // per minute, the cliRateWindow, keyed on the principal ) // rateLimiter is a fixed-window counter, keyed by whatever the caller thinks // identifies a client. In-memory and per-process on purpose: the console is // one process on one box, and reaching for a shared store here would add a // dependency to buy nothing. type rateLimiter struct { mu sync.Mutex max int window time.Duration lastSweep time.Time hits map[string]*rateWindow } type rateWindow struct { start time.Time n int } // maxRateKeys hard-bounds a limiter's map. Some limiters are keyed on // caller-chosen values (a device code), so "one entry per distinct key" is a // memory budget an attacker writes — this is where that stops. const maxRateKeys = 8192 func newRateLimiter(max int, window time.Duration) *rateLimiter { return &rateLimiter{max: max, window: window, hits: map[string]*rateWindow{}} } // allow records one hit and reports whether it is within the window's budget. // // The sweep runs at most once per window, NEVER per request: an O(n) walk on // every call — while holding this mutex, in front of a database limited to one // writer that every console page also queues behind — turns a flood on one // endpoint into a stall on the whole console. Over the hard bound the map is // dropped wholesale rather than walked, because under a flood of made-up keys // there is nothing to distinguish worth keeping, and forgetting counters is a // far smaller failure than freezing the site. func (l *rateLimiter) allow(key string, now time.Time) bool { l.mu.Lock() defer l.mu.Unlock() if now.Sub(l.lastSweep) >= l.window { for k, w := range l.hits { if now.Sub(w.start) > l.window { delete(l.hits, k) } } l.lastSweep = now } if len(l.hits) > maxRateKeys { l.hits = make(map[string]*rateWindow, 64) l.lastSweep = now } w := l.hits[key] if w == nil || now.Sub(w.start) > l.window { l.hits[key] = &rateWindow{start: now, n: 1} return true } w.n++ return w.n <= l.max } // tripped is allow's READ-ONLY half: it reports whether key is ALREADY over // budget, in the current window, without recording a hit. It exists for a // caller that wants to skip an expensive operation ENTIRELY once the budget // is spent, rather than pay for the operation and only then be refused // (Task 5 review, C33) — a live outbound DNS round trip is the case that // motivated it, but the shape is general. // // Same sweep, same reason as allow: an expired window must read as fresh // here too, or a peek and the allow that follows it a moment later could // disagree about whether the caller is still bounded. It does NOT create an // entry for a key it has never seen and does NOT run allow's over-the-hard- // bound wholesale drop — a peek must never be the thing that evicts every // OTHER key's count, which a bare read has no business doing. func (l *rateLimiter) tripped(key string, now time.Time) bool { l.mu.Lock() defer l.mu.Unlock() if now.Sub(l.lastSweep) >= l.window { for k, w := range l.hits { if now.Sub(w.start) > l.window { delete(l.hits, k) } } l.lastSweep = now } w := l.hits[key] if w == nil || now.Sub(w.start) > l.window { return false } return w.n >= l.max } // resetIn reports how much of key's CURRENT window is left, so a refusal can // say WHEN instead of "wait a while". It is meaningful only immediately // after allow returned false for the same key: a key with no window, or one // whose window has already run out, has nothing to wait for and answers 0. // // Read-only like tripped, and — unlike both allow and tripped — it does NOT // sweep. The caller it exists for has just been through allow, which swept a // moment ago; a second walk of the map under the same mutex, on the path // that is by definition being hit hardest, is the stall allow's own comment // is at pains to avoid. // // Rounded UP to a whole second, and never below one: Retry-After's unit is // seconds, and a truthful "0" or "0.4" is an invitation to retry straight // back into the same refusal. func (l *rateLimiter) resetIn(key string, now time.Time) time.Duration { l.mu.Lock() defer l.mu.Unlock() w := l.hits[key] if w == nil { return 0 } left := w.start.Add(l.window).Sub(now) if left <= 0 { return 0 } secs := left / time.Second if left%time.Second != 0 { secs++ } return secs * time.Second } // retryAfter stamps the one part of a 429 a caller can act on without // reading prose. The message beside it says the same thing in words for a // human; this is for the script, the CI step and the agent, all of which // otherwise have to choose between retrying immediately (into the same // refusal) and giving up. Called BEFORE jsonError, which writes the status // line and so closes the header map. // // A zero or negative wait is stamped as one second rather than omitted: the // caller reaching this has been refused, and "retry whenever" is the answer // that produced the spin. func retryAfter(w http.ResponseWriter, d time.Duration) { if d < time.Second { d = time.Second } w.Header().Set("Retry-After", fmt.Sprintf("%d", d/time.Second)) } // cliLimits is the set of limiters the CLI endpoints share, hung off the // Server so a test gets a fresh set per fixture. type cliLimits struct { start, poll, pollIP, guessAddr, guessIP, write, ship, instances, create *rateLimiter // db is the `carlos db` door's own budget, keyed by token (cliDBBurst, // apidb.go): a restore is dozens of reads, a different shape from the // instance verbs. db *rateLimiter // runs is the follow routes' budget (run spec §5.6): the CLI polls once // a second, which the 60-a-minute instance window would refuse. The // create door spends instances, as spec refusal 3 says. runs *rateLimiter // svcCreate, svcWrite and svcShip are the SERVICE side of create/write/ // ship — separate counters, separate keys (principalKey's NAME, not the // person's ADDRESS or the ship-person's token hash), so a credential's // budget is never the operator's to spend (issue #220 consequence 1). // allowCreate/allowWrite/allowShip (svcprincipal.go) pick between the // person and the service limiter so no call site has to. svcCreate, svcWrite, svcShip *rateLimiter // tempCreate is /new-temporary's own budget (cliTemporaryCreateBurst), // one limiter for both principal kinds, keyed like create by // principalKey, separate from create and svcCreate so neither kind of // create spends the other's day. tempCreate *rateLimiter // wildFail bounds only FAILED wildcard-enable attempts — see // wildFailBurst. The domains doors are otherwise exempt from write/ // instances entirely (spec §4a). wildFail *rateLimiter // domainsCeiling is the convergence-rate governor attach/detach keep // even though they are exempt from write/instances — see // domainsCeilingBurst. One limiter, one key shape (principalKey), // regardless of person or service: the exemption never depended on // which kind of caller it was, so neither does this ceiling. domainsCeiling *rateLimiter } func newCLILimits() *cliLimits { return &cliLimits{ start: newRateLimiter(cliStartBurst, cliRateWindow), poll: newRateLimiter(cliPollBurst, cliRateWindow), pollIP: newRateLimiter(cliPollIPBurst, cliRateWindow), // Wrong codes are counted against the signed-in ADDRESS and the client // IP, never the session token: a session is re-minted by signing in // again, so a token-keyed counter is one the attacker resets at will. guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow), guessIP: newRateLimiter(cliGuessBurst, cliRateWindow), write: newRateLimiter(cliWriteBurst, cliRateWindow), // Ship gets its own budget, on its own window, keyed by token rather // than address (see handleAPIShip) — an upload is a different shape // of cost (bandwidth, storage) from the JSON mutations write covers. ship: newRateLimiter(cliShipBurst, cliShipWindow), // The instance verbs' own budget, keyed by TOKEN (see // cliInstanceBurst and apiinstances.go): the app token that does // most of this work is not a person, so the address-keyed write // budget is the wrong principal for it in both directions. instances: newRateLimiter(cliInstanceBurst, cliRateWindow), db: newRateLimiter(cliDBBurst, cliRateWindow), runs: newRateLimiter(cliRunsBurst, cliRateWindow), // Create gets its own budget on its own (day-long) window, keyed by // address — the stolen-token ceiling handleAPICreateApp explains. create: newRateLimiter(cliCreateBurst, cliCreateWindow), // Temporary creates get their own budget on create's window (spec // ruling 8) — see cliTemporaryCreateBurst. tempCreate: newRateLimiter(cliTemporaryCreateBurst, cliCreateWindow), svcCreate: newRateLimiter(svcCreateBurst, cliCreateWindow), svcWrite: newRateLimiter(svcWriteBurst, cliRateWindow), svcShip: newRateLimiter(svcShipBurst, cliShipWindow), wildFail: newRateLimiter(wildFailBurst, cliRateWindow), domainsCeiling: newRateLimiter(domainsCeilingBurst, cliRateWindow), } } // cliRouteKey is the context key cliRoute stashes the resolved principal // under. Unexported, and of an unexported type, so nothing outside this // package can forge one onto a request. type cliRouteKey struct{} // principalFrom reads the principal cliRoute resolved. ok is false only for a // request that did not come through cliRoute — which, for anything under // /api/cli/, TestEveryCLIDoorNamesItsRoles says cannot happen. func principalFrom(ctx context.Context) (Principal, bool) { p, ok := ctx.Value(cliRouteKey{}).(Principal) return p, ok } // cliRoute registers one CLI API door and is the ONLY way to register one. // It resolves the bearer once, applies the role gate, and passes the // principal down on the request context so the handler's own resolver does // not repeat the lookup. // // THE ROLE GATE RUNS BEFORE THE HANDLER, AND THEREFORE BEFORE THE SCOPE // CHECK — spec §3b. A role refusal reads neither the account segment nor the // database: it is a constant function of the door and the caller's own role, // identical whether the named account exists, belongs to someone else, or // belongs to the caller. There is nothing in it to leak. Checking scope first // would instead make the pair of answers a membership oracle — 404 for an // account you do not hold, 403 for one you do. Role-first is also the only // order that can be enforced structurally, out here, rather than asked of // every handler. // // A PERSON never meets the gate. Their authority is their membership set, // evaluated per request by the handler, and no role name resolves to it. // // allow is never optional and the zero Set permits nothing — not even admin — // so a door added without naming its roles is unreachable by every service // principal rather than open to all of them. func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc) { if s.cliRoles == nil { s.cliRoles = map[string]svcrole.Set{} } s.cliRoles[pattern] = allow s.registerRoute(pattern, func(w http.ResponseWriter, r *http.Request) { p, ok := s.store.CLIPrincipal(bearer(r), time.Now()) if !ok { // The same 401, with the same body, cliUser has always given: // an expired token, a revoked one and a fabricated one are not // distinguishable from outside. s.cliNotSignedIn(w) return } if p.Kind == PrincipalService && !allow.Permits(p.Role) { if allow.Empty() { // A closed door has no role that would work, so naming the // caller's role and an empty list ("needs one of []") would // read as a bug and send whoever hit it looking for the role // that opens it. There is none: this is the escalation cut. // Constant for every role and every account segment, which // keeps it as leak-free as the refusal below. jsonError(w, http.StatusForbidden, "this door is closed to service credentials — no role reaches it; it needs a person") return } jsonError(w, http.StatusForbidden, fmt.Sprintf( "this credential holds role %q; this door needs one of %v", p.Role, allow.Roles())) return } h(w, r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, p))) }) } // registerCLIRoutes wires the CLI-identity surface. Called from NewServer. // // The /auth/cli/ family and GET /settings below stay on plain // mux.HandleFunc, deliberately: they are the OTHER half of default-deny. // Every one of them that reads a bearer at all gates on cliUser, which // answers only for a person, so a service credential meets there exactly the // 401 a fabricated token meets. Pinned by TestAuthCLIFamilyResolvesOnlyPersons. // // Every /api/cli/ door goes through cliRoute and names its roles. Pinned by // TestEveryCLIDoorNamesItsRoles. func (s *Server) registerCLIRoutes() { s.registerRoute("POST /auth/cli/start", s.handleCLIStart) s.registerRoute("POST /auth/cli/poll", s.handleCLIPoll) s.registerRoute("GET /auth/cli", s.handleCLIApprovePage) s.registerRoute("POST /auth/cli/approve", s.handleCLIApprove) s.registerRoute("GET /settings", s.handleCLITokens) s.registerRoute("GET /auth/cli/tokens", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings", http.StatusSeeOther) }) s.registerRoute("POST /auth/cli/tokens/revoke", s.handleCLITokenRevoke) s.registerRoute("GET /auth/cli/whoami", s.handleCLIWhoami) s.registerRoute("POST /auth/cli/revoke", s.handleCLIRevoke) s.registerRoute("POST /auth/cli/accounts", s.handleCLINewAccount) // The CLI's read-only API (apicli.go) — deliberately a different prefix // (/api/cli/) from the identity routes above (/auth/cli/), which must not // move now that CLIs depend on them. s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps) // The fleet-wide live tenancy read (discussion #11). An EMPTY role set: // no service credential of any role reaches it, because the answer names // every account's instances on every box, and that is an operator's view // rather than any workload's. The operator check itself is inside the // handler, since cliRoute's role set can only narrow SERVICE principals // and a person always passes through it. s.cliRoute("GET /api/cli/status/instances", svcrole.Set{}, s.handleAPIStatusInstances) if s.cfg.HostClaim { // Host loss inspection and submission are person-only and repeat the // fleet-operator check in their handlers. Until the follow-on integration supplies the // ActorDirectory both routes stop at the same inert refusal. s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect) s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve) // Box and group roles (region HA spec 2 §7, apiboxroles.go): // operator-only, and registered only with CARLOS_REGION_HA on too, // so with it off they answer as any unknown route, as on main. if s.cfg.RegionHA { s.cliRoute("POST /api/cli/system/boxes/{box}/role", svcrole.Set{}, s.handleAPISystemBoxRole) s.cliRoute("POST /api/cli/system/groups/{region}/role", svcrole.Set{}, s.handleAPISystemGroupRole) } } // carlos status --layout (plan 4, task 5): which layout each instance is // on, box by box. Same posture as the door above — an empty role set, the // operator check inside the handler. s.cliRoute("GET /api/cli/status/layout", svcrole.Set{}, s.handleAPIStatusLayout) // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no // bare POST .../{app} route exists to collide with, and the mux prefers // the literal if one ever does. s.cliRoute("POST /api/cli/apps/{account}/new", svcrole.Allow(svcrole.Publish), s.handleAPICreateApp) // Temporary apps (temporary static sites spec §3). A separate route, not a // field on /new: /new's decoder accepts unknown fields, so an older // console would take "temporary" silently and create a permanent app. An // older console answers this route 404 and the CLI stops there. s.cliRoute("POST /api/cli/apps/{account}/new-temporary", svcrole.Allow(svcrole.Publish), s.handleAPICreateTemporaryApp) s.cliRoute("GET /api/cli/apps/{account}/list", svcrole.Allow(svcrole.Publish), s.handleAPIAppList) s.cliRoute("POST /api/cli/apps/{account}/{app}/ttl", svcrole.Allow(svcrole.Publish), s.handleAPIAppTTL) s.cliRoute("POST /api/cli/apps/{account}/{app}/keep", svcrole.Allow(svcrole.Publish), s.handleAPIAppKeep) s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels) s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote) s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback) // `carlos channels resign` (#253, spec §4.6): a person confirms the // manifest digests the console then signs into the channel's pointer, so // no credential reaches it. s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign) // `carlos releases authorize` (settings spec §4.1): GET shows a build's // digests, POST signs a promotion record for the digest a person // confirmed. A credential the app holds never vouches for a build. s.cliRoute("GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) s.cliRoute("POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize) s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) // `carlos restart` (issue #113): a member's "cycle this app's processes" // with no version and no config to push — the same write-door class as // promote and rollback, and the only one whose subject is a running // process rather than a pointer. s.cliRoute("POST /api/cli/apps/{account}/{app}/restart", svcrole.Allow(svcrole.Operate), s.handleAPIRestart) // The NARROWED form (discussion #16): one route, or everything reading one // config environment, plus the dry run that shows the set first. A // SEPARATE path rather than a body on the door above, and deliberately so: // that one reads no body, so a console predating this would answer a // narrowed request by cycling the whole app. Here it answers 404 and the // CLI says why. Same role — the subject is still a running process. s.cliRoute("POST /api/cli/apps/{account}/{app}/restart/scope", svcrole.Allow(svcrole.Operate), s.handleAPIRestartScope) // `carlos schedule` (apischedule.go, scheduled-work spec §8): declare // recurring work, take it back, and make it fire now. The RESTART class, // not the publish one — operating a running app rather than moving a // version pointer — so the same Operate role reaches all four. `delete` // and `run` are literal segments after the {name} wildcard, so a schedule // named "delete" is still just a name; the mux prefers the literal // regardless of registration order. s.cliRoute("GET /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleList) s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleSet) s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/delete", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRemove) s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/run", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRun) // `carlos releases retention` (issue #136): a member's ambient-prune // policy — read, set, clear, preview. Same identity gates as every // door here; the safety list in release.PruneEligible is what keeps a // policy from ever deleting anything promoted, tagged, or recent. s.cliRoute("GET /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionSet) // The version-target, tags and releases-list API (apiversion.go, // release versioning spec 2026-08-08). GET .../releases coexists with // the POST above on the SAME path — a distinct method route, not a // collision — for `carlos releases` to read what `carlos ship` writes. s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet) s.cliRoute("PUT /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetSet) s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList) s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet) s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList) // The pipeline API (apipipeline.go, release pipelines v2). GET is // release metadata like channels/releases above — Publish reaches it. // PUT rewrites the policy governing every promotion into every channel // (bake, passkey, approval quorum), account authority in the same // register as ledgers and store grants below — Admin-only for a // SERVICE credential. svcrole.Allow() alone would be the WRONG way to // say that: the empty Set is fully closed, Admin included // (TestEmptySetPermitsNothingIncludingAdmin, internal/svcrole) — the // same zero value svcrole.Set{} spells for the person-only doors // below. Naming Admin explicitly is what actually opens the door to an // admin-role service and closes it to every other role, exactly like // the ledgers and store families. s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet) s.cliRoute("PUT /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Admin), s.handleAPIPipelinePut) // What the app is waiting on: the proposals a gated promote, fast-track // or policy edit turned into (pendinghandlers.go). Read-only, and Publish // like channels and releases — a proposal names no secret, and the CLI // needs to see one precisely because it can never confirm one itself // (spec §4: confirming takes a passkey, in the console). s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending) // Trash and restore (apicli.go). Delete resolves through the LIVE-apps // door and restore through the trashed-apps one, so each verb answers // only for an app in the state it can act on — and both give a stranger // the same 404. s.cliRoute("POST /api/cli/apps/{account}/{app}/delete", svcrole.Allow(svcrole.Publish), s.handleAPITrashApp) s.cliRoute("POST /api/cli/apps/{account}/{app}/restore", svcrole.Allow(svcrole.Publish), s.handleAPIRestoreApp) // The ledger API (apiledger.go) — the only write path to ledgers/ from // outside a box. The literal `publish` segment is kept adjacent to the // {ledger} wildcards for readability; the mux prefers it regardless of // registration order. s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers", svcrole.Allow(svcrole.Admin), s.handleAPILedgerList) s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/publish", svcrole.Allow(svcrole.Admin), s.handleAPILedgerPublish) s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", svcrole.Allow(svcrole.Admin), s.handleAPILedgerHead) s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", svcrole.Allow(svcrole.Admin), s.handleAPILedgerAppend) s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", svcrole.Allow(svcrole.Admin), s.handleAPILedgerBlob) // The instance provisioning API (apiinstances.go) — the only door onto // instances/ and domains/ from outside a box. It was once the only // family a non-person could speak to; since the role gate landed that is // a property of each door's Allow set, not of this block. `instance-token` is a literal segment beside the // {host} wildcards; the mux prefers a literal regardless of // registration order, and it deliberately sits under a DIFFERENT // prefix from instances/{host} so a host can never be mistaken for the // mint verb. // `instances-manifest` is the member-facing half of `carlos instances // enable` (issue #66): a literal sibling of instances/, never under it, // so a host named "instances-manifest" could still never reach it. // // The zero Set, for the same escalation cut the mint door takes. This // door writes the MANIFEST — standing policy about which domain, pool, // regions and channel the app may ever be brought up on — and a // credential the app itself holds must not be able to rewrite the // bounds it runs inside. It takes no authority away: handleAPIInstanceEnable // has always gated on apiAccountApp, which could not see a non-person at // all, so no app token has ever reached it. The registration is where // that is now said, because a registration reading "instance may pass" // while the handler refuses is a registration that lies. As at the mint // door, the ANSWER a credential gets here moves from 401 to 403. s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable) s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList) // The armed-regions answer for consumer pickers (issue #346) — the // same gate and role as the list beside it; see handleAPIRegions. s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions) // The per-caller region guess (nearest-region spec §9.4): POST, so the // hints never ride a URL a redirect could echo. s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess) // Moving a route between channels (channelmove.go). The zero Set, for // the manifest door's reason: this decides what a route SERVES, and a // credential the app itself holds must not be able to walk its own route // onto a channel whose rules it never met. Widening it later is easy; // narrowing an authority already granted is not. s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel) // Update settings (instance update settings spec §5.1). Person-only until // #155 step 6 adds an instance-bound credential: svcrole.Instance is // app-wide, so it would let one instance's code set a sibling's pin. s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesGet) s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesPut) // "Update now" asks root for the newest build the settings allow; the // same people-only gate, and fast_track only for an account owner. s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.Set{}, s.handleAPIUpdatesInstall) // The app's instance update policy (spec §3.2) lives on the instances // manifest, so it is closed for the manifest door's reason: a credential // the app holds must not rewrite the bounds its instances run inside. s.cliRoute("PUT /api/cli/apps/{account}/{app}/updates", svcrole.Set{}, s.handleAPIUpdatesPolicy) // The logs read (`carlos logs`, apilogs.go) — same gate, same 404 // discipline, and an app token may read its own app's logs. s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs) // The edge reference code read (apiedgeref.go): what the owner asks when // a visitor reads out the code from a block page. It reads the app's own // edge records, so it takes the logs door's gate and roles. s.cliRoute("GET /api/cli/apps/{account}/{app}/edge-refs/{code}", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIEdgeRef) // The errors read (`carlos errors`, apierrors.go) — the logs door's twin, // over the same window from the other end: the same gate, the same roles, // and an app token may read its own app's errors for the same reason it // may read its own logs. s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors) s.cliRoute("GET /api/cli/apps/{account}/{app}/availability", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAvailability) // The analytics read (`carlos analytics`, apianalytics.go) — the third of // the same family, on the same footing: an app token that may read every // line of its own logs is not learning anything new from a count of its // own page views. The SETTING that decides whether those counts exist is // the telemetry PUT below, and that stays Operate-only. s.cliRoute("GET /api/cli/apps/{account}/{app}/analytics", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAnalytics) // The platform's GeoIP answer for an arbitrary address (geoip spec // §11.1): the same holders as the analytics read. s.cliRoute("GET /api/cli/apps/{account}/{app}/geo", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIGeo) // The telemetry SETTINGS write, Operate only and deliberately narrower // than the read beside it: this decides what counts as an error for the // app's owner, and a credential the app itself holds must not be able to // widen or narrow what its owner is shown. s.cliRoute("PUT /api/cli/apps/{account}/{app}/telemetry", svcrole.Allow(svcrole.Operate), s.handleAPITelemetryPut) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceCreate) s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGet) // PATCH is the multi-upstream repoint (issue #152): the two fields of a // record — its addr set and its probe — that change while the instance // keeps serving. Same {host} wildcard, same gate, same byte-identical // 404 as its GET and DELETE siblings. s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate) // The edge asset cache's purge and status (design §6, apiassets.go). // The purge draws a fresh asset generation for every instance of the // app: the deploy loop's and a deploy pipeline's act, beside the ship // that declared the folders. The status read is open to every app role: // it shows the app's own records and what their homes published. s.cliRoute("POST /api/cli/apps/{account}/{app}/assets/purge", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIAssetsPurge) s.cliRoute("GET /api/cli/apps/{account}/{app}/assets", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIAssetsStatus) // The deploy watch's gate pass (discussion #164). Instance, because an // Instance credential can already clear the gate through the PATCH // above, so a pass gives it nothing new. Deploy, because CI runs // `carlos deploy` with it, but per host: the handler refuses a host on a // channel the credential could not promote to. Publish is deliberately // NOT here: a Publish credential cannot always change what a host serves // (a passkey or approval rung turns its promote into a proposal), and it // cannot list instances, so `carlos deploy` never reaches this with one. s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/gate-pass", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceGatePass) // The residence-move doors (issue #331): requested by the tenant's // ROUTER with the app-held instance token (titogo's residence design — // never by an instance about itself... which the role system cannot yet // express; the Instance role is the same authority the create and // delete doors already grant this credential). s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveStatus) // The rate-limit read (#358): the settings the PATCH above writes, with // the plan ceiling, what the edge applies, and the last day's refusals. s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/rate-limit", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceRateLimitGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort) // With CARLOS_MOVES_PHASE1 on, the three mutations above are // operator-only (moveOperatorGate) and a resolve door joins them. Off, // that route is never registered, so every request to it answers // exactly as it did before Phase 1 (index D13). if s.cfg.MovesEnabled { s.registerMovesPhase1Routes() } // The offline operator move's doors: always registered, operator-only, // and refused with MV-O20 on a console with host claims or Phase 1 on. s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStatus) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStart) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/stage", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStage) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbSeal)) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFlip)) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbOpen)) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFinish)) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineAbort) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete) // The import door (seeded-instances spec §5): the instance role, like // the create door that begins it. s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/import", svcrole.Allow(svcrole.Instance), s.handleAPIImportUpload) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped", svcrole.Allow(svcrole.Instance), s.handleAPIImportStopped) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit", svcrole.Allow(svcrole.Instance), s.handleAPIImportCommit) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort", svcrole.Allow(svcrole.Instance), s.handleAPIImportAbort) // The run doors (service runs spec §1): a person on the member rule, or a // run service, and no other role. Exactly, not Allow, so Admin does not // join: a credential that may provision instances cannot run commands on // them unless it was minted for that. s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunCreate) s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunKeyLookup) s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", svcrole.Exactly(svcrole.Run), s.handleAPIRunGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", svcrole.Exactly(svcrole.Run), s.handleAPIRunCancel) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", svcrole.Exactly(svcrole.Run), s.handleAPIRunExpire) s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", svcrole.Exactly(svcrole.Run), s.handleAPIRunAck) // `carlos instances retry ` (apiretry.go): the operate family's, as // restart is. The {host} wildcard and the 404 are the instance family's. s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/retry", svcrole.Allow(svcrole.Operate), s.handleAPIInstanceRetry) // The `carlos db` replica door (apidb.go; spec 2026-09-02 §6): an // instance's replicated LTX files, by coordinate, for a member to // restore on their own machine. The zero Set, deliberately: the payload // is the whole customer database, and no legitimate credential needs it // (an app reads its own database live, and CI has no business pulling // production data). admin is refused with the rest. s.cliRoute("GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}", svcrole.Set{}, s.handleAPIDBList) s.cliRoute("GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}/{span}", svcrole.Set{}, s.handleAPIDBGet) // The forwarded read (db region forward spec §9.1): people only, the db // door's whole gate once, then the read inside the same request. s.cliRoute("POST /api/cli/apps/{account}/{app}/db/{host}/reads", svcrole.Set{}, s.handleAPIDBRead) // The zero Set, deliberately: a credential may never mint a credential, // including itself. This is the escalation cut (spec §3). It takes no // authority away — handleAPIInstanceToken gates on apiAccountApp, which // has never been able to see a non-person at all — it moves the refusal // out to the wrapper, where it is structural, and changes the ANSWER a // credential gets here from 401 to 403. Pinned by // TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor. s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken) // Door keys (#175 P7): person-only, the instance-token rule. A door key // reaches every instance of the app through every box's edge-local door. s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList) s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint) s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke) // The sidecar API (apisidecars.go) — the only door onto sidecars/ from // outside a box, sharing the instance family's gate exactly // (instanceGate) and, since the role gate landed, its Allow sets too: // an app process provisions a sidecar per enrolled member with the // CARLOS_INSTANCE_TOKEN it already holds, which is the Instance role. // // `sidecars-manifest` is the member-facing half of `carlos sidecars // enable`: a literal sibling of sidecars/, never under it, so a sidecar // named "sidecars-manifest" could still never reach it. It takes the // ZERO Set for `instances-manifest`'s reason, and the reason is sharper // here: this manifest carries `args` — the argv the platform execs // after the release binary — plus the placement the workload is held // to. A credential the app holds must not be able to rewrite what its // own boxes will exec on its behalf. handleAPISidecarEnable has always // gated on apiAccountApp, which cannot see a non-person at all, so this // takes no authority away; it moves the refusal out to the registration // and the answer from 401 to 403. // // `wake` is a literal segment UNDER {name}; the mux prefers a literal // regardless of registration order, and provision.ValidSidecarName is // one DNS label, so no sidecar name can ever be "wake"'s path either. s.cliRoute("PUT /api/cli/apps/{account}/{app}/sidecars-manifest", svcrole.Set{}, s.handleAPISidecarEnable) s.cliRoute("GET /api/cli/apps/{account}/{app}/sidecars", svcrole.Allow(svcrole.Instance), s.handleAPISidecarList) s.cliRoute("POST /api/cli/apps/{account}/{app}/sidecars", svcrole.Allow(svcrole.Instance), s.handleAPISidecarCreate) s.cliRoute("GET /api/cli/apps/{account}/{app}/sidecars/{name}", svcrole.Allow(svcrole.Instance), s.handleAPISidecarGet) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/sidecars/{name}", svcrole.Allow(svcrole.Instance), s.handleAPISidecarDelete) // No PATCH: a sidecar has no upstream set to repoint, which is the one // thing an instance record's update verb exists for. The family is // create-only plus delete. s.cliRoute("POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake", svcrole.Allow(svcrole.Instance), s.handleAPISidecarWake) // The extra-hostname API (apidomains.go, issue #151) — the only door onto // domains/ from outside a box, sharing the instance family's gate exactly // (instanceGate), so titogo's router attaches its customers' hostnames // with the CARLOS_INSTANCE_TOKEN it already holds. // // Domains carries `Instance` for COMPATIBILITY, not because it belongs // there: that router re-attaches every hostname on every reload pass with // its CARLOS_INSTANCE_TOKEN, which is a kind='app' row and so holds role // instance. Dropping it here 403s that router on the next console deploy. // Same for logs, above. Pinned by // TestInstanceRoleMatchesTodaysAppTokenReach; spec §5. s.cliRoute("GET /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainList) // The per-host read (#299): same roles as the list it narrows — it // discloses strictly less (one hostname the caller already owns), so it // cannot need a role the list does not. s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach) // The prefix-map publish (path-based routing): replace the WHOLE map of // leading path segments a claimed hostname splits its traffic by. The // tenant's own platform is the writer and the source of truth — it // regenerates the map from its database and republishes it — which is // why there is no per-prefix verb and no CLI writer at all. // // `Publish` AND `Instance`, matching the READ of the same state on // handleAPIDomainGet above. An earlier revision made this Publish-only on // the reasoning that a flip is deployment-shaped; the objection that // carried is internal coherence — as Publish-only, an Instance credential // could read a hostname's published prefix map and not write it, and this // file's own header names titogo's router, calling the domains family // with its CARLOS_INSTANCE_TOKEN, as the caller this door exists for. // // Widening crosses no tenancy boundary. The door is already owner-gated // on account and app (domainsGate) and guarded by // refuseServiceControlPlane, so an Instance token can only ever publish // its OWN app's map, naming targets the reconcile pass then re-checks // against the same account (aliases.go). It is not in // TestInstanceRoleMatchesTodaysAppTokenReach's legacy map, because // nothing reached it before service principals existed — see // exemptFromLegacyPin, where the deliberate grant is recorded. Design // spec §7. s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainPaths) // App feature flags (apifeatures.go, #347). The read carries Instance // because the app IS the consumer — its router reads its own switches // with the CARLOS_INSTANCE_TOKEN, the same credential and the same // answer (the domains deployment block) it already uses. The write is // Admin only: these are production switches, and the register is // `pipeline` PUT's and `apps place`'s, not promote's. An app's own // credential must never reach the write — a switch an app could flip // on itself is a root env flag by another name. s.cliRoute("GET /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIFeaturesGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Admin), s.handleAPIFeaturesSet) // The config API (apiconfig.go) — `carlos env` and `carlos secrets` // through the same gate as ship/promote, so deploy access no longer // means bucket credentials (issue #43). Secrets arrive already sealed; // the console relays an opaque envelope and holds no key to open it. s.cliRoute("GET /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigGet) s.cliRoute("PUT /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigPut) s.cliRoute("POST /api/cli/apps/{account}/{app}/config/sync", svcrole.Allow(svcrole.Operate), s.handleAPIConfigSync) // The routes read (apiroutes.go, issue #64) — the member-only door onto // this box's registry rows for one app, so a route's environment // binding can be read back without a box shell. // // The ZERO Set, and the reasoning is that file's own: "An app token is // a machine credential minted for one instance to register itself; this // answer describes every route of the app, with upstream addresses, // owning units and environment bindings, which is an operator's view // rather than a workload's." A service credential is the same kind of // caller an app token is, so the door that refuses one refuses all of // them. It joins policyDoors for that reason — person-only by intent, // not merely by bookkeeping. s.cliRoute("GET /api/cli/apps/{account}/{app}/routes", svcrole.Set{}, s.handleAPIRoutes) // The customer fleets API (fleets.go) — account-owned remote-box groups // and where an app's routes/instances place onto them. `new` is a // literal beside the {fleet} wildcard for the same reason apps/new is: // the mux prefers a literal regardless of registration order, so there // is no ambiguity with a fleet that happened to be named "new". // // Every one of them is the zero Set, deliberately: enrolling a box into a // fleet, and rotating or detaching one, hands out a MACHINE's credentials // and changes where a whole account's apps can be placed. That is a human // act, and no service role reaches it — the same escalation cut as the // mint door above. Pinned by TestNoRoleReachesAMintFleetOrPolicyDoor. s.cliRoute("POST /api/cli/fleets/{account}/new", svcrole.Set{}, s.handleAPIFleetNew) s.cliRoute("GET /api/cli/fleets/{account}", svcrole.Set{}, s.handleAPIFleetList) s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes", svcrole.Set{}, s.handleAPIFleetAddBox) s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", svcrole.Set{}, s.handleAPIFleetRotateBox) s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", svcrole.Set{}, s.handleAPIFleetDetachBox) s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", svcrole.Set{}, s.handleAPIFleetTagBox) // The service-credential API (apiservices.go, issue #220) — the // principal that is not an address. Every door here takes the ZERO role // set: a credential may never mint, rotate or revoke a credential, // including itself, which is what keeps a publish credential from // quietly becoming an admin one. Owner-gated inside, because minting is // an authority grant that outlives its minter. // // {name} is a wildcard with rotate and revoke as literal segments // beneath it — the mux prefers a literal regardless of registration // order, the same property the fleets and store families already rely // on, so a credential named "rotate" cannot shadow the verb. s.cliRoute("POST /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceCreate) s.cliRoute("GET /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceList) s.cliRoute("POST /api/cli/services/{account}/{name}/rotate", svcrole.Set{}, s.handleAPIServiceRotate) s.cliRoute("POST /api/cli/services/{account}/{name}/revoke", svcrole.Set{}, s.handleAPIServiceRevoke) // Placement lives under the apps family, not fleets: its subject is an // APP, and a target fleet name is only ever the request body's data. s.cliRoute("POST /api/cli/apps/{account}/{app}/place", svcrole.Allow(svcrole.Admin), s.handleAPIPlace) // The build probe on an EXISTING app (discussion #13). An empty role set: // the probe is what the deploy watch trusts instead of the header, so a // credential that could rewrite it could make its own deploys report // success over a process that never restarted — which is the exact bug // the probe exists to prevent, handed to the wrong caller. s.cliRoute("POST /api/cli/apps/{account}/{app}/build-probe", svcrole.Set{}, s.handleAPIBuildProbe) // The swap policy (zero-downtime spec §4.1, apiswap.go). The ZERO Set: // §11 says only app members change it, and a credential the app holds // must not choose what its own migrations run beside. s.cliRoute("PUT /api/cli/apps/{account}/{app}/swap", svcrole.Set{}, s.handleAPISwapPut) // The object-storage API (apistore.go, issue #55) — declare a store, read // what became of it, and drive the walks that make it real. Beside the // config routes on purpose: a store's credentials are DELIVERED as config, // so these doors are halves of the same story for whoever reads this list. // // `grant` is the deployment's first operator-identity door: same member // 404 as its siblings, then an address on CARLOS_STORE_OPERATORS. Rotate // and finish are MEMBER verbs (spec §8) — they cycle a credential inside an // estate a grant already paid for. All three are literal segments under // .../store, never {wildcards}, so no app or host name can reach them. s.cliRoute("POST /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreCreate) s.cliRoute("GET /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/provision", svcrole.Allow(svcrole.Admin), s.handleAPIStoreProvision) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish) // Account-level storage entitlement doors are person-only. Applying speaks // for an account owner; approvals spend deployment money and deliberately // resolve their target without requiring operator membership in it. s.cliRoute("POST /api/cli/accounts/{account}/store/apply", svcrole.Set{}, s.handleAPIStoreApply) s.cliRoute("POST /api/cli/accounts/{account}/store/approve", svcrole.Set{}, s.handleAPIStoreApprove) s.cliRoute("GET /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanGet) s.cliRoute("POST /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanSet) s.cliRoute("GET /api/cli/billing/problems", svcrole.Set{}, s.handleAPIBillingProblems) s.cliRoute("POST /api/cli/billing/problems/{id}/close", svcrole.Set{}, s.handleAPIBillingProblemClose) s.cliRoute("POST /api/cli/accounts/{account}/store/decline", svcrole.Set{}, s.handleAPIStoreDecline) s.cliRoute("GET /api/cli/store/approvals", svcrole.Set{}, s.handleAPIStoreApprovals) // The scan family (internal/console/apiscan.go). `scan` itself is the // member's request-and-status pair; grant, revoke, sweep and enforce are // operator verbs. Literal segments again, so no app or host name reaches // them. s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanRequest) s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanStatus) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/grant", svcrole.Allow(svcrole.Admin), s.handleAPIScanGrant) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/revoke", svcrole.Allow(svcrole.Admin), s.handleAPIScanRevoke) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/sweep", svcrole.Allow(svcrole.Admin), s.handleAPIScanSweep) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/enforce", svcrole.Allow(svcrole.Admin), s.handleAPIScanEnforce) s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/callback-key/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIScanCallbackRotate) // The ONE scan door an app's own credential reaches: "scan this object // now" (spec §5.1). Service, not Admin, and the difference is the point — // every verb above decides POLICY (who may scan, whether enforcement is // on) and belongs to a person, while this one asks for work inside a // policy somebody already set. It can add nothing an operator has not // already granted: the consent chain in the handler refuses unless the // store was requested, granted and enabled, so the worst a stolen service // token achieves is queueing scans of its own app's objects. s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanEnqueue) // And reading the answer back (spec §5.2), same role and same reasoning: // an app asking about its own object. s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanObject) // The relay family (internal/console/apirelay.go, relay spec §5): the // platform STUN/TURN relay's app-facing doors. enable/status/disable are // the member's own verbs — declaring, inspecting and ending the app's own // relay estate is not an authority over anything but that app, unlike the // object store's grant. adopted is the member's cutover ack for a // TCP-address route this platform cannot restart. Literal segments under // .../relay, same as store's, so no app or host name can reach them. s.cliRoute("POST /api/cli/apps/{account}/{app}/relay", svcrole.Allow(svcrole.Admin), s.handleAPIRelayEnable) s.cliRoute("GET /api/cli/apps/{account}/{app}/relay", svcrole.Allow(svcrole.Admin), s.handleAPIRelayGet) s.cliRoute("POST /api/cli/apps/{account}/{app}/relay/disable", svcrole.Allow(svcrole.Admin), s.handleAPIRelayDisable) s.cliRoute("POST /api/cli/apps/{account}/{app}/relay/adopted", svcrole.Allow(svcrole.Admin), s.handleAPIRelayAdopted) // rotate is the ONE relay door with no {account} segment: it moves a // REGION's issuer pointer and re-issues every app enabled there, an // authority over the deployment's key material (RelayOperators, server.go) // rather than over any one tenant's app. apiRelayOperator is its whole // gate. s.cliRoute("POST /api/cli/relay/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIRelayRotate) // `carlos system` (apisystemupdate.go and apisystem.go, spec §5) — the // deployment-wide doors. No {account} segment, so no service credential // can be scoped to them at all. Two families sit here: the operator's // promote of the PLATFORM's own binary (what is running where, roll the // fleet forward onto a signed public release or a local build, and roll // it back), and the deployment-wide antivirus policy. // // Every one of them is the ZERO Set, and for the sharpest version of the // escalation cut in this file: the subject is the substrate every tenant // runs inside. A credential that reached the update doors could point // every box in the deployment at a binary of its choosing, which is not a // widening of any workload's authority — it is the end of the boundary // the roles are drawn inside. An empty Set shuts them to every // credential, and closedDoors pins that shut. // // The person check itself is in the handlers, because cliRoute's Set can // only narrow SERVICE principals and a person always passes through it. // The update doors want an OWNER of the ops account or an address on // CARLOS_SYSTEM_OPERATORS, with upload the owner's alone // (apiSystemRoller); the antivirus doors want an address on // CARLOS_STORE_OPERATORS (apiSystemOperator). s.cliRoute("GET /api/cli/system/status", svcrole.Set{}, s.handleAPISystemStatus) s.cliRoute("POST /api/cli/system/update", svcrole.Set{}, s.handleAPISystemUpdate) s.cliRoute("GET /api/cli/system/jobs/{id}", svcrole.Set{}, s.handleAPISystemJob) s.cliRoute("PUT /api/cli/system/upload", svcrole.Set{}, s.handleAPISystemUpload) s.cliRoute("POST /api/cli/system/rollback", svcrole.Set{}, s.handleAPISystemRollback) // I-4: releasing an executable activation reservation that was never // published. The same person gate as rollback (apiSystemRoller). s.cliRoute("POST /api/cli/system/activation/cancel", svcrole.Set{}, s.handleAPISystemActivationCancel) s.cliRoute("GET /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutGet) s.cliRoute("POST /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutSet) s.cliRoute("GET /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusGet) s.cliRoute("POST /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusSet) s.cliRoute("POST /api/cli/system/antivirus/rotate-webhook-key", svcrole.Set{}, s.handleAPISystemWebhookRotate) // Platform GeoIP (geoip spec §4.2): apiSystemRoller, like the update // doors, never CARLOS_STORE_OPERATORS. s.cliRoute("PUT /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipPut) s.cliRoute("GET /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipGet) s.cliRoute("DELETE /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipDelete) // The seeds floor (seeded-instances spec §3.3): apiSystemRoller. s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor) // Legacy route assignments (seeded-instances spec §6.4): apiSystemRoller. s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes) // The lifetimes cutover (lifetimes_cutover.go): an operator's own CLI // token, else the move family's 404; closed to every service // credential. A host-claim console answers its 409. s.cliRoute("POST /api/cli/system/lifetimes/cutover", svcrole.Set{}, s.handleAPILifetimesCutover) // Pointer digests (#253, spec §4.6): each account's emission marker and // the channels still without digests. apiSystemRoller, like the update // doors. s.cliRoute("GET /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests) s.cliRoute("POST /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests) } // clientIP is who the rate limiter counts against. // // X-Forwarded-For is only consulted when the PEER is one we put there: the // edge on this box (loopback — the console listens on 127.0.0.1) or a // configured edge address. From anyone else the header is just a string the // client typed, and honouring it would let one caller present a fresh "IP" // per request and walk straight past every per-IP limit below. // // Loopback is not the edge alone, though: any process on this box can // connect to it. A loopback peer's chain is therefore believed only with the // edge's signature beside it (forwardedByEdge, edgeproof.go); without one the // request counts against the peer address like any other. // // When the peer is trusted, the LAST element is the one to take: the edge // appends its own view of the peer with SetXForwarded, so everything before // it may have been supplied by the client. func (s *Server) clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if !s.trustsProxy(host) { return host } // Every line, joined: that is the chain the edge signed, and a second // line is how a client would try to slip its own element in. xff := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") if xff == "" { return host } if isLoopbackPeer(host) && !s.forwardedByEdge(r, xff) { return host } parts := strings.Split(xff, ",") if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" { return ip } return host } // trustsProxy reports whether a peer is allowed to speak for somebody else. func (s *Server) trustsProxy(host string) bool { ip := net.ParseIP(host) if ip == nil { return false } if ip.IsLoopback() { return true // the edge, on this box, which is the only deployment shape } for _, e := range s.cfg.EdgeIPs { if other := net.ParseIP(strings.TrimSpace(e)); other != nil && other.Equal(ip) { return true } } return false } // ---- JSON plumbing ---------------------------------------------------------- func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(body); err != nil { log.Printf("console: write json: %v", err) } } func jsonError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // jsonErrorCode is jsonError with a stable machine-readable code beside the // sentence. The run handlers' own refusals use it (runRefusalCodes); a // shared gate never does, so its body stays the same at every door. func jsonErrorCode(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, map[string]string{"error": msg, "code": code}) } // readJSON decodes a CLI request body, insisting on a JSON content type. // // That insistence IS the CSRF defence for the unauthenticated endpoints: a // cross-site HTML form can only send urlencoded, multipart or plain text, so // a body this accepts cannot have been sent by a page the user merely // visited. Anything that can set application/json cross-origin has to pass a // CORS preflight first, and this console answers none. func readJSON(w http.ResponseWriter, r *http.Request, out any) bool { // A login request — and every other door but the config PUT — is a // handful of short strings; anything larger is not one of ours. return readJSONLimit(w, r, out, 8<<10) } // readJSONLimit is readJSON with the caller's own byte cap — the seam the // config PUT door uses (apiconfig.go), because a request carrying sealed // secret envelopes is legitimately larger than "a handful of short strings": // an envelope is ~4/3 the size of the plaintext it seals, and one long // base64 credential can pass 8KiB on its own. A body over the cap answers // its own 413 rather than hiding inside "malformed JSON body" — the caller's // fix (split the request, shorten the value) is nothing like a JSON fix. func readJSONLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool { return decodeJSONBody(w, r, out, limit, false) } // readJSONStrictLimit is readJSONLimit that also refuses a field the struct // does not declare, with a 400 that NAMES it. // // It is for the doors where an unrecognised key would otherwise be dropped and // the request acted on as if it had not been sent — which, at a door whose job // is to NARROW something, silently widens it. The narrowed restart door // (apirestartscope.go) is the case it was written for: a client that renames // `host` to `hosts` would, without this, POST a body with no selector in it // and be answered by a restart of every route the app owns. A door that exists // because another door widens must not itself widen on a typo. // // The unknown-field check is a prefix match on encoding/json's own message // because the package gives no typed error for it; the message is stable and // already names the field, which is the whole thing the caller needs. func readJSONStrictLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool { return decodeJSONBody(w, r, out, limit, true) } // decodeJSONBody is the shared body of the two above: content type, byte cap, // decode, and one refusal per failure shape. func decodeJSONBody(w http.ResponseWriter, r *http.Request, out any, limit int64, strict bool) bool { ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") if strings.TrimSpace(strings.ToLower(ct)) != "application/json" { jsonError(w, http.StatusUnsupportedMediaType, "send a JSON body with Content-Type: application/json") return false } r.Body = http.MaxBytesReader(w, r.Body, limit) dec := json.NewDecoder(r.Body) if strict { dec.DisallowUnknownFields() } if err := dec.Decode(out); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { jsonError(w, http.StatusRequestEntityTooLarge, "request body too large") return false } if strict && strings.HasPrefix(err.Error(), "json: unknown field ") { jsonError(w, http.StatusBadRequest, err.Error()+ " — this door refuses a body it does not fully understand rather than acting on the part it does") return false } jsonError(w, http.StatusBadRequest, "malformed JSON body") return false } return true } // bearer pulls the token out of the Authorization header. func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") { return "" } return strings.TrimSpace(h[7:]) } // cliNotSignedIn is the ONE 401 the CLI surface gives, written once so the // two authenticators cannot drift. cliUser answers it for a person's door and // cliRoute for an API door, and the bodies must be byte-identical: a // differently-phrased 401 over here would make "this bearer is a service // credential at a person's door" distinguishable from "this bearer does not // exist", which is the whole property the person-only doors hold. // Pinned by TestCLIRouteUnauthorizedMatchesCLIUser. func (s *Server) cliNotSignedIn(w http.ResponseWriter) { jsonError(w, http.StatusUnauthorized, "not signed in — run: carlos auth login --console "+s.cfg.Origin) } // cliUser authenticates a CLI request AS A PERSON. Every failure is the same // 401 with the same body: an expired token, a revoked one, a fabricated one // and a service credential are not distinguishable from outside. func (s *Server) cliUser(w http.ResponseWriter, r *http.Request) (string, bool) { addr, ok := s.store.CLITokenAddress(bearer(r), time.Now()) if !ok { s.cliNotSignedIn(w) return "", false } return addr, true } // ---- the flow --------------------------------------------------------------- // cleanLabel bounds and de-fangs what a terminal calls itself. // // PRINTABLE ASCII ONLY, which is stricter than it looks like it needs to be. // This is the one field on the approval page a human is asked to recognise — // "is that my terminal?" — and it is chosen by whoever is asking for the // credential. Allowing the full printable Unicode range would allow // right-to-left overrides and zero-width joiners, which exist precisely to // make a string render as something other than what it is. A label is // user@host; nobody needs U+202E in one. func cleanLabel(in string) string { var b strings.Builder for _, r := range in { if r < 0x20 || r > 0x7e { continue } b.WriteRune(r) if b.Len() >= 64 { break } } return strings.TrimSpace(b.String()) } // handleCLIStart opens a login attempt. Unauthenticated by necessity — the // terminal has no credential yet — which is why it is rate-limited per IP, // mints nothing but a ten-minute row, and grants nothing until a signed-in // human approves it. func (s *Server) handleCLIStart(w http.ResponseWriter, r *http.Request) { var req struct { Label string `json:"label"` } if !readJSON(w, r, &req) { return } ip := s.clientIP(r) if !s.cli.start.allow(ip, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many login attempts from here — wait a minute and try again") return } userCode, deviceCode, err := s.store.NewCLIAuth(cleanLabel(req.Label), ip, time.Now()) if err != nil { log.Printf("console: cli auth start: %v", err) jsonError(w, http.StatusInternalServerError, "could not start the login — try again") return } writeJSON(w, http.StatusOK, map[string]any{ "user_code": userCode, "device_code": deviceCode, "verification_uri": s.cfg.Origin + "/auth/cli", "verification_uri_complete": s.cfg.Origin + "/auth/cli?code=" + url.QueryEscape(userCode), "interval": cliPollInterval, "expires_in": int(CLIAuthTTL.Seconds()), }) } // handleCLIPoll is the CLI's waiting room. Every answer is 200 with a status // word — the CLI is a program, and a status it can switch on beats an HTTP // code it has to interpret. func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) { var req struct { DeviceCode string `json:"device_code"` } if !readJSON(w, r, &req) { return } // Both keys, and the IP one first. The device code is chosen by the // caller, so a per-code limit alone is no limit at all — every made-up // code is a fresh budget, and a fresh map entry. The per-code limit still // earns its place: it stops one honest-but-broken client spinning, without // two developers behind one NAT throttling each other. now := time.Now() if !s.cli.pollIP.allow(s.clientIP(r), now) || !s.cli.poll.allow(hashToken(req.DeviceCode), now) { jsonError(w, http.StatusTooManyRequests, "polling too fast") return } status, token, addr, err := s.store.PollCLIAuth(req.DeviceCode, time.Now()) if err != nil { log.Printf("console: cli auth poll: %v", err) jsonError(w, http.StatusInternalServerError, "could not check the login — try again") return } out := map[string]any{"status": status, "interval": cliPollInterval} if status == CLIAuthApproved { out["token"] = token out["address"] = addr out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339) out["console"] = s.cfg.Origin go s.notifyCLIIssued(addr, token) } writeJSON(w, http.StatusOK, out) } // notifyCLIIssued tells the human that a terminal now holds their identity — // naming it, where it dialled from, and how to kill it. Someone who signed in // with Keymail hears in their own Keymail inbox, through their grant; someone // who signed in by email (no grant) hears through deliver: Keymail first if // the address turns out to have a mailbox there, else the console's own email. // // This is the control that makes a phished approval DETECTABLE. Every other // defence in this flow is preventive and therefore fallible; a message that // arrives whether or not the approval was a good idea is what turns "somebody // has been me for two months" into "I revoked that within the hour". Same // channels as invites. // // Off the request goroutine on purpose: a slow Keymail must never stall the // poll the CLI is waiting on. Issuance has already happened and the request row // is burnt, so a CLI that gave up would otherwise leave a token nobody // collected. Best-effort throughout: a notice that cannot be delivered is // logged, never a reason to fail a login. func (s *Server) notifyCLIIssued(address, rawToken string) { defer func() { if s.cliNotified != nil { s.cliNotified <- struct{}{} } }() grant, err := s.store.GrantToken(address) if err != nil { return } label, from := "", "" if toks, err := s.store.CLITokens(address); err == nil { for _, t := range toks { if t.ID == hashToken(rawToken) { label, from = t.Label, t.RemoteAddr } } } if label == "" { label = "(unnamed terminal)" } if from == "" { from = "an address the console could not determine" } subject := "A command line signed in as you on the CARLOS platform" body := "A terminal was just approved to act as " + address + " on the CARLOS platform.\n\n" + " terminal: " + label + "\n" + " from: " + from + "\n" + " when: " + time.Now().UTC().Format("2006-01-02 15:04 UTC") + "\n\n" + "It can do anything you can do from the command line, for the next " + fmt.Sprint(int(CLITokenTTL/(24*time.Hour))) + " days.\n\n" + "If that was you running `carlos auth login`, nothing to do.\n\n" + "If it was NOT you — if you clicked a link somebody sent you — revoke it now:\n\n" + s.cfg.Origin + "/settings\n" if grant == "" { // Signed in by email: no grant to send with. Keymail first if the // address turns out to have a mailbox, then the console's own email. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if _, err := s.deliver(ctx, address, subject, body, 12*time.Second); err != nil { log.Printf("console: could not notify %s of a CLI token issue: %v", address, err) } return } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if _, err := s.km.SendApp(ctx, grant, address, subject, body); err != nil { log.Printf("console: could not notify %s of a CLI token issue: %v", address, err) } } // cliPage is the approval and tokens pages' data. It embeds page so the // shared chrome ("top"/"bottom") keeps working unchanged. type cliPage struct { page Code string // the code the human typed, echoed back into the form // LinkCode is the code carried by the ?code= in the URL they followed. // Shown ONLY as something to check against the terminal's screen — never // prefilled into the form, never resolved. See handleCLIApprovePage. LinkCode string Console string // this deployment's origin — the -console the CLI needs Request *CLIAuthRequest Tokens []CLIToken Days int // ServiceAccounts maps a service credential's account id to that // account's sqid, so the read-only mirror can say WHERE a row is managed // and link there. Built here rather than hung on CLIToken because the // sqid is page chrome, not part of the stored row — and a row whose // account this console cannot resolve is simply absent from the map, // which the template renders as "managed on its own account" rather than // as a broken link. ServiceAccounts map[int64]string } func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cliPage) { s.chrome(&p.page) _ = s.writeTemplate(w, status, name, p) } // handleCLIApprovePage is where the printed link lands. A signed-out visitor // gets the front page, asked to sign in, with next set back here (spec 8). A // code-shaped ?code= rides along in next only so it can be shown again for // comparison; the console never carries a pending approval through an // authentication it has not finished. // // IT DELIBERATELY DOES NOT ACT ON ?code=. Not to resolve it, not to look it // up, and above all not to render an Approve button for it. A prefilled code // that arrives one click from approval is the device-code phishing attack that // has been run at scale against Microsoft tenants: the attacker starts the // flow, sends the victim the ready-made link with a plausible pretext, and one // click binds a 90-day credential — held by the attacker — to the victim's // identity. Requiring the code to be TYPED is what breaks it: to type it you // have to be reading it off a terminal you started, and a code you cannot see // is one you cannot transcribe. The value from the URL is shown only as // something to check against your screen, never as something to submit. func (s *Server) handleCLIApprovePage(w http.ResponseWriter, r *http.Request) { addr, raw, ok := s.session(r) if !ok { next := "/auth/cli" if c := NormalizeUserCode(r.URL.Query().Get("code")); len(c) == userCodeLen+1 { next += "?code=" + url.QueryEscape(c) } s.renderHome(w, r, homeForm{Next: next, Banner: copyText("home.cli_prompt")}) return } p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw}, Days: int(CLITokenTTL / (24 * time.Hour))} // Echoed for comparison only — no store lookup, which also means this page // is not an oracle for enumerating live codes. p.LinkCode = NormalizeUserCode(r.URL.Query().Get("code")) s.renderCLI(w, http.StatusOK, "cli_approve", p) } // handleCLIApprove is both steps a human takes, distinguished by action: // // continue — a typed code is looked up and the terminal asking is DESCRIBED // (label, where it dialled from, when it expires) // approve — the decision, on a code the human has now both typed and seen // described // deny — "that wasn't me", which is final // // All three are session-gated and CSRF-checked with the double-submitted // session token, exactly like every other mutation here: a cross-site POST // that could approve a terminal would be the whole attack. func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) { addr, raw, ok := s.session(r) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } code := NormalizeUserCode(r.FormValue("code")) action := r.FormValue("action") now := time.Now() p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw}, Code: code, Days: int(CLITokenTTL / (24 * time.Hour))} // Wrong codes are counted wherever a code is resolved, and against both // the signed-in address and the client IP. The exposure this bounds is not // the obvious one: guessing somebody else's PENDING code and approving it // binds THEIR terminal to YOUR address, which is the inverted-payoff half // of this flow and the reason the counter has to exist even though ~39 // bits in ten minutes is not findable. badCode := func() bool { ip := s.clientIP(r) okAddr := s.cli.guessAddr.allow(addr, now) okIP := s.cli.guessIP.allow(ip, now) return okAddr && okIP } tooMany := func() { s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts", "Too many bad login codes. Wait a minute, then try again.") } switch action { case "approve", "deny": // Passkey step-up on APPROVE only (spec §7a, stepup.go). Approving // mints a 30-day credential that acts as this identity from a machine // that has never been seen — the single largest thing a stolen session // cookie could do here, and the reason presence has to be proved rather // than inferred. Denying is left ungated on purpose: "that wasn't me" // is the panic button, it only ever destroys a pending request, and // putting an authenticator prompt in front of it would be friction on // exactly the path we want people to take without hesitating. if action == "approve" && !s.requireFreshAuth(w, r, addr, raw) { return } var err error if action == "deny" { err = s.store.DenyCLIAuth(code, now) } else { err = s.store.ApproveCLIAuth(code, addr, now) } if err != nil { if !badCode() { tooMany() return } p.Error = "That code has expired or was already used. Run carlos auth login again for a fresh one." s.renderCLI(w, http.StatusOK, "cli_approve", p) return } p.Title = "Approved" if action == "deny" { p.Title = "Denied" } s.renderCLI(w, http.StatusOK, "cli_decided", p) default: // "continue": describe the terminal, then let the human decide. req, err := s.store.CLIAuthByUserCode(code, now) if err != nil { if !badCode() { tooMany() return } p.Error = "No login is waiting on that code — check what your terminal printed. " + "Codes last ten minutes." s.renderCLI(w, http.StatusOK, "cli_approve", p) return } p.Request = req s.renderCLI(w, http.StatusOK, "cli_approve", p) } } // serviceAccountSqids resolves the accounts the viewer's SERVICE credentials // are bound to, for the read-only mirror's "lives on account — manage // there" link. // // Service rows only: an app token keeps its own Revoke button on this page // (it always has had one, and its minter is not necessarily an account // owner), so it needs no pointer elsewhere. A lookup that fails is left out // of the map rather than filled with a guess — a link to /a//settings is // worse than plain words. func (s *Server) serviceAccountSqids(toks []CLIToken) map[int64]string { out := map[int64]string{} for _, t := range toks { if !t.IsService() || t.AccountID == 0 { continue } if _, done := out[t.AccountID]; done { continue } acct, err := s.store.AccountByID(t.AccountID) if err != nil || acct == nil { log.Printf("console: settings mirror: account %d: %v", t.AccountID, err) continue } out[t.AccountID] = acct.Sqid } return out } // handleCLITokens lists the signed-in user's own CLI credentials. Per USER, // not per account: a token is an identity, and it carries whatever accounts // that identity belongs to — so the person who can kill it is the person it // speaks as. // // SERVICE credentials the viewer minted stay listed here, READ-ONLY. They are // managed on their account's own pane (svcpage.go), where the owner gate and // the audit trail live — but absorbing them into that listing must not make // them vanish from a page people already know, so the row stays and points // there instead of carrying a button. func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) { addr, raw, ok := s.session(r) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } toks, err := s.store.CLITokens(addr) if err != nil { log.Printf("console: cli tokens: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your terminals. Please try again.") return } p := cliPage{page: page{Title: "Your settings", Address: addr, CSRF: raw}, Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour)), ServiceAccounts: s.serviceAccountSqids(toks)} p.Flash, p.FlashErr = s.takeFlash(w, r) s.renderCLI(w, http.StatusOK, "settings", p) } // serviceRowOf finds a live SERVICE credential of this address's by listing // id, for handleCLITokenRevoke's refusal below. It answers three ways — // found, not-a-service, and COULD NOT TELL — because the third is not the // second. // // ok=false with err!=nil means the read failed, and the caller FAILS CLOSED // on it. That is the asymmetry of the two mistakes: refusing a person's own // terminal revoke for as long as a database hiccup lasts is annoying and // retryable, while letting an unreadable row through routes a service // credential into RevokeCLIToken's hard DELETE, which is neither. func (s *Server) serviceRowOf(addr, id string) (tok CLIToken, isService bool, err error) { if id == "" { return CLIToken{}, false, nil } toks, err := s.store.CLITokens(addr) if err != nil { return CLIToken{}, false, err } for _, t := range toks { if t.ID == id { return t, t.IsService(), nil } } return CLIToken{}, false, nil } func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) { addr, raw, ok := s.session(r) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } // C49: a SERVICE credential is not revocable here, and withholding the // button from the template was never the control — this door accepts any // listing id whose row carries the viewer's address, and a service // credential's `address` column IS its minter. // // Three things make that the wrong door rather than merely a second one. // RevokeCLIToken is a hard DELETE where the account pane writes the // revoked_at TOMBSTONE the store's design depends on (CLIPrincipal goes // on refusing the exact secret; a later mint of the same name gets a NEW // row rather than resurrecting this one's identity). It writes no ACCOUNT // audit row, so the feed an owner reads would say nothing — which would // hollow out the very assurance the pane's missing revoke-confirmation // leans on (see handleServiceRevokePage, C48). And it is scoped to the // MINTER, so it outlives ownership: mint a credential, be transferred out // of the account, and this door would still let you stop a production // service you no longer have any authority over. // // Refused BEFORE requireFreshAuth: no gesture could make this the right // door, so asking for one would waste it. // // SCOPED TO SERVICE ROWS ONLY. A legacy kind='app' row keeps working here // (TestPersonalSettingsStillRevokesALegacyAppToken pins it) — its minter // is not necessarily an account owner, so this may be the only door they // have. The same hard-DELETE-and-no-audit inconsistency is true of it and // predates this branch; widening the refusal would take away a working // control to fix nothing here. // // But only while the minter is still a MEMBER of the token's account // (discussion #219, item 3). The app token speaks for that account's app, // so revoking it stops that app's service; somebody removed from the // account has no authority over it any more, and having minted it once // does not keep any. svc, isService, err := s.serviceRowOf(addr, r.FormValue("id")) switch { case err != nil: log.Printf("console: cli token revoke: classify %s: %v", addr, err) s.setFlash(w, "err", "Could not check what that credential is, so nothing was revoked. Try again.") http.Redirect(w, r, "/settings", http.StatusSeeOther) return case isService: where := "its account's settings page" if acct, err := s.store.AccountByID(svc.AccountID); err == nil && acct != nil { where = "/a/" + acct.Sqid + "/settings" } s.setFlash(w, "err", "“"+svc.Name+"” is a service credential, not a terminal of yours — it "+ "belongs to its account, and is revoked from "+where+", where the revocation is "+ "recorded in that account's activity. Nothing was revoked.") http.Redirect(w, r, "/settings", http.StatusSeeOther) return case svc.IsApp(): // Fails closed on a read error, the same direction as the classify // above, and is refused before the passkey for the same reason. role, rerr := s.store.Role(svc.AccountID, addr) if rerr != nil { log.Printf("console: cli token revoke: membership of %s in %d: %v", addr, svc.AccountID, rerr) s.setFlash(w, "err", "Could not check your membership, so nothing was revoked. Try again.") http.Redirect(w, r, "/settings", http.StatusSeeOther) return } if role == "" { s.setFlash(w, "err", "That credential belongs to an account you are no longer a member of, "+ "so it is not yours to revoke. Nothing was revoked.") http.Redirect(w, r, "/settings", http.StatusSeeOther) return } } // Step-up (spec §7a): killing a terminal is a security action, and the // person doing it should be the person whose terminals they are. An // attacker with a stolen cookie revoking the real owner's credentials is a // lockout, and the freshness check is what stops it being free. if !s.requireFreshAuth(w, r, addr, raw) { return } // Scoped to the signed-in address inside the store call: an id belonging // to somebody else simply is not found. // // A legacy app token's DELETE is admitted for its app (the claim gate), so // it runs under the request context; a refused or failed admission is not // "already gone", and the row is still live. if err := s.store.RevokeCLITokenContext(r.Context(), addr, r.FormValue("id")); err != nil { if s.cfg.HostClaim && !errors.Is(err, errNoSuchCLIToken) { log.Printf("console: cli token revoke %s: %v", addr, err) s.setFlash(w, "err", "Could not revoke that credential, so it may still be live. Try again.") } else { s.setFlash(w, "err", "That terminal is already gone.") } } else { s.setFlash(w, "ok", "Revoked — that terminal can no longer act as you.") } http.Redirect(w, r, "/settings", http.StatusSeeOther) } // handleCLIWhoami answers `carlos auth whoami`: who this token speaks as, and // until when. It is also the liveness check every verb that acts on the // token's identity makes before acting. func (s *Server) handleCLIWhoami(w http.ResponseWriter, r *http.Request) { addr, ok := s.cliUser(w, r) if !ok { return } out := map[string]any{"address": addr, "console": s.cfg.Origin} if toks, err := s.store.CLITokens(addr); err == nil { hash := hashToken(bearer(r)) for _, t := range toks { if t.ID == hash { out["label"] = t.Label out["expires_at"] = t.ExpiresAt.Format(time.RFC3339) } } } writeJSON(w, http.StatusOK, out) } // handleCLIRevoke is `carlos auth logout`: retire this credential rather than // merely forgetting it locally. func (s *Server) handleCLIRevoke(w http.ResponseWriter, r *http.Request) { if _, ok := s.cliUser(w, r); !ok { return } if err := s.store.RevokeCLITokenValueContext(r.Context(), bearer(r)); err != nil { log.Printf("console: cli revoke: %v", err) jsonError(w, http.StatusInternalServerError, "could not revoke the token") return } writeJSON(w, http.StatusOK, map[string]any{"revoked": true}) } // handleCLINewAccount is the point of the whole feature: `carlos accounts // create` from a logged-in CLI mints the account THROUGH the console, so the // human who ran it is its owner from the first instant. // // The alternative — the CLI minting straight into the bucket and hoping // something later notices who owns it — is exactly what produced accounts // nobody could open in the console. The console already has mintAccount: the // bucket manifest and the owner membership in one path. func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) { addr, ok := s.cliUser(w, r) if !ok { return } if !s.cli.write.allow(addr, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { Name string `json:"name"` } if !readJSON(w, r, &req) { return } name := strings.TrimSpace(req.Name) if !domains.ValidAccountName(name) { jsonError(w, http.StatusBadRequest, "an account name appears in URLs and --account: 1-63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen") return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() // Plans 3a: one free account per person (spec §8.1). defer s.planPersonLock(addr)() if err := s.checkNewFreeAccount(addr); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: cli mint account for %s: plan cap: %v", addr, err) jsonError(w, http.StatusBadGateway, "could not create the account — try again") return } a, err := s.mintAccount(ctx, name, addr) if err != nil { // Logged, not returned: every other failure in this console answers a // caller with a fixed sentence and keeps the detail on the box. A // mint failure is a bucket error, and bucket errors carry key names, // endpoints and occasionally credentials-adjacent text. log.Printf("console: cli mint account for %s: %v", addr, err) jsonError(w, http.StatusBadGateway, "could not create the account — try again") return } writeJSON(w, http.StatusOK, map[string]any{ "id": a.ID, "sqid": a.Sqid, "name": a.Name, "owner": addr, "url": s.cfg.Origin + "/a/" + url.PathEscape(a.Sqid) + "/", }) }