carlos / platform Public

settings-1 final fixes: resign takes the channel's release authority

ResignAuthorized asks an authorize hook of the pipeline inside the
admitted mutation, as RollbackAuthorized does. The console resign door
refuses stable before the bucket is read and asks the channel's pipeline
rules inside the mutation, with the rollback door's refusals, through
helpers both doors now share (Astra r1 #2, P11 reversed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev aaf5abb1119658f8a81d23d6ac15b0cf020a8432 parent dcab1cf
6 files changed, +277 −35
  • internal/console/apicli.go +37 −18
  • internal/console/apidigests.go +49 −1
  • internal/console/apidigests_test.go +119 −10
  • internal/release/claim_app_writes.go +8 −2
  • internal/release/resign.go +18 −3
  • internal/release/resign_test.go +46 −1
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index abedf8e67..cbedff9a7 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -1122,17 +1122,7 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
// This early call only ever produces a refusal; for stable
// deployScopeAllows refuses whatever the pipeline says, and the full check
// after the pipeline read stays as it is. Every other principal gets nil.
- if rung, _ := release.Rung(channel); rung == release.RungStable {
- if err := deployScopeAllows(p, false, channel, false); err != nil {
- s.writeDeployRefusal(w, err)
- return
- }
- }
- if rung, _ := release.Rung(channel); rung == release.RungStable {
- writeJSON(w, http.StatusForbidden, map[string]string{
- "error": "stable needs a passkey — finish in the console",
- "console_url": s.appConsoleURL(acct, app),
- })
+ if s.refuseStableOverAPI(w, p, acct, app, channel) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
@@ -1193,10 +1183,7 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
rules.PromoteApprovals, addr)
return
case rules.Passkey:
- writeJSON(w, http.StatusForbidden, map[string]string{
- "error": channel + " needs a passkey — finish in the console",
- "console_url": s.appConsoleURL(acct, app),
- })
+ s.writePasskeyRefusal(w, acct, app, channel)
return
}
signer := release.RootSigner(s.cfg.Key)
@@ -1209,9 +1196,7 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
return
}
signer = sg
- authorize = func(pp *release.Pipeline) error {
- return deployScopeAllows(p, pp != nil, channel, false)
- }
+ authorize = deployScopeAuthorize(p, channel)
}
if err := signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, authorize); err != nil {
var ae *release.AuthorizationError
@@ -1231,6 +1216,40 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
}
+// refuseStableOverAPI is the API's refusal of a pointer write onto stable
+// that is not a promotion (rollback, resign; Paul's ruling 2026-08-03): a
+// deploy credential gets its scope refusal, as it never holds the stable rung,
+// and everyone else the passkey 403. It reads nothing, so the refusal cannot
+// vary with the bucket. It returns true when it has written the response.
+func (s *Server) refuseStableOverAPI(w http.ResponseWriter, p Principal, acct *Account, app, channel string) bool {
+ if rung, _ := release.Rung(channel); rung != release.RungStable {
+ return false
+ }
+ if err := deployScopeAllows(p, false, channel, false); err != nil {
+ s.writeDeployRefusal(w, err)
+ return true
+ }
+ s.writePasskeyRefusal(w, acct, app, release.RungStable)
+ return true
+}
+
+// writePasskeyRefusal is the 403 for a pointer write the channel reserves to
+// a passkey in the browser, with the console page to finish it on.
+func (s *Server) writePasskeyRefusal(w http.ResponseWriter, acct *Account, app, channel string) {
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": channel + " needs a passkey — finish in the console",
+ "console_url": s.appConsoleURL(acct, app),
+ })
+}
+
+// deployScopeAuthorize is the deploy-scope check a pointer write asks again
+// inside its admitted mutation, of the pipeline read there.
+func deployScopeAuthorize(p Principal, channel string) func(*release.Pipeline) error {
+ return func(pp *release.Pipeline) error {
+ return deployScopeAllows(p, pp != nil, channel, false)
+ }
+}
+
// handleAPIRestart answers `carlos restart`: touch the app's restart
// generation, then bump the serial so every box serving it cycles that app's
// processes within seconds (issue #113). No version moves, no config is
diff --git a/internal/console/apidigests.go b/internal/console/apidigests.go
index c1c5ec307..be3d9266b 100644
--- a/internal/console/apidigests.go
+++ b/internal/console/apidigests.go
@@ -21,6 +21,14 @@ import (
// Member-level, like rollback, and on the same write budget, drawn before
// any store work. It is closed to service credentials (the zero Set): the
// confirmation is a person's.
+//
+// The plan is any member's to read. Signing it takes the channel's release
+// authority, the rollback door's (Astra r1 #2): resign binds bytes to a
+// version name, so a member who could not roll stable back cannot resign it
+// either. Stable is refused before the bucket is read, with the rollback
+// door's refusal; a pipelined channel's rules are asked inside the admitted
+// mutation (ResignAuthorized). A channel demanding approvals refuses as one
+// demanding a passkey does, since a resign has no proposal to make.
func (s *Server) handleAPIChannelResign(w http.ResponseWriter, r *http.Request) {
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
@@ -48,6 +56,9 @@ func (s *Server) handleAPIChannelResign(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusServiceUnavailable, "this console has no release key")
return
}
+ if req.Digest != "" && s.refuseStableOverAPI(w, p, acct, app, req.Channel) {
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
sg := release.RootSigner(s.cfg.Key)
@@ -67,11 +78,21 @@ func (s *Server) handleAPIChannelResign(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel))
return
}
- if err := sg.Resign(ctx, s.cfg.Releases, plan, addr); err != nil {
+ if err := sg.ResignAuthorized(ctx, s.cfg.Releases, plan, addr, resignAuthorize(p, req.Channel)); err != nil {
if errors.Is(err, release.ErrResignChanged) {
jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel))
return
}
+ var ae *release.AuthorizationError
+ if errors.As(err, &ae) {
+ var pr *passkeyRequired
+ if errors.As(ae.Err, &pr) {
+ s.writePasskeyRefusal(w, acct, app, pr.channel)
+ return
+ }
+ s.writeDeployRefusal(w, ae.Err)
+ return
+ }
log.Printf("console: api resign %s/%s %s: %v", acct.Sqid, app, req.Channel, err)
// TODO-COPY digests.console-pointer-write
jsonError(w, http.StatusBadGateway, "could not write the pointer")
@@ -86,6 +107,33 @@ func (s *Server) handleAPIChannelResign(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]string{"result": "resigned"})
}
+// passkeyRequired is resignAuthorize's refusal: the channel's release
+// authority is a passkey in the browser. The door answers it with
+// writePasskeyRefusal; its Error text is never shown.
+type passkeyRequired struct{ channel string }
+
+func (e *passkeyRequired) Error() string { return e.channel + ": passkey required" }
+
+// resignAuthorize is the rollback door's channel authority, asked inside the
+// resign's admitted mutation of the pipeline read there: deploy scope as
+// rollback asks it, stable unconditionally, and a pipelined channel whose
+// rules demand a passkey or approvals.
+func resignAuthorize(p Principal, channel string) func(*release.Pipeline) error {
+ scope := deployScopeAuthorize(p, channel)
+ return func(pp *release.Pipeline) error {
+ if err := scope(pp); err != nil {
+ return err
+ }
+ if rung, _ := release.Rung(channel); rung == release.RungStable {
+ return &passkeyRequired{channel: release.RungStable}
+ }
+ if rules := channelRules(pp, channel); rules.Passkey || rules.PromoteApprovals >= 1 {
+ return &passkeyRequired{channel: channel}
+ }
+ return nil
+ }
+}
+
func fmtResignChanged(channel string) string {
return fmt.Sprintf(release.TextResignChanged, channel)
}
diff --git a/internal/console/apidigests_test.go b/internal/console/apidigests_test.go
index 76903375b..072c1c801 100644
--- a/internal/console/apidigests_test.go
+++ b/internal/console/apidigests_test.go
@@ -19,7 +19,7 @@ import (
func TestResignDoorPlansThenSigns(t *testing.T) {
fx := newDigestsFixture(t)
- st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "stable"}, fx.member)
+ st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "beta"}, fx.member)
if st != http.StatusOK {
t.Fatalf("plan: %d %s", st, out)
}
@@ -35,17 +35,17 @@ func TestResignDoorPlansThenSigns(t *testing.T) {
}
if plan.Version != "v2" || len(plan.History) != 1 || plan.History[0] != "v1" || len(plan.HistoryDigests) != 1 ||
plan.HistoryDigests[0] == "" || len(plan.Computed) != 2 || !plan.Computed[0] || !plan.Computed[1] {
- t.Fatalf("plan = %+v, want v2 over v1, both digests read from the bucket now", plan)
+ t.Fatalf("plan = %+v, want v2 over v1, both digests read from the bucket now (beta carries no rules, so a member signs it)", plan)
}
- if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "stable"); p.Digest != "" {
+ if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "beta"); p.Digest != "" {
t.Fatal("planning signed something")
}
st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{
- "channel": "stable", "digest": plan.Digest, "history_digests": plan.HistoryDigests}, fx.member)
+ "channel": "beta", "digest": plan.Digest, "history_digests": plan.HistoryDigests}, fx.member)
if st != http.StatusOK || !strings.Contains(out, `"result":"resigned"`) {
t.Fatalf("sign: %d %s", st, out)
}
- p, err := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "stable")
+ p, err := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "beta")
if err != nil || p.Digest != plan.Digest || len(p.HistoryDigests) != 1 || p.HistoryDigests[0] != plan.HistoryDigests[0] {
t.Fatalf("pointer after resign = %+v, %v; want the plan's digests", p, err)
}
@@ -67,11 +67,11 @@ func TestResignDoorPlansThenSigns(t *testing.T) {
func TestResignDoorRefusesDigestsThatAreNotThePlan(t *testing.T) {
fx := newDigestsFixture(t)
st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{
- "channel": "stable", "digest": release.ManifestDigest([]byte("not it")), "history_digests": []string{""}}, fx.member)
- if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(release.TextResignChanged, "stable") {
+ "channel": "beta", "digest": release.ManifestDigest([]byte("not it")), "history_digests": []string{""}}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(release.TextResignChanged, "beta") {
t.Fatalf("mismatched confirmation: %d %s", st, out)
}
- if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "stable"); p.Digest != "" {
+ if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "beta"); p.Digest != "" {
t.Fatal("a refused confirmation signed something")
}
}
@@ -177,6 +177,113 @@ func TestRollbackDoorNamesResign(t *testing.T) {
}
}
+// resignPlanOf asks the resign door for channel's plan, as a member may.
+func (fx *digestsFixture) resignPlanOf(t *testing.T, channel string) (digest string, historyDigests []string) {
+ t.Helper()
+ st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": channel}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("plan %s: %d %s", channel, st, out)
+ }
+ var plan struct {
+ Digest string `json:"digest"`
+ HistoryDigests []string `json:"history_digests"`
+ }
+ if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.Digest == "" {
+ t.Fatalf("plan body %s: %v", out, err)
+ }
+ return plan.Digest, plan.HistoryDigests
+}
+
+// rollbackRefusal is what the rollback door answers the member for channel:
+// the refusal a resign of that channel must repeat.
+func (fx *digestsFixture) rollbackRefusal(t *testing.T, channel string) (int, string) {
+ t.Helper()
+ st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/rollback", map[string]string{"channel": channel}, fx.member)
+ if st != http.StatusForbidden {
+ t.Fatalf("control: the rollback door let the member roll %s back: %d %s", channel, st, out)
+ }
+ return st, out
+}
+
+// Astra r1 #2: signing a resign takes the rollback door's authority. A
+// member may read stable's plan, but confirming it is refused exactly as a
+// rollback of stable is, and the pointer is unchanged.
+func TestResignDoorRefusesAMemberOnStable(t *testing.T) {
+ fx := newDigestsFixture(t)
+ digest, hd := fx.resignPlanOf(t, "stable")
+ wantSt, wantBody := fx.rollbackRefusal(t, "stable")
+ st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{
+ "channel": "stable", "digest": digest, "history_digests": hd}, fx.member)
+ if st != wantSt || out != wantBody {
+ t.Fatalf("member resign of stable: %d %s, want the rollback door's %d %s", st, out, wantSt, wantBody)
+ }
+ if p := fx.channelVersion(t, "km", "stable"); p.Digest != "" || p.Version != "v2" {
+ t.Fatalf("a refused resign moved stable: %+v", p)
+ }
+}
+
+// Astra r1 #2's attack: a legacy stable pointer names v2, and v2's manifest
+// is replaced in the bucket (pruned and reshipped). The plan offers the new
+// bytes' digest; a member confirming it would bind stable's v2 to bytes no
+// one released. Refused as rollback is, and the pointer still signs nothing.
+func TestResignDoorRefusesALegacyHeadSubstitution(t *testing.T) {
+ fx := newDigestsFixture(t)
+ ctx := context.Background()
+ key := release.ManifestKey(release.OpsAccount, "km", "v2")
+ orig, etag, err := fx.store.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(orig, &m); err != nil {
+ t.Fatal(err)
+ }
+ m["built_at"] = "2026-10-07T00:00:00Z" // other bytes under the same version
+ swapped, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.store.Put(ctx, key, swapped, etag); err != nil {
+ t.Fatal(err)
+ }
+ digest, hd := fx.resignPlanOf(t, "stable")
+ if digest != release.ManifestDigest(swapped) {
+ t.Fatalf("the plan offers %s, want the substituted manifest's digest", digest)
+ }
+ wantSt, wantBody := fx.rollbackRefusal(t, "stable")
+ st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{
+ "channel": "stable", "digest": digest, "history_digests": hd}, fx.member)
+ if st != wantSt || out != wantBody {
+ t.Fatalf("member resign of a substituted stable head: %d %s, want %d %s", st, out, wantSt, wantBody)
+ }
+ if p := fx.channelVersion(t, "km", "stable"); p.Digest != "" {
+ t.Fatalf("stable was bound to the substituted bytes: %+v", p)
+ }
+}
+
+// A pipelined channel's rules govern resign as they govern rollback, asked
+// inside the admitted mutation: a passkey rule refuses with the rollback
+// door's refusal, and an approvals rule (which rollback turns into a
+// proposal; resign has none to make) refuses the same way. The pointer is
+// unchanged either way.
+func TestResignDoorAsksTheChannelsPipelineRules(t *testing.T) {
+ for _, rules := range []release.ChannelRules{{Passkey: true}, {PromoteApprovals: 1}} {
+ fx := newDigestsFixture(t)
+ digest, hd := fx.resignPlanOf(t, "beta")
+ setPipeline(t, fx.consoleFixture, "km", release.PipelineChannel{Name: "beta", Rules: release.ChannelRules{Passkey: true}})
+ wantSt, wantBody := fx.rollbackRefusal(t, "beta")
+ setPipeline(t, fx.consoleFixture, "km", release.PipelineChannel{Name: "beta", Rules: rules})
+ st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{
+ "channel": "beta", "digest": digest, "history_digests": hd}, fx.member)
+ if st != wantSt || out != wantBody {
+ t.Fatalf("rules %+v: member resign of beta: %d %s, want the rollback door's %d %s", rules, st, out, wantSt, wantBody)
+ }
+ if p := fx.channelVersion(t, "km", "beta"); p.Digest != "" {
+ t.Fatalf("rules %+v: a refused resign signed beta: %+v", rules, p)
+ }
+ }
+}
+
// jsonErrorText is the "error" of a jsonError body.
func jsonErrorText(t *testing.T, body string) string {
t.Helper()
@@ -190,8 +297,8 @@ func jsonErrorText(t *testing.T, body string) string {
}
// digestsFixture is newConsoleFixture with app km in the ops account bab:
-// v1 and v2 shipped, promoted v1 then v2 on stable and v1 on canary/x, all
-// before the account's digest marker went on, so none of them carries a
+// v1 and v2 shipped, promoted v1 then v2 on stable and on beta, and v1 on
+// canary/x, all before the account's digest marker went on, so none of them carries a
// digest. Then the marker, written with the root key (resign needs it, Astra
// I3 #4).
type digestsFixture struct {
@@ -217,6 +324,8 @@ func newDigestsFixture(t *testing.T) *digestsFixture {
seedChannel(t, fx, "km", "stable", "v1")
seedChannel(t, fx, "km", "stable", "v2")
seedChannel(t, fx, "km", "canary/x", "v1")
+ seedChannel(t, fx, "km", "beta", "v1")
+ seedChannel(t, fx, "km", "beta", "v2")
dx := &digestsFixture{
consoleFixture: fx,
store: fx.rel,
diff --git a/internal/release/claim_app_writes.go b/internal/release/claim_app_writes.go
index 41edc3114..50a030f07 100644
--- a/internal/release/claim_app_writes.go
+++ b/internal/release/claim_app_writes.go
@@ -45,12 +45,18 @@ func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, cha
})
}
-// Resign is claimRawResign under one app mutation.
+// Resign is ResignAuthorized with no authority hook.
func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor string) error {
+ return sg.ResignAuthorized(ctx, s, plan, actor, nil)
+}
+
+// ResignAuthorized is claimRawResign under one app mutation, with authorize
+// (nil = no hook) asked of the pipeline inside it, as RollbackAuthorized does.
+func (sg Signer) ResignAuthorized(ctx context.Context, s Store, plan *ResignPlan, actor string, authorize func(*Pipeline) error) error {
if plan == nil {
return errors.New("resign: no plan to sign")
}
return WithAppMutation(ctx, s, plan.Account, plan.App, "release.Resign", func(ctx context.Context) error {
- return sg.claimRawResign(ctx, s, plan, actor)
+ return sg.claimRawResign(ctx, s, plan, actor, authorize)
})
}
diff --git a/internal/release/resign.go b/internal/release/resign.go
index 26045383e..28c72f92d 100644
--- a/internal/release/resign.go
+++ b/internal/release/resign.go
@@ -72,9 +72,15 @@ func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel str
// claimRawResign signs the plan's pointer: the same version, history, audit
// trail and fast-track waiver, with the plan's digests, conditional on the
-// pointer being exactly the one the plan was made from. Resign is this under
-// the app mutation.
-func (sg Signer) claimRawResign(ctx context.Context, s Store, plan *ResignPlan, actor string) error {
+// pointer being exactly the one the plan was made from. Resign and
+// ResignAuthorized are this under the app mutation.
+//
+// authorize, when non-nil, is the caller's release authority over the
+// channel, asked of the pipeline read after the pointer, inside the same
+// admitted mutation, exactly as claimRawRollbackAuthorized asks it: resign
+// binds bytes to a version name, so it is a release decision (Astra r1 #2).
+// A refusal comes back as an AuthorizationError and nothing is written.
+func (sg Signer) claimRawResign(ctx context.Context, s Store, plan *ResignPlan, actor string, authorize func(*Pipeline) error) error {
if plan == nil {
return errors.New("resign: no plan to sign")
}
@@ -98,6 +104,15 @@ func (sg Signer) claimRawResign(ctx context.Context, s Store, plan *ResignPlan,
if err != nil {
return fmt.Errorf("resign: existing pointer at %s: %w", key, err)
}
+ if authorize != nil {
+ pipe, perr := GetPipeline(ctx, s, sg.verifyPub(), plan.Account, plan.App)
+ if perr != nil {
+ return fmt.Errorf("resign: cannot read the pipeline for %s/%s: %w", plan.Account, plan.App, perr)
+ }
+ if aerr := authorize(pipe); aerr != nil {
+ return &AuthorizationError{Err: aerr}
+ }
+ }
next := *old
// A pointer from before the account binding carries no account, which the
// verifier accepts and the signer refuses. A resigned pointer is a new
diff --git a/internal/release/resign_test.go b/internal/release/resign_test.go
index de3f5b07b..2d33da1b3 100644
--- a/internal/release/resign_test.go
+++ b/internal/release/resign_test.go
@@ -225,7 +225,7 @@ func TestResignBindsTheAccountOnAPreBindingPointer(t *testing.T) {
func TestResignRefusesAnInvalidPlan(t *testing.T) {
priv, _ := testKeys(t)
- if err := RootSigner(priv).claimRawResign(context.Background(), NewDirStore(t.TempDir()), &ResignPlan{Account: "bab", App: "km", Channel: "../x"}, "ops"); err == nil {
+ if err := RootSigner(priv).claimRawResign(context.Background(), NewDirStore(t.TempDir()), &ResignPlan{Account: "bab", App: "km", Channel: "../x"}, "ops", nil); err == nil {
t.Fatal("a plan with an invalid channel was signed")
}
}
@@ -281,3 +281,48 @@ func TestDigestReportListsEveryChannel(t *testing.T) {
t.Fatalf("report = %+v", rows)
}
}
+
+// Astra r1 #2: ResignAuthorized asks the caller's authority of the pipeline
+// read inside the mutation, after the pointer; a refusal is an
+// AuthorizationError and the pointer is untouched. With no hook (Resign) the
+// same plan signs.
+func TestResignAuthorizedAsksTheHookAndWritesNothingOnRefusal(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetPipeline(ctx, s, priv, "bab", "km", []PipelineChannel{{Name: "stable", Rules: ChannelRules{Passkey: true}}}, "t"); err != nil {
+ t.Fatal(err)
+ }
+ markerOn(t, s, priv, "bab")
+ sg := RootSigner(priv)
+ plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable")
+ if err != nil {
+ t.Fatal(err)
+ }
+ refusal := errors.New("needs a passkey")
+ var saw *Pipeline
+ err = sg.ResignAuthorized(ctx, s, plan, "ops@example.com", func(p *Pipeline) error {
+ saw = p
+ return refusal
+ })
+ var ae *AuthorizationError
+ if !errors.As(err, &ae) || !errors.Is(ae.Err, refusal) {
+ t.Fatalf("ResignAuthorized = %v, want the hook's refusal as an AuthorizationError", err)
+ }
+ if saw == nil || len(saw.Channels) != 1 || !saw.Channels[0].Rules.Passkey {
+ t.Fatalf("the hook saw pipeline %+v, want the app's", saw)
+ }
+ if p := readPtr(t, s, pub, "stable"); p.Digest != "" {
+ t.Fatalf("a refused resign wrote %+v", p)
+ }
+ if err := sg.ResignAuthorized(ctx, s, plan, "ops@example.com", func(*Pipeline) error { return nil }); err != nil {
+ t.Fatalf("an allowing hook: %v", err)
+ }
+ if p := readPtr(t, s, pub, "stable"); p.Digest != plan.Digest {
+ t.Fatalf("after an allowed resign = %+v", p)
+ }
+}