carlos / platform Public

docs: P7 plan rev 2, folding in Astra round 1; round-1 findings unedited; round-2 brief

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ed1c5faad3e89ed0fbb900435ad3d2a2cbba70bc parent 37f04ba
3 files changed, +393 −309
  • docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md +320 −309
  • docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR.md +55 −0
  • docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R2.md +18 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md
index f3cd0aba4..113ea1941 100644
--- a/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md
+++ b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md
@@ -1,35 +1,60 @@
-# Instance moves P7: the edge-local door — Implementation Plan
+# Instance moves P7: the edge-local door — Implementation Plan (rev 2)
> **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:** An app's own processes on a box (Tito's router, relay and import-sync loop) reach any of the app's instances through one box-local unix socket, whatever box the instance lives on and whatever its socket path, authenticated by an app-scoped door key that a box verifies offline.
-**Architecture:** A signed-in person mints a door key for an app at the console. The console stores only the key's SHA-256, in a record under `control/door-keys/<account>/<app>.json` that it signs with the deployment's root release key. Every edge mirrors those records on its existing 30 s home-map poll and verifies them against `CARLOS_RELEASE_PUBKEY`, so a bucket writer (discussion #171) cannot mint a key. A second listener on `/run/carlos/edge-local.sock` (mode 0660, group `carlos-sockets` by the directory's setgid) authenticates the `X-Carlos-Door-Key` header, strips it, checks that the requested host belongs to the key's account and app, marks the request as a door caller, and hands it to the ordinary `Edge.ServeHTTP`. Local hosts are served (and woken) as for public traffic. Remote hosts backhaul exactly as public traffic does. Remote ownership comes from new `account`/`app` fields each box publishes in its edges file.
+**Architecture:** A signed-in person mints a door key at the console. The console's own database is the authority for keys. The bucket holds a signed projection of them, under `control/door-keys/<account>/<app>.json`, signed with the deployment's root release key, carrying a monotonic revision and a 72-hour expiry, and re-signed every 12 hours. Every edge mirrors the projections on their own 30-second loop, verifies them against `CARLOS_RELEASE_PUBKEY`, and keeps a persisted revision high-water mark, so neither a forged nor a replayed record admits a key. A second listener on `/run/carlos/edge-local.sock` (0660, group `carlos-sockets` through the directory's setgid) authenticates `X-Carlos-Door-Key`, strips it, normalizes X-Forwarded-For, marks the request as a door caller and hands it to `Edge.ServeHTTP`. `ServeHTTP` refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve, after the login re-resolution. A host served elsewhere is backhauled only to a box that advertises door support, with the door scope bound into the hop MAC, and the receiving edge enforces the same rule on its own final route.
-**Tech Stack:** Go 1.x standard library (`net/http`, `crypto/ed25519`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's `cliRoute`/`svcrole`, the CLI's `apiSession`.
+**Tech Stack:** Go standard library (`net/http`, `crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/subtle`), the repo's `release.Store`, the console's SQLite store, `cliRoute`/`svcrole`, the CLI's `apiSession`.
-**Spec:** `docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md` on branch `docs-instance-moves-design` (§0 rulings R5, Q3, Q5; §8; §10), and Astra's rulings file `CODEX-RULINGS-INSTANCE-MOVES.md` beside it.
+**Spec:** `docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md` on branch `docs-instance-moves-design` (§0 rulings R5, Q3, Q5; §8; §10), and `CODEX-RULINGS-INSTANCE-MOVES.md` beside it. Review round 1: `CODEX-FINDINGS-P7-EDGE-DOOR.md` beside this plan.
+
+## Revision 2: Astra round 1 dispositions
+
+| # | finding | disposition |
+|---|---|---|
+| 1 | console re-signs a copied record for another app | **Accepted, fixed structurally:** keys live in the console database; the console never reads a bucket record back (Task 2). Edge also requires payload account/app to match the path (Task 4). |
+| 2 | remote authorization loses the app boundary | **Accepted:** door scope bound into the hop MAC; the receiving edge enforces it on its final route; door traffic backhauls only to boxes advertising `door: true` (Tasks 5, 6). |
+| 3 | login re-resolution and lookup race | **Accepted:** the check moves into `ServeHTTP`, against the final `up` after the login re-resolution, before the gate, cache or dispatch (Task 5). |
+| 4 | reserved handlers ahead of the lookup | **Accepted:** door callers never reach the steering probe, tunnel hub or store door; a door request for either reserved name answers unknown host (Task 5). |
+| 5 | replayed pre-revocation record | **Accepted:** monotonic revision from the console database, persisted high-water mark per box, 72 h expiry, 12 h re-sign (Tasks 1, 2, 4). |
+| 6 | malformed XFF stays authoritative | **Accepted:** the door replaces X-Forwarded-For with exactly one validated address before `ServeHTTP` (Task 6). |
+| 7 | "30 s" is not the failure contract | **Accepted:** own loop and 10 s budget; list failure retains at most 10 minutes; absence, bad signature, expiry drop at once (Task 4). |
+| 8 | ready hook runs before the mirror | **Accepted:** `WithEdgeDoor` RunOption; the edge opens the door after the home map and the key mirror are armed, and closes it on shutdown; off in dev mode (Task 7). |
+| 9 | wrong file and fixture references | **Accepted:** `runInstances` is in `cli.go:284`; fixture is `fx.ts`/`fx.pub`, helpers in `apiinstances_test.go`; catchall publisher `edgesfile.go:363` and the alias-versus-route fold covered; docs go to `docs/site/cli.md` (the only reference page). |
+| 10 | root signing acceptable; add a domain prefix | **Accepted:** signatures cover `"carlos door-keys v1\n" + payload`; cross-kind rejection tests (Task 1). |
## Global Constraints
-- **Q3 (ruled):** the door authenticates an app-scoped credential restricted to this door; the edge checks the requested host's account and app against the credential's, and strips the credential before forwarding. Unix permissions on the socket stay as a second check, not the identity.
+- **Q3 (ruled):** the door authenticates an app-scoped credential restricted to this door; the edge checks the requested host's account and app against the credential's, and strips the credential before forwarding. Unix permissions on the socket are a second check, not the identity.
- **R5 (ruled):** one box-local door; every enrolled consumer (router, relay, import-sync loop: G18) moves to it before P9. P7 builds the door; moving Tito's processes is Tito's slice.
-- **§8:** the door is not a bypass: the password gate, the edge's XFF rules and (from P1) the hook-path refusal apply as on the public door. Headers the app sets pass through untouched.
-- **R2 (ruled, lands in P1):** hook paths are refused on the local door too. P7 adds no hook paths; P1 must add its refusal where this plan's `serveDoor` calls `ServeHTTP`.
-- **Q5 (ruled):** move mutations for other accounts are operator-only until #171 is fixed. The door moves nothing, so it is not gated by Q5, but the key record must not be forgeable by a bucket writer (signed, below).
-- **Dormant by default:** no socket is created unless `CARLOS_EDGE_DOOR` names a path. Rolling the binary changes nothing on a box until that is set.
-- **Every `carlos` command completes its job without box access** (AGENTS.md, 2026-08-08): minting, listing and revoking keys are console API plus CLI.
-- **Copy:** every new user-facing string (CLI output, API errors, docs) passes copy review before merge. No em dashes; a full stop and a new sentence, lower case, in Go error strings.
-- **Cross-compile:** CI builds darwin and windows (`GOOS=darwin go build ./... && GOOS=windows go build ./...`); anything Linux-only needs `//go:build linux` and a `!linux` stub.
+- **§8:** the door is not a bypass: the password gate and (from P1) the hook-path refusal apply as on the public door. Headers the app sets pass through untouched.
+- **R2 (ruled, lands in P1):** hook paths are refused on the local door too. P1 adds that refusal beside Task 5's ownership check in `ServeHTTP`, so it covers local and backhauled door traffic alike.
+- **Q5 (ruled):** the door moves nothing, so it is not gated by Q5; the key record must not be forgeable or replayable by a bucket writer (#171).
+- **Dormant by default:** no socket unless `CARLOS_EDGE_DOOR` names a path.
+- **Every `carlos` command completes its job without box access** (AGENTS.md): mint, list and revoke are console API plus CLI.
+- **Copy:** every new user-facing string passes copy review before merge. No em dashes; in Go error strings, a full stop and a new sentence in lower case.
+- **Cross-compile:** `GOOS=darwin go build ./... && GOOS=windows go build ./...` must pass.
- **Tests with unix sockets** run outside the Claude sandbox with `TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache GOFLAGS=-buildvcs=false`.
+## Freshness contract (the numbers)
+
+| event | effect at a box |
+|---|---|
+| key minted | admitted within one mirror interval (30 s) after the serial bump |
+| key revoked, bucket healthy | refused within 30 s |
+| key revoked, bucket list failing | refused at the latest 10 minutes after the last successful list, when the whole map is cleared |
+| old record replayed into the bucket | refused on any box that has seen a newer revision (persisted high-water mark); a box that never saw the newer revision accepts it until the replayed record's own `expires_at`, at most 72 h after it was signed |
+| console down | records expire 72 h after their last re-sign; the door refuses every key of an expired record |
+
## Review Focus
-1. **A door caller sending the mesh's hop headers** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`) must never gain hop trust; the door deletes both before `ServeHTTP`. Test in Task 6.
-2. **A host on a path-prefix hostname** whose prefixes belong to different apps: ownership is checked on the route `lookupUpstreamPath(host, path)` resolves, the same resolution `ServeHTTP` uses, so app A's key cannot reach app B's prefix. Test in Task 6.
-3. **A remote host published by a box that predates the owner fields** has no owner in the fold; the door answers unknown host for it (fail closed) rather than backhauling. Test in Task 5.
-4. **A revoked key** stops working within one home-map poll (30 s); a record that fails signature verification is dropped, never kept as last-good. Test in Task 4.
-5. **An X-Forwarded-For that is absent, empty or not an IP**: the door's client address falls back to `127.0.0.1`, and a garbage chain never reaches the gate's rate-limit key or analytics. Test in Task 6.
+1. **A door caller sending mesh vocabulary** (`X-Carlos-Hop`, `X-Carlos-Hop-Auth`, `X-Carlos-Hop-Door`) never gains hop trust or a different scope; the door deletes all three (Task 6 test).
+2. **A path-prefix hostname whose prefixes belong to different apps**, locally and behind a backhaul: app A's key never reaches app B's prefix (Tasks 5 and 6 tests).
+3. **A gate login POST whose `next` names another app's prefix** is refused before the gate judges the password (Task 5 test).
+4. **A replayed older record** after a revocation, before and after a verifier restart (Task 4 test).
+5. **An X-Forwarded-For that is absent, empty, multi-line or not an IP** leaves exactly one valid address in the header the app and analytics see (Task 6 test).
---
@@ -37,45 +62,43 @@
| File | Responsibility |
|---|---|
-| `internal/doorkey/doorkey.go` (new) | Token format, record schema, record key, sign/verify, constant-time admit. No I/O. |
-| `internal/doorkey/doorkey_test.go` (new) | Unit tests for the above. |
-| `internal/console/apidoorkeys.go` (new) | Console doors: create, list, revoke. |
-| `internal/console/apidoorkeys_test.go` (new) | Door tests. |
-| `internal/console/authcli.go` (modify, near line 637) | Route registration, person-only. |
+| `internal/doorkey/doorkey.go` (new) | Token format, record schema, key path, domain-separated sign/verify, admit. No I/O. |
+| `internal/console/doorkeys_store.go` (new) | `door_keys` table: insert, list, revoke, per-app revision counter. |
+| `internal/console/apidoorkeys.go` (new) | Mint, list, revoke doors; `publishDoorKeys`; the 12 h re-sign loop. |
+| `internal/console/authcli.go` (modify, beside `instance-token`, ~line 637) | Routes, person-only. |
| `doorkeys_cli.go` (new, package main) | `carlos instances door-key create|list|revoke`. |
-| `instances_cli.go` (modify) | Dispatch `door-key` from `runInstances`. |
-| `internal/host/edgesfile.go` (modify, `edgeHost`) | Publish `account` and `app` per host. |
-| `internal/edge/homemap.go` (modify) | Fold the owner; mirror and verify door-key records; `Owner(host)`; `doorKey(acct, app)`. |
-| `internal/edge/door.go` (new) | Door context mark, `serveDoor`, `ServeDoor(path)` listener. |
-| `internal/edge/door_test.go` (new) | Door behaviour tests. |
-| `internal/edge/homemap.go` `setForwarded`, `internal/edge/analytics.go` `clientIP` (modify) | Trust the door's chain the way a verified hop's is trusted. |
-| `internal/host/agent.go` (modify, the `RunWithActivatorReady` ready hook ~line 510) | Start the door when `CARLOS_EDGE_DOOR` is set. |
-| `docs/site/instances.md` or the CLI reference (modify) | Document the door and the key verbs (copy review). |
+| `cli.go` (modify, `runInstances` at ~line 284) | Dispatch `door-key`. |
+| `internal/host/edgesfile.go` (modify: `edgeHost`, the route fill, the catchall publisher ~line 363, top-level `Door`) | Publish per-host `account`/`app` and the box's `door: true`. |
+| `internal/edge/homemap.go` (modify) | Fold owner and door support with the winning entry; `Owner`; `acceptHop` door scope; `setForwarded` trust. |
+| `internal/edge/mesh.go` (modify) | `signHopDoor`/`verifyHopDoorEitherKey`: the MAC with the scope bound in. |
+| `internal/edge/doorkeys.go` (new) | The key mirror: its own loop, freshness contract, high-water state file. |
+| `internal/edge/door.go` (new) | Door mark, `serveDoor`, `doorOwnsRoute`, reserved-name refusal, the listener. |
+| `internal/edge/edge.go` (modify: `ServeHTTP`, `backhaul`/`backhaulProxy`, `RunWithActivatorReady`, shutdown) | Enforcement points and lifecycle. |
+| `internal/edge/analytics.go` (modify `clientIP`) | Trust the door's normalized address. |
+| `internal/host/agent.go` (modify: options passed to `RunWithActivatorReady`) | `edge.WithEdgeDoor` from `CARLOS_EDGE_DOOR`. |
+| `docs/site/cli.md` (modify) | The door and the key verbs. |
---
### Task 1: The door-key package
-**Files:**
-- Create: `internal/doorkey/doorkey.go`
-- Test: `internal/doorkey/doorkey_test.go`
+**Files:** Create `internal/doorkey/doorkey.go`, `internal/doorkey/doorkey_test.go`.
-**Interfaces:**
-- Produces:
- - `const Header = "X-Carlos-Door-Key"`
- - `func RecordKey(account, app string) string` → `"control/door-keys/" + account + "/" + app + ".json"`
- - `const Prefix = "control/door-keys/"`
- - `type Token struct{ Account, App, ID, Secret string }`
- - `func NewToken(account, app string) (raw string, entry Entry, err error)`
- - `func ParseToken(raw string) (Token, error)`
- - `type Entry struct{ ID string; SHA256 string; Label string; CreatedAt time.Time }` (JSON: `id`, `sha256`, `label`, `created_at`)
- - `type Record struct{ V int; Account, App string; Keys []Entry; UpdatedAt time.Time }` (JSON: `v`, `account`, `app`, `keys`, `updated_at`)
- - `const MaxKeys = 8`
- - `func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error)`
- - `func Verify(pub ed25519.PublicKey, wire []byte) (Record, error)`
- - `func (r Record) Admits(t Token) bool`
-
-Token wire form: `cdk1.<account>.<app>.<id>.<secret>`. `account` matches `release.ValidAccount`; `app` matches `^[a-z][a-z0-9-]{0,31}$`; `id` is 16 lowercase hex; `secret` is 43 characters of unpadded base64url (32 random bytes). The dots cannot appear in any field, so `strings.Split` into exactly five parts is unambiguous.
+**Interfaces (produced):**
+- `const Header = "X-Carlos-Door-Key"`, `const Prefix = "control/door-keys/"`, `const MaxKeys = 8`, `const Lifetime = 72 * time.Hour`
+- `func RecordKey(account, app string) string` → `Prefix + account + "/" + app + ".json"`
+- `type Token struct{ Account, App, ID, Secret string }`
+- `func NewToken(account, app string) (raw string, entry Entry, err error)`
+- `func ParseToken(raw string) (Token, error)`
+- `type Entry struct{ ID, SHA256, Label string; CreatedAt time.Time }` (JSON `id`, `sha256`, `label`, `created_at`)
+- `type Record struct{ V int; Account, App string; Revision uint64; Keys []Entry; SignedAt, ExpiresAt time.Time }` (JSON `v`, `account`, `app`, `revision`, `keys`, `signed_at`, `expires_at`)
+- `func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error)`
+- `func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error)` — signature, shape, and `now` before `ExpiresAt`
+- `func (r Record) Admits(t Token) bool`
+
+Token wire form `cdk1.<account>.<app>.<id>.<secret>`: account per `release.ValidAccount`, app `^[a-z][a-z0-9-]{0,31}$`, id 16 lowercase hex, secret 43 characters unpadded base64url (32 random bytes). No field can contain a dot.
+
+The signature covers `"carlos door-keys v1\n" + payloadB64`, never `payloadB64` alone, so no other root-signed wrapper (release pointers, pipelines, system pointers, grants: all sign the bare base64) verifies as a door-key record, and a door-key record verifies as none of them.
- [ ] **Step 1: Write the failing tests**
@@ -85,11 +108,19 @@ package doorkey
import (
"crypto/ed25519"
"crypto/rand"
+ "encoding/base64"
+ "encoding/json"
"strings"
"testing"
"time"
)
+func rec1(entry Entry) Record {
+ now := time.Now().UTC()
+ return Record{V: 1, Account: "bae", App: "titogo", Revision: 1, Keys: []Entry{entry},
+ SignedAt: now, ExpiresAt: now.Add(Lifetime)}
+}
+
func TestTokenRoundTripAndAdmit(t *testing.T) {
raw, entry, err := NewToken("bae", "titogo")
if err != nil {
@@ -99,18 +130,14 @@ func TestTokenRoundTripAndAdmit(t *testing.T) {
t.Fatalf("token %q lacks its prefix", raw)
}
tok, err := ParseToken(raw)
- if err != nil {
- t.Fatal(err)
- }
- if tok.Account != "bae" || tok.App != "titogo" || tok.ID != entry.ID {
- t.Fatalf("parsed %+v, entry %+v", tok, entry)
+ if err != nil || tok.ID != entry.ID {
+ t.Fatalf("parse %q: %+v %v", raw, tok, err)
}
- rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}}
- if !rec.Admits(tok) {
+ if !rec1(entry).Admits(tok) {
t.Fatal("the record does not admit its own key")
}
tok.Secret = strings.Repeat("A", 43)
- if rec.Admits(tok) {
+ if rec1(entry).Admits(tok) {
t.Fatal("a wrong secret was admitted")
}
}
@@ -118,19 +145,19 @@ func TestTokenRoundTripAndAdmit(t *testing.T) {
func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) {
raw, entry, _ := NewToken("bae", "titogo")
tok, _ := ParseToken(raw)
- other := Record{V: 1, Account: "bae", App: "other", Keys: []Entry{entry}}
+ other := rec1(entry)
+ other.App = "other"
if other.Admits(tok) {
t.Fatal("a record for another app admitted the key")
}
}
func TestParseTokenRefusesMalformed(t *testing.T) {
+ good := "0123456789abcdef." + strings.Repeat("A", 43)
for _, raw := range []string{
- "", "cdk1", "cdk2.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43),
- "cdk1.BAE.titogo.0123456789abcdef." + strings.Repeat("A", 43),
+ "", "cdk1", "cdk2.bae.titogo." + good, "cdk1.BAE.titogo." + good,
"cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43),
- "cdk1.bae.titogo.0123456789abcdef.short",
- "cdk1.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43) + ".extra",
+ "cdk1.bae.titogo.0123456789abcdef.short", "cdk1.bae.titogo." + good + ".extra",
} {
if _, err := ParseToken(raw); err == nil {
t.Errorf("ParseToken(%q) accepted a malformed token", raw)
@@ -138,54 +165,74 @@ func TestParseTokenRefusesMalformed(t *testing.T) {
}
}
-func TestSignVerify(t *testing.T) {
+func TestSignVerifyExpiryAndKeys(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
_, entry, _ := NewToken("bae", "titogo")
- rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}, UpdatedAt: time.Now().UTC()}
- wire, err := Sign(priv, rec)
+ wire, err := Sign(priv, rec1(entry))
if err != nil {
t.Fatal(err)
}
- got, err := Verify(pub, wire)
- if err != nil || len(got.Keys) != 1 || got.Keys[0].ID != entry.ID {
+ got, err := Verify(pub, wire, time.Now())
+ if err != nil || got.Revision != 1 || got.Keys[0].ID != entry.ID {
t.Fatalf("Verify = %+v, %v", got, err)
}
+ if _, err := Verify(pub, wire, time.Now().Add(Lifetime+time.Minute)); err == nil {
+ t.Fatal("an expired record verified")
+ }
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
- if _, err := Verify(otherPub, wire); err == nil {
+ if _, err := Verify(otherPub, wire, time.Now()); err == nil {
t.Fatal("a record signed by another key verified")
}
- tampered := []byte(strings.Replace(string(wire), `"sig": "`, `"sig": "A`, 1))
- if _, err := Verify(pub, tampered); err == nil {
- t.Fatal("a tampered signature verified")
+}
+
+// The domain prefix: a wrapper signed over the bare payload (every other
+// root-signed record's shape) must not verify as a door-key record.
+func TestVerifyRefusesABareSignedWrapper(t *testing.T) {
+ pub, priv, _ := ed25519.GenerateKey(rand.Reader)
+ _, entry, _ := NewToken("bae", "titogo")
+ payload, _ := json.Marshal(rec1(entry))
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ wire, _ := json.Marshal(map[string]string{"payload": b64,
+ "sig": base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))})
+ if _, err := Verify(pub, wire, time.Now()); err == nil {
+ t.Fatal("a bare-signed wrapper verified as a door-key record")
+ }
+ // And the other direction: a door-key record's signature does not
+ // verify over its bare payload.
+ door, _ := Sign(priv, rec1(entry))
+ var w struct{ Payload, Sig string }
+ json.Unmarshal(door, &w)
+ sig, _ := base64.StdEncoding.DecodeString(w.Sig)
+ if ed25519.Verify(pub, []byte(w.Payload), sig) {
+ t.Fatal("a door-key signature verifies over the bare payload")
}
}
func TestSignRefusesTooManyKeys(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
- rec := Record{V: 1, Account: "bae", App: "titogo"}
- for i := 0; i <= MaxKeys; i++ {
+ _, e, _ := NewToken("bae", "titogo")
+ r := rec1(e)
+ for i := 0; i < MaxKeys; i++ {
_, e, _ := NewToken("bae", "titogo")
- rec.Keys = append(rec.Keys, e)
+ r.Keys = append(r.Keys, e)
}
- if _, err := Sign(priv, rec); err == nil {
+ if _, err := Sign(priv, r); err == nil {
t.Fatal("signed a record over MaxKeys")
}
}
```
-- [ ] **Step 2: Run to verify they fail**
-
-Run: `go test ./internal/doorkey/`
-Expected: FAIL (package has no non-test files).
+- [ ] **Step 2: Run** `go test ./internal/doorkey/` → FAIL (no package).
- [ ] **Step 3: Implement**
```go
// Package doorkey is the credential for the edge-local door (instance-moves
-// design §8, ruling Q3): an app-scoped key that a box verifies offline. The
-// console mints it and keeps only its SHA-256, in a record it signs with the
-// deployment's root release key, so a bucket writer (discussion #171) can
-// read the record but cannot add a key to it.
+// design §8, ruling Q3): an app-scoped key a box verifies offline. The
+// console's database is the authority; the bucket holds a signed, expiring,
+// revisioned projection of it, so a bucket writer (discussion #171) can
+// neither add a key nor bring a revoked one back for longer than the
+// projection's own lifetime on a box that never saw the revocation.
package doorkey
import (
@@ -206,10 +253,12 @@ import (
)
const (
- Header = "X-Carlos-Door-Key"
- Prefix = "control/door-keys/"
- MaxKeys = 8
- scheme = "cdk1"
+ Header = "X-Carlos-Door-Key"
+ Prefix = "control/door-keys/"
+ MaxKeys = 8
+ Lifetime = 72 * time.Hour
+ scheme = "cdk1"
+ domain = "carlos door-keys v1\n"
)
var (
@@ -233,8 +282,10 @@ type Record struct {
V int `json:"v"`
Account string `json:"account"`
App string `json:"app"`
+ Revision uint64 `json:"revision"`
Keys []Entry `json:"keys"`
- UpdatedAt time.Time `json:"updated_at"`
+ SignedAt time.Time `json:"signed_at"`
+ ExpiresAt time.Time `json:"expires_at"`
}
type signedWrapper struct {
@@ -242,7 +293,8 @@ type signedWrapper struct {
Sig string `json:"sig"`
}
-func digest(secret string) string {
+// Digest is the stored form of a key's secret.
+func Digest(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
@@ -262,7 +314,7 @@ func NewToken(account, app string) (string, Entry, error) {
t := Token{Account: account, App: app, ID: hex.EncodeToString(id[:]),
Secret: base64.RawURLEncoding.EncodeToString(secret[:])}
raw := strings.Join([]string{scheme, t.Account, t.App, t.ID, t.Secret}, ".")
- return raw, Entry{ID: t.ID, SHA256: digest(t.Secret), CreatedAt: time.Now().UTC()}, nil
+ return raw, Entry{ID: t.ID, SHA256: Digest(t.Secret), CreatedAt: time.Now().UTC()}, nil
}
func ParseToken(raw string) (Token, error) {
@@ -274,14 +326,14 @@ func ParseToken(raw string) (Token, error) {
return Token{Account: f[1], App: f[2], ID: f[3], Secret: f[4]}, nil
}
-// Admits answers whether t is one of this record's keys. The account and app
-// must match the record's own, so a key can never be looked up under
-// another app's record and pass.
+// Admits answers whether t is one of this record's keys. The record's own
+// account and app must be the token's, so a key can never pass against a
+// record filed for another app.
func (r Record) Admits(t Token) bool {
if r.Account != t.Account || r.App != t.App {
return false
}
- want := digest(t.Secret)
+ want := Digest(t.Secret)
for _, e := range r.Keys {
if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 {
return true
@@ -291,7 +343,8 @@ func (r Record) Admits(t Token) bool {
}
func (r Record) valid() error {
- if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) {
+ if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) || r.Revision == 0 ||
+ r.ExpiresAt.IsZero() || !r.ExpiresAt.After(r.SignedAt) || r.ExpiresAt.Sub(r.SignedAt) > Lifetime {
return errors.New("doorkey: record shape")
}
if len(r.Keys) > MaxKeys {
@@ -318,10 +371,10 @@ func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) {
}
b64 := base64.StdEncoding.EncodeToString(payload)
return json.MarshalIndent(signedWrapper{Payload: b64,
- Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}, "", " ")
+ Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(domain+b64)))}, "", " ")
}
-func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) {
+func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) {
if len(pub) != ed25519.PublicKeySize {
return Record{}, errors.New("doorkey: no public key")
}
@@ -330,7 +383,7 @@ func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) {
return Record{}, err
}
sig, err := base64.StdEncoding.DecodeString(w.Sig)
- if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(w.Payload), sig) {
+ if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(domain+w.Payload), sig) {
return Record{}, errors.New("doorkey: bad signature")
}
payload, err := base64.StdEncoding.DecodeString(w.Payload)
@@ -341,273 +394,243 @@ func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) {
if err := json.Unmarshal(payload, &rec); err != nil {
return Record{}, err
}
- return rec, rec.valid()
+ if err := rec.valid(); err != nil {
+ return Record{}, err
+ }
+ if !now.Before(rec.ExpiresAt) {
+ return Record{}, errors.New("doorkey: record expired")
+ }
+ return rec, nil
}
```
-- [ ] **Step 4: Run to verify they pass**
-
-Run: `go test ./internal/doorkey/ -count=1`
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/doorkey
-git commit -m "doorkey: the edge-local door's app-scoped, root-signed credential (#175 P7)"
-```
+- [ ] **Step 4: Run** `go test ./internal/doorkey/ -count=1` → PASS.
+- [ ] **Step 5: Commit** `doorkey: the edge-local door's app-scoped, root-signed, expiring credential (#175 P7)`.
---
-### Task 2: Console doors to mint, list and revoke
+### Task 2: Console authority, doors and re-sign loop
-**Files:**
-- Create: `internal/console/apidoorkeys.go`, `internal/console/apidoorkeys_test.go`
-- Modify: `internal/console/authcli.go` (register beside the `instance-token` route at ~line 637)
+**Files:** Create `internal/console/doorkeys_store.go`, `internal/console/apidoorkeys.go`, `internal/console/apidoorkeys_test.go`. Modify `internal/console/authcli.go` (beside the `instance-token` route, ~line 637), and the console's schema migration list (find it with `grep -n "CREATE TABLE IF NOT EXISTS cli_tokens" internal/console/*.go` and add beside it).
**Interfaces:**
-- Consumes: `doorkey.NewToken`, `doorkey.Sign`, `doorkey.Verify`, `doorkey.RecordKey`, `doorkey.MaxKeys`; `s.cfg.Key` (the root `ed25519.PrivateKey`; nil on a read-only console); `s.cfg.Releases` (`release.Store`); `s.apiPerson`, `s.apiAccountApp`, `s.allowWrite`, `cleanLabel`, `s.audit`, `writeJSON`, `jsonError`, `readJSON` (all existing, as used by `handleAPIInstanceToken` in `apiinstances.go:1554`).
-- Produces (wire):
- - `POST /api/cli/apps/{account}/{app}/door-keys` body `{"label": "..."}` → 201 `{"key": "<raw>", "id": "...", "account": "...", "app": "..."}`
- - `GET /api/cli/apps/{account}/{app}/door-keys` → 200 `{"keys": [{"id","label","created_at"}]}` (never the hash)
- - `POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke` → 200 `{"revoked": "<id>"}`; 404 for an unknown id.
+- Consumes: `doorkey.*`; `s.cfg.Key` (root `ed25519.PrivateKey`, empty on a read-only console); `s.cfg.Releases`; `s.apiPerson`, `s.apiAccountApp`, `s.allowWrite`, `cleanLabel`, `s.audit`, `s.bumpSerial`, `writeJSON`, `jsonError`, `readJSON` (as `handleAPIInstanceToken`, `apiinstances.go:1554`, uses them).
+- Produces:
+ - table `door_keys(account_id INTEGER, app TEXT, id TEXT, sha256 TEXT, label TEXT, created_at INTEGER, revoked_at INTEGER, PRIMARY KEY(account_id, app, id))` and `door_key_revisions(account_id INTEGER, app TEXT, revision INTEGER, PRIMARY KEY(account_id, app))`
+ - `func (st *Store) DoorKeyInsert(accountID int64, app string, e doorkey.Entry) (revision uint64, err error)` — refuses when the app already has `doorkey.MaxKeys` live keys (`errDoorKeysFull`); bumps the revision in the same transaction
+ - `func (st *Store) DoorKeyRevoke(accountID int64, app, id string) (revision uint64, err error)` — `os.ErrNotExist` for an unknown or already revoked id; bumps the revision
+ - `func (st *Store) DoorKeysLive(accountID int64, app string) (keys []doorkey.Entry, revision uint64, err error)`
+ - `func (st *Store) DoorKeyApps() ([]struct{ AccountID int64; Sqid, App string }, error)` — every app with a revision row
+ - `func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error` — builds the record from `DoorKeysLive` (never from the bucket), `SignedAt = now`, `ExpiresAt = now + doorkey.Lifetime`, signs, and `Put`s unconditionally (no etag: the database is the authority, and overwriting a tampered object is the point)
+ - `func (s *Server) doorKeyResignLoop(stop <-chan struct{})` — every 12 h, `publishDoorKeys` for every `DoorKeyApps()` row; logs failures; started beside the console's other loops (find them with `grep -n "go s\..*Loop(" internal/console/server.go`), only when `len(s.cfg.Key) > 0`
+ - Wire: `POST /api/cli/apps/{account}/{app}/door-keys` `{"label"}` → 201 `{"key","id","account","app"}`; `GET .../door-keys` → 200 `{"keys":[{"id","label","created_at"}]}`; `POST .../door-keys/{id}/revoke` → 200 `{"revoked":id}`, 404 unknown id; 409 on a full set; 503 with the read-only sentence when `len(s.cfg.Key) == 0`. All person-only.
-All three are person-only (`svcrole.Set{}` plus `s.apiPerson`), the `instance-token` rule: a door key reaches every instance of the app. A console with no signing key answers 503 with the existing read-only sentence family. Writes are a read-modify-CAS on the record's etag with one retry, like `SetRecordMove`'s `updateRecord`.
+A mutation is: database write (which yields the new revision), then `publishDoorKeys`, then `bumpSerial`. If publishing fails after the database write, answer 502 "saved but not yet published" family and rely on the re-sign loop; the database is still the truth.
-- [ ] **Step 1: Write the failing tests** — mint returns a parseable key whose record verifies against the fixture's public key and admits it; list shows the id and label but no `sha256`; revoke removes it and the re-read record no longer admits it; a ninth key is refused 409; an API key (service principal of any role, including Admin) is refused; a console fixture with no signing key answers 503 and writes nothing. Use the fixture helpers `instanceFixture`/`rawReq` from `apiinstancemove_test.go`, and read the fixture's public key the way the pipeline tests do (`grep -n "cfg.Key" internal/console/*_test.go` for the existing pattern).
+- [ ] **Step 1: Failing tests** (fixture from `apiinstances_test.go`: `instanceFixture(t, code)` returns the fixture and a person token; the HTTP server is `fx.ts`, the signing public key `fx.pub`, the bucket `fx.rel`):
```go
+func doorKeysURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/door-keys" }
+
+func readDoorRecord(t *testing.T, fx *consoleFixture) doorkey.Record {
+ t.Helper()
+ wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, err := doorkey.Verify(fx.pub, wire, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec
+}
+
func TestDoorKeysMintListRevoke(t *testing.T) {
fx, token := instanceFixture(t, "code-door-keys")
- u := fx.srv.URL + "/api/cli/apps/bab/titogo/door-keys"
- code, _, out := rawReq(t, http.MethodPost, u, token, `{"label":"router"}`)
+ code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`)
if code != http.StatusCreated {
t.Fatalf("mint = %d %s", code, out)
}
var minted struct{ Key, ID string }
- if err := json.Unmarshal([]byte(out), &minted); err != nil {
- t.Fatal(err)
- }
+ json.Unmarshal([]byte(out), &minted)
tok, err := doorkey.ParseToken(minted.Key)
if err != nil || tok.ID != minted.ID {
t.Fatalf("minted key %q: %v", minted.Key, err)
}
- wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
- if err != nil {
- t.Fatal(err)
- }
- rec, err := doorkey.Verify(fx.pub, wire)
- if err != nil || !rec.Admits(tok) {
- t.Fatalf("record %+v, %v: does not admit the minted key", rec, err)
+ rec := readDoorRecord(t, fx)
+ if !rec.Admits(tok) || rec.Revision != 1 {
+ t.Fatalf("record %+v does not admit the minted key at revision 1", rec)
}
- code, _, out = rawReq(t, http.MethodGet, u, token, "")
+ code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, "")
if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") {
t.Fatalf("list = %d %s", code, out)
}
- code, _, out = rawReq(t, http.MethodPost, u+"/"+minted.ID+"/revoke", token, "")
- if code != http.StatusOK {
+ if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusOK {
t.Fatalf("revoke = %d %s", code, out)
}
- wire, _, _ = fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo"))
- if rec, _ := doorkey.Verify(fx.pub, wire); rec.Admits(tok) {
- t.Fatal("a revoked key is still admitted")
+ if rec := readDoorRecord(t, fx); rec.Admits(tok) || rec.Revision != 2 {
+ t.Fatalf("after revoke: %+v", rec)
}
}
-```
-
-(`fx.pub` and `fx.srv`: if the fixture does not already expose the signing public key and server URL under those names, add the two fields to `consoleFixture` in this task; `instancesURL(fx, ...)` in `apiinstancemove_test.go` shows how the URL is built today.)
-
-- [ ] **Step 2: Run to verify it fails** — `go test ./internal/console -run DoorKeys -count=1` → FAIL (404 from the unregistered route).
-- [ ] **Step 3: Implement** `apidoorkeys.go`:
-
-```go
-package console
-
-// Door keys (instance-moves design §8, ruling Q3): the credential an app's
-// own processes present at a box's edge-local door. Person-only, like the
-// instance token: a door key reaches every instance of the app on every box.
-// The console keeps only each key's SHA-256, in a record signed with the
-// root release key, so a bucket writer cannot add one (discussion #171).
-
-func (s *Server) doorKeyUpdate(ctx context.Context, acct, app string, mut func(*doorkey.Record) error) (*doorkey.Record, error) {
- key := doorkey.RecordKey(acct, app)
- update := func() (*doorkey.Record, error) {
- wire, etag, err := s.cfg.Releases.Get(ctx, key)
- rec := doorkey.Record{V: 1, Account: acct, App: app}
- switch {
- case errors.Is(err, os.ErrNotExist):
- etag = ""
- case err != nil:
- return nil, err
- default:
- if rec, err = doorkey.Verify(s.cfg.Key.Public().(ed25519.PublicKey), wire); err != nil {
- return nil, fmt.Errorf("door-key record for %s/%s does not verify: %w", acct, app, err)
- }
- }
- if err := mut(&rec); err != nil {
- return nil, err
- }
- rec.UpdatedAt = time.Now().UTC()
- body, err := doorkey.Sign(s.cfg.Key, rec)
- if err != nil {
- return nil, err
- }
- if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil {
- return nil, err
- }
- return &rec, nil
+// Finding 1: whatever sits in the bucket, the console publishes from its
+// database. A record copied in from another app's path is overwritten, not
+// extended.
+func TestDoorKeysPublishFromTheDatabaseNotTheBucket(t *testing.T) {
+ fx, token := instanceFixture(t, "code-door-keys-copy")
+ _, planted, _ := doorkey.NewToken("bab", "titogo")
+ now := time.Now().UTC()
+ forged, _ := doorkey.Sign(fx.key, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 99,
+ Keys: []doorkey.Entry{planted}, SignedAt: now, ExpiresAt: now.Add(time.Hour)})
+ fx.rel.Put(context.Background(), doorkey.RecordKey("bab", "titogo"), forged, "")
+ if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusCreated {
+ t.Fatalf("mint = %d %s", code, out)
}
- rec, err := update()
- if errors.Is(err, release.ErrCASConflict) {
- return update()
+ rec := readDoorRecord(t, fx)
+ for _, e := range rec.Keys {
+ if e.ID == planted.ID {
+ t.Fatal("the console carried a key it never minted into its signed record")
+ }
}
- return rec, err
}
+
+func TestDoorKeysRefuseServiceCredentialsAndReadOnlyConsoles(t *testing.T) { /* an Admin-role service principal gets 403 (use the existing service-principal fixture helper: grep -n "NewServicePrincipal" internal/console/*_test.go); a fixture with cfg.Key = nil gets 503 and no object is written */ }
+
+func TestDoorKeysRefuseANinthKey(t *testing.T) { /* eight mints 201, the ninth 409, the record still has eight */ }
+
+func TestDoorKeyResignRefreshesExpiry(t *testing.T) { /* mint; call publishDoorKeys again after advancing the store's clock seam (or compare SignedAt before/after a second call); revision unchanged, ExpiresAt later */ }
```
-Then the three handlers, each opening with `if !s.apiPerson(w, r) { return }`, `addr, acct, app, ok := s.apiAccountApp(w, r)`, and a 503 when `len(s.cfg.Key) == 0`. Mint: `raw, entry, err := doorkey.NewToken(acct.Sqid, app)`; `entry.Label = cleanLabel(req.Label)` defaulting to `app + " door key"`; in the mutator refuse `len(rec.Keys) >= doorkey.MaxKeys` with a sentinel mapped to 409; append; `s.audit(acct, app, addr, "door-key", "minted door key "+entry.ID+" for "+app)`; `s.bumpSerial(ctx)`; 201 with the raw key shown once. List: read and verify (404-free: an absent record lists `[]`), return `id`, `label`, `created_at`. Revoke: the mutator removes the entry by id, or returns a sentinel mapped to 404; audit; bump serial.
+(`fx.key`: if the fixture exposes the private key under another name, use that; `grep -n "ed25519.GenerateKey" internal/console/server_test.go`.) Write out the three stubbed tests in full in the same style before running.
-Register in `authcli.go` beside `instance-token`:
+- [ ] **Step 2: Run** `go test ./internal/console -run DoorKey -count=1` → FAIL.
+- [ ] **Step 3: Implement** the store, the doors, `publishDoorKeys`, the loop, and the routes:
```go
// Door keys (#175 P7): person-only, the instance-token rule. A door key
- // reaches every instance of the app on every box's edge-local door.
+ // reaches every instance of the app through every box's edge-local door.
s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList)
s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint)
s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke)
```
-- [ ] **Step 4: Run** `go test ./internal/console -run DoorKeys -count=1` → PASS; then the whole package.
-- [ ] **Step 5: Commit** `console: mint, list and revoke door keys (#175 P7)`.
+- [ ] **Step 4: Run** the new tests, then `go test ./internal/console -count=1`.
+- [ ] **Step 5: Commit** `console: door keys, with the database as authority and a re-signed projection (#175 P7)`.
---
### Task 3: `carlos instances door-key`
-**Files:**
-- Create: `doorkeys_cli.go`, `doorkeys_cli_test.go`
-- Modify: `instances_cli.go` (`runInstances` dispatch)
+**Files:** Create `doorkeys_cli.go`, `doorkeys_cli_test.go`. Modify `cli.go` (`runInstances`, ~line 284: add `case "door-key":` beside the existing subcommand words).
-**Interfaces:**
-- Consumes: the three console doors; `requireAPISession`, `resolvedAccount`, `resolvedApp`, `apiPath`, `s.getJSON`, `s.postJSON`, `printUsing` (existing, as `runInstancesDelete` uses them).
-- Produces: `carlos instances door-key create --app <app> [--label <text>]` (prints the key once, then one line saying it is shown once); `... list --app <app>` (id, label, created); `... revoke --app <app> <id>`.
+**Interfaces:** Consumes the three doors; `requireAPISession`, `printUsing`, `resolvedAccount`, `resolvedApp`, `apiPath`, `s.getJSON`, `s.postJSON` (as `runInstancesDelete` and `mail_cli.go`'s wire calls use them). Produces `carlos instances door-key create --app <app> [--label <text>]`, `list --app <app>`, `revoke --app <app> <id>`.
-- [ ] **Step 1: Failing test** — drive the three verbs against an `httptest` console the way existing `apiSession` tests do (`grep -n "httptest.NewServer" instances_cli_test.go apimode_test.go` for the harness), asserting the request paths and that `create` prints the key.
+- [ ] **Step 1: Failing test** against an `httptest` console, in the pattern of the existing `apiSession` tests (`grep -ln "httptest.NewServer" *_test.go`, pick the one that stubs `/api/cli/apps/`): assert request method and path for each verb and that `create` prints the key.
- [ ] **Step 2: Run** `go test . -run DoorKey -count=1` → FAIL.
-- [ ] **Step 3: Implement** the three `apiSession` wire calls (`doorKeyMint`, `doorKeyList`, `doorKeyRevoke`) in the `mail_cli.go` style and the flag parsing in the `runInstancesDelete` style; add `case "door-key":` to `runInstances`.
+- [ ] **Step 3: Implement** `doorKeyMint`, `doorKeyList`, `doorKeyRevoke` wire calls and the three verbs.
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Commit** `cli: carlos instances door-key create, list, revoke (#175 P7)`.
---
-### Task 4: Edges files publish the owner; the edge mirrors door keys
+### Task 4: Edges files publish owners and door support; the edge mirrors door keys
-**Files:**
-- Modify: `internal/host/edgesfile.go` (`edgeHost`, and wherever hosts are filled, ~lines 190-280)
-- Modify: `internal/edge/homemap.go` (`edgesHostView`, `homeEntry`, `foldHomes`, `Refresh`, new `refreshDoorKeys`, new `Owner`, new `doorRecord`)
-- Test: `internal/host/edgesfile_test.go`, `internal/edge/homemap_test.go`
+**Files:** Modify `internal/host/edgesfile.go`, `internal/edge/homemap.go`. Create `internal/edge/doorkeys.go`. Tests in `internal/host/edgesfile_test.go`, `internal/edge/homemap_test.go`, `internal/edge/doorkeys_test.go`.
**Interfaces:**
-- Produces:
- - edges-file `hosts[h].account`, `hosts[h].app` (omitempty; set for role `home` routes and aliases from the row's `release.RouteAccount(rt)` and `rt.App`; an alias carries its primary's).
- - `func (m *homeMap) Owner(host string) (account, app string, ok bool)` — from the same fold `Home` reads, including its one-label wildcard match; `ok` false when the entry has no owner (a pre-field publisher).
- - `func (m *homeMap) doorRecord(account, app string) (doorkey.Record, bool)`
- - `homeMap` gains `pub ed25519.PublicKey` (from `e.staticPub`, set in `newHomeMap`).
-
-`refreshDoorKeys` lists `doorkey.Prefix`, gets each object, verifies it with `m.pub`, and requires the record's own `account`/`app` to equal the key's path segments (a signed record copied to another app's path is refused). It swaps in the whole verified map on success. **Unlike the host fold, a record that fails verification is dropped, not kept as last good**, and a list failure keeps the previous map (a blip must not lock every router out). It runs inside `Refresh` beside `refreshHosts` and `refreshKeys`, joined into the same error.
-
-- [ ] **Step 1: Failing tests** — (a) the publisher emits `account`/`app` for a home row and for an alias (the primary's owner); (b) `Owner` returns them through the fold and through a one-label wildcard; (c) a file without the fields gives `ok == false`; (d) `refreshDoorKeys` admits a validly signed record, drops one signed by another key, drops one whose payload names a different app than its path, and after a revoke (record rewritten without the key) the next refresh no longer admits it.
-- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile' -count=1` → FAIL.
-- [ ] **Step 3: Implement** as specified.
-- [ ] **Step 4: Run** → PASS; then both packages whole.
-- [ ] **Step 5: Commit** `edges files publish each host's owner; the edge mirrors signed door keys (#175 P7)`.
+- Edges file: `edgeHost` gains `Account string \`json:"account,omitempty"\``, `App string \`json:"app,omitempty"\``, set for every role-`home` route from `release.RouteAccount(rt)`/`rt.App`, for aliases from their primary's row, and for the served catchall at `edgesfile.go:363` from the wildcard claim's account/app. `edgesFile` gains `Door bool \`json:"door,omitempty"\``, always true from this build on. It is a capability ("this edge verifies door hops and enforces their scope"), not a statement that the box runs a door of its own: a box receiving backhauled door traffic needs only the binary. An older publisher omits it, and door traffic is never backhauled to it.
+- Fold: `edgesHostView` and `homeEntry` gain `account`, `app`; `edgesFileView` gains `Door`; `homeEntry` gains `door bool` from its file. When `foldHomes` lets a route replace an alias (~`homemap.go:581`), owner and door come from the winner, never merged.
+- `func (m *homeMap) Owner(host string) (account, app string, door, ok bool)` — same lookup as `Home`, including the one-label wildcard; `ok` false when the winning entry carries no owner. Nil receiver returns zeros.
+- `type doorKeyMirror struct{ ... }` in `doorkeys.go` with `func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey, stateFile string) *doorKeyMirror`, `func (d *doorKeyMirror) Refresh(ctx context.Context) error`, `func (d *doorKeyMirror) run(stop <-chan struct{})` (30 s ticker, 10 s per-refresh budget), `func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool)` (nil-safe; also refuses a record whose `ExpiresAt` has passed at read time).
+
+Mirror rules, each with its own test:
+- list succeeds: records absent from the list are dropped immediately;
+- list fails: the previous map is kept, but once 10 minutes have passed since the last successful list the whole map is cleared;
+- a GET fails for one key: its previous record is kept under the same 10-minute bound, measured from that record's last successful read;
+- verification fails (signature, shape, expiry), or the payload's account/app differ from the path's: that record is dropped immediately;
+- revision below the high-water mark for that account/app: the stored newer record is kept (if still unexpired) and the replay is logged once per revision;
+- the high-water marks are persisted to `stateFile` (JSON map `"<account>/<app>": revision`, written atomically via temp file and rename, 0600) after any refresh that raised one, and loaded in `newDoorKeyMirror`.
+
+- [ ] **Step 1: Failing tests** — publisher: owner on a home row, on an alias (primary's), on the served catchall; `door: true` on every file this build writes. Fold: owner through exact and wildcard lookup; missing owner gives `ok == false`; a route replacing an alias carries the route's owner and door. Mirror: each rule above, plus **replay across restart**: publish revision 2 (key revoked), refresh, then put revision 1 (key present) back, refresh → not admitted; construct a new mirror over the same `stateFile`, put revision 1, refresh → still not admitted.
+- [ ] **Step 2: Run** `go test ./internal/host ./internal/edge -run 'Owner|DoorKey|EdgesFile|Fold' -count=1` → FAIL.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** both packages whole.
+- [ ] **Step 5: Commit** `edges files publish owners and door support; the edge mirrors signed door keys (#175 P7)`.
---
-### Task 5: The door's ownership rule
+### Task 5: Enforcement inside `ServeHTTP` and across the hop
-**Files:**
-- Create: `internal/edge/door.go`
-- Test: `internal/edge/door_test.go`
+**Files:** Create `internal/edge/door.go`. Modify `internal/edge/edge.go` (`ServeHTTP`, `backhaul`, `backhaulProxy`), `internal/edge/homemap.go` (`acceptHop`, `setForwarded`), `internal/edge/mesh.go`, `internal/edge/analytics.go` (`clientIP`). Tests in `internal/edge/door_test.go`, `internal/edge/mesh_test.go`.
**Interfaces:**
-- Consumes: `doorkey.ParseToken`, `doorkey.Header`, `m.doorRecord`, `m.Owner`, `e.lookupUpstreamPath`, `release.RouteAccount`.
-- Produces:
- - `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`
- - `func doorCallerFrom(ctx context.Context) (doorCaller, bool)`
- - `func (e *Edge) doorOwns(c doorCaller, host, path string) bool`
-
-```go
-// doorOwns is the door's whole authorization rule: the host (and, on a
-// path-prefix hostname, the prefix) resolves to a route of the caller's
-// account and app. A local route answers from the row itself, resolved the
-// way ServeHTTP will resolve it. A host this box does not serve answers from
-// the owner its home box published. No owner published is no.
-func (e *Edge) doorOwns(c doorCaller, host, path string) bool {
- if up, ok := e.lookupUpstreamPath(host, path); ok {
- return release.RouteAccount(up.route) == c.Account && up.route.App == c.App
- }
- acct, app, ok := e.homes.Load().Owner(host)
- return ok && acct == c.Account && app == c.App
-}
-```
-
-- [ ] **Step 1: Failing tests** — local row of the caller's app: true; local row of another app: false; path-prefix hostname with prefixes owned by two apps: true only for the caller's prefix; remote host whose published owner matches: true; remote host with no owner fields: false; unknown host: false.
-- [ ] **Step 2–4:** run, implement, run.
-- [ ] **Step 5: Commit** `edge: the door's ownership rule (#175 P7)`.
+- `type doorCallerKey struct{}`; `type doorCaller struct{ Account, App string }`; `func doorCallerFrom(ctx context.Context) (doorCaller, bool)`
+- `const hopDoorHeader = "X-Carlos-Hop-Door"` (value `<account>/<app>`)
+- `func signHopDoor(key []byte, host, scope string, now time.Time) string` and `func verifyHopDoorEitherKey(current, next []byte, host, scope, header string, now time.Time) bool` — same timestamp format and skew window as `signHop`/`verifyHop`, MAC over `canonicalizeHopHost(host) + "\n" + ts + "\n" + "door " + scope`. A door hop's signature therefore never verifies as a plain hop and the reverse.
+- `func doorOwnsRoute(c doorCaller, rt registry.Route) bool` → `release.RouteAccount(rt) == c.Account && rt.App == c.App`
+- `func (e *Edge) doorReservedHost(host string) bool` — true for the tunnel hostname and the steering probe hostname, compared after lowercasing and stripping one trailing dot (the normalization `steeringProbe` and `canonicalizeHopHost` use)
+- `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || door caller present`; used by `setForwarded` and `clientIP` only. `backhaul`'s one-hop refusal keeps reading `hopVerified`.
+
+Enforcement, in `ServeHTTP` order:
+1. **First statement after the port strip:** `door, isDoor := doorCallerFrom(r.Context())`. If `isDoor && e.doorReservedHost(host)`: `e.unknownHost(w, r); return`. If `isDoor`, skip the steering probe and tunnel branches entirely (wrap them in `if !isDoor { ... }`).
+2. `acceptHop` now also handles `X-Carlos-Hop-Door`: it deletes the header in every case; when present on a hop, it verifies with `verifyHopDoorEitherKey` instead of the plain verifier, and on success returns the request with both `hopVerifiedKey` and `doorCallerKey{scope}`. On any request without a hop header it deletes a stray `X-Carlos-Hop-Door`. So a hop-delivered door request becomes `isDoor` at the receiving edge; re-read `door, isDoor` after `acceptHop`.
+3. **Miss branch:** if `isDoor` and not hopped: `acct, app, doorOK, ok := e.homes.Load().Owner(host)`; unless `ok && doorOK && acct == door.Account && app == door.App`, answer `unknownHost`. Otherwise `backhaul`, which, for a door request, signs with `signHopDoor(key, host, door.Account+"/"+door.App, now)` and sets `X-Carlos-Hop-Door` (in `backhaulProxy`'s Rewrite, after deleting any inbound copy).
+4. **After the final `up` is chosen**, that is, after the login POST re-resolution block (~`edge.go:1416-1446`) and before the gate (`:1516`), the cache and dispatch: `if isDoor && !doorOwnsRoute(door, up.route) { e.unknownHost(w, r); return }`. The same `up` value is then used for everything after it, so authorization and dispatch cannot diverge.
+
+- [ ] **Step 1: Failing tests** (an in-process `Edge` built the way existing `edge_test.go` tests build one with local routes and a stubbed home map; `grep -n "func newTestEdge\|func testEdge" internal/edge/*_test.go`):
+ 1. door caller, local route of its app → served;
+ 2. door caller, local route of another app → unknown host, upstream never called;
+ 3. path-prefix host: `/a/` owned by the caller's app, `/b/` by another → `/a/x` served, `/b/x` unknown host;
+ 4. gate login POST to `/.carlos/gate` with `next=/b/x` (plain and percent-encoded) on that host, caller owns `/a/` only → unknown host, and the gate's password check never runs (assert via a gate stub or by a correct password not yielding a cookie);
+ 5. the tunnel hostname and the steering probe hostname (also upper-cased and with a trailing dot), with a wildcard route owned by the caller that would otherwise match → unknown host, and neither the hub nor the probe handler runs;
+ 6. remote host with published owner = caller and `door: true` → `backhaul` called with the door scope signed; published owner = caller but `door` false → unknown host; no published owner → unknown host;
+ 7. **receiving side:** a request with a valid door-hop signature for scope `bab/a` to a path-prefix host whose `/b/` belongs to app `b` → unknown host; to `/a/` → served; a plain-hop signature presented with `X-Carlos-Hop-Door` → not verified (forwarded headers stripped, no door mark);
+ 8. mesh: `verifyHopDoorEitherKey` refuses a plain `signHop` value and `verifyHopEitherKey` refuses a `signHopDoor` value.
+- [ ] **Step 2–4:** run (outside the sandbox), implement, run `go test ./internal/edge -count=1`.
+- [ ] **Step 5: Commit** `edge: enforce the door's scope on the final route, across the hop, and away from reserved handlers (#175 P7)`.
---
### Task 6: The door handler and listener
-**Files:**
-- Modify: `internal/edge/door.go`, `internal/edge/homemap.go` (`setForwarded`), `internal/edge/analytics.go` (`clientIP`)
-- Test: `internal/edge/door_test.go`
+**Files:** Modify `internal/edge/door.go`; tests in `internal/edge/door_test.go`.
**Interfaces:**
-- Produces:
- - `func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)`
- - `func (e *Edge) ServeDoor(path string) (stop func(), err error)`
- - `func forwardedTrusted(ctx context.Context) bool` → `hopVerified(ctx) || doorCaller present`, replacing `hopVerified` in `setForwarded` and `clientIP` only. **`backhaul`'s one-hop refusal keeps reading `hopVerified`**: a door request is not a hop and must be allowed to backhaul.
+- `func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)`
+- `func (e *Edge) openDoor(path string) (closeDoor func(), err error)`
```go
func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request) {
raw := r.Header.Get(doorkey.Header)
r.Header.Del(doorkey.Header)
- // The mesh's vocabulary is never a door caller's to speak: a hop mark
- // would buy the stale-map refusal and chain trust without a mesh key.
+ // The mesh's vocabulary is never a door caller's to speak.
r.Header.Del(hopHeader)
r.Header.Del(hopAuthHeader)
+ r.Header.Del(hopDoorHeader)
tok, err := doorkey.ParseToken(raw)
if err != nil {
doorRefused(w)
return
}
- rec, ok := e.homes.Load().doorRecord(tok.Account, tok.App)
+ rec, ok := e.doorKeys.Record(tok.Account, tok.App)
if !ok || !rec.Admits(tok) {
doorRefused(w)
return
}
- host := r.Host
- if h, _, err := net.SplitHostPort(host); err == nil {
- host = h
- }
- c := doorCaller{Account: tok.Account, App: tok.App}
- if !e.doorOwns(c, host, r.URL.Path) {
- e.unknownHost(w, r)
- return
- }
- r.RemoteAddr = net.JoinHostPort(doorClientIP(r), "0")
- e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, c)))
+ ip := doorClientIP(r)
+ r.Header.Del("Forwarded")
+ r.Header.Set("X-Forwarded-For", ip)
+ r.RemoteAddr = net.JoinHostPort(ip, "0")
+ e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App})))
}
// doorClientIP is the caller's claim of who the visitor is: the last
-// X-Forwarded-For element, if it parses as an IP. Anything else is the
-// loopback address, so a malformed chain never reaches the gate's rate-limit
-// key or the visitor hash.
+// X-Forwarded-For element across every header line, if it parses as an IP.
+// Anything else is the loopback address. The door then REPLACES the header
+// with that one address, so nothing unparsed travels on to the app, the
+// gate, analytics or a backhaul.
func doorClientIP(r *http.Request) string {
- chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
+ chain := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
if i := strings.LastIndexByte(chain, ','); i >= 0 {
chain = chain[i+1:]
}
@@ -623,61 +646,49 @@ func doorRefused(w http.ResponseWriter) {
}
```
-`ServeDoor(path)`: `os.Remove(path)` a stale socket, `net.Listen("unix", path)`, `os.Chmod(path, 0o660)`, serve `http.Server{Handler: http.HandlerFunc(e.serveDoor), ReadHeaderTimeout: 10 * time.Second}` in a goroutine; `stop` closes the server and removes the socket. The socket's group comes from `/run/carlos`'s setgid `carlos-sockets` (bootstrap.go:61, 86-87); do not chgrp.
-
-- [ ] **Step 1: Failing tests** (httptest with an in-process `Edge`, a local upstream, and a homeMap stub holding one signed record):
- 1. no key → 401, upstream never called;
- 2. a valid key for a local host of its app → upstream called; the upstream sees no `X-Carlos-Door-Key` and sees the caller's other headers (e.g. `X-Tito-Auth-User`) untouched;
- 3. a valid key for another app's host → the unknown-host answer, upstream never called;
- 4. a request carrying `X-Carlos-Hop` and `X-Carlos-Hop-Auth` → neither reaches the upstream, and a miss goes to `backhaul`, not `staleHopRefusal` (Review Focus 1);
- 5. `X-Forwarded-For: 203.0.113.9` → the upstream's `X-Forwarded-For` ends in `203.0.113.9`; `X-Forwarded-For: garbage` and none → it ends in `127.0.0.1` (Review Focus 5);
- 6. a gated route still demands the password (the gate's refusal page, not the upstream);
- 7. `ServeDoor` on a temp path creates a 0660 socket and a real unix-socket client round-trips case 2.
-- [ ] **Step 2–4:** run, implement, run (`go test ./internal/edge -count=1`, outside the sandbox).
+`openDoor(path)`: refuse if `e.doorKeys == nil`; `os.Remove(path)` a stale socket; `net.Listen("unix", path)`; `os.Chmod(path, 0o660)`; serve `&http.Server{Handler: http.HandlerFunc(e.serveDoor), ReadHeaderTimeout: 10 * time.Second}` in a goroutine; `closeDoor` shuts the server down with a 5 s context and removes the socket. Group comes from `/run/carlos`'s setgid `carlos-sockets` (bootstrap.go:61, 86-87); do not chgrp.
+
+- [ ] **Step 1: Failing tests:**
+ 1. no key, malformed key, revoked key → 401 with `Cache-Control: no-store`, upstream never called;
+ 2. valid key → served; the upstream sees no `X-Carlos-Door-Key` and sees `X-Tito-Auth-User` untouched;
+ 3. inbound `X-Carlos-Hop`, `X-Carlos-Hop-Auth`, `X-Carlos-Hop-Door` → none reaches the upstream, and a miss goes to the door's owner check, never `staleHopRefusal`;
+ 4. X-Forwarded-For cases: `203.0.113.9` → upstream sees exactly `203.0.113.9`; `garbage`, empty, absent, a trailing empty element (`203.0.113.9, `), and two header lines whose last element is `198.51.100.7` → upstream sees exactly `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `127.0.0.1`, `198.51.100.7`; `clientIP` returns the same value;
+ 5. a gated route still answers with the gate's page;
+ 6. `openDoor` on a temp path makes a 0660 socket; a real unix-socket client round-trips case 2; `closeDoor` removes the socket; `openDoor` with a nil mirror returns an error and creates nothing.
+- [ ] **Step 2–4:** run, implement, run.
- [ ] **Step 5: Commit** `edge: the edge-local door (#175 P7)`.
---
-### Task 7: Arm the door on the box, dormant by default
+### Task 7: Lifecycle: arm the door after the mirrors, close it on shutdown
-**Files:**
-- Modify: `internal/host/agent.go` (the ready hook passed to `edge.RunWithActivatorReady`, ~line 510)
-- Test: `internal/host/agent_test.go` (or the nearest existing ready-hook test)
+**Files:** Modify `internal/edge/edge.go` (a new `RunOption`; the home-map arming block ~line 2985; the shutdown block ~line 3110; the dev-mode branch that returns early), `internal/host/agent.go` (the options passed to `edge.RunWithActivatorReady`, ~line 510). Tests in `internal/edge/edge_test.go` (or `door_test.go`) and `internal/host/agent_test.go`.
-```go
- // The edge-local door (#175 P7), dormant unless the box names a path.
- // /run/carlos/edge-local.sock is the convention; the directory's
- // setgid gives the socket group carlos-sockets, so only enrolled
- // consumers can dial it, and the door key is the identity on top.
- if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" {
- if _, err := e.ServeDoor(p); err != nil {
- log.Printf("agent: edge-local door at %s: %v (the door is off)", p, err)
- } else {
- log.Printf("agent: edge-local door listening at %s", p)
- }
- }
-```
+**Interfaces:**
+- `func WithEdgeDoor(path, stateFile string) RunOption` — records both on the `Edge`.
+- In the run path, immediately after `e.homes.Store(hm)` and its `go hm.run(...)`: when a door path is set and `e.staticStore` and `e.staticPub` are non-nil, build `e.doorKeys = newDoorKeyMirror(e.staticStore, e.staticPub, stateFile)`, run one synchronous `Refresh` with a 10 s budget (log a failure, carry on: every key is refused until a refresh lands), start `go e.doorKeys.run(...)`, then `openDoor(path)` and keep `closeDoor` on the `Edge`. With no store or no public key: log that the door is off and why. In dev mode: log that the door is off in dev mode.
+- Shutdown calls `closeDoor` before the HTTP servers' `Shutdown`.
+- agent: `if p := strings.TrimSpace(os.Getenv("CARLOS_EDGE_DOOR")); p != "" { opts = append(opts, edge.WithEdgeDoor(p, filepath.Join(dataDir, "door-keys.state"))) }`.
-- [ ] **Step 1: Failing test** — with `CARLOS_EDGE_DOOR` unset no socket exists; set to a temp path, the socket appears. If the ready hook is not reachable from a unit test, extract `armEdgeDoor(e *edge.Edge, getenv func(string) string)` and test that.
-- [ ] **Step 2–4:** run, implement, run; `GOOS=darwin go build ./... && GOOS=windows go build ./...`.
-- [ ] **Step 5: Commit** `agent: arm the edge-local door when CARLOS_EDGE_DOOR names a path (#175 P7)`.
+- [ ] **Step 1: Failing tests:** extract the arming block into `func (e *Edge) armDoor(ctx context.Context) error` so it is testable: with a `DirStore` holding one signed record and a temp socket path, `armDoor` then a real unix-socket request with the key → served; before any record exists → 401 (not a panic); after `closeDoor` the socket is gone. Agent: with `CARLOS_EDGE_DOOR` unset no `WithEdgeDoor` option is passed (extract `edgeDoorOptions(getenv func(string) string, dataDir string) []edge.RunOption`).
+- [ ] **Step 2–4:** run, implement, run; then `GOOS=darwin go build ./... && GOOS=windows go build ./...`.
+- [ ] **Step 5: Commit** `edge: arm the door after its mirrors, close it on shutdown; agent: CARLOS_EDGE_DOOR (#175 P7)`.
---
### Task 8: Docs and copy review
-**Files:**
-- Modify: `docs/site/cli.md` (the `carlos instances` section) and the instances guide page that covers app-owned routers (find with `grep -ln "socket-consumer\|carlos-sockets" docs/site`).
+**Files:** Modify `docs/site/cli.md` (the `carlos instances` section: a `carlos instances door-key` entry, and a short passage on the edge-local door: what it is, `CARLOS_EDGE_DOOR`, the socket path, the `Host:` and `X-Carlos-Door-Key:` headers, enrolling the calling user with `carlos bootstrap --socket-consumer <user>`, the freshness contract in plain words).
-- [ ] **Step 1:** Draft the strings into `copy-review/strings.json`: every CLI line Task 3 prints, the console errors Task 2 returns, `door key refused`, and the docs prose (what the door is, how to mint a key, how to point a router at `/run/carlos/edge-local.sock` with `Host:` and `X-Carlos-Door-Key:`, that the box must set `CARLOS_EDGE_DOOR`, that the calling process must be enrolled with `carlos bootstrap --socket-consumer <user>`).
-- [ ] **Step 2:** Run the copy-review server; apply the approved text verbatim to code and docs.
-- [ ] **Step 3:** `go test ./... ` outside the sandbox; cross-compile.
+- [ ] **Step 1:** Draft into `copy-review/strings.json`: every CLI line Task 3 prints, every console error Task 2 returns, `door key refused`, and the docs prose.
+- [ ] **Step 2:** Run the copy-review server; apply the approved text verbatim.
+- [ ] **Step 3:** `go test ./...` outside the sandbox; cross-compile.
- [ ] **Step 4: Commit** `docs: the edge-local door and door keys (#175 P7)`.
---
-## Rollout (not a task; for the handoff)
+## Rollout (for the handoff, not a task)
-1. Consoles first (they mint the records nobody reads yet), then boxes (they publish owners and mirror keys, still dormant).
-2. On carlos-tito-ie, with scoped approval: set `CARLOS_EDGE_DOOR=/run/carlos/edge-local.sock` in the agent's environment, restart the agent, confirm the socket is `srw-rw---- carlos carlos-sockets`.
+1. Consoles first (they publish records nothing reads yet), then boxes (they publish owners and `door: true`, and verify door hops, but open no socket).
+2. On carlos-tito-ie, with scoped approval: set `CARLOS_EDGE_DOOR=/run/carlos/edge-local.sock`, restart the agent, confirm the socket is `srw-rw---- carlos carlos-sockets`. Boxes that home Tito instances elsewhere need only the new binary to receive door traffic; they need no door of their own.
3. Tito: mint a key (`carlos instances door-key create --app titogo`), deliver it to the router, relay and import-sync processes, enrol any of their users not already in `carlos-sockets`, and switch each from computed socket paths to the door. Only after that may P9 run on that box.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR.md b/docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR.md
new file mode 100644
index 000000000..508e20cf2
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-P7-EDGE-DOOR.md
@@ -0,0 +1,55 @@
+1. **P1 — The console can be tricked into signing a key for another app.**
+ Task 2’s `doorKeyUpdate` verifies the existing record but never compares its signed account/app with the requested account/app ([plan:433](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:433)). A bucket writer with membership in app A can copy B’s signed record into A’s record path, mint through A’s API, then copy the newly signed record back to B. The returned secret’s hash now appears in a record naming B; changing the token’s account/app fields produces a valid B credential.
+
+ **Fix:** Require exact account/app equality immediately after verification, before mutation or listing. Test the complete copied-record attack and assert that it produces no signed write.
+
+2. **P1 — Remote authorization loses the app boundary during backhaul.**
+ Task 5 authorizes remote traffic using `Owner(host)`, without the path. A remote front hostname owned by A can route `/prefix` to B: that is an explicitly supported cross-app arrangement ([internal/edge/edge.go:1164](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1164)). A’s door key therefore passes the sending edge, and the receiving edge serves B. The door’s context mark does not cross HTTP; the current backhaul sends ordinary mesh headers without an authenticated app restriction ([internal/edge/edge.go:2772](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:2772)). Stale ownership after reassignment creates the same problem even without path routing.
+
+ **Fix:** Carry an authenticated door scope across the hop and enforce it against the receiving edge’s final resolved route. Unsupported receivers must fail closed. Test with two edges, different apps behind one hostname, and stale published ownership.
+
+3. **P1 — Local authorization checks a different route from the gate login.**
+ `serveDoor` checks `r.URL.Path`, but a POST to `/.carlos/gate` later selects its route from the decoded, sanitized `next` value ([internal/edge/edge.go:1416](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1416), [internal/edge/edge.go:1438](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1438)). A front-route key for A can submit passwords against B’s prefixed route and receive B’s gate cookie on success. Conversely, B’s legitimate key cannot pass the initial check when the login path resolves to A.
+
+ There is also a race between the door lookup and `ServeHTTP`’s separate lookup: a reload can change the authorized route between them.
+
+ **Fix:** Check door scope against the final selected upstream, after login re-resolution and before gate processing, cache access, or dispatch. Keep authorization and dispatch bound to that same upstream. Add cross-app login tests, including encoded `next` paths, and a deterministic reload test.
+
+4. **P1 — Reserved handlers can override a route that passed ownership checks.**
+ A matching wildcard or shadowed exact route can satisfy `doorOwns`, yet `ServeHTTP` handles the steering probe, tunnel registration, or store door before consulting that route ([internal/edge/edge.go:1329](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1329), [internal/edge/edge.go:1342](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1342)). Wildcard lookup makes this possible without an exact reserved-host row ([internal/edge/edge.go:1107](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:1107)). Those handlers retain their own protections, but the door’s promised scope is already violated.
+
+ **Fix:** Explicitly exclude reserved handlers from door traffic, including authenticated door traffic received through backhaul. Match the steering probe’s case/trailing-dot normalization. Test overlapping wildcard and exact routes, not only reserved hosts absent from the routing map.
+
+5. **P1 — A signed pre-revocation record can restore a revoked key.**
+ Task 1 verifies signature and shape only; `UpdatedAt` has no freshness or rollback semantics ([plan:293](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md:293)). Under the plan’s bucket-writer threat model, an attacker can save a valid record, wait for revocation, then restore it. Every subsequent successful mirror accepts the revoked key again. Dropping invalid signatures does not prevent this.
+
+ **Fix:** Specify an enforceable revocation/freshness mechanism before building: a trusted current generation or revocation authority, with explicit cold-start behavior and bounded stale acceptance. An in-memory timestamp comparison alone does not survive restart. Test revoke → replay old signed record → refresh, both before and after restarting the verifier.
+
+6. **P2 — The proposed IP fallback leaves the malformed header authoritative.**
+ `doorClientIP` sanitizes only `RemoteAddr`. After the proposed trust widening, `setForwarded` overwrites its generated address with the original XFF header ([internal/edge/homemap.go:912](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/homemap.go:912)); `clientIP` likewise returns a nonempty last element without parsing it ([internal/edge/analytics.go:76](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/analytics.go:76)). Thus `X-Forwarded-For: garbage` still reaches the upstream and analytics as `garbage`, despite `RemoteAddr` becoming loopback. Task 6’s stated test would fail.
+
+ **Fix:** Normalize the trusted header before entering `ServeHTTP`, replacing invalid or empty visitor claims with the fallback. Define how malformed earlier chain elements are handled. Test multiple header lines, trailing empty elements, local forwarding, backhaul, and analytics.
+
+7. **P2 — “Revoked within 30 seconds” is not the mirror’s actual failure contract.**
+ Task 4 retains the previous credential map on list failure indefinitely. Also, the existing refresh runs host listing and reads before key refresh, with a two-minute timeout ([internal/edge/homemap.go:250](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/homemap.go:250), [internal/edge/homemap.go:118](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/homemap.go:118)). A slow host poll can delay credential refresh beyond one interval.
+
+ **Fix:** Distinguish normal propagation time from maximum stale authorization. Give credential refresh a bounded budget and specify list failure, individual GET failure, deletion, and signature failure separately. Test each transition from a previously admitted key. Retaining availability during a brief outage is reasonable; indefinite retention cannot support a bounded revocation promise.
+
+8. **P2 — The ready hook runs before the credential mirror exists.**
+ Task 7 opens the listener from `ready(e)`, called at [internal/edge/edge.go:2927](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:2927). The home map is initialized later at [internal/edge/edge.go:2989](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/edge.go:2989); dev mode returns before reaching that initialization. Consequently, socket existence does not demonstrate a usable door. Depending on the new methods’ nil handling, early requests either fail authentication or panic. Task 7 also discards the listener’s stop function.
+
+ **Fix:** Define mirror readiness and nil behavior explicitly, start the listener at the appropriate lifecycle point, and retain its shutdown handle. Test an authenticated request through actual startup wiring, plus shutdown; the proposed socket-appearance test misses these failures.
+
+9. **P2 — Several implementation steps reference the wrong interfaces or omit important integration cases.**
+ Task 3 puts `runInstances` dispatch in `instances_cli.go`, but it lives at [cli.go:284](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/cli.go:284). Task 2’s `fx.srv.URL` does not compile: `srv` is `*Server`; the HTTP server is `fx.ts`, and `fx.pub` already exists ([internal/console/server_test.go:32](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/console/server_test.go:32)). The fixture helpers are in `apiinstances_test.go`, not `apiinstancemove_test.go`.
+
+ Task 4 must also cover the separate catchall publisher at [internal/host/edgesfile.go:363](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/host/edgesfile.go:363). Ownership must follow the winning dial target when `foldHomes` replaces an alias with a real route ([internal/edge/homemap.go:581](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/edge/homemap.go:581)).
+
+ **Fix:** Correct the file list and fixture code; add published-catchall and conflicting-owner fold tests. Name the documentation destination explicitly: the suggested socket-consumer search finds no existing page in `docs/site`.
+
+10. **P3 — Root signing is acceptable here; existing release grants are not door-scoped grants.**
+ The console already holds this key for promotion ([internal/console/server.go:485](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/console/server.go:485)). `release.Grant` delegates release rungs, not credential-record authority ([internal/release/grant.go:58](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/release/grant.go:58)). Reusing it would require a new capability contract.
+
+ I found no current cross-kind replay involving the proposed door record: it requires `v:1`, whereas release pointers and the other root-signed control records require `schema:1` and additional shape checks, for example [internal/release/release.go:448](/home/paulca/amadan.net/carlos/platform-instance-moves-p7/internal/release/release.go:448). Still, add a door-specific signed domain prefix and cross-kind rejection tests so future schema changes cannot accidentally create overlap. The specified no-key 503 behavior is appropriate.
+
+Once the P1 fixes are specified, the plan is ready to build with the P2 corrections incorporated into their affected tasks.
\ No newline at end of file
diff --git a/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R2.md b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R2.md
new file mode 100644
index 000000000..4b013bebf
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR-R2.md
@@ -0,0 +1,18 @@
+# Brief: P7 edge-door plan, review round 2
+
+Round 1's brief is `CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md` and its findings are
+`CODEX-FINDINGS-P7-EDGE-DOOR.md`, both beside the plan. The plan,
+`docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md`, is now
+revision 2. It opens with a table giving each round-1 finding's disposition.
+
+1. **Disposition table first.** For each of the ten round-1 findings, say in one line whether revision 2's fix actually closes it (closed / partly / not closed), and why where it isn't closed.
+2. **Then only new findings,** ranked P1/P2/P3, each with the failure scenario and a fix. Look especially at what revision 2 introduced:
+ - the console database as authority and the unconditional publish;
+ - the revision high-water state file, and its behaviour on a fresh box, after data loss, and when the console's revision counter restarts;
+ - the door-hop MAC and the `door` capability flag in edges files;
+ - enforcement moved into `ServeHTTP`, including the ordering against the tunnel and steering branches and the re-read of the door mark after `acceptHop`;
+ - `openDoor` lifecycle placement.
+3. The rulings in the design record's §0 are fixed. Don't re-argue them, or round-1 dispositions marked accepted, unless the fix itself is wrong.
+4. Check every `path:line` you cite against this checkout.
+
+End with one line saying whether the plan is ready to build. Keep it under about 1,500 words.