carlos / platform Public

console: ledger CLI API — append/head/blobs/list/publish, member-gated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 574913ae4e6a5dc860b997d76c9e9a5bd2ddfb91 parent fe7472e
3 files changed, +523 −0
  • internal/console/apiledger.go +180 −0
  • internal/console/apiledger_test.go +334 −0
  • internal/console/authcli.go +9 −0
diff --git a/internal/console/apiledger.go b/internal/console/apiledger.go
new file mode 100644
index 000000000..8b4c27f86
--- /dev/null
+++ b/internal/console/apiledger.go
@@ -0,0 +1,180 @@
+// 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 (
+ "encoding/json"
+ "errors"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+)
+
+func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) {
+ addr, 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
+ }
+ // 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
+ }
+ a, err := ledger.Append(r.Context(), s.cfg.Releases, acct.Sqid, app, name, addr,
+ in.Payload, time.Now)
+ if err != nil {
+ log.Printf("console: ledger append %s/%s/%s: %v", acct.Sqid, app, name, err)
+ jsonError(w, http.StatusBadRequest, err.Error())
+ 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
+ }
+ b, _, err := s.cfg.Releases.Get(r.Context(), ledger.HeadKey(acct.Sqid, app, name))
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not read ledger head")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(b)
+}
+
+func (s *Server) handleAPILedgerBlob(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
+ }
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, ledger.MaxBlob))
+ if err != nil {
+ jsonError(w, http.StatusRequestEntityTooLarge, "blob exceeds the 4 MiB cap")
+ return
+ }
+ sha, err := ledger.PutBlob(r.Context(), s.cfg.Releases, acct.Sqid, app, name, body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ 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
+ }
+ m, err := ledger.ReadManifest(r.Context(), 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:
+ jsonError(w, http.StatusInternalServerError, "could not read ledger manifest")
+ return
+ }
+ // Distinct ledger names, derived from the HEAD keys under this app.
+ keys, err := s.cfg.Releases.List(r.Context(), "ledgers/"+acct.Sqid+"/"+app+"/")
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not list ledgers")
+ return
+ }
+ var names []string
+ for _, k := range keys {
+ if !strings.HasSuffix(k, "/HEAD.json") {
+ continue
+ }
+ rest := strings.TrimPrefix(k, "ledgers/"+acct.Sqid+"/"+app+"/")
+ names = append(names, strings.TrimSuffix(rest, "/HEAD.json"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"manifest": manifest, "ledgers": names})
+}
+
+func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ 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
+ }
+ }
+ if err := ledger.WriteManifest(r.Context(), s.cfg.Releases, acct.Sqid, app, in); err != nil {
+ log.Printf("console: ledger publish %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not write manifest")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte("{}"))
+}
diff --git a/internal/console/apiledger_test.go b/internal/console/apiledger_test.go
new file mode 100644
index 000000000..fefab06c5
--- /dev/null
+++ b/internal/console/apiledger_test.go
@@ -0,0 +1,334 @@
+package console
+
+// The ledger CLI API (apiledger.go): the ONLY write path to ledgers/ from
+// outside a box. The properties that matter, in the order they'd hurt if
+// lost: the writer stamped on an entry is the AUTHENTICATED identity and
+// nothing a client sent, every route is member-gated through apiAccountApp
+// (so an unknown account and someone else's are the identical 404), and the
+// caps — 64 KiB payload, 4 MiB blob, a validated ledger name — are enforced
+// before anything touches the store.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+)
+
+// postBody is postJSON's raw-body cousin: these tests send bodies that are
+// deliberately not round-trippable through json.Marshal (an oversize
+// payload, a body carrying a field the server must ignore), and read the
+// response as bytes so it can be decoded into a typed struct.
+func postBody(t *testing.T, u, bearer, body string) (int, []byte) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, out
+}
+
+// ledgerFixture is a console with titogo claimed in the ops account and a
+// MEMBER (not owner) token for it — member-level is exactly the gate this
+// API grants, the same one promote uses.
+func ledgerFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// TestAPILedgerAppendAndHead: an append round-trips through HEAD, and the
+// writer on the stored entry is the token's identity — never anything the
+// client put in the body (the request below tries to set one).
+func TestAPILedgerAppendAndHead(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-append")
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"kg_central":1},"writer":"attacker@example.com"}`)
+ if status != http.StatusOK {
+ t.Fatalf("append = %d %s", status, out)
+ }
+ var a ledger.Appended
+ if err := json.Unmarshal(out, &a); err != nil {
+ t.Fatalf("append body %s: %v", out, err)
+ }
+ if a.Count != 1 || a.Key == "" || a.SHA256 == "" {
+ t.Fatalf("append = %+v, want count 1 with a key and sha", a)
+ }
+
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token)
+ if status != http.StatusOK {
+ t.Fatalf("head = %d %s", status, body)
+ }
+ var h ledger.Head
+ if err := json.Unmarshal([]byte(body), &h); err != nil {
+ t.Fatalf("head body %s: %v", body, err)
+ }
+ if h.HeadKey != a.Key || h.HeadSHA256 != a.SHA256 || h.Count != 1 {
+ t.Fatalf("head = %+v, want it to name the appended entry %+v", h, a)
+ }
+
+ // The writer is the token's identity, stamped server-side. The body's
+ // own "writer" field must have gone nowhere.
+ eb, _, err := fx.rel.Get(context.Background(), a.Key)
+ if err != nil {
+ t.Fatalf("stored entry %s: %v", a.Key, err)
+ }
+ var e ledger.Entry
+ if err := json.Unmarshal(eb, &e); err != nil {
+ t.Fatal(err)
+ }
+ if e.Writer != "alice@keymail.dev" {
+ t.Fatalf("entry writer = %q, want the authenticated identity alice@keymail.dev (%+v)", e.Writer, e)
+ }
+ if bytes.Contains(eb, []byte("attacker@example.com")) {
+ t.Fatalf("stored entry carries a client-supplied writer: %s", eb)
+ }
+}
+
+// TestAPILedgerAuthz: no token is the same 401 every CLI door gives, and a
+// non-member — whether the account is one they don't belong to or an app it
+// never claimed — gets the byte-identical 404 apiAccountApp already
+// produces, on the WRITE routes too.
+func TestAPILedgerAuthz(t *testing.T) {
+ fx, _ := ledgerFixture(t, "code-ledger-authz")
+
+ status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", "",
+ `{"payload":1}`)
+ if status != http.StatusUnauthorized {
+ t.Fatalf("append with no token = %d, want 401", status)
+ }
+
+ // (a) member of nothing, probing a real account+app.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-ledger-eve")
+ statusA, bodyA := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", eve,
+ `{"payload":1}`)
+ if statusA != http.StatusNotFound {
+ t.Fatalf("append as non-member = %d, want 404", statusA)
+ }
+ // (b) member of bab, but an app it never claimed.
+ if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ frank := cliToken(t, fx, "frank@keymail.dev", "code-ledger-frank")
+ statusB, bodyB := postBody(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/ledgers/carbon/append", frank,
+ `{"payload":1}`)
+ if statusB != http.StatusNotFound {
+ t.Fatalf("append against an unclaimed app = %d, want 404", statusB)
+ }
+ if !bytes.Equal(bodyA, bodyB) {
+ t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB)
+ }
+ if string(bodyA) != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA)
+ }
+
+ // Nothing was written by any of the refused calls.
+ if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil {
+ t.Fatalf("a ledger HEAD exists after only refused appends, want none")
+ }
+
+ // The read routes refuse identically.
+ if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", eve); st != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("list as non-member = %d %q, want the identical 404", st, body)
+ }
+ if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", eve); st != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("head as non-member = %d %q, want the identical 404", st, body)
+ }
+}
+
+// TestAPILedgerRejects: a name that isn't a ledger name, and a payload over
+// the 64 KiB cap, are both refused before anything lands in the store.
+func TestAPILedgerRejects(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-rejects")
+
+ // Bad ledger name in the path — uppercase and a space.
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/append", token,
+ `{"payload":1}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad ledger name = %d %s, want 400", status, out)
+ }
+ // A traversal attempt in the name segment, which would otherwise become
+ // bucket key segments.
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/..%2f..%2fetc/append", token,
+ `{"payload":1}`)
+ if status != http.StatusBadRequest && status != http.StatusNotFound {
+ t.Fatalf("traversal ledger name = %d %s, want a refusal", status, out)
+ }
+
+ // Oversized payload: over the cap, but under MaxBytesReader's limit, so
+ // this is the handler's own arithmetic refusing it, not the reader's.
+ big := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload) + `"}`
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, big)
+ if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversize payload = %d %s, want 400 or 413", status, out)
+ }
+ // Far oversized: MaxBytesReader cuts this one off.
+ huge := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload+64<<10) + `"}`
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, huge)
+ if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("far-oversize payload = %d %s, want 400 or 413", status, out)
+ }
+ // A body with no payload at all.
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("missing payload = %d %s, want 400", status, out)
+ }
+
+ // None of the refusals created a chain.
+ if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil {
+ t.Fatalf("carbon HEAD exists after only refused appends, want none")
+ }
+}
+
+// TestAPILedgerPublishAndList: the opt-in manifest round-trips, and the
+// listing names both the manifest and the ledgers that actually exist —
+// derived from the HEAD keys in the bucket, not from the manifest.
+func TestAPILedgerPublishAndList(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-publish")
+
+ // A write happens with NO manifest present: opt-in gates serving, not
+ // writing (spec §2.3).
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"n":1}}`); status != http.StatusOK {
+ t.Fatalf("append before any publish = %d %s, want 200 (opt-in gates serving, not writing)", status, out)
+ }
+
+ // Before publishing, the manifest reads back null and the ledger is
+ // still listed.
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
+ if status != http.StatusOK {
+ t.Fatalf("list before publish = %d %s", status, body)
+ }
+ var pre struct {
+ Manifest *ledger.Manifest `json:"manifest"`
+ Ledgers []string `json:"ledgers"`
+ }
+ if err := json.Unmarshal([]byte(body), &pre); err != nil {
+ t.Fatalf("list body %s: %v", body, err)
+ }
+ if pre.Manifest != nil {
+ t.Fatalf("manifest before publish = %+v, want null", pre.Manifest)
+ }
+ if len(pre.Ledgers) != 1 || pre.Ledgers[0] != "carbon" {
+ t.Fatalf("ledgers before publish = %v, want [carbon]", pre.Ledgers)
+ }
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`)
+ if status != http.StatusOK {
+ t.Fatalf("publish = %d %s", status, out)
+ }
+
+ status, body = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
+ if status != http.StatusOK || !strings.Contains(body, `"carbon"`) {
+ t.Fatalf("list = %d %s, want carbon among the ledgers", status, body)
+ }
+ var post struct {
+ Manifest *ledger.Manifest `json:"manifest"`
+ Ledgers []string `json:"ledgers"`
+ }
+ if err := json.Unmarshal([]byte(body), &post); err != nil {
+ t.Fatalf("list body %s: %v", body, err)
+ }
+ if post.Manifest == nil || !post.Manifest.Public || post.Manifest.Title != "Titogo" ||
+ len(post.Manifest.Ledgers) != 1 || post.Manifest.Ledgers[0] != "carbon" {
+ t.Fatalf("manifest after publish = %+v, want the published one", post.Manifest)
+ }
+ if post.Manifest.Schema != ledger.Schema {
+ t.Fatalf("manifest schema = %d, want %d (stamped server-side)", post.Manifest.Schema, ledger.Schema)
+ }
+
+ // A manifest naming a ledger that isn't a ledger name is refused.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["Not Valid"]}`); status != http.StatusBadRequest {
+ t.Fatalf("publish with a bad ledger name = %d %s, want 400", status, out)
+ }
+}
+
+// TestAPILedgerHeadMissing: a ledger that was never written is the same
+// stdlib 404 as an app you can't see — there is nothing to distinguish.
+func TestAPILedgerHeadMissing(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-head-missing")
+
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/nothing-here/head", token)
+ if status != http.StatusNotFound {
+ t.Fatalf("head of an unwritten ledger = %d %q, want 404", status, body)
+ }
+}
+
+// TestAPILedgerBlob: raw bytes upload content-addressed, the returned sha
+// is what the bytes hash to, and a re-upload of the same bytes is the same
+// answer.
+func TestAPILedgerBlob(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-blob")
+
+ req, err := http.NewRequest(http.MethodPost,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs",
+ bytes.NewReader([]byte("corpus")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("blob = %d %s", res.StatusCode, raw)
+ }
+ var out struct {
+ SHA256 string `json:"sha256"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("blob body %s: %v", raw, err)
+ }
+ want := ledger.SHA256Hex([]byte("corpus"))
+ if out.SHA256 != want {
+ t.Fatalf("blob sha256 = %q, want %q (computed from the bytes sent)", out.SHA256, want)
+ }
+ stored, _, err := fx.rel.Get(context.Background(), ledger.BlobKey("bab", "titogo", "prompts", want))
+ if err != nil {
+ t.Fatalf("stored blob: %v", err)
+ }
+ if !bytes.Equal(stored, []byte("corpus")) {
+ t.Fatalf("stored blob = %q, want the bytes sent", stored)
+ }
+
+ // A bad ledger name in the path refuses before the body is read.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/blobs", token,
+ "corpus"); status != http.StatusBadRequest {
+ t.Fatalf("blob with a bad ledger name = %d %s, want 400", status, out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 2ebb76b69..122cec00e 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -178,6 +178,15 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip)
+ // The ledger API (apiledger.go) — the only write path to ledgers/ from
+ // outside a box. The literal `publish` segment is kept adjacent to the
+ // {ledger} wildcards for readability; the mux prefers it regardless of
+ // registration order.
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers", s.handleAPILedgerList)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/publish", s.handleAPILedgerPublish)
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", s.handleAPILedgerHead)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob)
}
// clientIP is who the rate limiter counts against.