// The CLI's config API: `carlos env` and `carlos secrets` reading and // writing an app's config bundles THROUGH the console, so "give Vicky deploy // access" stops meaning "give Vicky the deployment bucket's credentials the // moment her app needs a secret" (issue #43). Three doors, all behind the // same apiAccountApp gate as ship/promote — an unknown account, an unknown // app and someone else's account are one byte-identical 404. // // WHAT THE CONSOLE LEARNS BY RELAYING THIS: nothing it could not already // see. Plain env vars it stores and serves in the clear, exactly as the // bucket does. SECRETS arrive already sealed — `carlos secrets set` seals // against CARLOS_SECRETS_PUBKEY on the operator's own machine — so what // crosses this door is an opaque envelope, strictly less sensitive than the // release artifacts the ship door already accepts. Only a box holding // CARLOS_SECRETS_KEY can open one, and the console holds no such key by // design. That is why a sealed envelope may pass through here at all, and // why an envelope must never come back OUT: the GET door has no field for // one (see handleAPIConfigGet), and a "secret" var carrying a plaintext // `value` is refused rather than quietly stripped. // // There is NO signing-key check on any door here, unlike promote/rollback: // config manifests aren't signed. Nothing in the fleet executes because a // manifest says so (store.go's package comment), so a read-only console — // one running without CARLOS_RELEASE_KEY — still serves config in full. // // Spec: docs/superpowers/specs/2026-08-04-config-console-api-design.md §2. package console import ( "context" "errors" "fmt" "log" "net/http" "sort" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/release" ) // configStoreTimeout bounds every store round trip these handlers make — the // same 15s its siblings in apicli.go and apiledger.go use, for the same // reason: a wedged bucket must cost one request 15 seconds, not a console // goroutine forever. Each handler derives its context from the request's, so // a client that hangs up still cancels immediately; this only adds a ceiling. const configStoreTimeout = 15 * time.Second // configPutMaxBytes caps the PUT door's body — its own cap, not readJSON's // 8KiB, because sealed envelopes make config writes legitimately larger than // every other CLI JSON body: an envelope runs ~4/3 the size of the plaintext // it seals, so one long base64 credential can pass 8KiB alone, and a bulk // import of a few dozen is routine. 256KiB keeps three orders of magnitude // of headroom over that while staying nothing like an upload — the ship door // (256MiB) is where real bytes belong. const configPutMaxBytes = 256 << 10 // configWriteAllowed is the write budget both MUTATING doors pass, via // allowWrite (svcprincipal.go), the same picker every other write door here // uses. // // A person is keyed on the ADDRESS, not the token: the principal a write // budget belongs to is the PERSON, not the terminal they happen to be // typing on. Keying by token instead would give someone with several live // tokens an independent budget per token, which is the limit doing less // than it should, not more. A service is keyed on its NAME, its own budget // separate from the operator who minted it (issue #220). // // The GET door is deliberately unlimited, matching its read-only siblings: // an exhausted write budget must not blind a CLI to what it already wrote. func (s *Server) configWriteAllowed(w http.ResponseWriter, p Principal) bool { if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return false } return true } // configBundleName is how a bundle is named back to a human — the same // vocabulary appconfig.DefaultBundle and the CLI's own `list` header use, so // an audit row, an error and a response can never disagree about what "" is. func configBundleName(envName string) string { if envName == "" { return appconfig.DefaultBundle } return envName } // validAPIEnvironment refuses a bad environment name — from the GET door's // query parameter or the PUT door's body — before it can reach the store, // answering 400 itself and reporting whether the caller may proceed. // // "" is not a refusal: it is how both doors say "the default bundle". But // "default" IS refused, and with its own sentence, because it already NAMES // that bundle (config.json) — accepting it would either error confusingly // deep in appconfig or, worse, read and write a second bundle that merely // looks like the default one. Same rule the CLI enforces client-side // (checkEnvironmentFlag, appconfig_cli.go); enforced here too, because a // door must not depend on its client's manners. // // The name is NOT echoed back. There is exactly one of them per request, so // naming it buys a caller nothing they don't already know, and the shape // rule is the actionable half. func validAPIEnvironment(w http.ResponseWriter, name string) bool { if name == "" { return true } if name == appconfig.DefaultBundle { jsonError(w, http.StatusBadRequest, `"default" already names the base bundle (config.json) that every route gets when no `+ `environment is bound — omit the environment to work on it directly, don't name it`) return false } if !appconfig.ValidEnvironmentName(name) { jsonError(w, http.StatusBadRequest, "invalid environment name — want [a-z0-9-]{1,32}") return false } return true } // configNothingToRemove reports whether err is appconfig's refusal to create // a bundle for a call that only REMOVES things — `nothing to unset` from // applyAt, or `nothing to turn off` from SetExecDelivery. A permanent // conflict a retry can never fix, so it earns a 409 rather than the // retry-flavored 502 every genuine store failure gets. // // A STRING match, like shipImmutableConflict's (apicli.go), and fragile in // exactly the same way: appconfig exposes no sentinel or typed error for // this case, so the literal wording of those two fmt.Errorf calls is the // only signal available (errors.Is/As have nothing to grab onto). If either // wording changes in appconfig, this silently stops firing and the refusal // falls back to the 502 below — worth revisiting if appconfig ever grows a // proper sentinel for it. // // No other error from these paths can match: the store's own failures are // IO/HTTP text, and appconfig's other refusals ("invalid account", "invalid // var name", the schema check) share no wording with either phrase. func configNothingToRemove(err error) bool { msg := err.Error() return strings.Contains(msg, "nothing to unset") || strings.Contains(msg, "nothing to turn off") } // configStoreError answers a config write that failed, mapping the three // outcomes a caller can act on differently and describing none of them in // bucket terms — the store's own error text (bucket names, key paths, AWS // request ids) goes to the log, where the operator who can act on it will // see it, and never to the client. func (s *Server) configStoreError(w http.ResponseWriter, what string, err error) { log.Printf("console: %s: %v", what, err) if errors.Is(err, release.ErrCASConflict) { // Someone else wrote this bundle between our read and our write. // Retry-worthy, and the operator has to re-read first — appconfig // deliberately has no automatic retry (see apply's doc comment). jsonError(w, http.StatusConflict, "config changed concurrently — try again") return } if configNothingToRemove(err) { // appconfig's own sentence, verbatim: it names the app and the // bundle, which is exactly what the operator needs and carries // nothing they didn't send. jsonError(w, http.StatusConflict, err.Error()) return } jsonError(w, http.StatusBadGateway, "could not store the config — try again") } // handleAPIConfigGet answers `carlos env list` / `carlos secrets list` / // `carlos env environments`: one bundle's vars, the app's environment names, // and (when this console serves one) the public key a client seals with. // // A SECRET'S ENVELOPE IS STRUCTURALLY ABSENT from the answer, not blanked: // the row for a secret carries only `key` and `kind`, so there is no field // for ciphertext to occupy even if a future edit forgot why. Only "env" // vars get a `value` — fail-closed, so a var whose kind is neither (a // hand-edited manifest, a future kind this binary predates) reveals nothing. // // An app that has never been configured answers as an empty bundle rather // than a 404 — appconfig.Get's own "never configured is not an error" // convention, which `list` needs since it has nothing to print either way. // The same is true of a NAMED bundle: appconfig.LoadEnvironment returns an // empty manifest and a nil error when the object is absent (environments.go), // so there is no not-found case to translate here — a bundle you haven't // created yet lists empty. func (s *Server) handleAPIConfigGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } envName := strings.TrimSpace(r.URL.Query().Get("environment")) if !validAPIEnvironment(w, envName) { return } ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) defer cancel() var m *appconfig.Manifest var err error if envName == "" { m, err = appconfig.Get(ctx, s.cfg.Releases, acct.Sqid, app) } else { m, err = appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, envName) } if err != nil { log.Printf("console: api config get %s/%s (%s): %v", acct.Sqid, app, configBundleName(envName), err) jsonError(w, http.StatusBadGateway, "could not read the config — try again") return } // Always present, possibly empty: it powers `carlos env environments` // without a fourth door, and a client should read the key rather than // test for its absence. envs, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api config environments %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the config — try again") return } if envs == nil { envs = []string{} } keys := make([]string, 0, len(m.Vars)) for k := range m.Vars { keys = append(keys, k) } sort.Strings(keys) vars := make([]map[string]any, 0, len(keys)) for _, k := range keys { v := m.Vars[k] row := map[string]any{"key": k, "kind": v.Kind} if v.Kind == "env" { row["value"] = v.Value } vars = append(vars, row) } out := map[string]any{ "bundle": configBundleName(envName), "environments": envs, "vars": vars, } // exec_delivery is emitted for whichever bundle was asked for: since // issue #195 it can be set in a named one, where it means "the TCP-addr // exec routes bound to this environment get files". Omitted when the // bundle is silent on it, rather than reported as the value it // INHERITS — the answer describes this bundle, and an inherited value // would read as a statement the bundle does not make. if m.ExecDelivery != "" { out["exec_delivery"] = m.ExecDelivery } // Public material by design — it only encrypts. Serving it is what makes // `carlos auth login` sufficient for `carlos secrets set`; omitted when // this deployment configured none, so the CLI can tell "no key here" // from "empty key" and fall back to its own env var. if s.cfg.SecretsPub != "" { out["secrets_pubkey"] = s.cfg.SecretsPub } // The account a secret for this app is bound to (appconfig.Binding), as // the sqid this console resolved the request to — the CLI may only know // the account by name. Present ONLY while this deployment writes bound // (v2) envelopes (CONSOLE_SECRETS_ENVELOPE_V2): its presence is how the // CLI learns to seal v2, and absent — this console with the switch off, // or an older console — it seals v1, which every box can open // (discussion #220). if appconfig.BoundSealing() { out["secrets_account"] = acct.Sqid } writeJSON(w, http.StatusOK, out) } // handleAPIConfigPut answers `carlos env set/unset`, `carlos secrets // set/unset` and `carlos env exec-delivery`: one bundle, one request, one CAS // write for the set/unset half (appconfig.Apply) — two would mean a // half-applied request whenever the second lost a race. // // EVERY REFUSAL RUNS BEFORE THE FIRST STORE CALL. That is not tidiness: a // request carrying one good var and one malformed one must leave the bundle // exactly as it found it, or an operator retrying a corrected request would // be reasoning about a bundle that already half-moved. (The invariant is // about REFUSALS, not store failures: a request carrying both a set/unset // half and exec_delivery is two CAS writes — see the comment at the second // one — so a store failure between them can land the first half alone. The // CLI never sends both halves in one request.) It also means a // garbage envelope is caught HERE, while the person who pasted it is still // looking at their terminal — accepted now, it would stall the app's whole // config at render time, hours later, on every host, out of sight // (appconfig.CheckEnvelope's doc comment). // // NOTHING IN AN ERROR, A LOG LINE OR AN AUDIT ROW CARRIES CONTENT. Var NAMES // do appear in 400s — a caller who sent five vars has to learn which one was // refused, and the names are not secret (the secrets list shows them). Values // and envelopes never do. func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.configWriteAllowed(w, p) { return } // No s.cfg.Key check, unlike promote/rollback: config manifests are not // signed, so a console with no signing key writes them just as well. var req struct { Environment string `json:"environment"` Message string `json:"message"` Set map[string]struct { Kind string `json:"kind"` Value string `json:"value"` Envelope string `json:"envelope"` } `json:"set"` Unset []string `json:"unset"` // A POINTER, so an absent field ("leave it alone") is // distinguishable from a present empty one. The wire vocabulary is // "file"/"off" rather than "file"/"": a client that forgot to set // the field would otherwise be indistinguishable from one asking to // turn the flag OFF. ExecDelivery *string `json:"exec_delivery"` } if !readJSONLimit(w, r, &req, configPutMaxBytes) { return } message, ok := checkedActivityMessage(w, req.Message) if !ok { return } if len(req.Set) == 0 && len(req.Unset) == 0 && req.ExecDelivery == nil { jsonError(w, http.StatusBadRequest, "nothing to do — send set, unset or exec_delivery") return } envName := strings.TrimSpace(req.Environment) if !validAPIEnvironment(w, envName) { return } // Sorted, so a request with several defects always refuses on the same // one — a map's own iteration order would make the error a caller sees // depend on nothing they can reason about. keys := make([]string, 0, len(req.Set)) for k := range req.Set { keys = append(keys, k) } sort.Strings(keys) sets := make(map[string]appconfig.Var, len(req.Set)) for _, k := range keys { v := req.Set[k] if !appconfig.ValidVarName(k) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid var name %q — want a letter or underscore, then letters, digits or underscores", k)) return } switch v.Kind { case "env": // The same check applyAt makes, made earlier: here the operator // can still see the plaintext and fix it. if err := appconfig.ValidVarValue(v.Value); err != nil { jsonError(w, http.StatusBadRequest, fmt.Sprintf("var %s: %v", k, err)) return } if v.Envelope != "" { jsonError(w, http.StatusBadRequest, fmt.Sprintf(`var %s is kind "env" but carries an envelope — send it as kind "secret", or drop the envelope`, k)) return } // Built field by field, never copied wholesale from the wire // shape: an "env" var cannot smuggle an envelope into the bucket // even if the check above were ever loosened. sets[k] = appconfig.Var{Kind: "env", Value: v.Value} case "secret": if err := appconfig.CheckEnvelope(v.Envelope); err != nil { // CheckEnvelope's errors name the defect and never echo the // envelope — deliberately, because they end up here. jsonError(w, http.StatusBadRequest, fmt.Sprintf("var %s: %v", k, err)) return } if v.Value != "" { // Refused, not silently dropped: a caller who sent plaintext // to a door that stores ciphertext has to LEARN that their // secret crossed the wire in the clear, so they can rotate // it. The refusal never echoes the value it is refusing. jsonError(w, http.StatusBadRequest, fmt.Sprintf(`var %s is kind "secret" but carries a plaintext value — seal it client-side and send only the envelope`, k)) return } sets[k] = appconfig.Var{Kind: "secret", Envelope: v.Envelope} default: jsonError(w, http.StatusBadRequest, fmt.Sprintf(`var %s: invalid kind %q — want "env" or "secret"`, k, v.Kind)) return } } // Unset keys are deliberately NOT name-checked: a name that could never // be SET can never be present to remove, so refusing one would only turn // a harmless no-op into an error. Same division appconfig.applyAt makes. execDelivery := "" if req.ExecDelivery != nil { // The wire's clearing word is the SCOPE's own, and the two are not // interchangeable (issue #195): clearing the default bundle's flag // stops delivery, while clearing a named bundle's hands the // question back to the default. Accepting "off" for a named bundle // would let a caller ask to stop delivery and get the opposite // whenever the app default is on. Both refusals are worded here, // once, so the CLI and any other client read the same sentence // rather than a paraphrase of it. switch *req.ExecDelivery { case appconfig.ExecDeliveryFile: execDelivery = appconfig.ExecDeliveryFile case "off": if envName != "" { jsonError(w, http.StatusBadRequest, "exec_delivery \"off\" is app-level — in a named environment, clearing the flag means \"inherit\" "+ "(routes bound to it fall back to the app's default bundle), which is not the same as turning "+ "delivery off. Send \"inherit\" for this bundle, or send \"off\" with no environment to turn "+ "the app's default off.") return } // The wire's "off" is appconfig's "" — the vocabulary changes at // this boundary and nowhere else. execDelivery = "" case "inherit": if envName == "" { jsonError(w, http.StatusBadRequest, "exec_delivery \"inherit\" needs an environment — the default bundle has nothing above it to "+ "inherit from. Send \"off\" to clear it.") return } execDelivery = "" default: want := `"off"` if envName != "" { want = `"inherit"` } jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid exec_delivery %q — want %q or %s", *req.ExecDelivery, appconfig.ExecDeliveryFile, want)) return } } ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) defer cancel() bundle := configBundleName(envName) removed := 0 // Skipped entirely for an exec_delivery-only request: Apply with neither // sets nor unsets against an app with no bundle is appconfig's // unset-only refusal (applyAt), and asking to set a flag is not asking // to remove anything. if len(sets) > 0 || len(req.Unset) > 0 { var err error if envName == "" { removed, err = appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, sets, req.Unset) } else { removed, err = appconfig.ApplyEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, envName, sets, req.Unset) } if err != nil { s.configStoreError(w, fmt.Sprintf("api config apply %s/%s (%s)", acct.Sqid, app, bundle), err) return } // Counts and the bundle, never keys and never values — an audit trail // that recorded a secret's envelope would be a copy of the secret // store in a database with different reach. The unset count is what // was ACTUALLY removed, matching the wire's own `removed` (2026-08-02 // review, MINOR 9): a row claiming three removals when nothing was // there would mislead exactly the person reading rows to find out // what happened. s.auditActivity(acct, app, addr, Activity{ Action: "config", TargetType: "environment", Target: bundle, Message: message, Detail: fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle), }) // Box-consumed (carlos-deploy §3): the fast loop's config-sync leg // renders this bundle onto every route that references it. Announced // per successful write rather than once at the end, for the same // reason the audit row is: the two writes below are separate CAS // operations, and if the second fails the first has still landed and // must still reach the boxes. s.bumpSerial(ctx) } if req.ExecDelivery != nil { // A SECOND CAS write, and deliberately not folded into the one // above: it writes one FIELD of the bundle through a different // setter, where the block above writes vars. The two cannot be one // transaction, so the audit row for the set/unset half is written // above, before this runs — if this fails, the trail still says // truthfully what landed. if err := appconfig.SetExecDeliveryAt(ctx, s.cfg.Releases, acct.Sqid, app, envName, execDelivery); err != nil { s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s (%s)", acct.Sqid, app, bundle), err) return } // The caller's own word, not one reconstructed from the stored // value: "off" and "inherit" both store "", and a trail that // recorded them identically would lose which scope was acted on. s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+*req.ExecDelivery+" ("+bundle+") (cli)") s.bumpSerial(ctx) } out := map[string]any{"result": "ok", "removed": removed} // Whether this write landed on a process that will never read it // (discussion #16). Computed AFTER both writes, because it describes // what they left behind, and only when something actually changed: an // unset of keys that were not there removed nothing and moved no file, // so there is nothing for anyone to restart over. if len(sets) > 0 || removed > 0 || req.ExecDelivery != nil { out["restart"] = s.configRestartNotice(ctx, acct.Sqid, app, envName, writtenKeys(sets, req.Unset)) } writeJSON(w, http.StatusOK, out) } // apiConfigRestart is the config door's answer to "did that reach // anything?" — the wire half of appconfig.AwaitingRestart. // // Known is the field that carries the whole point. A console with no // registry, or one whose registry will not open, has not looked and must // not answer with an empty list: "nothing needs restarting" and "I could // not tell" rendering identically is the failure discussion #16 reported, // and answering it with a second instance of itself would be worse than // saying nothing. The CLI renders the two differently, and a client that // predates the field sees no `restart` key at all — see apimode.go, where // the field is a pointer for exactly that third state. type apiConfigRestart struct { Known bool `json:"known"` // Hosts got a file that nothing will bounce. Unit, when set, is the // systemd unit that owns the process — the difference between an // operator who can be pointed at a command and one who has to reach // for their own supervisor. Hosts []apiConfigRestartHost `json:"hosts,omitempty"` // Unresolved got neither a yes nor a no: their bundle would not read, // or the bundle they are bound to is missing. Unresolved []string `json:"unresolved,omitempty"` } type apiConfigRestartHost struct { Host string `json:"host"` Unit string `json:"unit,omitempty"` } // configRestartNotice resolves the notice for one config write, or reports // that it could not. // // SCOPE: the console reads its OWN box's registry, so in a multi-box fleet // the other boxes' routes are not represented here — the same blind spot // handleAPIRoutes documents at the top of apiroutes.go, and internal/ // fleetapi (#145) is the eventual fix. The CLI says what it looked at // rather than letting a short list read as a complete one. func (s *Server) configRestartNotice(ctx context.Context, acct, app, envName string, keys []string) apiConfigRestart { if s.cfg.Registry == "" { // Opening "" would hand sqlite a throwaway temp DB and answer // "no routes" having created a file to say it — handleAPIRoutes' // twin guard, and the same reasoning. return apiConfigRestart{} } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: api config: registry %s: %v", s.cfg.Registry, err) return apiConfigRestart{} } routes, err := reg.List() if err != nil { log.Printf("console: api config: registry list: %v", err) return apiConfigRestart{} } p := appconfig.AwaitingRestart(ctx, s.cfg.Releases, routes, acct, app, envName, keys) out := apiConfigRestart{Known: true, Unresolved: p.Unresolved} for _, h := range p.Hosts { out.Hosts = append(out.Hosts, apiConfigRestartHost{Host: h.Host, Unit: h.Unit}) } return out } // writtenKeys is the var names one config write touched, set and unset // together — what AwaitingRestart needs to tell whether a default-bundle // write can have changed anything a BOUND route resolves to. Sets and // unsets are one list because the shadowing question does not care which // it was: a key the route's own bundle states is a key this write did not // move for that route, whichever way it moved elsewhere. func writtenKeys(sets map[string]appconfig.Var, unset []string) []string { out := make([]string, 0, len(sets)+len(unset)) for k := range sets { out = append(out, k) } return append(out, unset...) } // handleAPIConfigSync answers `carlos env sync`: touch the app's generation // object so the agent's fast loop picks the CURRENT bucket contents up within // seconds, instead of waiting out the hourly configSyncLoop (issue #44). The // escape hatch for "apply what's already there, now" — and the alternative it // replaces is restarting the box's shared TLS terminator over SSM to deliver // one app's variable. // // Audited, unlike a read: this makes exec-backed instances converge and // bounce, which an operator reading audit rows must be able to see. // // No body is read. There is nothing to say beyond the app already named in // the path, and a bearer request carries no cookie for a cross-site form to // forge in the first place. An app with no config at all is not refused: the // touch is a signal, and SyncExec resolves what (if anything) that app's // routes actually need — a generation nobody's config references costs one // small read on the next tick. func (s *Server) handleAPIConfigSync(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.configWriteAllowed(w, p) { return } ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) defer cancel() if err := appconfig.TouchGeneration(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { log.Printf("console: api config sync %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not request the sync — try again") return } s.audit(acct, app, addr, "config-sync", "requested config sync (cli)") // This door exists to make the fleet converge NOW; the generation touch // without the announcement would leave it waiting on the hourly loop it // was written to skip. s.bumpSerial(ctx) writeJSON(w, http.StatusOK, map[string]any{"result": "requested"}) }