carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

Instance moves P7: the edge-local door — Implementation Plan (rev 6)

> 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 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 revision and a 72-hour expiry, re-signed every 12 hours, and written by one serialized, compare-and-swap publisher that always rebuilds from the database. Every edge mirrors the projections on their own 30-second loop and verifies them against CARLOS_RELEASE_PUBKEY. 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, removes every forwarded-address header, marks the request as a door caller and hands it to Edge.ServeHTTP. For a host this box serves, ServeHTTP refuses reserved handlers to door callers and checks ownership against the final upstream it is about to serve. For a host served elsewhere, the door checks the owner the home box published, which a box publishes only for a host whose every route has one owner, and then backhauls it as an ordinary hop.

Security model (rev 6; Paul's ruling, 2026-09-28)

A door request is a request from this box. Paul ruled (2026-09-28) that the door believes no caller-supplied visitor address. Rounds 4 and 5 showed that any invented representation (unknown, then RFC 7600's 192.0.0.8) has some framework that reads it as local, private or trusted (Rails' local? fallback; python-ipware, where Python classes 192.0.0.8 as private). So the door invents nothing. It presents the box's own public address, the one its edges file publishes (readPublicIP, internal/host/edgesfile.go:534). That is exactly what the app would see if the calling process, on this box, requested the instance's public URL, since the request would leave the box and come back through an edge from that address. So the door grants nothing a public request from the same box lacks, whatever framework the app runs.

  • serveDoor deletes every header that claims a client address, then sets RemoteAddr to <box public address>:0. The headers deleted are: - stripForwardedHeaders (homemap.go:806): Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto; - doorAddressHeaders: X-Forwarded, Forwarded-For, Client-IP, X-Real-IP, True-Client-IP, CF-Connecting-IP, Fastly-Client-IP, X-Client-IP, X-Cluster-Client-IP, X-Originating-IP.
  • The ordinary SetXForwarded in setForwarded then writes that address into X-Forwarded-For, as it does for any client. The deletion is belt and braces. With a global address in X-Forwarded-For, the resolvers Astra traced (Rails RemoteIp, Express proxy-addr, Laravel/Symfony, Go realip, chi, python-ipware) pick it, exactly as they do for a public request.
  • setForwarded's only door branch sets X-Forwarded-Proto: https, the scheme the same request would have had through the public URL. The far edge of a backhaul gives https by its own TLS.
  • Everything downstream then treats the request as a request from the box: the gate's limiter, analytics' visitor hash, the response cache, and a backhaul. Backhauled, the far edge believes the near edge's chain (the box address). If it cannot verify the hop, it uses the near edge's peer address, which is the same box. There is no separate degraded mode. Door requests are not excluded from the cache. Rev 5's bypass is dropped, because a request from the box is cached like any public request. The cache keys Vary on inbound headers (edge.go:1583, :1702) while forwarding rewrites them, and that mismatch is pre-existing for all traffic. It is filed as its own discussion.
  • No public address, no door. If the agent cannot read the box's public address, the door stays closed and says so in the journal. It never falls back to loopback, a private address or a placeholder.
  • An app that needs the buyer's address has its own router carry it in the app's own signed header, as Tito already does for identity (X-Tito-Auth-*). The platform reads no such header.

What the door key is for, then. It says which app's processes are calling, and confines them to that app's hosts (ruling Q3). With no privilege behind the door, a gap in that confinement lets a caller reach a host through the door that it could already reach from the internet, with less than an internet request carries. That is why the residuals below are accepted. They are hygiene, not security boundaries:

| residual | bound | |---|---| | a bucket writer (#171) forges or replays a door-key record | a replayed record is admitted until its own expires_at, at most 72 h after signing | | a bucket writer forges an edges-file owner, or ownership is stale | until an honest publish replaces it; unbounded if the owner's box never rewrites the file | | ownership changes (a host moves between apps) | until every edge's home map has refreshed | | the console's database is restored from backup | keys minted after the backup vanish; keys revoked after it come back until revoked again |

The credential machinery (signed, revisioned, expiring records; one serialized publisher) stays as reviewed in rounds 1–3. It costs little, keeps keys unforgeable by a bucket writer, and would be the base if the door ever needed a privilege. Any such privilege needs its own design and review.

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 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). |

Revision 6: Astra round 5 dispositions

| # | finding | disposition | |---|---|---| | R5-1 | python-ipware: X-Forwarded/Forwarded-For survive, and Python classes 192.0.0.8 as private | Accepted, fixed by removing the invented address: the door presents the box's own public address (Security model), which is global in every classifier and is what a public request from the box shows. Both headers join the strip list anyway. | | R5-2 | the cache bypass does not cross a backhaul | Superseded: a door request is a request from the box and is cached like one; no bypass. The pre-existing inbound-Vary mismatch is filed separately. |

Revision 5: Astra round 4 dispositions

| # | finding | disposition | |---|---|---| | R4-1 | unknown lets frameworks fall back to Client-IP or loopback | Accepted: the address is 192.0.0.8 (RFC 7600), and the door strips every alternate client-address header (Security model; Task 6 tests with a Rails-rule resolver). | | R4-2 | the cache varies on inbound headers the door rewrote | Accepted: door requests bypass the cache (Task 5); the public-traffic mismatch is filed separately. | | R4-3 | failed hop verification substitutes an infrastructure address | Accepted as a stated degraded mode (Security model): it is the same outcome public backhaul has in that failure, and a box's public EIP confers nothing. | | R4-4 | gate and visitor partitions misstated | Accepted: the Security model states the actual keys. |

Revision 4: Astra round 3 dispositions

| # | finding | disposition | |---|---|---| | R3-1 | address trust confers gate and app authority | Accepted, resolved by Paul's ruling: the door believes no caller address (Security model). The remote-confinement question goes away with the privilege. | | R3-2 | ownership residuals have no maximum lifetime | Accepted: the bounds are restated honestly (Security model), and the residuals are accepted as hygiene because the door carries no privilege. | | R3-3 | forwarded scheme differs by placement | Accepted: setForwarded gives door requests X-Forwarded-Proto: https locally; the far edge's TLS gives the same (Task 5). | | R3-4 | renewal bumps the revision; stale WithEdgeDoor example | Accepted: the advance fires only when the database is below the bucket (Task 2). The agent example takes one argument (Task 7). |

Revision 3: Astra round 2 dispositions

| # | finding | disposition | |---|---|---| | R2-1 | failed door-hop verification drops the scope | Superseded: rev 3 carries no door scope across a hop (Security model). | | R2-2 | door: true is unauthenticated; selection race | Superseded: no capability flag. The remote check reads owner and dial target from one Owner call on one snapshot (Task 5). | | R2-3 | receiving edge reaches reserved handlers first | Superseded: a backhauled door request is an ordinary hop; the reserved handlers it could reach are the ones any internet client reaches. Local door callers are still excluded from them (Task 5). | | R2-4 | Put(..., "") is create-only | Accepted: publication reads the current ETag and writes with it, retrying on conflict with a fresh rebuild from the database (Task 2). | | R2-5 | concurrent publishers overwrite a revocation | Accepted: one per-app publication mutex in the console process; keys and revision read in one transaction inside it; a CAS retry rebuilds from the database; the publisher never writes a revision lower than the verified one in the bucket (Task 2). The console is one process per deployment. | | R2-6 | a restored database reuses revisions | Accepted in part: if the bucket's verified revision is at or above the database's, the publisher first advances the database's counter past it, so revisions stay monotonic across a restore. That a restore brings back keys revoked after the backup is stated as a residual (Security model). | | R2-7 | high-water persistence contract | Superseded: no box-side persistence. |

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 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 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 | | a lower revision appears than the one this process last admitted | kept out for the life of the process (in-memory only); after a restart, admitted until its own expires_at (Security model residual) | | old record replayed into the bucket | admitted until its own expires_at, at most 72 h after it was signed (Security model residual) | | 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 mesh vocabulary (X-Carlos-Hop, X-Carlos-Hop-Auth) never gains hop trust; the door deletes both (Task 6 test).
  2. A path-prefix hostname whose prefixes belong to different apps: locally, app A's key never reaches app B's prefix; remotely, the host publishes no owner and the door refuses it (Tasks 4 and 5 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 revoke racing a renewal never leaves the older record in the bucket (Task 2 test).
  5. Any header claiming a client address (Forwarded, X-Forwarded-*, and every name in doorAddressHeaders) never reaches the app, the gate or analytics. The app sees the box's public address and https, served on this box or across a hop (Tasks 5 and 6 tests).

---

File structure

| File | Responsibility | |---|---| | 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. | | cli.go (modify, runInstances at ~line 284) | Dispatch door-key. | | internal/host/edgesfile.go (modify: edgeHost, the route fill, the catchall publisher ~line 363) | Publish per-host account/app for single-owner hostnames. | | internal/edge/homemap.go (modify) | Fold the owner with the winning entry; Owner; setForwarded's door branch. | | internal/edge/doorkeys.go (new) | The key mirror: its own loop and freshness contract. | | 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/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, internal/doorkey/doorkey_test.go.

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
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 {
		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 || tok.ID != entry.ID {
		t.Fatalf("parse %q: %+v %v", raw, tok, err)
	}
	if !rec1(entry).Admits(tok) {
		t.Fatal("the record does not admit its own key")
	}
	tok.Secret = strings.Repeat("A", 43)
	if rec1(entry).Admits(tok) {
		t.Fatal("a wrong secret was admitted")
	}
}

func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) {
	raw, entry, _ := NewToken("bae", "titogo")
	tok, _ := ParseToken(raw)
	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." + good, "cdk1.BAE.titogo." + good,
		"cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43),
		"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)
		}
	}
}

