// The CLI's ledger API: the ONLY write path to ledgers/ from outside a // box. Contributor machines hold no bucket credentials — the console // performs the CAS append server-side and stamps the writer from the // authenticated identity. Member-level, same gate as promote: publishing // an app's ledger is ordinary member work. // // Every route here begins with apiAccountApp, so an unknown account and one // the caller doesn't belong to are the same 404 on the WRITE doors too, and // none of them consult the manifest's public flag — opt-in gates serving, // not writing (spec §2.3). // // `publish` and `head` are effectively reserved ledger names: they are // literal path segments in this API's URL space, and Go 1.22's mux prefers a // literal segment over a {ledger} wildcard. ValidName allows both, so a // ledger so named can still be written and read at its own sub-routes, but // nothing in this API should ever grow a route where the two would collide. // Acceptable: they are our own API's words. // // Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2.3. package console import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "slices" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/ledger" "github.com/carlosframework/platform/internal/release" ) // ledgerStoreTimeout bounds every store round trip these handlers make, the // same 15s its siblings in apicli.go use: a wedged bucket must cost one // request 15 seconds, not a console goroutine forever. Each handler derives // its own context from the request's, so a client that hangs up still // cancels immediately — this only adds a ceiling. const ledgerStoreTimeout = 15 * time.Second // ledgerWriteAllowed is the write budget every ledger WRITE door passes, via // allowWrite (svcprincipal.go): a person is keyed on the ADDRESS, the same // principal a write budget has always belonged to, not the terminal they // happen to be typing on; a service is keyed on its NAME, its own budget // separate from the operator who minted it. The two GET doors below are // unlimited, matching their read-only siblings (channels, apps). func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, p Principal) bool { if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return false } return true } // ledgerStoreError answers a failure that is NOT the caller's fault. Two // things matter here. First, contention is retry-worthy and a broken bucket // is not, so they must not share a status: a 503 tells a CLI to try again, // a 500 tells it to stop. Second, the store's own error text — bucket names, // key paths, AWS request ids — never reaches the client; it goes to the log, // where the operator who can act on it will see it. func (s *Server) ledgerStoreError(w http.ResponseWriter, what string, err error) { log.Printf("console: %s: %v", what, err) if errors.Is(err, ledger.ErrBusy) || errors.Is(err, release.ErrCASConflict) { jsonError(w, http.StatusServiceUnavailable, "the ledger is busy right now — try again in a moment") return } jsonError(w, http.StatusInternalServerError, "could not reach the ledger store") } func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.ledgerWriteAllowed(w, p) { return } name := r.PathValue("ledger") if !ledger.ValidName(name) { jsonError(w, http.StatusBadRequest, "invalid ledger name") return } // Only the payload is read off the wire: there is no field on this // shape by which a caller could name a writer other than themselves. var in struct { Payload json.RawMessage `json:"payload"` } r.Body = http.MaxBytesReader(w, r.Body, ledger.MaxPayload+8<<10) if err := json.NewDecoder(r.Body).Decode(&in); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap") return } jsonError(w, http.StatusBadRequest, "malformed body — want {\"payload\": ...}") return } if len(in.Payload) == 0 { jsonError(w, http.StatusBadRequest, "missing payload") return } // The cap, checked HERE so an over-cap payload is the caller's 413 and // not swept into ledger.Append's generic error return below. A body // under MaxBytesReader's allowance can still carry an over-cap payload: // the reader bounds the request, this bounds the entry. if len(in.Payload) > ledger.MaxPayload { jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap") return } ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) defer cancel() a, err := ledger.Append(ctx, s.cfg.Releases, acct.Sqid, app, name, addr, in.Payload, time.Now) if err != nil { // Every caller-caused failure has been answered above — an invalid // name, a payload over the cap, a body that isn't JSON. What's left // is contention or a broken store, neither of which is the caller's // fault and neither of which they should read raw. s.ledgerStoreError(w, fmt.Sprintf("ledger append %s/%s/%s", acct.Sqid, app, name), err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(a) } func (s *Server) handleAPILedgerHead(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } name := r.PathValue("ledger") if !ledger.ValidName(name) { jsonError(w, http.StatusBadRequest, "invalid ledger name") return } ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) defer cancel() b, _, err := s.cfg.Releases.Get(ctx, ledger.HeadKey(acct.Sqid, app, name)) if errors.Is(err, os.ErrNotExist) { http.NotFound(w, r) return } if err != nil { s.ledgerStoreError(w, fmt.Sprintf("ledger head %s/%s/%s", acct.Sqid, app, name), err) return } w.Header().Set("Content-Type", "application/json") w.Write(b) } // handleAPILedgerBlob takes raw bytes. It is rate-limited on the write // budget and hard-bounded at 4 MiB by MaxBytesReader, and deliberately does // NOT take ship's semaphore: a blob is 64× smaller than a ship's cap, so a // bounded 4 MiB read is not the unbounded-buffer hazard shipSem exists for, // and sharing that two-slot semaphore would let blob traffic starve the // deploy path. func (s *Server) handleAPILedgerBlob(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.ledgerWriteAllowed(w, p) { return } name := r.PathValue("ledger") if !ledger.ValidName(name) { jsonError(w, http.StatusBadRequest, "invalid ledger name") return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, ledger.MaxBlob)) if err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { jsonError(w, http.StatusRequestEntityTooLarge, "blob exceeds the 4 MiB cap") return } jsonError(w, http.StatusBadRequest, "could not read the request body") return } ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) defer cancel() sha, err := ledger.PutBlob(ctx, s.cfg.Releases, acct.Sqid, app, name, body) if err != nil { s.ledgerStoreError(w, fmt.Sprintf("ledger blob %s/%s/%s", acct.Sqid, app, name), err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"sha256": sha}) } func (s *Server) handleAPILedgerList(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) defer cancel() m, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app) var manifest *ledger.Manifest switch { case err == nil: manifest = &m case errors.Is(err, os.ErrNotExist): // no manifest yet — null in the response default: s.ledgerStoreError(w, fmt.Sprintf("ledger manifest %s/%s", acct.Sqid, app), err) return } // The ledger names are the immediate child directories of the app's // prefix — one delimiter-aware listing, whose cost is the number of // chains rather than the total length of their histories. A flat List // here would walk every entry and every blob ever written under this // app just to learn a handful of names. Blobs live INSIDE a chain's // directory, so the children are exactly the chain names; ledgers.json // is a file at this level, and ListDirs never returns files. prefix := release.LedgerAppPrefix(acct.Sqid, app) dirs, err := s.cfg.Releases.ListDirs(ctx, prefix) if err != nil { s.ledgerStoreError(w, fmt.Sprintf("ledger list %s/%s", acct.Sqid, app), err) return } var names []string for _, d := range dirs { n := strings.TrimSuffix(strings.TrimPrefix(d, prefix), "/") if n == "" { continue } names = append(names, n) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"manifest": manifest, "ledgers": names}) } // maxLedgerTitle bounds Manifest.Title, the one member-supplied string on // this API that is served verbatim to the public (ledgers.json, and from // there whatever page renders it). 120 bytes is a display title's worth; the // cap is here, at the write door, because that is the only place a bound // holds for every reader that will ever fetch the object. const maxLedgerTitle = 120 // maxLedgerCount bounds Manifest.Ledgers. Publishing is additive // read-modify-write, which removed the implicit bound the 8 KiB request-body // cap used to give when a publish stored the request's manifest wholesale: // names now ACCUMULATE across calls, and ledgers.json is member-supplied // content that is served publicly at the edge, held in the edge's in-memory // cache, and scanned linearly on every single ledger request (the allowlist // gate). 64 published chains per app is far past any real app and keeps that // per-request scan trivially short. Like the title's cap, it lives at the // write door, because that is the only place a bound holds for every reader // that will ever fetch the object. const maxLedgerCount = 64 // errTooManyLedgers is how the publish apply below REFUSES from inside // ledger.UpdateManifest's CAS loop: the cap can only be judged against the // stored set, which the loop may re-read, so the check has to live where the // decision is made and travel back out as an error the handler recognises. // Its text is the client-facing message — it names the cap and nothing else, // so there is no path by which store internals reach a caller through it. var errTooManyLedgers = fmt.Errorf("too many public ledgers (max %d)", maxLedgerCount) // handleAPILedgerPublish is READ-MODIFY-WRITE, per ledger. The manifest is a // SET of published ledgers accrued across separate publish calls, and the // edge reads that set as an ALLOWLIST (internal/edge/status.go) — so a // handler that stored the request's manifest wholesale would silently // unpublish every chain the caller didn't name in this one call, while // answering success. Hence: // // - public:true — add the named ledgers to the stored set (order kept, no // duplicates), set public, and replace the title only if the request // carries one. Publishing a second chain is not a request to erase the // app's title. // - public:false — remove the named ledgers. public flips off only when // the set empties, so unpublishing one of two chains leaves the other // serving. The title survives: unpublishing is not forgetting. // // The CAS is ledger.UpdateManifest's, which re-reads and re-applies on a // lost race rather than re-putting a stale copy — two contributors // publishing two chains at once must end with both published. // // The response is the RESULTING manifest, not an empty ack: the caller's // request said what to change, and only the answer can say what is public // now. func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.ledgerWriteAllowed(w, p) { return } var in ledger.Manifest r.Body = http.MaxBytesReader(w, r.Body, 8<<10) if err := json.NewDecoder(r.Body).Decode(&in); err != nil { jsonError(w, http.StatusBadRequest, "malformed manifest") return } for _, n := range in.Ledgers { if !ledger.ValidName(n) { jsonError(w, http.StatusBadRequest, "invalid ledger name "+n) return } } title := strings.TrimSpace(in.Title) if len(title) > maxLedgerTitle { jsonError(w, http.StatusBadRequest, fmt.Sprintf("title too long (max %d bytes)", maxLedgerTitle)) return } // An unpublish against an app with no manifest is a no-op success: there // is nothing to take private, and conjuring a public:false manifest for // an app that never published one would be a write with no meaning. ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) defer cancel() if !in.Public { if _, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app); errors.Is(err, os.ErrNotExist) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ledger.Manifest{}) return } } m, err := ledger.UpdateManifest(ctx, s.cfg.Releases, acct.Sqid, app, func(m *ledger.Manifest) error { if in.Public { m.Public = true for _, n := range in.Ledgers { if !slices.Contains(m.Ledgers, n) { m.Ledgers = append(m.Ledgers, n) } } // The cap is checked HERE, on the manifest this attempt read, and // on the RESULT — not on the request. Only the stored set knows // how many names are already there, and UpdateManifest re-reads // and re-applies on a lost race, so a check made once outside // this closure could be stepped over by two publishers racing. // Refusing from inside aborts before the Put: nothing is written. if len(m.Ledgers) > maxLedgerCount { return errTooManyLedgers } if title != "" { m.Title = title } return nil } m.Ledgers = slices.DeleteFunc(m.Ledgers, func(n string) bool { return slices.Contains(in.Ledgers, n) }) if len(m.Ledgers) == 0 { m.Public = false } return nil }) // A refusal is the CALLER's fault and must not be laundered through // ledgerStoreError, which would log it and answer 500: this is the one // error out of UpdateManifest that a client can act on, and the only one // whose text is ours to show. if errors.Is(err, errTooManyLedgers) { jsonError(w, http.StatusBadRequest, errTooManyLedgers.Error()) return } if err != nil { s.ledgerStoreError(w, fmt.Sprintf("ledger publish %s/%s", acct.Sqid, app), err) return } // Flipping an app's ledgers public is exactly the kind of act the audit // trail exists for, and the ONLY one on this API that changes what the // world can see — the same trail promote, rollback and ship write to. // The line records the RESULTING state, which is what a reader of the // trail needs to know; the request alone would not say it. s.audit(acct, app, addr, "ledger-publish", "set ledgers public="+strconv.FormatBool(m.Public)+ " ["+strings.Join(m.Ledgers, " ")+"] (cli)") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(m) }