| 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. |
| 24 | package console |
| 25 | |
| 26 | import ( |
| 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. |
| 45 | const ( |
| 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. |
| 58 | const 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. |
| 64 | type 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. |
| 97 | type 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. |
| 107 | type 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. |
| 116 | func (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. |
| 122 | func 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. |
| 131 | func 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. |
| 145 | func 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. |
| 201 | func (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. |
| 236 | func (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 "". |
| 264 | func (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". |
| 278 | func 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. |
| 309 | func (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. |
| 400 | func (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. |
| 419 | func 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. |
| 457 | func (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. |
| 473 | func (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. |
| 484 | func 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 | |
| 491 | func (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 | |
| 586 | func (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 | |
| 622 | func (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. |
| 661 | func (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. |
| 691 | func (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. |
| 755 | func (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. |
| 777 | func (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). |
| 814 | func (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 | |