func TestSignVerifyExpiryAndKeys(t *testing.T) {
	pub, priv, _ := ed25519.GenerateKey(rand.Reader)
	_, entry, _ := NewToken("bae", "titogo")
	wire, err := Sign(priv, rec1(entry))
	if err != nil {
		t.Fatal(err)
	}
	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, time.Now()); err == nil {
		t.Fatal("a record signed by another key 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)
	_, e, _ := NewToken("bae", "titogo")
	r := rec1(e)
	for i := 0; i < MaxKeys; i++ {
		_, e, _ := NewToken("bae", "titogo")
		r.Keys = append(r.Keys, e)
	}
	if _, err := Sign(priv, r); err == nil {
		t.Fatal("signed a record over MaxKeys")
	}
}
  • [ ] Step 2: Run go test ./internal/doorkey/ → FAIL (no package).
  • [ ] Step 3: Implement
// Package doorkey is the credential for the edge-local door (instance-moves
// 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 (
	"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
	Lifetime = 72 * time.Hour
	scheme   = "cdk1"
	domain   = "carlos door-keys v1\n"
)

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"`
	Revision  uint64    `json:"revision"`
	Keys      []Entry   `json:"keys"`
	SignedAt  time.Time `json:"signed_at"`
	ExpiresAt time.Time `json:"expires_at"`
}

