carlos / platform Public

Merge pull request #145 from carlosframework/worktree-fleet-api

Fleet API surface: the deployment bucket behind the edge (#73)
Paul Campbell a73539fe80972e40d63a41ff0a3ceb7693ccbd6b parent fb59056parent 9460853
23 files changed, +3718 −73
  • docs/superpowers/plans/2026-08-11-fleet-api-surface.md +97 −0
  • docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md +115 −0
  • infra/dev/region-pi.md +12 −0
  • infra/eleven-fleet.md +160 −0
  • infra/modules/README.md +9 −5
  • infra/modules/region-remote-host/README.md +20 −3
  • infra/modules/region-remote-host/main.tf +33 −13
  • infra/modules/region-remote-host/variables.tf +2 −1
  • internal/console/accountfleet_test.go +7 −0
  • internal/console/templates/fleet_byo.html +19 −2
  • internal/edge/edge.go +76 −0
  • internal/edge/fleetapi_e2e_test.go +544 −0
  • internal/edge/storedoor.go +505 −0
  • internal/edge/storedoor_test.go +701 −0
  • internal/edge/tunnel.go +38 −9
  • internal/edge/tunnel_test.go +66 −0
  • internal/fleetapi/fleetapi.go +254 −0
  • internal/fleetapi/fleetapi_internal_test.go +39 −0
  • internal/fleetapi/fleetapi_test.go +366 −0
  • internal/host/agent.go +198 −22
  • internal/host/agent_test.go +422 −0
  • internal/host/logship.go +20 −8
  • internal/host/metrics_loop.go +15 −10
diff --git a/docs/superpowers/plans/2026-08-11-fleet-api-surface.md b/docs/superpowers/plans/2026-08-11-fleet-api-surface.md
new file mode 100644
index 000000000..ff4d3a856
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-11-fleet-api-surface.md
@@ -0,0 +1,97 @@
+# Fleet API Surface Implementation Plan (issue #73)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fleet boxes lose direct platform-bucket S3: a `release.Store` spoken over HTTPS through the edge's reserved hostname, fleet-token-authed, account-scoped server-side — per `docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md` (F1–F8).
+
+**Architecture:** New edge handler beside the tunnel `/register` reusing the dynamic-auth table; new `internal/fleetapi` client implementing `release.Store`; env-driven store selection on the box (bucket set wins = fallback). No loop changes.
+
+**Tech Stack:** Go stdlib HTTP; no new deps.
+
+## Global Constraints
+
+- Branch `worktree-fleet-api` from main 79f6fc2. Gate per commit batch: `go build ./... && go vet ./... && go test ./... -count=1` (all packages), `gofmt -l` clean on touched files, `GOOS=darwin go build ./...`. Env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; sandbox off only on "read-only file system". Known flake: internal/activator TestUnitTenantFailedHealthStopsUnit — re-run once.
+- Security invariants from the spec §Security are binding on every task: byte-identical 404s (no oracle), constant-time token compare, static names never authenticate the door, canonicalization before ACL, no token logging.
+- Edge test suite runs -race in CI: run `go test ./internal/edge/ -count=1 -race` in any task touching internal/edge.
+- Wire shape additive; platform boxes' S3 behavior byte-identical (regression-pinned in Task 4).
+
+---
+
+### Task 1: `TunnelHub.VerifyDynamic` — auth extraction
+
+**Files:** Modify `internal/edge/tunnel.go` (ServeHTTP dynamic branch ~L129-138); Test `internal/edge/tunnel_test.go`.
+
+**Interfaces — Produces:** `func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool)` — ok only when `name` contains "/" AND is present in h.dynamicAuth AND the sha256-hex of token constant-time-matches; fleet = the segment before the first "/". ServeHTTP's dynamic branch calls it (behavior byte-identical: same single-401, viaDynamic = ok).
+
+Steps (TDD): failing tests — static name refused even with correct plaintext in h.clients; unknown dynamic name refused; wrong token refused; right token → (fleet, true); after SetDynamicAuth swap removing the name, refused. Then extract, re-run edge suite with -race, commit `edge: VerifyDynamic — the dynamic-auth check as a reusable gate`.
+
+### Task 2: the store door — edge handler + canonicalization + ACL
+
+**Files:** Create `internal/edge/storedoor.go`, `internal/edge/storedoor_test.go`. Modify `internal/edge/edge.go` (reserved-host routing: today it hands the reserved host to TunnelHub — route `/store/` paths to the new handler first, `/register` to the hub unchanged) and `internal/edge/tunnel.go` only if the hub needs a getter for the door.
+
+**Interfaces — Consumes:** Task 1's `VerifyDynamic`; the edge's existing deployment store handle (find how edge.go builds/holds its release store — reuse, do not open a second S3 client) and its registry handle (`e.reg.GetFleet(name)` for fleet→account sqid). **Produces:**
+
+```go
+// storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4).
+type storeDoor struct {
+ hub *TunnelHub // VerifyDynamic
+ store release.Store // the deployment bucket, edge's own handle
+ fleetAccount func(fleet string) (sqid string, ok bool) // registry lookup, cached per reload like FleetChannelAuth consumers
+}
+// Routes (reserved host only):
+// GET /store/o/<key> → Get (200 body + ETag; 404)
+// PUT /store/o/<key> → Put (If-Match or If-None-Match:*; 200 new ETag; 412)
+// DELETE /store/o/<key> → Delete (204 always for in-scope keys)
+// GET /store/list?prefix=<p> → List (200 JSON array of keys)
+// GET /store/dirs?prefix=<p> → ListDirs (200 JSON array)
+func canonicalKey(raw string) (string, bool) // refuses "..", "//", leading "/", empty, >1024
+func aclRead(sqid, fleet, key string) bool // control/serial | bootstrap/carlos | accounts/<sqid>.json | apps/<sqid>/**
+func aclWrite(fleet, key string) bool // fleets/<fleet>/** only (write AND delete)
+func aclListScope(sqid, fleet, prefix string) (string, bool) // maps "apps/"→"apps/<sqid>/", "accounts/"→allowed-with-filter, "fleets/<fleet>/..."→itself; else false
+```
+
+Every refusal — bad auth, bad key, out-of-scope, missing object — is `http.NotFound(w, r)` after auth ran; auth runs exactly once per request; the handler on any NON-reserved host is unreachable (assert in test via the edge's routing, mirroring how tunnel registration is host-gated).
+
+Steps (TDD): the ACL matrix table test FIRST (every inventory key class from the spec × op × own/other → allow/404 — write ~25 rows, exact keys like `apps/ab12/app/channels/stable.json`, `apps/OTHER/app/...`, `fleets/pi/bindings/h.json`, `fleets/other/...`, `control/serial`, `bootstrap/carlos`, `accounts/ab12.json`, `accounts/other.json`, `../etc`, `apps/ab12/../OTHER/x`); handler tests over httptest with a DirStore: CAS round-trip (Put no-precondition → 400; If-Match stale → 412; fresh → 200 new etag), Get missing → 404 byte-identical to out-of-scope 404, list scoping strips other accounts, static-name auth → 404s identical too. Then implement; `-race` suite; commit `edge: the store door — release.Store over HTTPS, account-scoped (spec F1-F4)`.
+
+### Task 3: `internal/fleetapi` — the client store
+
+**Files:** Create `internal/fleetapi/fleetapi.go`, `internal/fleetapi/fleetapi_test.go`.
+
+**Interfaces — Consumes:** Task 2's wire (test against the real storeDoor over httptest, not a hand-rolled fake). **Produces:**
+
+```go
+// New builds a release.Store speaking the edge store door.
+// base = CARLOS_TUNNEL_URL's origin, name/token = the box's channel identity.
+func New(base, name, token string) (release.Store, error)
+```
+
+Semantics: Get returns (body, etag); Put sends If-Match (or If-None-Match:* when ifMatch==""... CHECK: read release.Store's Put contract for the empty-ifMatch meaning in DirStore/S3Store first and mirror it EXACTLY); 412 → `release.ErrCASConflict`; 404 on Get → the same not-found error S3Store returns (`os.ErrNotExist`-wrapped — check and mirror); Delete idempotent; List/ListDirs decode. Timeouts: 30s per call; artifact GETs must stream (return the body reader... CHECK Store.Get returns []byte — it does; keep []byte, buffered — fine for binaries the box already downloads whole today). No token in error strings.
+
+Steps: failing round-trip tests through a real storeDoor+DirStore; implement; commit `fleetapi: release.Store over the edge store door`.
+
+### Task 4: box-side selection + retention skip
+
+**Files:** Modify `internal/host/agent.go` (`openDeployStore` ~L1926; status-bucket resolution `resolveStatusBucket` consumers in statusLoop ~L1582 and metrics_loop.go ~L748; retentionLoop arming ~L276/1456); Test `internal/host/agent_test.go` (+ existing fixtures).
+
+**Interfaces — Consumes:** `fleetapi.New`, `resolveFleetIdentity` (fleetidentity.go). **Produces:** selection rule (spec F5): when `resolveFleetIdentity` succeeds AND the relevant bucket env (`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` for openDeployStore; `CARLOS_STATUS_BUCKET` else deployment for status/metrics) is UNSET → `fleetapi.New(os.Getenv("CARLOS_TUNNEL_URL"), identity string, token)`; bucket set → S3/dir exactly as today. retentionLoop: fleet identity present → log once `agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)` and return.
+
+Tests: table over env permutations pinning which store type each path builds (export a tiny seam if needed — prefer a package-level `deployStoreForTest` accessor pattern ONLY if an existing test seam convention exists; otherwise test via behavior: fake edge httptest server + env pointing at it, assert a serial read hits the door). Regression pin: bucket-set env yields S3-backed behavior with zero requests to the door. Retention skip test. Commit `host: fleet boxes speak the store door; retention stays a platform job (spec F5/F6)`.
+
+### Task 5: e2e — the fleet loop through the door
+
+**Files:** Modify/extend `internal/edge/fleet_e2e_test.go` (or a new `fleetapi_e2e_test.go` beside it — read the existing harness first and extend, don't duplicate).
+
+Re-run the existing two-fake-box fleet e2e with the boxes' deployment store swapped for `fleetapi.New` pointed at the test edge: adoption converges, serial poll works, heartbeat lands under `fleets/<name>/status/...` via the door, binding takeover works, and the ACL blocks box A's fleet from reading a second account's keys planted in the store. Commit `e2e: fleet boxes converge with no bucket credentials (spec proof)`.
+
+### Task 6: docs + module + BYO contract
+
+**Files:** Modify `internal/console/templates/fleet_byo.html` (env contract: the no-bucket form becomes primary — CARLOS_TUNNEL_URL/NAME/TOKEN + hibernate bucket creds only; deployment-bucket form demoted to a "legacy direct-S3" note), its test assertion in `internal/console/accountfleet_test.go` if strings pinned; `infra/dev/region-pi.md` (flip note); `infra/modules/region-remote-host/{variables.tf,main.tf,README.md}` (`deployment_bucket_arn` optional — default `""`, IAM statement emitted only when non-empty; README reframes the customer artifact); spec status line.
+
+Gate + commit `docs+module: fleet boxes without platform-bucket credentials`.
+
+---
+
+## Self-review notes
+
+Spec coverage: F1→T2 routing; F2→T1; F3→T2 ACL; F4→T2+T3 CAS both ends; F5→T4; F6→T4; F7→T2 allowlist row; F8 is a non-feature (streaming = []byte Get, noted T3). Security §: every invariant appears as a named test in T1/T2. Inventory classes all appear in the T2 matrix. Types: `VerifyDynamic` (T1) consumed T2; `storeDoor` internal; `fleetapi.New` (T3) consumed T4/T5. Open checks flagged inline (Put empty-ifMatch contract, not-found error shape) — implementers resolve against DirStore/S3Store as instructed, never invent.
diff --git a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
new file mode 100644
index 000000000..a56c06867
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
@@ -0,0 +1,115 @@
+# Fleet API Surface: the deployment bucket behind the edge (issue #73)
+
+**Date:** 2026-08-11
+**Status:** All six implementation tasks built on branch `worktree-fleet-api`
+(edge store door, `internal/fleetapi` client, host env-driven store
+selection + F6 retention skip, this doc + BYO page + region-pi.md +
+region-remote-host module updates) — pending merge to `main` and the edge
+roll; designed autonomously in the unattended fleets session, Paul
+pre-approved the direction in issue #73 (2026-08-07: "don't paper over it
+with a bucket policy — abstract the bucket behind the carlos API").
+**Depends on:** customer fleets v1+v2 (merged, main 00c3344); the tunnel
+dynamic-auth table (edge/tunnel.go).
+**Closes:** #73. **Unblocks:** untrusted-customer fleets, the Amadan
+cross-account fleet, the Eleven→Carloku flip without platform-bucket creds.
+
+## The problem (from the #73 inventory, verified at 00c3344)
+
+A fleet box holds AWS credentials with RW on the PLATFORM's deployment
+bucket. Every consumer flows through `release.Store` handles built in
+exactly three places on the box: `openDeployStore` (agent.go, 10 call
+sites), and `release.NewS3Store` in statusLoop + metricsLoop (status-bucket
+resolution). What those handles actually touch, complete inventory:
+
+- **Reads**: `control/serial` (every 2s); `apps/<sqid>/<app>/…` channel
+ pointers, production flags, release manifests + artifacts, restart-gen,
+ instance records, config bundles + environments + config-gen, trash
+ markers; `accounts/` list + records; `bootstrap/carlos` has NO in-repo
+ fetch path today (pinfra pushes over SSM — a customer box has no update
+ path at all).
+- **Writes (CAS)**: heartbeats/metrics/alerts under the
+ `fleets/<name>/…` prefix rebase; fleet bindings at
+ `fleets/<name>/bindings/<host>.json` (unprefixed handle, same keyspace).
+- **Deletes**: metrics 48h prune under own prefix; `retentionLoop` would
+ delete RELEASE artifacts platform-wide — armed on any box with the
+ pubkey, which is exactly the tamper surface to close.
+- **Not on fleet boxes** (verified gates): status-lease election
+ (`shouldSkipPublisherElection`), `ReconcileFleetBindings`, domainsLoop
+ (no `PLATFORM_APPS_DOMAIN`). Instance leases + litestream live entirely
+ in the customer's own hibernate bucket — untouched by this spec.
+
+## Design: `release.Store` over HTTPS, ACL'd server-side
+
+One new mechanism, not N endpoints: an edge-hosted **store door** that
+speaks the five `release.Store` operations over plain HTTPS, authenticated
+per-request by the credential the box already holds (its fleet token), with
+a server-side ACL derived from the fleet's identity. The box swaps its S3
+store handle for an HTTP one; every loop — adoption, fast-converge,
+reconcile, config, status, metrics, bindings — runs unchanged.
+
+### Decisions
+
+| # | Decision | Why |
+|---|---|---|
+| F1 | **Transport is the edge's reserved tunnel hostname**, new paths beside `/register`: `GET/PUT/DELETE /store/o/<key>`, `GET /store/list?prefix=`, `GET /store/dirs?prefix=`. Same headers as the tunnel dial: `Authorization: Bearer <token>` + `Carlos-Tunnel-Name: <fleet>/<label>`. | The reserved host is already TLS'd, already routed, already carries the only box-shaped credential the platform can verify. No new hostname, cert, or credential mint. |
+| F2 | **Auth = the dynamic-auth table**, via a new `TunnelHub.VerifyDynamic(name, token) (fleet string, ok bool)` refactored from ServeHTTP's dynamic branch (constant-time hash compare, single-401 shape preserved; static `CARLOS_TUNNEL_CLIENTS` names never authenticate here — this door is for fleet boxes only). | One credential, one hash table, one revocation path: detach/rotate already converge to the edge in seconds and now revoke API access with the same motion. |
+| F3 | **ACL is account-scoped + own-prefix**, derived server-side from the authenticated fleet's registry row (`fleets.account` → sqid): READ allows `control/serial`, `bootstrap/carlos`, `accounts/<own-sqid>/**` (the account manifest is `accounts/<sqid>/account.json` — `domains.AccountManifestKey`, one path level deep — NOT `accounts/<sqid>.json`; a box that cannot read it sees zero accounts and `provision.Reconcile` retires every provisioned route on it), and `apps/<own-sqid>/**`; list of `accounts/` and `apps/` answers only the own-account subset, INCLUDING the delimiter-aware listing — `ListDirs("accounts/")` returns one child prefix per account on the platform, so it is filtered per tenant, not against the prefix it was listed under. WRITE/DELETE allow `fleets/<own-fleet>/**` and `apps/<own-sqid>/<one app segment>/logs/**` (the app-log chunks `logshipLoop` writes and prunes — `applog.Prefix`), and nothing else. Everything else: byte-identical 404 (never 403 — the keyspace must not be an oracle).<br><br>The logs grant is **segment-exact**, and the exactness is the security property: `apps/<sqid>/<app>/logs/**`, never `apps/<sqid>/**` and never a "the key contains `logs/`" test. A blanket own-account write would let a box pin its own `channels/*.json` (self-promoting any build it likes) or mint `instances/*.json` records (self-authorizing routes) — the exact writes this door exists to prevent.<br><br>**Caveat (accepted):** the `<box>` segment inside an app's log subtree is an unverifiable pseudonym (`status.Label` of a machine id the box chose), so one box of an account can delete a same-account sibling box's chunks. Blast radius is inside a single account's own logs — no other tenant, no release data — and pruning its own subtree is what logship must do hourly. Optional hardening if it ever matters: bind the box segment to the authenticated channel label, which needs a per-box identity the door does not carry today. Wider than the sibling-box case, and accepted on the same terms: the logs grant is **account-wide**, so a compromised fleet box can forge or delete log chunks for *any* of its account's apps — including apps that run on the account's PLATFORM boxes and never touch the fleet at all — and there is no server-side quota on what it writes beyond the 32 MiB per-PUT cap; a door-side chunk shape/size guard is a filed follow-up, not part of v1.<br><br>**Deliberately NOT granted:** provisioning record writes (`apps/<sqid>/<app>/instances/*.json`). A box's `provision.Reconcile` makes zero store writes — every `Put`/`Delete` on those keys is console- or CLI-side — so there is nothing to grant, and granting it would hand a box self-authorization over its own routes. | This is the issue's "account-scoped surface" literally: a fleet reads only its owning account's estate, and writes only its own corner plus the log chunks it produces about its own tenant's apps. A compromised customer box can no longer read other tenants or touch platform releases. |
+| F4 | **CAS passes through**: Get returns the backing etag in `ETag`; Put requires `If-Match` (or `If-None-Match: *` for create), maps conflict to 412; the client maps 412 back to `release.ErrCASConflict`. Deletes are idempotent 204. | The lease-adjacent writers (heartbeat CAS, binding takeover guard) keep their exact semantics through the proxy. |
+| F5 | **Client = `internal/fleetapi.Store`**, implementing `release.Store` over the F1 wire with the box's existing `CARLOS_TUNNEL_URL`/`_NAME`/`_TOKEN` env. Selection: `openDeployStore` (and the status/metrics bucket resolution) returns the API store **when fleet identity resolves AND `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_STATUS_BUCKET` is unset**; a bucket still set wins (the issue's migration fallback — platform boxes and grandfathered fleet boxes keep direct S3, byte-for-byte unchanged). Log shipping arms off the same selection (so `carlos logs` keeps working on a bucketless box, with keys left UNPREFIXED — the console reads the platform layout). On the door branch only, an empty `CARLOS_STATUS_PREFIX` defaults to `fleets/<fleet>/`: status/metrics at the shared root are outside the door's write ACL and would 404 forever. | Env-driven cutover, box by box, no flag day: drop the bucket var + AWS creds from a box's env and it is on the API; put them back and it is off. |
+| F6 | **retentionLoop does not arm on a fleet box** (fleet identity present → skip with one log line). | A customer box scheduling deletes of platform-bucket releases was wrong under direct S3 too; the ACL would refuse it anyway — skipping avoids an hourly error loop. Platform boxes keep retention. |
+| F7 | **`bootstrap/carlos` is readable through the door** (F3 allowlist). | Gives customer boxes their first sanctioned self-update path (#73's §9 gap): customer tooling can fetch the platform binary with the fleet token; sha256 discipline stays the operator's, as with pinfra's script. |
+| F8 | **Artifact reads stream** (no presigned-URL redirect in v1). | Presigning re-exposes the bucket identity this spec exists to hide; edge egress for one fleet's artifacts is small, and the edge already streams tunnel traffic. Revisit only if a real fleet's adoption traffic hurts. |
+
+### What this deletes (when a box flips)
+
+`CARLOS_DEPLOYMENT_BUCKET` and the platform-bucket half of
+`/etc/carlos/credentials` on fleet boxes; the cross-account bucket-policy
+requirement (Amadan); the "trusted customers only" caveat on fleets. The
+`region-remote-host` module's `deployment_bucket_arn` input becomes
+optional (omitted = fleet-API consumer; the grant is not emitted).
+
+## Security invariants (test-pinned)
+
+- The door answers ONLY on the reserved hostname; every other host 404s it.
+- Auth failures, unknown keys, and out-of-scope keys are byte-identical
+ 404s after a single constant-time verify; no response distinguishes
+ "wrong token" / "no such key" / "someone else's key".
+- Static tunnel names (no `/`) never authenticate; a detached box's token
+ fails from the moment `SetDynamicAuth` swaps the table (same bound as
+ tunnel revocation).
+- Key canonicalization refuses `..`, empty segments, leading `/`, and
+ keys >1024 bytes BEFORE the ACL runs; the ACL is prefix-string exact.
+- The proxy never logs tokens, and never forwards client-supplied AWS
+ headers; it holds the deployment store handle the edge already has.
+
+## Testing
+
+Unit: ACL matrix table (every §inventory key class × read/write/delete ×
+own/other account → allow/404); VerifyDynamic refutes static names, wrong
+tokens, detached boxes; canonicalization rejects. Integration: fleetapi
+client against a real edge handler over `httptest` backed by a DirStore —
+Get/Put CAS round-trip incl. 412→ErrCASConflict, List/ListDirs prefix
+strip, artifact streaming; then the FULL existing fleet e2e (two fake
+boxes) re-run with the boxes' deploy store swapped to the API client —
+adoption, serial poll, reconcile, heartbeat, binding takeover all through
+the door. Fallback: bucket-set env still builds S3 stores (platform boxes
+regression-pinned).
+
+## Rollout
+
+1. This branch: edge door + client + env switch + F6 + docs (BYO page env
+ contract gains the no-bucket form; region-pi.md notes the flip).
+2. Platform binary roll (edge gains the door; no behavior change for any
+ existing box).
+3. Pi fleet flips first (drop bucket env, keep hibernate creds), then
+ Amadan joins API-native (no cross-account policy ever), then Eleven per
+ its runbook.
+4. `region-remote-host` module: `deployment_bucket_arn` optional, README
+ reframed (customer artifact = hibernate-bucket-only IAM).
+
+## Out of scope
+
+Presigned redirects (F8); per-app (finer than account) ACL; device tokens
+for non-fleet boxes; moving platform boxes off S3; hibernate-bucket
+abstraction (the customer owns that bucket by design); revoking the pi
+fleet's existing IAM user (operator step after its flip).
diff --git a/infra/dev/region-pi.md b/infra/dev/region-pi.md
index d0f48c86f..7a03a9f54 100644
--- a/infra/dev/region-pi.md
+++ b/infra/dev/region-pi.md
@@ -41,6 +41,18 @@ Follow infra/remote-host.md with these dev-platform values:
shows UNREACHABLE in the fleet view and `carlos status -live`; instances
resume when it re-dials. This is the approved demo posture.
+**Flipping to the fleet API (issue #73, branch `worktree-fleet-api`, not yet
+merged):** once that branch is on `main` and rolled to the dev edge, drop
+`CARLOS_DEPLOYMENT_BUCKET` from the standard agent env on this box, and drop
+the platform-bucket half of `region-remote-host`'s IAM grant
+(`deployment_bucket_arn` unset on the `pi-fleet` tofu root — the module
+defaults it to `""` and simply omits that statement). Keep
+`AWS_SHARED_CREDENTIALS_FILE`/`CARLOS_HIBERNATE_BUCKET` exactly as above —
+the Pi still owns its data bucket directly. `CARLOS_STATUS_PREFIX` becomes
+optional at that point too (an unset value defaults to `fleets/<name>/` on
+the door branch); keep the explicit line above until this doc is updated
+post-merge, or drop it once verified.
+
Seeding: scripts/seed-pi-demo.sh (batches of ≤10/day — console limiter;
re-running an already-seeded batch is safe but not free, since the limiter
counts attempts, not successes, so only re-run a batch that actually
diff --git a/infra/eleven-fleet.md b/infra/eleven-fleet.md
new file mode 100644
index 000000000..e6dc9a414
--- /dev/null
+++ b/infra/eleven-fleet.md
@@ -0,0 +1,160 @@
+# Eleven → Carloku fleet migration runbook
+
+Eleven (elevenmessenger.com, Tito's flagship CARLOS app) moves from Tito's
+own self-hosted CARLOS install (`console.platform.tito.io`, on
+`carlos-tito-ie`) onto Carloku's console (`console.carloku.com`) as a
+**customer fleet**: Eleven's account on Carloku owns fleet `eleven`, the two
+production boxes (already in Tito's own AWS account, `titogo`) attach as
+fleet boxes, and every Eleven app is placed `fleet/eleven` — opted out of
+Carloku's shared fleet by construction. Eleven's data never leaves Eleven's
+own bucket. The boxes' network posture does not change: outbound-only dial.
+
+Executed by Paul, one box at a time, with verification and an abort path at
+every step. Scope is the **fleet attach + placement** flip only — Eleven's
+account and app records on Carloku are assumed to already exist (see
+Preconditions). Not a data migration: there is no data to migrate.
+
+## Preconditions (must be true before Step 1)
+
+- [ ] **#73 (fleet API surface) merged and deployed to Carloku's flagship
+ edge.** *Built on branch `worktree-fleet-api`, pending merge + edge
+ roll* — the design
+ (`docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md`), the
+ store door (`/store/o/<key>` etc.), the `internal/fleetapi` client, and
+ the env-driven store selection in `openDeployStore` are all built and
+ tested on that branch, but nothing has merged to `main` or shipped to
+ Carloku's edge yet. Without a deployed edge a fleet box still needs RW
+ on Carloku's platform deployment bucket to run — unacceptable for
+ boxes in Tito's own AWS account.
+- [ ] **`region-remote-host`'s `deployment_bucket_arn` is optional.** Also
+ built on branch `worktree-fleet-api` (`infra/modules/region-remote-host/
+ variables.tf` now defaults it to `""`, IAM statement emitted only when
+ set) but not yet merged. Eleven's tofu root must be able to omit the
+ grant, which requires that branch's module on `main`.
+- [ ] **Eleven's account exists on Carloku**, passkey-reachable, apps already
+ created under it (`carlos accounts create -name eleven -owner
+ <address>`, then the normal `apps create` flow) — currently unplaced,
+ since the fleet doesn't exist yet.
+- [ ] **Eleven-owned tofu root**, `region-remote-host`/`paulca/pi-fleet`
+ pattern, applied in `titogo` against Eleven's existing data bucket —
+ `deployment_bucket_arn` **omitted** (#73: no platform-bucket creds
+ needed at all, only the box's own bucket).
+- [ ] `carlos` agent binary on both boxes built from a post-#73 `main`
+ (stale-checkout mismatch is a recorded trap — build fresh).
+
+## Ceremony
+
+Signed in to Carloku (`carlos auth login -console https://console.carloku.com`)
+as a member of Eleven's account, or `-account eleven` as ops.
+
+**1. Create the fleet**
+
+ carlos fleets create -bucket <eleven's data bucket> -account eleven eleven
+
+Mints the record only; the bucket is taken on trust that it exists (it
+does). The fleets namespace is flat and global — a 409 "that name is taken"
+doesn't necessarily mean *this* attempt took it.
+
+*Verify:* `carlos fleets ls -account eleven` shows fleet `eleven`, correct
+bucket, no boxes.
+*Abort:* no `fleets delete` verb exists (`create|add-box|rotate-token|
+tag-box|detach-box|ls` is the full set). A fleet with the wrong bucket has
+no clean undo short of an operator-side registry fix — get the bucket name
+right first.
+
+**2. Add box 1**
+
+ carlos fleets add-box -account eleven eleven <box-1-label>
+
+Token prints **exactly once** — capture it now (`rotate-token` mints a new
+one; the plaintext is never re-shown).
+
+*Verify:* `carlos fleets ls -account eleven` shows the box, status `active`.
+*Abort:* if added in error and never dialed, `carlos fleets detach-box
+-account eleven eleven <box-1-label>` disables the row (no delete verb, but
+detach suffices).
+
+**3. Flip box 1's env**
+
+Stage a new `/etc/carlos/host.env` (keep the old file as
+`host.env.pre-carloku` — the entire rollback for this step). Post-#73
+contract — no `CARLOS_DEPLOYMENT_BUCKET`, no platform-bucket half of
+`/etc/carlos/credentials`, hibernate-bucket creds only:
+
+ AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
+ CARLOS_HIBERNATE_BUCKET=<eleven's data bucket>
+ CARLOS_FLEET=eleven
+ CARLOS_FLEET_BOX=<box-1-label>
+ CARLOS_REGION=fleet/eleven
+ CARLOS_STATUS_PREFIX=fleets/eleven/
+ CARLOS_RELEASE_PUBKEY=<Carloku's release public key>
+ CARLOS_TUNNEL_URL=https://<Carloku's reserved tunnel hostname>
+ CARLOS_TUNNEL_NAME=eleven/<box-1-label>
+ CARLOS_TUNNEL_TOKEN=<from Step 2 — shown once>
+
+`CARLOS_RELEASE_PUBKEY` must be **Carloku's** release public key, not
+tito's — carrying the old `host.env`'s value forward silently ships the
+wrong key, and adoption then fails signature verification (the box will
+not adopt any release, with no other symptom). `CARLOS_FLEET_BOX` defaults
+from `CARLOS_TUNNEL_NAME`'s label half, but write it explicitly.
+`/etc/carlos/credentials` now holds ONLY the
+hibernate-bucket key from the Eleven-owned tofu root — any platform-bucket
+key pair the file carried for tito's own console is removed here, not just
+unused. Restart the agent unit to pick up the new env.
+
+*Verify:*
+- `journalctl -u carlos-edge` shows "tunnel connected".
+- Carloku fleet page (`/a/<eleven-sqid>/fleets/eleven`) shows box 1 with a
+ fresh heartbeat, tunnel connected.
+- `carlos fleets ls -account eleven` still shows the box `active` (env flip
+ changes which console it dials, not registry status).
+
+**Do not proceed to box 2 until box 1 verifies clean** — the whole point of
+flipping one box at a time is that box 2 keeps serving Eleven's traffic
+under its prior config while box 1 is proven.
+
+*Abort:* restore `host.env.pre-carloku`, restart the unit — the box re-dials
+whatever it dialed before, nothing on Carloku needs touching. For a
+suspected-compromised token rather than a bad env, `carlos fleets
+detach-box -account eleven eleven <box-1-label>` is the hard cut — closes
+the channel immediately; `rotate-token` only refuses the *next* dial-in.
+
+**4. Repeat Steps 2-3 for box 2**, only once box 1 is verified clean.
+
+## App placement
+
+For each Eleven app (already created under the account, per Preconditions):
+
+ carlos apps place -app <name> -target fleet/eleven -account eleven
+
+`-target` is required and tri-state (`""` clears placement — never omit it;
+an omitted `-target` is a usage error, not a no-op). Placement re-targets
+*serving* only, converging within a pass — it does not copy state (v1's
+"nothing migrates," restated as D6 in the fleets-console spec). That caveat
+is moot here: Eleven's instance state was never anywhere but Eleven's own
+bucket, so there's nothing to leave behind. Only the control plane moves.
+
+*Verify per app:* `curl -sI https://<app-host>` (`--resolve` at Carloku's
+edge IP ahead of any DNS cutover — never trust a cached answer) shows
+`X-Carlos-Version` matching what Eleven expects.
+
+*Abort:* `-target ""` un-places, but on Carloku that lands the app on the
+**shared** fleet, which has no access to Eleven's bucket — not a rollback to
+a working state. If a box misbehaves, fix or detach it and leave placement
+at `fleet/eleven`: the dark-fleet 503 (age shown, nothing silently wrong) is
+the intended failure mode here, not un-placing.
+
+## Post-flip
+
+- [ ] **Revoke the old platform-bucket IAM grant.** Whatever principal gave
+ Eleven's boxes RW on tito's own platform deployment bucket (from
+ tito's own `platform-infrastructure` tofu) is no longer needed once
+ both boxes verify clean on Carloku — revoke it; don't delete the
+ principal blind without confirming nothing else references it.
+- [ ] **DNS for elevenmessenger.com — a Paul decision.** Point the host
+ directly at Carloku's edge (clean cutover, `X-Carlos-Version` is
+ immediately authoritative, but TTL/propagation lag is live downtime
+ risk for a flagship app) versus leaving DNS on tito's own edge proxying
+ or CNAMEing through to Carloku (slower, an extra hop, but a fallback
+ that doesn't depend on DNS propagation). Both are compatible with
+ everything above; neither is assumed here.
diff --git a/infra/modules/README.md b/infra/modules/README.md
index 5687b9b81..857b58d23 100644
--- a/infra/modules/README.md
+++ b/infra/modules/README.md
@@ -82,11 +82,15 @@ wants one.
What an off-cloud CARLOS host (customer fleets and tito's hq)
needs from AWS when there is no EC2 instance profile to lean on: a region
-data bucket plus a scoped IAM user + access key, limited to exactly the
-deployment bucket and this region's data bucket — the same
-wrong-region-fails-at-IAM residency property `region-host` gives cloud
-hosts. Consumed by customer fleets (paulca/pi-fleet is the live example) and
-the platform's tito hq; the module is reusable as-is for both.
+data bucket plus a scoped IAM user + access key, limited to this region's
+data bucket always, plus the deployment bucket only when
+`deployment_bucket_arn` is set — the same wrong-region-fails-at-IAM
+residency property `region-host` gives cloud hosts. Fleet-API consumers
+(issue #73) omit `deployment_bucket_arn` entirely: the box reaches the
+platform's deployment bucket over the console's edge with its tunnel
+token, not a direct IAM grant. Consumed by customer fleets (paulca/pi-fleet
+is the live example) and the platform's tito hq; the module is reusable
+as-is for both.
| Variable | Description |
|---|---|
diff --git a/infra/modules/region-remote-host/README.md b/infra/modules/region-remote-host/README.md
index e68f31059..aceb8ff16 100644
--- a/infra/modules/region-remote-host/README.md
+++ b/infra/modules/region-remote-host/README.md
@@ -2,14 +2,31 @@
Provisions what an off-cloud CARLOS host needs when there is no EC2 instance
profile to lean on: a region data bucket plus an IAM user carrying a scoped
-inline policy and access key, limited to exactly the deployment bucket and
-this region's data bucket (wrong-region-fails-at-IAM, the same residency
-property `region-host` gives cloud hosts).
+inline policy and access key, limited to this region's data bucket and
+nothing else (wrong-region-fails-at-IAM, the same residency property
+`region-host` gives cloud hosts).
Consumed by customer fleets (paulca/pi-fleet is the live example) and
the platform's tito hq box. The module is reusable as-is for both — they
differ only in account and variable values, not shape.
+**`deployment_bucket_arn` is optional (default `""`).** The fleet API
+surface (issue #73) means a fleet box no longer needs any IAM grant on the
+platform's deployment bucket at all: it reads releases and status, and
+writes heartbeats, through the console's edge (`/store/o/...`) using the
+same tunnel token it already dials with — the door authenticates and
+scopes the request, not a bucket-level AWS credential. That is the shape
+every new fleet-API consumer's tofu root should apply with: leave
+`deployment_bucket_arn` unset, and the module emits no deployment-bucket
+IAM statement at all.
+
+The variable still exists for **grandfathered direct-S3 boxes** — ones
+still running the pre-#73 agent, or deliberately kept off the fleet API —
+which need RW straight on the deployment bucket exactly as before; set
+`deployment_bucket_arn` explicitly for those and the module emits the
+extra scoped statement (`aws_iam_user_policy.host_deployment_bucket`) on
+top of the always-present own-data-bucket grant.
+
The S3 action list in the inline policy is not independently chosen: it is
copied verbatim from `infra/modules/region-host/main.tf`'s
`aws_iam_role_policy.host_buckets` (the authority for what this principal
diff --git a/infra/modules/region-remote-host/main.tf b/infra/modules/region-remote-host/main.tf
index 90b74d912..ce7d0ecf5 100644
--- a/infra/modules/region-remote-host/main.tf
+++ b/infra/modules/region-remote-host/main.tf
@@ -3,9 +3,11 @@
# IAM principal, since there is no EC2 instance profile to lean on off-cloud.
#
# Residency is enforced the same way as region-host: the principal reaches
-# the deployment bucket plus ITS OWN region's data bucket and nothing else.
-# A wrong-region host fails loudly at the IAM layer, same property as every
-# cloud host.
+# ITS OWN region's data bucket and nothing else. A wrong-region host fails
+# loudly at the IAM layer, same property as every cloud host. The deployment
+# bucket is grandfathered-optional (var.deployment_bucket_arn, default "")
+# — a fleet-API box needs no platform-bucket grant at all; see the
+# aws_iam_user_policy.host_deployment_bucket comment below.
#
# The data bucket follows the deployment-buckets convention for this class
# of bucket (infra/modules/README.md, infra/modules/deployment-buckets/
@@ -58,8 +60,8 @@ resource "aws_iam_access_key" "host" {
user = aws_iam_user.host.name
}
-# Scope: the deployment bucket + THIS region's data bucket and nothing
-# else — wrong-region-fails-at-IAM, same property as every cloud host.
+# Scope: THIS region's data bucket, always — wrong-region-fails-at-IAM, same
+# property as every cloud host.
#
# The action list is not independently chosen: region-host's
# aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is
@@ -74,28 +76,46 @@ resource "aws_iam_user_policy" "host" {
Version = "2012-10-17"
Statement = [
{
- Sid = "DeploymentBucketObjects"
+ Sid = "OwnRegionDataObjects"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
- Resource = ["${var.deployment_bucket_arn}/*"]
+ Resource = ["${aws_s3_bucket.data.arn}/*"]
},
{
- Sid = "DeploymentBucketList"
+ Sid = "OwnRegionDataList"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
- Resource = [var.deployment_bucket_arn]
+ Resource = [aws_s3_bucket.data.arn]
},
+ ]
+ })
+}
+
+# Deployment-bucket grant: GRANDFATHERED, direct-S3 boxes only. A fleet-API
+# box (issue #73) never needs this — it reaches the platform's deployment
+# bucket through the console's edge, over the same tunnel token it dials
+# with, so var.deployment_bucket_arn is left "" and this whole statement
+# (and the IAM policy resource carrying it) is omitted. count, not a dynamic
+# block, matches the same optional-statement pattern used by
+# aws_iam_role_policy.host_route53 in the region-host module.
+resource "aws_iam_user_policy" "host_deployment_bucket" {
+ count = var.deployment_bucket_arn != "" ? 1 : 0
+ name = "carlos-remote-host-${var.region_code}-deployment-bucket"
+ user = aws_iam_user.host.name
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
{
- Sid = "OwnRegionDataObjects"
+ Sid = "DeploymentBucketObjects"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
- Resource = ["${aws_s3_bucket.data.arn}/*"]
+ Resource = ["${var.deployment_bucket_arn}/*"]
},
{
- Sid = "OwnRegionDataList"
+ Sid = "DeploymentBucketList"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
- Resource = [aws_s3_bucket.data.arn]
+ Resource = [var.deployment_bucket_arn]
},
]
})
diff --git a/infra/modules/region-remote-host/variables.tf b/infra/modules/region-remote-host/variables.tf
index 8b1240278..2096ca518 100644
--- a/infra/modules/region-remote-host/variables.tf
+++ b/infra/modules/region-remote-host/variables.tf
@@ -9,6 +9,7 @@ variable "data_bucket_name" {
}
variable "deployment_bucket_arn" {
- description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to."
+ description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to. Optional (default \"\"): a fleet-API box (fleet API surface, issue #73) reaches the platform's deployment bucket over the console's edge with its tunnel token, not a direct IAM grant — leave this unset and no deployment-bucket statement is emitted. Only a grandfathered direct-S3 box needs it set."
type = string
+ default = ""
}
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go
index 1cd589a81..800664bb0 100644
--- a/internal/console/accountfleet_test.go
+++ b/internal/console/accountfleet_test.go
@@ -359,6 +359,13 @@ func TestFleetBYOPageMemberReadable(t *testing.T) {
t.Fatalf("BYO page missing %q in:\n%s", want, body)
}
}
+ // The primary env contract is bucketless: no platform-bucket var or
+ // credential belongs there (the box reaches releases/heartbeats through
+ // the console's edge with its tunnel token). CARLOS_DEPLOYMENT_BUCKET
+ // still appears, but only inside the demoted legacy-direct-S3 note.
+ if !strings.Contains(body, "Legacy direct-S3") {
+ t.Fatalf("BYO page missing the legacy direct-S3 note in:\n%s", body)
+ }
}
// TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost
diff --git a/internal/console/templates/fleet_byo.html b/internal/console/templates/fleet_byo.html
index 5d88b38c2..ddd0339e1 100644
--- a/internal/console/templates/fleet_byo.html
+++ b/internal/console/templates/fleet_byo.html
@@ -31,13 +31,30 @@ CARLOS_HIBERNATE_BUCKET=&lt;your-bucket&gt;
CARLOS_FLEET=&lt;name&gt;
CARLOS_FLEET_BOX=&lt;label&gt;
CARLOS_REGION=fleet/&lt;name&gt;
-CARLOS_STATUS_PREFIX=fleets/&lt;name&gt;/
+CARLOS_RELEASE_PUBKEY=&lt;this deployment's release public key&gt;
CARLOS_TUNNEL_URL=&lt;this deployment's tunnel address&gt;
CARLOS_TUNNEL_TOKEN=&lt;from carlos fleets add-box &lt;name&gt; &lt;label&gt; — shown once&gt;
CARLOS_TUNNEL_NAME=&lt;name&gt;/&lt;label&gt;</code></pre>
+<p class="small">Release pointers are Ed25519-signed; without <code>CARLOS_RELEASE_PUBKEY</code> the
+box has no key to verify against and refuses to adopt anything.</p>
<p class="small"><code>CARLOS_FLEET_BOX</code> is optional and defaults from
<code>CARLOS_TUNNEL_NAME</code>'s label half. <code>AWS_SHARED_CREDENTIALS_FILE</code> points at
-an access key for the bucket above, written root-owned mode 600 alongside the env file.</p>
+an access key for the bucket above — <strong>your own bucket only</strong> — written root-owned
+mode 600 alongside the env file.</p>
+<p class="small">No <code>CARLOS_DEPLOYMENT_BUCKET</code>, and no AWS credentials for the
+platform's own bucket, belong in this file at all: the box reads its app releases and writes its
+heartbeats through the console's edge, over HTTPS, using the same tunnel token it already dials
+in with — not a bucket-level AWS grant.</p>
+<p class="small">Optional override: <code>CARLOS_STATUS_PREFIX=fleets/&lt;name&gt;/</code> — the box
+defaults to this on its own; set it explicitly only if you're pointing status/heartbeats
+somewhere non-standard.</p>
+
+<h3>Legacy direct-S3 (grandfathered)</h3>
+<p class="small">A box still running a pre-fleet-API agent build needs
+<code>CARLOS_DEPLOYMENT_BUCKET</code> set to the platform's own deployment bucket, plus an AWS
+credential granting it RW there, in addition to the hibernate-bucket vars above. New boxes should
+not be configured this way — it is kept working for boxes that haven't upgraded yet, not as a
+choice.</p>
<h2>Hardening</h2>
<ul>
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index dd84325ee..219e8697e 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -108,6 +108,22 @@ type Edge struct {
// to its socket/addr, or goes unserved if it has neither — the same
// nil-means-disabled convention as act/cache/staticStore.
tunnels *TunnelHub
+ // door, when non-nil, is the fleet store door (storedoor.go): the five
+ // release.Store operations served over HTTPS under /store/ on the SAME
+ // reserved hostname the hub answers on, so a fleet box needs no AWS
+ // credentials on the deployment bucket. It exists only when BOTH halves
+ // it needs are configured — the hub (its only credential source) and the
+ // deployment store (EnableStatic's handle, reused, never a second
+ // client) — so armStoreDoor is called from both EnableXxx, whichever
+ // lands second. nil = /store/ falls through to the hub's own 404, the
+ // same nil-means-disabled convention as act/cache/staticStore.
+ door *storeDoor
+ // fleetAccounts is the reload-time fleet→account-sqid snapshot the door's
+ // ACL is derived from, refreshed on the SAME pass that feeds the hub's
+ // dynamic auth table (Reload, below) so a box's credentials and its
+ // tenancy can never come from two different reads of the registry.
+ // Atomic: written by Reload while requests are being served.
+ fleetAccounts atomic.Pointer[map[string]string]
// cache, when non-nil, answers cacheable GET/HEAD requests without ever
// invoking the hibernation activator or the backend — the mitigation
// this whole feature exists for. nil disables caching entirely.
@@ -163,6 +179,7 @@ func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir
e.staticPub = pub
e.staticCacheDir = cacheDir
e.staticRecheck = staticRecheck
+ e.armStoreDoor()
}
// EnableCache wires a response cache into the edge. Called once at startup
@@ -178,6 +195,39 @@ func (e *Edge) EnableCache(cache *Cache) {
// Reload afterwards, tunnel routes stay skipped from the last table build.
func (e *Edge) EnableTunnels(hub *TunnelHub) {
e.tunnels = hub
+ e.armStoreDoor()
+}
+
+// armStoreDoor builds (or clears) the fleet store door from whatever is
+// configured now. Called from both EnableStatic and EnableTunnels because the
+// door needs one thing from each and the two are wired in either order at
+// startup (enableStaticFromEnv runs first today, but a caller composing an
+// Edge by hand — every test does — must not have to know that). A deployment
+// with no release pubkey configured therefore has no store handle and so no
+// door: /store/ then reaches the hub, which 404s it, byte-identically.
+func (e *Edge) armStoreDoor() {
+ if e.tunnels == nil || e.staticStore == nil {
+ e.door = nil
+ return
+ }
+ e.door = &storeDoor{hub: e.tunnels, store: e.staticStore, fleetAccount: e.fleetAccount}
+}
+
+// fleetAccount resolves an authenticated fleet name to its owning account's
+// sqid from the last reload's snapshot. A fleet the snapshot does not carry
+// (created since the last reload, or deleted) has no tenancy, so the door
+// answers it nothing — the same convergence bound as its channel credentials,
+// which come from the same pass.
+func (e *Edge) fleetAccount(fleet string) (string, bool) {
+ m := e.fleetAccounts.Load()
+ if m == nil {
+ return "", false
+ }
+ sqid, ok := (*m)[fleet]
+ if !ok || sqid == "" {
+ return "", false
+ }
+ return sqid, true
}
// SetStaticRecheck tunes how long a static route trusts a resolved channel
@@ -384,6 +434,22 @@ func (e *Edge) Reload() error {
return err
}
e.tunnels.SetDynamicAuth(auth)
+ // The store door's tenancy map, from the SAME pass: a box's
+ // credentials and the account its reads are scoped to must never come
+ // from two different reads of the registry. ListFleets goes over a
+ // fresh connection (like FleetChannelAuth above), so a fleet the
+ // console created moments ago is visible here rather than stuck
+ // behind a stale WAL snapshot on the long-lived handle. Same
+ // all-or-nothing error contract as everything else in Reload.
+ fleets, err := e.reg.ListFleets()
+ if err != nil {
+ return err
+ }
+ accounts := make(map[string]string, len(fleets))
+ for _, f := range fleets {
+ accounts[f.Name] = f.Account
+ }
+ e.fleetAccounts.Store(&accounts)
}
e.upstreams.Store(&m)
// Reconcile the activator with the new route set: a host un-flagged (or
@@ -463,6 +529,16 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// hub ends its handshake in a hijack, so it must be handed the REAL
// ResponseWriter, never a recordingResponseWriter wrapping it.
if e.tunnels != nil && host == e.tunnels.Host() {
+ // The fleet store door (storedoor.go) shares this hostname, under
+ // /store/ — the same host gate as registration, which is the whole
+ // reason it is reachable at all: it is not a registry route, so no
+ // other host can ever reach it. /register and everything else stay
+ // the hub's. Ahead of the hub rather than behind it because the hub
+ // 404s any path that is not /register.
+ if e.door != nil && strings.HasPrefix(r.URL.Path, storeDoorPrefix) {
+ e.door.ServeHTTP(w, r)
+ return
+ }
e.tunnels.ServeHTTP(w, r)
return
}
diff --git a/internal/edge/fleetapi_e2e_test.go b/internal/edge/fleetapi_e2e_test.go
new file mode 100644
index 000000000..c32c34101
--- /dev/null
+++ b/internal/edge/fleetapi_e2e_test.go
@@ -0,0 +1,544 @@
+// Task 5 of the fleet-api plan: the spec's central promise proved end to end
+// — a fleet box holding NO bucket credentials converges entirely through the
+// store door (spec 2026-08-11-fleet-api-surface-design.md, F1-F8; issue #73).
+//
+// The box's whole view of the platform's deployment bucket is a
+// fleetapi.New(...) client pointed at a REAL edge: real TunnelHub auth
+// (registry-backed, the same table detach/rotate converge), the real
+// fleet→account snapshot Reload builds, the real ACL, a DirStore behind it.
+// No box-side code path in this file ever touches that DirStore — every
+// platform-side use of it below is the OTHER role (the console/CLI writing
+// the bucket, or the assertion reading back what a box wrote through the
+// door).
+//
+// It sits beside fleet_e2e_test.go rather than inside it because the two
+// prove different things about the same fleet and want different worlds:
+// TestTwoBoxFleetEndToEnd drives the ROUTING failure sequence (channels,
+// leases, takeover, dark fleet) and its edge deliberately has no static
+// store — no door at all. This one drives the STORE loop (adopt, serial,
+// provision, heartbeat, bindings, logs) and needs EnableStatic wired. What
+// they do share is shared: e2eHashToken, startFleetBox, namedFleetHandler,
+// waitChannel, e2eGet and waitBody all come from that file, so the edge/hub
+// arrangement has exactly one source of truth per concern.
+//
+// Hermetic and fast: loopback httptest edge, real h2 reverse channels via
+// the production tunnelclient.Loop, real ed25519 release signing.
+package edge_test
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/fleetapi"
+ "github.com/carlosframework/platform/internal/host"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+const (
+ fapiFleet = "orchard"
+ fapiBoxA = "box-a"
+ fapiBoxB = "box-b"
+ fapiTokenA = "orchard-token-a"
+ fapiTokenB = "orchard-token-b"
+
+ fapiApp = "shop"
+ fapiDomain = "orchard.example"
+ fapiHost = "acme." + fapiDomain
+ fapiRegion = "ie"
+ fapiChannel = "canary/e2e" // canary/* has no bake window: the pass applies at once
+ fapiVersion = "v1"
+
+ // The two account identities. Both are real sqids (domains.Sqid of an id),
+ // never literals: provision.knownAccounts refuses any account manifest
+ // whose path sqid, body sqid and Sqid(body.ID) do not all agree, so a
+ // hardcoded string would quietly stop being an account the moment the
+ // frozen encoding changed — and this test's whole provisioning leg would
+ // then pass for the wrong reason (nothing known, nothing minted).
+ fapiAcctID int64 = 7
+ fapiOtherID int64 = 8
+ fapiOtherApp = "intruder"
+)
+
+// fapiPlatform is the artifact key a box of THIS machine's shape adopts —
+// release's own adoptPlatform, which is unexported.
+var fapiPlatform = runtime.GOOS + "-" + runtime.GOARCH
+
+// fleetDoorWorld is one platform + one fleet: the edge and its bucket on one
+// side, two fleet boxes whose only bucket access is the door on the other.
+type fleetDoorWorld struct {
+ reg *registry.Registry // the PLATFORM's registry: fleet rows, box credentials, fleet-placed routes
+ store *release.DirStore // the platform's deployment bucket — edge-side and console-side handle
+ srvURL string
+ hub *edge.TunnelHub
+ priv ed25519.PrivateKey
+ pub ed25519.PublicKey
+ sqid string // the fleet's owning account
+ other string // a second tenant on the same bucket, which the fleet must never see
+ boxA release.Store
+ boxB release.Store
+}
+
+// newFleetDoorWorld stands the world up and returns it with both boxes'
+// channels connected — the same credential that dials the tunnel is the one
+// their store clients authenticate with, which is F1/F2 stated as a fixture.
+func newFleetDoorWorld(t *testing.T) *fleetDoorWorld {
+ t.Helper()
+ w := &fleetDoorWorld{
+ sqid: domains.Sqid(fapiAcctID),
+ other: domains.Sqid(fapiOtherID),
+ }
+
+ // Not closed at cleanup, for the reason fleet_e2e_test.go gives: Edge.Watch
+ // may still be mid-poll when the test returns.
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ w.reg = reg
+ if err := reg.UpsertFleet(registry.Fleet{Name: fapiFleet, Account: w.sqid, Bucket: "orchard-data"}); err != nil {
+ t.Fatalf("UpsertFleet: %v", err)
+ }
+ for label, tok := range map[string]string{fapiBoxA: fapiTokenA, fapiBoxB: fapiTokenB} {
+ if err := reg.AddFleetBox(registry.FleetBox{
+ Fleet: fapiFleet, Label: label, TokenHash: e2eHashToken(tok), Status: "active",
+ }); err != nil {
+ t.Fatalf("AddFleetBox(%s): %v", label, err)
+ }
+ }
+
+ w.store = release.NewDirStore(t.TempDir())
+ seedB64, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ if w.priv, err = release.ParseSigningKey(seedB64, "test"); err != nil {
+ t.Fatalf("ParseSigningKey: %v", err)
+ }
+ if w.pub, err = release.ParsePublicKey(pubB64, "test"); err != nil {
+ t.Fatalf("ParsePublicKey: %v", err)
+ }
+
+ e, err := edge.New(reg, nil)
+ if err != nil {
+ t.Fatalf("edge.New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ w.srvURL = srv.URL
+ u, err := url.Parse(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // EnableStatic is what hands the door its store handle — the edge's OWN,
+ // never a second client, and the door is only reachable on the hub's
+ // reserved hostname (here the listener's own, as in production
+ // CARLOS_TUNNEL_HOST).
+ w.hub = edge.NewTunnelHub(u.Hostname(), nil)
+ e.EnableStatic(w.store, w.pub, filepath.Join(t.TempDir(), "cache"))
+ e.EnableTunnels(w.hub)
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ stopWatch := make(chan struct{})
+ t.Cleanup(func() { close(stopWatch) })
+ go e.Watch(stopWatch)
+
+ stopA := startFleetBox(t, srv.URL, fapiFleet+"/"+fapiBoxA, fapiTokenA, namedFleetHandler(fapiBoxA))
+ t.Cleanup(stopA)
+ stopB := startFleetBox(t, srv.URL, fapiFleet+"/"+fapiBoxB, fapiTokenB, namedFleetHandler(fapiBoxB))
+ t.Cleanup(stopB)
+ waitChannel(t, w.hub, fapiFleet+"/"+fapiBoxA, "connected", e2eDialBound)
+ waitChannel(t, w.hub, fapiFleet+"/"+fapiBoxB, "connected", e2eDialBound)
+
+ // The boxes' deployment stores. srv.URL is the door's base exactly as
+ // CARLOS_TUNNEL_URL is in production, and the name is the box's own
+ // channel identity — no AWS config, no bucket name, no credentials.
+ w.boxA = fapiStore(t, srv.URL, fapiBoxA, fapiTokenA)
+ w.boxB = fapiStore(t, srv.URL, fapiBoxB, fapiTokenB)
+ return w
+}
+
+func fapiStore(t *testing.T, base, label, token string) release.Store {
+ t.Helper()
+ s, err := fleetapi.New(base, fapiFleet+"/"+label, token)
+ if err != nil {
+ t.Fatalf("fleetapi.New(%s): %v", label, err)
+ }
+ return s
+}
+
+// put is a platform-side (console/CLI-shaped) write straight to the bucket:
+// create-only, the same shape every real writer of these keys uses.
+func (w *fleetDoorWorld) put(t *testing.T, key, body string) {
+ t.Helper()
+ if _, err := w.store.Put(context.Background(), key, []byte(body), ""); err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+}
+
+// accountManifest is domains' account record at the key provision reads —
+// accounts/<sqid>/account.json, one level deep (never accounts/<sqid>.json).
+func fapiAccountManifest(id int64, sqid, name string) string {
+ b, _ := json.Marshal(map[string]any{
+ "id": id, "sqid": sqid, "name": name, "created_at": "2026-08-11T09:00:00Z",
+ })
+ return string(b)
+}
+
+// TestFleetBoxConvergesWithoutBucketCredentials is Task 5's acceptance
+// evidence. The legs run in sequence against ONE world, each depending on
+// what the last left behind (a record becomes a route, the route adopts a
+// release, the box heartbeats about it, its binding lands in routing) — a
+// failure means the loop stopped there and nothing after it is meaningful.
+func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) {
+ ctx := context.Background()
+ w := newFleetDoorWorld(t)
+
+ // A second tenant on the same bucket, planted BEFORE anything else so
+ // every listing below runs against a store that really does hold another
+ // account's keys — the negative leg (f) is only meaningful if the data it
+ // says is invisible is genuinely there.
+ w.put(t, "accounts/"+w.other+"/account.json", fapiAccountManifest(fapiOtherID, w.other, "Intruder"))
+ otherChannelKey := "apps/" + w.other + "/" + fapiOtherApp + "/channels/stable.json"
+ w.put(t, otherChannelKey, `{"app":"intruder","version":"v9"}`)
+ otherLogKey := applog.ChunkKey(w.other, fapiOtherApp, fapiBoxA, applog.HourOf(time.Now()), 1, 9)
+ w.put(t, otherLogKey, "not yours")
+ w.put(t, "fleets/other/status/boxes/box-a.json", `{"label":"box-a"}`)
+
+ // --- (a) the 2s convergence poll: control/serial, read-only --------------
+ // The one shared platform key a box polls every 2 seconds. It must read,
+ // and it must never be writable — a box that could bump the serial could
+ // make every other box in the deployment run a full pass set on demand.
+ w.put(t, release.SerialKey, "41")
+ serial, _, err := w.boxA.Get(ctx, release.SerialKey)
+ if err != nil || string(serial) != "41" {
+ t.Fatalf("(a) serial through the door = %q, %v; want \"41\"", serial, err)
+ }
+ _, etag, err := w.store.Get(ctx, release.SerialKey)
+ if err != nil {
+ t.Fatalf("(a) read serial etag: %v", err)
+ }
+ if _, err := w.store.Put(ctx, release.SerialKey, []byte("42"), etag); err != nil {
+ t.Fatalf("(a) console bump: %v", err)
+ }
+ if serial, _, err = w.boxA.Get(ctx, release.SerialKey); err != nil || string(serial) != "42" {
+ t.Fatalf("(a) serial after the console bump = %q, %v; want \"42\" — the poll would never see a deploy", serial, err)
+ }
+ if _, err := w.boxA.Put(ctx, release.SerialKey, []byte("999"), ""); err == nil {
+ t.Fatal("(a) a fleet box bumped control/serial through the door")
+ }
+ t.Log("(a) control/serial reads through the door and refuses the box's write")
+
+ // --- (b) provisioning: an instance record becomes a local route ----------
+ // The account-scoped LISTING path end to end: knownAccounts lists
+ // accounts/ (a shared prefix, filtered per tenant), the record scan lists
+ // apps/ (scope-mapped to this account's subtree), and the pass mints.
+ w.put(t, "accounts/"+w.sqid+"/account.json", fapiAccountManifest(fapiAcctID, w.sqid, "Orchard"))
+ if err := provision.PutManifest(ctx, w.store, w.sqid, fapiApp, provision.Manifest{
+ Schema: 1, App: fapiApp, Domain: fapiDomain, Regions: []string{fapiRegion}, Seed: []string{"serve"},
+ }); err != nil {
+ t.Fatalf("(b) PutManifest: %v", err)
+ }
+ if err := provision.CreateRecord(ctx, w.store, provision.Record{
+ Schema: 1, Host: fapiHost, Slug: "acme", App: fapiApp, Account: w.sqid,
+ Region: fapiRegion, Channel: fapiChannel, CreatedAt: time.Now(),
+ }); err != nil {
+ t.Fatalf("(b) CreateRecord: %v", err)
+ }
+ // The other tenant's record, in THIS box's region and pool: the only thing
+ // keeping it off this box is the door's account scope.
+ if err := provision.CreateRecord(ctx, w.store, provision.Record{
+ Schema: 1, Host: "evil." + fapiDomain, Slug: "evil", App: fapiOtherApp, Account: w.other,
+ Region: fapiRegion, Channel: "stable", CreatedAt: time.Now(),
+ }); err != nil {
+ t.Fatalf("(b) CreateRecord (other tenant): %v", err)
+ }
+
+ // The box's OWN registry — a fleet box has one, separate from the
+ // platform's, and this pass is what puts routes in it.
+ boxReg, err := registry.Open(filepath.Join(t.TempDir(), "box-registry.db"))
+ if err != nil {
+ t.Fatalf("(b) open box registry: %v", err)
+ }
+ dataDir := t.TempDir()
+ if err := provision.Reconcile(ctx, w.boxA, boxReg, provision.ReconcileConfig{
+ Region: fapiRegion, DataDir: dataDir,
+ }); err != nil {
+ t.Fatalf("(b) provision.Reconcile through the door: %v", err)
+ }
+ minted, err := boxReg.Get(fapiHost)
+ if err != nil || minted == nil {
+ t.Fatalf("(b) route %s = %+v, %v — the record never became a route", fapiHost, minted, err)
+ }
+ if minted.App != fapiApp || minted.Account != w.sqid || minted.Channel != fapiChannel ||
+ !minted.Provisioned || minted.Seed != `["serve"]` {
+ t.Fatalf("(b) minted route = %+v, want the record's app/account/channel, Provisioned and the manifest seed", minted)
+ }
+ if rt, err := boxReg.Get("evil." + fapiDomain); err != nil || rt != nil {
+ t.Fatalf("(b) the other tenant's record minted a route on this box: %+v, %v", rt, err)
+ }
+ t.Log("(b) provision.Reconcile minted the fleet's own instance route and could not see the other tenant's")
+
+ // --- (c) release adoption: pointer, artifact, staging --------------------
+ // The whole adoption pass over the door: the signed channel pointer is
+ // fetched and verified, the manifest read, the artifact BODY streamed
+ // through the edge (F8 — no presigned redirect), and the version staged
+ // and stamped on the route.
+ artifact := make([]byte, 64<<10)
+ for i := range artifact {
+ artifact[i] = byte(i * 7)
+ }
+ if err := release.Ship(ctx, w.store, w.sqid, release.Manifest{
+ App: fapiApp, Version: fapiVersion, Kind: "binary",
+ Artifacts: map[string]release.Artifact{fapiPlatform: {}},
+ }, map[string][]byte{fapiPlatform: artifact}); err != nil {
+ t.Fatalf("(c) Ship: %v", err)
+ }
+ if err := release.Promote(ctx, w.store, w.priv, w.sqid, fapiApp, fapiChannel, fapiVersion); err != nil {
+ t.Fatalf("(c) Promote: %v", err)
+ }
+ optDir := t.TempDir()
+ var restarted []string
+ restart := func(h string) error { restarted = append(restarted, h); return nil }
+ if err := release.Pass(ctx, boxReg, w.boxA, w.pub, optDir, restart); err != nil {
+ t.Fatalf("(c) release.Pass through the door: %v", err)
+ }
+ if v, err := release.LiveVersion(optDir, fapiHost); v != fapiVersion {
+ t.Fatalf("(c) live version = %q (%v), want %q — adoption did not converge through the door", v, err, fapiVersion)
+ }
+ staged, err := os.ReadFile(release.VersionPath(optDir, w.sqid, fapiVersion, fapiApp))
+ if err != nil || !slices.Equal(staged, artifact) {
+ t.Fatalf("(c) staged artifact = %d bytes (%v), want the shipped %d — a truncated body would pass a shorter check", len(staged), err, len(artifact))
+ }
+ adopted, err := boxReg.Get(fapiHost)
+ if err != nil || adopted == nil || adopted.Version != fapiVersion {
+ t.Fatalf("(c) route version = %+v, %v; want %q stamped", adopted, err, fapiVersion)
+ }
+ t.Logf("(c) adoption converged: pointer verified, %d artifact bytes streamed and staged (restarted %v)", len(artifact), restarted)
+
+ // --- (d) the heartbeat, under the Task-4 default prefix ------------------
+ // A door-backed box gets CARLOS_STATUS_PREFIX defaulted to
+ // fleets/<fleet>/ (spec F5) precisely because the shared status/ root is
+ // outside the door's write ACL. The two lines statusLoop runs, with that
+ // rebase and the agent's label override:
+ prefixed := release.NewPrefixStore(w.boxA, "fleets/"+fapiFleet+"/")
+ routes, err := boxReg.List()
+ if err != nil {
+ t.Fatalf("(d) list box routes: %v", err)
+ }
+ box := status.CollectBox(routes, status.Probes{MachineID: "orchard-a", Region: "IE"}, time.Now())
+ box.Label = fapiBoxA // statusTick's labelOverride: the fleet label, not a machine-id hash
+ if err := status.WriteBox(ctx, prefixed, box); err != nil {
+ t.Fatalf("(d) status.WriteBox through the door: %v", err)
+ }
+ heartbeatKey := "fleets/" + fapiFleet + "/status/boxes/" + fapiBoxA + ".json"
+ raw, _, err := w.store.Get(ctx, heartbeatKey)
+ if err != nil {
+ t.Fatalf("(d) heartbeat did not land at %s: %v", heartbeatKey, err)
+ }
+ var landed status.Box
+ if err := json.Unmarshal(raw, &landed); err != nil {
+ t.Fatalf("(d) heartbeat body: %v", err)
+ }
+ if landed.Label != fapiBoxA {
+ t.Fatalf("(d) heartbeat label = %q, want %q", landed.Label, fapiBoxA)
+ }
+ // The SECOND write is the one that exercises CAS through the door:
+ // WriteBox reads its own object's etag first (the read half of the fleet
+ // corner, which only exists so this replace can happen) and Puts with
+ // If-Match. A door that dropped etags would fail here, not above.
+ if err := status.WriteBox(ctx, prefixed, box); err != nil {
+ t.Fatalf("(d) heartbeat replace (read-etag-then-Put) through the door: %v", err)
+ }
+ // And a box may not heartbeat into a fleet that is not its own.
+ if _, err := w.boxA.Put(ctx, "fleets/other/status/boxes/box-a.json", []byte("{}"), ""); err == nil {
+ t.Fatal("(d) box-a wrote into another fleet's corner")
+ }
+ t.Logf("(d) heartbeat landed at %s and replaced itself under CAS", heartbeatKey)
+
+ // --- (e) a fleet binding takeover, box → bucket → routing ----------------
+ // The platform's route for the app, placed on the fleet and currently
+ // bound to box-b.
+ if err := w.reg.Upsert(registry.Route{
+ Host: fapiHost, Kind: "instance", Status: "ready",
+ Region: registry.PlacementTarget(fapiFleet), FleetBox: fapiBoxB,
+ }); err != nil {
+ t.Fatalf("(e) upsert fleet-placed route: %v", err)
+ }
+ // box-b's agent-startup resident pass (takeover=false), through ITS door
+ // client — an observation, with no lease behind it.
+ if err := host.WriteFleetBinding(ctx, w.boxB, host.FleetBinding{
+ Host: fapiHost, Fleet: fapiFleet, Box: fapiBoxB, BoundAt: time.Now().UnixMilli(),
+ }, false); err != nil {
+ t.Fatalf("(e) box-b resident binding: %v", err)
+ }
+ // box-a wakes the app and wins the lease (modeled in fleet_e2e_test.go;
+ // what matters here is the WRITE): a takeover claims the binding.
+ if err := host.WriteFleetBinding(ctx, w.boxA, host.FleetBinding{
+ Host: fapiHost, Fleet: fapiFleet, Box: fapiBoxA, BoundAt: time.Now().UnixMilli(),
+ }, true); err != nil {
+ t.Fatalf("(e) box-a takeover binding through the door: %v", err)
+ }
+ // The platform reads it back from its own bucket handle — the object IS
+ // the message — and restamps routing.
+ if err := host.ReconcileFleetBindings(ctx, w.store, w.reg); err != nil {
+ t.Fatalf("(e) ReconcileFleetBindings: %v", err)
+ }
+ rt, err := w.reg.Get(fapiHost)
+ if err != nil || rt == nil || rt.FleetBox != fapiBoxA {
+ t.Fatalf("(e) Route.FleetBox = %+v, %v; want %q — the takeover never made it back into routing", rt, err, fapiBoxA)
+ }
+ // And the guard survives the round trip: box-b's next resident pass reads
+ // the existing binding through the door and declines to steal it back.
+ if err := host.WriteFleetBinding(ctx, w.boxB, host.FleetBinding{
+ Host: fapiHost, Fleet: fapiFleet, Box: fapiBoxB, BoundAt: time.Now().UnixMilli(),
+ }, false); err != nil {
+ t.Fatalf("(e) box-b second resident pass: %v", err)
+ }
+ if err := host.ReconcileFleetBindings(ctx, w.store, w.reg); err != nil {
+ t.Fatalf("(e) ReconcileFleetBindings (after box-b's pass): %v", err)
+ }
+ if rt, err = w.reg.Get(fapiHost); err != nil || rt == nil || rt.FleetBox != fapiBoxA {
+ t.Fatalf("(e) Route.FleetBox = %+v, %v; want %q — a non-takeover write stole the binding through the door", rt, err, fapiBoxA)
+ }
+ // End to end, from outside: the edge's 2s poll picks the restamp up and
+ // the app is served by box-a's own channel. Record → route → release →
+ // binding → traffic, with the box holding no bucket credential anywhere
+ // in that chain.
+ waitBody(t, w.srvURL, fapiHost, fapiBoxA, e2eConvergeBound)
+ t.Log("(e) the takeover written through the door reached routing, and the bound box serves")
+
+ // --- (f) the other tenant is invisible ----------------------------------
+ // Listings first: the easiest place to leak a neighbour, and the one
+ // provision.Reconcile above depends on being right.
+ appKeys, err := w.boxA.List(ctx, "apps/")
+ if err != nil {
+ t.Fatalf("(f) list apps/: %v", err)
+ }
+ for _, k := range appKeys {
+ if !strings.HasPrefix(k, "apps/"+w.sqid+"/") {
+ t.Fatalf("(f) list apps/ returned %q — outside this fleet's account", k)
+ }
+ }
+ if len(appKeys) == 0 {
+ t.Fatal("(f) list apps/ came back empty — the scope map is refusing the fleet's OWN estate")
+ }
+ acctKeys, err := w.boxA.List(ctx, "accounts/")
+ if err != nil {
+ t.Fatalf("(f) list accounts/: %v", err)
+ }
+ if !slices.Equal(acctKeys, []string{"accounts/" + w.sqid + "/account.json"}) {
+ t.Fatalf("(f) list accounts/ = %v, want only this fleet's own account record", acctKeys)
+ }
+ acctDirs, err := w.boxA.ListDirs(ctx, "accounts/")
+ if err != nil {
+ t.Fatalf("(f) listdirs accounts/: %v", err)
+ }
+ if !slices.Equal(acctDirs, []string{"accounts/" + w.sqid + "/"}) {
+ t.Fatalf("(f) listdirs accounts/ = %v, want only this fleet's own directory (one child per account exists)", acctDirs)
+ }
+ fleetDirs, err := w.boxA.ListDirs(ctx, "fleets/")
+ if err != nil {
+ t.Fatalf("(f) listdirs fleets/: %v", err)
+ }
+ for _, d := range fleetDirs {
+ if !strings.HasPrefix(d, "fleets/"+fapiFleet+"/") {
+ t.Fatalf("(f) listdirs fleets/ returned %q — another fleet's corner", d)
+ }
+ }
+ // Then direct gets: every one of these keys exists in the bucket, and
+ // every one of them is os.ErrNotExist to this box.
+ for _, key := range []string{
+ "accounts/" + w.other + "/account.json",
+ otherChannelKey,
+ otherLogKey,
+ "apps/" + w.other + "/" + fapiOtherApp + "/instances/evil." + fapiDomain + ".json",
+ "fleets/other/status/boxes/box-a.json",
+ } {
+ if _, _, err := w.store.Get(ctx, key); err != nil {
+ t.Fatalf("(f) fixture %s is not actually in the bucket: %v", key, err)
+ }
+ if _, _, err := w.boxA.Get(ctx, key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("(f) GET %s through the door = %v, want os.ErrNotExist (byte-identical 404)", key, err)
+ }
+ }
+ // Writes and deletes into the neighbour are refused too — including the
+ // delete of a log chunk, which is the one write verb the account-wide logs
+ // grant makes tempting.
+ if _, err := w.boxA.Put(ctx, otherChannelKey, []byte(`{"version":"evil"}`), ""); err == nil {
+ t.Fatal("(f) box-a promoted a release in another account")
+ }
+ if _, err := w.boxA.Put(ctx, otherLogKey, []byte("forged"), ""); err == nil {
+ t.Fatal("(f) box-a forged a log chunk in another account")
+ }
+ if err := w.boxA.Delete(ctx, otherLogKey); err == nil {
+ t.Fatal("(f) box-a deleted another account's log chunk")
+ }
+ if _, _, err := w.store.Get(ctx, otherChannelKey); err != nil {
+ t.Fatalf("(f) the refused writes changed the neighbour's object anyway: %v", err)
+ }
+ t.Log("(f) the second tenant is invisible to this fleet: listings, gets, writes and deletes all refuse")
+
+ // --- (g) log chunks: ship and prune through the door ---------------------
+ // The Task-4 grant, exercised as logshipLoop uses it: chunks are written
+ // UNPREFIXED under the app's own subtree (applog.Prefix — where `carlos
+ // logs` reads them), listed back, and pruned by the same box.
+ hour := applog.HourOf(time.Now())
+ chunk, err := applog.EncodeChunk([]applog.Record{{
+ TS: time.Now(), Box: fapiBoxA, Host: fapiHost, Stream: "stdout", Msg: "listening", Seq: 1,
+ }})
+ if err != nil {
+ t.Fatalf("(g) EncodeChunk: %v", err)
+ }
+ chunkKey := applog.ChunkKey(w.sqid, fapiApp, fapiBoxA, hour, 1, len(chunk))
+ if _, err := w.boxA.Put(ctx, chunkKey, chunk, ""); err != nil {
+ t.Fatalf("(g) ship a log chunk through the door: %v", err)
+ }
+ landedChunk, _, err := w.store.Get(ctx, chunkKey)
+ if err != nil {
+ t.Fatalf("(g) chunk did not land at %s: %v", chunkKey, err)
+ }
+ recs, err := applog.DecodeChunk(landedChunk)
+ if err != nil || len(recs) != 1 || recs[0].Msg != "listening" {
+ t.Fatalf("(g) chunk round trip = %+v, %v", recs, err)
+ }
+ listed, err := w.boxA.List(ctx, applog.BoxPrefix(w.sqid, fapiApp, fapiBoxA))
+ if err != nil || !slices.Contains(listed, chunkKey) {
+ t.Fatalf("(g) list own log subtree = %v, %v; want %s", listed, err, chunkKey)
+ }
+ if err := w.boxA.Delete(ctx, chunkKey); err != nil {
+ t.Fatalf("(g) prune own chunk through the door: %v", err)
+ }
+ if _, _, err := w.store.Get(ctx, chunkKey); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("(g) chunk survived its prune: %v", err)
+ }
+ // The grant stays segment-exact: the same account, the same app, one
+ // segment over, is still refused.
+ for _, key := range []string{
+ release.ChannelKey(w.sqid, fapiApp, fapiChannel),
+ provision.RecordKey(w.sqid, fapiApp, fapiHost),
+ "apps/" + w.sqid + "/" + fapiApp + "/config/production.json",
+ } {
+ if _, err := w.boxA.Put(ctx, key, []byte("{}"), ""); err == nil {
+ t.Fatalf("(g) the logs grant let the box write %s", key)
+ }
+ }
+ t.Log("(g) log chunks ship, list and prune through the door; neighbouring keys stay refused")
+}
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go
new file mode 100644
index 000000000..f96443f79
--- /dev/null
+++ b/internal/edge/storedoor.go
@@ -0,0 +1,505 @@
+// 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"
+ "strconv"
+ "strings"
+
+ "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/<own>/ — 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: <etag> → Put(ifMatch: <etag>) 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 ("<fleet>/<label>") can pass — VerifyDynamic
+ // refuses slash-less static names outright, so a CARLOS_TUNNEL_CLIENTS
+ // credential can never open this door. The token is never logged, never
+ // echoed, and never compared in the clear.
+ name := r.Header.Get("Carlos-Tunnel-Name")
+ token, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
+ if name == "" || !hasBearer {
+ http.NotFound(w, r)
+ return
+ }
+ fleet, ok := d.hub.VerifyDynamic(name, token)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ sqid, ok := d.fleetAccount(fleet)
+ if !ok || sqid == "" {
+ // Authenticated, but the fleet's registry row is not in the edge's
+ // current snapshot (a fleet deleted between reloads, or a hub whose
+ // auth table was set by hand). No identity, no ACL, no answer.
+ http.NotFound(w, r)
+ return
+ }
+ switch {
+ case strings.HasPrefix(r.URL.Path, storeObjectPrefix):
+ d.serveObject(w, r, sqid, fleet, strings.TrimPrefix(r.URL.Path, storeObjectPrefix))
+ case r.URL.Path == storeListPath:
+ d.serveListing(w, r, sqid, fleet, false)
+ case r.URL.Path == storeDirsPath:
+ d.serveListing(w, r, sqid, fleet, true)
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+// serveObject is Get/Put/Delete on one key. rawKey is the path remainder,
+// already percent-decoded by net/http — which is exactly what must be
+// canonicalized: an encoded "%2e%2e" arrives here as a real ".." segment.
+func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fleet, rawKey string) {
+ key, ok := canonicalKey(rawKey)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ ctx := r.Context()
+ switch r.Method {
+ case http.MethodGet:
+ if !aclRead(sqid, fleet, key) {
+ http.NotFound(w, r)
+ return
+ }
+ body, etag, err := d.store.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r) // byte-identical to every refusal above
+ return
+ }
+ d.storeFailed(w, r, "get", key, err)
+ return
+ }
+ if etag != "" {
+ w.Header().Set("ETag", etag)
+ }
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ w.Write(body)
+ case http.MethodPut:
+ if !aclWrite(sqid, fleet, key) {
+ http.NotFound(w, r)
+ return
+ }
+ // Precondition parsing is AFTER the ACL on purpose: a 400 here must
+ // never be reachable for a key the caller may not write, or the
+ // difference between 400 and 404 would map the keyspace.
+ ifMatch, ok := putPrecondition(r)
+ if !ok {
+ http.Error(w, "want If-Match: <etag> or If-None-Match: *", http.StatusBadRequest)
+ return
+ }
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxStoreBodyBytes))
+ if err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "read body", http.StatusBadRequest)
+ return
+ }
+ etag, err := d.store.Put(ctx, key, body, ifMatch)
+ if err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ // The client maps this back to release.ErrCASConflict, so a
+ // heartbeat or binding takeover keeps its exact semantics
+ // through the proxy (F4).
+ http.Error(w, "precondition failed", http.StatusPreconditionFailed)
+ return
+ }
+ d.storeFailed(w, r, "put", key, err)
+ return
+ }
+ if etag != "" {
+ w.Header().Set("ETag", etag)
+ }
+ w.WriteHeader(http.StatusOK)
+ case http.MethodDelete:
+ if !aclWrite(sqid, fleet, key) {
+ http.NotFound(w, r)
+ return
+ }
+ if err := d.store.Delete(ctx, key); err != nil {
+ d.storeFailed(w, r, "delete", key, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent) // idempotent, per the Store contract
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+// serveListing is List (dirs=false) and ListDirs (dirs=true). The requested
+// prefix is scope-MAPPED rather than merely checked (aclListScope): a caller
+// asking for "apps/" gets its own account's subtree, which is what lets an
+// unmodified release.Store caller work through the door. Every returned entry
+// is then re-checked per tenant — aclRead for objects, aclDir for child
+// prefixes — so the answer is in scope even if a future mapping rule is ever
+// wrong, and so the one scope that IS shared (accounts/) can only ever answer
+// with this caller's own record.
+func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, fleet string, dirs bool) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+ prefix, ok := canonicalPrefix(r.URL.Query().Get("prefix"))
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ scope, ok := aclListScope(sqid, fleet, prefix)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ out := []string{} // never null on the wire: an empty listing is []
+ if dirs {
+ got, err := d.store.ListDirs(r.Context(), scope)
+ if err != nil {
+ d.storeFailed(w, r, "listdirs", scope, err)
+ return
+ }
+ for _, dir := range got {
+ // Judged per TENANT, never "is it under the prefix we listed":
+ // under the shared accounts/ scope that test is a tautology and
+ // would enumerate every account on the platform (aclDir).
+ if aclDir(sqid, fleet, dir) {
+ out = append(out, dir)
+ }
+ }
+ } else {
+ got, err := d.store.List(r.Context(), scope)
+ if err != nil {
+ d.storeFailed(w, r, "list", scope, err)
+ return
+ }
+ for _, key := range got {
+ if aclRead(sqid, fleet, key) {
+ out = append(out, key)
+ }
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(out)
+}
+
+// storeFailed answers a genuine backing-store failure. It is deliberately NOT
+// folded into the 404 shape: a bucket outage that looked like "the object is
+// gone" would make a box act on absence (a missing channel pointer, an empty
+// listing) instead of retrying. It is no keyspace oracle either — it is only
+// reachable once the key has already passed canonicalization and the ACL, so
+// the caller learns nothing it was not already entitled to. The log line
+// carries the key and the error; never the token, never the auth header.
+func (d *storeDoor) storeFailed(w http.ResponseWriter, r *http.Request, op, key string, err error) {
+ log.Printf("edge: store door: %s %s: %v", op, key, err)
+ http.Error(w, "store error", http.StatusBadGateway)
+}
+
+// putPrecondition maps the request's conditional headers onto release.Store's
+// ifMatch argument. The Store contract has no unconditional Put — "" is
+// create-only (If-None-Match: *) and non-empty is replace-only — so a PUT
+// carrying neither header is a client bug, refused rather than silently
+// turned into a create. Both headers together is equally ill-defined.
+// If-None-Match is honoured only as the literal "*": an etag list there would
+// mean something this wire does not implement.
+func putPrecondition(r *http.Request) (ifMatch string, ok bool) {
+ im := strings.TrimSpace(r.Header.Get("If-Match"))
+ inm := strings.TrimSpace(r.Header.Get("If-None-Match"))
+ switch {
+ case im != "" && inm != "":
+ return "", false
+ case inm == "*":
+ return "", true
+ case inm != "":
+ return "", false
+ case im != "":
+ return im, true
+ default:
+ return "", false
+ }
+}
+
+// canonicalKey is the pre-ACL gate: it refuses, rather than cleans, anything
+// that could re-point a prefix — "..", "." and empty segments (which covers a
+// leading "/", a trailing "/" and any "//"), an empty key, control bytes, and
+// anything over maxStoreKeyBytes. Refusing rather than normalizing is the
+// point: the ACL below is prefix-string exact, and a key that had to be
+// rewritten to be safe is a key whose author meant something else.
+//
+// It runs BEFORE the ACL, so "apps/<own>/../<other>/x" never reaches a prefix
+// comparison that would have approved its first segment.
+func canonicalKey(raw string) (string, bool) {
+ if raw == "" || len(raw) > maxStoreKeyBytes {
+ return "", false
+ }
+ if strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") {
+ return "", false
+ }
+ for i := 0; i < len(raw); i++ {
+ if raw[i] < 0x20 || raw[i] == 0x7f {
+ return "", false
+ }
+ }
+ for _, seg := range strings.Split(raw, "/") {
+ if seg == "" || seg == "." || seg == ".." {
+ return "", false
+ }
+ }
+ return raw, true
+}
+
+// canonicalPrefix is canonicalKey for a LISTING prefix: same refusals, except
+// that a prefix may end in "/" (and usually does — "apps/", "fleets/pi/").
+// The trailing slash is preserved, because aclListScope's mapping rules are
+// exact string matches and "apps" must not be allowed to stand in for a
+// prefix that would also match "appsomething/".
+func canonicalPrefix(raw string) (string, bool) {
+ if _, ok := canonicalKey(strings.TrimSuffix(raw, "/")); !ok {
+ return "", false
+ }
+ return raw, true
+}
+
+// aclRead is the read half of F3, prefix-string exact against the
+// authenticated fleet's own identity:
+//
+// control/serial the convergence poll
+// bootstrap/carlos the sanctioned self-update path (F7)
+// accounts/<sqid>/** its owning account's record — the manifest lives at
+// accounts/<sqid>/account.json (domains.AccountManifestKey),
+// NOT accounts/<sqid>.json: a box that cannot read it
+// sees an empty account set, and provision.Reconcile's
+// removal phase then retires every provisioned route on
+// the box (knownAccounts -> ParseRecordKey)
+// apps/<sqid>/** its owning account's whole app estate
+// fleets/<fleet>/** its own corner
+//
+// The last line is not in F3's enumeration and is not a widening of it: the
+// fleet's own prefix is the only place it may WRITE, and every write on this
+// wire is a CAS (read the etag, then Put with If-Match). A write scope you
+// cannot read is a write scope you cannot use — the binding takeover guard
+// and the heartbeat both read their own object first. It grants access to
+// nothing beyond what aclWrite already grants.
+func aclRead(sqid, fleet, key string) bool {
+ if sqid == "" || fleet == "" {
+ return false
+ }
+ switch key {
+ case "control/serial", "bootstrap/carlos":
+ return true
+ }
+ return strings.HasPrefix(key, "accounts/"+sqid+"/") ||
+ strings.HasPrefix(key, "apps/"+sqid+"/") ||
+ strings.HasPrefix(key, "fleets/"+fleet+"/")
+}
+
+// aclDir is aclRead for a LISTING's child prefixes (ListDirs), which are
+// directories rather than objects and so cannot be judged by it: a returned
+// prefix is in scope exactly when it lies inside one of this tenant's own
+// subtrees, or IS its own account directory.
+//
+// It exists because "the prefix we listed under" is not a sufficient filter
+// for the one shared scope: accounts/ must be listable (it is how a box
+// discovers its own account record) but ListDirs("accounts/") returns
+// accounts/<sqid>/ for EVERY tenant on the platform, and a HasPrefix check
+// against the scope we just used is a tautology that would hand all of them
+// back.
+func aclDir(sqid, fleet, dir string) bool {
+ if sqid == "" || fleet == "" {
+ return false
+ }
+ if dir == "accounts/"+sqid+"/" {
+ return true
+ }
+ return strings.HasPrefix(dir, "apps/"+sqid+"/") ||
+ strings.HasPrefix(dir, "fleets/"+fleet+"/")
+}
+
+// aclWrite is the write half of F3 — and DELETE's half too. Two scopes, and
+// only two:
+//
+// fleets/<fleet>/** its own corner
+// apps/<sqid>/<app>/logs/** its own account's app logs (applog.Prefix)
+//
+// It can never write a release artifact, a channel pointer, or an instance
+// record, which is the tamper surface the whole spec exists to close (F6:
+// retention does not arm on a fleet box either).
+//
+// The logs scope is SEGMENT-EXACT on purpose, and the exactness is the
+// security property: the grant is apps/<sqid>/<ONE app segment>/logs/**, never
+// apps/<sqid>/** and never a "logs/ appears somewhere in the key" test. A
+// blanket own-account write would let a box pin its own channels/*.json (self
+// -promoting any build it likes) or mint instances/*.json records
+// (self-authorizing routes) — precisely the writes this door exists to
+// prevent. Log chunks are the one thing a box legitimately produces about its
+// own tenant's apps (applog.ChunkKey), and they are write-once objects under a
+// per-box subtree.
+//
+// HONEST LIMIT: the <box> segment inside that subtree is an unverifiable
+// pseudonym (status.Label of a machine id the box chose), so one box of an
+// account can in principle delete a same-account sibling box's chunks. The
+// blast radius is inside a single account's own logs — no other tenant, no
+// release data — and pruning its own subtree is exactly what logship must do
+// every hour; verifying the segment would need a per-box identity the door
+// does not have today.
+func aclWrite(sqid, fleet, key string) bool {
+ if fleet == "" {
+ return false
+ }
+ if strings.HasPrefix(key, "fleets/"+fleet+"/") {
+ return true
+ }
+ return aclWriteAppLogs(sqid, key)
+}
+
+// aclWriteAppLogs implements aclWrite's second scope: exactly
+// apps/<sqid>/<app>/logs/<something>, with <app> a single non-empty segment
+// and at least one key component below logs/. Anything else — a shorter key,
+// a second path shape, "logs" as the app segment — is refused, so the grant
+// can never be widened by a key that merely contains the word.
+func aclWriteAppLogs(sqid, key string) bool {
+ if sqid == "" {
+ return false
+ }
+ rest, ok := strings.CutPrefix(key, "apps/"+sqid+"/")
+ if !ok {
+ return false
+ }
+ app, tail, ok := strings.Cut(rest, "/")
+ if !ok || app == "" {
+ return false
+ }
+ below, ok := strings.CutPrefix(tail, "logs/")
+ return ok && below != ""
+}
+
+// aclListScope maps a requested listing prefix onto the prefix the door will
+// actually list, or refuses. Mapping rather than refusing is what keeps
+// unmodified release.Store callers working: "apps/" is what platform code
+// asks for, and a fleet box asking it gets its own account's subtree back
+// (spec F3: "list of accounts/ and apps/ answers only the own-account
+// subset"). A bare "apps"/"fleets"/"accounts" (no trailing slash) maps the
+// same way; anything narrower must already be inside the caller's own
+// subtree, and a partial sqid or fleet name ("apps/ab1", "fleets/pix/") is
+// refused rather than widened, since S3 prefix matching would otherwise reach
+// a sibling tenant.
+//
+// accounts/ is the one scope that maps to a SHARED prefix — it has to be
+// listable, since that is how a box finds its own account manifest
+// (provision.knownAccounts lists accounts/ and parses
+// accounts/<sqid>/account.json). The caller may see only its own record
+// there, which serveListing enforces by re-checking every returned entry
+// (aclRead for objects, aclDir for the child prefixes ListDirs returns —
+// there is one per account on the platform).
+func aclListScope(sqid, fleet, prefix string) (string, bool) {
+ if sqid == "" || fleet == "" || prefix == "" {
+ return "", false
+ }
+ ownApps := "apps/" + sqid + "/"
+ ownFleet := "fleets/" + fleet + "/"
+ switch {
+ case prefix == "apps" || prefix == "apps/" || prefix == "apps/"+sqid:
+ return ownApps, true
+ case strings.HasPrefix(prefix, ownApps):
+ return prefix, true
+ case strings.HasPrefix(prefix, "apps/"):
+ return "", false
+ case prefix == "accounts" || prefix == "accounts/":
+ return "accounts/", true
+ case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet:
+ return ownFleet, true
+ case strings.HasPrefix(prefix, ownFleet):
+ return prefix, true
+ default:
+ return "", false
+ }
+}
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
new file mode 100644
index 000000000..a2eb8011d
--- /dev/null
+++ b/internal/edge/storedoor_test.go
@@ -0,0 +1,701 @@
+package edge
+
+// Task 2: the store door — the ACL is the whole security story, so it is
+// tested as a matrix BEFORE any HTTP shape exists: every key class from the
+// spec's #73 inventory × operation × own/other tenancy, with the exact keys a
+// real box uses. A row that flips here is a tenancy breach, not a test nit.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The two identities every row is judged against: fleet "pi" owned by account
+// sqid "ab12". "OTHER"/"zz99" is any other tenant.
+const (
+ testSqid = "ab12"
+ testFleet = "pi"
+)
+
+type aclOp int
+
+const (
+ opRead aclOp = iota
+ opWrite
+ opDelete
+)
+
+func (o aclOp) String() string {
+ switch o {
+ case opRead:
+ return "read"
+ case opWrite:
+ return "write"
+ default:
+ return "delete"
+ }
+}
+
+// aclAllows is the door's decision for one (key, op), canonicalization
+// included — exactly the order the handler runs it in: canonicalKey first, so
+// a traversal key is refused before any ACL string ever sees it.
+func aclAllows(key string, op aclOp) bool {
+ k, ok := canonicalKey(key)
+ if !ok {
+ return false
+ }
+ if op == opRead {
+ return aclRead(testSqid, testFleet, k)
+ }
+ return aclWrite(testSqid, testFleet, k)
+}
+
+func TestStoreDoorACLMatrix(t *testing.T) {
+ rows := []struct {
+ key string
+ op aclOp
+ want bool
+ why string
+ }{
+ // --- the shared platform reads the spec allowlists (F3, F7) ---
+ {"control/serial", opRead, true, "the 2s convergence poll"},
+ {"control/serial", opWrite, false, "a box must never bump the platform serial"},
+ {"control/serial", opDelete, false, "nor delete it"},
+ {"bootstrap/carlos", opRead, true, "F7: the sanctioned self-update path"},
+ {"bootstrap/carlos", opWrite, false, "a box must never publish the platform binary"},
+ {"control/serial2", opRead, false, "the allowlist is exact, not a prefix"},
+ {"control/", opRead, false, "no listing-shaped key stands in for the allowlist"},
+ {"bootstrap/carlos.sig", opRead, false, "exact key only"},
+
+ // --- own account estate ---
+ // The account manifest is accounts/<sqid>/account.json
+ // (domains.AccountManifestKey), NOT accounts/<sqid>.json: a box that
+ // cannot read it sees zero accounts, and provision.Reconcile then
+ // retires every provisioned route on the box.
+ {"accounts/ab12/account.json", opRead, true, "own account manifest"},
+ {"accounts/zz99/account.json", opRead, false, "another tenant's account manifest"},
+ {"accounts/ab12/account.json", opWrite, false, "reads only, even of its own account"},
+ {"accounts/ab12.json", opRead, false, "the manifest is a directory deeper; this key is not the account"},
+ {"accounts/ab12x/account.json", opRead, false, "sqid prefix must not match a longer sqid"},
+ {"accounts/ab12", opRead, false, "the account prefix itself is not an object"},
+ {"apps/ab12/web/channels/stable.json", opRead, true, "own app channel pointer"},
+ {"apps/ab12/web/releases/v1.2.3/manifest.json", opRead, true, "own release manifest"},
+ {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opRead, true, "own artifact (F8: streamed, not presigned)"},
+ {"apps/ab12/web/config/production.json", opRead, true, "own config bundle"},
+ {"apps/ab12/web/restart-gen.json", opRead, true, "own restart generation"},
+ {"apps/ab12/web/channels/stable.json", opWrite, false, "a box never promotes"},
+ {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opDelete, false, "retention is not a box's job (F6)"},
+
+ // --- own account's app LOGS: the second (and only other) write scope ---
+ // applog.ChunkKey's shape, which is what logshipLoop PUTs and what its
+ // hourly prune DELETEs. The rows below the allows are the regression
+ // pins that keep this from becoming a blanket apps/<sqid>/** grant.
+ {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opWrite, true, "own app's log chunk"},
+ {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opDelete, true, "logship prunes its own subtree hourly"},
+ {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opRead, true, "reads already covered by the app estate"},
+ {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opWrite, false, "another account's app logs"},
+ {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opDelete, false, "nor delete them"},
+ {"apps/ab12x/web/logs/box-a/x.jsonl.gz", opWrite, false, "sqid prefix must not match a longer sqid"},
+ {"apps/ab12/web/instances/h.example.json", opWrite, false, "REGRESSION: the logs grant must not mint instance records"},
+ {"apps/ab12/web/config/production.json", opWrite, false, "REGRESSION: nor rewrite config"},
+ {"apps/ab12/web/restart-gen.json", opWrite, false, "REGRESSION: nor request its own restarts"},
+ {"apps/ab12/web/releases/v1.2.3/manifest.json", opWrite, false, "REGRESSION: nor publish a release"},
+ {"apps/ab12/logs/box-a/x.jsonl.gz", opWrite, false, "logs must sit under an app segment, not the account root"},
+ {"apps/ab12/web/logs", opWrite, false, "the logs prefix itself is not an object"},
+ {"apps/ab12/web/logs/", opWrite, false, "nor is the bare prefix with a slash"},
+ {"apps/ab12/web/channels/logs/stable.json", opWrite, false, "\"logs\" deeper in the key grants nothing"},
+ {"apps/ab12/weblogs/box-a/x.jsonl.gz", opWrite, false, "the segment is logs/, not a suffix of the app name"},
+ {"apps/ab12/web/logs/../../zz99/web/logs/x.jsonl.gz", opWrite, false, "traversal out of the logs subtree"},
+ {"apps/ab12/web/logs/../channels/stable.json", opWrite, false, "traversal out of logs into channels"},
+
+ // --- other tenants ---
+ {"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"},
+ {"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"},
+ {"apps/ab1/web/channels/stable.json", opRead, false, "nor a shorter one"},
+ {"apps/ab12", opRead, false, "the account prefix itself is not an object"},
+
+ // --- own fleet corner: the only writable place there is ---
+ {"fleets/pi/bindings/app.example.json", opRead, true, "the binding takeover guard reads before it CASes"},
+ {"fleets/pi/bindings/app.example.json", opWrite, true, "fleet bindings are the box's own message"},
+ {"fleets/pi/status/boxes/box-a.json", opWrite, true, "own heartbeat"},
+ {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opWrite, true, "own metrics"},
+ {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opDelete, true, "own 48h metrics prune"},
+ {"fleets/other/status/boxes/box-a.json", opRead, false, "another fleet's corner"},
+ {"fleets/other/status/boxes/box-a.json", opWrite, false, "and it cannot write there"},
+ {"fleets/other/status/boxes/box-a.json", opDelete, false, "nor delete there"},
+ {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"},
+ {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"},
+
+ // --- shapes canonicalKey must kill before the ACL is consulted ---
+ {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"},
+ {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"},
+ {"../etc/passwd", opRead, false, "traversal below the keyspace"},
+ {"/control/serial", opRead, false, "leading slash"},
+ {"apps//ab12/web/channels/stable.json", opRead, false, "empty segment"},
+ {"apps/ab12/./web/channels/stable.json", opRead, false, "dot segment"},
+ {"", opRead, false, "empty key"},
+ }
+ for _, row := range rows {
+ got := aclAllows(row.key, row.op)
+ if got != row.want {
+ t.Errorf("acl %s %q = %v, want %v (%s)", row.op, row.key, got, row.want, row.why)
+ }
+ }
+}
+
+// TestCanonicalKeyRefusesShapes pins the pre-ACL gate on its own: the ACL is
+// prefix-string exact, which is only safe because nothing that could re-point
+// a prefix ever reaches it.
+func TestCanonicalKeyRefusesShapes(t *testing.T) {
+ bad := []string{
+ "",
+ "/apps/ab12/x",
+ "apps//ab12/x",
+ "apps/ab12/../zz99/x",
+ "..",
+ "../x",
+ "apps/ab12/x/..",
+ "apps/./ab12/x",
+ "apps/ab12/x/",
+ "apps/ab12/\x00x",
+ "apps/ab12/x\ny",
+ strings.Repeat("a", 1025),
+ }
+ for _, k := range bad {
+ if got, ok := canonicalKey(k); ok {
+ t.Errorf("canonicalKey(%q) = (%q, true), want refused", k, got)
+ }
+ }
+ good := []string{
+ "control/serial",
+ "bootstrap/carlos",
+ "accounts/ab12.json",
+ "apps/ab12/web/releases/v1.2.3/app.tar.gz",
+ "fleets/pi/bindings/app.example.json",
+ strings.Repeat("a", 1024),
+ }
+ for _, k := range good {
+ got, ok := canonicalKey(k)
+ if !ok || got != k {
+ t.Errorf("canonicalKey(%q) = (%q, %v), want (%q, true)", k, got, ok, k)
+ }
+ }
+}
+
+// TestACLListScope: a listing is scope-MAPPED, never scope-checked — asking
+// for "apps/" gets you your own account's subtree, not a refusal, because
+// that is what makes an unmodified release.Store caller work through the door.
+func TestACLListScope(t *testing.T) {
+ cases := []struct {
+ prefix string
+ want string
+ ok bool
+ }{
+ {"apps/", "apps/ab12/", true},
+ {"apps", "apps/ab12/", true},
+ {"apps/ab12", "apps/ab12/", true},
+ {"apps/ab12/", "apps/ab12/", true},
+ {"apps/ab12/web/releases/", "apps/ab12/web/releases/", true},
+ {"apps/zz99/", "", false},
+ {"apps/ab1", "", false},
+ {"apps/ab12x/", "", false},
+ {"accounts/", "accounts/", true},
+ {"accounts", "accounts/", true},
+ {"accounts/zz99.json", "", false},
+ // DELIBERATE, and the trap worth naming: aclDir is STRICTER than
+ // aclRead here. aclRead admits accounts/<own-sqid>/** as an object
+ // read, but the listing scope map admits only the bare, SHARED
+ // accounts/ prefix — a caller narrowing its listing to its own
+ // account directory is refused. That is not an oversight to "fix":
+ // every real caller of this prefix lists accounts/ whole
+ // (provision.knownAccounts, domains.Reconcile, console.materialize)
+ // and takes the per-tenant filter serveListing applies, so a
+ // per-tenant mapping row here would widen the scope map for no
+ // caller. If one ever appears, adding the row is the deliberate act
+ // this pin makes visible.
+ {"accounts/" + testSqid + "/", "", false},
+ {"accounts/" + testSqid, "", false},
+ {"fleets/", "fleets/pi/", true},
+ {"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true},
+ {"fleets/other/", "", false},
+ {"control/", "", false},
+ {"bootstrap/", "", false},
+ {"", "", false},
+ {"status/", "", false},
+ }
+ for _, c := range cases {
+ got, ok := aclListScope(testSqid, testFleet, c.prefix)
+ if got != c.want || ok != c.ok {
+ t.Errorf("aclListScope(%q) = (%q, %v), want (%q, %v)", c.prefix, got, ok, c.want, c.ok)
+ }
+ }
+}
+
+// TestACLDirIsPerTenant pins the ListDirs predicate on its own. A child
+// prefix cannot be judged by aclRead (it is a directory, not an object), and
+// judging it by "is it under the prefix we listed" would enumerate every
+// account on the platform the moment accounts/ is listed.
+func TestACLDirIsPerTenant(t *testing.T) {
+ cases := map[string]bool{
+ "accounts/" + testSqid + "/": true,
+ "accounts/zz99/": false,
+ "accounts/" + testSqid + "x/": false,
+ "accounts/": false,
+ "apps/" + testSqid + "/web/": true,
+ "apps/zz99/web/": false,
+ "apps/" + testSqid + "x/web/": false,
+ "fleets/" + testFleet + "/status/": true,
+ "fleets/other/status/": false,
+ "fleets/" + testFleet + "x/y/": false,
+ "control/": false,
+ }
+ for dir, want := range cases {
+ if got := aclDir(testSqid, testFleet, dir); got != want {
+ t.Errorf("aclDir(%q) = %v, want %v", dir, got, want)
+ }
+ }
+}
+
+// ---- handler tests ------------------------------------------------------
+
+// The credentials every handler test runs with: a fleet channel
+// "<fleet>/<label>" whose token hash sits in the hub's dynamic table (the
+// registry-backed source), alongside a STATIC client which must never open
+// this door.
+const (
+ testChannel = testFleet + "/box-a"
+ testToken = "box-a-bearer-token"
+ staticName = "hq"
+ staticToken = "static-client-token"
+ ownKey = "apps/" + testSqid + "/web/channels/stable.json"
+ ownFleetKey = "fleets/" + testFleet + "/status/boxes/box-a.json"
+ otherAccountKey = "apps/zz99/web/channels/stable.json"
+)
+
+// newTestDoor builds a door over a DirStore with the identities above — the
+// same wiring edge.go does in production (hub + the edge's own store handle +
+// the reload-time fleet→account snapshot).
+func newTestDoor(t *testing.T) (*storeDoor, *release.DirStore) {
+ t.Helper()
+ hub := NewTunnelHub("tunnel.test", map[string]string{staticName: staticToken})
+ hub.SetDynamicAuth(map[string]string{testChannel: sha256Hex(testToken)})
+ store := release.NewDirStore(t.TempDir())
+ return &storeDoor{hub: hub, store: store, fleetAccount: func(fleet string) (string, bool) {
+ if fleet == testFleet {
+ return testSqid, true
+ }
+ return "", false
+ }}, store
+}
+
+// doorReq builds a request with the given credentials; an empty token sends
+// no Authorization header at all, an empty name no Carlos-Tunnel-Name.
+func doorReq(method, target, name, token, body string) *http.Request {
+ var r *http.Request
+ if body == "" {
+ r = httptest.NewRequest(method, target, nil)
+ } else {
+ r = httptest.NewRequest(method, target, strings.NewReader(body))
+ }
+ if name != "" {
+ r.Header.Set("Carlos-Tunnel-Name", name)
+ }
+ if token != "" {
+ r.Header.Set("Authorization", "Bearer "+token)
+ }
+ return r
+}
+
+// doorDo runs one correctly authenticated request through the door.
+func doorDo(d *storeDoor, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder {
+ r := doorReq(method, target, testChannel, testToken, body)
+ for k, v := range hdr {
+ r.Header.Set(k, v)
+ }
+ w := httptest.NewRecorder()
+ d.ServeHTTP(w, r)
+ return w
+}
+
+func seedObject(t *testing.T, s *release.DirStore, key, body string) string {
+ t.Helper()
+ etag, err := s.Put(context.Background(), key, []byte(body), "")
+ if err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+ return etag
+}
+
+func TestStoreDoorGetServesOwnEstate(t *testing.T) {
+ d, store := newTestDoor(t)
+ etag := seedObject(t, store, ownKey, `{"version":"v1"}`)
+ seedObject(t, store, "control/serial", "42")
+ seedObject(t, store, "bootstrap/carlos", "ELF...")
+
+ for _, key := range []string{ownKey, "control/serial", "bootstrap/carlos"} {
+ w := doorDo(d, "GET", "/store/o/"+key, "", nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", key, w.Code)
+ }
+ }
+ w := doorDo(d, "GET", "/store/o/"+ownKey, "", nil)
+ if got := w.Body.String(); got != `{"version":"v1"}` {
+ t.Errorf("body = %q, want the stored object", got)
+ }
+ if got := w.Header().Get("ETag"); got != etag {
+ t.Errorf("ETag = %q, want the backing store's %q (etags are opaque, passed through)", got, etag)
+ }
+}
+
+// TestStoreDoorCASRoundTrip pins F4 on the wire: there is no unconditional
+// Put, a lost race is 412, and a won race hands back the new etag.
+func TestStoreDoorCASRoundTrip(t *testing.T) {
+ d, store := newTestDoor(t)
+ path := "/store/o/" + ownFleetKey
+
+ if w := doorDo(d, "PUT", path, "first", nil); w.Code != http.StatusBadRequest {
+ t.Fatalf("PUT with no precondition = %d, want 400 (the Store contract has no blind write)", w.Code)
+ }
+ if _, _, err := store.Get(context.Background(), ownFleetKey); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a refused PUT still created the object")
+ }
+ if w := doorDo(d, "PUT", path, "x", map[string]string{"If-Match": "abc", "If-None-Match": "*"}); w.Code != http.StatusBadRequest {
+ t.Errorf("PUT with both preconditions = %d, want 400", w.Code)
+ }
+
+ w := doorDo(d, "PUT", path, "first", map[string]string{"If-None-Match": "*"})
+ if w.Code != http.StatusOK {
+ t.Fatalf("create-only PUT = %d, want 200", w.Code)
+ }
+ e1 := w.Header().Get("ETag")
+ if e1 == "" {
+ t.Fatal("create-only PUT returned no ETag")
+ }
+ if w := doorDo(d, "PUT", path, "again", map[string]string{"If-None-Match": "*"}); w.Code != http.StatusPreconditionFailed {
+ t.Errorf("create-only PUT over an existing object = %d, want 412", w.Code)
+ }
+ if w := doorDo(d, "PUT", path, "stale", map[string]string{"If-Match": "0000"}); w.Code != http.StatusPreconditionFailed {
+ t.Errorf("replace-only PUT with a stale etag = %d, want 412", w.Code)
+ }
+ w2 := doorDo(d, "PUT", path, "second", map[string]string{"If-Match": e1})
+ if w2.Code != http.StatusOK {
+ t.Fatalf("replace-only PUT with the fresh etag = %d, want 200", w2.Code)
+ }
+ if e2 := w2.Header().Get("ETag"); e2 == "" || e2 == e1 {
+ t.Errorf("ETag after replace = %q, want a new one (was %q)", e2, e1)
+ }
+ body, _, err := store.Get(context.Background(), ownFleetKey)
+ if err != nil || string(body) != "second" {
+ t.Fatalf("stored body = %q, %v; want \"second\"", body, err)
+ }
+}
+
+func TestStoreDoorDeleteIsScopedAndIdempotent(t *testing.T) {
+ d, store := newTestDoor(t)
+ seedObject(t, store, ownFleetKey, "{}")
+ seedObject(t, store, ownKey, "{}")
+
+ for i := 0; i < 2; i++ {
+ if w := doorDo(d, "DELETE", "/store/o/"+ownFleetKey, "", nil); w.Code != http.StatusNoContent {
+ t.Fatalf("DELETE own fleet key (attempt %d) = %d, want 204", i+1, w.Code)
+ }
+ }
+ if w := doorDo(d, "DELETE", "/store/o/"+ownKey, "", nil); w.Code != http.StatusNotFound {
+ t.Fatalf("DELETE a release object = %d, want 404 (retention is not a box's job)", w.Code)
+ }
+ if _, _, err := store.Get(context.Background(), ownKey); err != nil {
+ t.Fatalf("the refused DELETE removed the object anyway: %v", err)
+ }
+}
+
+// TestStoreDoorRefusalsAreByteIdentical is the spec's central security
+// invariant: bad auth, a static (non-fleet) name, another tenant's key, a
+// missing in-scope key and a malformed key must be INDISTINGUISHABLE — same
+// status, same headers, same bytes — or the door becomes a keyspace oracle.
+func TestStoreDoorRefusalsAreByteIdentical(t *testing.T) {
+ d, store := newTestDoor(t)
+ seedObject(t, store, otherAccountKey, "someone else's")
+ seedObject(t, store, "fleets/other/status/boxes/box-a.json", "another fleet's")
+ // Seeded so the account-manifest row below refuses a key that really
+ // EXISTS: the refusal must not be distinguishable from the missing-key
+ // one, which is only proved against an object that is actually there.
+ seedObject(t, store, "accounts/zz99/account.json", "another tenant's account")
+
+ req := func(name, token, method, target string) *http.Request {
+ return doorReq(method, target, name, token, "")
+ }
+ cases := map[string]*http.Request{
+ "wrong token": req(testChannel, "not-the-token", "GET", "/store/o/"+ownKey),
+ "no auth at all": req("", "", "GET", "/store/o/"+ownKey),
+ "static name + token": req(staticName, staticToken, "GET", "/store/o/control/serial"),
+ "unknown fleet": req("nosuch/box", testToken, "GET", "/store/o/control/serial"),
+ "other account's key": req(testChannel, testToken, "GET", "/store/o/"+otherAccountKey),
+ "other fleet's key": req(testChannel, testToken, "GET", "/store/o/fleets/other/status/boxes/box-a.json"),
+ "missing in-scope key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/web/nope.json"),
+ "traversal key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/../zz99/x"),
+ "empty key": req(testChannel, testToken, "GET", "/store/o/"),
+ "write out of scope": req(testChannel, testToken, "DELETE", "/store/o/"+otherAccountKey),
+ "list out of scope": req(testChannel, testToken, "GET", "/store/list?prefix=apps/zz99/"),
+ "unknown path": req(testChannel, testToken, "GET", "/store/nope"),
+ "wrong method": req(testChannel, testToken, "POST", "/store/o/"+ownKey),
+ // The two classes Task 2 deferred. The account manifest is the key a
+ // tenant-enumeration probe would reach for FIRST (accounts/<sqid>/
+ // account.json is the one object whose presence answers "does this
+ // account exist"), and /store/dirs is the whole second listing route
+ // — its refusals were never pinned to the same shape as /store/list's.
+ "other account's manifest": req(testChannel, testToken, "GET", "/store/o/accounts/zz99/account.json"),
+ "dirs out of scope": req(testChannel, testToken, "GET", "/store/dirs?prefix=accounts/zz99/"),
+ "dirs of another fleet": req(testChannel, testToken, "GET", "/store/dirs?prefix=fleets/other/"),
+ "dirs unauthenticated": req(testChannel, "not-the-token", "GET", "/store/dirs?prefix=fleets/"),
+ }
+ type shape struct {
+ code int
+ header string
+ body string
+ }
+ var want *shape
+ var wantName string
+ for name, r := range cases {
+ w := httptest.NewRecorder()
+ d.ServeHTTP(w, r)
+ got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()}
+ if got.code != http.StatusNotFound {
+ t.Errorf("%s: status = %d, want 404", name, got.code)
+ }
+ if strings.Contains(got.body, testToken) || strings.Contains(got.header, testToken) {
+ t.Errorf("%s: response echoes the bearer token", name)
+ }
+ if want == nil {
+ g := got
+ want, wantName = &g, name
+ continue
+ }
+ if got != *want {
+ t.Errorf("%q answers %+v but %q answers %+v — the two refusals are distinguishable", name, got, wantName, *want)
+ }
+ }
+}
+
+// TestStoreDoorListScopeStripsOtherTenants: a listing is the easiest place to
+// leak a neighbour, so every listing route is checked against a store that
+// holds another account's and another fleet's objects.
+func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) {
+ d, store := newTestDoor(t)
+ seedObject(t, store, ownKey, "{}")
+ seedObject(t, store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}")
+ seedObject(t, store, otherAccountKey, "{}")
+ seedObject(t, store, "accounts/"+testSqid+"/account.json", "{}")
+ seedObject(t, store, "accounts/zz99/account.json", "{}")
+ seedObject(t, store, ownFleetKey, "{}")
+ seedObject(t, store, "fleets/other/status/boxes/box-a.json", "{}")
+
+ list := func(target string) []string {
+ t.Helper()
+ w := doorDo(d, "GET", target, "", nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", target, w.Code)
+ }
+ var out []string
+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
+ t.Fatalf("GET %s: decode %q: %v", target, w.Body.String(), err)
+ }
+ return out
+ }
+ for _, c := range []struct {
+ target string
+ want []string
+ }{
+ {"/store/list?prefix=apps/", []string{ownKey, "apps/" + testSqid + "/web/releases/v1/manifest.json"}},
+ {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + "/account.json"}},
+ {"/store/list?prefix=fleets/", []string{ownFleetKey}},
+ {"/store/list?prefix=apps/" + testSqid + "/web/releases/", []string{"apps/" + testSqid + "/web/releases/v1/manifest.json"}},
+ {"/store/dirs?prefix=apps/", []string{"apps/" + testSqid + "/web/"}},
+ {"/store/dirs?prefix=fleets/", []string{"fleets/" + testFleet + "/status/"}},
+ // The regression that matters most on the dirs route: accounts/ is a
+ // SHARED prefix, and ListDirs returns one child per account on the
+ // platform. Filtering by "is it under the prefix we listed" is a
+ // tautology there — the answer must be this tenant's own directory
+ // and nothing else, with zz99 seeded above to prove it.
+ {"/store/dirs?prefix=accounts/", []string{"accounts/" + testSqid + "/"}},
+ } {
+ got := list(c.target)
+ if !slices.Equal(got, c.want) {
+ t.Errorf("%s = %v, want %v", c.target, got, c.want)
+ }
+ }
+ // An empty listing is [] on the wire, never null: the client decodes into
+ // a []string either way, but null would invite a nil-vs-empty distinction
+ // the Store contract does not have.
+ w := doorDo(d, "GET", "/store/list?prefix=apps/"+testSqid+"/nosuchapp/", "", nil)
+ if body := strings.TrimSpace(w.Body.String()); body != "[]" {
+ t.Errorf("empty listing body = %q, want []", body)
+ }
+}
+
+// doorOpCounter records how many store operations a request performed —
+// enough to prove a refused request touches the bucket zero times.
+type doorOpCounter struct {
+ release.Store
+ ops int
+}
+
+func (c *doorOpCounter) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.ops++
+ return c.Store.Get(ctx, key)
+}
+
+func (c *doorOpCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ c.ops++
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (c *doorOpCounter) List(ctx context.Context, prefix string) ([]string, error) {
+ c.ops++
+ return c.Store.List(ctx, prefix)
+}
+
+func (c *doorOpCounter) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ c.ops++
+ return c.Store.ListDirs(ctx, prefix)
+}
+
+func (c *doorOpCounter) Delete(ctx context.Context, key string) error {
+ c.ops++
+ return c.Store.Delete(ctx, key)
+}
+
+// TestStoreDoorAuthRunsExactlyOncePerRequest pins the invariant two ways: the
+// handler contains exactly ONE call to VerifyDynamic (no path can skip it,
+// none can pay for it twice — and the auth logic itself is never
+// re-implemented here), and an unauthenticated request of any shape reaches
+// the store zero times.
+func TestStoreDoorAuthRunsExactlyOncePerRequest(t *testing.T) {
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, "storedoor.go", nil, 0)
+ if err != nil {
+ t.Fatalf("parse storedoor.go: %v", err)
+ }
+ calls := 0
+ ast.Inspect(f, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "VerifyDynamic" {
+ calls++
+ }
+ return true
+ })
+ if calls != 1 {
+ t.Errorf("storedoor.go calls VerifyDynamic %d times, want exactly 1 (one auth, one place)", calls)
+ }
+
+ d, _ := newTestDoor(t)
+ counting := &doorOpCounter{Store: d.store}
+ d.store = counting
+ for _, r := range []*http.Request{
+ doorReq("GET", "/store/o/"+ownKey, testChannel, "wrong", ""),
+ doorReq("PUT", "/store/o/"+ownFleetKey, testChannel, "wrong", "x"),
+ doorReq("DELETE", "/store/o/"+ownFleetKey, testChannel, "wrong", ""),
+ doorReq("GET", "/store/list?prefix=apps/", testChannel, "wrong", ""),
+ doorReq("GET", "/store/dirs?prefix=apps/", testChannel, "wrong", ""),
+ doorReq("GET", "/store/o/"+ownKey, staticName, staticToken, ""),
+ doorReq("GET", "/store/o/"+ownKey, "", "", ""),
+ } {
+ w := httptest.NewRecorder()
+ d.ServeHTTP(w, r)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("%s %s unauthenticated = %d, want 404", r.Method, r.URL, w.Code)
+ }
+ }
+ if counting.ops != 0 {
+ t.Errorf("unauthenticated requests performed %d store operation(s), want 0", counting.ops)
+ }
+}
+
+// TestStoreDoorOnlyOnReservedHost drives the door through the EDGE, not
+// directly: it must be unreachable on every host except the reserved tunnel
+// hostname, exactly like tunnel registration — and it must not have swallowed
+// /register on the way in.
+func TestStoreDoorOnlyOnReservedHost(t *testing.T) {
+ reg := openReg(t)
+ if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil {
+ t.Fatalf("UpsertFleet: %v", err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: testFleet, Label: "box-a", TokenHash: sha256Hex(testToken), Status: "active"}); err != nil {
+ t.Fatalf("AddFleetBox: %v", err)
+ }
+ backend, _ := hostEchoBackend(t, "the app, not the door")
+ if err := reg.Upsert(registry.Route{Host: "app.example", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert route: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ seedObject(t, store, "control/serial", "77")
+ _, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
+ e.EnableTunnels(NewTunnelHub("tunnel.test", nil))
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ get := func(host, path string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequest("GET", srv.URL+path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Host = host
+ req.Header.Set("Carlos-Tunnel-Name", testChannel)
+ req.Header.Set("Authorization", "Bearer "+testToken)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET %s (host %s): %v", path, host, err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(b)
+ }
+ if code, body := get("tunnel.test", "/store/o/control/serial"); code != http.StatusOK || body != "77" {
+ t.Fatalf("on the reserved host: %d %q, want 200 \"77\"", code, body)
+ }
+ if code, body := get("app.example", "/store/o/control/serial"); code != http.StatusOK || body != "the app, not the door" {
+ t.Fatalf("on a routed host: %d %q — the door answered on a host it must not serve", code, body)
+ }
+ if code, _ := get("nosuch.example", "/store/o/control/serial"); code == http.StatusOK {
+ t.Fatal("the door answered on an unknown host")
+ }
+ // /register stays the hub's on the same hostname: no upgrade headers, so
+ // a 400 (the hub's wire-contract refusal), never the door's 404.
+ if code, _ := get("tunnel.test", "/register"); code != http.StatusBadRequest {
+ t.Fatalf("/register on the reserved host = %d, want 400 (still the hub's)", code)
+ }
+}
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go
index d40999a60..7f619d995 100644
--- a/internal/edge/tunnel.go
+++ b/internal/edge/tunnel.go
@@ -154,15 +154,8 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// that fact is known.
viaDynamic := false
if !authOK && name != "" && hasBearer {
- h.mu.Lock()
- wantHash, dynOK := h.dynamicAuth[name]
- h.mu.Unlock()
- if dynOK && wantHash != "" {
- sum := sha256.Sum256([]byte(token))
- gotHash := hex.EncodeToString(sum[:])
- authOK = subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) == 1
- viaDynamic = authOK
- }
+ _, viaDynamic = h.VerifyDynamic(name, token)
+ authOK = viaDynamic
}
if !authOK {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -253,6 +246,42 @@ func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dy
}
}
+// VerifyDynamic reports whether (name, token) authenticates as a dynamic
+// (registry-backed fleet) channel, and which fleet owns it. It is the single
+// answer to that question: ServeHTTP's dynamic branch is one caller, and
+// surfaces with no ResponseWriter to 401 with are the others.
+//
+// The "/" requirement is a gate, not a parse convenience: static channel names
+// are ValidTunnelName-checked and never contain "/", so refusing slash-less
+// names here means a static client's plaintext token can never be admitted by
+// this path, whatever h.clients (or a hand-built dynamicAuth) holds. An empty
+// fleet segment ("/box-a") is refused for the same reason: it would otherwise
+// authenticate into fleet "", a name no real fleet has, and every ownership
+// check downstream compares against the fleet this returns.
+//
+// The presented token is never compared in the clear — only its sha256 hex is,
+// against the stored hash — so a leaked dump of this table yields no usable
+// bearer token, and the compare is constant-time so a wrong token leaks no
+// prefix information by timing.
+func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool) {
+ fleet, _, hasSlash := strings.Cut(name, "/")
+ if !hasSlash || fleet == "" {
+ return "", false
+ }
+ h.mu.Lock()
+ wantHash, known := h.dynamicAuth[name]
+ h.mu.Unlock()
+ if !known || wantHash == "" {
+ return "", false
+ }
+ sum := sha256.Sum256([]byte(token))
+ gotHash := hex.EncodeToString(sum[:])
+ if subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) != 1 {
+ return "", false
+ }
+ return fleet, true
+}
+
// SetDynamicAuth replaces the entire registry-backed credential set —
// channel "<fleet>/<label>" -> sha256 hex of its bearer token — with auth.
// The whole-map REPLACE (not an incremental merge) is what makes a NEW dial
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go
index 3f7b278c7..4ba3c095a 100644
--- a/internal/edge/tunnel_test.go
+++ b/internal/edge/tunnel_test.go
@@ -197,6 +197,72 @@ func TestStaticClientStillAuthenticatesAlongsideDynamicSet(t *testing.T) {
}
}
+// TestVerifyDynamic covers the extracted gate directly, without a handshake:
+// VerifyDynamic is the one place that answers "does (name, token) authenticate
+// as a fleet channel, and whose?" — ServeHTTP's dynamic branch is now just a
+// caller, and later fleet-API surfaces (which have no ResponseWriter to 401
+// with) need the same answer.
+func TestVerifyDynamic(t *testing.T) {
+ // The static client is configured on the SAME hub on purpose: a static
+ // name must never authenticate through this gate, even presenting the
+ // correct plaintext token h.clients holds for it. ServeHTTP's static
+ // compare is the only path that may admit it.
+ hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"})
+ hub.SetDynamicAuth(map[string]string{"pi/box-a": sha256Hex("tok")})
+ for _, tc := range []struct {
+ desc, name, token string
+ wantFleet string
+ wantOK bool
+ }{
+ {desc: "static name with its own correct plaintext token", name: "hq", token: "sekrit"},
+ {desc: "unknown dynamic name", name: "pi/box-z", token: "tok"},
+ {desc: "right name, wrong token", name: "pi/box-a", token: "wrong"},
+ {desc: "right name and token", name: "pi/box-a", token: "tok", wantFleet: "pi", wantOK: true},
+ } {
+ fleet, ok := hub.VerifyDynamic(tc.name, tc.token)
+ if ok != tc.wantOK || fleet != tc.wantFleet {
+ t.Errorf("%s: VerifyDynamic(%q, %q) = (%q, %v), want (%q, %v)",
+ tc.desc, tc.name, tc.token, fleet, ok, tc.wantFleet, tc.wantOK)
+ }
+ }
+ // A SetDynamicAuth swap that drops the name must refuse it the instant it
+ // returns — the same whole-map-replace property detach relies on.
+ hub.SetDynamicAuth(map[string]string{"pi/box-b": sha256Hex("tok")})
+ if fleet, ok := hub.VerifyDynamic("pi/box-a", "tok"); ok || fleet != "" {
+ t.Errorf("after a SetDynamicAuth swap dropping the name: got (%q, %v), want (\"\", false)", fleet, ok)
+ }
+}
+
+// TestVerifyDynamicRefusesSlashlessNameInDynamicAuth is the one case only the
+// "/" gate can refuse, and so the only test that actually pins it: the name is
+// PRESENT in dynamicAuth under a hash that matches the presented token, so
+// every other check in VerifyDynamic passes. Delete the gate and this test
+// fails; the table above would not, because "hq" simply misses that map.
+//
+// It makes the doc comment's claim real: no slash-less name authenticates
+// here, whatever dynamicAuth holds. Production never writes such a key
+// (FleetChannelAuth emits "<fleet>/<label>" only) — the point is that a future
+// writer that did could not silently turn a static channel name, or a name
+// with no fleet at all, into a fleet-authenticated one.
+func TestVerifyDynamicRefusesSlashlessNameInDynamicAuth(t *testing.T) {
+ hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"})
+ hub.SetDynamicAuth(map[string]string{
+ "hq": sha256Hex("sekrit"), // a static channel name, hash-correct
+ "/box-a": sha256Hex("tok"), // empty fleet segment, hash-correct
+ "noslash": sha256Hex("tok"),
+ })
+ for _, tc := range []struct{ desc, name, token string }{
+ {"static name, hash-correct, present in dynamicAuth", "hq", "sekrit"},
+ {"empty fleet segment", "/box-a", "tok"},
+ {"bare name with no separator", "noslash", "tok"},
+ } {
+ if fleet, ok := hub.VerifyDynamic(tc.name, tc.token); ok || fleet != "" {
+ t.Errorf("%s: VerifyDynamic(%q, %q) = (%q, %v), want (\"\", false)",
+ tc.desc, tc.name, tc.token, fleet, ok)
+ }
+ }
+}
+
// TestCloseChannelClosesAndRemovesLiveConn: CloseChannel is the detach/rotate
// primitive — force-close a live conn and drop it from the map. No real h2
// handshake needed: CloseChannel only ever touches .raw.
diff --git a/internal/fleetapi/fleetapi.go b/internal/fleetapi/fleetapi.go
new file mode 100644
index 000000000..73f1e0402
--- /dev/null
+++ b/internal/fleetapi/fleetapi.go
@@ -0,0 +1,254 @@
+// Package fleetapi is the client half of the fleet store door (spec
+// 2026-08-11-fleet-api-surface-design.md, F5; Task 2's server is
+// internal/edge/storedoor.go). It implements release.Store over the five
+// HTTPS operations the door serves on the edge's reserved tunnel hostname —
+// GET/PUT/DELETE /store/o/<key>, GET /store/list, GET /store/dirs — using the
+// same two headers a tunnel dial already carries: "Authorization: Bearer
+// <token>" and "Carlos-Tunnel-Name: <fleet>/<label>". A box that builds one
+// of these needs no AWS credentials on the platform's deployment bucket at
+// all: every loop that already speaks release.Store — adoption, fast
+// convergence, config, status, metrics, bindings — runs unchanged against it.
+package fleetapi
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// callTimeout bounds connect, TLS handshake, and the wait for the response
+// headers to start arriving — the "is the edge even alive and answering"
+// phase of a call. It deliberately does NOT bound the rest of the exchange
+// (reading/writing the body): Get is the artifact-download path (release
+// adoption fetches whole app.tar.gz bodies through this store), and this
+// spec's own target box class — home broadband, a Pi's uplink — can take
+// well over 30s to pull a large artifact while still making steady
+// progress. Putting this on http.Client.Timeout instead (a whole-exchange
+// wall clock) would fail every such transfer deterministically, on every
+// adoption pass, forever; S3Store has no equivalent cap, so this store must
+// not invent a stricter one. A wedged edge that never starts answering still
+// fails fast — that's what ResponseHeaderTimeout below is for.
+const callTimeout = 30 * time.Second
+
+// store is the client half of the door: release.Store spoken over base's
+// /store/ routes, authenticated by name/token on every request.
+type store struct {
+ base string // origin, no trailing slash — e.g. "https://tunnel.example.com"
+ name string // Carlos-Tunnel-Name: "<fleet>/<label>"
+ token string // bearer token; NEVER placed in an error string (see errorf)
+ client *http.Client
+}
+
+// New builds a release.Store speaking the edge's store door at base
+// (CARLOS_TUNNEL_URL's origin) as the fleet channel identity name/token (the
+// box's CARLOS_TUNNEL_NAME/_TOKEN). All three are required — there is no
+// meaningful partial identity to fall back to, and a caller that got one of
+// them empty has a config bug worth failing on at construction rather than
+// on the first call.
+func New(base, name, token string) (release.Store, error) {
+ if base == "" {
+ return nil, errors.New("fleetapi: base is required")
+ }
+ if name == "" {
+ return nil, errors.New("fleetapi: name is required")
+ }
+ if token == "" {
+ return nil, errors.New("fleetapi: token is required")
+ }
+ u, err := url.Parse(base)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return nil, fmt.Errorf("fleetapi: invalid base %q: want an absolute URL", base)
+ }
+ return &store{
+ base: strings.TrimSuffix(base, "/"),
+ name: name,
+ token: token,
+ // No Client.Timeout (see callTimeout's comment): only the connect/TLS/
+ // response-header phase is bounded, so a slow-but-progressing artifact
+ // transfer can still finish.
+ client: &http.Client{Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{
+ Timeout: callTimeout,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ TLSHandshakeTimeout: callTimeout,
+ ResponseHeaderTimeout: callTimeout,
+ ExpectContinueTimeout: 1 * time.Second,
+ }},
+ }, nil
+}
+
+// errorf builds a client-side error. Every call site here formats around
+// the key/prefix and the response shape only — never s.token — so a leaked
+// error string (a log line, a status page) can never hand back the bearer
+// credential this store authenticates with.
+func errorf(format string, args ...any) error {
+ return fmt.Errorf("fleetapi: "+format, args...)
+}
+
+// objectURL builds the /store/o/<key> URL, percent-encoding key the same way
+// any other path segment is encoded — the door decodes it back with
+// net/http's ordinary request parsing (storedoor.go's canonicalKey comment:
+// "already percent-decoded by net/http"), so this is the inverse of that,
+// not a second canonicalization.
+func (s *store) objectURL(key string) string {
+ u := url.URL{Path: "/store/o/" + key}
+ return s.base + u.EscapedPath()
+}
+
+func (s *store) listURL(path, prefix string) string {
+ return s.base + path + "?" + url.Values{"prefix": {prefix}}.Encode()
+}
+
+// newRequest attaches the two headers every door route requires (F1): the
+// same bearer token and channel name a tunnel dial sends.
+func (s *store) newRequest(ctx context.Context, method, rawURL string, body io.Reader) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
+ if err != nil {
+ return nil, errorf("build request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+s.token)
+ req.Header.Set("Carlos-Tunnel-Name", s.name)
+ return req, nil
+}
+
+// Get fetches key and its etag. A 404 — which the door hands back
+// byte-identically whether the key never existed, belongs to another
+// tenant, or the caller's credential is bad (spec: no response distinguishes
+// them) — is mapped uniformly onto release.Store's os.ErrNotExist contract:
+// this client cannot tell those apart either, by design, and must not
+// pretend otherwise.
+func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) {
+ req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, "", errorf("get %s: %w", key, err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", errorf("get %s: read body: %w", key, err)
+ }
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return body, resp.Header.Get("ETag"), nil
+ case http.StatusNotFound:
+ return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist)
+ default:
+ return nil, "", errorf("get %s: unexpected status %d", key, resp.StatusCode)
+ }
+}
+
+// Put mirrors the door's CAS mapping exactly (storedoor.go's
+// putPrecondition / F4): ifMatch == "" sends If-None-Match: * (create-only,
+// the contract's only form of an unconditional-looking write), a non-empty
+// ifMatch sends If-Match: <etag> (replace-only). There is no unconditional
+// Put on this wire, so this client never attempts one. A lost race (412)
+// maps back to release.ErrCASConflict — the same error a direct DirStore or
+// S3Store Put would return, so a heartbeat or binding-takeover CAS loop
+// behaves identically whether or not it is going through the door.
+func (s *store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ req, err := s.newRequest(ctx, http.MethodPut, s.objectURL(key), bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.ContentLength = int64(len(body))
+ if ifMatch == "" {
+ req.Header.Set("If-None-Match", "*")
+ } else {
+ req.Header.Set("If-Match", ifMatch)
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return "", errorf("put %s: %w", key, err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return resp.Header.Get("ETag"), nil
+ case http.StatusPreconditionFailed:
+ return "", fmt.Errorf("fleetapi: %s: %w", key, release.ErrCASConflict)
+ default:
+ return "", errorf("put %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody)))
+ }
+}
+
+// Delete is idempotent, matching release.Store's contract: the door
+// (storedoor.go) always answers 204 for an in-scope key, deleted or not,
+// which is where the idempotency lives — NOT in this client folding a 404
+// into success. A 404 here is only ever a refusal (bad token, bad name, an
+// out-of-scope key, a malformed key, a misrouted base): it is never "already
+// gone", because "already gone" is exactly the case the door answers 204.
+// Treating it as success would make a mis-credentialled client report every
+// delete as having worked.
+func (s *store) Delete(ctx context.Context, key string) error {
+ req, err := s.newRequest(ctx, http.MethodDelete, s.objectURL(key), nil)
+ if err != nil {
+ return err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return errorf("delete %s: %w", key, err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ if resp.StatusCode != http.StatusNoContent {
+ return errorf("delete %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody)))
+ }
+ return nil
+}
+
+// List is the door's /store/list — see listDecode. Note the spec's callout:
+// the door 404s a root listing (List("")) the same as any other out-of-scope
+// prefix (aclListScope refuses an empty prefix outright), and this client
+// does not special-case it: an empty-prefix caller gets exactly that error,
+// same as any other caller would over a direct S3Store asking for a prefix
+// its bucket policy refuses.
+func (s *store) List(ctx context.Context, prefix string) ([]string, error) {
+ return s.listDecode(ctx, "/store/list", prefix)
+}
+
+// ListDirs is the door's /store/dirs (the delimiter-aware listing).
+func (s *store) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ return s.listDecode(ctx, "/store/dirs", prefix)
+}
+
+func (s *store) listDecode(ctx context.Context, path, prefix string) ([]string, error) {
+ req, err := s.newRequest(ctx, http.MethodGet, s.listURL(path, prefix), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, errorf("list %s: %w", prefix, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, errorf("list %s: unexpected status %d: %s", prefix, resp.StatusCode, strings.TrimSpace(string(respBody)))
+ }
+ // The door never sends null (storedoor.go: "an empty listing is []"), but
+ // decoding into a nil slice handles either shape the same way a caller
+ // ranging over the result cares about: zero iterations.
+ var out []string
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, errorf("list %s: decode response: %w", prefix, err)
+ }
+ return out, nil
+}
diff --git a/internal/fleetapi/fleetapi_internal_test.go b/internal/fleetapi/fleetapi_internal_test.go
new file mode 100644
index 000000000..ea1dada3e
--- /dev/null
+++ b/internal/fleetapi/fleetapi_internal_test.go
@@ -0,0 +1,39 @@
+package fleetapi
+
+// White-box (package fleetapi, not fleetapi_test): pins the transport
+// construction New() does, which no exported surface exposes — C2's fix
+// shape (a Transport with dial/TLS/response-header timeouts and no
+// Client.Timeout) is otherwise invisible to a caller until an actual slow
+// transfer either survives or doesn't.
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestClientHasNoWholeExchangeTimeoutButBoundsResponseHeaders(t *testing.T) {
+ s, err := New("https://tunnel.example.com", "pi/box-a", "tok")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ st, ok := s.(*store)
+ if !ok {
+ t.Fatalf("New returned %T, want *store", s)
+ }
+ if st.client.Timeout != 0 {
+ t.Errorf("client.Timeout = %v, want 0 (a whole-exchange deadline would fail slow-but-progressing artifact downloads)", st.client.Timeout)
+ }
+ tr, ok := st.client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("client.Transport = %T, want *http.Transport", st.client.Transport)
+ }
+ if tr.ResponseHeaderTimeout != callTimeout {
+ t.Errorf("ResponseHeaderTimeout = %v, want %v (a wedged edge must still fail fast)", tr.ResponseHeaderTimeout, callTimeout)
+ }
+ if tr.TLSHandshakeTimeout != callTimeout {
+ t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, callTimeout)
+ }
+ if tr.DialContext == nil {
+ t.Error("DialContext = nil, want a dialer with a connect timeout set")
+ }
+}
diff --git a/internal/fleetapi/fleetapi_test.go b/internal/fleetapi/fleetapi_test.go
new file mode 100644
index 000000000..4956c9b18
--- /dev/null
+++ b/internal/fleetapi/fleetapi_test.go
@@ -0,0 +1,366 @@
+package fleetapi_test
+
+// Round-trip tests against the REAL store door (internal/edge/storedoor.go,
+// Task 2) over a real net.Listener + release.DirStore — no hand-rolled fake
+// speaks for the wire here. The door is reached by wiring a full edge.Edge
+// the same way production does (registry fleet/box rows, EnableStatic,
+// EnableTunnels, Reload) and pointing the hub's reserved hostname at the
+// listener's own address, so an ordinary HTTP client naturally sends the
+// Host header the door's routing (edge.go: r.Host == hub.Host(), port
+// stripped) requires — no Host-header override needed, the same shape a real
+// box dialing CARLOS_TUNNEL_URL would produce.
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/fleetapi"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ testSqid = "ab12"
+ testFleet = "pi"
+ testLabel = "box-a"
+ testToken = "box-a-bearer-token-abc123"
+)
+
+func sha256Hex(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
+
+// testDoor wires a real edge.Edge with the store door armed, over a plain
+// net.Listener whose bound IP becomes the hub's reserved hostname — the
+// production shape (EnableStatic + EnableTunnels + Reload), minus TLS, which
+// the door itself has no opinion about (edge.go terminates that separately).
+type testDoor struct {
+ base string
+ store *release.DirStore
+}
+
+func newTestDoor(t *testing.T) testDoor {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("registry.Open: %v", err)
+ }
+ if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil {
+ t.Fatalf("UpsertFleet: %v", err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{
+ Fleet: testFleet, Label: testLabel, TokenHash: sha256Hex(testToken), Status: "active",
+ }); err != nil {
+ t.Fatalf("AddFleetBox: %v", err)
+ }
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ host := ln.Addr().(*net.TCPAddr).IP.String() // "127.0.0.1" — edge.go strips the port before comparing
+
+ e, err := edge.New(reg, nil)
+ if err != nil {
+ t.Fatalf("edge.New: %v", err)
+ }
+ dirStore := release.NewDirStore(t.TempDir())
+ e.EnableStatic(dirStore, nil, filepath.Join(t.TempDir(), "cache"))
+ e.EnableTunnels(edge.NewTunnelHub(host, nil))
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+
+ srv := &http.Server{Handler: e}
+ go srv.Serve(ln) //nolint:errcheck
+ t.Cleanup(func() { srv.Close() })
+
+ return testDoor{base: "http://" + ln.Addr().String(), store: dirStore}
+}
+
+func (d testDoor) client(t *testing.T, name, token string) release.Store {
+ t.Helper()
+ s, err := fleetapi.New(d.base, name, token)
+ if err != nil {
+ t.Fatalf("fleetapi.New: %v", err)
+ }
+ return s
+}
+
+func (d testDoor) box(t *testing.T) release.Store {
+ return d.client(t, testFleet+"/"+testLabel, testToken)
+}
+
+func seed(t *testing.T, s *release.DirStore, key, body string) string {
+ t.Helper()
+ etag, err := s.Put(context.Background(), key, []byte(body), "")
+ if err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+ return etag
+}
+
+// ---- New() argument validation --------------------------------------------
+
+func TestNewRefusesEmptyArgs(t *testing.T) {
+ cases := []struct{ base, name, token string }{
+ {"", "pi/box-a", "tok"},
+ {"https://tunnel.example.com", "", "tok"},
+ {"https://tunnel.example.com", "pi/box-a", ""},
+ {"not-a-url", "pi/box-a", "tok"},
+ }
+ for _, c := range cases {
+ if s, err := fleetapi.New(c.base, c.name, c.token); err == nil {
+ t.Errorf("New(%q, %q, %q) = %v, nil; want an error", c.base, c.name, c.token, s)
+ }
+ }
+}
+
+// ---- Get / Put CAS round trip ----------------------------------------------
+
+func TestGetPutCASRoundTrip(t *testing.T) {
+ d := newTestDoor(t)
+ s := d.box(t)
+ ctx := context.Background()
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+
+ // Create-only (ifMatch == "" -> If-None-Match: *).
+ etag1, err := s.Put(ctx, key, []byte(`{"n":1}`), "")
+ if err != nil {
+ t.Fatalf("create-only Put: %v", err)
+ }
+ if etag1 == "" {
+ t.Fatal("create-only Put returned no etag")
+ }
+
+ // Round-trip Get.
+ body, gotEtag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if string(body) != `{"n":1}` {
+ t.Errorf("body = %q, want the stored object", body)
+ }
+ if gotEtag != etag1 {
+ t.Errorf("etag = %q, want %q (etags are opaque, passed through unmodified)", gotEtag, etag1)
+ }
+
+ // Create-only again over an existing object: CAS conflict.
+ if _, err := s.Put(ctx, key, []byte("again"), ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Errorf("create-only Put over existing object: err = %v, want release.ErrCASConflict", err)
+ }
+
+ // Replace with a stale etag: CAS conflict.
+ if _, err := s.Put(ctx, key, []byte("stale"), "0000000000000000000000000000000000000000000000000000000000000000"); !errors.Is(err, release.ErrCASConflict) {
+ t.Errorf("replace with stale etag: err = %v, want release.ErrCASConflict", err)
+ }
+
+ // Replace with the fresh etag: succeeds, new etag differs.
+ etag2, err := s.Put(ctx, key, []byte(`{"n":2}`), gotEtag)
+ if err != nil {
+ t.Fatalf("replace Put: %v", err)
+ }
+ if etag2 == "" || etag2 == etag1 {
+ t.Errorf("replace etag = %q, want a new etag (was %q)", etag2, etag1)
+ }
+ body, _, err = s.Get(ctx, key)
+ if err != nil || string(body) != `{"n":2}` {
+ t.Fatalf("stored body = %q, %v; want {\"n\":2}", body, err)
+ }
+}
+
+// ---- Get missing --------------------------------------------------------
+
+func TestGetMissingIsNotExist(t *testing.T) {
+ d := newTestDoor(t)
+ s := d.box(t)
+ _, _, err := s.Get(context.Background(), "fleets/"+testFleet+"/status/boxes/nosuchbox.json")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get missing in-scope key: err = %v, want errors.Is(err, os.ErrNotExist)", err)
+ }
+}
+
+// ---- List / ListDirs ------------------------------------------------------
+
+func TestListAndListDirsRoundTrip(t *testing.T) {
+ d := newTestDoor(t)
+ seed(t, d.store, "apps/"+testSqid+"/web/channels/stable.json", "{}")
+ seed(t, d.store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}")
+ seed(t, d.store, "apps/zz99/web/channels/stable.json", "someone else's")
+ s := d.box(t)
+ ctx := context.Background()
+
+ keys, err := s.List(ctx, "apps/")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ want := []string{"apps/" + testSqid + "/web/channels/stable.json", "apps/" + testSqid + "/web/releases/v1/manifest.json"}
+ if !equalUnordered(keys, want) {
+ t.Errorf("List(apps/) = %v, want %v (own account only)", keys, want)
+ }
+
+ dirs, err := s.ListDirs(ctx, "apps/")
+ if err != nil {
+ t.Fatalf("ListDirs: %v", err)
+ }
+ if !equalUnordered(dirs, []string{"apps/" + testSqid + "/web/"}) {
+ t.Errorf("ListDirs(apps/) = %v, want [apps/%s/web/]", dirs, testSqid)
+ }
+
+ // An empty listing is [] on the wire, never null, and this client must
+ // decode that into a zero-length (possibly nil) slice a caller can range
+ // over without a nil check.
+ empty, err := s.List(ctx, "apps/"+testSqid+"/web/nosuchapp/")
+ if err != nil {
+ t.Fatalf("List (empty): %v", err)
+ }
+ if len(empty) != 0 {
+ t.Errorf("List of an empty prefix = %v, want empty", empty)
+ }
+
+ // The door 404s a root listing (aclListScope refuses prefix == "") —
+ // this client does not special-case it, so List("") surfaces as a plain
+ // error, not success and not the not-found mapping Get uses.
+ if _, err := s.List(ctx, ""); err == nil {
+ t.Error("List(\"\") = nil error, want the door's refusal to surface")
+ } else if errors.Is(err, os.ErrNotExist) {
+ t.Errorf("List(\"\") = %v; List has no not-found mapping (only Get does)", err)
+ }
+}
+
+func equalUnordered(got, want []string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ seen := map[string]int{}
+ for _, g := range got {
+ seen[g]++
+ }
+ for _, w := range want {
+ if seen[w] == 0 {
+ return false
+ }
+ seen[w]--
+ }
+ return true
+}
+
+// ---- Delete -----------------------------------------------------------
+
+func TestDeleteIsIdempotent(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/metrics/boxes/" + testLabel + "/raw/1699.json"
+ seed(t, d.store, key, "{}")
+ s := d.box(t)
+ ctx := context.Background()
+
+ for i := 0; i < 2; i++ {
+ if err := s.Delete(ctx, key); err != nil {
+ t.Fatalf("Delete (attempt %d): %v", i+1, err)
+ }
+ }
+ if _, _, err := s.Get(ctx, key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get after Delete: err = %v, want os.ErrNotExist", err)
+ }
+}
+
+// TestDeleteWithWrongCredentialErrors pins C1: a 404 on DELETE is only ever
+// a refusal (the door answers 204 for every in-scope key, present or
+// absent — that's where Delete's idempotency lives). A mis-credentialled
+// client must see an error, not silent "success" for a delete that never
+// happened.
+func TestDeleteWithWrongCredentialErrors(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+ seed(t, d.store, key, "{}")
+
+ wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
+ if err := wrongToken.Delete(context.Background(), key); err == nil {
+ t.Fatal("Delete with wrong token = nil error, want an error (the key must still exist)")
+ }
+ if _, _, err := d.box(t).Get(context.Background(), key); err != nil {
+ t.Fatalf("the refused Delete removed the object anyway: %v", err)
+ }
+
+ otherAccountKey := "apps/zz99/web/channels/stable.json"
+ seed(t, d.store, otherAccountKey, "someone else's")
+ if err := d.box(t).Delete(context.Background(), otherAccountKey); err == nil {
+ t.Fatal("Delete of another tenant's key = nil error, want an error")
+ }
+ if _, _, err := d.store.Get(context.Background(), otherAccountKey); err != nil {
+ t.Fatalf("the refused Delete removed another tenant's object anyway: %v", err)
+ }
+}
+
+// ---- Auth failure is opaque, matching the door -----------------------
+
+// TestAuthFailureSurfacesAsNotFound pins the door's central opacity
+// invariant on the client side: a bad token answers 404, indistinguishable
+// from a missing key, so this client's Get must map it the same way — never
+// a distinct "auth failed" error a caller could branch on to learn it has a
+// bad credential versus a bad key.
+func TestAuthFailureSurfacesAsNotFound(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+ seed(t, d.store, key, "{}")
+
+ wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
+ if _, _, err := wrongToken.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get with wrong token: err = %v, want os.ErrNotExist (opaque, like a missing key)", err)
+ }
+
+ unknownFleet := d.client(t, "nosuch/box", testToken)
+ if _, _, err := unknownFleet.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get from an unregistered fleet: err = %v, want os.ErrNotExist", err)
+ }
+}
+
+// ---- token never leaks into an error string ----------------------------
+
+// TestTokenNeverInErrorStrings exercises every failure path this client can
+// produce against a live door and checks none of them echo the bearer
+// token — the credential itself, not just its hash — into the returned
+// error's text.
+func TestTokenNeverInErrorStrings(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+ seed(t, d.store, key, "{}")
+ ctx := context.Background()
+
+ wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
+ _, _, getErr := wrongToken.Get(ctx, key)
+
+ s := d.box(t)
+ _, putErr := s.Put(ctx, key, []byte("x"), "") // CAS conflict: already exists
+ _, _, missingErr := s.Get(ctx, "fleets/"+testFleet+"/status/boxes/nosuchbox.json")
+ _, listErr := s.List(ctx, "")
+
+ for name, err := range map[string]error{
+ "wrong-token get": getErr,
+ "cas conflict put": putErr,
+ "missing get": missingErr,
+ "refused list": listErr,
+ } {
+ if err == nil {
+ t.Errorf("%s: no error to check (expected one)", name)
+ continue
+ }
+ if strings.Contains(err.Error(), testToken) {
+ t.Errorf("%s: error %q contains the bearer token", name, err.Error())
+ }
+ if strings.Contains(err.Error(), "not-the-real-token") {
+ t.Errorf("%s: error %q contains the wrong-token value", name, err.Error())
+ }
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 231e0a1e7..23fb23f62 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -40,7 +40,11 @@
// instead of the shared paths every platform box also writes — so
// neither its status heartbeat nor its metrics/alerts ever collide with
// (or leak into) the platform's own fleet.json, metrics view, or alert
-// webhook. Empty (every platform box today) is a pure passthrough. A
+// webhook. Empty (every platform box today) is a pure passthrough —
+// EXCEPT on a door-backed fleet box, where empty defaults to
+// "fleets/<fleet>/" because the door admits no other write scope
+// (resolveStatusPrefix). Log shipping is deliberately NOT rebased: see
+// logship.go. A
// PUBLISHING box with a prefix set would relocate status/fleet.json
// itself under that prefix too — moot today since nothing sets a prefix
// on a publishing (non-fleet) box, and a fleet box never publishes at
@@ -68,6 +72,19 @@
// — an invalid result refuses to arm the wake-time/startup binding
// publish (fleetbind.go) loudly, and degrades the heartbeat's label back
// to the hashed pseudonym rather than disabling it outright.
+// - CARLOS_TUNNEL_URL / CARLOS_TUNNEL_TOKEN — the tunnel env
+// tunnel_client.go already dials with, read a SECOND time here (spec
+// 2026-08-11-fleet-api-surface, F5) as the fleet store door's address
+// and credential: on a fleet box with NO bucket env set, openDeployStore
+// and the status/metrics store both become internal/fleetapi clients
+// speaking release.Store over the edge's /store/ routes as
+// "<fleet>/<label>", so the box needs no AWS credentials on the
+// platform's deployment bucket at all. A bucket still set WINS
+// everywhere (the migration fallback): every platform box, and any
+// grandfathered fleet box that still carries bucket credentials, keeps
+// direct S3 byte for byte. Missing tunnel env on a fleet box with no
+// bucket is a loud error, not a silent degrade — that box has no way to
+// reach a store at all.
// - STATUS_BOX_REGION — an operator-set, coarse region word (e.g.
// "EU · Ireland") carried into status.Probes.Region and published
// verbatim on this box's snapshot; "" omits the field. Deliberately
@@ -128,6 +145,7 @@ import (
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/fleetapi"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -173,11 +191,18 @@ func RunAgent(args []string) {
cgm := cgroup.Setup(log.Printf)
// Log shipping (`carlos logs`): one sink for every producer in this
- // process, armed only when a bucket exists to ship to — a nil sink
+ // process, armed only when there is somewhere to ship to — a nil sink
// leaves every producer exactly as before (no tees, no pipes, no
// events). The box identity is the one metrics publishes under.
+ //
+ // The gate is openStatusStore, not a bucket test: a cut-over fleet box has
+ // no bucket at all and ships through the store door, and gating on the
+ // bucket alone would leave `carlos logs` silently empty for exactly the
+ // boxes an operator can't SSH into. Constructing the store here to throw
+ // it away is deliberate and cheap (neither branch makes a call), and it
+ // asks precisely the question logshipLoop will ask again for itself.
var logSink *applog.Sink
- if resolveStatusBucket(os.Getenv) != "" {
+ if _, _, err := openStatusStore(context.Background(), os.Getenv); err == nil {
logSink = applog.NewSink(status.Label(readMachineID()), time.Now)
}
@@ -1458,6 +1483,19 @@ func instancesLoop(reg *registry.Registry) {
// box that cannot verify pointers must not delete anything.
func retentionLoop(reg *registry.Registry) {
ctx := context.Background()
+ // Platform boxes only (spec F6). Retention prunes the PLATFORM's release
+ // objects — a customer's box scheduling deletes of them was already wrong
+ // under direct S3, and through the store door it is refused outright (the
+ // door's write/delete ACL scopes a fleet to fleets/<fleet>/**), so arming
+ // here would buy nothing but an hourly loop of 404s. Reuses
+ // shouldSkipPublisherElection because the question is exactly "is this a
+ // fleet box", the same one instancesLoop asks — and it is deliberately the
+ // bare CARLOS_FLEET test, not resolveFleetIdentity: a fleet box whose
+ // LABEL is misconfigured is still a fleet box, and must still not prune.
+ if shouldSkipPublisherElection(os.Getenv) {
+ log.Printf("agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)")
+ return
+ }
store, err := openDeployStore(ctx)
if err != nil {
log.Printf("agent: retention loop disabled: %v", err)
@@ -1585,12 +1623,13 @@ func shouldSkipPublisherElection(getenv func(string) string) bool {
// other tick failure.
func statusLoop(reg *registry.Registry) {
ctx := context.Background()
+ // bucket is read for the lease key alone (lease.Acquire below), which a
+ // fleet box never reaches: shouldSkipPublisherElection short-circuits the
+ // election before it, and a fleet box is the only shape that gets a store
+ // with no bucket behind it (openStatusStore). Every other box resolves a
+ // real bucket here exactly as before.
bucket := resolveStatusBucket(os.Getenv)
- if bucket == "" {
- log.Printf("agent: status loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set (CARLOS_DEPLOYMENT_DIR deployments don't publish status yet — set CARLOS_STATUS_BUCKET explicitly)")
- return
- }
- prefix := os.Getenv("CARLOS_STATUS_PREFIX")
+ prefix := resolveStatusPrefix(os.Getenv)
region := os.Getenv("CARLOS_STATUS_REGION")
boxRegion := os.Getenv("STATUS_BOX_REGION")
skipElection := shouldSkipPublisherElection(os.Getenv)
@@ -1615,9 +1654,13 @@ func statusLoop(reg *registry.Registry) {
log.Printf("agent: %v — heartbeat will publish under the hashed pseudonym instead", err)
}
- s, err := release.NewS3Store(ctx, bucket)
+ s, target, err := openStatusStore(ctx, os.Getenv)
if err != nil {
- log.Printf("agent: status loop disabled: %v", err)
+ if errors.Is(err, errNoStatusStore) {
+ log.Printf("agent: status loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set (CARLOS_DEPLOYMENT_DIR deployments don't publish status yet — set CARLOS_STATUS_BUCKET explicitly)")
+ } else {
+ log.Printf("agent: status loop disabled: %v", err)
+ }
return
}
// store is what statusTick actually reads/writes through: identical to s
@@ -1626,12 +1669,13 @@ func statusLoop(reg *registry.Registry) {
// release.NewPrefixStore and the package doc comment's
// CARLOS_STATUS_PREFIX entry.
store := release.NewPrefixStore(s, prefix)
- // The effective bucket is worth a log line on its own: it's easy to set
- // CARLOS_DEPLOYMENT_BUCKET and CARLOS_STATUS_BUCKET to different values
- // by accident (or on purpose) and end up with the agent writing status
- // to a bucket the edge/console never look at — this line is the one
- // place an operator can confirm which bucket actually won.
- log.Printf("agent: status loop armed (%s + jitter), publishing to bucket %s", status.SnapshotPeriod, bucket)
+ // The effective destination is worth a log line on its own: it's easy to
+ // set CARLOS_DEPLOYMENT_BUCKET and CARLOS_STATUS_BUCKET to different
+ // values by accident (or on purpose) and end up with the agent writing
+ // status to a bucket the edge/console never look at — this line is the
+ // one place an operator can confirm which bucket (or, on a bucketless
+ // fleet box, which door identity) actually won.
+ log.Printf("agent: status loop armed (%s + jitter), publishing to %s", status.SnapshotPeriod, target)
// canaryStore/canaryPub resolve the release deployment config
// countExpiredCanaries needs to answer §5's "how many canaries on this
@@ -1645,7 +1689,16 @@ func statusLoop(reg *registry.Registry) {
// nil, same as every other unmeasured probe in this loop. Resolved once
// here, not re-read every tick, mirroring adoptLoop's own one-time
// resolution.
- canaryStore, _ := openDeployStore(ctx)
+ //
+ // The error is logged rather than swallowed: "no deployment store" is a
+ // valid box shape, but since the store can now also fail to build for a
+ // REAL misconfiguration (a fleet box with no bucket and no tunnel env —
+ // openFleetDoorStore's loud error), silence here would turn that into an
+ // unexplained permanently-nil ExpiredCanaries probe.
+ canaryStore, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: status loop: expired-canary probe unavailable: %v", err)
+ }
canaryPub, _ := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
// status.SnapshotPeriod IS this cadence — the const is named for the tick
@@ -1956,19 +2009,142 @@ func bounceFn(svc *activator.Service) func(host string) bool {
}
// openDeployStore opens the release store the box adopts from:
-// CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (a directory —
-// tests and single-box no-AWS deployments). Mirrors cli.go's openStore, but
-// returns the error instead of dying: the agent degrades, the CLI exits.
+// CARLOS_DEPLOYMENT_BUCKET (S3), CARLOS_DEPLOYMENT_DIR (a directory — tests
+// and single-box no-AWS deployments), or — on a fleet box with neither set —
+// the edge's store door over the tunnel env (spec F5, openFleetDoorStore).
+// Mirrors cli.go's openStore, but returns the error instead of dying: the
+// agent degrades, the CLI exits.
func openDeployStore(ctx context.Context) (release.Store, error) {
- if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
+ return openDeployStoreEnv(ctx, os.Getenv)
+}
+
+// openDeployStoreEnv is openDeployStore with the environment injected — the
+// same getenv seam resolveStatusBucket already uses in this file, so the
+// selection rule can be tested without mutating process-wide env.
+//
+// Order is the whole contract (spec F5): a bucket or dir still set WINS,
+// before fleet identity is even consulted, so every platform box and every
+// grandfathered fleet box that still has bucket credentials keeps exactly
+// today's store, byte for byte. Only a box that resolves a fleet identity
+// AND has no bucket/dir at all reaches the door — that is the cutover
+// switch, per box, no flag day: drop the bucket var and the box is on the
+// API; put it back and it is off.
+func openDeployStoreEnv(ctx context.Context, getenv func(string) string) (release.Store, error) {
+ if b := getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
return release.NewS3Store(ctx, b)
}
- if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
+ if d := getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
return release.NewDirStore(d), nil
}
+ identity, err := resolveFleetIdentity(getenv)
+ switch {
+ case err == nil:
+ return openFleetDoorStore(getenv, identity)
+ case !errors.Is(err, errNotFleetBox):
+ // CARLOS_FLEET is set but the identity is unusable, and there is no
+ // bucket to fall back to: this box has no store at all. Return the
+ // identity error itself rather than the generic "no bucket" line —
+ // the actual fix is the label, and the generic message would send an
+ // operator hunting for a bucket var that is deliberately absent.
+ return nil, err
+ }
return nil, errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set")
}
+// openFleetDoorStore builds the fleetapi client a bucketless fleet box adopts
+// through: the edge origin it already tunnels to (CARLOS_TUNNEL_URL), its
+// channel identity ("<fleet>/<label>" — the same string tunnel_client.go
+// registers with and edge.fleetChannelFor dials), and the tunnel bearer token.
+//
+// Missing tunnel env here is an ERROR, never a silent no-store: the caller
+// established that this box is a fleet box with no bucket, so there is no
+// other store it could possibly be meant to use, and a quiet nil would leave
+// every loop in this file reporting "not configured" about a box that is in
+// fact configured — just with one variable missing.
+func openFleetDoorStore(getenv func(string) string, identity FleetIdentity) (release.Store, error) {
+ base := strings.TrimSpace(getenv("CARLOS_TUNNEL_URL"))
+ token := strings.TrimSpace(getenv("CARLOS_TUNNEL_TOKEN"))
+ name := identity.Fleet + "/" + identity.Label
+ if base == "" || token == "" {
+ return nil, fmt.Errorf("fleet box %s has no deployment bucket and cannot reach the store door: both CARLOS_TUNNEL_URL and CARLOS_TUNNEL_TOKEN must be set", name)
+ }
+ return fleetapi.New(base, name, token)
+}
+
+// resolveStatusPrefix returns the prefix every key statusLoop and metricsLoop
+// touch is rebased under (release.NewPrefixStore at both call sites).
+//
+// An explicit CARLOS_STATUS_PREFIX always wins, unchanged, on every box. What
+// this adds is the DOOR-branch default: a fleet box publishing through the
+// store door with an EMPTY prefix would otherwise write status/boxes/... and
+// metrics/boxes/... at the shared platform root, and the door's write ACL
+// admits only fleets/<own fleet>/** — every heartbeat and every flush would
+// 404, forever, on a box that looks correctly configured. So on that branch
+// alone the prefix defaults to "fleets/<fleet>/", which is exactly the layout
+// the console's fleet page already reads.
+//
+// S3-path semantics are deliberately untouched: an empty prefix on a
+// bucket-backed box is the platform box's shared root, which is correct
+// there, and a bucket-backed fleet box keeps whatever the operator set (the
+// customer-fleets rollout sets CARLOS_STATUS_PREFIX=fleets/<name>/ by hand).
+func resolveStatusPrefix(getenv func(string) string) string {
+ if p := getenv("CARLOS_STATUS_PREFIX"); p != "" {
+ return p
+ }
+ if resolveStatusBucket(getenv) != "" {
+ return ""
+ }
+ identity, err := resolveFleetIdentity(getenv)
+ if err != nil {
+ return ""
+ }
+ return "fleets/" + identity.Fleet + "/"
+}
+
+// errNoStatusStore is openStatusStore's "this deployment simply hasn't
+// configured one" sentinel — distinct from a real construction failure, so
+// each loop can keep its own long-standing disabled-log wording for the
+// unconfigured case while sharing one selection rule.
+var errNoStatusStore = errors.New("no status store configured")
+
+// openStatusStore resolves the store statusLoop and metricsLoop publish
+// through, plus a human phrase naming where that is for their armed log
+// lines. Same F5 rule as openDeployStoreEnv, read through
+// resolveStatusBucket: an explicit CARLOS_STATUS_BUCKET (else the
+// CARLOS_DEPLOYMENT_BUCKET fallback) WINS and keeps direct S3; only a fleet
+// box with neither set speaks the door.
+//
+// LEDGER NOTE (spec F5): the door serves the DEPLOYMENT store. A fleet box
+// that sets CARLOS_STATUS_BUCKET to some OTHER bucket therefore keeps direct
+// S3 for status/metrics while its releases may come through the door — that
+// is the bucket-set-wins rule doing its job, not a bug: an operator who named
+// a bucket explicitly gets that bucket.
+//
+// The CARLOS_STATUS_PREFIX rebase both loops apply (release.NewPrefixStore)
+// is deliberately NOT applied here — it stays at each call site, unchanged,
+// and wraps whichever store this returns.
+func openStatusStore(ctx context.Context, getenv func(string) string) (release.Store, string, error) {
+ if bucket := resolveStatusBucket(getenv); bucket != "" {
+ s, err := release.NewS3Store(ctx, bucket)
+ if err != nil {
+ return nil, "", err
+ }
+ return s, "bucket " + bucket, nil
+ }
+ identity, err := resolveFleetIdentity(getenv)
+ switch {
+ case err == nil:
+ s, derr := openFleetDoorStore(getenv, identity)
+ if derr != nil {
+ return nil, "", derr
+ }
+ return s, "the fleet store door as " + identity.Fleet + "/" + identity.Label, nil
+ case !errors.Is(err, errNotFleetBox):
+ return nil, "", err
+ }
+ return nil, "", errNoStatusStore
+}
+
// RunAdopt is the `carlos adopt` verb: one immediate adoption pass — the
// carlos-adopt.timer unit's ExecStart, and an operator's "converge now".
// Runs as root (see carlos-adopt.service): this is the ONLY place the
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go
index 682ae7181..19783582a 100644
--- a/internal/host/agent_test.go
+++ b/internal/host/agent_test.go
@@ -7,12 +7,15 @@ import (
"encoding/base64"
"errors"
"log"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
+ "sync"
"testing"
"time"
@@ -1199,6 +1202,425 @@ func TestFastSweepBouncesAWarmHibernatingCanary(t *testing.T) {
}
}
+// ---- store selection: bucket, dir, or the fleet store door (spec F5) -------
+
+// doorHit is one request the fake door saw, reduced to the parts these tests
+// reason about: which call it was, and the two headers that prove WHICH box
+// made it (fleetapi sends the tunnel token and channel name on every request).
+type doorHit struct{ Method, Path, Auth, Name string }
+
+// doorFake stands in for the edge's store door. It answers the single GET
+// these tests drive and records every request, so a test can assert both
+// halves of the F5 rule: that a bucketless fleet box's store really does
+// speak to the door under its channel identity, and — the regression pin that
+// matters more — that a bucket-configured box never sends it anything.
+type doorFake struct {
+ url string
+ mu sync.Mutex
+ hits []doorHit
+}
+
+func newDoorFake(t *testing.T) *doorFake {
+ t.Helper()
+ d := &doorFake{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ d.mu.Lock()
+ d.hits = append(d.hits, doorHit{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Carlos-Tunnel-Name")})
+ d.mu.Unlock()
+ if r.Method == http.MethodGet && r.URL.Path == "/store/o/control/serial" {
+ w.Header().Set("ETag", `"e1"`)
+ _, _ = w.Write([]byte("42"))
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ d.url = srv.URL
+ return d
+}
+
+func (d *doorFake) seen() []doorHit {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return append([]doorHit(nil), d.hits...)
+}
+
+// fleetDoorEnv is the env a fleet box that has been CUT OVER carries: fleet
+// identity plus tunnel address/credential, and deliberately no bucket and no
+// AWS credentials at all — the whole point of the door.
+func fleetDoorEnv(doorURL string) map[string]string {
+ return map[string]string{
+ "CARLOS_FLEET": "pi",
+ "CARLOS_FLEET_BOX": "pi-box",
+ "CARLOS_TUNNEL_URL": doorURL,
+ "CARLOS_TUNNEL_TOKEN": "tok-123",
+ }
+}
+
+// awsEnvForS3Store makes release.NewS3Store constructible in a test without
+// network or a developer's real profile — the same shape internal/release's
+// own S3 tests use. Nothing is ever called on the resulting client here; these
+// tests only assert which store TYPE the selection built.
+func awsEnvForS3Store(t *testing.T) {
+ t.Helper()
+ t.Setenv("AWS_ACCESS_KEY_ID", "k")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
+ t.Setenv("AWS_SESSION_TOKEN", "")
+ t.Setenv("AWS_REGION", "eu-west-1")
+ t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
+}
+
+// TestOpenDeployStoreSelection pins spec F5's whole rule for the store every
+// converge loop on the box reads through. The migration fallback is the
+// load-bearing half: a bucket or dir still set must win OUTRIGHT, before fleet
+// identity is even consulted, or rolling this branch out would silently move
+// every already-working fleet box onto a door it may not be able to reach.
+func TestOpenDeployStoreSelection(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+
+ t.Run("fleet box with no bucket speaks the door", func(t *testing.T) {
+ door := newDoorFake(t)
+ s, err := openDeployStoreEnv(ctx, envFunc(fleetDoorEnv(door.url)))
+ if err != nil {
+ t.Fatalf("openDeployStoreEnv: %v", err)
+ }
+ body, etag, err := s.Get(ctx, "control/serial")
+ if err != nil {
+ t.Fatalf("Get through the door: %v", err)
+ }
+ if string(body) != "42" || etag != `"e1"` {
+ t.Fatalf("Get = %q, %q; want \"42\", \"e1\"", body, etag)
+ }
+ hits := door.seen()
+ if len(hits) != 1 {
+ t.Fatalf("door saw %d requests, want 1: %+v", len(hits), hits)
+ }
+ if hits[0].Path != "/store/o/control/serial" {
+ t.Fatalf("door path = %q, want /store/o/control/serial", hits[0].Path)
+ }
+ // The identity on the wire is the channel identity the edge dials —
+ // "<fleet>/<label>", never the hashed status pseudonym.
+ if hits[0].Name != "pi/pi-box" {
+ t.Fatalf("Carlos-Tunnel-Name = %q, want pi/pi-box", hits[0].Name)
+ }
+ if hits[0].Auth != "Bearer tok-123" {
+ t.Fatalf("Authorization = %q, want the tunnel token", hits[0].Auth)
+ }
+ })
+
+ t.Run("dir set wins on a fleet box, door untouched", func(t *testing.T) {
+ door := newDoorFake(t)
+ env := fleetDoorEnv(door.url)
+ env["CARLOS_DEPLOYMENT_DIR"] = dir
+ s, err := openDeployStoreEnv(ctx, envFunc(env))
+ if err != nil {
+ t.Fatalf("openDeployStoreEnv: %v", err)
+ }
+ if _, ok := s.(*release.DirStore); !ok {
+ t.Fatalf("store = %T, want *release.DirStore — a configured dir must win", s)
+ }
+ if _, _, err := s.Get(ctx, "control/serial"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get = %v, want the dir store's not-exist", err)
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero requests — a dir-configured box must never reach it", hits)
+ }
+ })
+
+ t.Run("bucket set wins on a fleet box, door untouched", func(t *testing.T) {
+ awsEnvForS3Store(t)
+ door := newDoorFake(t)
+ env := fleetDoorEnv(door.url)
+ env["CARLOS_DEPLOYMENT_BUCKET"] = "carlos-deploy"
+ s, err := openDeployStoreEnv(ctx, envFunc(env))
+ if err != nil {
+ t.Fatalf("openDeployStoreEnv: %v", err)
+ }
+ if _, ok := s.(*release.S3Store); !ok {
+ t.Fatalf("store = %T, want *release.S3Store — a configured bucket must win", s)
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero requests — a bucket-configured box must never reach it", hits)
+ }
+ })
+
+ t.Run("platform box is unchanged", func(t *testing.T) {
+ s, err := openDeployStoreEnv(ctx, envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": dir}))
+ if err != nil {
+ t.Fatalf("openDeployStoreEnv: %v", err)
+ }
+ if _, ok := s.(*release.DirStore); !ok {
+ t.Fatalf("store = %T, want *release.DirStore", s)
+ }
+ _, err = openDeployStoreEnv(ctx, envFunc(map[string]string{}))
+ if err == nil || !strings.Contains(err.Error(), "neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") {
+ t.Fatalf("unconfigured platform box err = %v, want today's message verbatim", err)
+ }
+ })
+
+ t.Run("fleet box with no bucket and no tunnel env fails loudly", func(t *testing.T) {
+ for _, missing := range []string{"CARLOS_TUNNEL_URL", "CARLOS_TUNNEL_TOKEN"} {
+ env := fleetDoorEnv("https://tunnel.example.com")
+ delete(env, missing)
+ _, err := openDeployStoreEnv(ctx, envFunc(env))
+ if err == nil {
+ t.Fatalf("missing %s: want an error, got a store", missing)
+ }
+ if !strings.Contains(err.Error(), "pi/pi-box") || !strings.Contains(err.Error(), missing) {
+ t.Fatalf("missing %s: err = %v, want it to name the box and the variable", missing, err)
+ }
+ // The token must never be quoted back into an error a log line or
+ // a status page could carry.
+ if strings.Contains(err.Error(), "tok-123") {
+ t.Fatalf("err leaks the tunnel token: %v", err)
+ }
+ }
+ })
+
+ t.Run("unusable fleet identity with no bucket names the identity", func(t *testing.T) {
+ _, err := openDeployStoreEnv(ctx, envFunc(map[string]string{
+ "CARLOS_FLEET": "pi",
+ "CARLOS_TUNNEL_NAME": "legacy-box", // no "pi/" prefix: underivable label
+ "CARLOS_TUNNEL_URL": "https://tunnel.example.com",
+ "CARLOS_TUNNEL_TOKEN": "tok-123",
+ }))
+ if err == nil || !strings.Contains(err.Error(), "CARLOS_TUNNEL_NAME") {
+ t.Fatalf("err = %v, want the identity error, not the generic no-bucket line", err)
+ }
+ })
+}
+
+// TestOpenStatusStoreSelection pins the same rule for the status/metrics
+// store, including the ledger call it makes deliberately: the door serves the
+// DEPLOYMENT store, so a fleet box that names its own CARLOS_STATUS_BUCKET
+// keeps direct S3 for status — bucket-set-wins, on purpose.
+func TestOpenStatusStoreSelection(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("fleet box with no bucket speaks the door", func(t *testing.T) {
+ door := newDoorFake(t)
+ s, target, err := openStatusStore(ctx, envFunc(fleetDoorEnv(door.url)))
+ if err != nil {
+ t.Fatalf("openStatusStore: %v", err)
+ }
+ if _, _, err := s.Get(ctx, "control/serial"); err != nil {
+ t.Fatalf("Get through the door: %v", err)
+ }
+ if hits := door.seen(); len(hits) != 1 || hits[0].Name != "pi/pi-box" {
+ t.Fatalf("door hits = %+v, want one request as pi/pi-box", hits)
+ }
+ if !strings.Contains(target, "pi/pi-box") {
+ t.Fatalf("armed-log target = %q, want it to name the door identity", target)
+ }
+ })
+
+ t.Run("status bucket set on a fleet box keeps direct S3", func(t *testing.T) {
+ awsEnvForS3Store(t)
+ door := newDoorFake(t)
+ env := fleetDoorEnv(door.url)
+ env["CARLOS_STATUS_BUCKET"] = "carlos-status"
+ s, target, err := openStatusStore(ctx, envFunc(env))
+ if err != nil {
+ t.Fatalf("openStatusStore: %v", err)
+ }
+ if _, ok := s.(*release.S3Store); !ok {
+ t.Fatalf("store = %T, want *release.S3Store", s)
+ }
+ if target != "bucket carlos-status" {
+ t.Fatalf("target = %q, want %q", target, "bucket carlos-status")
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero requests", hits)
+ }
+ })
+
+ t.Run("deployment bucket fallback still applies", func(t *testing.T) {
+ awsEnvForS3Store(t)
+ door := newDoorFake(t)
+ env := fleetDoorEnv(door.url)
+ env["CARLOS_DEPLOYMENT_BUCKET"] = "carlos-deploy"
+ s, target, err := openStatusStore(ctx, envFunc(env))
+ if err != nil {
+ t.Fatalf("openStatusStore: %v", err)
+ }
+ if _, ok := s.(*release.S3Store); !ok {
+ t.Fatalf("store = %T, want *release.S3Store", s)
+ }
+ if target != "bucket carlos-deploy" {
+ t.Fatalf("target = %q, want %q", target, "bucket carlos-deploy")
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero requests", hits)
+ }
+ })
+
+ t.Run("platform box with no bucket stays unconfigured", func(t *testing.T) {
+ _, _, err := openStatusStore(ctx, envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": t.TempDir()}))
+ if !errors.Is(err, errNoStatusStore) {
+ t.Fatalf("err = %v, want errNoStatusStore — a DIR deployment publishes no status", err)
+ }
+ })
+
+ t.Run("fleet box with no bucket and no tunnel env fails loudly", func(t *testing.T) {
+ env := fleetDoorEnv("https://tunnel.example.com")
+ delete(env, "CARLOS_TUNNEL_TOKEN")
+ _, _, err := openStatusStore(ctx, envFunc(env))
+ if err == nil || errors.Is(err, errNoStatusStore) {
+ t.Fatalf("err = %v, want a loud construction error, not the unconfigured sentinel", err)
+ }
+ })
+}
+
+// TestResolveStatusPrefixDefaultsOnTheDoorBranch pins the fix for a fleet box
+// that would otherwise 404 forever: through the door, the write ACL admits
+// only fleets/<own fleet>/**, so a door-backed box with an EMPTY
+// CARLOS_STATUS_PREFIX must rebase itself rather than write at the shared
+// platform root. Bucket-backed boxes keep today's semantics exactly — an
+// empty prefix there IS the correct shared root.
+func TestResolveStatusPrefixDefaultsOnTheDoorBranch(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ env map[string]string
+ want string
+ }{
+ {"platform box, no prefix", map[string]string{"CARLOS_STATUS_BUCKET": "b"}, ""},
+ {"platform box, explicit prefix", map[string]string{"CARLOS_STATUS_BUCKET": "b", "CARLOS_STATUS_PREFIX": "x/"}, "x/"},
+ {"bucket-backed fleet box keeps its own setting", map[string]string{
+ "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box",
+ "CARLOS_STATUS_BUCKET": "b", "CARLOS_STATUS_PREFIX": "fleets/pi/",
+ }, "fleets/pi/"},
+ {"bucket-backed fleet box with no prefix is left alone", map[string]string{
+ "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box", "CARLOS_STATUS_BUCKET": "b",
+ }, ""},
+ {"door-backed fleet box defaults", map[string]string{
+ "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box",
+ "CARLOS_TUNNEL_URL": "https://t.example.com", "CARLOS_TUNNEL_TOKEN": "tok",
+ }, "fleets/pi/"},
+ {"door-backed fleet box with an explicit prefix keeps it", map[string]string{
+ "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box",
+ "CARLOS_TUNNEL_URL": "https://t.example.com", "CARLOS_TUNNEL_TOKEN": "tok",
+ "CARLOS_STATUS_PREFIX": "custom/",
+ }, "custom/"},
+ {"no fleet, no bucket", map[string]string{}, ""},
+ } {
+ if got := resolveStatusPrefix(envFunc(c.env)); got != c.want {
+ t.Errorf("%s: resolveStatusPrefix = %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+// TestDoorBackedStatusWriteLandsInTheFleetCorner is the behavior half: the two
+// lines statusLoop and metricsLoop actually run (openStatusStore +
+// NewPrefixStore over resolveStatusPrefix) must put a heartbeat at
+// fleets/<fleet>/status/boxes/..., the one place the door will accept it and
+// the exact layout the console's fleet page reads.
+func TestDoorBackedStatusWriteLandsInTheFleetCorner(t *testing.T) {
+ ctx := context.Background()
+ door := newDoorFake(t)
+ env := envFunc(fleetDoorEnv(door.url)) // no bucket, no CARLOS_STATUS_PREFIX
+
+ raw, _, err := openStatusStore(ctx, env)
+ if err != nil {
+ t.Fatalf("openStatusStore: %v", err)
+ }
+ store := release.NewPrefixStore(raw, resolveStatusPrefix(env))
+ // The door fake 404s anything but the serial, so the Put errors — the
+ // assertion is the KEY that went on the wire, not the response.
+ _, _ = store.Put(ctx, "status/boxes/pi-box.json", []byte("{}"), "")
+
+ hits := door.seen()
+ if len(hits) != 1 {
+ t.Fatalf("door saw %d requests, want 1: %+v", len(hits), hits)
+ }
+ if want := "/store/o/fleets/pi/status/boxes/pi-box.json"; hits[0].Path != want {
+ t.Fatalf("door path = %q, want %q — an unrebased key is outside the door's write ACL and 404s forever", hits[0].Path, want)
+ }
+}
+
+// TestRetentionLoopIsAPlatformBoxJob pins spec F6. A customer's box pruning
+// the PLATFORM's releases was wrong under direct S3 and is refused outright
+// through the door (its write ACL scopes a fleet to fleets/<fleet>/**), so a
+// fleet box must not arm the loop at all — one log line, then out, before it
+// ever opens a store.
+func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) {
+ const skipLine = "agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)"
+
+ t.Run("fleet box skips before arming", func(t *testing.T) {
+ logs := captureLog(t)
+ door := newDoorFake(t)
+ for k, v := range fleetDoorEnv(door.url) {
+ t.Setenv(k, v)
+ }
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+
+ retentionLoop(testRegistry(t)) // must return; a fleet box never reaches the loop
+
+ if got := logs(); !strings.Contains(got, skipLine) {
+ t.Fatalf("log = %q, want the F6 skip line verbatim", got)
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero — the skip must land BEFORE the store opens", hits)
+ }
+ })
+
+ t.Run("platform box still arms", func(t *testing.T) {
+ pub, _, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ t.Setenv("CARLOS_FLEET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+
+ // retentionLoop never returns once armed, so the arming log line is
+ // what proves it: run it in its own goroutine (it parks in a
+ // time.Sleep for a minute, long past the end of this test) against a
+ // synchronized sink, and wait for the line to appear.
+ var mu sync.Mutex
+ var buf bytes.Buffer
+ old, flags := log.Writer(), log.Flags()
+ log.SetOutput(writerFunc(func(p []byte) (int, error) {
+ mu.Lock()
+ defer mu.Unlock()
+ return buf.Write(p)
+ }))
+ log.SetFlags(0)
+ t.Cleanup(func() { log.SetOutput(old); log.SetFlags(flags) })
+
+ // A REAL registry, never nil: this goroutine outlives the test and
+ // runs retentionPass a minute from now, which lists routes — nil
+ // would panic the whole test binary long after the test that started
+ // it has passed.
+ go retentionLoop(testRegistry(t))
+
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ mu.Lock()
+ got := buf.String()
+ mu.Unlock()
+ if strings.Contains(got, "agent: retention loop armed") {
+ if strings.Contains(got, skipLine) {
+ t.Fatalf("platform box took the fleet skip: %q", got)
+ }
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("retention loop never armed on a platform box; log = %q", got)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ })
+}
+
+// writerFunc adapts a func to io.Writer — the log sink above needs its own
+// mutex because the loop under test keeps running (and may keep logging)
+// after the test returns, which a bare bytes.Buffer would race on.
+type writerFunc func(p []byte) (int, error)
+
+func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
// ---- stamped units for the metrics sweep (#110 column) ----------------------
// TestStampedUnitNamesDedupesAndSkipsUnstamped pins the metrics sweep's one
diff --git a/internal/host/logship.go b/internal/host/logship.go
index 6861c0a28..1f097be4d 100644
--- a/internal/host/logship.go
+++ b/internal/host/logship.go
@@ -14,6 +14,7 @@ package host
import (
"context"
+ "errors"
"log"
"os"
"time"
@@ -46,17 +47,28 @@ func logshipLoop(sink *applog.Sink, box string) {
}()
ctx := context.Background()
- bucket := resolveStatusBucket(os.Getenv)
- if bucket == "" {
- log.Printf("agent: logship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
- return
- }
- s, err := release.NewS3Store(ctx, bucket)
+ // Same store selection as statusLoop/metricsLoop (agent.go's
+ // openStatusStore): a bucket wins, and a cut-over fleet box with no bucket
+ // ships through the edge's store door instead of going silent.
+ //
+ // The keys stay UNPREFIXED — deliberately unlike status and metrics, which
+ // rebase under CARLOS_STATUS_PREFIX. A log chunk is the app's own data
+ // (applog.Prefix = release.AppPrefix + "logs/"), and the console reads it
+ // straight from the platform layout (applog.ReadWindow over
+ // apps/<sqid>/<app>/logs/**); rebasing under fleets/<name>/ would file a
+ // fleet box's logs somewhere `carlos logs` never looks. The door's write
+ // ACL admits exactly this shape for the box's own account (aclWrite's
+ // segment-exact apps/<sqid>/<app>/logs/** scope).
+ s, target, err := openStatusStore(ctx, os.Getenv)
if err != nil {
- log.Printf("agent: logship loop disabled: %v", err)
+ if errors.Is(err, errNoStatusStore) {
+ log.Printf("agent: logship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
+ } else {
+ log.Printf("agent: logship loop disabled: %v", err)
+ }
return
}
- log.Printf("agent: logship loop armed (10s ticks, event fast-flush), publishing to bucket %s", bucket)
+ log.Printf("agent: logship loop armed (10s ticks, event fast-flush), publishing to %s", target)
pending := make(map[applog.AppKey][]applog.Record)
pruned := "" // last hour a prune ran for, "" = not yet
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go
index 9d6e26add..f3ffc1296 100644
--- a/internal/host/metrics_loop.go
+++ b/internal/host/metrics_loop.go
@@ -766,14 +766,16 @@ func metricsLoop(execRoot string, stampedUnits func() []string) {
}()
ctx := context.Background()
- bucket := resolveStatusBucket(os.Getenv)
- if bucket == "" {
- log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
- return
- }
- raw, err := release.NewS3Store(ctx, bucket)
+ // Same selection rule statusLoop uses, in one place for both (agent.go's
+ // openStatusStore): a status/deployment bucket wins and keeps direct S3;
+ // a fleet box with neither set publishes through the edge's store door.
+ raw, target, err := openStatusStore(ctx, os.Getenv)
if err != nil {
- log.Printf("agent: metrics loop disabled: %v", err)
+ if errors.Is(err, errNoStatusStore) {
+ log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
+ } else {
+ log.Printf("agent: metrics loop disabled: %v", err)
+ }
return
}
// s is what metricsFlush/alertTick actually read/write through: the same
@@ -785,8 +787,11 @@ func metricsLoop(execRoot string, stampedUnits func() []string) {
// metricsview lists metrics/boxes/) and an alert would page the
// platform's own webhook for customer hardware. Empty prefix (every
// platform box today) is a pure passthrough — see the package doc
- // comment's CARLOS_STATUS_PREFIX entry.
- s := release.NewPrefixStore(raw, os.Getenv("CARLOS_STATUS_PREFIX"))
+ // comment's CARLOS_STATUS_PREFIX entry. resolveStatusPrefix, not a bare
+ // env read: on a door-backed fleet box an empty prefix would put every
+ // flush outside the door's write ACL, so that branch defaults to
+ // fleets/<fleet>/.
+ s := release.NewPrefixStore(raw, resolveStatusPrefix(os.Getenv))
box := metricsBoxLabel(os.Getenv, readMachineID())
statePath := filepath.Join(dataDir(), "monitor", "alerts.json")
@@ -795,7 +800,7 @@ func metricsLoop(execRoot string, stampedUnits func() []string) {
logRejectedAlertOverrides(os.Getenv)
webhookURL := os.Getenv("CARLOS_ALERT_WEBHOOK")
- log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket)
+ log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to %s", target)
sm := &sampler{}
probes := realSampleProbes(execRoot, stampedUnits)