carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The doors onto the pending-change primitive (pending.go): proposing a
2// gated action, confirming one until its quorum is met, rejecting one, and —
3// the only place any of it actually writes to the bucket — executing it.
4// Release pipelines v2 §4/§5
5// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md).
6//
7// Three shapes live here, and the difference between them is the whole
8// security story:
9//
10// - proposeOverAPI is the bearer-token shape. It starts at ZERO
11// confirmations however many members the quorum names, because a token
12// has proved nobody is present (spec §4). Its answer is 202 and a console
13// URL: "finish where the passkeys are".
14// - proposeThenExecute is the browser shape, and is only ever called from
15// BEHIND requireFreshAuth. That step-up is what earns the proposer their
16// own first confirmation — and when the quorum is 1, the change is
17// already met on creation and is carried out in the same request, exactly
18// as a passkey rule would have been.
19// - executePending is where a quorum-met change becomes a signed write. It
20// re-checks the world against the payload the proposal PINNED, so a
21// pipeline or a channel head that moved underneath a week-old proposal
22// invalidates it instead of landing something nobody approved. A failed
23// execution leaves the change unexecuted: it is retryable, never consumed.
24package console
25
26import (
27 "context"
28 "crypto/subtle"
29 "encoding/json"
30 "errors"
31 "fmt"
32 "log"
33 "net/http"
34 "net/url"
35 "os"
36 "strings"
37 "time"
38
39 "github.com/carlosframework/platform/internal/release"
40)
41
42// The three kinds of gated action, spelled once. They are stored in the
43// database, so they are a wire shape in the sense that matters here: a row
44// written before a rename must still be executable after it.
45const (
46 kindPromote = "promote"
47 kindPolicy = "policy"
48 kindFastTrack = "fasttrack"
49 // kindRouteChannel moves one ROUTE between channels — not a release
50 // anywhere, which is why it is its own kind rather than a promote with a
51 // different payload: nothing is published, and what changes is which
52 // pointer one host follows.
53 kindRouteChannel = "route-channel"
54)
55
56// pendingTimeout bounds the store round trips an execution makes — the same
57// budget the promote and pipeline doors give the bucket.
58const pendingTimeout = 15 * time.Second
59
60// promotePayload pins a promotion: this VERSION onto this CHANNEL, never
61// "whatever the head is when somebody gets around to confirming" (spec §4).
62// Hotfix rides along because it changes what execution is allowed to
63// re-check — order, and only order.
64type promotePayload struct {
65 Version string `json:"version"`
66 Channel string `json:"channel"`
67 Hotfix bool `json:"hotfix,omitempty"`
68 // Rollback marks a proposal that came from the rollback door rather than
69 // promote (server.go's browser handleRollback, apicli.go's API rollback
70 // door) — additive, so a row written before this field existed still
71 // parses and simply renders as a promotion, exactly as it did before
72 // (Task 8 review: pendingSummary said "promote vX to Y" for a rollback,
73 // which is illegible to a confirmer who never asked for vX). Display
74 // only for what lands: both doors already pin the OLDER version as a
75 // promotion with Hotfix set; execution reads it solely to voice the
76 // audit row and the flash as the rollback the confirmer approved.
77 Rollback bool `json:"rollback,omitempty"`
78 // ObservedHead is the target channel's version at PROPOSE time — spec
79 // §4's other pin: "a head that moved underneath a proposal invalidates
80 // it". Execution refuses when the channel no longer serves it. Additive:
81 // "" (an old row, or a channel with no pointer yet at propose time) pins
82 // nothing and keeps the pre-field behaviour.
83 ObservedHead string `json:"observed_head,omitempty"`
84 // Retry is `carlos promote --retry` (D1 of the console release
85 // defences): renew the channel's current promotion of Version with a
86 // fresh PromotedAt, never change the version. Execution passes it to
87 // PromoteOnLadder, whose retry rules (renew only, version guard at the
88 // write) then apply at the moment the write happens. Additive: an old
89 // row has none and is an ordinary promotion.
90 Retry bool `json:"retry,omitempty"`
91}
92
93// policyPayload pins a pipeline edit: the full proposed channel list, plus
94// the SetAt of the pipeline it was composed against. Execution refuses on a
95// mismatch — a stale channel list applied blind would silently revert
96// whatever landed in between.
97type policyPayload struct {
98 Channels []release.PipelineChannel `json:"channels"`
99 ObservedSetAt time.Time `json:"observed_set_at"`
100}
101
102// fastTrackPayload pins a waiver to the version it was proposed for.
103// release.FastTrack acts on the channel's CURRENT head by contract, so
104// execution compares that head with this pin first; without the comparison
105// the pin would be decoration and a confirmation would waive a build nobody
106// looked at.
107type fastTrackPayload struct {
108 Channel string `json:"channel"`
109 Version string `json:"version"`
110}
111
112// appConsoleURL is where a refusal or a proposal points a CLI: the app page,
113// which is where the passkeys are. One spelling, because these strings are
114// compared byte-for-byte by tests that exist to prove a member and an owner
115// get the same refusal.
116func (s *Server) appConsoleURL(acct *Account, app string) string {
117 return s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
118}
119
120// appPath is appConsoleURL's origin-relative form, for the redirects a
121// browser door ends on.
122func appPath(acct *Account, app string) string {
123 return "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
124}
125
126// channelRules is the policy a target channel carries. An app with no
127// pipeline, and a channel outside a declared one (canary/*, a -hotfix
128// target), carry none — which is spec §2's default-permissive rule, not a
129// gap: the legacy ladder's own gates still apply to a pipeline-less app, and
130// they are enforced where they always were.
131func channelRules(p *release.Pipeline, channel string) release.ChannelRules {
132 if p == nil {
133 return release.ChannelRules{}
134 }
135 if c, ok := p.ChannelNamed(channel); ok {
136 return c.Rules
137 }
138 return release.ChannelRules{}
139}
140
141// pendingSummary is one line naming what a change would do — for the CLI's
142// list, the console's card, and the audit trail. It reads the PINNED payload
143// and nothing else, so it describes what would land rather than what the
144// world currently looks like.
145func pendingSummary(pc PendingChange) string {
146 switch pc.Kind {
147 case kindPromote:
148 var p promotePayload
149 if err := json.Unmarshal(pc.Payload, &p); err != nil {
150 return "promote " + pc.App
151 }
152 if p.Rollback {
153 // "roll back Y to vX", not "promote vX to Y" — the same pin, read
154 // the way a confirmer who never asked for vX can actually judge
155 // it (Task 8 review): a rollback proposal pins the OLDER version
156 // as a promotion (Hotfix, order bypassed). What lands is
157 // unchanged by the flag; execution reads it only to voice the
158 // audit row and the flash in this same rollback wording
159 // (executePendingPromote).
160 return "roll back " + p.Channel + " to " + p.Version
161 }
162 out := "promote " + p.Version + " to " + p.Channel
163 if p.Hotfix {
164 out += " (hotfix — out of order)"
165 }
166 return out
167 case kindPolicy:
168 var p policyPayload
169 if err := json.Unmarshal(pc.Payload, &p); err != nil {
170 return "change the pipeline of " + pc.App
171 }
172 names := make([]string, len(p.Channels))
173 for i, c := range p.Channels {
174 names[i] = c.Name
175 }
176 return "change the pipeline to " + strings.Join(names, " → ")
177 case kindFastTrack:
178 var p fastTrackPayload
179 if err := json.Unmarshal(pc.Payload, &p); err != nil {
180 return "skip the safety delay on " + pc.App
181 }
182 return "skip the safety delay for " + p.Version + " on " + p.Channel
183 case kindRouteChannel:
184 var p channelMovePayload
185 if err := json.Unmarshal(pc.Payload, &p); err != nil {
186 return "move a route of " + pc.App
187 }
188 // Both ends, because a confirmer judging this needs to know what the
189 // URL serves NOW as much as what it would serve after.
190 return "move " + p.Host + " from " + p.From + " to " + p.To
191 }
192 return pc.Kind + " on " + pc.App
193}
194
195// ---- proposing ---------------------------------------------------------------
196
197// proposeOverAPI records a bearer-token proposal and answers 202. Zero
198// confirmations, always: spec §4's load-bearing rule is that a token never
199// counts toward a passkey quorum, including for a quorum of one and
200// including when the same person could have confirmed it in a browser.
201func (s *Server) proposeOverAPI(w http.ResponseWriter, acct *Account, app, kind string, payload any, quorum int, proposer string) {
202 raw, err := json.Marshal(payload)
203 if err != nil {
204 log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err)
205 jsonError(w, http.StatusInternalServerError, "could not record the proposal — try again")
206 return
207 }
208 pc, err := s.store.ProposeChange(acct.ID, app, kind, raw, quorum, proposer, false, time.Now())
209 if err != nil {
210 log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err)
211 jsonError(w, http.StatusInternalServerError, "could not record the proposal — try again")
212 return
213 }
214 s.audit(acct, app, proposer, "proposed", pendingSummary(*pc)+
215 fmt.Sprintf(" (needs %d confirmation(s) in the console)", quorum))
216 writeJSON(w, http.StatusAccepted, map[string]any{
217 "result": "proposed",
218 "pending_id": pc.ID,
219 "needed": pc.Quorum,
220 "confirmed": len(pc.Confirms),
221 "console_url": s.appConsoleURL(acct, app),
222 })
223}
224
225// proposeThenExecute is the browser half. CALL IT ONLY FROM BEHIND
226// requireFreshAuth: proposerConfirmed=true is a claim that this proposal is
227// passkey-backed, and it is the difference between a quorum of humans and a
228// quorum of one person's session cookie.
229//
230// A quorum of 1 is met the moment the row exists, and the change is carried
231// out in the same request — with the proposer's presence just proved, there
232// is nobody left to ask, and parking it for a second confirmation nobody owes
233// would leave a "pending" card that only its own author can clear.
234//
235// Returns the flash kind and message the caller redirects with.
236func (s *Server) proposeThenExecute(ctx context.Context, acct *Account, app, kind string, payload any, quorum int, proposer string) (string, string) {
237 raw, err := json.Marshal(payload)
238 if err != nil {
239 log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err)
240 return "err", "Could not record that proposal. Please try again."
241 }
242 pc, err := s.store.ProposeChangeContext(ctx, acct.ID, app, kind, raw, quorum, proposer, true, time.Now())
243 if err != nil {
244 log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err)
245 return "err", "Could not record that proposal. Please try again."
246 }
247 if len(pc.Confirms) < pc.Quorum {
248 s.audit(acct, app, proposer, "proposed", fmt.Sprintf("%s (%d of %d confirmations)",
249 pendingSummary(*pc), len(pc.Confirms), pc.Quorum))
250 return "ok", fmt.Sprintf("Proposed: %s. %d of %d confirmations — it happens when the rest confirm it here with their passkeys.",
251 pendingSummary(*pc), len(pc.Confirms), pc.Quorum)
252 }
253 msg, err := s.executePending(ctx, acct, app, proposer, pc)
254 if err != nil {
255 return "err", "Could not carry that out: " + err.Error()
256 }
257 return "ok", msg
258}
259
260// channelHead is the target channel's current version — read at a propose
261// site to pin promotePayload.ObservedHead, and again at execution to check
262// the pin. "" with a nil error means the channel has no pointer yet; any
263// real read/verify failure is the error, never folded into "".
264func (s *Server) channelHead(ctx context.Context, acct *Account, app, channel string) (string, error) {
265 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
266 if errors.Is(err, os.ErrNotExist) {
267 return "", nil
268 }
269 if err != nil {
270 return "", err
271 }
272 return cur.Version, nil
273}
274
275// policyProposal builds a policy payload against the pipeline it was composed
276// from. cur is nil for an app that has never had one, whose zero SetAt is
277// itself a legitimate pin: "there was nothing here when I proposed this".
278func policyProposal(cur *release.Pipeline, next []release.PipelineChannel) policyPayload {
279 p := policyPayload{Channels: next}
280 if cur != nil {
281 p.ObservedSetAt = cur.SetAt
282 }
283 return p
284}
285
286// ---- executing ---------------------------------------------------------------
287
288// executePending carries out a change whose quorum is met — the ONE place a
289// gated write happens. Per kind: re-check the pin, write, announce, audit,
290// and only then mark the change executed.
291//
292// An error return means nothing landed and the change is still pending: a
293// caller must not mark it executed, and the member who confirmed it is told
294// why rather than left believing it went through.
295//
296// The first checks are this function's OWN preconditions, restated rather
297// than assumed. Every caller today establishes them — quorum via
298// ConfirmChange's `met`, the app via pendingScoped's 404 — but this is billed
299// as the one place a gated write happens, and a future caller (the console's
300// own pipeline page, Task 9) that got either wrong would otherwise turn a
301// mistake into an unapproved signed write. They are errors, never panics: a
302// bug here must refuse, not take the console down.
303//
304// The quorum precondition is not a restatement, though: ConfirmChange counts
305// ROWS against the number STORED on the proposal, and both of those can be
306// stale by the time anyone confirms. This is where the CURRENT rule and the
307// CURRENT membership are what the change has to meet — see the comment on the
308// check itself.
309func (s *Server) executePending(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
310 if pc.AccountID != acct.ID || pc.App != app {
311 log.Printf("console: execute %s %s: change belongs to %d/%q, not %d/%q",
312 pc.Kind, pc.ID, pc.AccountID, pc.App, acct.ID, app)
313 return "", errors.New("that change belongs to a different app")
314 }
315 // The quorum this change must meet NOW, not merely the one it was
316 // proposed under. A proposal can wait a week (PendingChangeTTL), and both
317 // halves of "N distinct members" can move underneath it:
318 //
319 // - the RULE can be tightened. Executing at the stored number would make
320 // a stale proposal the way past a raised rule — propose at 2, wait for
321 // somebody to raise it to 3, land it with 2. Required is therefore
322 // max(stored, current). Loosening never lowers the stored number: the
323 // confirmers agreed to something that needed N of them, and one person
324 // lowering the rule afterwards is not the others' confirmation.
325 // - the MEMBERSHIP can change. A confirmation is standing authority, and
326 // an address that has left the account has none, so its row stops
327 // counting (MemberConfirms) while staying on the record.
328 //
329 // Both are refusals, never consumptions: the change stays unexecuted,
330 // listed and retryable, and the remaining members can still meet it.
331 required, err := s.currentQuorum(ctx, acct, app, pc)
332 if err != nil {
333 return "", err
334 }
335 counted, err := s.store.MemberConfirms(pc.AccountID, pc.ID)
336 if err != nil {
337 log.Printf("console: execute %s %s: member confirms: %v", pc.Kind, pc.ID, err)
338 return "", errors.New("could not check who has confirmed this change")
339 }
340 if len(counted) < required {
341 log.Printf("console: execute %s %s: %d of %d confirmations counted (%d rows, quorum stored %d)",
342 pc.Kind, pc.ID, len(counted), required, len(pc.Confirms), pc.Quorum)
343 return "", fmt.Errorf("that change has %d of %d confirmations", len(counted), required)
344 }
345 // From the gate onwards, this change's confirmations ARE the counted
346 // ones: every audit row below reports "%d confirmations" from here, and a
347 // trail that credited a departed member's row would record a quorum that
348 // was never met (PR #258 review, I3). The rows themselves are untouched
349 // in the database — this narrows only what the EXECUTION claims it had.
350 pc.Confirms = counted
351 if s.cfg.Key == nil {
352 return "", errors.New("no signing key on this host — the console is running read-only")
353 }
354 var msg string
355 switch pc.Kind {
356 case kindPromote:
357 msg, err = s.executePendingPromote(ctx, acct, app, actor, pc)
358 case kindPolicy:
359 msg, err = s.executePendingPolicy(ctx, acct, app, actor, pc)
360 case kindFastTrack:
361 msg, err = s.executePendingFastTrack(ctx, acct, app, actor, pc)
362 case kindRouteChannel:
363 msg, err = s.executePendingRouteChannel(ctx, acct, app, actor, pc)
364 default:
365 return "", fmt.Errorf("this console does not know how to carry out a %q change", pc.Kind)
366 }
367 if err != nil {
368 return "", err
369 }
370 if err := s.store.MarkExecutedContext(ctx, pc.AccountID, pc.App, pc.ID, time.Now()); err != nil {
371 // The write above already landed. Failing the caller here would say
372 // "that didn't work" about something that did; the cost of the miss
373 // is a change that stays listed, and every kind's own pin refuses a
374 // second execution of the same payload.
375 log.Printf("console: mark pending change %s executed: %v", pc.ID, err)
376 }
377 return msg, nil
378}
379
380// currentQuorum is how many confirmations this change must have RIGHT NOW:
381// the number it was proposed under, raised to the target channel's current
382// rule if that rule has since been tightened. Never lowered — see
383// executePending's comment for why a loosening is not a confirmation.
384//
385// It costs one pipeline read that the promote path then makes again a moment
386// later. That duplication is deliberate: this is the gate, and a gate that
387// depends on a value threaded down through three kind-specific functions is a
388// gate that a fourth kind can be added without. A gated execution is a rare,
389// human-paced action; the read is not a cost worth trading the shape for.
390//
391// Every kind pays the read, INCLUDING a policy edit — the pipeline is fetched
392// before the kind is looked at, so an unreadable one fails the execution
393// closed whatever it is. What a policy edit does not do is raise its number
394// from a channel rule: its quorum comes from the channels its own edit
395// TOUCHES (guardedEdit), and executePendingPolicy refuses outright unless the
396// stored pipeline is byte-for-byte the one the proposal was composed against
397// (the SetAt pin). A rule that moved at all invalidates the proposal before
398// any quorum question is asked, so for that kind requiredQuorum returns the
399// stored number and max(stored, current) could only ever equal it.
400func (s *Server) currentQuorum(ctx context.Context, acct *Account, app string, pc *PendingChange) (int, error) {
401 pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
402 if err != nil {
403 // Fail closed, like every other unreadable-pipeline path: a proposal
404 // whose governing rules cannot be read is not executable.
405 log.Printf("console: execute %s %s/%s: read pipeline: %v", pc.Kind, acct.Sqid, app, err)
406 return 0, errors.New("could not read the pipeline")
407 }
408 return requiredQuorum(pc, pipe)
409}
410
411// requiredQuorum is currentQuorum's pure half: max(the number stored on the
412// proposal, the target channel's rule in pipe). Shared with the DISPLAY
413// surfaces (pendingChangeViews, handleAPIPending) so a card can never promise
414// a quorum execution will not accept — one rule, one place.
415//
416// A nil pipe (legacy app, or a display caller whose best-effort read failed)
417// carries no rules, so the answer is the stored quorum: the pre-existing
418// behaviour, and the safe direction for a display.
419func requiredQuorum(pc *PendingChange, pipe *release.Pipeline) (int, error) {
420 var channel string
421 var rule func(release.ChannelRules) int
422 switch pc.Kind {
423 case kindPromote:
424 var p promotePayload
425 if err := json.Unmarshal(pc.Payload, &p); err != nil {
426 return 0, errors.New("this proposal's payload cannot be read — reject it and propose again")
427 }
428 channel, rule = p.Channel, func(r release.ChannelRules) int { return r.PromoteApprovals }
429 case kindFastTrack:
430 var p fastTrackPayload
431 if err := json.Unmarshal(pc.Payload, &p); err != nil {
432 return 0, errors.New("this proposal's payload cannot be read — reject it and propose again")
433 }
434 // A fast-track is gated by change_approvals, not promote_approvals
435 // (spec §2: one field covers editing a channel's entry AND overriding
436 // its bake), so it must be re-checked against that same field.
437 channel, rule = p.Channel, func(r release.ChannelRules) int { return r.ChangeApprovals }
438 default:
439 return pc.Quorum, nil
440 }
441 if n := rule(channelRules(pipe, channel)); n > pc.Quorum {
442 return n, nil
443 }
444 return pc.Quorum, nil
445}
446
447// pendingTally is the two numbers every SURFACE shows for one pending change
448// — the card, the CLI list, the confirm flash and the audit row — computed
449// the way execution counts them: confirmations from current members, against
450// max(stored quorum, current rule). Best-effort by construction: a failed
451// membership read or an unreadable payload degrades to the raw row rather
452// than costing the page, and execution re-checks both for real anyway.
453//
454// The gap between this and len(pc.Confirms) is deliberately not hidden: a
455// change stuck because someone left the account shows fewer confirmations
456// than it has rows, which is the honest explanation for why it is stuck.
457func (s *Server) pendingTally(pc PendingChange, pipe *release.Pipeline) (confirmed, needed int) {
458 confirmed, needed = len(pc.Confirms), pc.Quorum
459 if counted, err := s.store.MemberConfirms(pc.AccountID, pc.ID); err != nil {
460 log.Printf("console: pending tally %s: member confirms: %v", pc.ID, err)
461 } else {
462 confirmed = len(counted)
463 }
464 if n, err := requiredQuorum(&pc, pipe); err == nil && n > needed {
465 needed = n
466 }
467 return confirmed, needed
468}
469
470// pendingPipeline is the display surfaces' best-effort pipeline read: one per
471// listing, never fatal. A WRITE door reads its own copy and refuses on error
472// (currentQuorum); a card that cannot read the pipeline still renders.
473func (s *Server) pendingPipeline(ctx context.Context, acct *Account, app string) *release.Pipeline {
474 pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
475 if err != nil {
476 log.Printf("console: pending tally %s/%s: read pipeline: %v", acct.Sqid, app, err)
477 return nil
478 }
479 return pipe
480}
481
482// staleProposal is the one refusal every kind shares: the world the proposal
483// pinned is not the world any more.
484func staleProposal(detail string) error {
485 if detail == "" {
486 return errors.New("the pipeline moved underneath this proposal — re-propose")
487 }
488 return errors.New("the pipeline moved underneath this proposal — re-propose (" + detail + ")")
489}
490
491func (s *Server) executePendingPromote(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
492 var p promotePayload
493 if err := json.Unmarshal(pc.Payload, &p); err != nil {
494 return "", errors.New("this proposal's payload cannot be read — reject it and propose again")
495 }
496 pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
497 if err != nil {
498 log.Printf("console: execute promote %s/%s: read pipeline: %v", acct.Sqid, app, err)
499 return "", errors.New("could not read the pipeline")
500 }
501 // The nil guard is not defensive tidiness: VersionStage dereferences the
502 // pipeline it is handed, and an app whose pipeline was deleted while a
503 // proposal waited has no order left for that proposal to be judged
504 // against. Refuse, rather than fall back to a ladder nobody approved.
505 if pipe == nil {
506 return "", staleProposal("this app no longer has a pipeline")
507 }
508 // The head pin (spec §4: "a head that moved underneath a proposal
509 // invalidates it"), checked for EVERY promote-kind proposal — hotfixes
510 // and rollbacks bypass the order re-check below, never this one. On a
511 // mismatch the proposal can never succeed, but it is refused rather than
512 // consumed — surfaced exactly as the policy pin's SetAt mismatch is: an
513 // error return, nothing written, the change still listed until someone
514 // rejects or re-proposes. An empty pin (an old row, or a channel that
515 // had no pointer at propose time) keeps the pre-field behaviour.
516 //
517 // A retry is pinned harder than this, by PromoteOnLadder itself: its
518 // version guard requires the head to be Version on the very pointer the
519 // write is conditional on (and the propose door only lets a retry out
520 // with head == Version). It is left to that guard so a moved channel
521 // fails with the retry's own refusal, "renews a promotion".
522 if p.ObservedHead != "" && !p.Retry {
523 head, herr := s.channelHead(ctx, acct, app, p.Channel)
524 if herr != nil {
525 log.Printf("console: execute promote %s/%s: read channel %s: %v", acct.Sqid, app, p.Channel, herr)
526 return "", errors.New("could not read channel " + p.Channel)
527 }
528 if head != p.ObservedHead {
529 return "", errors.New("the channel moved underneath this proposal — re-propose (" +
530 p.Channel + " no longer serves " + p.ObservedHead + ")")
531 }
532 }
533 // No order re-check for a retry: it moves nothing along the pipeline,
534 // the same reason PromoteOnLadder's repeat path asks no order question.
535 if !p.Hotfix && !p.Retry {
536 stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, p.Version, pipe)
537 if serr != nil {
538 log.Printf("console: execute promote %s/%s: stage: %v", acct.Sqid, app, serr)
539 return "", errors.New("could not work out where this build sits in the pipeline")
540 }
541 if aerr := release.PipelinePromotionAllowed(pipe, stage, p.Channel); aerr != nil {
542 return "", staleProposal(aerr.Error())
543 }
544 }
545 // A daemon release waiting in a proposal meets the plan as it stands at
546 // execution, not at propose time (Astra plans-3b finding 2). Rollbacks
547 // too: the target need be on no channel now (round 2 finding 2). A retry
548 // only renews the version the channel already serves.
549 // With plans off this reads nothing (checkDaemonVersion).
550 if !p.Retry {
551 if cerr := s.checkDaemonVersion(ctx, acct, app, p.Version); cerr != nil {
552 var planCap *planCapError
553 if errors.As(cerr, &planCap) {
554 return "", errors.New(planCap.Browser())
555 }
556 log.Printf("console: execute promote %s/%s: plan: %v", acct.Sqid, app, cerr)
557 return "", errors.New(planCheckFailedCopy)
558 }
559 }
560 res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, p.Version,
561 release.PromoteOptions{Actor: actor, Hotfix: p.Hotfix, Retry: p.Retry})
562 if err != nil {
563 log.Printf("console: execute promote %s %s -> %s: %v", app, p.Version, p.Channel, err)
564 return "", err
565 }
566 if res.NoOp {
567 // Nothing moved, so nothing is announced and nothing is audited —
568 // the same silence the promote doors keep on a repeat.
569 return p.Channel + " already serves " + p.Version + tagSuffix(res) + " — nothing to do.", nil
570 }
571 s.bumpSerial(ctx)
572 if p.Rollback {
573 // The card said "roll back" (pendingSummary); the trail and the
574 // flash must say what the confirmer approved, in the house voice the
575 // direct rollback door already uses — never "promoted vX" for a
576 // build nobody asked to promote.
577 s.auditActivity(acct, app, actor, Activity{Action: "rollback", TargetType: "channel", Target: p.Channel, Detail: fmt.Sprintf("rolled back %s to %s (proposed by %s, %d confirmations)",
578 p.Channel, p.Version, pc.Proposer, len(pc.Confirms))})
579 return "Rolled back " + p.Channel + " to " + p.Version + tagSuffix(res) + ".", nil
580 }
581 s.auditActivity(acct, app, actor, Activity{Action: "promote", TargetType: "channel", Target: p.Channel, Detail: fmt.Sprintf("promoted %s to %s (proposed by %s, %d confirmations)",
582 p.Version, p.Channel, pc.Proposer, len(pc.Confirms))})
583 return "Promoted " + p.Version + " to " + p.Channel + tagSuffix(res) + ".", nil
584}
585
586func (s *Server) executePendingPolicy(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
587 var p policyPayload
588 if err := json.Unmarshal(pc.Payload, &p); err != nil {
589 return "", errors.New("this proposal's payload cannot be read — reject it and propose again")
590 }
591 cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
592 if err != nil {
593 log.Printf("console: execute policy %s/%s: read pipeline: %v", acct.Sqid, app, err)
594 return "", errors.New("could not read the current pipeline")
595 }
596 var setAt time.Time
597 if cur != nil {
598 setAt = cur.SetAt
599 }
600 // The pin (spec §4). A policy edit is a whole-list replacement, so a
601 // pipeline that moved since the proposal was composed would be silently
602 // reverted by landing this list — including, potentially, a tightening
603 // somebody else made in between.
604 if !setAt.Equal(p.ObservedSetAt) {
605 return "", staleProposal("it was proposed against an older pipeline")
606 }
607 set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channels, actor)
608 if err != nil {
609 status, msg := classifySetPipelineError(err)
610 if status != http.StatusUnprocessableEntity {
611 log.Printf("console: execute policy %s/%s: set: %v", acct.Sqid, app, err)
612 }
613 return "", errors.New(msg)
614 }
615 s.ensureProductionForBakes(ctx, acct, app, set, actor)
616 s.bumpSerial(ctx)
617 s.audit(acct, app, actor, "pipeline", pipelineAuditDetail(set,
618 fmt.Sprintf("proposed by %s, %d confirmations", pc.Proposer, len(pc.Confirms))))
619 return "Pipeline updated.", nil
620}
621
622func (s *Server) executePendingFastTrack(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
623 var p fastTrackPayload
624 if err := json.Unmarshal(pc.Payload, &p); err != nil {
625 return "", errors.New("this proposal's payload cannot be read — reject it and propose again")
626 }
627 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, p.Channel)
628 if err != nil {
629 log.Printf("console: execute fast-track %s/%s: read channel: %v", acct.Sqid, app, err)
630 return "", errors.New("could not read channel " + p.Channel)
631 }
632 // FastTrack waives the CURRENT head. What was approved is a specific
633 // build, so a head that moved since invalidates the waiver rather than
634 // transferring it to whatever arrived.
635 if cur.Version != p.Version {
636 return "", errors.New("channel " + p.Channel + " now serves " + cur.Version +
637 ", not the " + p.Version + " this was proposed for — re-propose")
638 }
639 ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, actor)
640 if err != nil {
641 log.Printf("console: execute fast-track %s %s: %v", app, p.Channel, err)
642 return "", err
643 }
644 s.bumpSerial(ctx)
645 s.audit(acct, app, actor, "fast-track", fmt.Sprintf(
646 "skipped the safety delay for %s on %s (proposed by %s, %d confirmations)",
647 ver, p.Channel, pc.Proposer, len(pc.Confirms)))
648 return "Skipped the delay for " + ver + " on " + p.Channel + " — it rolls out within a couple of minutes.", nil
649}
650
651// ---- the browser doors -------------------------------------------------------
652
653// pendingScoped resolves the change this request names, and refuses with the
654// byte-identical 404 for anything that is not a live change of THIS account's
655// THIS app. That single refusal covers an id that never existed, one that
656// belongs to another account, one that expired and one that already ran —
657// Store.PendingChange treats the last two as absence, and the account/app
658// comparison here is the whole of the authorization scoping, because
659// ConfirmChange takes no account id and would happily confirm a stranger's
660// change for anyone who learned its id.
661func (s *Server) pendingScoped(w http.ResponseWriter, r *http.Request, acct *Account, app string) *PendingChange {
662 id := r.PathValue("id")
663 pc, err := s.store.PendingChange(id, time.Now())
664 if err != nil {
665 log.Printf("console: pending change %s: %v", id, err)
666 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read that change. Please try again.")
667 return nil
668 }
669 if pc == nil || pc.AccountID != acct.ID || pc.App != app {
670 http.NotFound(w, r)
671 return nil
672 }
673 return pc
674}
675
676// handlePendingConfirm records one member's passkey-backed confirmation and,
677// when that meets the quorum, carries the change out.
678//
679// Gate order, the mandated one: session + membership (accountApp, 404 for a
680// non-member) → CSRF → signing key → the change's own 404 → requireFreshAuth
681// LAST. Membership is the gate here rather than ownership: the quorum names
682// N MEMBERS, and demanding ownership on top would make an owner's absence a
683// way to freeze every gated action in the account.
684//
685// ConfirmChange's three refusals ("no such", "expired", "already executed")
686// are only reachable as a race with another request between the 404 above and
687// the write, so mapping them can leak nothing: by then the caller has been
688// proved a member of the account that owns the change. They map to 404 / 410
689// / 409 respectively — an unknown id stays indistinguishable from another
690// account's, because both were already answered by the 404 above.
691func (s *Server) handlePendingConfirm(w http.ResponseWriter, r *http.Request) {
692 addr, raw, acct, _, app, ok := s.accountApp(w, r)
693 if !ok {
694 return
695 }
696 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
697 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
698 return
699 }
700 if s.cfg.Key == nil {
701 s.renderMessage(w, r, http.StatusServiceUnavailable, "Confirmations disabled",
702 "No signing key on this host — the console is running read-only.")
703 return
704 }
705 pc := s.pendingScoped(w, r, acct, app)
706 if pc == nil {
707 return
708 }
709 if !s.requireFreshAuth(w, r, addr, raw) {
710 return
711 }
712 back := func(kind, msg string) {
713 s.setFlash(w, kind, msg)
714 http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther)
715 }
716 pc, _, err := s.store.ConfirmChangeContext(r.Context(), acct.ID, app, pc.ID, addr, time.Now())
717 if err != nil {
718 s.refuseConfirm(w, r, err)
719 return
720 }
721 ctx, cancel := context.WithTimeout(r.Context(), pendingTimeout)
722 defer cancel()
723 // ConfirmChange's own `met` counts ROWS against the STORED quorum, which
724 // is not what execution requires (see executePending): a change whose
725 // rule was raised, or whose confirmations include somebody who has since
726 // left the account, is not met however many rows it carries. The tally
727 // shown and audited here is the one that decides, so the flash a member
728 // gets and the row the trail keeps say the same thing the gate does.
729 // Deciding on it also keeps the not-met path a plain progress message
730 // rather than an execution that refuses a beat later.
731 confirmed, needed := s.pendingTally(*pc, s.pendingPipeline(ctx, acct, app))
732 if confirmed < needed {
733 s.audit(acct, app, addr, "confirmed", fmt.Sprintf("%s (%d of %d confirmations)",
734 pendingSummary(*pc), confirmed, needed))
735 back("ok", fmt.Sprintf("Confirmed: %s. %d of %d confirmations.",
736 pendingSummary(*pc), confirmed, needed))
737 return
738 }
739 msg, err := s.executePending(ctx, acct, app, addr, pc)
740 if err != nil {
741 back("err", "Could not carry that out: "+err.Error())
742 return
743 }
744 back("ok", msg)
745}
746
747// refuseConfirm maps ConfirmChange's own refusals onto the wire. See
748// handlePendingConfirm's doc comment for why this cannot be an oracle.
749//
750// errors.Is, never the message text: these are sentinel values (pending.go)
751// precisely so that rewording a refusal cannot silently turn a 410 or a 409
752// into the 500 fallback — "please try again" is the wrong thing to tell
753// somebody about a change that expired, and a wrong status is the kind of
754// regression no test catches unless the mapping is pinned to values.
755func (s *Server) refuseConfirm(w http.ResponseWriter, r *http.Request, err error) {
756 switch {
757 case errors.Is(err, ErrNoPendingChange):
758 http.NotFound(w, r)
759 case errors.Is(err, ErrPendingChangeExpired):
760 s.renderMessage(w, r, http.StatusGone, "Expired",
761 "That change expired before it collected enough confirmations. Nothing has been changed — propose it again.")
762 case errors.Is(err, ErrPendingChangeExecuted):
763 s.renderMessage(w, r, http.StatusConflict, "Already done",
764 "That change was carried out while you were confirming it. Nothing else has been changed.")
765 default:
766 log.Printf("console: confirm pending change: %v", err)
767 s.renderMessage(w, r, http.StatusInternalServerError, "Error",
768 "Could not record your confirmation. Please try again.")
769 }
770}
771
772// handlePendingReject deletes a proposal outright. The proposer or an account
773// owner may do it, and NEITHER needs a passkey: refusing authority is not
774// exercising it, and a rejection that failed closed on an unreachable Keymail
775// would leave a change nobody could withdraw sitting one confirmation away
776// from landing.
777func (s *Server) handlePendingReject(w http.ResponseWriter, r *http.Request) {
778 addr, raw, acct, role, app, ok := s.accountApp(w, r)
779 if !ok {
780 return
781 }
782 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
783 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
784 return
785 }
786 pc := s.pendingScoped(w, r, acct, app)
787 if pc == nil {
788 return
789 }
790 // A member who is neither the proposer nor an owner meets the same 404
791 // the change's own scope check gives — they learn nothing about a change
792 // they have no say over.
793 if pc.Proposer != addr && role != RoleOwner {
794 http.NotFound(w, r)
795 return
796 }
797 if err := s.store.DeleteChangeContext(r.Context(), acct.ID, app, pc.ID); err != nil {
798 log.Printf("console: reject pending change %s: %v", pc.ID, err)
799 s.setFlash(w, "err", "Could not withdraw that change. Please try again.")
800 http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther)
801 return
802 }
803 s.audit(acct, app, addr, "rejected", pendingSummary(*pc)+" (proposed by "+pc.Proposer+")")
804 s.setFlash(w, "ok", "Withdrawn: "+pendingSummary(*pc)+". Nothing was changed.")
805 http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther)
806}
807
808// ---- the CLI door ------------------------------------------------------------
809
810// handleAPIPending answers `carlos pipeline`'s pending list: what this app is
811// waiting on, and how far along each one is. Read-only and Publish-reachable,
812// like channels and releases — a proposal names no secret, and the CLI needs
813// it precisely because the CLI cannot confirm anything itself (spec §4).
814func (s *Server) handleAPIPending(w http.ResponseWriter, r *http.Request) {
815 _, acct, app, ok := s.apiAccountApp(w, r)
816 if !ok {
817 return
818 }
819 list, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now())
820 if err != nil {
821 log.Printf("console: api pending %s/%s: %v", acct.Sqid, app, err)
822 jsonError(w, http.StatusInternalServerError, "could not read pending changes")
823 return
824 }
825 // The same tally the card shows, from one pipeline read: "confirmed" and
826 // "needed" are what EXECUTION will count, so `carlos pipeline` cannot
827 // print a change as ready when confirming it would refuse (PR #258
828 // review, I3).
829 var pipe *release.Pipeline
830 if len(list) > 0 {
831 pipe = s.pendingPipeline(r.Context(), acct, app)
832 }
833 out := make([]map[string]any, 0, len(list))
834 for _, pc := range list {
835 confirmed, needed := s.pendingTally(pc, pipe)
836 out = append(out, map[string]any{
837 "id": pc.ID,
838 "kind": pc.Kind,
839 "summary": pendingSummary(pc),
840 "confirmed": confirmed,
841 "needed": needed,
842 })
843 }
844 writeJSON(w, http.StatusOK, map[string]any{"pending": out})
845}
846