type signedWrapper struct {
	Payload string `json:"payload"`
	Sig     string `json:"sig"`
}

// Digest is the stored form of a key's secret.
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 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)
	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) || 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 {
		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(domain+b64)))}, "", "  ")
}

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")
	}
	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(domain+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
	}
	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 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 authority, doors and re-sign loop

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.*; 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 (st *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error) — sets the revision to past+1 if it is below past; returns the resulting revision - func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error — the one publisher, protocol below - s.doorPublish — map[string]*sync.Mutex keyed "<sqid>/<app>", guarded by its own mutex, one lock per app

Publication protocol (round 2, findings 4–6). release.Store.Put(ctx, key, body, etag) is create-only with etag == "" and compare-and-swap otherwise (internal/release/store.go:37), so:

  1. Take the app's publication mutex; hold it to the end.
  2. wire, etag, err := s.cfg.Releases.Get(ctx, key). Not found: etag = "", bucketRev = 0. Found: rec, verr := doorkey.Verify(pub, wire, time.Time{}). Pass the zero time so an expired record still yields its revision. If Verify refuses the zero time, add doorkey.VerifyRevision(pub, wire) (uint64, error), which checks the signature and shape only. bucketRev is the verified revision, or 0 for anything unverifiable (a tampered object is overwritten, never trusted).
  3. keys, rev := DoorKeysLive(...), one read transaction. If rev < bucketRev, then rev = DoorKeyAdvanceRevision(..., bucketRev) and re-read keys. An equal revision is the ordinary renewal case and is re-published as it is. That covers a restored database and a second writer.
  4. Build doorkey.Record{Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now + doorkey.Lifetime}, sign it, and Put(ctx, key, body, etag).
  5. On release.ErrCASConflict, go back to step 2, at most three times. Every retry rebuilds from the database and never reuses signed bytes. If all three fail, return the error.

