plan: instance-bound tokens (#155 half 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+1063
−0
docs/superpowers/plans/2026-10-16-instance-bound-tokens.md+1063 −0
CI cancelled — superseded by a newer push — run details
CI log
=== gate === cd launchctx && go vet ./... && go test ./... -count=1 ok amadan.net/carlos/platform.git/launchctx 0.011s ? amadan.net/carlos/platform.git/launchctx/controlname [no test files] ? amadan.net/carlos/platform.git/launchctx/wireid [no test files] go vet ./... gofmt -l .: clean go test ./... -count=1 -timeout 30m amadan: job cancelled — a newer push superseded it
diff --git a/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md b/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md| new file mode 100644 |
| index 000000000..68f498800 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md |
| @@ -0,0 +1,1063 @@ |
| +# Instance-bound tokens (#155 half 1) 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:** A `cli_tokens` credential bound to one instance incarnation, resolving to a non-mintable role `svcrole.Self` that reaches only its own host on six doors. |
| + |
| +**Architecture:** A new token kind `instance` with `host` and `host_born` columns resolves to `Principal{Role: Self, Instance, InstanceBorn}`. `cliRoute` refuses any other `{host}` with `http.NotFound`; a `selfBinding` value travels into every record read on the Self doors and refuses a different incarnation. Admission into doors is through `svcrole.AllowSelf`, never `Allow`, so Admin does not ride along. |
| + |
| +**Tech Stack:** Go, SQLite (`internal/console` store, claim-gated), the release bucket store (`internal/provision`). |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md` (rev 3). Read §2 (rulings), §3.4 (lifetime), §3.5 (the consumer table), §4.3 and §4.4 before any task. |
| + |
| +## Global Constraints |
| + |
| +- Self is never in `svcrole.All()`, never accepted by `svcrole.Parse`, and `svcrole.Allow(Self)` panics. |
| +- Every refusal of a host the token is not bound to is `http.NotFound(w, r)`, byte-identical to the stranger's 404 (`canonicalNotFound` in tests). |
| +- Every write to `cli_tokens` goes through `withAutoAppSQL` (claim gate) with a `claimRaw…` body, as `NewAppToken` does. Tests that write `cli_tokens` also run against the gated store (`OpenGated`, `HostClaim` on); find the existing gated fixture with `grep -rn OpenGated internal/console/*_test.go`. |
| +- No tenant names in new code, tests or comments: use `shop`, `a.example.com`, `b.example.com` (house rule). Existing fixtures named `titogo` may be reused as they are. |
| +- User-facing strings are drafts until Paul's copy review (Task 8). Each draft is a named const so the approved text replaces it verbatim. No em dashes in new copy. |
| +- Go: leave `GOCACHE` unset. Run only the touched packages' tests locally (`go test ./internal/console/ -run 'TestX|TestY'`, `./internal/svcrole/`, the root package `-run`); CI runs the full suite. Unix-socket tests need the sandbox off. |
| +- Commit per task; message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. |
| + |
| +## Review Focus |
| + |
| +1. A Self token whose host is spelled differently from the record (upper case, trailing dot, percent-encoded): must be the canonical 404, never a match after normalisation. Pinned in Task 3. |
| +2. A Self token on a door with `{host}` that is NOT one of the six (delete, move mutations, import, channel, runs, db, email instance doors): must be the role 403 even for its own host. Pinned by Task 4's whole-route table. |
| +3. An app with no instances manifest or no `updates` policy: `instance_control` is empty, so Self can change nothing and install nothing. Pinned in Task 5. |
| +4. The browser settings page (`browserupdates.go`) shares `readUpdatesTarget`, `applyUpdates` and `requestInstall`: a person there must behave exactly as before. Pinned in Task 5 by running the existing browser updates tests unchanged. |
| +5. A minting member who later loses membership: the token keeps working (it belongs to the account, like app tokens) but they cannot revoke it from `/settings`; an owner can from the account pane. Pinned in Task 6. |
| + |
| +--- |
| + |
| +### Task 1: `svcrole.Self` and `AllowSelf` |
| + |
| +**Files:** |
| +- Modify: `internal/svcrole/svcrole.go` |
| +- Test: `internal/svcrole/svcrole_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `svcrole.Self Role = "self"`; `func AllowSelf(base Set) Set`. |
| + |
| +- [ ] **Step 1: Write the failing tests** (append to `svcrole_test.go`) |
| + |
| +```go |
| +func TestSelfIsNotMintable(t *testing.T) { |
| + if _, ok := Parse("self"); ok { |
| + t.Fatal(`Parse("self") succeeded; Self must never be stored on a service row`) |
| + } |
| + for _, r := range All() { |
| + if r == Self { |
| + t.Fatal("Self is in All(); the mint door and form would offer it") |
| + } |
| + } |
| +} |
| + |
| +func TestAllowRefusesSelf(t *testing.T) { |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("Allow(Self) did not panic; it would admit Admin with it") |
| + } |
| + }() |
| + Allow(Self) |
| +} |
| + |
| +func TestAllowSelfAddsOnlySelf(t *testing.T) { |
| + closed := AllowSelf(Set{}) |
| + if !closed.Permits(Self) { |
| + t.Fatal("AllowSelf(Set{}) refuses Self") |
| + } |
| + for _, r := range All() { |
| + if closed.Permits(r) { |
| + t.Fatalf("AllowSelf(Set{}) admits %q", r) |
| + } |
| + } |
| + open := AllowSelf(Allow(Instance)) |
| + for _, r := range []Role{Self, Instance, Admin} { |
| + if !open.Permits(r) { |
| + t.Fatalf("AllowSelf(Allow(Instance)) refuses %q", r) |
| + } |
| + } |
| + if open.Permits(Publish) { |
| + t.Fatal("AllowSelf widened the base set") |
| + } |
| + base := Allow(Instance) |
| + _ = AllowSelf(base) |
| + if base.Permits(Self) { |
| + t.Fatal("AllowSelf mutated its argument") |
| + } |
| + if closed.Empty() { |
| + t.Fatal("AllowSelf(Set{}) reads as a closed door") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, expect FAIL** (`undefined: Self`, `undefined: AllowSelf`) |
| + |
| +Run: `go test ./internal/svcrole/ -run 'TestSelf|TestAllowRefusesSelf|TestAllowSelf'` |
| + |
| +- [ ] **Step 3: Implement** (in `svcrole.go`, after the `catalogue` var) |
| + |
| +```go |
| +// Self is what a kind='instance' credential resolves to: the authority of |
| +// one instance over itself (spec 2026-10-16-instance-bound-tokens §3.3). |
| +// |
| +// It is deliberately NOT in the catalogue. Parse refuses it, so no service |
| +// row can be minted with it, and All omits it, so no mint door or form can |
| +// offer it. Allow refuses it too, because Allow adds Admin to every set and |
| +// Admin is app-wide: a door opened to Self through Allow would open to every |
| +// Admin credential, which has no host to be checked against. AllowSelf is |
| +// the only way a door admits it. |
| +const Self Role = "self" |
| + |
| +// AllowSelf returns base with Self added and nothing else: AllowSelf(Set{}) |
| +// admits Self alone (not Admin), AllowSelf(Allow(Instance)) admits Instance, |
| +// Admin and Self. base is not modified. |
| +func AllowSelf(base Set) Set { |
| + s := Set{roles: make(map[Role]bool, len(base.roles)+1)} |
| + for r := range base.roles { |
| + s.roles[r] = true |
| + } |
| + s.roles[Self] = true |
| + return s |
| +} |
| +``` |
| + |
| +`Allow` already panics for any role `Parse` refuses, so `Allow(Self)` panics with no change to `Allow`. Check that `Roles()` sorts Self in with the rest (it does: it reads the map). |
| + |
| +- [ ] **Step 4: Run, expect PASS**, plus the package: `go test ./internal/svcrole/` |
| +- [ ] **Step 5: Commit** `svcrole: Self, outside the catalogue, and AllowSelf (#155)` |
| + |
| +--- |
| + |
| +### Task 2: The token kind in the store |
| + |
| +**Files:** |
| +- Modify: `internal/console/authstore.go` (schema ALTERs, `cliKindInstance`, `CLIToken`, `scanCLITokenRows`, `CLITokens` query, `NeverExpires`, `IsInstance`) |
| +- Modify: `internal/console/svcprincipal.go` (`Principal`, `CLIPrincipal`, `ServicePrincipals`, `principalKey`, new `NewInstanceToken`, new `RevokeInstanceTokens`) |
| +- Modify: `internal/console/apicli.go` (`auditActor`) |
| +- Modify: `internal/console/store.go:1736`, `:1928`, `:1970`, `:2074`, `:2089` and `internal/console/claim_app_identity.go:182` (kind lists) |
| +- Test: `internal/console/instancetoken_store_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: `svcrole.Self` (Task 1). |
| +- Produces: |
| + - `const cliKindInstance = "instance"` |
| + - `Principal.Instance string`, `Principal.InstanceBorn int64` |
| + - `CLIToken.Host string`, `func (t CLIToken) IsInstance() bool` |
| + - `func (s *Store) NewInstanceTokenContext(ctx context.Context, accountID int64, app, host string, born int64, mintedBy, label string, now time.Time) (string, error)` |
| + - `func (s *Store) RevokeInstanceTokensContext(ctx context.Context, accountID int64, app, host string, now time.Time) error` |
| + - `principalKey` → `"instance:<id>/<app>/<host>"`; `auditActor` → `"instance:<sqid>/<app>/<host>"` |
| + |
| +- [ ] **Step 1: Write the failing tests** in `instancetoken_store_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +const testBorn = int64(1_789_000_000_123_456_789) |
| + |
| +func mintInstanceToken(t *testing.T, st *Store, accountID int64, app, host string, born int64) string { |
| + t.Helper() |
| + raw, err := st.NewInstanceTokenContext(context.Background(), accountID, app, host, born, "alice@keymail.dev", "", time.Now()) |
| + if err != nil { |
| + t.Fatalf("NewInstanceTokenContext(%s): %v", host, err) |
| + } |
| + return raw |
| +} |
| + |
| +func TestCLIPrincipalInstanceRowIsSelf(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := mintInstanceToken(t, fx.st, OpsAccountID, "shop", "a.example.com", testBorn) |
| + p, ok := fx.st.CLIPrincipal(raw, time.Now()) |
| + if !ok { |
| + t.Fatal("instance token did not resolve") |
| + } |
| + want := Principal{Kind: PrincipalService, Address: "alice@keymail.dev", AccountID: OpsAccountID, |
| + App: "shop", Role: svcrole.Self, Instance: "a.example.com", InstanceBorn: testBorn} |
| + if p.Kind != want.Kind || p.Address != want.Address || p.AccountID != want.AccountID || p.App != want.App || |
| + p.Role != want.Role || p.Instance != want.Instance || p.InstanceBorn != want.InstanceBorn || p.Name != "" || p.Rungs != nil { |
| + t.Fatalf("principal = %+v, want %+v", p, want) |
| + } |
| +} |
| + |
| +// Every malformed instance row fails to resolve rather than resolving to |
| +// something wider. Written with raw SQL because no minter can produce them. |
| +func TestCLIPrincipalRefusesMalformedInstanceRows(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + cases := map[string]string{ |
| + "no app": `UPDATE cli_tokens SET app = '' WHERE token_hash = ?`, |
| + "bad host": `UPDATE cli_tokens SET host = '../x' WHERE token_hash = ?`, |
| + "no host": `UPDATE cli_tokens SET host = '' WHERE token_hash = ?`, |
| + "no born": `UPDATE cli_tokens SET host_born = 0 WHERE token_hash = ?`, |
| + "a role": `UPDATE cli_tokens SET role = 'admin' WHERE token_hash = ?`, |
| + "a name": `UPDATE cli_tokens SET name = 'hub' WHERE token_hash = ?`, |
| + "rungs": `UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, |
| + "no acct": `UPDATE cli_tokens SET account_id = 0 WHERE token_hash = ?`, |
| + "revoked": `UPDATE cli_tokens SET revoked_at = 1 WHERE token_hash = ?`, |
| + } |
| + for name, stmt := range cases { |
| + raw := mintInstanceToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn) |
| + if _, err := fx.st.db.Exec(stmt, hashToken(raw)); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if p, ok := fx.st.CLIPrincipal(raw, time.Now()); ok { |
| + t.Errorf("%s: resolved to %+v, want no principal", name, p) |
| + } |
| + } |
| +} |
| + |
| +func TestInstanceTokenKeysArePerHost(t *testing.T) { |
| + a := Principal{Kind: PrincipalService, AccountID: 7, App: "shop", Role: svcrole.Self, Instance: "a.example.com", InstanceBorn: 1} |
| + b := a |
| + b.Instance = "b.example.com" |
| + legacy := Principal{Kind: PrincipalService, AccountID: 7, App: "shop", Role: svcrole.Instance} |
| + if principalKey(a) != "instance:7/shop/a.example.com" { |
| + t.Fatalf("principalKey(a) = %q", principalKey(a)) |
| + } |
| + if principalKey(a) == principalKey(b) || principalKey(a) == principalKey(legacy) { |
| + t.Fatal("instance principal keys collapse onto a sibling's or the app token's") |
| + } |
| + acct := &Account{ID: 7, Sqid: "k7"} |
| + if got := auditActor(a, acct); got != "instance:k7/shop/a.example.com" { |
| + t.Fatalf("auditActor = %q", got) |
| + } |
| +} |
| + |
| +func TestInstanceTokensListRevokeAndNeverExpire(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + raw := mintInstanceToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn) |
| + rows, err := fx.st.ServicePrincipals(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var row CLIToken |
| + for _, r := range rows { |
| + if r.ID == hashToken(raw) { |
| + row = r |
| + } |
| + } |
| + if !row.IsInstance() || row.Host != "a.example.com" || row.Role != string(svcrole.Self) || !row.NeverExpires() || row.App != "kass" { |
| + t.Fatalf("listed row = %+v", row) |
| + } |
| + if row.Label != "a.example.com instance token" { |
| + t.Fatalf("default label = %q", row.Label) |
| + } |
| + if err := fx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := fx.st.CLIPrincipal(raw, time.Now()); ok { |
| + t.Fatal("revoked instance token still resolves") |
| + } |
| +} |
| +``` |
| + |
| +Add, in the same file, the lifecycle tests, each copying the setup of the existing app-token twin named beside it: |
| + |
| +- `TestPurgeDeletesInstanceTokens`: copy `TestRenameAppMovesServicePrincipalToo`'s fixture style; purge an app holding an instance row; assert `SELECT COUNT(*) FROM cli_tokens WHERE kind='instance'` is 0. |
| +- `TestTrashKeepsInstanceTokens`: trash then restore; the original token still resolves. |
| +- `TestRenameAndMoveCarryInstanceTokens`: after `RenameAppIn` and `MoveApp`, the row's `app` / `account_id` follow. |
| +- `TestStaleInstanceRowsDoNotBlockRenameOrMove`: insert an instance row at the destination (raw SQL, as an older console's purge would leave it); `RenameAppIn` and `MoveApp` succeed. |
| +- `TestOldConsoleSQLLeavesHarmlessInstanceRows`: run the PREVIOUS release's purge statement literally (`DELETE FROM cli_tokens WHERE kind IN ('app','service') AND account_id = ? AND app = ?`) on a db holding an instance row; a later `RenameAppIn` into that name succeeds. |
| +- Run each of these against both the plain fixture and the gated one. |
| + |
| +- [ ] **Step 2: Run, expect FAIL** (undefined `NewInstanceTokenContext`, fields). |
| + |
| +Run: `go test ./internal/console/ -run 'TestCLIPrincipalInstance|TestCLIPrincipalRefusesMalformed|TestInstanceToken|TestPurgeDeletesInstance|TestTrashKeepsInstance|TestRenameAndMoveCarryInstance|TestStaleInstanceRows|TestOldConsoleSQL'` |
| + |
| +- [ ] **Step 3: Implement.** |
| + |
| +`authstore.go`: add to the ALTER list |
| + |
| +```go |
| + `ALTER TABLE cli_tokens ADD COLUMN host TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE cli_tokens ADD COLUMN host_born INTEGER NOT NULL DEFAULT 0`, |
| +``` |
| + |
| +and `cliKindInstance = "instance"` to the kind consts with a comment: "bound to one incarnation of one instance (spec 2026-10-16 §3); an older console resolves no such row". `CLIToken` gains `Host string // instance tokens only`. Add: |
| + |
| +```go |
| +// IsInstance reports whether this row is an instance-bound token, for the |
| +// same reason IsApp exists: a template cannot see cliKindInstance. |
| +func (t CLIToken) IsInstance() bool { return t.Kind == cliKindInstance } |
| +``` |
| + |
| +`NeverExpires` becomes `(t.IsApp() || t.IsService() || t.IsInstance()) && …`. |
| + |
| +`scanCLITokenRows` scans one more column, `host`, after `rungs` (update BOTH queries, `CLITokens` and `ServicePrincipals`, to select `…, rungs, host`), and sets `t.Role = string(svcrole.Self)` when `t.Kind == cliKindInstance`. `ServicePrincipals`' `kind IN (?, ?)` becomes `kind IN (?, ?, ?)` with `cliKindInstance`. |
| + |
| +`svcprincipal.go`: `Principal` gains |
| + |
| +```go |
| + // Instance and InstanceBorn bind a kind='instance' credential to one |
| + // incarnation of one host: Instance is the host, InstanceBorn the |
| + // record's CreatedAt in Unix nanoseconds at mint. Both are zero for |
| + // every other kind, and cliRoute and selfBinding read Instance != "" as |
| + // "this caller may act only on that host". |
| + Instance string |
| + InstanceBorn int64 |
| +``` |
| + |
| +`CLIPrincipal` selects `host, host_born` too (`var host string; var born int64`) and gains, before `default:`, |
| + |
| +```go |
| + case cliKindInstance: |
| + // Fail closed on anything a minter would never write: a row bound |
| + // to no app, no host or no incarnation is not read as bound to |
| + // more, and any role, name or rungs is a row this console did not |
| + // write. |
| + if accountID == 0 || app == "" || !provision.ValidHostShape(host) || born == 0 || |
| + role != "" || name != "" || rungs != "" { |
| + return Principal{}, false |
| + } |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, |
| + Role: svcrole.Self, Instance: host, InstanceBorn: born} |
| +``` |
| + |
| +(import `internal/provision` if not already imported; check for an import cycle with `go build ./internal/console/` and, if there is one, use the console's existing host-shape helper instead — `grep -n ValidHostShape internal/console/*.go | head`). |
| + |
| +Minter and revoker, following `NewAppToken`'s wrapper pattern exactly: |
| + |
| +```go |
| +// NewInstanceTokenContext mints a credential bound to one incarnation of one |
| +// instance (spec 2026-10-16 §3, §5). born is the record's CreatedAt in Unix |
| +// nanoseconds; mintedBy is the member, kept so a human stays answerable. |
| +// Never expires, like an app token: revocation and the instance's own life |
| +// (§3.4) are the controls. |
| +func (s *Store) NewInstanceTokenContext(ctx context.Context, accountID int64, app, host string, born int64, mintedBy, label string, now time.Time) (raw string, err error) { |
| + if accountID == 0 || app == "" || host == "" || born == 0 { |
| + return "", errors.New("console: an instance token needs an account, an app, a host and an incarnation") |
| + } |
| + if label == "" { |
| + label = fmt.Sprintf(instanceTokenDefaultLabel, host) |
| + } |
| + err = s.withAutoAppSQL(ctx, accountID, app, "sql.NewInstanceToken", func(view *Store) error { |
| + raw, err = view.claimRawNewInstanceToken(accountID, app, host, born, mintedBy, label, now) |
| + return err |
| + }) |
| + return raw, err |
| +} |
| + |
| +func (s *Store) claimRawNewInstanceToken(accountID int64, app, host string, born int64, mintedBy, label string, now time.Time) (string, error) { |
| + raw, err := randomToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + if _, err := s.db.Exec(`INSERT INTO cli_tokens |
| +(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, host, host_born) |
| +VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, label, now.Unix(), cliKindInstance, accountID, app, host, born); err != nil { |
| + return "", err |
| + } |
| + return raw, nil |
| +} |
| + |
| +// RevokeInstanceTokensContext soft-revokes every live token bound to host, |
| +// whatever incarnation it names. Zero rows is not an error: the delete door |
| +// calls it for every host, minted or not. |
| +func (s *Store) RevokeInstanceTokensContext(ctx context.Context, accountID int64, app, host string, now time.Time) error { |
| + return s.withAutoAppSQL(ctx, accountID, app, "sql.RevokeInstanceTokens", func(view *Store) error { |
| + _, err := view.db.Exec(`UPDATE cli_tokens SET revoked_at = ? |
| +WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND revoked_at = 0`, |
| + now.Unix(), cliKindInstance, accountID, app, host) |
| + return err |
| + }) |
| +} |
| +``` |
| + |
| +The default label is a copy-review draft; declare it in a new file `internal/console/instancetoken_words.go` that Task 8 rewrites: |
| + |
| +```go |
| +package console |
| + |
| +// Drafts for #155 half 1, awaiting Paul's copy review (plan Task 8). The |
| +// approved text replaces each value verbatim. |
| +const ( |
| + instanceTokenDefaultLabel = "%s instance token" |
| +) |
| +``` |
| + |
| +If the claim layer lints raw SQL outside `claimRaw…` functions (check: `grep -rn "claimRaw" internal/console/*_test.go | grep -i "ast\|lint" | head`), move the UPDATE in `RevokeInstanceTokensContext` into a `claimRawRevokeInstanceTokens` method. |
| + |
| +`principalKey` and `auditActor` gain, as their FIRST service branch (before `p.Name == ""`): |
| + |
| +```go |
| + if p.Instance != "" { |
| + return "instance:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App + "/" + p.Instance |
| + } |
| +``` |
| + |
| +```go |
| + if p.Instance != "" { |
| + // Before the Name test: an instance row has no name either, and |
| + // must not be filed as the app token. |
| + return "instance:" + acct.Sqid + "/" + p.App + "/" + p.Instance |
| + } |
| +``` |
| + |
| +Kind lists: |
| +- `store.go:1736` and `claim_app_identity.go:182` (purge, DELETE): `kind IN (?, ?, ?)` adding `cliKindInstance`. |
| +- `store.go:1970` and `:2089` (the UPDATEs that carry credentials with a move/rename): add `cliKindInstance`. |
| +- `store.go:1928` and `:2074` (the destination stale COUNTs): leave as `(cliKindApp, cliKindService)`, with a comment: "Instance rows are not counted: one left at the destination by an older console's purge is bound to a record incarnation that no longer exists, so it can only 404 (spec 2026-10-16 §3.4)." |
| +- Also grep the rename path: `grep -n "kind IN" internal/console/*.go` and apply the same rule to every hit, recording each in the commit message. |
| + |
| +- [ ] **Step 4: Run, expect PASS.** Then the neighbours: `go test ./internal/console/ -run 'TestCLIPrincipal|TestAppToken|TestRenameApp|TestMoveApp|TestPurge|TestServicePrincipal|TestTokens'` |
| +- [ ] **Step 5: Commit** `console: the instance token kind in the store (#155)` |
| + |
| +--- |
| + |
| +### Task 3: The gate |
| + |
| +**Files:** |
| +- Modify: `internal/console/authcli.go` (`cliRoute`) |
| +- Modify: `internal/console/apiinstances.go` (`instanceGateSaying` limiter key) |
| +- Create: `internal/console/selfbinding.go` |
| +- Test: `internal/console/instancetoken_gate_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 2's `Principal.Instance`, `InstanceBorn`, `principalKey`. |
| +- Produces: |
| + |
| +```go |
| +type selfBinding struct { |
| + Host string |
| + Born int64 |
| +} |
| +func bindingOf(p Principal) selfBinding |
| +func (b selfBinding) refuses(rec *provision.Record) bool |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing tests.** In `instancetoken_gate_test.go`, a test server with three throwaway doors registered through `cliRoute` on a fresh `Server` from the console fixture (`fx.srv` — check the fixture's field name with `grep -n "type consoleFixture" -A15 internal/console/*_test.go`): |
| + |
| +```go |
| +func TestCLIRouteSelfHostCheck(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + raw := mintInstanceToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn) |
| + hit := 0 |
| + fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/instances/{host}/probe", svcrole.AllowSelf(svcrole.Set{}), |
| + func(w http.ResponseWriter, r *http.Request) { hit++; w.WriteHeader(http.StatusNoContent) }) |
| + want := canonicalNotFound(t) |
| + base := fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/" |
| + for _, host := range []string{"b.example.com", "A.example.com", "a.example.com.", "a.example.com%2e", "nope"} { |
| + st, h, body := rawReq(t, http.MethodGet, base+host+"/probe", raw, "") |
| + assertNotFound(t, "self token on "+host, want, st, h, body) |
| + } |
| + if hit != 0 { |
| + t.Fatalf("handler ran %d times for hosts the token is not bound to", hit) |
| + } |
| + if st, _, body := rawReq(t, http.MethodGet, base+"a.example.com/probe", raw, ""); st != http.StatusNoContent { |
| + t.Fatalf("own host = %d %s", st, body) |
| + } |
| +} |
| + |
| +func TestCLIRoutePanicsOnSelfDoorWithoutHost(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("a Self door without {host} registered") |
| + } |
| + }() |
| + fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/nohost", svcrole.AllowSelf(svcrole.Set{}), func(http.ResponseWriter, *http.Request) {}) |
| +} |
| + |
| +func TestSelfBindingRefusesAnotherIncarnation(t *testing.T) { |
| + b := selfBinding{Host: "a.example.com", Born: testBorn} |
| + same := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn)} |
| + reborn := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn+1)} |
| + other := &provision.Record{Host: "b.example.com", CreatedAt: time.Unix(0, testBorn)} |
| + if b.refuses(same) || !b.refuses(reborn) || !b.refuses(other) || !b.refuses(nil) { |
| + t.Fatal("selfBinding.refuses wrong") |
| + } |
| + if (selfBinding{}).refuses(reborn) { |
| + t.Fatal("the zero binding refused a record") |
| + } |
| +} |
| + |
| +func TestInstanceLimiterIsPerHost(t *testing.T) { |
| + // Two tokens for host A share one budget on a real door; a token for B |
| + // is unaffected. Set s.cli.instances to a burst of 2 for this test; find |
| + // how other tests shrink it: grep -n "cli.instances" internal/console/*_test.go |
| +} |
| +``` |
| + |
| +Fill `TestInstanceLimiterIsPerHost` with the shrinking pattern that grep finds, two tokens minted for `a.example.com` and one for `b.example.com`, and a door from Task 4 is not available yet, so use `GET …/instances/{host}` registered by the real server: call it with token A1, A1, A2 (third must be 429) and B1 (must not be 429). The record need not exist; a 404 still spends the budget, since the limiter runs before the read. |
| + |
| +- [ ] **Step 2: Run, expect FAIL.** |
| +- [ ] **Step 3: Implement.** |
| + |
| +`cliRoute`, at the top (registration time): |
| + |
| +```go |
| + if allow.Permits(svcrole.Self) && !strings.Contains(pattern, "{host}") { |
| + // A Self door can only ever answer for one host; one without a host |
| + // in its path would 404 every request (spec §4.1). |
| + panic("console: " + pattern + " admits svcrole.Self but has no {host}") |
| + } |
| +``` |
| + |
| +and in the handler, straight after the role gate: |
| + |
| +```go |
| + // The instance-bound credential's host check (spec §4.1). Like the |
| + // role gate it reads only the path and the caller's own binding, so |
| + // it leaks nothing, and its answer is the stranger's 404 byte for |
| + // byte. Exact comparison: another spelling of the same host fails |
| + // closed. |
| + if p.Instance != "" && r.PathValue("host") != p.Instance { |
| + http.NotFound(w, r) |
| + return |
| + } |
| +``` |
| + |
| +`selfbinding.go`: |
| + |
| +```go |
| +package console |
| + |
| +import "github.com/carlosframework/platform/internal/provision" |
| + |
| +// selfBinding is the instance incarnation a Self principal may act on |
| +// (spec 2026-10-16 §4.3). The zero value, every other caller's, binds |
| +// nothing and refuses nothing. It travels as a value into every helper that |
| +// reads the record, because a check made once in a handler misses a retry |
| +// that re-reads a replacement record. |
| +type selfBinding struct { |
| + Host string |
| + Born int64 |
| +} |
| + |
| +func bindingOf(p Principal) selfBinding { |
| + return selfBinding{Host: p.Instance, Born: p.InstanceBorn} |
| +} |
| + |
| +// refuses reports whether rec is not the incarnation b names. Callers answer |
| +// http.NotFound when it does, before anything that depends on rec. |
| +func (b selfBinding) refuses(rec *provision.Record) bool { |
| + if b.Host == "" { |
| + return false |
| + } |
| + return rec == nil || rec.Host != b.Host || rec.CreatedAt.UnixNano() != b.Born |
| +} |
| +``` |
| + |
| +`instanceGateSaying`'s limiter: |
| + |
| +```go |
| + // Keyed on the TOKEN for every caller but an instance-bound one, whose |
| + // tokens for one host share one budget so that minting more of them buys |
| + // nothing, while a sibling's stays its own (spec §3.5). |
| + key := hashToken(bearer(r)) |
| + if p, _ := principalFrom(r.Context()); p.Instance != "" { |
| + key = principalKey(p) |
| + } |
| + if !s.cli.instances.allow(key, time.Now()) { |
| +``` |
| + |
| +- [ ] **Step 4: Run, expect PASS**; then `go test ./internal/console/ -run 'TestCLIRoute|TestEveryCLIDoor|TestAPIInstance'`. |
| +- [ ] **Step 5: Commit** `console: cliRoute's host check and the self binding (#155)` |
| + |
| +--- |
| + |
| +### Task 4: GET, PATCH and move status admit Self |
| + |
| +**Files:** |
| +- Modify: `internal/console/authcli.go` (three registrations) |
| +- Modify: `internal/console/apiinstances.go` (`handleAPIInstanceGet`, `handleAPIInstanceUpdate`) |
| +- Modify: `internal/console/apiinstancemove.go` (`moveGate`) |
| +- Modify: `internal/console/instancetoken_words.go` (one draft) |
| +- Modify: `internal/console/svcroutes_test.go` (pins) |
| +- Test: `internal/console/instancetoken_doors_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: `svcrole.AllowSelf`, `bindingOf`, `selfBinding.refuses`. |
| +- Produces: the registered door set below; `selfDoors` (test var) listing all six patterns, used by Task 5. |
| + |
| +- [ ] **Step 1: Write the failing tests.** |
| + |
| +In `instancetoken_doors_test.go`: |
| + |
| +```go |
| +// The six doors a Self principal may pass (spec §4.2). Task 5 adds the |
| +// three update doors' behaviour; their registration is pinned here. |
| +var selfDoors = []string{ |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}", |
| + "PATCH /api/cli/apps/{account}/{app}/instances/{host}", |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/move", |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| + "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", |
| +} |
| + |
| +func TestSelfRoleDoors(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + want := map[string]bool{} |
| + for _, d := range selfDoors { |
| + want[d] = true |
| + } |
| + for pattern, set := range fx.srv.cliRoles { |
| + if set.Permits(svcrole.Self) != want[pattern] { |
| + t.Errorf("%s: admits Self = %v, want %v", pattern, set.Permits(svcrole.Self), want[pattern]) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +The containment table: `TestSelfTokenContainment` builds the `instanceFixture`, enables instances with a manifest for `shop` whose domain covers `a.example.com` and `b.example.com` (copy `titoManifest`, change names), creates both records through the create door with the member token, reads A's record to get `CreatedAt`, mints a Self token for A with that born, then walks EVERY entry of `fx.srv.cliRoles`: |
| + |
| +- doors not in `selfDoors`: request with wildcards filled (`{host}` = A's host; reuse the replacer from `TestEveryClosedDoorRefusesAServiceCredential`) → status 403; |
| +- `selfDoors`, `{host}` = B → canonical 404; `{host}` = `c.example.com` (no record) → canonical 404; |
| +- after each request, A's and B's records (read with `provision.GetRecordETag`) have unchanged ETags, except for the PATCH on A below. |
| +- GET on A → 200 and the body's `host` is A. |
| +- PATCH on A with `{"probe":{"path":"/healthz","status":200}}` → 200. |
| +- PATCH on A with each of `{"environment":"x"}`, `{"steering":"latency"}`, `{"gate":"pw-123456"}`, and `{"probe":null,"environment":""}` → 403 with body containing the draft `selfPatchFieldsRefused` text; A's ETag unchanged afterwards; the app's environment list and store seed marker unchanged (read them the way `seedenvbind_test.go` does). |
| +- Move status on A → whatever a member gets (compare status codes with the member token). |
| + |
| +`TestSelfTokenDiesWithItsIncarnation`: delete A through the delete door with the member token, but first set a test hook that skips the revoke (Task 6 adds the revoke; for now there is none, so no hook yet: write the test so it holds both before and after Task 6 by deleting the token row's revoked mark directly: `UPDATE cli_tokens SET revoked_at = 0`), re-create A, then every Self door on A answers the canonical 404, and a move-status read when the re-created A has `kind: "static"` answers the canonical 404, not the kind refusal. |
| + |
| +In `svcroutes_test.go`: `TestInstanceRoleMatchesTodaysAppTokenReach` must still pass unchanged (the three base sets keep `Instance`). Remove the three update doors from `closedDoors` (Task 5 opens them; do it in this task so the pins move together), change the `seen != 60` expectation and its sentence to 57 and drop "three instance update settings doors," from the sentence. Add the three update doors to a new pin in `TestAdminIsRefusedAtAClosedDoor`'s neighbourhood: |
| + |
| +```go |
| +func TestAppWideCredentialsStillRefusedAtUpdateDoors(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + for _, role := range []svcrole.Role{svcrole.Admin, svcrole.Instance} { |
| + tok := serviceToken(t, fx.st, OpsAccountID, "c-"+string(role), "kass", role) |
| + for _, d := range selfDoors[3:] { |
| + method, path, _ := strings.Cut(d, " ") |
| + u := strings.NewReplacer("{account}", fx.sqid, "{app}", "kass", "{host}", "web.go.tito.io").Replace(path) |
| + if st, _, body := rawReq(t, method, fx.ts.URL+u, tok, `{}`); st != http.StatusForbidden { |
| + t.Errorf("%s as %s = %d %s, want 403", d, role, st, body) |
| + } |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, expect FAIL.** |
| +- [ ] **Step 3: Implement.** |
| + |
| +Registrations in `authcli.go`: |
| + |
| +```go |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceGet) |
| + s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceUpdate) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceMoveStatus) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesGet) |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesPut) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesInstall) |
| +``` |
| + |
| +Rewrite the comments above them: the update doors' "Person-only until #155 step 6" becomes "People and the instance's own credential (#155, spec 2026-10-16 §4.2). Still closed to Instance and Admin: an app-wide credential would let one instance's code set a sibling's pin." Also update the file comment at `apiupdates.go:12-15` and `instanceupdates/control.go:4` to say the instance-bound credential has shipped and writes go through the console. |
| + |
| +`handleAPIInstanceGet`, straight after the successful `GetRecord`: |
| + |
| +```go |
| + p, _ := principalFrom(r.Context()) |
| + if bindingOf(p).refuses(rec) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| +``` |
| + |
| +`handleAPIInstanceUpdate`, straight after `readJSON` succeeds and before anything else reads `req`: |
| + |
| +```go |
| + if p.Instance != "" && (req.Environment != nil || req.Steering != nil || req.Gate != nil) { |
| + // An instance's own credential may repoint its upstreams and probe, |
| + // nothing else (spec §4.4.1): environment alone would bind it to a |
| + // sibling's bundle and its secrets. |
| + jsonError(w, http.StatusForbidden, selfPatchFieldsRefused) |
| + return |
| + } |
| +``` |
| + |
| +and straight after its `GetRecordETag` succeeds: |
| + |
| +```go |
| + if bindingOf(p).refuses(rec) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| +``` |
| + |
| +(`p` is already in scope there from `principalFrom`.) The write stays `provision.UpdateRecord(ctx, …, *rec, etag)` on that ETag. |
| + |
| +`moveGate`, straight after its `GetRecord` succeeds and BEFORE the kind check: |
| + |
| +```go |
| + p, _ := principalFrom(r.Context()) |
| + if bindingOf(p).refuses(rec) { |
| + http.NotFound(w, r) |
| + return nil, "", "", nil, false |
| + } |
| +``` |
| + |
| +Draft in `instancetoken_words.go`: |
| + |
| +```go |
| + selfPatchFieldsRefused = "an instance's own credential can change addrs and probe only. environment, steering and gate need a member of the app" |
| +``` |
| + |
| +- [ ] **Step 4: Run, expect PASS**; then `go test ./internal/console/ -run 'TestSelf|TestAppWide|TestEveryCLIDoor|TestEveryClosedDoor|TestInstanceRoleMatches|TestNoRoleReaches|TestAPIInstance|TestAPIInstanceMove'`. |
| +- [ ] **Step 5: Commit** `console: GET, PATCH and move status admit an instance's own credential (#155)` |
| + |
| +--- |
| + |
| +### Task 5: Update settings from the instance itself |
| + |
| +**Files:** |
| +- Modify: `internal/console/updatescore.go` (`readUpdatesTarget`, `applyUpdates`, `requestInstall`) |
| +- Modify: `internal/console/apiupdates.go` (three handlers) |
| +- Modify: `internal/console/browserupdates.go` (pass `selfBinding{}`) |
| +- Test: `internal/console/instancetoken_updates_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: `selfBinding`, `bindingOf`, `selfDoors`. |
| +- Produces (new signatures): |
| + |
| +```go |
| +func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string, b selfBinding) (*provision.Record, string, *provision.UpdatePolicy, int, string) |
| +func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal string, b selfBinding, rec *provision.Record, etag string, policy *provision.UpdatePolicy, req map[string]any) (updatesView, int, string) |
| +func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal string, b selfBinding, host string, fast bool) (uint64, int, string) |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing tests** in `instancetoken_updates_test.go`. Setup: copy `TestUpdatesDoorsRefuseAppWideCredentials`' fixture (`apiupdates_test.go:458`) for a record and an update policy, set the policy's `instance_control` through the member-only policy door, mint a Self token for the record. |
| + |
| +Cases, each a subtest: |
| +- `instance_control = []`: PUT `{"updates":"manual"}` → 403, body is `fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), "updates")`; the record's ETag is unchanged. |
| +- `instance_control = ["pin"]`: PUT `{"pin":"","updates":"manual"}` raw JSON with pin FIRST → 403 naming `updates` (fixed order, not request order). |
| +- `instance_control = ["updates"]`: PUT `{"updates":"manual"}` → 200, record `Updates == "manual"`, an activity row whose actor is `instance:<sqid>/<app>/<host>`. |
| +- inherited value: app `default_updates = manual`, record `Updates = ""`, `instance_control = []`: PUT the GET response's `explicit` object minus `channel` → 200 and no write (ETag unchanged); PUT `{"updates":"manual"}` (the effective value) → 403 naming `updates`. |
| +- `{"channel":"stable"}` (unchanged) → 400 `settings.console.channel-elsewhere`, as for a member. |
| +- no manifest at all: PUT `{"updates":"auto"}` where the record already has `""` → 403 naming `updates`. |
| +- install with `instance_control = []` → 403 naming `updates`; with `["updates"]` → 200 and `install_request.id` advanced; with `["updates"]` and `{"fast_track":true}` → 403 `settings.console.fast-track-owner`, and a test store spy (or a log assertion) shows `Store.Role` was never called with the instance actor. If there is no seam for the spy, assert instead that an account OWNER's own fast-track still works and that the Self request is refused before any record read (ETag unchanged, no audit row). |
| +- incarnation during install retry: install from Self, with a store wrapper (find the existing CAS-conflict injection in `apiupdates_test.go` or `updatescore_test.go`: `grep -n "ErrCASConflict" internal/console/*updates*_test.go`) that, on the first `UpdateRecord`, deletes the record and re-creates it with a new `CreatedAt` before returning the conflict → canonical 404, and neither the old nor the new record carries an install request. |
| +- a sibling host on all three doors → canonical 404 (already covered by Task 4's table; assert once here for PUT with a valid body to be sure the body is never parsed into a write). |
| +- Run `go test ./internal/console/ -run 'TestBrowserUpdates|TestAPIUpdates|TestUpdates'` unchanged as the regression for Review Focus 4. |
| + |
| +- [ ] **Step 2: Run, expect FAIL.** |
| +- [ ] **Step 3: Implement.** |
| + |
| +`readUpdatesTarget`, straight after the record read succeeds: |
| + |
| +```go |
| + if b.refuses(rec) { |
| + // Answered exactly as a host with no record: the caller is bound to |
| + // another incarnation (spec §4.3). |
| + return nil, "", nil, http.StatusNotFound, "" |
| + } |
| +``` |
| + |
| +`applyUpdates`, after `next` is fully computed from `req` and before `provision.ValidateUpdates`: |
| + |
| +```go |
| + if b.Host != "" { |
| + if key, refused := selfRefusedKey(*rec, next, policy); refused { |
| + return updatesView{}, http.StatusForbidden, fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), key) |
| + } |
| + } |
| +``` |
| + |
| +with, in `updatescore.go`: |
| + |
| +```go |
| +// selfRefusedKey is §4.4.2: the first of updates, major and pin, in that |
| +// fixed order, whose EXPLICIT value this request changes and which the app's |
| +// instance_control does not name. Effective values are not compared: echoing |
| +// an inherited value back would write an override. |
| +func selfRefusedKey(before, after provision.Record, policy *provision.UpdatePolicy) (string, bool) { |
| + may := map[string]bool{} |
| + if policy != nil { |
| + for _, k := range policy.InstanceControl { |
| + may[k] = true |
| + } |
| + } |
| + switch { |
| + case before.Updates != after.Updates && !may["updates"]: |
| + return "updates", true |
| + case !sameMajor(before.Major, after.Major) && !may["major"]: |
| + return "major", true |
| + case before.Pin != after.Pin && !may["pin"]: |
| + return "pin", true |
| + } |
| + return "", false |
| +} |
| +``` |
| + |
| +`requestInstall`: pass `b` to `readUpdatesTarget` on every attempt, keep the policy it returns (`rec, etag, policy, st, msg := …`), and after `shapeRefusal`: |
| + |
| +```go |
| + if b.Host != "" && !policyNames(policy, "updates") { |
| + return 0, http.StatusForbidden, fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), "updates") |
| + } |
| +``` |
| + |
| +with a small `policyNames(policy *provision.UpdatePolicy, key string) bool` helper beside `selfRefusedKey`. |
| + |
| +Handlers in `apiupdates.go`: each reads `p, _ := principalFrom(r.Context())` and passes `bindingOf(p)` into `updatesTarget` (give `updatesTarget` a `b selfBinding` parameter and forward it), `applyUpdates` and `requestInstall`. In `handleAPIUpdatesInstall`, before the existing fast-track owner check: |
| + |
| +```go |
| + if req.FastTrack && p.Instance != "" { |
| + jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner")) |
| + return |
| + } |
| +``` |
| + |
| +`browserupdates.go:78`, `:244`, `:281`, `:305`: pass `selfBinding{}`. |
| + |
| +- [ ] **Step 4: Run, expect PASS**, plus the regression list above. |
| +- [ ] **Step 5: Commit** `console: an instance sets its own update settings within instance_control (#155)` |
| + |
| +--- |
| + |
| +### Task 6: The mint door, revoke on delete, and the credential surfaces |
| + |
| +**Files:** |
| +- Create: `internal/console/apiinstancetoken.go` (the mint handler) |
| +- Modify: `internal/console/authcli.go` (register the door; `serviceAccountSqids`; `serviceRowOf` and `handleCLITokenRevoke` branch) |
| +- Modify: `internal/console/apiinstances.go` (`handleAPIInstanceDelete`) |
| +- Modify: `internal/console/apiservices.go` (`serviceWhat`, `serviceView` gains `Host string \`json:"host,omitempty"\``) |
| +- Modify: `internal/console/templates/settings.html` and the account credentials pane template (find it: `grep -ln "IsApp" internal/console/templates/*.html`) |
| +- Modify: `internal/console/instancetoken_words.go` (drafts) |
| +- Modify: `internal/console/svcroutes_test.go` (add the mint door to `closedDoors`, count 58) |
| +- Test: `internal/console/instancetoken_mint_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: `NewInstanceTokenContext`, `RevokeInstanceTokensContext`. |
| +- Produces: `POST /api/cli/apps/{account}/{app}/instances/{host}/token` → 201 `{"token","account","app","host"}`. |
| + |
| +- [ ] **Step 1: Write the failing tests:** |
| +- mint as a member → 201; the token resolves; its `host_born` equals the record's `CreatedAt.UnixNano()`; an activity row `instance-token` names the host. |
| +- mint for a host with no record → canonical 404; for a record whose `CreatedAt` is zero (write it with `provision.WriteRecord` directly) → 409 with `instanceTokenNoBirth`. |
| +- every service role, Admin included, and a Self token → 403 closed-door; a stranger person → the family 404 (copy `TestAPIInstanceTokenMintEndToEnd`'s shape). |
| +- delete A through the delete door → A's tokens are revoked (`CLIPrincipal` fails) before the record is gone; with a store hook making `RevokeInstanceTokensContext` fail → 500 and the record still exists. |
| +- personal revoke: Alice mints, is removed from the account, signs in, posts `/auth/cli/tokens/revoke` for the row → the "no longer a member" flash and the row is live; a current member who minted it can revoke it there. |
| +- `GET` services API lists the row with `role: "self"` and `host`. |
| +- the settings page renders the row with `instanceTokenRowLabel` (render and grep the HTML). |
| + |
| +- [ ] **Step 2: Run, expect FAIL.** |
| +- [ ] **Step 3: Implement.** |
| + |
| +`apiinstancetoken.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// handleAPIInstanceTokenMint mints a credential bound to one incarnation of |
| +// one instance (spec 2026-10-16 §5). People only, for the app-token mint's |
| +// reason: a credential never mints a credential. The zero Set at cliRoute is |
| +// the outer wall, apiPerson the inner one. |
| +func (s *Server) handleAPIInstanceTokenMint(w http.ResponseWriter, r *http.Request) { |
| + if !s.apiPerson(w, r) { |
| + return |
| + } |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, "this console has no deployment bucket configured") |
| + return |
| + } |
| + host := r.PathValue("host") |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var req struct { |
| + Label string `json:"label"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return |
| + } |
| + if rec.CreatedAt.IsZero() { |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(instanceTokenNoBirth, host)) |
| + return |
| + } |
| + raw, err := s.store.NewInstanceTokenContext(r.Context(), acct.ID, app, host, rec.CreatedAt.UnixNano(), addr, cleanLabel(req.Label), time.Now()) |
| + if err != nil { |
| + log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusInternalServerError, "could not mint the token — try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "instance-token", "minted an instance token for "+host+" (cli)") |
| + writeJSON(w, http.StatusCreated, map[string]any{"token": raw, "account": acct.Sqid, "app": app, "host": host}) |
| +} |
| +``` |
| + |
| +(The 429, 503 and 500 sentences are the app-token mint's existing ones, copied, so they need no review. Check the exact 503 text the instance family uses, `mainGateCopy().noBucket`, and use that.) |
| + |
| +Register beside the app-token mint in `authcli.go`, with the zero Set and a comment pointing at the mint door's reasoning: |
| + |
| +```go |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/token", svcrole.Set{}, s.handleAPIInstanceTokenMint) |
| +``` |
| + |
| +`handleAPIInstanceDelete`: after the host shape check and the instance gate, BEFORE the record delete: |
| + |
| +```go |
| + // Revoke first (spec §3.4): a failed revoke deletes nothing, and a |
| + // failed delete after it leaves a live instance with no token, which a |
| + // member mints again. The incarnation check is the guarantee; this is |
| + // tidiness. |
| + if err := s.store.RevokeInstanceTokensContext(ctx, acct.ID, app, host, time.Now()); err != nil { |
| + log.Printf("console: api instances delete %s/%s/%s: revoke instance tokens: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusInternalServerError, "could not delete the instance — try again") |
| + return |
| + } |
| +``` |
| + |
| +Read the handler first: if its delete is idempotent for an absent record, the revoke still runs (harmless, zero rows). |
| + |
| +`serviceRowOf`/`handleCLITokenRevoke`: change the classify so an instance row takes the `svc.IsApp()` branch: `case svc.IsApp() || svc.IsInstance():`. Check `serviceRowOf`'s `isService` return: it must stay false for instance rows (they are not "service credentials" with a name, so the first branch's flash, which prints `svc.Name`, never sees one). `serviceAccountSqids`: `if !(t.IsService() || t.IsInstance()) || t.AccountID == 0 { continue }`. |
| + |
| +`serviceWhat`: before the app-credential fallback, |
| + |
| +```go |
| + if row.IsInstance() { |
| + return row.App, fmt.Sprintf(instanceTokenRevokeWhat, row.Host, row.App) |
| + } |
| +``` |
| + |
| +`serviceView` gains `Host` filled from `t.Host`. Templates: where a row's label reads `{{if .IsApp}}…{{end}}`, add `{{else if .IsInstance}}` rendering `instanceTokenRowLabel` with the host (pass it through a template func or a `CLIToken` method `InstanceLabel() string` that formats the draft const; templates cannot read Go consts). |
| + |
| +Drafts in `instancetoken_words.go`: |
| + |
| +```go |
| + instanceTokenNoBirth = "%s has no creation time on its record, so a token cannot be bound to it. Delete and create it again" |
| + instanceTokenRevokeWhat = "instance credential for %s in %s" |
| + instanceTokenRowLabel = "bound to %s" |
| +``` |
| + |
| +- [ ] **Step 4: Run, expect PASS**; then `go test ./internal/console/ -run 'TestAPIInstanceToken|TestCLITokenRevoke|TestSettings|TestService|TestEveryClosedDoor|TestEveryCLIDoor|TestAPIInstanceDelete'`. |
| +- [ ] **Step 5: Commit** `console: mint an instance token; delete revokes it; credential surfaces name the host (#155)` |
| + |
| +--- |
| + |
| +### Task 7: Pin the creation call sites |
| + |
| +**Files:** |
| +- Test: `internal/console/instancetoken_creation_test.go` (new) |
| + |
| +- [ ] **Step 1: Write the test.** Parse every non-test `.go` file under `internal/` and the repo root with `go/parser`; for each call expression whose function is `provision.CreateRecord`, `CreateRecord` inside package `provision`, `hostops.PrepareInstance` or `PrepareInstance` inside package `hostops`, record `file:enclosing func`. Compare against: |
| + |
| +```go |
| +// Every path that creates an instance record (spec 2026-10-16 §3.4). Each |
| +// stamps CreatedAt from the clock (or, for PrepareInstance, refuses a |
| +// supplied one and stamps its own), which is what makes an instance token's |
| +// incarnation binding sound. A new caller fails this test: review it against |
| +// that rule, then add it here. |
| +var reviewedCreationSites = map[string]bool{ |
| + "internal/console/apiinstances.go:handleAPIInstanceCreate": true, |
| + "internal/console/apiinstanceseed.go:<func>": true, |
| + "internal/console/apiinstanceimport.go:<func>": true, |
| + "internal/console/domainattach.go:<func>": true, |
| + "internal/provision/replica.go:<func>": true, |
| +} |
| +``` |
| + |
| +Fill the `<func>` names from the first run's output (read each site before adding it and check it stamps the clock; `apiinstanceseed.go` and `apiinstanceimport.go` create from `c.rec`, stamped by the create door). Include the `hostops.PrepareInstance` caller(s) the same way. The test is in `internal/console` only because that is where the others live; it reads files by path relative to the module root (find the root with `go list -m -f '{{.Dir}}'` at test time via `os/exec`, or walk up from `os.Getwd()` to `go.mod`). |
| + |
| +- [ ] **Step 2: Run; adjust the map to the reviewed set; run again, PASS.** |
| +- [ ] **Step 3: Commit** `console: pin the instance record creation sites (#155)` |
| + |
| +--- |
| + |
| +### Task 8: CLI verb, docs and copy review |
| + |
| +**Files:** |
| +- Modify: `cli.go` (`runInstances`: `case "token": runInstancesToken(args[1:])`) |
| +- Modify: `instances_cli.go` (`runInstancesToken`, usage lines) |
| +- Modify: `apimode.go` (`func (s *apiSession) instanceToken(ctx context.Context, a apiAcct, app, host, label string) (string, error)`) |
| +- Modify: `docs/site/cli.md` (the `carlos instances` section) |
| +- Modify: `internal/console/instancetoken_words.go` (approved text) |
| +- Test: `instances_cli_test.go` (the root package's existing httptest pattern for `instanceDelete`) |
| + |
| +- [ ] **Step 1: Failing test**: against an httptest console stub (copy the `instanceDelete` client test), `instanceToken` POSTs `{"label":…}` to `…/instances/<escaped host>/token` and returns the token from a 201; a non-201 returns `apiErrorFromBody`. |
| +- [ ] **Step 2: Implement** the client method (shape of `instanceDelete`, with `s.postJSON`; check its name with `grep -n "func (s \*apiSession) postJSON" apimode.go`), and the verb: |
| + |
| +```go |
| +func runInstancesToken(args []string) { |
| + fs := flag.NewFlagSet("instances token", flag.ExitOnError) |
| + app := appFlag(fs, "app the instance belongs to") |
| + host := fs.String("host", "", "instance host the token is bound to") |
| + label := fs.String("label", "", "label shown on the account's credentials pane") |
| + consoleFlag := consoleOption(fs, "console to mint through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, instancesRecordsUsage) |
| + fs.Parse(args) |
| + if *host == "" { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + sess := requireAPISession("token", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + tok, err := sess.instanceToken(context.Background(), acct, app2, strings.TrimSpace(*host), *label) |
| + if err != nil { |
| + log.Fatalf("instances token: %v", err) |
| + } |
| + fmt.Fprintln(stdout, tok) |
| + fmt.Fprintf(os.Stderr, instancesTokenNotice+"\n", *host) |
| +} |
| +``` |
| + |
| +The token goes to stdout alone (so `$(carlos instances token …)` captures it); the notice goes to stderr. Add the usage line ` carlos instances token --app <a> --host <h> [--label <l>]` to `instancesRecordsUsage` and a draft: |
| + |
| +```go |
| +// instancesTokenNotice is a copy-review draft (#155, plan Task 8). |
| +const instancesTokenNotice = "this token acts only on %s, and only while this instance exists. It is shown once" |
| +``` |
| + |
| +The docs section in `docs/site/cli.md`: a short paragraph under `carlos instances` (draft, reviewed with the rest). |
| + |
| +- [ ] **Step 3: Copy review.** Invoke the `copy-review` skill with every draft: `instanceTokenDefaultLabel`, `selfPatchFieldsRefused`, `instanceTokenNoBirth`, `instanceTokenRevokeWhat`, `instanceTokenRowLabel`, `instancesTokenNotice`, the usage line and the docs paragraph. Before applying, count each string's occurrences at the base commit (house rule: shared strings need their own review). Apply the approved text verbatim. Read `result.json` before restarting the review server, never after. |
| +- [ ] **Step 4: Run** `go test . -run 'TestInstances'` and `go test ./internal/console/ -run 'TestSelf|TestAPIInstanceToken|TestService'`. |
| +- [ ] **Step 5: Commit** `carlos instances token, docs, and the reviewed copy (#155)` |
| + |
| +--- |
| + |
| +## After the tasks |
| + |
| +- Whole-branch review by Astra (Opus is the session model): a brief naming the spec, the plan, the branch range, and the Review Focus list. |
| +- Merge per AGENTS.md: re-merge main, wait for CI green on the tip, `amadan branch merge carlos/platform instance-bound-tokens-155 -squash -expect <tip> -message "…"`. Never run `amadan branch merge` bare. |
| +- Not in this branch: release, roll (dev first), and half 2. |