// The status serving class (routes with Kind=="status"): the fleet status // page's three published objects (see internal/status's PublishFleet), // served straight out of the deployment bucket behind a per-key, // short-lived in-memory cache. Modeled on static.go's staticSite but much // simpler: PublishFleet overwrites the same three keys in place every // 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///..., path-for-key, gated per app by that app's // opt-in manifest — see serveLedger. package edge import ( "context" "encoding/json" "errors" "log" "net/http" "os" "path" "slices" "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 // 5 minutes (PublishFleet's publish cadence per the design spec). const statusCacheTTL = 60 * time.Second // statusRoutes is the fleet status page's fixed, allowlisted menu: the // public path a browser requests, mapped to the bucket key PublishFleet // wrote it under and the Content-Type to serve it with. Unlike static.go's // staticSite (an arbitrary extracted file tree), the status route never // serves anything outside this map — a request for any other path 404s, // same as an unlisted key would on a real file server. var statusRoutes = map[string]struct { key string ctype string }{ "/": {"status/index.html", "text/html; charset=utf-8"}, "/fleet.json": {"status/fleet.json", "application/json"}, "/status.css": {"status/status.css", "text/css; charset=utf-8"}, } // statusCached is one fetched-and-cached bucket object. type statusCached struct { body []byte ctype string fetched time.Time } // statusHandler serves the fleet status page's three files out of a // release.Store, cached per key. The cache is the point of this handler, not // an optimization bolted on afterward: the fleet status page exists so an // operator can tell whether the fleet is healthy, which means IT must not go // dark the instant the deployment bucket has a blip. So the contract is // asymmetric by design — // // - inside the TTL: always serve the cached copy, even if the store has // moved on (bounds bucket read volume/cost to once per key per TTL). // - outside the TTL with a store error and a cached copy in hand: still // serve the (now stale) cached copy rather than fail the request — the // status page outliving a transient bucket error is the whole point; // fleet.json's own staleness bands (internal/status/fleet.go) are what // tell the operator the DATA is old, so this handler doesn't need to // lie by omission (503) to be honest — the page itself says "stale". // - outside the TTL with a store error and NO cached copy (nothing has // ever been successfully fetched): 503. There is nothing honest left to // serve. // - one exception, for ledger objects only: outside the TTL with the // object GONE (os.ErrNotExist), drop the cached copy and fail. For a // ledger a deletion is the bucket's answer, not a blip: it is how an app // purge reaches this cache, and without it the purged app's public // ledger kept serving from every edge that had read it, to the end of // the process (discussion #219, item 2). The status page's own files // keep the stale-on-anything rule above; nothing deletes them on purpose. type statusHandler struct { store release.Store // ttl and now are both overridable by tests (a tiny ttl plus a fake // clock lets a TTL-expiry test run in microseconds instead of sleeping // 60 real seconds); production always gets statusCacheTTL and time.Now. ttl time.Duration now func() time.Time mu sync.Mutex cache map[string]statusCached // misses is the negative half of the ledger gate: manifest key → when // the "not servable" decision was taken. See markMiss. misses map[string]time.Time } // statusMissCacheMax bounds the negative cache. It exists because the // positive cache's bound — "get only records an entry after a SUCCESSFUL // store read, so only objects that exist can grow it" — is exactly the // property a negative cache gives up: the prober picks the account and app // segments, so the set of manifest keys they can name is unbounded. 1024 // apps' worth of decisions is far more than any real deployment publishes // and costs a few tens of KiB; past it the map is CLEARED rather than // evicted one entry at a time, because there is no access pattern here worth // approximating with an LRU — the honest read of a full map is "this is // being probed", and a probe's entries are the ones worth dropping. const statusMissCacheMax = 1024 // newStatusHandler builds the handler for one status route. store is shared // with the static serving class (see EnableStatic's doc comment) — the // status page has no per-route config of its own (no app, no channel), so // unlike newStaticHandler this takes no registry.Route. func newStatusHandler(store release.Store) *statusHandler { return &statusHandler{ store: store, ttl: statusCacheTTL, now: time.Now, cache: make(map[string]statusCached), misses: make(map[string]time.Time), } } // missFresh reports whether the "not servable" decision for this manifest // key was taken recently enough to reuse. func (h *statusHandler) missFresh(key string) bool { h.mu.Lock() defer h.mu.Unlock() at, ok := h.misses[key] return ok && h.now().Sub(at) < h.ttl } // markMiss records that an app is not servable — no manifest, an unparseable // one, or public:false — for the same TTL the positive cache trusts a fetched // object. Publishing an app therefore takes effect within TWO ticks worst // case (~2×TTL), one typically: the two caches are stamped independently, so // a public:false manifest read at t=0 can still be serving from the positive // cache at t=59s and stamp a NEGATIVE entry then, which outlives it. Revoking // has the same shape from the other side. Both are bounded and small; the // alternative — invalidating across the two caches — would buy a second off a // 5-minute-cadence page. // // The bound is the point: see statusMissCacheMax. func (h *statusHandler) markMiss(key string) { h.mu.Lock() defer h.mu.Unlock() if len(h.misses) >= statusMissCacheMax { if _, ok := h.misses[key]; !ok { clear(h.misses) } } h.misses[key] = h.now() } func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 } body, ctype, err := h.get(r.Context(), rt.key, rt.ctype) if err != nil { log.Printf("edge: status %s: %v", rt.key, err) http.Error(w, "status page unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", ctype) w.Write(body) } // serveLedger maps /ledgers///... 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, 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: // // - ledgers.json and HEAD.json are MUTABLE and few — one manifest per // public app, one HEAD per ledger — so they ride h.get's shared 60s // cache. Revoking public therefore takes at most one TTL, and a cached // public manifest keeps serving through a store outage exactly like any // other cached object (get's stale-on-error half). That map cannot be // grown by a stranger probing random sqids: get only writes an entry // AFTER a successful store read (see its tail), so it is bounded by what // actually exists. The REFUSALS a prober generates are remembered // separately, in the explicitly bounded negative cache (markMiss), so a // probe costs a bucket GET at most once per TTL per app rather than once // per request. // - entries/*.json and blobs/* are IMMUTABLE and unbounded in number // (an entry per append, blobs up to ledger.MaxBlob each), so they are // NOT cached in this process at all: a direct store read plus a // year-long immutable Cache-Control, which puts the caching in browsers // and CDNs where the memory isn't ours. That is deliberate: a public // chain walker must not be able to pin arbitrary edge memory by simply // reading every entry of a public ledger. 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 } } obj, ok := ledgerShape(parts[3:]) if !ok { http.NotFound(w, r) return } sqid, app := parts[1], parts[2] // The negative half of the gate, checked BEFORE the store: a miss is // never recorded by get (it only caches successful reads), and // status-kind responses never enter the shared response cache, so // without this every probe at a made-up account/app costs one bucket GET // — and the prober writes the path. mkey := ledger.ManifestKey(sqid, app) if h.missFresh(mkey) { http.NotFound(w, r) return } mb, _, err := h.get(r.Context(), mkey, "application/json") if err != nil { // Only a genuine not-found is REMEMBERED. That is what the negative // cache is for: a prober names manifests that do not exist, and the // store answers os.ErrNotExist (release.Store's contract). Any other // error is a blip — a throttle, an expired credential — and stamping // it would take a live public app dark for the full TTL because the // bucket hiccuped once while this key's positive cache happened to be // cold. This request still 404s (fail-closed: nothing readable said // public); the next one retries. if errors.Is(err, os.ErrNotExist) { h.markMiss(mkey) } 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 { h.markMiss(mkey) 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 := 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 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 // meaning. A store error here is the same plain 404 as a key that // was never written; nothing is logged, for the same reason the // rest of this handler logs nothing. body, _, err := h.store.Get(r.Context(), key) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", ctype) w.Header().Set("Cache-Control", ledgerImmutableCacheControl) w.Write(body) return } 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) } // ledgerImmutableCacheControl is what entries and blobs are served with: a // year, immutable. Content-addressed keys make that safe, and it is what // keeps the caching of an unbounded object set in browsers and CDNs instead // of this process's memory. ledgers.json and HEAD.json deliberately get NO // Cache-Control — they are the mutable head of the chain and their freshness // 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///. 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. 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 && rest[0] == "ledgers.json": // ledgers///ledgers.json return ledgerObject{ctype: jsonCT}, true case len(rest) == 2 && rest[1] == "HEAD.json": // ...//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": // ...//blobs/ return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true } return ledgerObject{}, 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 // on error, and only fails when there is nothing cached to fall back to or // a ledger object is gone. func (h *statusHandler) get(ctx context.Context, key, ctype string) ([]byte, string, error) { h.mu.Lock() c, cached := h.cache[key] fresh := cached && h.now().Sub(c.fetched) < h.ttl h.mu.Unlock() if fresh { return c.body, c.ctype, nil } body, _, err := h.store.Get(ctx, key) if err != nil { if errors.Is(err, os.ErrNotExist) && strings.HasPrefix(key, "ledgers/") { if cached { h.mu.Lock() delete(h.cache, key) h.mu.Unlock() } return nil, "", err } if cached { log.Printf("edge: status %s: store read failed outside TTL (%v) — serving stale copy fetched at %s", key, err, c.fetched.Format(time.RFC3339)) return c.body, c.ctype, nil } return nil, "", err } entry := statusCached{body: body, ctype: ctype, fetched: h.now()} h.mu.Lock() h.cache[key] = entry h.mu.Unlock() return body, ctype, nil }