package console import ( "context" "errors" "fmt" "log" "net/http" "slices" "time" "github.com/carlosframework/platform/internal/release" ) // Copy Paul approved for pointer digests (#253), recorded in // docs/superpowers/plans/2026-10-07-instance-settings-1-copy.json and pinned // by approved_copy_digests_test.go. const ( textNoReleaseKey = "this console has no release key" textPointerWrite = "could not write the pointer" textMarkerWrite = "could not write the marker for %s" textActivityResigned = "re-signed %s at %s with manifest digests (cli)" ) // handleAPIChannelResign answers `carlos channels resign` (#253, spec §4.6). // A body naming only the channel gets the plan; a body that also carries // the digests the operator confirmed signs them, if a plan made now is // exactly that plan. The console signs with its root key for an app member, // as it does for a promotion. // // 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 { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, textTooManyRequests) return } var req struct { Channel string `json:"channel"` Digest string `json:"digest"` HistoryDigests []string `json:"history_digests"` } if !readJSON(w, r, &req) { return } if !release.ValidChannel(req.Channel) { jsonError(w, http.StatusBadRequest, "invalid channel name") return } if len(s.cfg.Key) == 0 { jsonError(w, http.StatusServiceUnavailable, textNoReleaseKey) 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) plan, err := sg.PlanResign(ctx, s.cfg.Releases, acct.Sqid, app, req.Channel) if err != nil { jsonError(w, http.StatusConflict, err.Error()) return } if req.Digest == "" { writeJSON(w, http.StatusOK, map[string]any{ "channel": plan.Channel, "version": plan.Version, "digest": plan.Digest, "history": plan.History, "history_digests": plan.HistoryDigests, "computed": plan.Computed, }) return } if req.Digest != plan.Digest || !slices.Equal(req.HistoryDigests, plan.HistoryDigests) { jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel)) return } 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) jsonError(w, http.StatusBadGateway, textPointerWrite) return } s.auditActivity(acct, app, addr, Activity{ Action: "resign", TargetType: "channel", Target: req.Channel, Detail: fmt.Sprintf(textActivityResigned, req.Channel, plan.Version), }) s.bumpSerial(ctx) 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) } // handleAPISystemPointerDigests is GET and POST /api/cli/system/pointer-digests: // the emission marker per account and the channels still without digests // (spec §4.6 "Watch for stragglers", "The fleet-wide switch"). A POST turns // the marker on or off for the accounts it names, or every account the // console knows, and answers the report as the GET does. func (s *Server) handleAPISystemPointerDigests(w http.ResponseWriter, r *http.Request) { addr, _, ok := s.apiSystemRoller(w, r) if !ok { return } if !s.systemBucket(w) { return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() if r.Method == http.MethodPost { p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, textTooManyRequests) return } var req struct { On bool `json:"on"` Accounts []string `json:"accounts"` } if !readJSON(w, r, &req) { return } if len(s.cfg.Key) == 0 { jsonError(w, http.StatusServiceUnavailable, textNoReleaseKey) return } accts, err := s.store.Accounts() if err != nil { jsonError(w, http.StatusInternalServerError, "could not load accounts") return } for _, a := range accts { if len(req.Accounts) > 0 && !slices.Contains(req.Accounts, a.Sqid) { continue } action := "pointer-digests-on" if req.On { err = release.WriteDigestMarker(ctx, s.cfg.Releases, release.RootSigner(s.cfg.Key), a.Sqid, addr) } else { action = "pointer-digests-off" err = release.RemoveDigestMarker(ctx, s.cfg.Releases, a.Sqid) } if err != nil { log.Printf("console: pointer digests %s: %v", a.Sqid, err) jsonError(w, http.StatusBadGateway, fmt.Sprintf(textMarkerWrite, a.Sqid)) return } s.auditSystem(addr, action, "account "+a.Sqid+" (cli)") } } accts, err := s.store.Accounts() if err != nil { jsonError(w, http.StatusInternalServerError, "could not load accounts") return } type acctRow struct { Account string `json:"account"` On bool `json:"on"` Since string `json:"since,omitempty"` Error string `json:"error,omitempty"` } type chRow struct { Account string `json:"account"` App string `json:"app"` Channel string `json:"channel"` Version string `json:"version"` Straggler bool `json:"straggler"` } acctRows, chRows := []acctRow{}, []chRow{} for _, a := range accts { row := acctRow{Account: a.Sqid} m, err := release.ReadDigestMarker(ctx, s.cfg.Releases, s.cfg.ReleasePub, a.Sqid) if err != nil { row.Error = err.Error() acctRows = append(acctRows, row) continue } if m != nil { row.On, row.Since = true, m.At.UTC().Format(time.RFC3339) } apps, err := s.store.AppsIn(a.ID) if err != nil { row.Error = "could not load apps" acctRows = append(acctRows, row) continue } for _, app := range apps { states, _, err := release.DigestReport(ctx, s.cfg.Releases, s.cfg.ReleasePub, a.Sqid, app) if err != nil { row.Error = err.Error() break } for _, st := range states { switch { case st.Err != "": // Unknown, not "has digests": the account's report is // incomplete, so the CLI never calls the fleet ready. if row.Error == "" { row.Error = st.Err } case !st.HeadDigest: chRows = append(chRows, chRow{Account: a.Sqid, App: app, Channel: st.Channel, Version: st.Version, Straggler: st.Straggler}) } } } acctRows = append(acctRows, row) } writeJSON(w, http.StatusOK, map[string]any{"accounts": acctRows, "channels": chRows}) }