carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
20package console
21
22import (
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.
45const 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).
53func (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.
67func (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
77func (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
134func (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.
165func (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
200func (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.
248const 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.
260const 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.
268var 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.
292func (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