Mint and revoke do the database write, then publishDoorKeys, then bumpSerial. The renewal loop calls publishDoorKeys with no database write, so a renewal re-publishes the current revision with a fresh expiry, or a newer one if step 3 advanced it. Because the mutex serializes a renewal against a revoke, and each builds from the database inside the lock, a renewal can never write a key set older than one already published. The console runs as one process per deployment. If that ever changes, step 2's revision check keeps a slower writer from putting back an older revision, because it re-reads under CAS. - 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.

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: 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):
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")
	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 }
	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)
	}
	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, doorKeysURL(fx), token, "")
	if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") {
		t.Fatalf("list = %d %s", code, out)
	}
	if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusOK {
		t.Fatalf("revoke = %d %s", code, out)
	}
	if rec := readDoorRecord(t, fx); rec.Admits(tok) || rec.Revision != 2 {
		t.Fatalf("after revoke: %+v", rec)
	}
}

// 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 := 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")
		}
	}
}

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 */ }

// Round 2 finding 5: a renewal that read revision N must not land after a
// revoke published N+1. Drive it deterministically: wrap fx.rel in a store
// whose Put blocks on a channel for the FIRST call only; start a renewal
// (publishDoorKeys) in a goroutine so it blocks inside the lock; start a
// revoke in another goroutine; release the renewal; wait for both. The
// bucket must hold the revoke's revision and not admit the revoked key.
func TestDoorKeyRevokeIsNotOverwrittenByARacingRenewal(t *testing.T) { /* as described */ }

// Round 2 finding 6: a database whose counter is behind the bucket (a
// restore) publishes above the bucket's revision, never at or below it.
func TestDoorKeyPublishAdvancesPastTheBucketRevision(t *testing.T) { /* publish revision 1; plant a validly signed revision 7 in the bucket; mint; the new record's revision is 8 and the database's counter is 8 */ }

// Round 2 finding 4: replacing an existing object works, and a tampered
// object is overwritten rather than trusted.
func TestDoorKeyPublishReplacesExistingAndTamperedObjects(t *testing.T) { /* mint twice (second Put replaces); then write garbage bytes at the key and revoke: the revoke publishes a verifiable record */ }

