carlos / platform Public

console: pipeline API — GET/PUT, edge default at create, production side-write

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 621ae1bd0b69c71eaa222a02f67a480ed94393f0 parent d9ae49e
4 files changed, +533 −0
  • internal/console/apipipeline.go +252 −0
  • internal/console/apipipeline_test.go +257 −0
  • internal/console/authcli.go +14 −0
  • internal/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
}