console: pipeline API — GET/PUT, edge default at create, production side-write
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+533
−0
internal/console/apipipeline.go+252 −0internal/console/apipipeline_test.go+257 −0internal/console/authcli.go+14 −0internal/console/server.go+10 −0
diff --git a/internal/console/apipipeline.go b/internal/console/apipipeline.go| new file mode 100644 |
| index 000000000..3cb1de236 |
| --- /dev/null |
| +++ b/internal/console/apipipeline.go |
| @@ -0,0 +1,252 @@ |
| +// The CLI's pipeline API: `carlos pipeline` reading and writing an app's |
| +// pipeline object through the console, the same door class as promote and |
| +// config — release pipelines v2 |
| +// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). |
| +// |
| +// GET is Publish-reachable like channels/releases: a pipeline is release |
| +// metadata, not a secret. PUT is admin-only for service credentials |
| +// (svcrole.Allow(svcrole.Admin) at registration) — it rewrites the policy |
| +// that governs every promotion into every channel of the app, including |
| +// whether one needs a passkey or a quorum of humans, which is account |
| +// authority in the same register as ledgers and object storage grants, not |
| +// day-to-day deploy work. A human member reaches it regardless of role: the |
| +// role gate in cliRoute only ever narrows a SERVICE credential. |
| +// |
| +// PUT is a stepping stone, not the finished gate. The real approval |
| +// machinery (spec §4's pending-change primitive: proposals, quorum, |
| +// passkey-confirmed counting) is Task 8. Until then, this door refuses |
| +// outright — 403, "finish in the console" — any edit that touches a channel |
| +// whose CURRENTLY STORED rules already demand change_approvals: a member |
| +// typing a bearer-token PUT must never be able to strip or reorder past a |
| +// rule that exists specifically to require more than one bearer token's |
| +// say-so. Tightening (raising a channel's own approvals, or adding a guard |
| +// where there was none) still goes straight through — only a channel the |
| +// stored pipeline ALREADY guards is closed off. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// pipelineStoreTimeout bounds every store round trip these handlers make — |
| +// the same budget handleAPIChannels and the config doors give the bucket. |
| +const pipelineStoreTimeout = 15 * time.Second |
| + |
| +// handleAPIPipelineGet answers `carlos pipeline`: an app's declared |
| +// pipeline, or null when it has none. Absence is not an error — it is spec |
| +// §2's "no pipeline.json" case, meaning the legacy edge/beta/stable ladder |
| +// applies untouched — so it is a 200 with a null field, not a 404. |
| +// |
| +// GetPipeline's error return is NEVER treated as absence: an unreadable or |
| +// unverifiable object is a 502, distinct from the (nil, nil) genuinely-missing |
| +// case. Collapsing the two would tell a caller "this app has no pipeline" |
| +// when the truth is "the console could not tell you" — the exact trap |
| +// Task 1's review caught for this same function. |
| +func (s *Server) handleAPIPipelineGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) |
| + defer cancel() |
| + p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api pipeline get %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the pipeline") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"pipeline": p}) |
| +} |
| + |
| +// handleAPIPipelinePut answers `carlos pipeline set` (and the console's own |
| +// pipeline editor, once it exists): replace an app's channel list. |
| +// |
| +// Doors, in order, and why: |
| +// |
| +// token + account/app 404 — apiAccountApp, same byte-identical refusal |
| +// every other door in this family gives. |
| +// write budget — allowWrite, the same picker every other |
| +// mutating CLI door here uses (promote, |
| +// rollback, config, restart): a person keyed on |
| +// their address, a service on its name. |
| +// signing key — SetPipeline signs root-only, like promote and |
| +// production; a read-only console (no |
| +// CARLOS_RELEASE_KEY) refuses with 503 before |
| +// ever reading a body, the same shape |
| +// handleAPIPromote gives. |
| +// body + current read — decoded and the CURRENT pipeline read (needed |
| +// for the guarded-edit check below) before any |
| +// refusal that depends on either. |
| +// member-count ceiling — spec §4: no channel's approvals may exceed |
| +// the account's own member count, checked |
| +// against the PROPOSED shape. |
| +// guarded-edit refusal — the interim gate this door's doc comment |
| +// describes; Task 8 replaces it with the real |
| +// quorum flow. |
| +// SetPipeline — validates shape internally (canary reserved, |
| +// duplicate names, bad bake strings, negative |
| +// approvals) and signs; its error becomes 422, |
| +// the caller's to fix. |
| +// production side-write — spec §3's old-binary fallback, below. |
| +// audit + bump — after the write lands, never before or in |
| +// place of a gate above. |
| +func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + principal, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(principal, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no signing key on this host — the console is running read-only") |
| + return |
| + } |
| + var in struct { |
| + Channels []release.PipelineChannel `json:"channels"` |
| + } |
| + if !readJSON(w, r, &in) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) |
| + defer cancel() |
| + cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api pipeline put %s/%s: read current: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "cannot read the current pipeline") |
| + return |
| + } |
| + proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: in.Channels} |
| + members, err := s.store.Members(acct.ID) |
| + if err != nil { |
| + log.Printf("console: api pipeline put %s/%s: members: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "members lookup failed") |
| + return |
| + } |
| + if n := proposed.MaxApprovals(); n > len(members) { |
| + jsonError(w, http.StatusUnprocessableEntity, fmt.Sprintf( |
| + "approvals of %d exceed the account's %d member(s) — add members first", n, len(members))) |
| + return |
| + } |
| + if ch, n := guardedEdit(cur, in.Channels); n > 0 { |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": fmt.Sprintf( |
| + "channel %s needs %d-member confirmation to change — finish in the console", ch, n), |
| + "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| + }) |
| + return |
| + } |
| + set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, in.Channels, addr) |
| + if err != nil { |
| + jsonError(w, http.StatusUnprocessableEntity, err.Error()) |
| + return |
| + } |
| + s.ensureProductionForBakes(ctx, acct.Sqid, app, set, addr) |
| + s.audit(acct, app, addr, "pipeline", "set the pipeline (cli)") |
| + // Box-consumed (carlos-deploy §3): boxes read pipeline.json on the same |
| + // fast pass channel pointers and config already ride. |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, map[string]any{"pipeline": set}) |
| +} |
| + |
| +// guardedEdit is the interim confirmation gate (this file's doc comment; |
| +// Task 8 replaces it with the real quorum flow). It reports the first |
| +// channel in cur whose CURRENTLY STORED rules demand change_approvals >= 1 |
| +// and whose entry next does not preserve, plus that channel's quorum — or |
| +// ("", 0) when nothing guarded was touched. |
| +// |
| +// cur is nil for an app that has never had a pipeline (GetPipeline's |
| +// (nil, nil) case): nothing is guarded yet, so nothing can be touched. |
| +// |
| +// "Preserve" means: still present in next, at the SAME index, with an |
| +// IDENTICAL PipelineChannel value — name, bake and every rule, compared with |
| +// Go's struct ==. Removing the channel, reordering it, or changing anything |
| +// about it (including loosening or tightening its own approvals) all count |
| +// as touching it, matching spec §2's "removing the channel or moving its |
| +// position in the list counts as an edit to its entry — otherwise deletion |
| +// is the loophole around every rule it carries." Only a channel cur did NOT |
| +// already guard is free to be added, removed or reordered without |
| +// confirmation. |
| +func guardedEdit(cur *release.Pipeline, next []release.PipelineChannel) (string, int) { |
| + if cur == nil { |
| + return "", 0 |
| + } |
| + for i, c := range cur.Channels { |
| + if c.Rules.ChangeApprovals < 1 { |
| + continue |
| + } |
| + if i >= len(next) || next[i] != c { |
| + return c.Name, c.Rules.ChangeApprovals |
| + } |
| + } |
| + return "", 0 |
| +} |
| + |
| +// ensureProductionForBakes is spec §3's old-binary mitigation: enabling a |
| +// pipeline with ANY nonzero bake also sets production: true, so a box that |
| +// has not yet been updated to read pipeline.json — and therefore ignores it |
| +// entirely — falls back to its OWN fail-closed legacy holds (HoldFor's 72h |
| +// for a name it doesn't recognise) instead of adopting a held release |
| +// immediately. |
| +// |
| +// It only ever flips TOWARD true. A pipeline whose every channel bakes for |
| +// 0s is not a reason to turn a flag OFF — production may be true for a |
| +// reason that has nothing to do with this pipeline (spec's deletion |
| +// protection) — and an UNREADABLE flag is left alone rather than |
| +// re-declared: GetProduction's error already fails every reader toward |
| +// "treat as production" (adopt path, trash's delete gate), so writing over |
| +// it here would only risk losing whatever the existing object actually |
| +// says. Log-only on every failure: the pipeline write itself already |
| +// landed, and a box that never sees this flag flip is no worse off than a |
| +// pipeline-less app was yesterday. |
| +func (s *Server) ensureProductionForBakes(ctx context.Context, acct, app string, p *release.Pipeline, actor string) { |
| + bakes := false |
| + for _, c := range p.Channels { |
| + if d, err := c.BakeWindow(); err == nil && d > 0 { |
| + bakes = true |
| + break |
| + } |
| + } |
| + if !bakes { |
| + return |
| + } |
| + flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app) |
| + if err != nil { |
| + log.Printf("console: pipeline %s/%s: read production flag for bake fallback: %v", acct, app, err) |
| + return |
| + } |
| + if flag != nil && flag.Production { |
| + return // already true — never re-declared, never downgraded |
| + } |
| + if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct, app, true, actor); err != nil { |
| + log.Printf("console: pipeline %s/%s: set production for bake fallback: %v", acct, app, err) |
| + } |
| +} |
| + |
| +// holdWindow is the pipeline-aware form of release.HoldFor (bake.go), for |
| +// later tasks (adoption, promote) that need one channel's bake window |
| +// without first checking whether the app has opted into a pipeline at all. |
| +// A pipelined app's channel carries its OWN window — possibly zero, and for |
| +// a name the legacy ladder has never heard of. An app with no pipeline, or a |
| +// channel that sits outside a declared one (canary/*, a -hotfix target), |
| +// falls back to the legacy default table exactly as it does today. |
| +func holdWindow(p *release.Pipeline, channel string) time.Duration { |
| + if p != nil { |
| + if c, ok := p.ChannelNamed(channel); ok { |
| + if d, err := c.BakeWindow(); err == nil { |
| + return d |
| + } |
| + } |
| + } |
| + return release.HoldFor(release.DefaultHolds, channel) |
| +} |
diff --git a/internal/console/apipipeline_test.go b/internal/console/apipipeline_test.go| new file mode 100644 |
| index 000000000..be23f50df |
| --- /dev/null |
| +++ b/internal/console/apipipeline_test.go |
| @@ -0,0 +1,257 @@ |
| +package console |
| + |
| +// The CLI's pipeline API (apipipeline.go): `carlos pipeline` reading and |
| +// writing an app's pipeline object through the console — release pipelines |
| +// v2 (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). The |
| +// properties that matter, in the order they'd hurt if lost: a fresh app |
| +// gets the one-channel edge default with no separate call, GET tells |
| +// "absent" (null) apart from "unreadable" (502), a write that declares any |
| +// nonzero bake flips the legacy production flag ON and never back off, no |
| +// channel's approvals may exceed the account's own member count, and — until |
| +// Task 8 builds the real quorum flow — a PUT can never silently edit past a |
| +// channel the STORED pipeline already guards with change_approvals. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func TestAPIPipelineRoundTripAndDefaults(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-create") |
| + |
| + // 1. Create an app through the CLI door -> GET pipeline returns the |
| + // one-channel edge default (spec §2), not null. |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "widget"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("create app = %d %v", status, out) |
| + } |
| + status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("pipeline get (fresh app) = %d %v", status, out) |
| + } |
| + p, _ := out["pipeline"].(map[string]any) |
| + if p == nil { |
| + t.Fatalf("pipeline (fresh app) = %v, want the default pipeline, not null", out["pipeline"]) |
| + } |
| + chans, _ := p["channels"].([]any) |
| + if len(chans) != 1 { |
| + t.Fatalf("default pipeline channels = %v, want exactly one (edge)", chans) |
| + } |
| + ch0, _ := chans[0].(map[string]any) |
| + if str(t, ch0, "name") != "edge" { |
| + t.Fatalf("default pipeline channel[0] = %v, want name edge", ch0) |
| + } |
| + |
| + // 2. PUT a two-channel pipeline with a nonzero bake -> 200; GET reflects |
| + // it; control/serial bumps. |
| + before := serial(t, fx) |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "24h"}, |
| + }}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("pipeline put = %d %v", status, out) |
| + } |
| + if got := serial(t, fx); got != before+1 { |
| + t.Fatalf("serial after pipeline put = %d, want %d", got, before+1) |
| + } |
| + status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("pipeline get after put = %d %v", status, out) |
| + } |
| + p, _ = out["pipeline"].(map[string]any) |
| + chans, _ = p["channels"].([]any) |
| + if len(chans) != 2 { |
| + t.Fatalf("pipeline after put channels = %v, want 2", chans) |
| + } |
| + ch1, _ := chans[1].(map[string]any) |
| + if str(t, ch1, "name") != "production" || str(t, ch1, "bake") != "24h" { |
| + t.Fatalf("pipeline after put channel[1] = %v, want production/24h", ch1) |
| + } |
| + |
| + // 3. The app's production flag is now TRUE (side-write on nonzero bake, |
| + // spec §3); a PUT with all-zero bakes must NOT flip it back. |
| + ctx := context.Background() |
| + flag, err := release.GetProduction(ctx, fx.rel, fx.pub, "bab", "widget") |
| + if err != nil || flag == nil || !flag.Production { |
| + t.Fatalf("production flag after a pipelined bake = %v, %v, want true", flag, err) |
| + } |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("all-zero-bake pipeline put = %d %v", status, out) |
| + } |
| + flag, err = release.GetProduction(ctx, fx.rel, fx.pub, "bab", "widget") |
| + if err != nil || flag == nil || !flag.Production { |
| + t.Fatalf("production flag after an all-zero-bake put = %v, %v, want still true", flag, err) |
| + } |
| + |
| + // 4. PUT with rules {"promote_approvals": 2} on a 1-member account -> |
| + // 422, body names the member count. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "rules": map[string]any{"promote_approvals": 2}}, |
| + }}, token) |
| + if status != http.StatusUnprocessableEntity { |
| + t.Fatalf("over-quorum pipeline put = %d %v, want 422", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "1 member") { |
| + t.Fatalf("over-quorum error = %v, want it to name the member count", out["error"]) |
| + } |
| + |
| + // 5. PUT with a channel named "canary" -> 422. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{{"name": "canary"}}}, token) |
| + if status != http.StatusUnprocessableEntity { |
| + t.Fatalf("canary pipeline put = %d %v, want 422", status, out) |
| + } |
| +} |
| + |
| +// TestAPIPipelineGetUnreadableIsNot502 pins the exact trap Task 1's review |
| +// caught for GetPipeline itself: a MISSING pipeline is (nil, nil) — the 200 |
| +// null case above — but an object that exists and fails verification is an |
| +// ERROR, and this door must answer 502 for it, never quietly fold it into |
| +// "absent". |
| +func TestAPIPipelineGetUnreadableIsNot502(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Garbage at the pipeline key: present, but unverifiable. |
| + if _, err := fx.rel.Put(context.Background(), release.PipelineKey("bab", "widget"), []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-unreadable") |
| + |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("pipeline get on an unreadable object = %d %v, want 502", status, out) |
| + } |
| +} |
| + |
| +// TestAPIPipelinePutGuardedChannelInterimRefusal: until Task 8 builds the |
| +// real quorum flow, a PUT that edits, removes or reorders a channel whose |
| +// CURRENT stored rules already carry change_approvals >= 1 is refused (403) |
| +// rather than silently applied — the exact loophole spec §2 calls out |
| +// ("removing the channel or moving its position ... counts as an edit to |
| +// its entry"). Adding a guard where there was none is a tightening edit |
| +// against an UNGUARDED channel and goes straight through. |
| +func TestAPIPipelinePutGuardedChannelInterimRefusal(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + // A second member so a change_approvals of 2 (the re-tightening case |
| + // below) is not itself refused by the member-count ceiling — this test |
| + // is about the guard, not that unrelated gate. |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-guard") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "widget"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("create app = %d %v", status, out) |
| + } |
| + |
| + // Setting the guard in the first place is a tightening edit against the |
| + // pipeline-less default (no channel is guarded yet) — it must go |
| + // straight through with no confirmation. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "rules": map[string]any{"change_approvals": 1}}, |
| + }}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("set the guard = %d %v", status, out) |
| + } |
| + |
| + // Editing the guarded channel's bake — leaving its own guard untouched — |
| + // is refused: 403, naming the quorum and pointing at the console. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}}, |
| + }}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("guarded edit = %d %v, want 403", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "1-member confirmation") { |
| + t.Fatalf("guarded edit error = %v, want it to name the quorum", out["error"]) |
| + } |
| + if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { |
| + t.Fatalf("guarded edit console_url = %v, want the app page", out["console_url"]) |
| + } |
| + |
| + // Removing the guarded channel is refused the same way. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("removing the guarded channel = %d %v, want 403", status, out) |
| + } |
| + |
| + // Reordering it is refused the same way. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{ |
| + {"name": "production", "rules": map[string]any{"change_approvals": 1}}, |
| + {"name": "edge"}, |
| + }}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("reordering the guarded channel = %d %v, want 403", status, out) |
| + } |
| + |
| + // Re-tightening it further (raising its OWN approvals count) is refused |
| + // too — the CURRENT stored rule is what gates, not the proposed one. |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "rules": map[string]any{"change_approvals": 2}}, |
| + }}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("re-tightening the guarded channel = %d %v, want 403", status, out) |
| + } |
| + |
| + // None of the refused attempts moved the stored pipeline. |
| + status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("pipeline get = %d %v", status, out) |
| + } |
| + p, _ := out["pipeline"].(map[string]any) |
| + chans, _ := p["channels"].([]any) |
| + if len(chans) != 2 { |
| + t.Fatalf("pipeline after refused edits = %v, want unchanged at 2 channels", chans) |
| + } |
| +} |
| + |
| +// TestAPIPipelinePutNoServiceRoleReachesIt: svcrole.Allow() with NO roles is |
| +// the fully-closed Set — Admin included (svcrole.TestEmptySetPermitsNothingIncludingAdmin) |
| +// — so the door must be registered svcrole.Allow(svcrole.Admin), not |
| +// svcrole.Allow(). This pins the door's actual behaviour rather than the |
| +// registration line, so a future edit that regresses to the empty form |
| +// breaks a test instead of silently locking every service out. |
| +func TestAPIPipelinePutNoServiceRoleReachesIt(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + admin := serviceToken(t, fx.st, OpsAccountID, "deploybot", "widget", svcrole.Admin) |
| + publish := serviceToken(t, fx.st, OpsAccountID, "publishbot", "widget", svcrole.Publish) |
| + |
| + status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{{"name": "edge"}}}, admin) |
| + if status != http.StatusOK { |
| + t.Fatalf("admin service pipeline put = %d %v, want 200", status, out) |
| + } |
| + |
| + status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| + map[string]any{"channels": []map[string]any{{"name": "edge"}}}, publish) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("publish-role service pipeline put = %d %v, want 403", status, out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index f7ef00374..1a4689944 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -436,6 +436,20 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish), s.handleAPITagsList) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish), s.handleAPITagGet) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish), s.handleAPIReleasesList) |
| + // The pipeline API (apipipeline.go, release pipelines v2). GET is |
| + // release metadata like channels/releases above — Publish reaches it. |
| + // PUT rewrites the policy governing every promotion into every channel |
| + // (bake, passkey, approval quorum), account authority in the same |
| + // register as ledgers and store grants below — Admin-only for a |
| + // SERVICE credential. svcrole.Allow() alone would be the WRONG way to |
| + // say that: the empty Set is fully closed, Admin included |
| + // (TestEmptySetPermitsNothingIncludingAdmin, internal/svcrole) — the |
| + // same zero value svcrole.Set{} spells for the person-only doors |
| + // below. Naming Admin explicitly is what actually opens the door to an |
| + // admin-role service and closes it to every other role, exactly like |
| + // the ledgers and store families. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish), s.handleAPIPipelineGet) |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Admin), s.handleAPIPipelinePut) |
| // Trash and restore (apicli.go). Delete resolves through the LIVE-apps |
| // door and restore through the trashed-apps one, so each verb answers |
| // only for an app in the state it can act on — and both give a stranger |
diff --git a/internal/console/server.go b/internal/console/server.go| index fd581946d..66ea737cf 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -2499,6 +2499,16 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| // account's own manifest is what the agent materializes, and minting |
| // already wrote it. |
| s.ensureAccountManifest(ctx, acct) |
| + // Default pipeline (release pipelines v2 spec §2): every fresh claim |
| + // gets the one-channel edge default up front, through the ONE place |
| + // both doors funnel through — so neither can end up creating an app |
| + // that skips it. Best-effort, like the placement write above and for |
| + // the same reason: an app that ends up pipeline-less is not broken, it |
| + // is the pre-pipelines world (spec §2, "absence means the legacy |
| + // ladder") — exactly as safe as every app created before this existed. |
| + if _, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, name, release.DefaultPipelineChannels(), addr); err != nil { |
| + log.Printf("console: claim %s/%s: set default pipeline: %v", acct.Sqid, name, err) |
| + } |
| return claimOK, placed |
| } |