(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.

  • [ ] 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:
	// Door keys (#175 P7): person-only, the instance-token rule. A door key
	// 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 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 cli.go (runInstances, ~line 284: add case "door-key": beside the existing subcommand words).

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 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 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 single owners; the edge mirrors door keys

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:

  • Edges file: edgeHost gains Account string \json:"account,omitempty"\` and App string \json:"app,omitempty"\. **They are published only when every route this box serves under that hostname (the row, every path-prefix row that shares it, and for an alias, its primary's rows) has one account and app.** A mixed hostname publishes neither, and the door refuses it remotely. Set for role-home routes from release.RouteAccount(rt)/rt.App, for aliases from their primary, and for the served catchall at edgesfile.go:363` from the wildcard claim's account/app. No capability flag.
  • Fold: edgesHostView and homeEntry gain account, app. When foldHomes lets a route replace an alias (~homemap.go:581), the owner comes from the winner, never merged.
  • func (m *homeMap) Owner(host string) (account, app, eip, box string, ok bool): one read of one snapshot answers both whom to check and where to dial, so the check and the dial cannot disagree. It uses the same lookup as Home, including the one-label wildcard. ok is false when the winning entry has no owner or is not a dial target. A nil receiver returns zeros.
  • type doorKeyMirror struct{ ... } in doorkeys.go: - func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey) *doorKeyMirror - func (d *doorKeyMirror) Refresh(ctx context.Context) error - func (d *doorKeyMirror) run(stop <-chan struct{}), with a 30 s ticker and a 10 s budget per refresh - func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool), which is nil-safe and 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;
  • a lower revision than the one this process last admitted for that account/app: the newer record is kept while unexpired and the lower one is logged once. This is in memory only; the Security model states what a restart allows.
  • [ ] Step 1: Failing tests — publisher: owner on a home row, on an alias (the primary's), and on the served catchall; no owner on a hostname whose path-prefix rows belong to two apps. Fold: the owner through exact and wildcard lookup; a missing owner gives ok == false; a route replacing an alias carries the route's owner; Owner's eip/box equal Home's for the same host. Mirror: each rule above.
  • [ ] 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 single owners; the edge mirrors signed door keys (#175 P7).

---

Task 5: Enforcement inside ServeHTTP

Files: Create internal/edge/door.go. Modify internal/edge/edge.go (ServeHTTP, backhaul) and internal/edge/homemap.go (setForwarded). Tests in internal/edge/door_test.go.

Interfaces:

  • type doorCallerKey struct{}; type doorCaller struct{ Account, App string }; func doorCallerFrom(ctx context.Context) (doorCaller, bool)
  • func (e *Edge) backhaulTo(w http.ResponseWriter, r *http.Request, host, eip, box string) bool: the dial half of backhaul, split out so the door dials exactly the target Owner returned. backhaul becomes Home plus backhaulTo, and its behaviour is unchanged.
  • 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)
  • var doorAddressHeaders = []string{"X-Forwarded", "Forwarded-For", "Client-IP", "X-Real-IP", "True-Client-IP", "CF-Connecting-IP", "Fastly-Client-IP", "X-Client-IP", "X-Cluster-Client-IP", "X-Originating-IP"}
  • setForwarded gains a first branch:
func setForwarded(pr *httputil.ProxyRequest) {
	pr.SetXForwarded()
	// A door request is a request from this box (Security model): its
	// RemoteAddr is already the box's public address, so SetXForwarded wrote
	// the right chain. Only the scheme differs from the public route to the
	// same URL, which would have arrived over TLS.
	if _, ok := doorCallerFrom(pr.In.Context()); ok {
		pr.Out.Header.Set("X-Forwarded-Proto", "https")
		return
	}
	if !hopVerified(pr.In.Context()) {
		return
	}
	if chain := pr.In.Header.Values("X-Forwarded-For"); len(chain) > 0 {
		pr.Out.Header.Set("X-Forwarded-For", strings.Join(chain, ", "))
	}
}

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 { ... }). The mark is only ever set by serveDoor on this box. Nothing carries it across a hop, and acceptHop is unchanged.
  2. Miss branch: if isDoor, call acct, app, eip, box, ok := e.homes.Load().Owner(host). Unless ok && acct == door.Account && app == door.App, answer unknownHost. Otherwise backhaulTo(w, r, host, eip, box). The receiving edge sees an ordinary verified hop, as it does for public backhaul today. It believes the forwarded chain, which is the box's public address, so the app there sees exactly what a local serve shows it (Security model).
  3. 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 whose published owner is the caller → backhaulTo is called with Owner's own eip and box; published owner another app → unknown host; no published owner (a mixed hostname, or an older publisher) → unknown host; 7. a request carrying a doorCallerKey mark can only come from serveDoor: assert that a request arriving on the public handler with any X-Carlos-* header never has the mark.
  • [ ] 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 and away from reserved handlers (#175 P7).

---

Task 6: The door handler and listener

Files: Modify internal/edge/door.go; tests in internal/edge/door_test.go.

Interfaces:

  • func (e *Edge) serveDoor(w http.ResponseWriter, r *http.Request)
  • func (e *Edge) openDoor(path string) (closeDoor func(), err error)
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.
	r.Header.Del(hopHeader)
	r.Header.Del(hopAuthHeader)
	tok, err := doorkey.ParseToken(raw)
	if err != nil {
		doorRefused(w)
		return
	}
	rec, ok := e.doorKeys.Record(tok.Account, tok.App)
	if !ok || !rec.Admits(tok) {
		doorRefused(w)
		return
	}
	// No visitor address is believed (Security model): every header that
	// claims one goes, and the request comes from this box's own public
	// address, exactly as it would through the public URL.
	stripForwardedHeaders(r)
	for _, h := range doorAddressHeaders {
		r.Header.Del(h)
	}
	r.RemoteAddr = net.JoinHostPort(e.doorAddr, "0")
	e.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, doorCaller{Account: tok.Account, App: tok.App})))
}

