| 1 | // The CLI's ledger API: the ONLY write path to ledgers/ from outside a |
| 2 | // box. Contributor machines hold no bucket credentials — the console |
| 3 | // performs the CAS append server-side and stamps the writer from the |
| 4 | // authenticated identity. Member-level, same gate as promote: publishing |
| 5 | // an app's ledger is ordinary member work. |
| 6 | // |
| 7 | // Every route here begins with apiAccountApp, so an unknown account and one |
| 8 | // the caller doesn't belong to are the same 404 on the WRITE doors too, and |
| 9 | // none of them consult the manifest's public flag — opt-in gates serving, |
| 10 | // not writing (spec §2.3). |
| 11 | // |
| 12 | // `publish` and `head` are effectively reserved ledger names: they are |
| 13 | // literal path segments in this API's URL space, and Go 1.22's mux prefers a |
| 14 | // literal segment over a {ledger} wildcard. ValidName allows both, so a |
| 15 | // ledger so named can still be written and read at its own sub-routes, but |
| 16 | // nothing in this API should ever grow a route where the two would collide. |
| 17 | // Acceptable: they are our own API's words. |
| 18 | // |
| 19 | // Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2.3. |
| 20 | package console |
| 21 | |
| 22 | import ( |
| 23 | "context" |
| 24 | "encoding/json" |
| 25 | "errors" |
| 26 | "fmt" |
| 27 | "io" |
| 28 | "log" |
| 29 | "net/http" |
| 30 | "os" |
| 31 | "slices" |
| 32 | "strconv" |
| 33 | "strings" |
| 34 | "time" |
| 35 | |
| 36 | "github.com/carlosframework/platform/internal/ledger" |
| 37 | "github.com/carlosframework/platform/internal/release" |
| 38 | ) |
| 39 | |
| 40 | // ledgerStoreTimeout bounds every store round trip these handlers make, the |
| 41 | // same 15s its siblings in apicli.go use: a wedged bucket must cost one |
| 42 | // request 15 seconds, not a console goroutine forever. Each handler derives |
| 43 | // its own context from the request's, so a client that hangs up still |
| 44 | // cancels immediately — this only adds a ceiling. |
| 45 | const ledgerStoreTimeout = 15 * time.Second |
| 46 | |
| 47 | // ledgerWriteAllowed is the write budget every ledger WRITE door passes, via |
| 48 | // allowWrite (svcprincipal.go): a person is keyed on the ADDRESS, the same |
| 49 | // principal a write budget has always belonged to, not the terminal they |
| 50 | // happen to be typing on; a service is keyed on its NAME, its own budget |
| 51 | // separate from the operator who minted it. The two GET doors below are |
| 52 | // unlimited, matching their read-only siblings (channels, apps). |
| 53 | func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, p Principal) bool { |
| 54 | if !s.allowWrite(p, time.Now()) { |
| 55 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 56 | return false |
| 57 | } |
| 58 | return true |
| 59 | } |
| 60 | |
| 61 | // ledgerStoreError answers a failure that is NOT the caller's fault. Two |
| 62 | // things matter here. First, contention is retry-worthy and a broken bucket |
| 63 | // is not, so they must not share a status: a 503 tells a CLI to try again, |
| 64 | // a 500 tells it to stop. Second, the store's own error text — bucket names, |
| 65 | // key paths, AWS request ids — never reaches the client; it goes to the log, |
| 66 | // where the operator who can act on it will see it. |
| 67 | func (s *Server) ledgerStoreError(w http.ResponseWriter, what string, err error) { |
| 68 | log.Printf("console: %s: %v", what, err) |
| 69 | if errors.Is(err, ledger.ErrBusy) || errors.Is(err, release.ErrCASConflict) { |
| 70 | jsonError(w, http.StatusServiceUnavailable, |
| 71 | "the ledger is busy right now — try again in a moment") |
| 72 | return |
| 73 | } |
| 74 | jsonError(w, http.StatusInternalServerError, "could not reach the ledger store") |
| 75 | } |
| 76 | |
| 77 | func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) { |
| 78 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 79 | if !ok { |
| 80 | return |
| 81 | } |
| 82 | p, _ := principalFrom(r.Context()) |
| 83 | if !s.ledgerWriteAllowed(w, p) { |
| 84 | return |
| 85 | } |
| 86 | name := r.PathValue("ledger") |
| 87 | if !ledger.ValidName(name) { |
| 88 | jsonError(w, http.StatusBadRequest, "invalid ledger name") |
| 89 | return |
| 90 | } |
| 91 | // Only the payload is read off the wire: there is no field on this |
| 92 | // shape by which a caller could name a writer other than themselves. |
| 93 | var in struct { |
| 94 | Payload json.RawMessage `json:"payload"` |
| 95 | } |
| 96 | r.Body = http.MaxBytesReader(w, r.Body, ledger.MaxPayload+8<<10) |
| 97 | if err := json.NewDecoder(r.Body).Decode(&in); err != nil { |
| 98 | var tooBig *http.MaxBytesError |
| 99 | if errors.As(err, &tooBig) { |
| 100 | jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap") |
| 101 | return |
| 102 | } |
| 103 | jsonError(w, http.StatusBadRequest, "malformed body — want {\"payload\": ...}") |
| 104 | return |
| 105 | } |
| 106 | if len(in.Payload) == 0 { |
| 107 | jsonError(w, http.StatusBadRequest, "missing payload") |
| 108 | return |
| 109 | } |
| 110 | // The cap, checked HERE so an over-cap payload is the caller's 413 and |
| 111 | // not swept into ledger.Append's generic error return below. A body |
| 112 | // under MaxBytesReader's allowance can still carry an over-cap payload: |
| 113 | // the reader bounds the request, this bounds the entry. |
| 114 | if len(in.Payload) > ledger.MaxPayload { |
| 115 | jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap") |
| 116 | return |
| 117 | } |
| 118 | ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| 119 | defer cancel() |
| 120 | a, err := ledger.Append(ctx, s.cfg.Releases, acct.Sqid, app, name, addr, |
| 121 | in.Payload, time.Now) |
| 122 | if err != nil { |
| 123 | // Every caller-caused failure has been answered above — an invalid |
| 124 | // name, a payload over the cap, a body that isn't JSON. What's left |
| 125 | // is contention or a broken store, neither of which is the caller's |
| 126 | // fault and neither of which they should read raw. |
| 127 | s.ledgerStoreError(w, fmt.Sprintf("ledger append %s/%s/%s", acct.Sqid, app, name), err) |
| 128 | return |
| 129 | } |
| 130 | w.Header().Set("Content-Type", "application/json") |
| 131 | json.NewEncoder(w).Encode(a) |
| 132 | } |
| 133 | |
| 134 | func (s *Server) handleAPILedgerHead(w http.ResponseWriter, r *http.Request) { |
| 135 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 136 | if !ok { |
| 137 | return |
| 138 | } |
| 139 | name := r.PathValue("ledger") |
| 140 | if !ledger.ValidName(name) { |
| 141 | jsonError(w, http.StatusBadRequest, "invalid ledger name") |
| 142 | return |
| 143 | } |
| 144 | ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| 145 | defer cancel() |
| 146 | b, _, err := s.cfg.Releases.Get(ctx, ledger.HeadKey(acct.Sqid, app, name)) |
| 147 | if errors.Is(err, os.ErrNotExist) { |
| 148 | http.NotFound(w, r) |
| 149 | return |
| 150 | } |
| 151 | if err != nil { |
| 152 | s.ledgerStoreError(w, fmt.Sprintf("ledger head %s/%s/%s", acct.Sqid, app, name), err) |
| 153 | return |
| 154 | } |
| 155 | w.Header().Set("Content-Type", "application/json") |
| 156 | w.Write(b) |
| 157 | } |
| 158 | |
| 159 | // handleAPILedgerBlob takes raw bytes. It is rate-limited on the write |
| 160 | // budget and hard-bounded at 4 MiB by MaxBytesReader, and deliberately does |
| 161 | // NOT take ship's semaphore: a blob is 64× smaller than a ship's cap, so a |
| 162 | // bounded 4 MiB read is not the unbounded-buffer hazard shipSem exists for, |
| 163 | // and sharing that two-slot semaphore would let blob traffic starve the |
| 164 | // deploy path. |
| 165 | func (s *Server) handleAPILedgerBlob(w http.ResponseWriter, r *http.Request) { |
| 166 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 167 | if !ok { |
| 168 | return |
| 169 | } |
| 170 | p, _ := principalFrom(r.Context()) |
| 171 | if !s.ledgerWriteAllowed(w, p) { |
| 172 | return |
| 173 | } |
| 174 | name := r.PathValue("ledger") |
| 175 | if !ledger.ValidName(name) { |
| 176 | jsonError(w, http.StatusBadRequest, "invalid ledger name") |
| 177 | return |
| 178 | } |
| 179 | body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, ledger.MaxBlob)) |
| 180 | if err != nil { |
| 181 | var tooBig *http.MaxBytesError |
| 182 | if errors.As(err, &tooBig) { |
| 183 | jsonError(w, http.StatusRequestEntityTooLarge, "blob exceeds the 4 MiB cap") |
| 184 | return |
| 185 | } |
| 186 | jsonError(w, http.StatusBadRequest, "could not read the request body") |
| 187 | return |
| 188 | } |
| 189 | ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| 190 | defer cancel() |
| 191 | sha, err := ledger.PutBlob(ctx, s.cfg.Releases, acct.Sqid, app, name, body) |
| 192 | if err != nil { |
| 193 | s.ledgerStoreError(w, fmt.Sprintf("ledger blob %s/%s/%s", acct.Sqid, app, name), err) |
| 194 | return |
| 195 | } |
| 196 | w.Header().Set("Content-Type", "application/json") |
| 197 | json.NewEncoder(w).Encode(map[string]string{"sha256": sha}) |
| 198 | } |
| 199 | |
| 200 | func (s *Server) handleAPILedgerList(w http.ResponseWriter, r *http.Request) { |
| 201 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 202 | if !ok { |
| 203 | return |
| 204 | } |
| 205 | ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| 206 | defer cancel() |
| 207 | m, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| 208 | var manifest *ledger.Manifest |
| 209 | switch { |
| 210 | case err == nil: |
| 211 | manifest = &m |
| 212 | case errors.Is(err, os.ErrNotExist): |
| 213 | // no manifest yet — null in the response |
| 214 | default: |
| 215 | s.ledgerStoreError(w, fmt.Sprintf("ledger manifest %s/%s", acct.Sqid, app), err) |
| 216 | return |
| 217 | } |
| 218 | // The ledger names are the immediate child directories of the app's |
| 219 | // prefix — one delimiter-aware listing, whose cost is the number of |
| 220 | // chains rather than the total length of their histories. A flat List |
| 221 | // here would walk every entry and every blob ever written under this |
| 222 | // app just to learn a handful of names. Blobs live INSIDE a chain's |
| 223 | // directory, so the children are exactly the chain names; ledgers.json |
| 224 | // is a file at this level, and ListDirs never returns files. |
| 225 | prefix := release.LedgerAppPrefix(acct.Sqid, app) |
| 226 | dirs, err := s.cfg.Releases.ListDirs(ctx, prefix) |
| 227 | if err != nil { |
| 228 | s.ledgerStoreError(w, fmt.Sprintf("ledger list %s/%s", acct.Sqid, app), err) |
| 229 | return |
| 230 | } |
| 231 | var names []string |
| 232 | for _, d := range dirs { |
| 233 | n := strings.TrimSuffix(strings.TrimPrefix(d, prefix), "/") |
| 234 | if n == "" { |
| 235 | continue |
| 236 | } |
| 237 | names = append(names, n) |
| 238 | } |
| 239 | w.Header().Set("Content-Type", "application/json") |
| 240 | json.NewEncoder(w).Encode(map[string]any{"manifest": manifest, "ledgers": names}) |
| 241 | } |
| 242 | |
| 243 | // maxLedgerTitle bounds Manifest.Title, the one member-supplied string on |
| 244 | // this API that is served verbatim to the public (ledgers.json, and from |
| 245 | // there whatever page renders it). 120 bytes is a display title's worth; the |
| 246 | // cap is here, at the write door, because that is the only place a bound |
| 247 | // holds for every reader that will ever fetch the object. |
| 248 | const maxLedgerTitle = 120 |
| 249 | |
| 250 | // maxLedgerCount bounds Manifest.Ledgers. Publishing is additive |
| 251 | // read-modify-write, which removed the implicit bound the 8 KiB request-body |
| 252 | // cap used to give when a publish stored the request's manifest wholesale: |
| 253 | // names now ACCUMULATE across calls, and ledgers.json is member-supplied |
| 254 | // content that is served publicly at the edge, held in the edge's in-memory |
| 255 | // cache, and scanned linearly on every single ledger request (the allowlist |
| 256 | // gate). 64 published chains per app is far past any real app and keeps that |
| 257 | // per-request scan trivially short. Like the title's cap, it lives at the |
| 258 | // write door, because that is the only place a bound holds for every reader |
| 259 | // that will ever fetch the object. |
| 260 | const maxLedgerCount = 64 |
| 261 | |
| 262 | // errTooManyLedgers is how the publish apply below REFUSES from inside |
| 263 | // ledger.UpdateManifest's CAS loop: the cap can only be judged against the |
| 264 | // stored set, which the loop may re-read, so the check has to live where the |
| 265 | // decision is made and travel back out as an error the handler recognises. |
| 266 | // Its text is the client-facing message — it names the cap and nothing else, |
| 267 | // so there is no path by which store internals reach a caller through it. |
| 268 | var errTooManyLedgers = fmt.Errorf("too many public ledgers (max %d)", maxLedgerCount) |
| 269 | |
| 270 | // handleAPILedgerPublish is READ-MODIFY-WRITE, per ledger. The manifest is a |
| 271 | // SET of published ledgers accrued across separate publish calls, and the |
| 272 | // edge reads that set as an ALLOWLIST (internal/edge/status.go) — so a |
| 273 | // handler that stored the request's manifest wholesale would silently |
| 274 | // unpublish every chain the caller didn't name in this one call, while |
| 275 | // answering success. Hence: |
| 276 | // |
| 277 | // - public:true — add the named ledgers to the stored set (order kept, no |
| 278 | // duplicates), set public, and replace the title only if the request |
| 279 | // carries one. Publishing a second chain is not a request to erase the |
| 280 | // app's title. |
| 281 | // - public:false — remove the named ledgers. public flips off only when |
| 282 | // the set empties, so unpublishing one of two chains leaves the other |
| 283 | // serving. The title survives: unpublishing is not forgetting. |
| 284 | // |
| 285 | // The CAS is ledger.UpdateManifest's, which re-reads and re-applies on a |
| 286 | // lost race rather than re-putting a stale copy — two contributors |
| 287 | // publishing two chains at once must end with both published. |
| 288 | // |
| 289 | // The response is the RESULTING manifest, not an empty ack: the caller's |
| 290 | // request said what to change, and only the answer can say what is public |
| 291 | // now. |
| 292 | func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) { |
| 293 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 294 | if !ok { |
| 295 | return |
| 296 | } |
| 297 | p, _ := principalFrom(r.Context()) |
| 298 | if !s.ledgerWriteAllowed(w, p) { |
| 299 | return |
| 300 | } |
| 301 | var in ledger.Manifest |
| 302 | r.Body = http.MaxBytesReader(w, r.Body, 8<<10) |
| 303 | if err := json.NewDecoder(r.Body).Decode(&in); err != nil { |
| 304 | jsonError(w, http.StatusBadRequest, "malformed manifest") |
| 305 | return |
| 306 | } |
| 307 | for _, n := range in.Ledgers { |
| 308 | if !ledger.ValidName(n) { |
| 309 | jsonError(w, http.StatusBadRequest, "invalid ledger name "+n) |
| 310 | return |
| 311 | } |
| 312 | } |
| 313 | title := strings.TrimSpace(in.Title) |
| 314 | if len(title) > maxLedgerTitle { |
| 315 | jsonError(w, http.StatusBadRequest, |
| 316 | fmt.Sprintf("title too long (max %d bytes)", maxLedgerTitle)) |
| 317 | return |
| 318 | } |
| 319 | |
| 320 | // An unpublish against an app with no manifest is a no-op success: there |
| 321 | // is nothing to take private, and conjuring a public:false manifest for |
| 322 | // an app that never published one would be a write with no meaning. |
| 323 | ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| 324 | defer cancel() |
| 325 | if !in.Public { |
| 326 | if _, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app); errors.Is(err, os.ErrNotExist) { |
| 327 | w.Header().Set("Content-Type", "application/json") |
| 328 | json.NewEncoder(w).Encode(ledger.Manifest{}) |
| 329 | return |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | m, err := ledger.UpdateManifest(ctx, s.cfg.Releases, acct.Sqid, app, func(m *ledger.Manifest) error { |
| 334 | if in.Public { |
| 335 | m.Public = true |
| 336 | for _, n := range in.Ledgers { |
| 337 | if !slices.Contains(m.Ledgers, n) { |
| 338 | m.Ledgers = append(m.Ledgers, n) |
| 339 | } |
| 340 | } |
| 341 | // The cap is checked HERE, on the manifest this attempt read, and |
| 342 | // on the RESULT — not on the request. Only the stored set knows |
| 343 | // how many names are already there, and UpdateManifest re-reads |
| 344 | // and re-applies on a lost race, so a check made once outside |
| 345 | // this closure could be stepped over by two publishers racing. |
| 346 | // Refusing from inside aborts before the Put: nothing is written. |
| 347 | if len(m.Ledgers) > maxLedgerCount { |
| 348 | return errTooManyLedgers |
| 349 | } |
| 350 | if title != "" { |
| 351 | m.Title = title |
| 352 | } |
| 353 | return nil |
| 354 | } |
| 355 | m.Ledgers = slices.DeleteFunc(m.Ledgers, func(n string) bool { |
| 356 | return slices.Contains(in.Ledgers, n) |
| 357 | }) |
| 358 | if len(m.Ledgers) == 0 { |
| 359 | m.Public = false |
| 360 | } |
| 361 | return nil |
| 362 | }) |
| 363 | // A refusal is the CALLER's fault and must not be laundered through |
| 364 | // ledgerStoreError, which would log it and answer 500: this is the one |
| 365 | // error out of UpdateManifest that a client can act on, and the only one |
| 366 | // whose text is ours to show. |
| 367 | if errors.Is(err, errTooManyLedgers) { |
| 368 | jsonError(w, http.StatusBadRequest, errTooManyLedgers.Error()) |
| 369 | return |
| 370 | } |
| 371 | if err != nil { |
| 372 | s.ledgerStoreError(w, fmt.Sprintf("ledger publish %s/%s", acct.Sqid, app), err) |
| 373 | return |
| 374 | } |
| 375 | // Flipping an app's ledgers public is exactly the kind of act the audit |
| 376 | // trail exists for, and the ONLY one on this API that changes what the |
| 377 | // world can see — the same trail promote, rollback and ship write to. |
| 378 | // The line records the RESULTING state, which is what a reader of the |
| 379 | // trail needs to know; the request alone would not say it. |
| 380 | s.audit(acct, app, addr, "ledger-publish", |
| 381 | "set ledgers public="+strconv.FormatBool(m.Public)+ |
| 382 | " ["+strings.Join(m.Ledgers, " ")+"] (cli)") |
| 383 | w.Header().Set("Content-Type", "application/json") |
| 384 | json.NewEncoder(w).Encode(m) |
| 385 | } |
| 386 | |