carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// CLI identity, HTTP half: the endpoints `carlos auth login` speaks to, and
2// the two pages a human uses — approve a terminal, and see (or kill) every
3// terminal that speaks as them.
4//
5// WHY THE CONSOLE AND NOT A SECOND KEYMAIL CLIENT. The console is already a
6// registered Keymail OAuth client and already the deployment's identity
7// authority: it knows who you are, which accounts you belong to, and what
8// role you hold in each. Minting a SECOND OAuth client for the CLI would mean
9// a second consent screen, a second grant to revoke, a second client_id to
10// register per self-hosted deployment — and a CLI that trusts keymail.dev
11// directly rather than trusting the console the operator actually runs. So
12// the CLI authenticates against the deployment's own console, and the console
13// authenticates the human with the Keymail sign-in it already has. One OAuth
14// client, one place to revoke, and a self-hoster's CLI trusts their own box.
15//
16// WHY A TYPED CODE AND NOT A LOCALHOST CALLBACK. The terminal that needs a
17// credential is very often an SSH session on a box, a container, or a CI
18// shell. A browser-redirect flow assumes the browser can reach a listener on
19// the machine running the CLI, which in all three cases it cannot. The typed
20// code crosses that gap by hand: it is short enough to retype from a phone,
21// and the credential is delivered back over the CLI's own polling channel.
22package console
23
24import (
25 "context"
26 "crypto/subtle"
27 "encoding/json"
28 "errors"
29 "fmt"
30 "log"
31 "net"
32 "net/http"
33 "net/url"
34 "strings"
35 "sync"
36 "time"
37
38 "github.com/carlosframework/platform/internal/domains"
39 "github.com/carlosframework/platform/internal/svcrole"
40)
41
42// Rate limits. Deliberately generous for humans and tight for scripts: the
43// numbers only have to make brute force and database-stuffing pointless, not
44// police normal use.
45const (
46 cliRateWindow = time.Minute
47 // cliStartBurst bounds unauthenticated login starts per client IP. Each
48 // start writes a row, so this is the anti-stuffing limit.
49 cliStartBurst = 10
50 // cliPollBurst is per device code — a polite CLI polls every 2s, so 60 a
51 // minute is twice what the honest client needs and nowhere near a spin.
52 // It is NOT a defence on its own: the device code is chosen by the
53 // caller, so a flood simply presents a fresh key every time. cliPollIPBurst
54 // is the limit that actually bounds a flood.
55 cliPollBurst = 60
56 // cliPollIPBurst bounds polling per client IP, whatever device codes it
57 // claims to hold. Roomy enough for a shared NAT with a dozen developers
58 // mid-login at once.
59 cliPollIPBurst = 240
60 // cliGuessBurst bounds WRONG typed codes, counted against the signed-in
61 // address and against the client IP. The user code is ~39 bits with a
62 // ten-minute life; at 10 guesses a minute it is not findable in that
63 // window by any margin that matters.
64 cliGuessBurst = 10
65 // cliWriteBurst bounds authenticated mutations per address.
66 cliWriteBurst = 30
67 // cliPollInterval is the wait the console asks the CLI to keep, seconds.
68 cliPollInterval = 2
69
70 // cliShipWindow is the minute window every other CLI limiter uses. It
71 // was an HOUR until 2026-08-23, on the premise that "a ship is a
72 // deliberate release cut, not a poll" — a premise a fixed-window
73 // counter turns into the worst refusal on this surface. Spend the hour
74 // budget in five minutes and the token is dead for the remaining
75 // fifty-five, with nothing in the answer to say so; the caller that
76 // actually does this is an agent iterating ship-test-ship, and what it
77 // learns is that shipping stopped working. A minute window bounds the
78 // same burst (see cliShipBurst) and bounds the WAIT too: the worst a
79 // refused caller can be asked for is one minute, which is a sleep, not
80 // an outage.
81 cliShipWindow = time.Minute
82 // cliShipBurst bounds ship uploads per TOKEN (see handleAPIShip) within
83 // that minute. Two, deliberately: a build-and-ship takes longer than
84 // half a minute to produce anything worth shipping, so two a minute is
85 // past what an honest iterating caller can generate — while a stolen
86 // token bursting flat out is held to a TIGHTER instantaneous rate than
87 // the old ten-in-an-hour allowed (which permitted ten back to back).
88 // The hourly ceiling rises 10 -> 120; the release retention sweep, not
89 // this counter, is what bounds what those 120 cost in storage.
90 cliShipBurst = 2
91
92 // cliInstanceBurst bounds the instance verbs (apiinstances.go) per
93 // TOKEN within the ordinary minute window. Keyed by token hash, not by
94 // address, because the caller here is usually not a person at all: an
95 // app token holds no identity to charge a person's budget against, and
96 // a burst of signups — several instances created in one minute — is the
97 // legitimate workload this API exists for. Sharing the address-keyed
98 // write budget would couple the two in both directions: a stolen user
99 // token's noise would starve the app's own signups, and a busy signup
100 // hour would refuse its minter's promotes.
101 cliInstanceBurst = 60
102 // cliRunsBurst is the follow routes' per-token budget per window: a
103 // once-a-second poll, its acks and cancels, with room.
104 cliRunsBurst = 600
105
106 // cliCreateWindow is app creation's own budget window — a DAY, the
107 // longest here, because claiming an app name is the rarest deliberate
108 // act on this surface and the one whose spam is permanent: a claimed
109 // name is a row an operator has to clean up, not a pointer the next
110 // promote overwrites. The number is a ceiling on a stolen token (ten
111 // inert names a day, per address), not a policing of humans — the
112 // browser's New app form has no limit and is the escape hatch
113 // (2026-08-04-cli-app-create-design.md §3).
114 cliCreateWindow = 24 * time.Hour
115 // cliCreateBurst bounds app-claim ATTEMPTS per address within that day
116 // — attempts, not successes, or refused probes would be free. One caveat
117 // the limiter's maxRateKeys note doesn't cover: create's keys are real
118 // member addresses accumulating for a whole day, not attacker-minted
119 // strings, so a deployment with >8192 members claiming apps in one day
120 // would trip the wholesale map drop and reset every budget. Orders of
121 // magnitude beyond this fleet; recorded, not defended.
122 cliCreateBurst = 10
123
124 // cliTemporaryCreateBurst bounds temporary-app claim ATTEMPTS per
125 // principal per day (spec ruling 8, Paul 2026-09-26). Its own budget,
126 // not cliCreateBurst's: the scratchpad use is an agent in a loop, and
127 // ten a day would refuse it by lunchtime. The live cap (20) bounds
128 // storage while apps live; this bounds churn, since a trashed
129 // temporary app still holds storage for its 3-day window, so the
130 // worst case is about 150 trashed-but-unpurged apps per principal.
131 cliTemporaryCreateBurst = 50
132
133 // Service-principal budgets (Task 5, issue #220 consequence 1). A hub is
134 // not a person, and each number below is its own judgement calibrated
135 // for what a legitimate AUTOMATED caller does — not a derivation from
136 // the person numbers above, and not one shared story: read each
137 // comment on its own rather than assuming the others generalize it.
138 //
139 // All three are keyed on the principal NAME (principalKey), not the
140 // token hash: rotating a credential must not hand it a fresh budget, or
141 // a rotation mid-flood would be the limit doing nothing.
142
143 // svcCreateBurst: 100 repos enabling Pages (or otherwise claiming an
144 // app) in one day — ten times cliCreateBurst's per-person ceiling,
145 // because one hub credential legitimately claims on behalf of many
146 // repos where one person claims only for themselves.
147 svcCreateBurst = 100 // per day, the cliCreateWindow
148 // svcWriteBurst: a mutation roughly every half second sustained for a
149 // full minute — four times cliWriteBurst's per-person ceiling, roomy
150 // for a CI fleet's shared credential running many promotes, rollbacks
151 // and restarts back to back.
152 svcWriteBurst = 120 // per minute, the cliRateWindow
153 // svcShipBurst: ten ships a minute sustained — five times
154 // cliShipBurst's per-token ceiling, for a busy CI fleet cutting many
155 // releases at once rather than one human's occasional deploy. It kept
156 // its multiple over the person budget when cliShipWindow went from an
157 // hour to a minute; the number moved (60 -> 10) only because the window
158 // under it did.
159 svcShipBurst = 10 // per minute, the cliShipWindow
160
161 // wildFailBurst bounds FAILED wildcard enables per principal per
162 // minute — PEEKED before the DNS round trip and CONSUMED only after it
163 // fails (rateLimiter.tripped / .allow, C33 review), so a caller whose
164 // CNAME is correct never accumulates a failure and never meets it,
165 // while a retrying caller stops costing the console a live outbound
166 // DNS lookup once it has tripped. The successful path is exempt
167 // entirely (spec §4a): with wildcard DNS a Pages hub makes no per-repo
168 // hostname call at all, and an enable is genuinely once-per-domain.
169 // Roomy enough for a resolver blip, closed enough that an exempt door
170 // is not a way to make the console do unbounded outbound DNS.
171 wildFailBurst = 20
172
173 // domainsCeilingBurst is NOT a workload budget — attach/detach are
174 // exempt from the write/instance budgets by design (spec §4a: a
175 // wildcard hub makes near-zero per-repo calls, so there is no
176 // legitimate workload to bound). It is a CONVERGENCE-RATE governor
177 // instead: every EFFECTIVE attach or detach audits and bumps
178 // control/serial, and boxes poll that serial at 2s and run a full pass
179 // on every move, so an unlimited exempt door would let one
180 // authenticated credential drive fleet-wide convergence at request
181 // rate — an alternating attach/detach loop on one host, for instance.
182 // 180/min is deliberately clear of svcWriteBurst (120) rather than
183 // merely above it: the two used to sit at the same number, which meant
184 // no test volume could land strictly between them, so a regression that
185 // put the write budget back onto this door (re-adding allowWrite to
186 // domainsGate) would refuse at the SAME threshold this ceiling does —
187 // nothing would fail. A real gap between the two numbers is what makes
188 // TestWildcardEnableIsNotRateLimited able to pick a volume that proves
189 // the write-budget exemption specifically, not just "some limiter or
190 // other let this through."
191 domainsCeilingBurst = 180 // per minute, the cliRateWindow, keyed on the principal
192)
193
194// rateLimiter is a fixed-window counter, keyed by whatever the caller thinks
195// identifies a client. In-memory and per-process on purpose: the console is
196// one process on one box, and reaching for a shared store here would add a
197// dependency to buy nothing.
198type rateLimiter struct {
199 mu sync.Mutex
200 max int
201 window time.Duration
202 lastSweep time.Time
203 hits map[string]*rateWindow
204}
205
206type rateWindow struct {
207 start time.Time
208 n int
209}
210
211// maxRateKeys hard-bounds a limiter's map. Some limiters are keyed on
212// caller-chosen values (a device code), so "one entry per distinct key" is a
213// memory budget an attacker writes — this is where that stops.
214const maxRateKeys = 8192
215
216func newRateLimiter(max int, window time.Duration) *rateLimiter {
217 return &rateLimiter{max: max, window: window, hits: map[string]*rateWindow{}}
218}
219
220// allow records one hit and reports whether it is within the window's budget.
221//
222// The sweep runs at most once per window, NEVER per request: an O(n) walk on
223// every call — while holding this mutex, in front of a database limited to one
224// writer that every console page also queues behind — turns a flood on one
225// endpoint into a stall on the whole console. Over the hard bound the map is
226// dropped wholesale rather than walked, because under a flood of made-up keys
227// there is nothing to distinguish worth keeping, and forgetting counters is a
228// far smaller failure than freezing the site.
229func (l *rateLimiter) allow(key string, now time.Time) bool {
230 l.mu.Lock()
231 defer l.mu.Unlock()
232 if now.Sub(l.lastSweep) >= l.window {
233 for k, w := range l.hits {
234 if now.Sub(w.start) > l.window {
235 delete(l.hits, k)
236 }
237 }
238 l.lastSweep = now
239 }
240 if len(l.hits) > maxRateKeys {
241 l.hits = make(map[string]*rateWindow, 64)
242 l.lastSweep = now
243 }
244 w := l.hits[key]
245 if w == nil || now.Sub(w.start) > l.window {
246 l.hits[key] = &rateWindow{start: now, n: 1}
247 return true
248 }
249 w.n++
250 return w.n <= l.max
251}
252
253// tripped is allow's READ-ONLY half: it reports whether key is ALREADY over
254// budget, in the current window, without recording a hit. It exists for a
255// caller that wants to skip an expensive operation ENTIRELY once the budget
256// is spent, rather than pay for the operation and only then be refused
257// (Task 5 review, C33) — a live outbound DNS round trip is the case that
258// motivated it, but the shape is general.
259//
260// Same sweep, same reason as allow: an expired window must read as fresh
261// here too, or a peek and the allow that follows it a moment later could
262// disagree about whether the caller is still bounded. It does NOT create an
263// entry for a key it has never seen and does NOT run allow's over-the-hard-
264// bound wholesale drop — a peek must never be the thing that evicts every
265// OTHER key's count, which a bare read has no business doing.
266func (l *rateLimiter) tripped(key string, now time.Time) bool {
267 l.mu.Lock()
268 defer l.mu.Unlock()
269 if now.Sub(l.lastSweep) >= l.window {
270 for k, w := range l.hits {
271 if now.Sub(w.start) > l.window {
272 delete(l.hits, k)
273 }
274 }
275 l.lastSweep = now
276 }
277 w := l.hits[key]
278 if w == nil || now.Sub(w.start) > l.window {
279 return false
280 }
281 return w.n >= l.max
282}
283
284// resetIn reports how much of key's CURRENT window is left, so a refusal can
285// say WHEN instead of "wait a while". It is meaningful only immediately
286// after allow returned false for the same key: a key with no window, or one
287// whose window has already run out, has nothing to wait for and answers 0.
288//
289// Read-only like tripped, and — unlike both allow and tripped — it does NOT
290// sweep. The caller it exists for has just been through allow, which swept a
291// moment ago; a second walk of the map under the same mutex, on the path
292// that is by definition being hit hardest, is the stall allow's own comment
293// is at pains to avoid.
294//
295// Rounded UP to a whole second, and never below one: Retry-After's unit is
296// seconds, and a truthful "0" or "0.4" is an invitation to retry straight
297// back into the same refusal.
298func (l *rateLimiter) resetIn(key string, now time.Time) time.Duration {
299 l.mu.Lock()
300 defer l.mu.Unlock()
301 w := l.hits[key]
302 if w == nil {
303 return 0
304 }
305 left := w.start.Add(l.window).Sub(now)
306 if left <= 0 {
307 return 0
308 }
309 secs := left / time.Second
310 if left%time.Second != 0 {
311 secs++
312 }
313 return secs * time.Second
314}
315
316// retryAfter stamps the one part of a 429 a caller can act on without
317// reading prose. The message beside it says the same thing in words for a
318// human; this is for the script, the CI step and the agent, all of which
319// otherwise have to choose between retrying immediately (into the same
320// refusal) and giving up. Called BEFORE jsonError, which writes the status
321// line and so closes the header map.
322//
323// A zero or negative wait is stamped as one second rather than omitted: the
324// caller reaching this has been refused, and "retry whenever" is the answer
325// that produced the spin.
326func retryAfter(w http.ResponseWriter, d time.Duration) {
327 if d < time.Second {
328 d = time.Second
329 }
330 w.Header().Set("Retry-After", fmt.Sprintf("%d", d/time.Second))
331}
332
333// cliLimits is the set of limiters the CLI endpoints share, hung off the
334// Server so a test gets a fresh set per fixture.
335type cliLimits struct {
336 start, poll, pollIP, guessAddr, guessIP, write, ship, instances, create *rateLimiter
337 // db is the `carlos db` door's own budget, keyed by token (cliDBBurst,
338 // apidb.go): a restore is dozens of reads, a different shape from the
339 // instance verbs.
340 db *rateLimiter
341 // runs is the follow routes' budget (run spec §5.6): the CLI polls once
342 // a second, which the 60-a-minute instance window would refuse. The
343 // create door spends instances, as spec refusal 3 says.
344 runs *rateLimiter
345 // svcCreate, svcWrite and svcShip are the SERVICE side of create/write/
346 // ship — separate counters, separate keys (principalKey's NAME, not the
347 // person's ADDRESS or the ship-person's token hash), so a credential's
348 // budget is never the operator's to spend (issue #220 consequence 1).
349 // allowCreate/allowWrite/allowShip (svcprincipal.go) pick between the
350 // person and the service limiter so no call site has to.
351 svcCreate, svcWrite, svcShip *rateLimiter
352 // tempCreate is /new-temporary's own budget (cliTemporaryCreateBurst),
353 // one limiter for both principal kinds, keyed like create by
354 // principalKey, separate from create and svcCreate so neither kind of
355 // create spends the other's day.
356 tempCreate *rateLimiter
357 // wildFail bounds only FAILED wildcard-enable attempts — see
358 // wildFailBurst. The domains doors are otherwise exempt from write/
359 // instances entirely (spec §4a).
360 wildFail *rateLimiter
361 // domainsCeiling is the convergence-rate governor attach/detach keep
362 // even though they are exempt from write/instances — see
363 // domainsCeilingBurst. One limiter, one key shape (principalKey),
364 // regardless of person or service: the exemption never depended on
365 // which kind of caller it was, so neither does this ceiling.
366 domainsCeiling *rateLimiter
367}
368
369func newCLILimits() *cliLimits {
370 return &cliLimits{
371 start: newRateLimiter(cliStartBurst, cliRateWindow),
372 poll: newRateLimiter(cliPollBurst, cliRateWindow),
373 pollIP: newRateLimiter(cliPollIPBurst, cliRateWindow),
374 // Wrong codes are counted against the signed-in ADDRESS and the client
375 // IP, never the session token: a session is re-minted by signing in
376 // again, so a token-keyed counter is one the attacker resets at will.
377 guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow),
378 guessIP: newRateLimiter(cliGuessBurst, cliRateWindow),
379 write: newRateLimiter(cliWriteBurst, cliRateWindow),
380 // Ship gets its own budget, on its own window, keyed by token rather
381 // than address (see handleAPIShip) — an upload is a different shape
382 // of cost (bandwidth, storage) from the JSON mutations write covers.
383 ship: newRateLimiter(cliShipBurst, cliShipWindow),
384 // The instance verbs' own budget, keyed by TOKEN (see
385 // cliInstanceBurst and apiinstances.go): the app token that does
386 // most of this work is not a person, so the address-keyed write
387 // budget is the wrong principal for it in both directions.
388 instances: newRateLimiter(cliInstanceBurst, cliRateWindow),
389 db: newRateLimiter(cliDBBurst, cliRateWindow),
390 runs: newRateLimiter(cliRunsBurst, cliRateWindow),
391 // Create gets its own budget on its own (day-long) window, keyed by
392 // address — the stolen-token ceiling handleAPICreateApp explains.
393 create: newRateLimiter(cliCreateBurst, cliCreateWindow),
394 // Temporary creates get their own budget on create's window (spec
395 // ruling 8) — see cliTemporaryCreateBurst.
396 tempCreate: newRateLimiter(cliTemporaryCreateBurst, cliCreateWindow),
397
398 svcCreate: newRateLimiter(svcCreateBurst, cliCreateWindow),
399 svcWrite: newRateLimiter(svcWriteBurst, cliRateWindow),
400 svcShip: newRateLimiter(svcShipBurst, cliShipWindow),
401 wildFail: newRateLimiter(wildFailBurst, cliRateWindow),
402
403 domainsCeiling: newRateLimiter(domainsCeilingBurst, cliRateWindow),
404 }
405}
406
407// cliRouteKey is the context key cliRoute stashes the resolved principal
408// under. Unexported, and of an unexported type, so nothing outside this
409// package can forge one onto a request.
410type cliRouteKey struct{}
411
412// principalFrom reads the principal cliRoute resolved. ok is false only for a
413// request that did not come through cliRoute — which, for anything under
414// /api/cli/, TestEveryCLIDoorNamesItsRoles says cannot happen.
415func principalFrom(ctx context.Context) (Principal, bool) {
416 p, ok := ctx.Value(cliRouteKey{}).(Principal)
417 return p, ok
418}
419
420// cliRoute registers one CLI API door and is the ONLY way to register one.
421// It resolves the bearer once, applies the role gate, and passes the
422// principal down on the request context so the handler's own resolver does
423// not repeat the lookup.
424//
425// THE ROLE GATE RUNS BEFORE THE HANDLER, AND THEREFORE BEFORE THE SCOPE
426// CHECK — spec §3b. A role refusal reads neither the account segment nor the
427// database: it is a constant function of the door and the caller's own role,
428// identical whether the named account exists, belongs to someone else, or
429// belongs to the caller. There is nothing in it to leak. Checking scope first
430// would instead make the pair of answers a membership oracle — 404 for an
431// account you do not hold, 403 for one you do. Role-first is also the only
432// order that can be enforced structurally, out here, rather than asked of
433// every handler.
434//
435// A PERSON never meets the gate. Their authority is their membership set,
436// evaluated per request by the handler, and no role name resolves to it.
437//
438// allow is never optional and the zero Set permits nothing — not even admin —
439// so a door added without naming its roles is unreachable by every service
440// principal rather than open to all of them.
441func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc) {
442 if s.cliRoles == nil {
443 s.cliRoles = map[string]svcrole.Set{}
444 }
445 s.cliRoles[pattern] = allow
446 s.registerRoute(pattern, func(w http.ResponseWriter, r *http.Request) {
447 p, ok := s.store.CLIPrincipal(bearer(r), time.Now())
448 if !ok {
449 // The same 401, with the same body, cliUser has always given:
450 // an expired token, a revoked one and a fabricated one are not
451 // distinguishable from outside.
452 s.cliNotSignedIn(w)
453 return
454 }
455 if p.Kind == PrincipalService && !allow.Permits(p.Role) {
456 if allow.Empty() {
457 // A closed door has no role that would work, so naming the
458 // caller's role and an empty list ("needs one of []") would
459 // read as a bug and send whoever hit it looking for the role
460 // that opens it. There is none: this is the escalation cut.
461 // Constant for every role and every account segment, which
462 // keeps it as leak-free as the refusal below.
463 jsonError(w, http.StatusForbidden,
464 "this door is closed to service credentials — no role reaches it; it needs a person")
465 return
466 }
467 jsonError(w, http.StatusForbidden, fmt.Sprintf(
468 "this credential holds role %q; this door needs one of %v", p.Role, allow.Roles()))
469 return
470 }
471 h(w, r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, p)))
472 })
473}
474
475// registerCLIRoutes wires the CLI-identity surface. Called from NewServer.
476//
477// The /auth/cli/ family and GET /settings below stay on plain
478// mux.HandleFunc, deliberately: they are the OTHER half of default-deny.
479// Every one of them that reads a bearer at all gates on cliUser, which
480// answers only for a person, so a service credential meets there exactly the
481// 401 a fabricated token meets. Pinned by TestAuthCLIFamilyResolvesOnlyPersons.
482//
483// Every /api/cli/ door goes through cliRoute and names its roles. Pinned by
484// TestEveryCLIDoorNamesItsRoles.
485func (s *Server) registerCLIRoutes() {
486 s.registerRoute("POST /auth/cli/start", s.handleCLIStart)
487 s.registerRoute("POST /auth/cli/poll", s.handleCLIPoll)
488 s.registerRoute("GET /auth/cli", s.handleCLIApprovePage)
489 s.registerRoute("POST /auth/cli/approve", s.handleCLIApprove)
490 s.registerRoute("GET /settings", s.handleCLITokens)
491 s.registerRoute("GET /auth/cli/tokens", func(w http.ResponseWriter, r *http.Request) {
492 http.Redirect(w, r, "/settings", http.StatusSeeOther)
493 })
494 s.registerRoute("POST /auth/cli/tokens/revoke", s.handleCLITokenRevoke)
495 s.registerRoute("GET /auth/cli/whoami", s.handleCLIWhoami)
496 s.registerRoute("POST /auth/cli/revoke", s.handleCLIRevoke)
497 s.registerRoute("POST /auth/cli/accounts", s.handleCLINewAccount)
498 // The CLI's read-only API (apicli.go) — deliberately a different prefix
499 // (/api/cli/) from the identity routes above (/auth/cli/), which must not
500 // move now that CLIs depend on them.
501 s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps)
502 // The fleet-wide live tenancy read (discussion #11). An EMPTY role set:
503 // no service credential of any role reaches it, because the answer names
504 // every account's instances on every box, and that is an operator's view
505 // rather than any workload's. The operator check itself is inside the
506 // handler, since cliRoute's role set can only narrow SERVICE principals
507 // and a person always passes through it.
508 s.cliRoute("GET /api/cli/status/instances", svcrole.Set{}, s.handleAPIStatusInstances)
509 if s.cfg.HostClaim {
510 // Host loss inspection and submission are person-only and repeat the
511 // fleet-operator check in their handlers. Until the follow-on integration supplies the
512 // ActorDirectory both routes stop at the same inert refusal.
513 s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect)
514 s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve)
515 // Box and group roles (region HA spec 2 §7, apiboxroles.go):
516 // operator-only, and registered only with CARLOS_REGION_HA on too,
517 // so with it off they answer as any unknown route, as on main.
518 if s.cfg.RegionHA {
519 s.cliRoute("POST /api/cli/system/boxes/{box}/role", svcrole.Set{}, s.handleAPISystemBoxRole)
520 s.cliRoute("POST /api/cli/system/groups/{region}/role", svcrole.Set{}, s.handleAPISystemGroupRole)
521 }
522 }
523 // carlos status --layout (plan 4, task 5): which layout each instance is
524 // on, box by box. Same posture as the door above — an empty role set, the
525 // operator check inside the handler.
526 s.cliRoute("GET /api/cli/status/layout", svcrole.Set{}, s.handleAPIStatusLayout)
527 // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no
528 // bare POST .../{app} route exists to collide with, and the mux prefers
529 // the literal if one ever does.
530 s.cliRoute("POST /api/cli/apps/{account}/new", svcrole.Allow(svcrole.Publish), s.handleAPICreateApp)
531 // Temporary apps (temporary static sites spec §3). A separate route, not a
532 // field on /new: /new's decoder accepts unknown fields, so an older
533 // console would take "temporary" silently and create a permanent app. An
534 // older console answers this route 404 and the CLI stops there.
535 s.cliRoute("POST /api/cli/apps/{account}/new-temporary", svcrole.Allow(svcrole.Publish), s.handleAPICreateTemporaryApp)
536 s.cliRoute("GET /api/cli/apps/{account}/list", svcrole.Allow(svcrole.Publish), s.handleAPIAppList)
537 s.cliRoute("POST /api/cli/apps/{account}/{app}/ttl", svcrole.Allow(svcrole.Publish), s.handleAPIAppTTL)
538 s.cliRoute("POST /api/cli/apps/{account}/{app}/keep", svcrole.Allow(svcrole.Publish), s.handleAPIAppKeep)
539 s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels)
540 s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote)
541 s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback)
542 // `carlos channels resign` (#253, spec §4.6): a person confirms the
543 // manifest digests the console then signs into the channel's pointer, so
544 // no credential reaches it.
545 s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign)
546 // `carlos releases authorize` (settings spec §4.1): GET shows a build's
547 // digests, POST signs a promotion record for the digest a person
548 // confirmed. A credential the app holds never vouches for a build.
549 s.cliRoute("GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize)
550 s.cliRoute("POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize)
551 s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip)
552 // `carlos restart` (issue #113): a member's "cycle this app's processes"
553 // with no version and no config to push — the same write-door class as
554 // promote and rollback, and the only one whose subject is a running
555 // process rather than a pointer.
556 s.cliRoute("POST /api/cli/apps/{account}/{app}/restart", svcrole.Allow(svcrole.Operate), s.handleAPIRestart)
557 // The NARROWED form (discussion #16): one route, or everything reading one
558 // config environment, plus the dry run that shows the set first. A
559 // SEPARATE path rather than a body on the door above, and deliberately so:
560 // that one reads no body, so a console predating this would answer a
561 // narrowed request by cycling the whole app. Here it answers 404 and the
562 // CLI says why. Same role — the subject is still a running process.
563 s.cliRoute("POST /api/cli/apps/{account}/{app}/restart/scope", svcrole.Allow(svcrole.Operate), s.handleAPIRestartScope)
564 // `carlos schedule` (apischedule.go, scheduled-work spec §8): declare
565 // recurring work, take it back, and make it fire now. The RESTART class,
566 // not the publish one — operating a running app rather than moving a
567 // version pointer — so the same Operate role reaches all four. `delete`
568 // and `run` are literal segments after the {name} wildcard, so a schedule
569 // named "delete" is still just a name; the mux prefers the literal
570 // regardless of registration order.
571 s.cliRoute("GET /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleList)
572 s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleSet)
573 s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/delete", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRemove)
574 s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/run", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRun)
575 // `carlos releases retention` (issue #136): a member's ambient-prune
576 // policy — read, set, clear, preview. Same identity gates as every
577 // door here; the safety list in release.PruneEligible is what keeps a
578 // policy from ever deleting anything promoted, tagged, or recent.
579 s.cliRoute("GET /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionGet)
580 s.cliRoute("POST /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionSet)
581 // The version-target, tags and releases-list API (apiversion.go,
582 // release versioning spec 2026-08-08). GET .../releases coexists with
583 // the POST above on the SAME path — a distinct method route, not a
584 // collision — for `carlos releases` to read what `carlos ship` writes.
585 s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet)
586 s.cliRoute("PUT /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetSet)
587 s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList)
588 s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet)
589 s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList)
590 // The pipeline API (apipipeline.go, release pipelines v2). GET is
591 // release metadata like channels/releases above — Publish reaches it.
592 // PUT rewrites the policy governing every promotion into every channel
593 // (bake, passkey, approval quorum), account authority in the same
594 // register as ledgers and store grants below — Admin-only for a
595 // SERVICE credential. svcrole.Allow() alone would be the WRONG way to
596 // say that: the empty Set is fully closed, Admin included
597 // (TestEmptySetPermitsNothingIncludingAdmin, internal/svcrole) — the
598 // same zero value svcrole.Set{} spells for the person-only doors
599 // below. Naming Admin explicitly is what actually opens the door to an
600 // admin-role service and closes it to every other role, exactly like
601 // the ledgers and store families.
602 s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet)
603 s.cliRoute("PUT /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Admin), s.handleAPIPipelinePut)
604 // What the app is waiting on: the proposals a gated promote, fast-track
605 // or policy edit turned into (pendinghandlers.go). Read-only, and Publish
606 // like channels and releases — a proposal names no secret, and the CLI
607 // needs to see one precisely because it can never confirm one itself
608 // (spec §4: confirming takes a passkey, in the console).
609 s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending)
610 // Trash and restore (apicli.go). Delete resolves through the LIVE-apps
611 // door and restore through the trashed-apps one, so each verb answers
612 // only for an app in the state it can act on — and both give a stranger
613 // the same 404.
614 s.cliRoute("POST /api/cli/apps/{account}/{app}/delete", svcrole.Allow(svcrole.Publish), s.handleAPITrashApp)
615 s.cliRoute("POST /api/cli/apps/{account}/{app}/restore", svcrole.Allow(svcrole.Publish), s.handleAPIRestoreApp)
616 // The ledger API (apiledger.go) — the only write path to ledgers/ from
617 // outside a box. The literal `publish` segment is kept adjacent to the
618 // {ledger} wildcards for readability; the mux prefers it regardless of
619 // registration order.
620 s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers", svcrole.Allow(svcrole.Admin), s.handleAPILedgerList)
621 s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/publish", svcrole.Allow(svcrole.Admin), s.handleAPILedgerPublish)
622 s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", svcrole.Allow(svcrole.Admin), s.handleAPILedgerHead)
623 s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", svcrole.Allow(svcrole.Admin), s.handleAPILedgerAppend)
624 s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", svcrole.Allow(svcrole.Admin), s.handleAPILedgerBlob)
625 // The instance provisioning API (apiinstances.go) — the only door onto
626 // instances/ and domains/ from outside a box. It was once the only
627 // family a non-person could speak to; since the role gate landed that is
628 // a property of each door's Allow set, not of this block. `instance-token` is a literal segment beside the
629 // {host} wildcards; the mux prefers a literal regardless of
630 // registration order, and it deliberately sits under a DIFFERENT
631 // prefix from instances/{host} so a host can never be mistaken for the
632 // mint verb.
633 // `instances-manifest` is the member-facing half of `carlos instances
634 // enable` (issue #66): a literal sibling of instances/, never under it,
635 // so a host named "instances-manifest" could still never reach it.
636 //
637 // The zero Set, for the same escalation cut the mint door takes. This
638 // door writes the MANIFEST — standing policy about which domain, pool,
639 // regions and channel the app may ever be brought up on — and a
640 // credential the app itself holds must not be able to rewrite the
641 // bounds it runs inside. It takes no authority away: handleAPIInstanceEnable
642 // has always gated on apiAccountApp, which could not see a non-person at
643 // all, so no app token has ever reached it. The registration is where
644 // that is now said, because a registration reading "instance may pass"
645 // while the handler refuses is a registration that lies. As at the mint
646 // door, the ANSWER a credential gets here moves from 401 to 403.
647 s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable)
648 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList)
649 // The armed-regions answer for consumer pickers (issue #346) — the
650 // same gate and role as the list beside it; see handleAPIRegions.
651 s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions)
652 // The per-caller region guess (nearest-region spec §9.4): POST, so the
653 // hints never ride a URL a redirect could echo.
654 s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess)
655 // Moving a route between channels (channelmove.go). The zero Set, for
656 // the manifest door's reason: this decides what a route SERVES, and a
657 // credential the app itself holds must not be able to walk its own route
658 // onto a channel whose rules it never met. Widening it later is easy;
659 // narrowing an authority already granted is not.
660 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel)
661 // Update settings (instance update settings spec §5.1). Person-only until
662 // #155 step 6 adds an instance-bound credential: svcrole.Instance is
663 // app-wide, so it would let one instance's code set a sibling's pin.
664 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesGet)
665 s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesPut)
666 // "Update now" asks root for the newest build the settings allow; the
667 // same people-only gate, and fast_track only for an account owner.
668 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.Set{}, s.handleAPIUpdatesInstall)
669 // The app's instance update policy (spec §3.2) lives on the instances
670 // manifest, so it is closed for the manifest door's reason: a credential
671 // the app holds must not rewrite the bounds its instances run inside.
672 s.cliRoute("PUT /api/cli/apps/{account}/{app}/updates", svcrole.Set{}, s.handleAPIUpdatesPolicy)
673 // The logs read (`carlos logs`, apilogs.go) — same gate, same 404
674 // discipline, and an app token may read its own app's logs.
675 s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs)
676 // The errors read (`carlos errors`, apierrors.go) — the logs door's twin,
677 // over the same window from the other end: the same gate, the same roles,
678 // and an app token may read its own app's errors for the same reason it
679 // may read its own logs.
680 s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors)
681 s.cliRoute("GET /api/cli/apps/{account}/{app}/availability", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAvailability)
682 // The analytics read (`carlos analytics`, apianalytics.go) — the third of
683 // the same family, on the same footing: an app token that may read every
684 // line of its own logs is not learning anything new from a count of its
685 // own page views. The SETTING that decides whether those counts exist is
686 // the telemetry PUT below, and that stays Operate-only.
687 s.cliRoute("GET /api/cli/apps/{account}/{app}/analytics", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAnalytics)
688 // The platform's GeoIP answer for an arbitrary address (geoip spec
689 // §11.1): the same holders as the analytics read.
690 s.cliRoute("GET /api/cli/apps/{account}/{app}/geo", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIGeo)
691 // The telemetry SETTINGS write, Operate only and deliberately narrower
692 // than the read beside it: this decides what counts as an error for the
693 // app's owner, and a credential the app itself holds must not be able to
694 // widen or narrow what its owner is shown.
695 s.cliRoute("PUT /api/cli/apps/{account}/{app}/telemetry", svcrole.Allow(svcrole.Operate), s.handleAPITelemetryPut)
696 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceCreate)
697 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGet)
698 // PATCH is the multi-upstream repoint (issue #152): the two fields of a
699 // record — its addr set and its probe — that change while the instance
700 // keeps serving. Same {host} wildcard, same gate, same byte-identical
701 // 404 as its GET and DELETE siblings.
702 s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate)
703 // The residence-move doors (issue #331): requested by the tenant's
704 // ROUTER with the app-held instance token (titogo's residence design —
705 // never by an instance about itself... which the role system cannot yet
706 // express; the Instance role is the same authority the create and
707 // delete doors already grant this credential).
708 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveStatus)
709 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest)
710 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut)
711 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort)
712 // With CARLOS_MOVES_PHASE1 on, the three mutations above are
713 // operator-only (moveOperatorGate) and a resolve door joins them. Off,
714 // that route is never registered, so every request to it answers
715 // exactly as it did before Phase 1 (index D13).
716 if s.cfg.MovesEnabled {
717 s.registerMovesPhase1Routes()
718 }
719 // The offline operator move's doors: always registered, operator-only,
720 // and refused with MV-O20 on a console with host claims or Phase 1 on.
721 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStatus)
722 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStart)
723 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/stage", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStage)
724 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbSeal))
725 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFlip))
726 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbOpen))
727 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFinish))
728 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineAbort)
729 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete)
730 // The import door (seeded-instances spec §5): the instance role, like
731 // the create door that begins it.
732 s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/import", svcrole.Allow(svcrole.Instance), s.handleAPIImportUpload)
733 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped", svcrole.Allow(svcrole.Instance), s.handleAPIImportStopped)
734 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit", svcrole.Allow(svcrole.Instance), s.handleAPIImportCommit)
735 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort", svcrole.Allow(svcrole.Instance), s.handleAPIImportAbort)
736 // The run doors (service runs spec §1): a person on the member rule, or a
737 // run service, and no other role. Exactly, not Allow, so Admin does not
738 // join: a credential that may provision instances cannot run commands on
739 // them unless it was minted for that.
740 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunCreate)
741 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Exactly(svcrole.Run), s.handleAPIRunKeyLookup)
742 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", svcrole.Exactly(svcrole.Run), s.handleAPIRunGet)
743 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", svcrole.Exactly(svcrole.Run), s.handleAPIRunCancel)
744 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire", svcrole.Exactly(svcrole.Run), s.handleAPIRunExpire)
745 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack", svcrole.Exactly(svcrole.Run), s.handleAPIRunAck)
746 // `carlos instances retry <host>` (apiretry.go): the operate family's, as
747 // restart is. The {host} wildcard and the 404 are the instance family's.
748 s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/retry", svcrole.Allow(svcrole.Operate), s.handleAPIInstanceRetry)
749 // The `carlos db` replica door (apidb.go; spec 2026-09-02 §6): an
750 // instance's replicated LTX files, by coordinate, for a member to
751 // restore on their own machine. The zero Set, deliberately: the payload
752 // is the whole customer database, and no legitimate credential needs it
753 // (an app reads its own database live, and CI has no business pulling
754 // production data). admin is refused with the rest.
755 s.cliRoute("GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}", svcrole.Set{}, s.handleAPIDBList)
756 s.cliRoute("GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}/{span}", svcrole.Set{}, s.handleAPIDBGet)
757 // The forwarded read (db region forward spec §9.1): people only, the db
758 // door's whole gate once, then the read inside the same request.
759 s.cliRoute("POST /api/cli/apps/{account}/{app}/db/{host}/reads", svcrole.Set{}, s.handleAPIDBRead)
760 // The zero Set, deliberately: a credential may never mint a credential,
761 // including itself. This is the escalation cut (spec §3). It takes no
762 // authority away — handleAPIInstanceToken gates on apiAccountApp, which
763 // has never been able to see a non-person at all — it moves the refusal
764 // out to the wrapper, where it is structural, and changes the ANSWER a
765 // credential gets here from 401 to 403. Pinned by
766 // TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor.
767 s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken)
768 // Door keys (#175 P7): person-only, the instance-token rule. A door key
769 // reaches every instance of the app through every box's edge-local door.
770 s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList)
771 s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint)
772 s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke)
773 // The sidecar API (apisidecars.go) — the only door onto sidecars/ from
774 // outside a box, sharing the instance family's gate exactly
775 // (instanceGate) and, since the role gate landed, its Allow sets too:
776 // an app process provisions a sidecar per enrolled member with the
777 // CARLOS_INSTANCE_TOKEN it already holds, which is the Instance role.
778 //
779 // `sidecars-manifest` is the member-facing half of `carlos sidecars
780 // enable`: a literal sibling of sidecars/, never under it, so a sidecar
781 // named "sidecars-manifest" could still never reach it. It takes the
782 // ZERO Set for `instances-manifest`'s reason, and the reason is sharper
783 // here: this manifest carries `args` — the argv the platform execs
784 // after the release binary — plus the placement the workload is held
785 // to. A credential the app holds must not be able to rewrite what its
786 // own boxes will exec on its behalf. handleAPISidecarEnable has always
787 // gated on apiAccountApp, which cannot see a non-person at all, so this
788 // takes no authority away; it moves the refusal out to the registration
789 // and the answer from 401 to 403.
790 //
791 // `wake` is a literal segment UNDER {name}; the mux prefers a literal
792 // regardless of registration order, and provision.ValidSidecarName is
793 // one DNS label, so no sidecar name can ever be "wake"'s path either.
794 s.cliRoute("PUT /api/cli/apps/{account}/{app}/sidecars-manifest", svcrole.Set{}, s.handleAPISidecarEnable)
795 s.cliRoute("GET /api/cli/apps/{account}/{app}/sidecars", svcrole.Allow(svcrole.Instance), s.handleAPISidecarList)
796 s.cliRoute("POST /api/cli/apps/{account}/{app}/sidecars", svcrole.Allow(svcrole.Instance), s.handleAPISidecarCreate)
797 s.cliRoute("GET /api/cli/apps/{account}/{app}/sidecars/{name}", svcrole.Allow(svcrole.Instance), s.handleAPISidecarGet)
798 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/sidecars/{name}", svcrole.Allow(svcrole.Instance), s.handleAPISidecarDelete)
799 // No PATCH: a sidecar has no upstream set to repoint, which is the one
800 // thing an instance record's update verb exists for. The family is
801 // create-only plus delete.
802 s.cliRoute("POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake", svcrole.Allow(svcrole.Instance), s.handleAPISidecarWake)
803 // The extra-hostname API (apidomains.go, issue #151) — the only door onto
804 // domains/ from outside a box, sharing the instance family's gate exactly
805 // (instanceGate), so titogo's router attaches its customers' hostnames
806 // with the CARLOS_INSTANCE_TOKEN it already holds.
807 //
808 // Domains carries `Instance` for COMPATIBILITY, not because it belongs
809 // there: that router re-attaches every hostname on every reload pass with
810 // its CARLOS_INSTANCE_TOKEN, which is a kind='app' row and so holds role
811 // instance. Dropping it here 403s that router on the next console deploy.
812 // Same for logs, above. Pinned by
813 // TestInstanceRoleMatchesTodaysAppTokenReach; spec §5.
814 s.cliRoute("GET /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainList)
815 // The per-host read (#299): same roles as the list it narrows — it
816 // discloses strictly less (one hostname the caller already owns), so it
817 // cannot need a role the list does not.
818 s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet)
819 s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach)
820 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach)
821 // The prefix-map publish (path-based routing): replace the WHOLE map of
822 // leading path segments a claimed hostname splits its traffic by. The
823 // tenant's own platform is the writer and the source of truth — it
824 // regenerates the map from its database and republishes it — which is
825 // why there is no per-prefix verb and no CLI writer at all.
826 //
827 // `Publish` AND `Instance`, matching the READ of the same state on
828 // handleAPIDomainGet above. An earlier revision made this Publish-only on
829 // the reasoning that a flip is deployment-shaped; the objection that
830 // carried is internal coherence — as Publish-only, an Instance credential
831 // could read a hostname's published prefix map and not write it, and this
832 // file's own header names titogo's router, calling the domains family
833 // with its CARLOS_INSTANCE_TOKEN, as the caller this door exists for.
834 //
835 // Widening crosses no tenancy boundary. The door is already owner-gated
836 // on account and app (domainsGate) and guarded by
837 // refuseServiceControlPlane, so an Instance token can only ever publish
838 // its OWN app's map, naming targets the reconcile pass then re-checks
839 // against the same account (aliases.go). It is not in
840 // TestInstanceRoleMatchesTodaysAppTokenReach's legacy map, because
841 // nothing reached it before service principals existed — see
842 // exemptFromLegacyPin, where the deliberate grant is recorded. Design
843 // spec §7.
844 s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainPaths)
845 // App feature flags (apifeatures.go, #347). The read carries Instance
846 // because the app IS the consumer — its router reads its own switches
847 // with the CARLOS_INSTANCE_TOKEN, the same credential and the same
848 // answer (the domains deployment block) it already uses. The write is
849 // Admin only: these are production switches, and the register is
850 // `pipeline` PUT's and `apps place`'s, not promote's. An app's own
851 // credential must never reach the write — a switch an app could flip
852 // on itself is a root env flag by another name.
853 s.cliRoute("GET /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIFeaturesGet)
854 s.cliRoute("POST /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Admin), s.handleAPIFeaturesSet)
855 // The config API (apiconfig.go) — `carlos env` and `carlos secrets`
856 // through the same gate as ship/promote, so deploy access no longer
857 // means bucket credentials (issue #43). Secrets arrive already sealed;
858 // the console relays an opaque envelope and holds no key to open it.
859 s.cliRoute("GET /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigGet)
860 s.cliRoute("PUT /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigPut)
861 s.cliRoute("POST /api/cli/apps/{account}/{app}/config/sync", svcrole.Allow(svcrole.Operate), s.handleAPIConfigSync)
862 // The routes read (apiroutes.go, issue #64) — the member-only door onto
863 // this box's registry rows for one app, so a route's environment
864 // binding can be read back without a box shell.
865 //
866 // The ZERO Set, and the reasoning is that file's own: "An app token is
867 // a machine credential minted for one instance to register itself; this
868 // answer describes every route of the app, with upstream addresses,
869 // owning units and environment bindings, which is an operator's view
870 // rather than a workload's." A service credential is the same kind of
871 // caller an app token is, so the door that refuses one refuses all of
872 // them. It joins policyDoors for that reason — person-only by intent,
873 // not merely by bookkeeping.
874 s.cliRoute("GET /api/cli/apps/{account}/{app}/routes", svcrole.Set{}, s.handleAPIRoutes)
875 // The customer fleets API (fleets.go) — account-owned remote-box groups
876 // and where an app's routes/instances place onto them. `new` is a
877 // literal beside the {fleet} wildcard for the same reason apps/new is:
878 // the mux prefers a literal regardless of registration order, so there
879 // is no ambiguity with a fleet that happened to be named "new".
880 //
881 // Every one of them is the zero Set, deliberately: enrolling a box into a
882 // fleet, and rotating or detaching one, hands out a MACHINE's credentials
883 // and changes where a whole account's apps can be placed. That is a human
884 // act, and no service role reaches it — the same escalation cut as the
885 // mint door above. Pinned by TestNoRoleReachesAMintFleetOrPolicyDoor.
886 s.cliRoute("POST /api/cli/fleets/{account}/new", svcrole.Set{}, s.handleAPIFleetNew)
887 s.cliRoute("GET /api/cli/fleets/{account}", svcrole.Set{}, s.handleAPIFleetList)
888 s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes", svcrole.Set{}, s.handleAPIFleetAddBox)
889 s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", svcrole.Set{}, s.handleAPIFleetRotateBox)
890 s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", svcrole.Set{}, s.handleAPIFleetDetachBox)
891 s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", svcrole.Set{}, s.handleAPIFleetTagBox)
892 // The service-credential API (apiservices.go, issue #220) — the
893 // principal that is not an address. Every door here takes the ZERO role
894 // set: a credential may never mint, rotate or revoke a credential,
895 // including itself, which is what keeps a publish credential from
896 // quietly becoming an admin one. Owner-gated inside, because minting is
897 // an authority grant that outlives its minter.
898 //
899 // {name} is a wildcard with rotate and revoke as literal segments
900 // beneath it — the mux prefers a literal regardless of registration
901 // order, the same property the fleets and store families already rely
902 // on, so a credential named "rotate" cannot shadow the verb.
903 s.cliRoute("POST /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceCreate)
904 s.cliRoute("GET /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceList)
905 s.cliRoute("POST /api/cli/services/{account}/{name}/rotate", svcrole.Set{}, s.handleAPIServiceRotate)
906 s.cliRoute("POST /api/cli/services/{account}/{name}/revoke", svcrole.Set{}, s.handleAPIServiceRevoke)
907 // Placement lives under the apps family, not fleets: its subject is an
908 // APP, and a target fleet name is only ever the request body's data.
909 s.cliRoute("POST /api/cli/apps/{account}/{app}/place", svcrole.Allow(svcrole.Admin), s.handleAPIPlace)
910 // The build probe on an EXISTING app (discussion #13). An empty role set:
911 // the probe is what the deploy watch trusts instead of the header, so a
912 // credential that could rewrite it could make its own deploys report
913 // success over a process that never restarted — which is the exact bug
914 // the probe exists to prevent, handed to the wrong caller.
915 s.cliRoute("POST /api/cli/apps/{account}/{app}/build-probe", svcrole.Set{}, s.handleAPIBuildProbe)
916 // The swap policy (zero-downtime spec §4.1, apiswap.go). The ZERO Set:
917 // §11 says only app members change it, and a credential the app holds
918 // must not choose what its own migrations run beside.
919 s.cliRoute("PUT /api/cli/apps/{account}/{app}/swap", svcrole.Set{}, s.handleAPISwapPut)
920 // The object-storage API (apistore.go, issue #55) — declare a store, read
921 // what became of it, and drive the walks that make it real. Beside the
922 // config routes on purpose: a store's credentials are DELIVERED as config,
923 // so these doors are halves of the same story for whoever reads this list.
924 //
925 // `grant` is the deployment's first operator-identity door: same member
926 // 404 as its siblings, then an address on CARLOS_STORE_OPERATORS. Rotate
927 // and finish are MEMBER verbs (spec §8) — they cycle a credential inside an
928 // estate a grant already paid for. All three are literal segments under
929 // .../store, never {wildcards}, so no app or host name can reach them.
930 s.cliRoute("POST /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreCreate)
931 s.cliRoute("GET /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGet)
932 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/provision", svcrole.Allow(svcrole.Admin), s.handleAPIStoreProvision)
933 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant)
934 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate)
935 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish)
936 // Account-level storage entitlement doors are person-only. Applying speaks
937 // for an account owner; approvals spend deployment money and deliberately
938 // resolve their target without requiring operator membership in it.
939 s.cliRoute("POST /api/cli/accounts/{account}/store/apply", svcrole.Set{}, s.handleAPIStoreApply)
940 s.cliRoute("POST /api/cli/accounts/{account}/store/approve", svcrole.Set{}, s.handleAPIStoreApprove)
941 s.cliRoute("GET /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanGet)
942 s.cliRoute("POST /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanSet)
943 s.cliRoute("POST /api/cli/accounts/{account}/store/decline", svcrole.Set{}, s.handleAPIStoreDecline)
944 s.cliRoute("GET /api/cli/store/approvals", svcrole.Set{}, s.handleAPIStoreApprovals)
945 // The scan family (internal/console/apiscan.go). `scan` itself is the
946 // member's request-and-status pair; grant, revoke, sweep and enforce are
947 // operator verbs. Literal segments again, so no app or host name reaches
948 // them.
949 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanRequest)
950 s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanStatus)
951 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/grant", svcrole.Allow(svcrole.Admin), s.handleAPIScanGrant)
952 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/revoke", svcrole.Allow(svcrole.Admin), s.handleAPIScanRevoke)
953 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/sweep", svcrole.Allow(svcrole.Admin), s.handleAPIScanSweep)
954 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/enforce", svcrole.Allow(svcrole.Admin), s.handleAPIScanEnforce)
955 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/callback-key/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIScanCallbackRotate)
956 // The ONE scan door an app's own credential reaches: "scan this object
957 // now" (spec §5.1). Service, not Admin, and the difference is the point —
958 // every verb above decides POLICY (who may scan, whether enforcement is
959 // on) and belongs to a person, while this one asks for work inside a
960 // policy somebody already set. It can add nothing an operator has not
961 // already granted: the consent chain in the handler refuses unless the
962 // store was requested, granted and enabled, so the worst a stolen service
963 // token achieves is queueing scans of its own app's objects.
964 s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanEnqueue)
965 // And reading the answer back (spec §5.2), same role and same reasoning:
966 // an app asking about its own object.
967 s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanObject)
968 // The relay family (internal/console/apirelay.go, relay spec §5): the
969 // platform STUN/TURN relay's app-facing doors. enable/status/disable are
970 // the member's own verbs — declaring, inspecting and ending the app's own
971 // relay estate is not an authority over anything but that app, unlike the
972 // object store's grant. adopted is the member's cutover ack for a
973 // TCP-address route this platform cannot restart. Literal segments under
974 // .../relay, same as store's, so no app or host name can reach them.
975 s.cliRoute("POST /api/cli/apps/{account}/{app}/relay", svcrole.Allow(svcrole.Admin), s.handleAPIRelayEnable)
976 s.cliRoute("GET /api/cli/apps/{account}/{app}/relay", svcrole.Allow(svcrole.Admin), s.handleAPIRelayGet)
977 s.cliRoute("POST /api/cli/apps/{account}/{app}/relay/disable", svcrole.Allow(svcrole.Admin), s.handleAPIRelayDisable)
978 s.cliRoute("POST /api/cli/apps/{account}/{app}/relay/adopted", svcrole.Allow(svcrole.Admin), s.handleAPIRelayAdopted)
979 // rotate is the ONE relay door with no {account} segment: it moves a
980 // REGION's issuer pointer and re-issues every app enabled there, an
981 // authority over the deployment's key material (RelayOperators, server.go)
982 // rather than over any one tenant's app. apiRelayOperator is its whole
983 // gate.
984 s.cliRoute("POST /api/cli/relay/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIRelayRotate)
985 // `carlos system` (apisystemupdate.go and apisystem.go, spec §5) — the
986 // deployment-wide doors. No {account} segment, so no service credential
987 // can be scoped to them at all. Two families sit here: the operator's
988 // promote of the PLATFORM's own binary (what is running where, roll the
989 // fleet forward onto a signed public release or a local build, and roll
990 // it back), and the deployment-wide antivirus policy.
991 //
992 // Every one of them is the ZERO Set, and for the sharpest version of the
993 // escalation cut in this file: the subject is the substrate every tenant
994 // runs inside. A credential that reached the update doors could point
995 // every box in the deployment at a binary of its choosing, which is not a
996 // widening of any workload's authority — it is the end of the boundary
997 // the roles are drawn inside. An empty Set shuts them to every
998 // credential, and closedDoors pins that shut.
999 //
1000 // The person check itself is in the handlers, because cliRoute's Set can
1001 // only narrow SERVICE principals and a person always passes through it.
1002 // The update doors want an OWNER of the ops account or an address on
1003 // CARLOS_SYSTEM_OPERATORS, with upload the owner's alone
1004 // (apiSystemRoller); the antivirus doors want an address on
1005 // CARLOS_STORE_OPERATORS (apiSystemOperator).
1006 s.cliRoute("GET /api/cli/system/status", svcrole.Set{}, s.handleAPISystemStatus)
1007 s.cliRoute("POST /api/cli/system/update", svcrole.Set{}, s.handleAPISystemUpdate)
1008 s.cliRoute("GET /api/cli/system/jobs/{id}", svcrole.Set{}, s.handleAPISystemJob)
1009 s.cliRoute("PUT /api/cli/system/upload", svcrole.Set{}, s.handleAPISystemUpload)
1010 s.cliRoute("POST /api/cli/system/rollback", svcrole.Set{}, s.handleAPISystemRollback)
1011 // I-4: releasing an executable activation reservation that was never
1012 // published. The same person gate as rollback (apiSystemRoller).
1013 s.cliRoute("POST /api/cli/system/activation/cancel", svcrole.Set{}, s.handleAPISystemActivationCancel)
1014 s.cliRoute("GET /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutGet)
1015 s.cliRoute("POST /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutSet)
1016 s.cliRoute("GET /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusGet)
1017 s.cliRoute("POST /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusSet)
1018 s.cliRoute("POST /api/cli/system/antivirus/rotate-webhook-key", svcrole.Set{}, s.handleAPISystemWebhookRotate)
1019 // Platform GeoIP (geoip spec §4.2): apiSystemRoller, like the update
1020 // doors, never CARLOS_STORE_OPERATORS.
1021 s.cliRoute("PUT /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipPut)
1022 s.cliRoute("GET /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipGet)
1023 s.cliRoute("DELETE /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipDelete)
1024 // The seeds floor (seeded-instances spec §3.3): apiSystemRoller.
1025 s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor)
1026 // Legacy route assignments (seeded-instances spec §6.4): apiSystemRoller.
1027 s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes)
1028 // Pointer digests (#253, spec §4.6): each account's emission marker and
1029 // the channels still without digests. apiSystemRoller, like the update
1030 // doors.
1031 s.cliRoute("GET /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests)
1032 s.cliRoute("POST /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests)
1033}
1034
1035// clientIP is who the rate limiter counts against.
1036//
1037// X-Forwarded-For is only consulted when the PEER is one we put there: the
1038// edge on this box (loopback — the console listens on 127.0.0.1) or a
1039// configured edge address. From anyone else the header is just a string the
1040// client typed, and honouring it would let one caller present a fresh "IP"
1041// per request and walk straight past every per-IP limit below.
1042//
1043// Loopback is not the edge alone, though: any process on this box can
1044// connect to it. A loopback peer's chain is therefore believed only with the
1045// edge's signature beside it (forwardedByEdge, edgeproof.go); without one the
1046// request counts against the peer address like any other.
1047//
1048// When the peer is trusted, the LAST element is the one to take: the edge
1049// appends its own view of the peer with SetXForwarded, so everything before
1050// it may have been supplied by the client.
1051func (s *Server) clientIP(r *http.Request) string {
1052 host, _, err := net.SplitHostPort(r.RemoteAddr)
1053 if err != nil {
1054 host = r.RemoteAddr
1055 }
1056 if !s.trustsProxy(host) {
1057 return host
1058 }
1059 // Every line, joined: that is the chain the edge signed, and a second
1060 // line is how a client would try to slip its own element in.
1061 xff := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
1062 if xff == "" {
1063 return host
1064 }
1065 if isLoopbackPeer(host) && !s.forwardedByEdge(r, xff) {
1066 return host
1067 }
1068 parts := strings.Split(xff, ",")
1069 if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
1070 return ip
1071 }
1072 return host
1073}
1074
1075// trustsProxy reports whether a peer is allowed to speak for somebody else.
1076func (s *Server) trustsProxy(host string) bool {
1077 ip := net.ParseIP(host)
1078 if ip == nil {
1079 return false
1080 }
1081 if ip.IsLoopback() {
1082 return true // the edge, on this box, which is the only deployment shape
1083 }
1084 for _, e := range s.cfg.EdgeIPs {
1085 if other := net.ParseIP(strings.TrimSpace(e)); other != nil && other.Equal(ip) {
1086 return true
1087 }
1088 }
1089 return false
1090}
1091
1092// ---- JSON plumbing ----------------------------------------------------------
1093
1094func writeJSON(w http.ResponseWriter, status int, body any) {
1095 w.Header().Set("Content-Type", "application/json")
1096 w.Header().Set("Cache-Control", "no-store")
1097 w.WriteHeader(status)
1098 if err := json.NewEncoder(w).Encode(body); err != nil {
1099 log.Printf("console: write json: %v", err)
1100 }
1101}
1102
1103func jsonError(w http.ResponseWriter, status int, msg string) {
1104 writeJSON(w, status, map[string]string{"error": msg})
1105}
1106
1107// jsonErrorCode is jsonError with a stable machine-readable code beside the
1108// sentence. The run handlers' own refusals use it (runRefusalCodes); a
1109// shared gate never does, so its body stays the same at every door.
1110func jsonErrorCode(w http.ResponseWriter, status int, code, msg string) {
1111 writeJSON(w, status, map[string]string{"error": msg, "code": code})
1112}
1113
1114// readJSON decodes a CLI request body, insisting on a JSON content type.
1115//
1116// That insistence IS the CSRF defence for the unauthenticated endpoints: a
1117// cross-site HTML form can only send urlencoded, multipart or plain text, so
1118// a body this accepts cannot have been sent by a page the user merely
1119// visited. Anything that can set application/json cross-origin has to pass a
1120// CORS preflight first, and this console answers none.
1121func readJSON(w http.ResponseWriter, r *http.Request, out any) bool {
1122 // A login request — and every other door but the config PUT — is a
1123 // handful of short strings; anything larger is not one of ours.
1124 return readJSONLimit(w, r, out, 8<<10)
1125}
1126
1127// readJSONLimit is readJSON with the caller's own byte cap — the seam the
1128// config PUT door uses (apiconfig.go), because a request carrying sealed
1129// secret envelopes is legitimately larger than "a handful of short strings":
1130// an envelope is ~4/3 the size of the plaintext it seals, and one long
1131// base64 credential can pass 8KiB on its own. A body over the cap answers
1132// its own 413 rather than hiding inside "malformed JSON body" — the caller's
1133// fix (split the request, shorten the value) is nothing like a JSON fix.
1134func readJSONLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool {
1135 return decodeJSONBody(w, r, out, limit, false)
1136}
1137
1138// readJSONStrictLimit is readJSONLimit that also refuses a field the struct
1139// does not declare, with a 400 that NAMES it.
1140//
1141// It is for the doors where an unrecognised key would otherwise be dropped and
1142// the request acted on as if it had not been sent — which, at a door whose job
1143// is to NARROW something, silently widens it. The narrowed restart door
1144// (apirestartscope.go) is the case it was written for: a client that renames
1145// `host` to `hosts` would, without this, POST a body with no selector in it
1146// and be answered by a restart of every route the app owns. A door that exists
1147// because another door widens must not itself widen on a typo.
1148//
1149// The unknown-field check is a prefix match on encoding/json's own message
1150// because the package gives no typed error for it; the message is stable and
1151// already names the field, which is the whole thing the caller needs.
1152func readJSONStrictLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool {
1153 return decodeJSONBody(w, r, out, limit, true)
1154}
1155
1156// decodeJSONBody is the shared body of the two above: content type, byte cap,
1157// decode, and one refusal per failure shape.
1158func decodeJSONBody(w http.ResponseWriter, r *http.Request, out any, limit int64, strict bool) bool {
1159 ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
1160 if strings.TrimSpace(strings.ToLower(ct)) != "application/json" {
1161 jsonError(w, http.StatusUnsupportedMediaType, "send a JSON body with Content-Type: application/json")
1162 return false
1163 }
1164 r.Body = http.MaxBytesReader(w, r.Body, limit)
1165 dec := json.NewDecoder(r.Body)
1166 if strict {
1167 dec.DisallowUnknownFields()
1168 }
1169 if err := dec.Decode(out); err != nil {
1170 var tooLarge *http.MaxBytesError
1171 if errors.As(err, &tooLarge) {
1172 jsonError(w, http.StatusRequestEntityTooLarge, "request body too large")
1173 return false
1174 }
1175 if strict && strings.HasPrefix(err.Error(), "json: unknown field ") {
1176 jsonError(w, http.StatusBadRequest, err.Error()+
1177 " — this door refuses a body it does not fully understand rather than acting on the part it does")
1178 return false
1179 }
1180 jsonError(w, http.StatusBadRequest, "malformed JSON body")
1181 return false
1182 }
1183 return true
1184}
1185
1186// bearer pulls the token out of the Authorization header.
1187func bearer(r *http.Request) string {
1188 h := r.Header.Get("Authorization")
1189 if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") {
1190 return ""
1191 }
1192 return strings.TrimSpace(h[7:])
1193}
1194
1195// cliNotSignedIn is the ONE 401 the CLI surface gives, written once so the
1196// two authenticators cannot drift. cliUser answers it for a person's door and
1197// cliRoute for an API door, and the bodies must be byte-identical: a
1198// differently-phrased 401 over here would make "this bearer is a service
1199// credential at a person's door" distinguishable from "this bearer does not
1200// exist", which is the whole property the person-only doors hold.
1201// Pinned by TestCLIRouteUnauthorizedMatchesCLIUser.
1202func (s *Server) cliNotSignedIn(w http.ResponseWriter) {
1203 jsonError(w, http.StatusUnauthorized, "not signed in — run: carlos auth login --console "+s.cfg.Origin)
1204}
1205
1206// cliUser authenticates a CLI request AS A PERSON. Every failure is the same
1207// 401 with the same body: an expired token, a revoked one, a fabricated one
1208// and a service credential are not distinguishable from outside.
1209func (s *Server) cliUser(w http.ResponseWriter, r *http.Request) (string, bool) {
1210 addr, ok := s.store.CLITokenAddress(bearer(r), time.Now())
1211 if !ok {
1212 s.cliNotSignedIn(w)
1213 return "", false
1214 }
1215 return addr, true
1216}
1217
1218// ---- the flow ---------------------------------------------------------------
1219
1220// cleanLabel bounds and de-fangs what a terminal calls itself.
1221//
1222// PRINTABLE ASCII ONLY, which is stricter than it looks like it needs to be.
1223// This is the one field on the approval page a human is asked to recognise —
1224// "is that my terminal?" — and it is chosen by whoever is asking for the
1225// credential. Allowing the full printable Unicode range would allow
1226// right-to-left overrides and zero-width joiners, which exist precisely to
1227// make a string render as something other than what it is. A label is
1228// user@host; nobody needs U+202E in one.
1229func cleanLabel(in string) string {
1230 var b strings.Builder
1231 for _, r := range in {
1232 if r < 0x20 || r > 0x7e {
1233 continue
1234 }
1235 b.WriteRune(r)
1236 if b.Len() >= 64 {
1237 break
1238 }
1239 }
1240 return strings.TrimSpace(b.String())
1241}
1242
1243// handleCLIStart opens a login attempt. Unauthenticated by necessity — the
1244// terminal has no credential yet — which is why it is rate-limited per IP,
1245// mints nothing but a ten-minute row, and grants nothing until a signed-in
1246// human approves it.
1247func (s *Server) handleCLIStart(w http.ResponseWriter, r *http.Request) {
1248 var req struct {
1249 Label string `json:"label"`
1250 }
1251 if !readJSON(w, r, &req) {
1252 return
1253 }
1254 ip := s.clientIP(r)
1255 if !s.cli.start.allow(ip, time.Now()) {
1256 jsonError(w, http.StatusTooManyRequests, "too many login attempts from here — wait a minute and try again")
1257 return
1258 }
1259 userCode, deviceCode, err := s.store.NewCLIAuth(cleanLabel(req.Label), ip, time.Now())
1260 if err != nil {
1261 log.Printf("console: cli auth start: %v", err)
1262 jsonError(w, http.StatusInternalServerError, "could not start the login — try again")
1263 return
1264 }
1265 writeJSON(w, http.StatusOK, map[string]any{
1266 "user_code": userCode,
1267 "device_code": deviceCode,
1268 "verification_uri": s.cfg.Origin + "/auth/cli",
1269 "verification_uri_complete": s.cfg.Origin + "/auth/cli?code=" + url.QueryEscape(userCode),
1270 "interval": cliPollInterval,
1271 "expires_in": int(CLIAuthTTL.Seconds()),
1272 })
1273}
1274
1275// handleCLIPoll is the CLI's waiting room. Every answer is 200 with a status
1276// word — the CLI is a program, and a status it can switch on beats an HTTP
1277// code it has to interpret.
1278func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) {
1279 var req struct {
1280 DeviceCode string `json:"device_code"`
1281 }
1282 if !readJSON(w, r, &req) {
1283 return
1284 }
1285 // Both keys, and the IP one first. The device code is chosen by the
1286 // caller, so a per-code limit alone is no limit at all — every made-up
1287 // code is a fresh budget, and a fresh map entry. The per-code limit still
1288 // earns its place: it stops one honest-but-broken client spinning, without
1289 // two developers behind one NAT throttling each other.
1290 now := time.Now()
1291 if !s.cli.pollIP.allow(s.clientIP(r), now) || !s.cli.poll.allow(hashToken(req.DeviceCode), now) {
1292 jsonError(w, http.StatusTooManyRequests, "polling too fast")
1293 return
1294 }
1295 status, token, addr, err := s.store.PollCLIAuth(req.DeviceCode, time.Now())
1296 if err != nil {
1297 log.Printf("console: cli auth poll: %v", err)
1298 jsonError(w, http.StatusInternalServerError, "could not check the login — try again")
1299 return
1300 }
1301 out := map[string]any{"status": status, "interval": cliPollInterval}
1302 if status == CLIAuthApproved {
1303 out["token"] = token
1304 out["address"] = addr
1305 out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339)
1306 out["console"] = s.cfg.Origin
1307 go s.notifyCLIIssued(addr, token)
1308 }
1309 writeJSON(w, http.StatusOK, out)
1310}
1311
1312// notifyCLIIssued tells the human that a terminal now holds their identity —
1313// naming it, where it dialled from, and how to kill it. Someone who signed in
1314// with Keymail hears in their own Keymail inbox, through their grant; someone
1315// who signed in by email (no grant) hears through deliver: Keymail first if
1316// the address turns out to have a mailbox there, else the console's own email.
1317//
1318// This is the control that makes a phished approval DETECTABLE. Every other
1319// defence in this flow is preventive and therefore fallible; a message that
1320// arrives whether or not the approval was a good idea is what turns "somebody
1321// has been me for two months" into "I revoked that within the hour". Same
1322// channels as invites.
1323//
1324// Off the request goroutine on purpose: a slow Keymail must never stall the
1325// poll the CLI is waiting on. Issuance has already happened and the request row
1326// is burnt, so a CLI that gave up would otherwise leave a token nobody
1327// collected. Best-effort throughout: a notice that cannot be delivered is
1328// logged, never a reason to fail a login.
1329func (s *Server) notifyCLIIssued(address, rawToken string) {
1330 defer func() {
1331 if s.cliNotified != nil {
1332 s.cliNotified <- struct{}{}
1333 }
1334 }()
1335 grant, err := s.store.GrantToken(address)
1336 if err != nil {
1337 return
1338 }
1339 label, from := "", ""
1340 if toks, err := s.store.CLITokens(address); err == nil {
1341 for _, t := range toks {
1342 if t.ID == hashToken(rawToken) {
1343 label, from = t.Label, t.RemoteAddr
1344 }
1345 }
1346 }
1347 if label == "" {
1348 label = "(unnamed terminal)"
1349 }
1350 if from == "" {
1351 from = "an address the console could not determine"
1352 }
1353 subject := "A command line signed in as you on the CARLOS platform"
1354 body := "A terminal was just approved to act as " + address + " on the CARLOS platform.\n\n" +
1355 " terminal: " + label + "\n" +
1356 " from: " + from + "\n" +
1357 " when: " + time.Now().UTC().Format("2006-01-02 15:04 UTC") + "\n\n" +
1358 "It can do anything you can do from the command line, for the next " +
1359 fmt.Sprint(int(CLITokenTTL/(24*time.Hour))) + " days.\n\n" +
1360 "If that was you running `carlos auth login`, nothing to do.\n\n" +
1361 "If it was NOT you — if you clicked a link somebody sent you — revoke it now:\n\n" +
1362 s.cfg.Origin + "/settings\n"
1363 if grant == "" {
1364 // Signed in by email: no grant to send with. Keymail first if the
1365 // address turns out to have a mailbox, then the console's own email.
1366 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1367 defer cancel()
1368 if _, err := s.deliver(ctx, address, subject, body, 12*time.Second); err != nil {
1369 log.Printf("console: could not notify %s of a CLI token issue: %v", address, err)
1370 }
1371 return
1372 }
1373 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1374 defer cancel()
1375 if _, err := s.km.SendApp(ctx, grant, address, subject, body); err != nil {
1376 log.Printf("console: could not notify %s of a CLI token issue: %v", address, err)
1377 }
1378}
1379
1380// cliPage is the approval and tokens pages' data. It embeds page so the
1381// shared chrome ("top"/"bottom") keeps working unchanged.
1382type cliPage struct {
1383 page
1384 Code string // the code the human typed, echoed back into the form
1385 // LinkCode is the code carried by the ?code= in the URL they followed.
1386 // Shown ONLY as something to check against the terminal's screen — never
1387 // prefilled into the form, never resolved. See handleCLIApprovePage.
1388 LinkCode string
1389 Console string // this deployment's origin — the -console the CLI needs
1390 Request *CLIAuthRequest
1391 Tokens []CLIToken
1392 Days int
1393 // ServiceAccounts maps a service credential's account id to that
1394 // account's sqid, so the read-only mirror can say WHERE a row is managed
1395 // and link there. Built here rather than hung on CLIToken because the
1396 // sqid is page chrome, not part of the stored row — and a row whose
1397 // account this console cannot resolve is simply absent from the map,
1398 // which the template renders as "managed on its own account" rather than
1399 // as a broken link.
1400 ServiceAccounts map[int64]string
1401}
1402
1403func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cliPage) {
1404 s.chrome(&p.page)
1405 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1406 w.WriteHeader(status)
1407 if err := templates.ExecuteTemplate(w, name, p); err != nil {
1408 log.Printf("console: render %s: %v", name, err)
1409 }
1410}
1411
1412// handleCLIApprovePage is where the printed link lands. A signed-out visitor
1413// gets the front page, asked to sign in, with next set back here (spec 8). A
1414// code-shaped ?code= rides along in next only so it can be shown again for
1415// comparison; the console never carries a pending approval through an
1416// authentication it has not finished.
1417//
1418// IT DELIBERATELY DOES NOT ACT ON ?code=. Not to resolve it, not to look it
1419// up, and above all not to render an Approve button for it. A prefilled code
1420// that arrives one click from approval is the device-code phishing attack that
1421// has been run at scale against Microsoft tenants: the attacker starts the
1422// flow, sends the victim the ready-made link with a plausible pretext, and one
1423// click binds a 90-day credential — held by the attacker — to the victim's
1424// identity. Requiring the code to be TYPED is what breaks it: to type it you
1425// have to be reading it off a terminal you started, and a code you cannot see
1426// is one you cannot transcribe. The value from the URL is shown only as
1427// something to check against your screen, never as something to submit.
1428func (s *Server) handleCLIApprovePage(w http.ResponseWriter, r *http.Request) {
1429 addr, raw, ok := s.session(r)
1430 if !ok {
1431 next := "/auth/cli"
1432 if c := NormalizeUserCode(r.URL.Query().Get("code")); len(c) == userCodeLen+1 {
1433 next += "?code=" + url.QueryEscape(c)
1434 }
1435 s.renderHome(w, r, homeForm{Next: next, Banner: copyText("home.cli_prompt")})
1436 return
1437 }
1438 p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw},
1439 Days: int(CLITokenTTL / (24 * time.Hour))}
1440 // Echoed for comparison only — no store lookup, which also means this page
1441 // is not an oracle for enumerating live codes.
1442 p.LinkCode = NormalizeUserCode(r.URL.Query().Get("code"))
1443 s.renderCLI(w, http.StatusOK, "cli_approve", p)
1444}
1445
1446// handleCLIApprove is both steps a human takes, distinguished by action:
1447//
1448// continue — a typed code is looked up and the terminal asking is DESCRIBED
1449// (label, where it dialled from, when it expires)
1450// approve — the decision, on a code the human has now both typed and seen
1451// described
1452// deny — "that wasn't me", which is final
1453//
1454// All three are session-gated and CSRF-checked with the double-submitted
1455// session token, exactly like every other mutation here: a cross-site POST
1456// that could approve a terminal would be the whole attack.
1457func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) {
1458 addr, raw, ok := s.session(r)
1459 if !ok {
1460 http.Redirect(w, r, "/", http.StatusFound)
1461 return
1462 }
1463 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1464 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1465 return
1466 }
1467 code := NormalizeUserCode(r.FormValue("code"))
1468 action := r.FormValue("action")
1469 now := time.Now()
1470 p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw},
1471 Code: code, Days: int(CLITokenTTL / (24 * time.Hour))}
1472
1473 // Wrong codes are counted wherever a code is resolved, and against both
1474 // the signed-in address and the client IP. The exposure this bounds is not
1475 // the obvious one: guessing somebody else's PENDING code and approving it
1476 // binds THEIR terminal to YOUR address, which is the inverted-payoff half
1477 // of this flow and the reason the counter has to exist even though ~39
1478 // bits in ten minutes is not findable.
1479 badCode := func() bool {
1480 ip := s.clientIP(r)
1481 okAddr := s.cli.guessAddr.allow(addr, now)
1482 okIP := s.cli.guessIP.allow(ip, now)
1483 return okAddr && okIP
1484 }
1485 tooMany := func() {
1486 s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts",
1487 "Too many bad login codes. Wait a minute, then try again.")
1488 }
1489
1490 switch action {
1491 case "approve", "deny":
1492 // Passkey step-up on APPROVE only (spec §7a, stepup.go). Approving
1493 // mints a 30-day credential that acts as this identity from a machine
1494 // that has never been seen — the single largest thing a stolen session
1495 // cookie could do here, and the reason presence has to be proved rather
1496 // than inferred. Denying is left ungated on purpose: "that wasn't me"
1497 // is the panic button, it only ever destroys a pending request, and
1498 // putting an authenticator prompt in front of it would be friction on
1499 // exactly the path we want people to take without hesitating.
1500 if action == "approve" && !s.requireFreshAuth(w, r, addr, raw) {
1501 return
1502 }
1503 var err error
1504 if action == "deny" {
1505 err = s.store.DenyCLIAuth(code, now)
1506 } else {
1507 err = s.store.ApproveCLIAuth(code, addr, now)
1508 }
1509 if err != nil {
1510 if !badCode() {
1511 tooMany()
1512 return
1513 }
1514 p.Error = "That code has expired or was already used. Run carlos auth login again for a fresh one."
1515 s.renderCLI(w, http.StatusOK, "cli_approve", p)
1516 return
1517 }
1518 p.Title = "Approved"
1519 if action == "deny" {
1520 p.Title = "Denied"
1521 }
1522 s.renderCLI(w, http.StatusOK, "cli_decided", p)
1523 default:
1524 // "continue": describe the terminal, then let the human decide.
1525 req, err := s.store.CLIAuthByUserCode(code, now)
1526 if err != nil {
1527 if !badCode() {
1528 tooMany()
1529 return
1530 }
1531 p.Error = "No login is waiting on that code — check what your terminal printed. " +
1532 "Codes last ten minutes."
1533 s.renderCLI(w, http.StatusOK, "cli_approve", p)
1534 return
1535 }
1536 p.Request = req
1537 s.renderCLI(w, http.StatusOK, "cli_approve", p)
1538 }
1539}
1540
1541// serviceAccountSqids resolves the accounts the viewer's SERVICE credentials
1542// are bound to, for the read-only mirror's "lives on account <sqid> — manage
1543// there" link.
1544//
1545// Service rows only: an app token keeps its own Revoke button on this page
1546// (it always has had one, and its minter is not necessarily an account
1547// owner), so it needs no pointer elsewhere. A lookup that fails is left out
1548// of the map rather than filled with a guess — a link to /a//settings is
1549// worse than plain words.
1550func (s *Server) serviceAccountSqids(toks []CLIToken) map[int64]string {
1551 out := map[int64]string{}
1552 for _, t := range toks {
1553 if !t.IsService() || t.AccountID == 0 {
1554 continue
1555 }
1556 if _, done := out[t.AccountID]; done {
1557 continue
1558 }
1559 acct, err := s.store.AccountByID(t.AccountID)
1560 if err != nil || acct == nil {
1561 log.Printf("console: settings mirror: account %d: %v", t.AccountID, err)
1562 continue
1563 }
1564 out[t.AccountID] = acct.Sqid
1565 }
1566 return out
1567}
1568
1569// handleCLITokens lists the signed-in user's own CLI credentials. Per USER,
1570// not per account: a token is an identity, and it carries whatever accounts
1571// that identity belongs to — so the person who can kill it is the person it
1572// speaks as.
1573//
1574// SERVICE credentials the viewer minted stay listed here, READ-ONLY. They are
1575// managed on their account's own pane (svcpage.go), where the owner gate and
1576// the audit trail live — but absorbing them into that listing must not make
1577// them vanish from a page people already know, so the row stays and points
1578// there instead of carrying a button.
1579func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) {
1580 addr, raw, ok := s.session(r)
1581 if !ok {
1582 http.Redirect(w, r, "/", http.StatusFound)
1583 return
1584 }
1585 toks, err := s.store.CLITokens(addr)
1586 if err != nil {
1587 log.Printf("console: cli tokens: %v", err)
1588 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your terminals. Please try again.")
1589 return
1590 }
1591 p := cliPage{page: page{Title: "Your settings", Address: addr, CSRF: raw},
1592 Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour)),
1593 ServiceAccounts: s.serviceAccountSqids(toks)}
1594 p.Flash, p.FlashErr = s.takeFlash(w, r)
1595 s.renderCLI(w, http.StatusOK, "settings", p)
1596}
1597
1598// serviceRowOf finds a live SERVICE credential of this address's by listing
1599// id, for handleCLITokenRevoke's refusal below. It answers three ways —
1600// found, not-a-service, and COULD NOT TELL — because the third is not the
1601// second.
1602//
1603// ok=false with err!=nil means the read failed, and the caller FAILS CLOSED
1604// on it. That is the asymmetry of the two mistakes: refusing a person's own
1605// terminal revoke for as long as a database hiccup lasts is annoying and
1606// retryable, while letting an unreadable row through routes a service
1607// credential into RevokeCLIToken's hard DELETE, which is neither.
1608func (s *Server) serviceRowOf(addr, id string) (tok CLIToken, isService bool, err error) {
1609 if id == "" {
1610 return CLIToken{}, false, nil
1611 }
1612 toks, err := s.store.CLITokens(addr)
1613 if err != nil {
1614 return CLIToken{}, false, err
1615 }
1616 for _, t := range toks {
1617 if t.ID == id {
1618 return t, t.IsService(), nil
1619 }
1620 }
1621 return CLIToken{}, false, nil
1622}
1623
1624func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) {
1625 addr, raw, ok := s.session(r)
1626 if !ok {
1627 http.Redirect(w, r, "/", http.StatusFound)
1628 return
1629 }
1630 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1631 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1632 return
1633 }
1634 // C49: a SERVICE credential is not revocable here, and withholding the
1635 // button from the template was never the control — this door accepts any
1636 // listing id whose row carries the viewer's address, and a service
1637 // credential's `address` column IS its minter.
1638 //
1639 // Three things make that the wrong door rather than merely a second one.
1640 // RevokeCLIToken is a hard DELETE where the account pane writes the
1641 // revoked_at TOMBSTONE the store's design depends on (CLIPrincipal goes
1642 // on refusing the exact secret; a later mint of the same name gets a NEW
1643 // row rather than resurrecting this one's identity). It writes no ACCOUNT
1644 // audit row, so the feed an owner reads would say nothing — which would
1645 // hollow out the very assurance the pane's missing revoke-confirmation
1646 // leans on (see handleServiceRevokePage, C48). And it is scoped to the
1647 // MINTER, so it outlives ownership: mint a credential, be transferred out
1648 // of the account, and this door would still let you stop a production
1649 // service you no longer have any authority over.
1650 //
1651 // Refused BEFORE requireFreshAuth: no gesture could make this the right
1652 // door, so asking for one would waste it.
1653 //
1654 // SCOPED TO SERVICE ROWS ONLY. A legacy kind='app' row keeps working here
1655 // (TestPersonalSettingsStillRevokesALegacyAppToken pins it) — its minter
1656 // is not necessarily an account owner, so this may be the only door they
1657 // have. The same hard-DELETE-and-no-audit inconsistency is true of it and
1658 // predates this branch; widening the refusal would take away a working
1659 // control to fix nothing here.
1660 //
1661 // But only while the minter is still a MEMBER of the token's account
1662 // (discussion #219, item 3). The app token speaks for that account's app,
1663 // so revoking it stops that app's service; somebody removed from the
1664 // account has no authority over it any more, and having minted it once
1665 // does not keep any.
1666 svc, isService, err := s.serviceRowOf(addr, r.FormValue("id"))
1667 switch {
1668 case err != nil:
1669 log.Printf("console: cli token revoke: classify %s: %v", addr, err)
1670 s.setFlash(w, "err", "Could not check what that credential is, so nothing was revoked. Try again.")
1671 http.Redirect(w, r, "/settings", http.StatusSeeOther)
1672 return
1673 case isService:
1674 where := "its account's settings page"
1675 if acct, err := s.store.AccountByID(svc.AccountID); err == nil && acct != nil {
1676 where = "/a/" + acct.Sqid + "/settings"
1677 }
1678 s.setFlash(w, "err", "“"+svc.Name+"” is a service credential, not a terminal of yours — it "+
1679 "belongs to its account, and is revoked from "+where+", where the revocation is "+
1680 "recorded in that account's activity. Nothing was revoked.")
1681 http.Redirect(w, r, "/settings", http.StatusSeeOther)
1682 return
1683 case svc.IsApp():
1684 // Fails closed on a read error, the same direction as the classify
1685 // above, and is refused before the passkey for the same reason.
1686 role, rerr := s.store.Role(svc.AccountID, addr)
1687 if rerr != nil {
1688 log.Printf("console: cli token revoke: membership of %s in %d: %v", addr, svc.AccountID, rerr)
1689 s.setFlash(w, "err", "Could not check your membership, so nothing was revoked. Try again.")
1690 http.Redirect(w, r, "/settings", http.StatusSeeOther)
1691 return
1692 }
1693 if role == "" {
1694 s.setFlash(w, "err", "That credential belongs to an account you are no longer a member of, "+
1695 "so it is not yours to revoke. Nothing was revoked.")
1696 http.Redirect(w, r, "/settings", http.StatusSeeOther)
1697 return
1698 }
1699 }
1700 // Step-up (spec §7a): killing a terminal is a security action, and the
1701 // person doing it should be the person whose terminals they are. An
1702 // attacker with a stolen cookie revoking the real owner's credentials is a
1703 // lockout, and the freshness check is what stops it being free.
1704 if !s.requireFreshAuth(w, r, addr, raw) {
1705 return
1706 }
1707 // Scoped to the signed-in address inside the store call: an id belonging
1708 // to somebody else simply is not found.
1709 //
1710 // A legacy app token's DELETE is admitted for its app (the claim gate), so
1711 // it runs under the request context; a refused or failed admission is not
1712 // "already gone", and the row is still live.
1713 if err := s.store.RevokeCLITokenContext(r.Context(), addr, r.FormValue("id")); err != nil {
1714 if s.cfg.HostClaim && !errors.Is(err, errNoSuchCLIToken) {
1715 log.Printf("console: cli token revoke %s: %v", addr, err)
1716 s.setFlash(w, "err", "Could not revoke that credential, so it may still be live. Try again.")
1717 } else {
1718 s.setFlash(w, "err", "That terminal is already gone.")
1719 }
1720 } else {
1721 s.setFlash(w, "ok", "Revoked — that terminal can no longer act as you.")
1722 }
1723 http.Redirect(w, r, "/settings", http.StatusSeeOther)
1724}
1725
1726// handleCLIWhoami answers `carlos auth whoami`: who this token speaks as, and
1727// until when. It is also the liveness check every verb that acts on the
1728// token's identity makes before acting.
1729func (s *Server) handleCLIWhoami(w http.ResponseWriter, r *http.Request) {
1730 addr, ok := s.cliUser(w, r)
1731 if !ok {
1732 return
1733 }
1734 out := map[string]any{"address": addr, "console": s.cfg.Origin}
1735 if toks, err := s.store.CLITokens(addr); err == nil {
1736 hash := hashToken(bearer(r))
1737 for _, t := range toks {
1738 if t.ID == hash {
1739 out["label"] = t.Label
1740 out["expires_at"] = t.ExpiresAt.Format(time.RFC3339)
1741 }
1742 }
1743 }
1744 writeJSON(w, http.StatusOK, out)
1745}
1746
1747// handleCLIRevoke is `carlos auth logout`: retire this credential rather than
1748// merely forgetting it locally.
1749func (s *Server) handleCLIRevoke(w http.ResponseWriter, r *http.Request) {
1750 if _, ok := s.cliUser(w, r); !ok {
1751 return
1752 }
1753 if err := s.store.RevokeCLITokenValueContext(r.Context(), bearer(r)); err != nil {
1754 log.Printf("console: cli revoke: %v", err)
1755 jsonError(w, http.StatusInternalServerError, "could not revoke the token")
1756 return
1757 }
1758 writeJSON(w, http.StatusOK, map[string]any{"revoked": true})
1759}
1760
1761// handleCLINewAccount is the point of the whole feature: `carlos accounts
1762// create` from a logged-in CLI mints the account THROUGH the console, so the
1763// human who ran it is its owner from the first instant.
1764//
1765// The alternative — the CLI minting straight into the bucket and hoping
1766// something later notices who owns it — is exactly what produced accounts
1767// nobody could open in the console. The console already has mintAccount: the
1768// bucket manifest and the owner membership in one path.
1769func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) {
1770 addr, ok := s.cliUser(w, r)
1771 if !ok {
1772 return
1773 }
1774 if !s.cli.write.allow(addr, time.Now()) {
1775 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
1776 return
1777 }
1778 var req struct {
1779 Name string `json:"name"`
1780 }
1781 if !readJSON(w, r, &req) {
1782 return
1783 }
1784 name := strings.TrimSpace(req.Name)
1785 if !domains.ValidAccountName(name) {
1786 jsonError(w, http.StatusBadRequest,
1787 "an account name appears in URLs and --account: 1-63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen")
1788 return
1789 }
1790 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
1791 defer cancel()
1792 // Plans 3a: one free account per person (spec §8.1).
1793 defer s.planPersonLock(addr)()
1794 if err := s.checkNewFreeAccount(addr); err != nil {
1795 if isPlanCap(err) {
1796 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
1797 return
1798 }
1799 log.Printf("console: cli mint account for %s: plan cap: %v", addr, err)
1800 jsonError(w, http.StatusBadGateway, "could not create the account — try again")
1801 return
1802 }
1803 a, err := s.mintAccount(ctx, name, addr)
1804 if err != nil {
1805 // Logged, not returned: every other failure in this console answers a
1806 // caller with a fixed sentence and keeps the detail on the box. A
1807 // mint failure is a bucket error, and bucket errors carry key names,
1808 // endpoints and occasionally credentials-adjacent text.
1809 log.Printf("console: cli mint account for %s: %v", addr, err)
1810 jsonError(w, http.StatusBadGateway, "could not create the account — try again")
1811 return
1812 }
1813 writeJSON(w, http.StatusOK, map[string]any{
1814 "id": a.ID, "sqid": a.Sqid, "name": a.Name, "owner": addr,
1815 "url": s.cfg.Origin + "/a/" + url.PathEscape(a.Sqid) + "/",
1816 })
1817}
1818