// The store door: release.Store spoken over HTTPS on the edge's reserved // tunnel hostname, so a fleet box needs no AWS credentials on the platform's // deployment bucket at all (spec 2026-08-11-fleet-api-surface-design.md, // F1-F4; issue #73). // // One mechanism, not N endpoints: the five Store operations, authenticated // per request by the credential the box already holds (its fleet tunnel // token, verified through TunnelHub.VerifyDynamic — the same hash table // detach/rotate already converge in seconds), and ACL'd server-side from the // fleet's registry row. The box's own loops — adoption, fast-converge, // config, status, metrics, bindings — run unchanged against the client half // (Task 3). // // Everything a caller is not entitled to is a byte-identical 404: wrong // token, static (non-fleet) name, another tenant's key, a key that does not // exist, a key shaped like a traversal. The keyspace must never be an // oracle, so no response distinguishes them, and no refusal ever costs more // than one constant-time verify. package edge import ( "encoding/json" "errors" "io" "log" "net/http" "os" "sort" "strconv" "strings" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/plans" "github.com/carlosframework/platform/internal/release" ) const ( // storeDoorPrefix is what edge.go routes to this handler on the reserved // tunnel hostname; /register (the tunnel handshake) stays with the hub. storeDoorPrefix = "/store/" storeObjectPrefix = "/store/o/" storeListPath = "/store/list" storeDirsPath = "/store/dirs" // maxStoreKeyBytes is the pre-ACL key ceiling (spec: keys >1024 bytes are // refused before the ACL runs). S3's own limit. maxStoreKeyBytes = 1024 // maxStoreBodyBytes bounds one PUT. Everything a box may write lives // under fleets// — heartbeats, metrics, alerts, bindings, shipped // logs — none of which approach this; the ceiling exists so a // compromised box cannot make the edge buffer arbitrary memory. maxStoreBodyBytes = 32 << 20 ) // storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4). // // It holds the deployment-store handle the edge ALREADY has (EnableStatic's, // shared with the static and status serving classes) — never a second S3 // client, and never anything the request can influence: no client-supplied // AWS header is read, and no bucket identity is ever exposed (F8: artifact // bytes stream through the edge rather than being redirected to a presigned // URL). type storeDoor struct { hub *TunnelHub // VerifyDynamic: the one auth path, called once per request store release.Store // the deployment bucket, edge's own handle // fleetAccount maps an authenticated fleet name to its owning account's // sqid — the whole basis of the read ACL. Backed by the edge's // reload-time snapshot (Edge.fleetAccount), refreshed on the same pass // that feeds the hub's FleetChannelAuth table, so a fleet's identity and // its credentials can never come from two different reads of the // registry. fleetAccount func(fleet string) (sqid string, ok bool) } // ServeHTTP is the whole door. The order is load-bearing: // // 1. auth — exactly once, before anything else, for every path and method; // 2. fleet → account (the ACL's identity); // 3. key canonicalization (traversal, empty segments, oversize); // 4. the ACL; // 5. only then the request's own well-formedness (a PUT's precondition) and // the store. // // Steps 1-4 all answer http.NotFound, so a caller cannot learn from a // response which of them refused it — in particular a 400 for a missing // precondition is only ever reachable on a key this fleet may actually // write, never as a probe for whether such a key exists. // // The CAS mapping onto release.Store's contract (store.go), exactly: // // If-None-Match: * → Put(ifMatch: "") create-only // If-Match: → Put(ifMatch: ) replace-only // neither → 400 (there is no unconditional Put in the contract) // ErrCASConflict → 412 // Get not-found → 404 (os.ErrNotExist-wrapped, per the contract) // Delete → 204, always, for an in-scope key (idempotent) // // Etags are opaque strings on this wire, passed through in both directions // exactly as the backing store produced them (DirStore's bare sha256 hex, // S3's quoted tag): the door never parses or rewrites one, so the client's // If-Match always carries the same bytes its Get was handed. func (d *storeDoor) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Auth, once. Same two headers as the tunnel dial (F1), same table (F2): // only fleet channels ("/