docs: P7 edge-door plan and Astra review brief (#175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+741
−0
docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md+683 −0docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md+58 −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| new file mode 100644 |
| index 000000000..f3cd0aba4 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md |
| @@ -0,0 +1,683 @@ |
| +# Instance moves P7: the edge-local door — Implementation Plan |
| + |
| +> **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. |
| + |
| +**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`. |
| + |
| +**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. |
| + |
| +## 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. |
| +- **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. |
| +- **Tests with unix sockets** run outside the Claude sandbox with `TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache GOFLAGS=-buildvcs=false`. |
| + |
| +## 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. |
| + |
| +--- |
| + |
| +## File structure |
| + |
| +| 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. | |
| +| `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). | |
| + |
| +--- |
| + |
| +### Task 1: The door-key package |
| + |
| +**Files:** |
| +- Create: `internal/doorkey/doorkey.go` |
| +- Test: `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. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +package doorkey |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestTokenRoundTripAndAdmit(t *testing.T) { |
| + raw, entry, err := NewToken("bae", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(raw, "cdk1.bae.titogo.") { |
| + 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) |
| + } |
| + rec := Record{V: 1, Account: "bae", App: "titogo", Keys: []Entry{entry}} |
| + if !rec.Admits(tok) { |
| + t.Fatal("the record does not admit its own key") |
| + } |
| + tok.Secret = strings.Repeat("A", 43) |
| + if rec.Admits(tok) { |
| + t.Fatal("a wrong secret was admitted") |
| + } |
| +} |
| + |
| +func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) { |
| + raw, entry, _ := NewToken("bae", "titogo") |
| + tok, _ := ParseToken(raw) |
| + other := Record{V: 1, Account: "bae", App: "other", Keys: []Entry{entry}} |
| + if other.Admits(tok) { |
| + t.Fatal("a record for another app admitted the key") |
| + } |
| +} |
| + |
| +func TestParseTokenRefusesMalformed(t *testing.T) { |
| + for _, raw := range []string{ |
| + "", "cdk1", "cdk2.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43), |
| + "cdk1.BAE.titogo.0123456789abcdef." + strings.Repeat("A", 43), |
| + "cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43), |
| + "cdk1.bae.titogo.0123456789abcdef.short", |
| + "cdk1.bae.titogo.0123456789abcdef." + strings.Repeat("A", 43) + ".extra", |
| + } { |
| + if _, err := ParseToken(raw); err == nil { |
| + t.Errorf("ParseToken(%q) accepted a malformed token", raw) |
| + } |
| + } |
| +} |
| + |
| +func TestSignVerify(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) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := Verify(pub, wire) |
| + if err != nil || len(got.Keys) != 1 || got.Keys[0].ID != entry.ID { |
| + t.Fatalf("Verify = %+v, %v", got, err) |
| + } |
| + otherPub, _, _ := ed25519.GenerateKey(rand.Reader) |
| + if _, err := Verify(otherPub, wire); 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") |
| + } |
| +} |
| + |
| +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") |
| + rec.Keys = append(rec.Keys, e) |
| + } |
| + if _, err := Sign(priv, rec); 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 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. |
| +package doorkey |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "regexp" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + Header = "X-Carlos-Door-Key" |
| + Prefix = "control/door-keys/" |
| + MaxKeys = 8 |
| + scheme = "cdk1" |
| +) |
| + |
| +var ( |
| + appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + idRe = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| + secretRe = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) |
| +) |
| + |
| +func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" } |
| + |
| +type Token struct{ Account, App, ID, Secret string } |
| + |
| +type Entry struct { |
| + ID string `json:"id"` |
| + SHA256 string `json:"sha256"` |
| + Label string `json:"label,omitempty"` |
| + CreatedAt time.Time `json:"created_at"` |
| +} |
| + |
| +type Record struct { |
| + V int `json:"v"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Keys []Entry `json:"keys"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| +} |
| + |
| +type signedWrapper struct { |
| + Payload string `json:"payload"` |
| + Sig string `json:"sig"` |
| +} |
| + |
| +func digest(secret string) string { |
| + sum := sha256.Sum256([]byte(secret)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +func NewToken(account, app string) (string, Entry, error) { |
| + if !release.ValidAccount(account) || !appRe.MatchString(app) { |
| + return "", Entry{}, fmt.Errorf("doorkey: invalid account or app %q/%q", account, app) |
| + } |
| + var id [8]byte |
| + var secret [32]byte |
| + if _, err := rand.Read(id[:]); err != nil { |
| + return "", Entry{}, err |
| + } |
| + if _, err := rand.Read(secret[:]); err != nil { |
| + return "", Entry{}, err |
| + } |
| + 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 |
| +} |
| + |
| +func ParseToken(raw string) (Token, error) { |
| + f := strings.Split(raw, ".") |
| + if len(f) != 5 || f[0] != scheme || !release.ValidAccount(f[1]) || !appRe.MatchString(f[2]) || |
| + !idRe.MatchString(f[3]) || !secretRe.MatchString(f[4]) { |
| + return Token{}, errors.New("doorkey: malformed door key") |
| + } |
| + 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. |
| +func (r Record) Admits(t Token) bool { |
| + if r.Account != t.Account || r.App != t.App { |
| + return false |
| + } |
| + want := digest(t.Secret) |
| + for _, e := range r.Keys { |
| + if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func (r Record) valid() error { |
| + if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) { |
| + return errors.New("doorkey: record shape") |
| + } |
| + if len(r.Keys) > MaxKeys { |
| + return fmt.Errorf("doorkey: %d keys, at most %d", len(r.Keys), MaxKeys) |
| + } |
| + for _, e := range r.Keys { |
| + if !idRe.MatchString(e.ID) || len(e.SHA256) != 64 { |
| + return errors.New("doorkey: record entry shape") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) { |
| + if len(priv) != ed25519.PrivateKeySize { |
| + return nil, errors.New("doorkey: no signing key") |
| + } |
| + if err := rec.valid(); err != nil { |
| + return nil, err |
| + } |
| + payload, err := json.Marshal(rec) |
| + if err != nil { |
| + return nil, err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + return json.MarshalIndent(signedWrapper{Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}, "", " ") |
| +} |
| + |
| +func Verify(pub ed25519.PublicKey, wire []byte) (Record, error) { |
| + if len(pub) != ed25519.PublicKeySize { |
| + return Record{}, errors.New("doorkey: no public key") |
| + } |
| + var w signedWrapper |
| + if err := json.Unmarshal(wire, &w); err != nil { |
| + return Record{}, err |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(w.Sig) |
| + if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(w.Payload), sig) { |
| + return Record{}, errors.New("doorkey: bad signature") |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(w.Payload) |
| + if err != nil { |
| + return Record{}, err |
| + } |
| + var rec Record |
| + if err := json.Unmarshal(payload, &rec); err != nil { |
| + return Record{}, err |
| + } |
| + return rec, rec.valid() |
| +} |
| +``` |
| + |
| +- [ ] **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)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Console doors to mint, list and revoke |
| + |
| +**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) |
| + |
| +**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. |
| + |
| +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`. |
| + |
| +- [ ] **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). |
| + |
| +```go |
| +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"}`) |
| + 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) |
| + } |
| + 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) |
| + } |
| + code, _, out = rawReq(t, http.MethodGet, u, 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 { |
| + 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") |
| + } |
| +} |
| +``` |
| + |
| +(`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 |
| + } |
| + rec, err := update() |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return update() |
| + } |
| + return rec, err |
| +} |
| +``` |
| + |
| +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. |
| + |
| +Register in `authcli.go` beside `instance-token`: |
| + |
| +```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. |
| + 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)`. |
| + |
| +--- |
| + |
| +### Task 3: `carlos instances door-key` |
| + |
| +**Files:** |
| +- Create: `doorkeys_cli.go`, `doorkeys_cli_test.go` |
| +- Modify: `instances_cli.go` (`runInstances` dispatch) |
| + |
| +**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>`. |
| + |
| +- [ ] **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 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 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 |
| + |
| +**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` |
| + |
| +**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)`. |
| + |
| +--- |
| + |
| +### Task 5: The door's ownership rule |
| + |
| +**Files:** |
| +- Create: `internal/edge/door.go` |
| +- Test: `internal/edge/door_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)`. |
| + |
| +--- |
| + |
| +### 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` |
| + |
| +**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. |
| + |
| +```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. |
| + r.Header.Del(hopHeader) |
| + r.Header.Del(hopAuthHeader) |
| + tok, err := doorkey.ParseToken(raw) |
| + if err != nil { |
| + doorRefused(w) |
| + return |
| + } |
| + rec, ok := e.homes.Load().doorRecord(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))) |
| +} |
| + |
| +// 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. |
| +func doorClientIP(r *http.Request) string { |
| + chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") |
| + if i := strings.LastIndexByte(chain, ','); i >= 0 { |
| + chain = chain[i+1:] |
| + } |
| + if ip := net.ParseIP(strings.TrimSpace(chain)); ip != nil { |
| + return ip.String() |
| + } |
| + return "127.0.0.1" |
| +} |
| + |
| +func doorRefused(w http.ResponseWriter) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Error(w, "door key refused", http.StatusUnauthorized) |
| +} |
| +``` |
| + |
| +`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). |
| +- [ ] **Step 5: Commit** `edge: the edge-local door (#175 P7)`. |
| + |
| +--- |
| + |
| +### Task 7: Arm the door on the box, dormant by default |
| + |
| +**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) |
| + |
| +```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) |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **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)`. |
| + |
| +--- |
| + |
| +### 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`). |
| + |
| +- [ ] **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 4: Commit** `docs: the edge-local door and door keys (#175 P7)`. |
| + |
| +--- |
| + |
| +## Rollout (not a task; for the handoff) |
| + |
| +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`. |
| +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-REVIEW-BRIEF-P7-EDGE-DOOR.md b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md| new file mode 100644 |
| index 000000000..8edfcbbf2 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-P7-EDGE-DOOR.md |
| @@ -0,0 +1,58 @@ |
| +# Brief: review the P7 edge-door plan |
| + |
| +Review `docs/superpowers/plans/2026-09-28-instance-moves-p7-edge-door.md` |
| +against the code in this checkout (branch `instance-moves-p7`, based on |
| +`main` with P0 merged) before anyone builds it. |
| + |
| +The design it implements is on branch `docs-instance-moves-design`: |
| +`git show origin/docs-instance-moves-design:docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md`. |
| +Read §0 (Paul's rulings, fixed: do not re-argue them), §8 and §10. Your own |
| +recommendations that became those rulings are in |
| +`CODEX-RULINGS-INSTANCE-MOVES.md` beside it. |
| + |
| +## What was decided while planning, and why |
| + |
| +- **The credential is a console-minted random key whose SHA-256 sits in a |
| + record under `control/door-keys/<account>/<app>.json`, signed with the |
| + deployment's root release key.** Boxes verify it against |
| + `CARLOS_RELEASE_PUBKEY`, which the edge already holds (`e.staticPub`). |
| + Rejected: the relay's HKDF-per-box-master pattern (a master staged by hand |
| + on every box, an infra change per box), and an unsigned hash record (any |
| + bucket writer could add a key, discussion #171). |
| +- **Remote ownership comes from new `account`/`app` fields in each box's |
| + edges file**, so the request path never reads the bucket (the edge's rule). |
| + A host whose publisher predates the fields is refused. |
| +- **The door calls the ordinary `Edge.ServeHTTP`** after authenticating, |
| + stripping the key and the hop headers, checking ownership, and setting a |
| + context mark. The mark widens X-Forwarded-For trust in `setForwarded` and |
| + `clientIP` only; the backhaul one-hop refusal still reads `hopVerified`. |
| +- **Dormant:** no socket unless `CARLOS_EDGE_DOOR` names a path. |
| + |
| +## What to look for |
| + |
| +1. **Authorization holes.** Any request a door caller can shape that reaches |
| + an instance of another app, or the tunnel host, the store door, the |
| + steering probe, or any other `ServeHTTP` branch that runs before the route |
| + lookup. Specifically check the login POST, which `ServeHTTP` re-resolves by |
| + its `next` parameter (edge.go around 1400-1446), not by `r.URL.Path`, while |
| + the door checks ownership by `r.URL.Path`. |
| +2. **Trust leaks.** Whether rewriting `RemoteAddr` from the caller's |
| + X-Forwarded-For, or the `forwardedTrusted` widening, lets a door caller |
| + spoof anything the edge or the gate relies on beyond the visitor address. |
| +3. **The signing choice.** Is signing door-key records with the root release |
| + key (the key that moves `stable`) acceptable, or does it need a scoped |
| + grant (`internal/release/grant.go`)? Does any existing verifier accept a |
| + root-signed wrapper of a different kind, so that a door-key record could be |
| + replayed as something else, or vice versa? |
| +4. **Revocation and failure behaviour.** The 30 s mirror; dropping records |
| + that fail verification but keeping the last map on a list failure; what a |
| + console with no signing key does. |
| +5. **Plan quality.** Tasks whose interfaces don't line up, steps that name |
| + helpers or fixture fields that don't exist (check each against the code), |
| + tests that would pass without the change, and anything the rulings require |
| + that no task builds. |
| + |
| +Cite code as `path:line` and check each citation. Findings first, ranked |
| +P1 (must fix before building) / P2 (should fix) / P3 (note), each with the |
| +failure scenario and a suggested fix. Then one line on whether the plan is |
| +ready to build once the P1s are fixed. Keep it under about 2,000 words. |