carlos / platform Public

edge: serve /ledgers/ on the status host, fail-closed behind the opt-in manifest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3b0b632dc92b4c9430925ae33a17bdc31b0340aa parent 5b970b8
2 files changed, +361 −1
  • internal/edge/status.go +105 −1
  • internal/edge/status_test.go +256 −0
diff --git a/internal/edge/status.go b/internal/edge/status.go
index 812602b85..ac6a83332 100644
--- a/internal/edge/status.go
+++ b/internal/edge/status.go
@@ -6,19 +6,31 @@
// publish cycle (no signed, versioned channel pointer to resolve), so there
// is no "which version" question — just "read the key, cache it briefly,
// serve it, and don't fall over when the bucket hiccups."
+//
+// The same host also publishes open ledgers (internal/ledger) under
+// /ledgers/<sqid>/<app>/..., path-for-key, gated per app by that app's
+// opt-in manifest — see serveLedger.
package edge
import (
"context"
+ "encoding/json"
"log"
"net/http"
"path"
+ "strings"
"sync"
"time"
+ "github.com/carlosframework/platform/internal/ledger"
"github.com/carlosframework/platform/internal/release"
)
+// ledgerPrefix is the one path prefix on the status host that is NOT the
+// fixed three-object menu: open ledgers (internal/ledger) are served from
+// here, path-for-key, gated per app by that app's opt-in manifest.
+const ledgerPrefix = "/ledgers/"
+
// statusCacheTTL is how long a fetched status object is trusted before the
// handler re-reads the store — mirrors staticRecheck's "trust briefly, then
// recheck" tradeoff, tuned loose because the backing data only changes every
@@ -91,7 +103,12 @@ func newStatusHandler(store release.Store) *statusHandler {
}
func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- rt, ok := statusRoutes[path.Clean(r.URL.Path)]
+ p := path.Clean(r.URL.Path)
+ if strings.HasPrefix(p, ledgerPrefix) {
+ h.serveLedger(w, r, p)
+ return
+ }
+ rt, ok := statusRoutes[p]
if !ok {
http.NotFound(w, r)
return
@@ -106,6 +123,93 @@ func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write(body)
}
+// serveLedger maps /ledgers/<sqid>/<app>/... 1:1 onto the bucket key — that
+// identity is what lets `carlos ledger verify -url` walk a public chain over
+// HTTPS with the very keys the store uses, no URL scheme to keep in sync.
+//
+// Everything here is fail-closed: the ONLY way to get a body out of this
+// handler is an app whose manifest reads, parses, and says public:true. No
+// manifest, an unreadable one, an unparseable one, or public:false all end
+// in the same plain 404 as an app that was never created — a caller must
+// not be able to probe which apps exist, let alone which have ledgers. A
+// missing object under a public app is a 404 too: the 503 "nothing honest
+// left to serve" contract above belongs to the fleet page's own three
+// objects, which are the page, not arbitrary reads.
+//
+// The manifest read rides the same per-key cache as everything else, so
+// revoking public takes at most one TTL — and, symmetrically, a cached
+// public manifest keeps serving through a store outage exactly like any
+// other cached object (see get's stale-on-error half).
+func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) {
+ // path.Clean above already collapsed dot and empty segments; re-check
+ // them anyway, because this is the one place where a public request
+ // chooses the bucket key and "the cleaner surely handled it" is how
+ // traversal bugs get written. Note ServeHTTP sees the DECODED path, so
+ // %2e%2e arrives here as ".." and dies on this check (or, once Clean
+ // has resolved it out of the /ledgers/ prefix entirely, never reaches
+ // this function at all).
+ parts := strings.Split(strings.TrimPrefix(p, "/"), "/") // ["ledgers", sqid, app, ...]
+ if len(parts) < 4 {
+ http.NotFound(w, r)
+ return
+ }
+ for _, seg := range parts {
+ if seg == "" || seg == "." || seg == ".." {
+ http.NotFound(w, r)
+ return
+ }
+ }
+ if !ledgerServable(parts[3:]) {
+ http.NotFound(w, r)
+ return
+ }
+ sqid, app := parts[1], parts[2]
+
+ mb, _, err := h.get(r.Context(), ledger.ManifestKey(sqid, app), "application/json")
+ if err != nil {
+ http.NotFound(w, r) // fail-closed: no readable manifest = not public
+ return
+ }
+ var m ledger.Manifest
+ if err := json.Unmarshal(mb, &m); err != nil || !m.Public {
+ http.NotFound(w, r)
+ return
+ }
+
+ key := strings.TrimPrefix(p, "/")
+ ctype := "application/json"
+ if !strings.HasSuffix(key, ".json") {
+ ctype = "application/octet-stream" // blobs are opaque bytes
+ }
+ body, ctype, err := h.get(r.Context(), key, ctype)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Write(body)
+}
+
+// ledgerServable is the object-shape allowlist inside one app's ledger
+// subtree, given the path segments AFTER ledgers/<sqid>/<app>/. The ledger
+// primitive writes exactly four shapes (internal/ledger/ledger.go's key
+// builders) and the edge publishes exactly those: living under a public
+// app's prefix is not, by itself, a reason to hand an object to the
+// internet. Same allowlist spirit as statusRoutes, one level down.
+func ledgerServable(rest []string) bool {
+ switch {
+ case len(rest) == 1: // ledgers/<sqid>/<app>/ledgers.json
+ return rest[0] == "ledgers.json"
+ case len(rest) == 2: // .../<ledger>/HEAD.json
+ return rest[1] == "HEAD.json"
+ case len(rest) == 3 && rest[1] == "entries": // .../<ledger>/entries/<ts>-<sha>.json
+ return strings.HasSuffix(rest[2], ".json")
+ case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha>
+ return true
+ }
+ return false
+}
+
// get implements the cache contract documented on statusHandler: fresh cache
// hit wins outright (no store touch at all — that's what bounds read
// volume); otherwise it tries the store, falling back to a stale cached copy
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go
new file mode 100644
index 000000000..34d8b197a
--- /dev/null
+++ b/internal/edge/status_test.go
@@ -0,0 +1,256 @@
+// Tests for statusHandler's /ledgers/ branch (status.go). The three fleet
+// routes are exercised end-to-end through the Edge in edge_test.go
+// (TestStatusRoute*); the ledger branch has no per-route config of its own,
+// so these drive the handler directly — same fake-clock/tiny-TTL seam.
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// seedLedger writes one real chained entry, one blob and the app's opt-in
+// manifest THROUGH the ledger package, so the keys under test are byte-for-
+// byte the keys production writes (the whole point of the 1:1 URL↔key map).
+func seedLedger(t *testing.T, st release.Store, public bool) (entryKey, blobSHA string) {
+ t.Helper()
+ ctx := context.Background()
+ a, err := ledger.Append(ctx, st, "bab", "platform", "carbon", "w",
+ json.RawMessage(`{"n":1}`),
+ func() time.Time { return time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC) })
+ if err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ sha, err := ledger.PutBlob(ctx, st, "bab", "platform", "carbon", []byte("blobby"))
+ if err != nil {
+ t.Fatalf("put blob: %v", err)
+ }
+ if err := ledger.WriteManifest(ctx, st, "bab", "platform",
+ ledger.Manifest{Public: public, Ledgers: []string{"carbon"}, Title: "CARLOS"}); err != nil {
+ t.Fatalf("write manifest: %v", err)
+ }
+ return a.Key, sha
+}
+
+func getStatus(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://status.test"+path, nil))
+ return rr
+}
+
+// TestLedgerServingPublic: with public:true, every object the ledger package
+// writes is fetchable at the path that equals its bucket key, with the
+// content type a browser (and `carlos ledger verify -url`) expects.
+func TestLedgerServingPublic(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ entryKey, blobSHA := seedLedger(t, st, true)
+ h := newStatusHandler(st)
+
+ for path, wantCT := range map[string]string{
+ "/ledgers/bab/platform/ledgers.json": "application/json",
+ "/ledgers/bab/platform/carbon/HEAD.json": "application/json",
+ "/" + entryKey: "application/json",
+ "/ledgers/bab/platform/carbon/blobs/" + blobSHA: "application/octet-stream",
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", path, rr.Code)
+ continue
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != wantCT {
+ t.Errorf("GET %s Content-Type = %q, want %q", path, ct, wantCT)
+ }
+ }
+ if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" {
+ t.Errorf("blob body = %q, want %q", body, "blobby")
+ }
+}
+
+// TestLedgerServingFailClosed: every way of NOT being a published public
+// object answers the same plain 404 — a non-public app must be
+// indistinguishable from an app that does not exist.
+func TestLedgerServingFailClosed(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ entryKey, blobSHA := seedLedger(t, st, false) // chain exists, manifest says NOT public
+ // An app whose manifest exists but is unparseable must also fail closed.
+ if _, err := st.Put(context.Background(), ledger.ManifestKey("bab", "broken"),
+ []byte("{not json"), ""); err != nil {
+ t.Fatalf("seed broken manifest: %v", err)
+ }
+ h := newStatusHandler(st)
+
+ for _, path := range []string{
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/ledgers.json",
+ "/" + entryKey,
+ "/ledgers/bab/platform/carbon/blobs/" + blobSHA,
+ "/ledgers/bab/broken/ledgers.json", // manifest unparseable
+ "/ledgers/nope/nothing/carbon/HEAD.json", // no manifest at all
+ "/ledgers/bab/platform/carbon/../../secrets", // traversal shape
+ "/ledgers/bab", // too-short path
+ "/ledgers/bab/platform", // too-short path
+ "/ledgers/", // bare prefix
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rr.Code)
+ }
+ }
+
+ // Byte-identical bodies: the private app and the app that was never
+ // created must not be told apart by their 404s.
+ private := getStatus(t, h, "/ledgers/bab/platform/ledgers.json")
+ absent := getStatus(t, h, "/ledgers/nope/nothing/ledgers.json")
+ if private.Body.String() != absent.Body.String() {
+ t.Errorf("404 bodies differ: private %q vs absent %q", private.Body.String(), absent.Body.String())
+ }
+}
+
+// TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that
+// app's own ledger subtree and nothing else — no encoded traversal reaches a
+// key outside it.
+func TestLedgerServingNeverLeavesPrefix(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"fleet":true}`), ""); err != nil {
+ t.Fatalf("seed fleet: %v", err)
+ }
+ if _, err := st.Put(context.Background(), "apps/bab/platform/config.json", []byte(`{"secret":true}`), ""); err != nil {
+ t.Fatalf("seed app config: %v", err)
+ }
+ h := newStatusHandler(st)
+
+ for _, path := range []string{
+ "/ledgers/bab/platform/%2e%2e/%2e%2e/%2e%2e/status/fleet.json",
+ "/ledgers/bab/platform/../../../apps/bab/platform/config.json",
+ "/ledgers/bab/platform/carbon/%2e%2e/%2e%2e/%2e%2e/%2e%2e/apps/bab/platform/config.json",
+ "/ledgers/bab/platform//../../apps/bab/platform/config.json",
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code == http.StatusOK {
+ t.Errorf("GET %s = 200 %q — escaped the app's ledger subtree", path, rr.Body.String())
+ }
+ }
+}
+
+// TestLedgerServingOnlyLedgerShapes: even inside a public app's own subtree
+// the handler serves only the four object shapes the ledger primitive
+// defines; anything else that happens to be under the prefix stays private.
+func TestLedgerServingOnlyLedgerShapes(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ ctx := context.Background()
+ for _, key := range []string{
+ "ledgers/bab/platform/notes.txt",
+ "ledgers/bab/platform/other.json",
+ "ledgers/bab/platform/carbon/notes.txt",
+ "ledgers/bab/platform/carbon/entries/raw.bin",
+ "ledgers/bab/platform/carbon/blobs/deep/nested",
+ } {
+ if _, err := st.Put(ctx, key, []byte("private"), ""); err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+ }
+ h := newStatusHandler(st)
+ for _, key := range []string{
+ "ledgers/bab/platform/notes.txt",
+ "ledgers/bab/platform/other.json",
+ "ledgers/bab/platform/carbon/notes.txt",
+ "ledgers/bab/platform/carbon/entries/raw.bin",
+ "ledgers/bab/platform/carbon/blobs/deep/nested",
+ } {
+ if rr := getStatus(t, h, "/"+key); rr.Code != http.StatusNotFound {
+ t.Errorf("GET /%s = %d, want 404", key, rr.Code)
+ }
+ }
+}
+
+// TestLedgerServingMissingObjectIs404: under a public app, an object that
+// isn't there is a 404 — the 503 "nothing honest left to serve" contract
+// belongs to the fleet page's own three objects, not to ledger reads.
+func TestLedgerServingMissingObjectIs404(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ h := newStatusHandler(st)
+ for _, path := range []string{
+ "/ledgers/bab/platform/nosuch/HEAD.json",
+ "/ledgers/bab/platform/carbon/entries/20260101T000000Z-deadbeefcafe.json",
+ "/ledgers/bab/platform/carbon/blobs/" + ledger.SHA256Hex([]byte("absent")),
+ } {
+ if rr := getStatus(t, h, path); rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rr.Code)
+ }
+ }
+}
+
+// TestLedgerServingStaleManifestHonored pins the interaction with get()'s
+// stale-on-error half: the manifest is just another cached object, so once a
+// public manifest has been read, a store outage keeps the already-cached
+// objects serving (the alternative — 404ing a public ledger because the
+// bucket blipped — is the same dishonesty the status page's cache exists to
+// avoid). An object that was never cached still 404s: stale caching, not
+// fabrication.
+func TestLedgerServingStaleManifestHonored(t *testing.T) {
+ dir := t.TempDir()
+ st := release.NewDirStore(dir)
+ entryKey, _ := seedLedger(t, st, true)
+ h := newStatusHandler(st)
+ h.ttl = 20 * time.Millisecond
+ fakeNow := time.Now()
+ h.now = func() time.Time { return fakeNow }
+
+ first := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json")
+ if first.Code != http.StatusOK {
+ t.Fatalf("first fetch = %d, want 200", first.Code)
+ }
+
+ // Break the store entirely and age both cached objects past the TTL.
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatalf("break store: %v", err)
+ }
+ fakeNow = fakeNow.Add(21 * time.Millisecond)
+
+ stale := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json")
+ if stale.Code != http.StatusOK || stale.Body.String() != first.Body.String() {
+ t.Fatalf("stale fetch = %d %q, want 200 %q", stale.Code, stale.Body.String(), first.Body.String())
+ }
+
+ // The entry was never cached: a stale-but-public manifest does not
+ // conjure a body for it.
+ if rr := getStatus(t, h, "/"+entryKey); rr.Code != http.StatusNotFound {
+ t.Errorf("uncached entry during outage = %d, want 404", rr.Code)
+ }
+}
+
+// TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must
+// not intercept the three fleet paths, which keep their own 503-when-nothing-
+// cached contract.
+func TestLedgerBranchLeavesFleetRoutesAlone(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ h := newStatusHandler(st)
+ for _, path := range []string{"/", "/fleet.json", "/status.css"} {
+ if rr := getStatus(t, h, path); rr.Code != http.StatusServiceUnavailable {
+ t.Errorf("GET %s = %d, want 503 (nothing published)", path, rr.Code)
+ }
+ }
+ if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil {
+ t.Fatalf("seed fleet: %v", err)
+ }
+ rr := getStatus(t, h, "/fleet.json")
+ if rr.Code != http.StatusOK || rr.Body.String() != `{"ok":true}` {
+ t.Errorf("GET /fleet.json = %d %q, want 200 fleet body", rr.Code, rr.Body.String())
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
+ t.Errorf("GET /fleet.json Content-Type = %q", ct)
+ }
+}