func doorRefused(w http.ResponseWriter) {
	w.Header().Set("Cache-Control", "no-store")
	http.Error(w, "door key refused", http.StatusUnauthorized)
}

openDoor(path): refuse if e.doorKeys == nil or e.doorAddr does not parse as a global unicast IP (net.IP.IsGlobalUnicast and not IsPrivate); 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 → neither reaches the upstream, and a miss goes to the door's owner check, never staleHopRefusal; 4. forwarded headers: with the door's address set to 203.0.113.10, a request carrying X-Forwarded-For: 198.51.100.7, Forwarded: for=198.51.100.9 and X-Forwarded-Proto: http reaches the upstream with exactly X-Forwarded-For: 203.0.113.10 and X-Forwarded-Proto: https, and no Forwarded. The same holds when none of them is sent. clientIP returns 203.0.113.10, and so does the gate stub's clientKey; 4b. alternate address headers: a request carrying every name in doorAddressHeaders reaches the upstream with none of them; 4c. resolver-rule tests in the upstream, written in the test to two published rule sets, fed the upstream's headers and a loopback peer: - Rails 8.0.2 RemoteIp#calculate_ip: drop non-IP XFF entries; strip trusted proxies (loopback, fc00::/7, 10/8, 172.16/12, 192.168/16); if nothing is left, prefer Client-IP, else the peer. - python-ipware 3.0: consult its header list; prefer a global address over a non-global one; Python's is_private ranges.

A door request carrying every name in doorAddressHeaders set to 8.8.8.8 resolves to 203.0.113.10 under both, served locally and backhauled. It never resolves loopback, a private address or 8.8.8.8; 5. a gated route still answers with the gate's page; 5b. a backhauled door request: using two in-process edges joined as the existing backhaul tests join them (grep -n "func.*TwoEdges\|backhaul.*httptest" internal/edge/*_test.go), the far upstream sees X-Forwarded-For: 203.0.113.10 and X-Forwarded-Proto: https, the same as case 4, and the same again when the far edge cannot verify the hop; 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: Lifecycle: arm the door after the mirrors, close it on shutdown

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.

Interfaces:

  • func WithEdgeDoor(path, publicAddr string) RunOption — records the path, and the box's public address as e.doorAddr.
  • 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), 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, no public key, or no usable public address: 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, readPublicIP())) }.
  • [ ] 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: 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 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).

---

As built: notes from Fable's final review (2026-09-29)

No blockers. Three notes were fixed on the branch: the racing-renewal test counts Puts, so it pins the lock; Verify refuses a record signed more than MaxClockSkew (5 minutes) ahead; the socket is bound in a private directory and renamed into place. The rest are recorded here:

  • Mint when publishing fails revokes the fresh key and answers 502, where Task 2 said "saved but not yet published". The key has been shown to nobody, so keeping it would strand a slot. It costs two revision bumps. Revoke keeps Task 2's 502.
  • The door's address is read once, at agent start. The edges-file loop re-probes it every tick. That matches the agent's own assumption that an EIP cannot change under a running box (edgesfile.go:531). If a box's EIP is ever re-associated, restart its agent, or the door keeps presenting the old address.
  • Runbook line: if the console database and the bucket are both restored below a revision an edge has already admitted, that edge refuses the app's keys once its kept record expires (72 h at most), until the console climbs past the mark or the agent restarts.
  • Reachability, not authority: the door needs no certificate or DNS for the host, so a route whose certificate never issued is reachable through the door only. It refuses every path on the steering hostname, where the public branch intercepts only GET /. Both are less than or equal to the public door.

Rollout (for the handoff, not a task)

  1. Consoles first (they publish records nothing reads yet), then boxes (they publish owners, 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.