carlos / platform Public

edge: per-ledger allowlist, nosniff, and no status responses in the shared cache

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b57e75125feeb75154f8b724fb85adecaf29b8a9 parent 5f99098
4 files changed, +242 −31
  • docs/superpowers/specs/2026-08-03-open-ledgers-design.md +6 −2
  • internal/edge/edge.go +18 −1
  • internal/edge/status.go +59 −28
  • internal/edge/status_test.go +159 −0
diff --git a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
index 329ca6329..9e879d7ba 100644
--- a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
+++ b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
@@ -153,8 +153,12 @@ The edge's `Kind=="status"` route (`internal/edge/status.go`) currently
serves a fixed allowlisted key→path map from the `status/` prefix with a 60s
cache and stale-serves-while-store-hiccups. It gains one prefix rule:
`GET /ledgers/<sqid>/<app>/...` maps to the same bucket key **iff** the
-ledger opt-in manifest (§4.1) marks that app public — checked from a cached
-copy on the same 60s tick, fail-closed (no manifest → 404). JSON and blobs
+ledger opt-in manifest (§4.1) marks that app public AND, for everything
+below the app level, names that ledger in its `ledgers` array — the array is
+the per-ledger allowlist, not merely an index, because an account accrues
+chains privately and publishing is per ledger by name (an empty array
+publishes nothing but `ledgers.json` itself). Checked from a cached copy on
+the same 60s tick, fail-closed (no manifest → 404). JSON and blobs
only, plus the static ledger pages (§4.3) which ship inside the status
site's existing page set. No new timers, no publish train: an appended entry
is publicly readable on the next cache tick. The fleet renderer's lease and
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 8c5372eb7..1dc161356 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -318,7 +318,20 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Cache check happens before the hibernation branch below — a hit must
// never reach the activator, since avoiding pointless wakes for
// bot/crawler/repeat traffic is the entire point of this cache.
- cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead)
+ //
+ // Kind=="status" is exempt from the shared cache in both directions: the
+ // status host caches for itself (status.go's per-key store cache) and its
+ // /ledgers/ objects carry a long immutable Cache-Control so browsers and
+ // CDNs cache them. Admitting them HERE would be a different thing
+ // entirely — this LRU is process-global and shared across every host, and
+ // a public ledger chain is unbounded in length, so an anonymous walker
+ // could evict every other tenant's cached responses at will. An evicted
+ // entry for a hibernating route means the next request wakes the instance
+ // instead of being answered from cache, which is the invariant this cache
+ // exists to hold: the status host caches for itself; its responses never
+ // enter the shared response cache.
+ cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
+ up.route.Kind != "status"
var key CacheKey
if cacheable {
key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery}
@@ -405,6 +418,10 @@ func (e *Edge) cacheMaxBody() int {
//
// The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always
// self-heals within a bounded window.
+//
+// One refusal is NOT made here because it has to be made earlier, where the
+// route is still in hand: Kind=="status" responses are never admitted at
+// all. See the cacheable computation in ServeHTTP for why.
func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) {
if e.cache == nil || !rec.cacheable || rec.truncated {
return
diff --git a/internal/edge/status.go b/internal/edge/status.go
index e951d5cd3..777581e62 100644
--- a/internal/edge/status.go
+++ b/internal/edge/status.go
@@ -18,6 +18,7 @@ import (
"log"
"net/http"
"path"
+ "slices"
"strings"
"sync"
"time"
@@ -128,13 +129,21 @@ func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 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.
+// handler is an app whose manifest reads, parses, says public:true, and —
+// for anything below the app level — names that ledger in its Ledgers list.
+// No manifest, an unreadable one, an unparseable one, public:false, or a
+// ledger the manifest doesn't name 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 of their ledgers do. 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.
+//
+// Manifest.Ledgers is read as an ALLOWLIST, not as a convenience index: an
+// account accrues ledgers privately (the console writes chains whether or
+// not anything is published), so "the app is public" cannot be allowed to
+// mean "every chain it has ever written is public". Publishing is per
+// ledger, by name; an empty list publishes nothing but the index itself.
//
// Ledger objects fall into two cache classes, because unlike the fleet
// page's three fixed objects a public ledger has unboundedly many:
@@ -173,8 +182,8 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st
return
}
}
- servable, immutable := ledgerShape(parts[3:])
- if !servable {
+ obj, ok := ledgerShape(parts[3:])
+ if !ok {
http.NotFound(w, r)
return
}
@@ -190,14 +199,23 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st
http.NotFound(w, r)
return
}
+ // obj.ledger is "" only for ledgers.json, the app's OWN published index,
+ // which the app is publishing by setting public:true. Everything else
+ // names a ledger and must be on the allowlist.
+ if obj.ledger != "" && !slices.Contains(m.Ledgers, obj.ledger) {
+ 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
- }
+ ctype := obj.ctype
+ // Sniffing is what would turn an opaque blob into something a browser
+ // executes in this origin's context; the ledger's bytes are arbitrary
+ // tenant content, so pin the declared type on every response, both
+ // classes.
+ w.Header().Set("X-Content-Type-Options", "nosniff")
- if immutable {
+ if obj.immutable {
// Uncached class: straight to the store, and let the caller cache
// it forever — an entry key commits to its own bytes (the ts-sha
// key) and a blob key IS its sha256, so neither can ever change
@@ -232,28 +250,41 @@ func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p st
// is the 60s server-side TTL's job, not a client's.
const ledgerImmutableCacheControl = "public, max-age=31536000, immutable"
+// ledgerObject is everything the path shape alone decides about a servable
+// ledger object — before any manifest is consulted.
+type ledgerObject struct {
+ // ctype comes from the SHAPE, never from the key's suffix: a blob is
+ // named by its sha256 and a tenant may well upload one whose sha ends
+ // ".json". Blobs are opaque bytes, and the shape is what knows that.
+ ctype string
+ // immutable selects the cache class documented on serveLedger.
+ immutable bool
+ // ledger is the ledger name this object belongs to, or "" for the
+ // app-level index (ledgers.json) — the allowlist gate keys off it.
+ ledger string
+}
+
// ledgerShape 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.
-//
-// immutable splits the two cache classes documented on serveLedger — true
-// for content-addressed entries and blobs, false for the mutable
-// ledgers.json/HEAD.json pair. It is only meaningful when servable is true.
-func ledgerShape(rest []string) (servable, immutable bool) {
+// spirit as statusRoutes, one level down. One switch decides shape, content
+// type, cache class and owning ledger together, so those four cannot drift
+// apart as shapes are added.
+func ledgerShape(rest []string) (ledgerObject, bool) {
+ const jsonCT, blobCT = "application/json", "application/octet-stream"
switch {
- case len(rest) == 1: // ledgers/<sqid>/<app>/ledgers.json
- return rest[0] == "ledgers.json", false
- case len(rest) == 2: // .../<ledger>/HEAD.json
- return rest[1] == "HEAD.json", false
- case len(rest) == 3 && rest[1] == "entries": // .../<ledger>/entries/<ts>-<sha>.json
- return strings.HasSuffix(rest[2], ".json"), true
+ case len(rest) == 1 && rest[0] == "ledgers.json": // ledgers/<sqid>/<app>/ledgers.json
+ return ledgerObject{ctype: jsonCT}, true
+ case len(rest) == 2 && rest[1] == "HEAD.json": // .../<ledger>/HEAD.json
+ return ledgerObject{ctype: jsonCT, ledger: rest[0]}, true
+ case len(rest) == 3 && rest[1] == "entries" && strings.HasSuffix(rest[2], ".json"):
+ return ledgerObject{ctype: jsonCT, immutable: true, ledger: rest[0]}, true
case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha>
- return true, true
+ return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true
}
- return false, false
+ return ledgerObject{}, false
}
// get implements the cache contract documented on statusHandler: fresh cache
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go
index beb152abe..c66260550 100644
--- a/internal/edge/status_test.go
+++ b/internal/edge/status_test.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -77,6 +78,11 @@ func TestLedgerServingPublic(t *testing.T) {
if cc := rr.Header().Get("Cache-Control"); cc != want[1] {
t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1])
}
+ // Ledger bytes are arbitrary tenant content served from the status
+ // host's own origin: every shape must refuse content sniffing.
+ if xcto := rr.Header().Get("X-Content-Type-Options"); xcto != "nosniff" {
+ t.Errorf("GET %s X-Content-Type-Options = %q, want nosniff", path, xcto)
+ }
}
if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" {
t.Errorf("blob body = %q, want %q", body, "blobby")
@@ -123,6 +129,90 @@ func TestLedgerServingFailClosed(t *testing.T) {
}
}
+// appendTo adds one entry to another ledger of the same app, so a test can
+// have a published and an unpublished chain side by side.
+func appendTo(t *testing.T, st release.Store, name string) string {
+ t.Helper()
+ a, err := ledger.Append(context.Background(), st, "bab", "platform", name, "w",
+ json.RawMessage(`{"n":2}`),
+ func() time.Time { return time.Date(2026, 8, 3, 22, 0, 0, 0, time.UTC) })
+ if err != nil {
+ t.Fatalf("append %s: %v", name, err)
+ }
+ return a.Key
+}
+
+// TestLedgerAllowlistIsPerLedger: Manifest.Ledgers is an allowlist, not an
+// index. An account accrues chains privately, so public:true on the app must
+// not publish every chain it has ever written — only the ones it names.
+func TestLedgerAllowlistIsPerLedger(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true) // manifest publishes "carbon" only
+ ironEntry := appendTo(t, st, "iron")
+ ironSHA, err := ledger.PutBlob(context.Background(), st, "bab", "platform", "iron", []byte("ironblob"))
+ if err != nil {
+ t.Fatalf("put iron blob: %v", err)
+ }
+ h := newStatusHandler(st)
+
+ if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
+ t.Fatalf("published ledger = %d, want 200", rr.Code)
+ }
+ absent := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json")
+ for _, path := range []string{
+ "/ledgers/bab/platform/iron/HEAD.json",
+ "/" + ironEntry,
+ "/ledgers/bab/platform/iron/blobs/" + ironSHA,
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("unpublished ledger GET %s = %d, want 404", path, rr.Code)
+ continue
+ }
+ if rr.Body.String() != absent.Body.String() {
+ t.Errorf("GET %s 404 body %q differs from an absent app's %q",
+ path, rr.Body.String(), absent.Body.String())
+ }
+ }
+
+ // An empty list publishes nothing but the app's own index.
+ if err := ledger.WriteManifest(context.Background(), st, "bab", "platform",
+ ledger.Manifest{Public: true, Ledgers: nil, Title: "CARLOS"}); err != nil {
+ t.Fatalf("rewrite manifest: %v", err)
+ }
+ h2 := newStatusHandler(st) // fresh handler: don't wait out the 60s TTL
+ if rr := getStatus(t, h2, "/ledgers/bab/platform/ledgers.json"); rr.Code != http.StatusOK {
+ t.Errorf("ledgers.json with an empty list = %d, want 200 (the app's own index)", rr.Code)
+ }
+ for _, path := range []string{
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/iron/HEAD.json",
+ } {
+ if rr := getStatus(t, h2, path); rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s with an empty allowlist = %d, want 404", path, rr.Code)
+ }
+ }
+}
+
+// TestLedgerBlobContentTypeComesFromShape: a blob is named by its sha256 and
+// nothing stops a tenant uploading one whose name ends ".json" — the content
+// type must come from the path SHAPE, not from a suffix probe on the key.
+func TestLedgerBlobContentTypeComesFromShape(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ if _, err := st.Put(context.Background(),
+ "ledgers/bab/platform/carbon/blobs/deadbeef.json", []byte(`{"not":"json-typed"}`), ""); err != nil {
+ t.Fatalf("seed blob: %v", err)
+ }
+ rr := getStatus(t, newStatusHandler(st), "/ledgers/bab/platform/carbon/blobs/deadbeef.json")
+ if rr.Code != http.StatusOK {
+ t.Fatalf("blob = %d, want 200", rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" {
+ t.Errorf("blob Content-Type = %q, want application/octet-stream", ct)
+ }
+}
+
// TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that
// app's own ledger subtree and nothing else — no encoded traversal reaches a
// key outside it.
@@ -316,6 +406,75 @@ func TestLedgerServingStaleManifestHonored(t *testing.T) {
}
}
+// statusEdgeWithCache wires a full Edge (Host dispatch, shared response
+// cache) in front of one status route — testEdgeWithStore's shape, but
+// returning the *Edge so a test can look inside e.cache.
+func statusEdgeWithCache(t *testing.T) (release.Store, *Edge) {
+ t.Helper()
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "status.test", Kind: "status", Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ store := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
+ _, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatalf("parse public key: %v", err)
+ }
+ e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ return store, e
+}
+
+// TestStatusResponsesNeverEnterSharedCache: the shared response cache is
+// process-global and shared across every host, and a public ledger chain is
+// unbounded in length — so a stranger walking one must not be able to admit
+// (and thereby evict) anything in it. An evicted entry for a hibernating
+// route is a wake that the cache existed to prevent. The client-facing
+// immutable Cache-Control still goes out: browsers and CDNs do the caching.
+func TestStatusResponsesNeverEnterSharedCache(t *testing.T) {
+ store, e := statusEdgeWithCache(t)
+ entryKey, blobSHA := seedLedger(t, store, true)
+ if _, err := store.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil {
+ t.Fatalf("seed fleet: %v", err)
+ }
+
+ for _, path := range []string{
+ "/" + entryKey,
+ "/ledgers/bab/platform/carbon/blobs/" + blobSHA,
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/ledgers.json",
+ "/fleet.json",
+ } {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", path, rec.Code)
+ }
+ if _, ok := e.cache.get(CacheKey{Host: "status.test", Method: "GET", Path: path}); ok {
+ t.Errorf("GET %s entered the shared edge response cache", path)
+ }
+ }
+
+ // The header a client sees is unchanged — only the edge's own admission
+ // is refused.
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/"+entryKey, nil))
+ if cc := rec.Header().Get("Cache-Control"); cc != ledgerImmutableCacheControl {
+ t.Errorf("entry Cache-Control = %q, want %q", cc, ledgerImmutableCacheControl)
+ }
+}
+
// TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must
// not intercept the three fleet paths, which keep their own 503-when-nothing-
// cached contract.