carlos / platform Public

console: CLI config doors — env/secrets/sync through the same gate as ship (issue #43)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVANvB83eHrRANRpEL8mn
Paul Campbell 3bad507327e82a552989c2d936a4b4193d9b5660 parent 5c6606e
5 files changed, +1434 −4
  • cmd/console/main.go +28 −1
  • internal/console/apiconfig.go +468 −0
  • internal/console/apiconfig_test.go +904 −0
  • internal/console/authcli.go +27 −3
  • internal/console/server.go +7 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 6956dbc51..c2686b9a2 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -31,7 +31,9 @@
// https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db),
// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY,
// and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the
-// console; unset = read-only). grant/revoke/owners need only CONSOLE_DB.
+// console; unset = read-only) and CARLOS_SECRETS_PUBKEY (SERVED to CLI
+// clients so `carlos secrets set` can seal with a login alone; never used
+// here). grant/revoke/owners need only CONSOLE_DB.
package main
import (
@@ -45,6 +47,7 @@ import (
"sort"
"strings"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/console"
"github.com/carlosframework/platform/internal/release"
)
@@ -223,6 +226,7 @@ func serve() {
ReleasePub: pub,
Key: key,
EdgeIPs: edgeIPs(),
+ SecretsPub: secretsPub(),
}, st)
if err != nil {
log.Fatal(err)
@@ -275,6 +279,29 @@ func openStore() release.Store {
return nil
}
+// secretsPub reads CARLOS_SECRETS_PUBKEY for the console to SERVE to CLI
+// clients (the config door) — never to USE. The console has no business
+// sealing or opening anything: secrets are sealed on the operator's machine
+// and opened on the box that holds CARLOS_SECRETS_KEY, and this half only
+// encrypts, which is why it is safe to hand out at all.
+//
+// Optional, like CARLOS_RELEASE_KEY: unset simply omits the field from the
+// config response and leaves every `carlos secrets set` needing the env var
+// in its own environment. Garbage is refused HERE, at boot, rather than
+// served to every CLI to choke on — a pubkey that doesn't parse would
+// otherwise surface as a seal failure on somebody else's machine, hours
+// later, with nothing pointing back at this variable.
+func secretsPub() string {
+ raw := strings.TrimSpace(os.Getenv("CARLOS_SECRETS_PUBKEY"))
+ if raw == "" {
+ return ""
+ }
+ if _, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY"); err != nil {
+ log.Fatal(err)
+ }
+ return raw
+}
+
// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the
// agent's domains reconcile loop reads, so the console's point-your-DNS-here
// guidance and the agent's DNS health checks can never name different
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go
new file mode 100644
index 000000000..4ea224e5a
--- /dev/null
+++ b/internal/console/apiconfig.go
@@ -0,0 +1,468 @@
+// 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, in the
+// same shape and with the same key as promote/rollback/new-account/ledgers.
+//
+// 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.
+//
+// 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, addr string) bool {
+ if !s.cli.write.allow(addr, 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 APP-LEVEL: it lives in the default bundle alone and
+ // LoadMerged never carries it out of a named one (Manifest.ExecDelivery's
+ // doc comment has the "config-that-lies" rationale). Reporting it while
+ // answering for a named bundle would suggest it could be set there.
+ if envName == "" && 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
+ }
+ 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. 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
+ }
+ if !s.configWriteAllowed(w, addr) {
+ 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"`
+ 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
+ }
+ 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 {
+ switch *req.ExecDelivery {
+ case appconfig.ExecDeliveryFile:
+ execDelivery = appconfig.ExecDeliveryFile
+ case "off":
+ // The wire's "off" is appconfig's "" — the vocabulary changes at
+ // this boundary and nowhere else.
+ execDelivery = ""
+ default:
+ jsonError(w, http.StatusBadRequest,
+ fmt.Sprintf("invalid exec_delivery %q — want %q or \"off\"", *req.ExecDelivery, appconfig.ExecDeliveryFile))
+ return
+ }
+ if envName != "" {
+ jsonError(w, http.StatusBadRequest,
+ "exec_delivery is app-level only — it lives in the default bundle (config.json) alone, "+
+ "never in a named environment, so send it without an environment")
+ 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.audit(acct, app, addr, "config",
+ fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle))
+ }
+ if req.ExecDelivery != nil {
+ // A SECOND CAS write, and deliberately not folded into the one above:
+ // SetExecDelivery is app-level and has no environment-scoped variant
+ // at all (store.go). 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.SetExecDelivery(ctx, s.cfg.Releases, acct.Sqid, app, execDelivery); err != nil {
+ s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s", acct.Sqid, app), err)
+ return
+ }
+ label := "off"
+ if execDelivery != "" {
+ label = execDelivery
+ }
+ s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+label+" (cli)")
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"result": "ok", "removed": removed})
+}
+
+// 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
+ }
+ if !s.configWriteAllowed(w, addr) {
+ 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)")
+ writeJSON(w, http.StatusOK, map[string]any{"result": "requested"})
+}
diff --git a/internal/console/apiconfig_test.go b/internal/console/apiconfig_test.go
new file mode 100644
index 000000000..db95ca691
--- /dev/null
+++ b/internal/console/apiconfig_test.go
@@ -0,0 +1,904 @@
+package console
+
+// The CLI's config API surface (apiconfig.go): `carlos env` and `carlos
+// secrets` reading and writing through the console instead of the bucket.
+// The properties that matter, in the order they'd hurt if lost: a secret's
+// PLAINTEXT is never anywhere near this console and its ENVELOPE never comes
+// back out of it, an unknown account / unknown app / someone else's account
+// are one byte-identical 404 on all three doors, every refusal happens before
+// anything is written, and the audit trail records counts rather than
+// content.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// putJSON is postJSON's PUT twin — the config write door is the only PUT in
+// this API, so it gets its own helper rather than a method parameter on the
+// existing ones.
+func putJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ return doJSON(t, req)
+}
+
+// putRaw is putJSON's byte-exact cousin, for the gate assertions: two
+// refusals have to be IDENTICAL, not merely equal key-by-key, and
+// http.NotFound's body is not JSON at all.
+func putRaw(t *testing.T, u string, body any, bearer string) (int, string) {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, string(out)
+}
+
+// configFixture is a console with titogo claimed by the ops account and a
+// member token for it — the shape every test below starts from.
+func configFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// configURL is the config door for one app in the fixture's account.
+func configURL(fx *consoleFixture, app string) string {
+ return fx.ts.URL + "/api/cli/apps/bab/" + app + "/config"
+}
+
+// storedManifest reads an app's config manifest straight out of the bucket —
+// the check that a secret's envelope arrived VERBATIM, and the check that a
+// refused write left nothing behind. found==false means no object at all.
+func storedManifest(t *testing.T, fx *consoleFixture, app string) (appconfig.Manifest, bool) {
+ t.Helper()
+ raw, _, err := fx.rel.Get(context.Background(), appconfig.Key(release.OpsAccount, app))
+ if err != nil {
+ return appconfig.Manifest{}, false
+ }
+ var m appconfig.Manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("stored manifest %s: %v", app, err)
+ }
+ return m, true
+}
+
+// genEtag is the app's generation-object etag, "" when the object does not
+// exist yet — the signal the agent's fast loop watches (issue #44).
+func genEtag(t *testing.T, fx *consoleFixture, app string) string {
+ t.Helper()
+ _, etag, err := fx.rel.Get(context.Background(), appconfig.GenKey(release.OpsAccount, app))
+ if err != nil {
+ return ""
+ }
+ return etag
+}
+
+// varsByKey indexes a GET response's vars array by key name.
+func varsByKey(t *testing.T, out map[string]any) map[string]map[string]any {
+ t.Helper()
+ vars, ok := out["vars"].([]any)
+ if !ok {
+ t.Fatalf("response has no vars array: %v", out)
+ }
+ byKey := map[string]map[string]any{}
+ for _, v := range vars {
+ m, _ := v.(map[string]any)
+ byKey[str(t, m, "key")] = m
+ }
+ return byKey
+}
+
+// sealedFor mints a secrets keypair and seals plaintext with it, returning
+// the base64 public key, the base64 private key and the envelope — a REAL
+// envelope from the real primitive, so CheckEnvelope's acceptance here means
+// what it means in production.
+func sealedFor(t *testing.T, plaintext string) (pub, priv, envelope string) {
+ t.Helper()
+ priv, pub, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pk, err := appconfig.ParsePublicKey(pub, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err = appconfig.Seal(pk, []byte(plaintext))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pub, priv, envelope
+}
+
+// ---- the gate ---------------------------------------------------------------
+
+// TestAPIConfigGateIsByteIdentical: on ALL THREE doors, a token that belongs
+// to no account, an account segment that doesn't exist, and an app the
+// account has never claimed are one and the same 404 — byte-identical to
+// http.NotFound's own output, so probing this API teaches a token holder
+// nothing about which accounts or apps exist.
+func TestAPIConfigGateIsByteIdentical(t *testing.T) {
+ fx, token := configFixture(t, "code-config-gate")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-config-gate-eve")
+
+ doors := []struct {
+ name string
+ call func(u, bearer string) (int, string)
+ }{
+ {"GET", func(u, bearer string) (int, string) { return getRaw(t, u, bearer) }},
+ {"PUT", func(u, bearer string) (int, string) {
+ return putRaw(t, u, map[string]any{"unset": []string{"X"}}, bearer)
+ }},
+ {"sync", func(u, bearer string) (int, string) {
+ return postRaw(t, u+"/sync", map[string]any{}, bearer)
+ }},
+ }
+ for _, d := range doors {
+ t.Run(d.name, func(t *testing.T) {
+ // (a) a member of nothing, against a real account and a real app.
+ statusA, bodyA := d.call(configURL(fx, "titogo"), eve)
+ // (b) a member, against an account that does not exist.
+ statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/config", token)
+ // (c) a member of the account, against an app it never claimed.
+ statusC, bodyC := d.call(configURL(fx, "ghost-app"), token)
+ if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound {
+ t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC)
+ }
+ if bodyA != bodyB || bodyB != bodyC {
+ t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC)
+ }
+ if bodyA != "404 page not found\n" {
+ t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA)
+ }
+ })
+ }
+}
+
+// TestAPIConfigNoToken: every door answers the same 401 cliUser always gives
+// — before it can matter what account or app was named.
+func TestAPIConfigNoToken(t *testing.T) {
+ fx, _ := configFixture(t, "code-config-notoken")
+
+ status, out := getJSON(t, configURL(fx, "titogo"), "")
+ if status != http.StatusUnauthorized || !strings.Contains(str(t, out, "error"), "not signed in") {
+ t.Fatalf("GET with no token = %d %v, want 401 and the cliUser sentence", status, out)
+ }
+ status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{"unset": []string{"X"}}, "")
+ if status != http.StatusUnauthorized {
+ t.Fatalf("PUT with no token = %d %v, want 401", status, out)
+ }
+ status, out = postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, "")
+ if status != http.StatusUnauthorized {
+ t.Fatalf("sync with no token = %d %v, want 401", status, out)
+ }
+}
+
+// ---- GET --------------------------------------------------------------------
+
+// TestAPIConfigGetEmpty: an app that has never been configured answers as an
+// empty DEFAULT bundle rather than a 404 — the same "never configured is not
+// an error" convention appconfig.Get itself keeps. environments is present
+// and empty (a client reads it without testing for absence); exec_delivery
+// and secrets_pubkey are absent, not empty strings.
+func TestAPIConfigGetEmpty(t *testing.T) {
+ fx, token := configFixture(t, "code-config-get-empty")
+
+ status, out := getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ if str(t, out, "bundle") != appconfig.DefaultBundle {
+ t.Fatalf("bundle = %v, want %q", out["bundle"], appconfig.DefaultBundle)
+ }
+ envs, present := out["environments"].([]any)
+ if !present || len(envs) != 0 {
+ t.Fatalf("environments = %v, want a present, empty list", out["environments"])
+ }
+ vars, present := out["vars"].([]any)
+ if !present || len(vars) != 0 {
+ t.Fatalf("vars = %v, want a present, empty list", out["vars"])
+ }
+ if _, present := out["exec_delivery"]; present {
+ t.Fatalf("exec_delivery = %v, want the field absent when unset", out["exec_delivery"])
+ }
+ if _, present := out["secrets_pubkey"]; present {
+ t.Fatalf("secrets_pubkey = %v, want it absent when the console serves none", out["secrets_pubkey"])
+ }
+}
+
+// TestAPIConfigGetSecretsPubkey: a console configured with
+// CARLOS_SECRETS_PUBKEY serves it — the whole point being that `carlos
+// secrets set` can seal with `carlos auth login` and nothing else. It is
+// public material: it only ever encrypts.
+func TestAPIConfigGetSecretsPubkey(t *testing.T) {
+ fx, token := configFixture(t, "code-config-pubkey")
+ pub, _, _ := sealedFor(t, "irrelevant")
+ fx.srv.cfg.SecretsPub = pub
+
+ status, out := getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ if str(t, out, "secrets_pubkey") != pub {
+ t.Fatalf("secrets_pubkey = %v, want the console's own %q", out["secrets_pubkey"], pub)
+ }
+}
+
+// ---- PUT: the round trips ---------------------------------------------------
+
+// TestAPIConfigPutRoundTrip is the property the whole feature rests on: an
+// env var round-trips in full, and a secret goes IN as an envelope, lands in
+// the bucket verbatim, opens with the private key the console has never
+// seen — and NEVER comes back out of the API, not as a value and not as an
+// envelope. The audit trail carries neither.
+func TestAPIConfigPutRoundTrip(t *testing.T) {
+ fx, token := configFixture(t, "code-config-roundtrip")
+ const plaintext = "s3cr3t-plaintext-never-relayed"
+ _, priv, envelope := sealedFor(t, plaintext)
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{
+ "PORT": map[string]any{"kind": "env", "value": "8080"},
+ "BANK_KEY": map[string]any{"kind": "secret", "envelope": envelope},
+ },
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %v", status, out)
+ }
+ if str(t, out, "result") != "ok" {
+ t.Fatalf("PUT body = %v, want result ok", out)
+ }
+ if n, _ := out["removed"].(float64); n != 0 {
+ t.Fatalf("removed = %v, want 0 (nothing was unset)", out["removed"])
+ }
+
+ status, out = getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ byKey := varsByKey(t, out)
+ port, ok := byKey["PORT"]
+ if !ok {
+ t.Fatalf("vars = %v, want PORT", out["vars"])
+ }
+ if str(t, port, "kind") != "env" || str(t, port, "value") != "8080" {
+ t.Fatalf("PORT = %v, want kind env value 8080", port)
+ }
+ bank, ok := byKey["BANK_KEY"]
+ if !ok {
+ t.Fatalf("vars = %v, want BANK_KEY", out["vars"])
+ }
+ if str(t, bank, "kind") != "secret" {
+ t.Fatalf("BANK_KEY = %v, want kind secret", bank)
+ }
+ // Structurally absent, not empty: there is no field here for a secret's
+ // content to occupy in the first place.
+ if _, present := bank["value"]; present {
+ t.Fatalf("BANK_KEY = %v, want NO value field at all", bank)
+ }
+ if _, present := bank["envelope"]; present {
+ t.Fatalf("BANK_KEY = %v, want NO envelope field at all — the console must not relay ciphertext back", bank)
+ }
+ // And the raw response body, in case a future shape smuggles it elsewhere.
+ _, rawBody := getRaw(t, configURL(fx, "titogo"), token)
+ if strings.Contains(rawBody, envelope) || strings.Contains(rawBody, plaintext) {
+ t.Fatalf("GET body carries the secret: %q", rawBody)
+ }
+
+ // The bucket holds the envelope byte-for-byte, and it still opens.
+ m, found := storedManifest(t, fx, "titogo")
+ if !found {
+ t.Fatal("no config manifest in the bucket after a successful PUT")
+ }
+ if m.Vars["BANK_KEY"].Envelope != envelope {
+ t.Fatalf("stored envelope = %q, want the exact bytes sent", m.Vars["BANK_KEY"].Envelope)
+ }
+ if m.Vars["BANK_KEY"].Value != "" {
+ t.Fatalf("stored secret carries a plaintext value %q, want none", m.Vars["BANK_KEY"].Value)
+ }
+ pk, err := appconfig.ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ opened, err := appconfig.Open(pk, m.Vars["BANK_KEY"].Envelope)
+ if err != nil {
+ t.Fatalf("stored envelope does not open: %v", err)
+ }
+ if string(opened) != plaintext {
+ t.Fatalf("opened secret = %q, want %q", opened, plaintext)
+ }
+ if m.Vars["PORT"].Value != "8080" {
+ t.Fatalf("stored PORT = %+v, want value 8080", m.Vars["PORT"])
+ }
+
+ // The generation object moved: this write is visible to the agent's fast
+ // loop rather than waiting on the hourly sweep (issue #44).
+ if genEtag(t, fx, "titogo") == "" {
+ t.Fatal("no generation object after a successful config write")
+ }
+
+ // Audit: counts and the bundle, never content.
+ events, err := fx.st.Events(OpsAccountID, "titogo", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found = false
+ for _, e := range events {
+ if strings.Contains(e.Detail, plaintext) || strings.Contains(e.Detail, envelope) {
+ t.Fatalf("audit row leaks the secret: %q", e.Detail)
+ }
+ if e.Action == "config" && strings.Contains(e.Detail, "(cli)") {
+ found = true
+ if !strings.Contains(e.Detail, "2 set") || !strings.Contains(e.Detail, appconfig.DefaultBundle) {
+ t.Fatalf("config audit detail = %q, want the counts and the bundle", e.Detail)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("events = %v, want a config row whose detail names the CLI", events)
+ }
+}
+
+// TestAPIConfigPutRemovedCount: `removed` is how many keys were ACTUALLY
+// there, not how many the caller listed — the CLI prints it, so an
+// argument-count answer would tell an operator they removed something they
+// didn't (2026-08-02 review, MINOR 9, on the wire too).
+func TestAPIConfigPutRemovedCount(t *testing.T) {
+ fx, token := configFixture(t, "code-config-removed")
+
+ if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{
+ "A": map[string]any{"kind": "env", "value": "1"},
+ "B": map[string]any{"kind": "env", "value": "2"},
+ },
+ }, token); status != http.StatusOK {
+ t.Fatalf("seed PUT = %d %v", status, out)
+ }
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "unset": []string{"A", "NEVER_SET"},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("unset PUT = %d %v", status, out)
+ }
+ if n, _ := out["removed"].(float64); n != 1 {
+ t.Fatalf("removed = %v, want 1 (A was there, NEVER_SET was not)", out["removed"])
+ }
+ m, _ := storedManifest(t, fx, "titogo")
+ if _, still := m.Vars["A"]; still {
+ t.Fatalf("A survived the unset: %v", m.Vars)
+ }
+ if _, gone := m.Vars["B"]; !gone {
+ t.Fatalf("B was removed by an unset that never named it: %v", m.Vars)
+ }
+}
+
+// TestAPIConfigPutSetAndUnsetTogether: one PUT carrying both halves is ONE
+// CAS write (appconfig.Apply), so it can never half-apply.
+func TestAPIConfigPutSetAndUnsetTogether(t *testing.T) {
+ fx, token := configFixture(t, "code-config-both")
+
+ if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"OLD": map[string]any{"kind": "env", "value": "1"}},
+ }, token); status != http.StatusOK {
+ t.Fatalf("seed PUT = %d %v", status, out)
+ }
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"NEW": map[string]any{"kind": "env", "value": "2"}},
+ "unset": []string{"OLD"},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("mixed PUT = %d %v", status, out)
+ }
+ if n, _ := out["removed"].(float64); n != 1 {
+ t.Fatalf("removed = %v, want 1", out["removed"])
+ }
+ m, _ := storedManifest(t, fx, "titogo")
+ if _, still := m.Vars["OLD"]; still {
+ t.Fatalf("OLD survived: %v", m.Vars)
+ }
+ if m.Vars["NEW"].Value != "2" {
+ t.Fatalf("NEW = %+v, want value 2", m.Vars["NEW"])
+ }
+}
+
+// TestAPIConfigNamedEnvironment: a named bundle round-trips at its own
+// sibling key, does not bleed into the default bundle, and shows up in
+// `environments` — which is what powers `carlos env environments` without a
+// fourth door. An environment that has never been written answers as an
+// empty bundle, matching appconfig's own convention for an absent config.
+func TestAPIConfigNamedEnvironment(t *testing.T) {
+ fx, token := configFixture(t, "code-config-env")
+
+ // Absent bundle: an empty answer, not a 404 and not an error.
+ status, out := getJSON(t, configURL(fx, "titogo")+"?environment=staging", token)
+ if status != http.StatusOK {
+ t.Fatalf("GET absent environment = %d %v, want 200", status, out)
+ }
+ if str(t, out, "bundle") != "staging" {
+ t.Fatalf("bundle = %v, want staging", out["bundle"])
+ }
+ if vars, _ := out["vars"].([]any); len(vars) != 0 {
+ t.Fatalf("vars = %v, want empty for a bundle that doesn't exist yet", out["vars"])
+ }
+
+ status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "staging",
+ "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "9090"}},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT staging = %d %v", status, out)
+ }
+
+ status, out = getJSON(t, configURL(fx, "titogo")+"?environment=staging", token)
+ if status != http.StatusOK {
+ t.Fatalf("GET staging = %d %v", status, out)
+ }
+ if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "9090" {
+ t.Fatalf("staging PORT = %v, want 9090", v)
+ }
+ if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" {
+ t.Fatalf("environments = %v, want [staging]", out["environments"])
+ }
+
+ // The default bundle is untouched by an environment-scoped write.
+ status, out = getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET default = %d %v", status, out)
+ }
+ if vars, _ := out["vars"].([]any); len(vars) != 0 {
+ t.Fatalf("default bundle vars = %v, want empty — staging's write must not land here", out["vars"])
+ }
+ if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" {
+ t.Fatalf("environments on the default bundle = %v, want [staging]", out["environments"])
+ }
+ // The exec_delivery field is app-level: even asking for a named bundle,
+ // it is never reported from one.
+ if _, present := out["exec_delivery"]; present {
+ t.Fatalf("exec_delivery = %v, want absent", out["exec_delivery"])
+ }
+}
+
+// TestAPIConfigAuditNamesTheBundle: an environment-scoped write audits under
+// that environment's name, so an operator reading rows can tell which bundle
+// moved without the row carrying a single key or value.
+func TestAPIConfigAuditNamesTheBundle(t *testing.T) {
+ fx, token := configFixture(t, "code-config-audit-bundle")
+
+ if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "staging",
+ "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "9090"}},
+ }, token); status != http.StatusOK {
+ t.Fatalf("PUT staging = %d %v", status, out)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range events {
+ if e.Action != "config" {
+ continue
+ }
+ found = true
+ if !strings.Contains(e.Detail, "staging") {
+ t.Fatalf("config audit detail = %q, want the bundle named", e.Detail)
+ }
+ if strings.Contains(e.Detail, "PORT") || strings.Contains(e.Detail, "9090") {
+ t.Fatalf("config audit detail = %q, want counts only — never keys or values", e.Detail)
+ }
+ }
+ if !found {
+ t.Fatalf("events = %v, want a config row", events)
+ }
+}
+
+// ---- PUT: the refusal matrix ------------------------------------------------
+
+// TestAPIConfigPutRefusals is spec §2's refusal list, one case each, asserting
+// the STATUS and that the error text is specific enough to act on — plus the
+// property underneath all of them: a refused PUT writes NOTHING. Validation
+// runs to completion before the first store call, so no case here can leave a
+// half-applied bundle (or mint a phantom one) behind.
+func TestAPIConfigPutRefusals(t *testing.T) {
+ fx, token := configFixture(t, "code-config-refusals")
+ _, _, envelope := sealedFor(t, "irrelevant")
+
+ cases := []struct {
+ name string
+ body map[string]any
+ status int
+ errSub string
+ }{
+ {"empty request", map[string]any{}, http.StatusBadRequest, "nothing to do"},
+ {"environment default", map[string]any{
+ "environment": "default",
+ "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1"}},
+ }, http.StatusBadRequest, "default"},
+ {"invalid environment name", map[string]any{
+ "environment": "Staging Prod",
+ "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1"}},
+ }, http.StatusBadRequest, "environment name"},
+ {"invalid var name", map[string]any{
+ "set": map[string]any{"9BAD": map[string]any{"kind": "env", "value": "1"}},
+ }, http.StatusBadRequest, "9BAD"},
+ {"unknown kind", map[string]any{
+ "set": map[string]any{"A": map[string]any{"kind": "sekret", "value": "1"}},
+ }, http.StatusBadRequest, "kind"},
+ {"env value with a newline", map[string]any{
+ "set": map[string]any{"A": map[string]any{"kind": "env", "value": "one\ntwo"}},
+ }, http.StatusBadRequest, "A"},
+ {"env var carrying an envelope", map[string]any{
+ "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1", "envelope": envelope}},
+ }, http.StatusBadRequest, "envelope"},
+ {"secret with a garbage envelope", map[string]any{
+ "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": "not base64 at all!!"}},
+ }, http.StatusBadRequest, "envelope"},
+ {"secret with a truncated envelope", map[string]any{
+ "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope[:20]}},
+ }, http.StatusBadRequest, "envelope"},
+ {"secret carrying a plaintext value", map[string]any{
+ "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope, "value": "hunter2"}},
+ }, http.StatusBadRequest, "value"},
+ {"bad exec_delivery value", map[string]any{"exec_delivery": "x"}, http.StatusBadRequest, "exec_delivery"},
+ {"exec_delivery with an environment", map[string]any{
+ "environment": "staging",
+ "exec_delivery": "file",
+ }, http.StatusBadRequest, "app-level"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ status, out := putJSON(t, configURL(fx, "titogo"), c.body, token)
+ if status != c.status {
+ t.Fatalf("%s = %d %v, want %d", c.name, status, out, c.status)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, c.errSub) {
+ t.Fatalf("%s error = %q, want it to name %q", c.name, msg, c.errSub)
+ }
+ })
+ }
+
+ // Nothing above may have reached the store: no default bundle, no
+ // staging bundle, and no generation object.
+ if _, found := storedManifest(t, fx, "titogo"); found {
+ t.Fatal("a config manifest exists after nothing but refused PUTs — validation must run before any write")
+ }
+ names, err := appconfig.ListEnvironments(context.Background(), fx.rel, release.OpsAccount, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 0 {
+ t.Fatalf("environment bundles = %v after nothing but refused PUTs, want none", names)
+ }
+ if etag := genEtag(t, fx, "titogo"); etag != "" {
+ t.Fatalf("generation object exists (%q) after nothing but refused PUTs, want none", etag)
+ }
+}
+
+// TestAPIConfigPutSecretPlaintextNeverStored: the refusal that matters most.
+// A secret var carrying a `value` is refused outright rather than stored with
+// the plaintext quietly dropped — a caller who sent one has to learn their
+// plaintext crossed the wire, and a future edit that "helpfully" kept it would
+// be the console holding a secret in the clear.
+func TestAPIConfigPutSecretPlaintextNeverStored(t *testing.T) {
+ fx, token := configFixture(t, "code-config-secret-plaintext")
+ _, _, envelope := sealedFor(t, "irrelevant")
+ const leaked = "plaintext-that-must-not-be-stored"
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"BANK_KEY": map[string]any{
+ "kind": "secret", "envelope": envelope, "value": leaked}},
+ }, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("secret with a value = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); strings.Contains(msg, leaked) {
+ t.Fatalf("the refusal echoes the plaintext back: %q", msg)
+ }
+ if _, found := storedManifest(t, fx, "titogo"); found {
+ t.Fatal("a manifest was written for a refused secret-with-value PUT")
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range events {
+ if strings.Contains(e.Detail, leaked) {
+ t.Fatalf("audit row leaks the refused plaintext: %q", e.Detail)
+ }
+ }
+}
+
+// TestAPIConfigUnsetOnlyNonexistentBundle: unsetting from a bundle that does
+// not exist is a 409, not a silent create — creating it would mint an empty
+// manifest, which by this package's own contract means "configured, then
+// fully unset" and converges every one of that app's env files to empty
+// (applyAt's CRITICAL 2 refusal, reached through the door).
+func TestAPIConfigUnsetOnlyNonexistentBundle(t *testing.T) {
+ fx, token := configFixture(t, "code-config-unset-nobundle")
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "unset": []string{"NOPE"},
+ }, token)
+ if status != http.StatusConflict {
+ t.Fatalf("unset-only on a nonexistent bundle = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "nothing to unset") {
+ t.Fatalf("error = %q, want appconfig's own refusal text", msg)
+ }
+ if _, found := storedManifest(t, fx, "titogo"); found {
+ t.Fatal("a phantom manifest was minted by a refused unset-only PUT")
+ }
+
+ status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "staging",
+ "unset": []string{"NOPE"},
+ }, token)
+ if status != http.StatusConflict {
+ t.Fatalf("unset-only on a nonexistent environment bundle = %d %v, want 409", status, out)
+ }
+ names, err := appconfig.ListEnvironments(context.Background(), fx.rel, release.OpsAccount, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 0 {
+ t.Fatalf("environment bundles = %v, want none — a phantom bundle satisfies the route -environment rail", names)
+ }
+}
+
+// ---- exec-delivery ----------------------------------------------------------
+
+// TestAPIConfigExecDelivery: the issue #18 opt-in through the door — "file"
+// shows up on the default bundle, "off" clears the field entirely (absent,
+// not empty), and both audit as their own row so the change is visible to an
+// operator reading the trail.
+func TestAPIConfigExecDelivery(t *testing.T) {
+ fx, token := configFixture(t, "code-config-execdelivery")
+
+ // A set alongside it: exec_delivery rides on the same PUT the CLI would
+ // otherwise need two calls for, and both halves land.
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}},
+ "exec_delivery": "file",
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT exec_delivery file = %d %v", status, out)
+ }
+ status, out = getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ if str(t, out, "exec_delivery") != appconfig.ExecDeliveryFile {
+ t.Fatalf("exec_delivery = %v, want %q", out["exec_delivery"], appconfig.ExecDeliveryFile)
+ }
+ if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "8080" {
+ t.Fatalf("PORT = %v, want 8080 — the set half must land too", v)
+ }
+
+ status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{"exec_delivery": "off"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT exec_delivery off = %d %v", status, out)
+ }
+ status, out = getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ if _, present := out["exec_delivery"]; present {
+ t.Fatalf("exec_delivery = %v after off, want the field absent", out["exec_delivery"])
+ }
+ if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "8080" {
+ t.Fatalf("PORT = %v after exec_delivery off, want it untouched", v)
+ }
+
+ events, err := fx.st.Events(OpsAccountID, "titogo", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var on, off bool
+ for _, e := range events {
+ if e.Action != "exec-delivery" {
+ continue
+ }
+ if strings.Contains(e.Detail, "file") {
+ on = true
+ }
+ if strings.Contains(e.Detail, "off") {
+ off = true
+ }
+ if !strings.Contains(e.Detail, "(cli)") {
+ t.Fatalf("exec-delivery audit detail = %q, want it to name the CLI", e.Detail)
+ }
+ }
+ if !on || !off {
+ t.Fatalf("events = %v, want an exec-delivery row for each of file and off", events)
+ }
+}
+
+// ---- sync -------------------------------------------------------------------
+
+// TestAPIConfigSync: `carlos env sync`'s door moves the generation etag —
+// which is the entire mechanism the agent's fast loop watches — and audits,
+// because a sync makes instances converge (and exec-backed ones restart),
+// which an operator reading rows must be able to see.
+func TestAPIConfigSync(t *testing.T) {
+ fx, token := configFixture(t, "code-config-sync")
+
+ if etag := genEtag(t, fx, "titogo"); etag != "" {
+ t.Fatalf("generation object exists (%q) before anything wrote config", etag)
+ }
+ status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("sync = %d %v", status, out)
+ }
+ if str(t, out, "result") != "requested" {
+ t.Fatalf("sync body = %v, want result requested", out)
+ }
+ first := genEtag(t, fx, "titogo")
+ if first == "" {
+ t.Fatal("no generation object after a sync request")
+ }
+ if status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("second sync = %d %v", status, out)
+ }
+ if second := genEtag(t, fx, "titogo"); second == first {
+ t.Fatalf("generation etag %q did not move on the second sync", second)
+ }
+
+ events, err := fx.st.Events(OpsAccountID, "titogo", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range events {
+ if e.Action == "config-sync" && strings.Contains(e.Detail, "(cli)") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("events = %v, want a config-sync row whose detail names the CLI", events)
+ }
+}
+
+// ---- rate limiting ----------------------------------------------------------
+
+// TestAPIConfigWritesAreRateLimited: both mutating doors pass the same write
+// budget promote/rollback/ledgers pass, keyed on the ADDRESS — and the READ
+// door does not, because an exhausted write budget must not blind a CLI to
+// what it already wrote.
+func TestAPIConfigWritesAreRateLimited(t *testing.T) {
+ fx, token := configFixture(t, "code-config-ratelimit")
+
+ limited := false
+ for i := 0; i < cliWriteBurst+3; i++ {
+ status, _ := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}},
+ }, token)
+ if status == http.StatusTooManyRequests {
+ limited = true
+ break
+ }
+ }
+ if !limited {
+ t.Fatalf("PUT was never rate-limited in %d attempts", cliWriteBurst+3)
+ }
+ // Same limiter, same key: an exhausted address cannot switch door.
+ if status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token); status != http.StatusTooManyRequests {
+ t.Fatalf("sync on an exhausted budget = %d %v, want 429", status, out)
+ }
+ if status, out := getJSON(t, configURL(fx, "titogo"), token); status != http.StatusOK {
+ t.Fatalf("GET on an exhausted write budget = %d %v, want 200", status, out)
+ }
+}
+
+// TestAPIConfigGetBadEnvironment: the query-parameter half of the environment
+// rule — "default" and a malformed name are refused on the READ door too,
+// rather than being resolved to some other bundle or reaching the store.
+func TestAPIConfigGetBadEnvironment(t *testing.T) {
+ fx, token := configFixture(t, "code-config-get-badenv")
+
+ status, out := getJSON(t, configURL(fx, "titogo")+"?environment=default", token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("GET ?environment=default = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "default") {
+ t.Fatalf("error = %q, want it to name the rule", msg)
+ }
+ status, out = getJSON(t, configURL(fx, "titogo")+"?environment=NOT%20valid", token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("GET ?environment=NOT valid = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "environment name") {
+ t.Fatalf("error = %q, want it to name the shape rule", msg)
+ }
+}
+
+// TestAPIConfigPutLargeEnvelope: the PUT door's own body cap
+// (configPutMaxBytes), not readJSON's 8KiB, governs a config write — a
+// sealed envelope runs ~4/3 the size of its plaintext, so one long base64
+// credential passes 8KiB on its own and must still land. The over-cap case
+// answers an honest 413, not "malformed JSON body": the caller's fix is
+// nothing like a JSON fix.
+func TestAPIConfigPutLargeEnvelope(t *testing.T) {
+ fx, token := configFixture(t, "code-config-large")
+
+ // ~16KiB of single-line plaintext (ValidVarValue-legal), well past the
+ // old shared cap once sealed and base64'd.
+ _, priv, envelope := sealedFor(t, strings.Repeat("a", 16<<10))
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"BIG_KEY": map[string]any{"kind": "secret", "envelope": envelope}},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("large envelope PUT = %d %v, want 200", status, out)
+ }
+ m, found := storedManifest(t, fx, "titogo")
+ if !found || m.Vars["BIG_KEY"].Envelope != envelope {
+ t.Fatal("large envelope did not land verbatim")
+ }
+ // And it still opens: the door relayed, never rewrote.
+ pk, err := appconfig.ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := appconfig.Open(pk, m.Vars["BIG_KEY"].Envelope)
+ if err != nil || len(got) != 16<<10 {
+ t.Fatalf("stored envelope does not open to the plaintext: %v", err)
+ }
+
+ // Over the cap: refused at read time, before validation could even see
+ // it, with the size's own status.
+ status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"TOO_BIG": map[string]any{"kind": "secret", "envelope": strings.Repeat("A", int(configPutMaxBytes)+1024)}},
+ }, token)
+ if status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("over-cap PUT = %d %v, want 413", status, out)
+ }
+ if m, _ := storedManifest(t, fx, "titogo"); m.Vars != nil {
+ if _, ok := m.Vars["TOO_BIG"]; ok {
+ t.Fatal("over-cap PUT wrote to the bucket")
+ }
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 122cec00e..cb573f144 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -25,6 +25,7 @@ import (
"context"
"crypto/subtle"
"encoding/json"
+ "errors"
"fmt"
"log"
"net"
@@ -187,6 +188,13 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", s.handleAPILedgerHead)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob)
+ // The config API (apiconfig.go) — `carlos env` and `carlos secrets`
+ // through the same gate as ship/promote, so deploy access no longer
+ // means bucket credentials (issue #43). Secrets arrive already sealed;
+ // the console relays an opaque envelope and holds no key to open it.
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet)
+ s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync)
}
// clientIP is who the rate limiter counts against.
@@ -257,15 +265,31 @@ func jsonError(w http.ResponseWriter, status int, msg string) {
// visited. Anything that can set application/json cross-origin has to pass a
// CORS preflight first, and this console answers none.
func readJSON(w http.ResponseWriter, r *http.Request, out any) bool {
+ // A login request — and every other door but the config PUT — is a
+ // handful of short strings; anything larger is not one of ours.
+ return readJSONLimit(w, r, out, 8<<10)
+}
+
+// readJSONLimit is readJSON with the caller's own byte cap — the seam the
+// config PUT door uses (apiconfig.go), because a request carrying sealed
+// secret envelopes is legitimately larger than "a handful of short strings":
+// an envelope is ~4/3 the size of the plaintext it seals, and one long
+// base64 credential can pass 8KiB on its own. A body over the cap answers
+// its own 413 rather than hiding inside "malformed JSON body" — the caller's
+// fix (split the request, shorten the value) is nothing like a JSON fix.
+func readJSONLimit(w http.ResponseWriter, r *http.Request, out any, limit int64) bool {
ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
if strings.TrimSpace(strings.ToLower(ct)) != "application/json" {
jsonError(w, http.StatusUnsupportedMediaType, "send a JSON body with Content-Type: application/json")
return false
}
- // A login request is a handful of short strings; anything larger is not
- // one of ours.
- r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
+ r.Body = http.MaxBytesReader(w, r.Body, limit)
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ jsonError(w, http.StatusRequestEntityTooLarge, "request body too large")
+ return false
+ }
jsonError(w, http.StatusBadRequest, "malformed JSON body")
return false
}
diff --git a/internal/console/server.go b/internal/console/server.go
index 5e44aa521..8783814da 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -138,6 +138,13 @@ type Config struct {
// point-your-DNS-here guidance. nil is legal: guidance then says
// "this deployment's edge IP" instead of inventing an address.
EdgeIPs []string
+ // SecretsPub is the deployment's CARLOS_SECRETS_PUBKEY, served (never
+ // used) by the CLI config door so `carlos secrets set` can seal
+ // client-side with `carlos auth login` alone. Public material by
+ // design — it only encrypts; the console cannot decrypt anything with
+ // or without it. Optional: "" omits the field from the API response
+ // and the CLI falls back to requiring the env var.
+ SecretsPub string
}
// Server is the console's http.Handler.