console: the instance token kind in the store (#155)
A cli_tokens kind 'instance' with host and host_born columns, resolving to svcrole.Self bound to one incarnation of one host. Purge deletes the rows, rename and account move carry them, and the destination stale-credential counts ignore them. Orphan rows whose app row is gone are left off both listings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+468
−18
internal/console/apicli.go+5 −0internal/console/authstore.go+23 −4internal/console/claim_app_identity.go+1 −1internal/console/instancetoken.go+69 −0internal/console/instancetoken_store_test.go+317 −0internal/console/instancetoken_words.go+7 −0internal/console/store.go+14 −6internal/console/svcprincipal.go+32 −7
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index b5c75ea34..d2986d52f 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -96,6 +96,11 @@ func auditActor(p Principal, acct *Account) string { |
| if p.Kind == PrincipalUser { |
| return p.Address |
| } |
| + 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 |
| + } |
| if p.Name == "" { |
| // A legacy kind='app' row has no name; keep the string the audit |
| // trail has always written for it, so old rows and new rows about |
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 252792a48..8b010a172 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -126,6 +126,8 @@ type CLIToken struct { |
| // Rungs is a deploy credential's scope in its stored, canonical form |
| // ("canary,edge"); empty for every other kind and role. |
| Rungs string |
| + // Host is an instance token's host; empty for every other kind. |
| + Host string |
| } |
| // RungList is Rungs for a person to read: "canary, edge". |
| @@ -136,7 +138,7 @@ func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", |
| // page asks this rather than testing ExpiresAt against the epoch itself, so |
| // the "0 means never" convention lives in exactly one place. |
| func (t CLIToken) NeverExpires() bool { |
| - return (t.IsApp() || t.IsService()) && (t.ExpiresAt.IsZero() || t.ExpiresAt.Unix() == 0) |
| + return (t.IsApp() || t.IsService() || t.IsInstance()) && (t.ExpiresAt.IsZero() || t.ExpiresAt.Unix() == 0) |
| } |
| // IsApp reports whether this row is an app-held token, for the page's |
| @@ -148,6 +150,10 @@ func (t CLIToken) IsApp() bool { return t.Kind == cliKindApp } |
| // reason IsApp exists: a template cannot see cliKindService. |
| func (t CLIToken) IsService() bool { return t.Kind == cliKindService } |
| +// 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 } |
| + |
| // Handle is how this credential is ADDRESSED by the account pane's rotate |
| // and revoke forms and by `carlos services revoke`: the NAME its owner chose |
| // when it has one, and its listing id otherwise. |
| @@ -222,6 +228,10 @@ func (s *Store) migrateCLIAuth() error { |
| `ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE cli_tokens ADD COLUMN rotated_at INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`, |
| + // An instance token's binding (spec 2026-10-16-instance-bound-tokens |
| + // §3.1): the host, and the record's CreatedAt in Unix nanoseconds. |
| + `ALTER TABLE cli_tokens ADD COLUMN host TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE cli_tokens ADD COLUMN host_born INTEGER NOT NULL DEFAULT 0`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -261,6 +271,11 @@ const ( |
| cliKindUser = "" |
| cliKindApp = "app" |
| cliKindService = "service" |
| + // cliKindInstance is bound to one incarnation of one instance (spec |
| + // 2026-10-16-instance-bound-tokens §3). It is a kind of its own, not a |
| + // column on kind='app', so a console that predates it resolves no such |
| + // row rather than reading it as app-wide. |
| + cliKindInstance = "instance" |
| ) |
| // newUserCode mints the typed code, grouped XXXX-XXXX so it can be read out |
| @@ -570,7 +585,7 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| var t CLIToken |
| var created, expires, used, revoked, rotated int64 |
| if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App, |
| - &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs); err != nil { |
| + &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs, &t.Host); err != nil { |
| return nil, err |
| } |
| t.CreatedAt = time.Unix(created, 0).UTC() |
| @@ -587,6 +602,9 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| if t.Kind == cliKindApp { |
| t.Role = string(svcrole.Instance) |
| } |
| + if t.Kind == cliKindInstance { |
| + t.Role = string(svcrole.Self) |
| + } |
| out = append(out, t) |
| } |
| return out, rows.Err() |
| @@ -601,8 +619,9 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| // keep that promise. |
| func (s *Store) CLITokens(address string) ([]CLIToken, error) { |
| rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| -role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs |
| -FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address) |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, host |
| +FROM cli_tokens WHERE address = ? AND revoked_at = 0 AND `+liveInstanceAppClause+` |
| +ORDER BY created_at DESC, token_hash`, address) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/console/claim_app_identity.go b/internal/console/claim_app_identity.go| index 21c9a91bc..f6ef44588 100644 |
| --- a/internal/console/claim_app_identity.go |
| +++ b/internal/console/claim_app_identity.go |
| @@ -179,7 +179,7 @@ func (s *Store) PurgeClaimAppRow(ctx context.Context, accountID int64, app, appI |
| if err = forgetMailApp(ctx, tx, sqid, app); err != nil { |
| return err |
| } |
| - if _, err = tx.ExecContext(ctx, `DELETE FROM cli_tokens WHERE kind IN (?,?) AND account_id=? AND app=?`, cliKindApp, cliKindService, accountID, app); err != nil { |
| + if _, err = tx.ExecContext(ctx, `DELETE FROM cli_tokens WHERE kind IN (?,?,?) AND account_id=? AND app=?`, cliKindApp, cliKindService, cliKindInstance, accountID, app); err != nil { |
| return err |
| } |
| if err = revokeClaimDoorKeys(tx, accountID, app); err != nil { |
diff --git a/internal/console/instancetoken.go b/internal/console/instancetoken.go| new file mode 100644 |
| index 000000000..3b4e75122 |
| --- /dev/null |
| +++ b/internal/console/instancetoken.go |
| @@ -0,0 +1,69 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// Instance-bound tokens: the minter, the revoker and the listing rule (spec |
| +// docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md §3). |
| + |
| +// liveInstanceAppClause keeps an orphan instance row off both listings: a |
| +// row whose (account, app) has no app row at all, live or trashed, can pass |
| +// no door, and in claim mode cannot be revoked either, because SQL |
| +// admission needs the app's identity. If an app of that name exists again |
| +// the row is listed again, still inert, and revocable through that app |
| +// (spec §3.4, codex R3-2). |
| +const liveInstanceAppClause = `(kind != 'instance' OR EXISTS (SELECT 1 FROM apps |
| +WHERE apps.account_id = cli_tokens.account_id AND apps.app = cli_tokens.app))` |
| + |
| +// NewInstanceTokenContext mints a credential bound to one incarnation of one |
| +// instance (spec §3, §5). born is the record's CreatedAt in Unix |
| +// nanoseconds; mintedBy is the member, kept so a human stays answerable. It |
| +// 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 for 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 { |
| + return view.claimRawRevokeInstanceTokens(accountID, app, host, now) |
| + }) |
| +} |
| + |
| +func (s *Store) claimRawRevokeInstanceTokens(accountID int64, app, host string, now time.Time) error { |
| + _, err := s.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 |
| +} |
diff --git a/internal/console/instancetoken_store_test.go b/internal/console/instancetoken_store_test.go| new file mode 100644 |
| index 000000000..083fad461 |
| --- /dev/null |
| +++ b/internal/console/instancetoken_store_test.go |
| @@ -0,0 +1,317 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// Instance-bound tokens in the store (spec 2026-10-16-instance-bound-tokens |
| +// §3). testBorn stands in for a record's CreatedAt in Unix nanoseconds. |
| +const testBorn = int64(1_789_000_000_123_456_789) |
| + |
| +func mintBoundToken(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 liveRowCount(t *testing.T, st *Store, kind string) int { |
| + t.Helper() |
| + var n int |
| + if err := st.db.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND revoked_at = 0`, kind).Scan(&n); err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| +} |
| + |
| +func TestCLIPrincipalInstanceRowIsSelf(t *testing.T) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.CreateAppIn(acct.ID, "shop", "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn) |
| + p, ok := s.CLIPrincipal(raw, time.Now()) |
| + if !ok { |
| + t.Fatal("instance token did not resolve") |
| + } |
| + if p.Kind != PrincipalService || p.Address != "alice@keymail.dev" || p.AccountID != acct.ID || p.App != "shop" || |
| + p.Role != svcrole.Self || p.Instance != "a.example.com" || p.InstanceBorn != testBorn || p.Name != "" || p.Rungs != nil { |
| + t.Fatalf("principal = %+v", p) |
| + } |
| +} |
| + |
| +// Every malformed instance row fails to resolve rather than resolving to |
| +// something wider. Raw SQL, because no minter can produce them. |
| +func TestCLIPrincipalRefusesMalformedInstanceRows(t *testing.T) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.CreateAppIn(acct.ID, "shop", "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn) |
| + if _, err := s.db.Exec(stmt, hashToken(raw)); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if p, ok := s.CLIPrincipal(raw, time.Now()); ok { |
| + t.Errorf("%s: resolved to %+v, want no principal", name, p) |
| + } |
| + } |
| +} |
| + |
| +func TestNewInstanceTokenRefusesAnEmptyBinding(t *testing.T) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + for name, c := range map[string]struct { |
| + acct int64 |
| + app, host string |
| + born int64 |
| + }{ |
| + "no account": {0, "shop", "a.example.com", testBorn}, |
| + "no app": {acct.ID, "", "a.example.com", testBorn}, |
| + "no host": {acct.ID, "shop", "", testBorn}, |
| + "no born": {acct.ID, "shop", "a.example.com", 0}, |
| + } { |
| + if _, err := s.NewInstanceTokenContext(context.Background(), c.acct, c.app, c.host, c.born, "alice@keymail.dev", "", time.Now()); err == nil { |
| + t.Errorf("%s: minted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestInstanceTokenKeysArePerIncarnation(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" |
| + reborn := a |
| + reborn.InstanceBorn = 2 |
| + legacy := Principal{Kind: PrincipalService, AccountID: 7, App: "shop", Role: svcrole.Instance} |
| + if got := principalKey(a); got != "instance:7/shop/a.example.com/1" { |
| + t.Fatalf("principalKey(a) = %q", got) |
| + } |
| + if principalKey(a) == principalKey(b) || principalKey(a) == principalKey(reborn) || principalKey(a) == principalKey(legacy) { |
| + t.Fatal("instance principal keys collapse onto a sibling's, a replacement'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) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.CreateAppIn(acct.ID, "shop", "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn) |
| + rows, err := s.ServicePrincipals(acct.ID) |
| + 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 != "shop" { |
| + t.Fatalf("listed row = %+v", row) |
| + } |
| + if row.Label != "a.example.com instance token" { |
| + t.Fatalf("default label = %q", row.Label) |
| + } |
| + mine, err := s.CLITokens("alice@keymail.dev") |
| + if err != nil || len(mine) != 1 || mine[0].Host != "a.example.com" { |
| + t.Fatalf("CLITokens = %+v, %v", mine, err) |
| + } |
| + if err := s.RevokeInstanceTokensContext(context.Background(), acct.ID, "shop", "a.example.com", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := s.CLIPrincipal(raw, time.Now()); ok { |
| + t.Fatal("revoked instance token still resolves") |
| + } |
| + // Zero rows is not an error: delete calls it for every host. |
| + if err := s.RevokeInstanceTokensContext(context.Background(), acct.ID, "shop", "never.example.com", time.Now()); err != nil { |
| + t.Fatalf("revoking a host with no tokens: %v", err) |
| + } |
| +} |
| + |
| +func TestPurgeDeletesInstanceTokens(t *testing.T) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.CreateAppIn(acct.ID, "shop", "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn) |
| + if err := s.TrashAppRow(acct.ID, "shop", time.Now()); err != nil { |
| + t.Fatalf("TrashAppRow: %v", err) |
| + } |
| + if err := s.PurgeAppRow(acct.ID, "shop"); err != nil { |
| + t.Fatalf("PurgeAppRow: %v", err) |
| + } |
| + var n int |
| + if err := s.db.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ?`, cliKindInstance).Scan(&n); err != nil || n != 0 { |
| + t.Fatalf("instance rows after purge = %d, %v", n, err) |
| + } |
| +} |
| + |
| +func TestTrashKeepsInstanceTokens(t *testing.T) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.CreateAppIn(acct.ID, "shop", "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn) |
| + if err := s.TrashAppRow(acct.ID, "shop", time.Now()); err != nil { |
| + t.Fatalf("TrashAppRow: %v", err) |
| + } |
| + if err := s.RestoreAppRow(acct.ID, "shop"); err != nil { |
| + t.Fatalf("RestoreAppRow: %v", err) |
| + } |
| + if _, ok := s.CLIPrincipal(raw, time.Now()); !ok { |
| + t.Fatal("trash then restore lost the instance token") |
| + } |
| +} |
| + |
| +func TestRenameAndMoveCarryInstanceTokens(t *testing.T) { |
| + s := openTestStore(t) |
| + from := mustAccount(t, s, "amadan") |
| + to := mustAccount(t, s, "other") |
| + if _, err := s.CreateAppIn(from.ID, "shop", "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := mintBoundToken(t, s, from.ID, "shop", "a.example.com", testBorn) |
| + if err := s.RenameAppIn(from.ID, "shop", "store"); err != nil { |
| + t.Fatalf("RenameAppIn: %v", err) |
| + } |
| + if p, ok := s.CLIPrincipal(raw, time.Now()); !ok || p.App != "store" { |
| + t.Fatalf("after rename: %+v, %v", p, ok) |
| + } |
| + if err := s.MoveApp("store", from.ID, to.ID); err != nil { |
| + t.Fatalf("MoveApp: %v", err) |
| + } |
| + if p, ok := s.CLIPrincipal(raw, time.Now()); !ok || p.AccountID != to.ID { |
| + t.Fatalf("after move: %+v, %v", p, ok) |
| + } |
| +} |
| + |
| +// A row an older console's purge left behind (it did not know the kind) is |
| +// bound to an incarnation that no longer exists, so it must not block a |
| +// rename or a move into its app name (spec §3.4). |
| +func TestStaleInstanceRowsDoNotBlockRenameOrMove(t *testing.T) { |
| + s := openTestStore(t) |
| + from := mustAccount(t, s, "amadan") |
| + to := mustAccount(t, s, "other") |
| + for _, app := range []string{"shop", "cart"} { |
| + if _, err := s.CreateAppIn(from.ID, app, "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Leftovers at the destinations: (from, "store") for the rename and |
| + // (to, "cart") for the move. |
| + mintBoundToken(t, s, from.ID, "store", "old.example.com", testBorn) |
| + mintBoundToken(t, s, to.ID, "cart", "old.example.com", testBorn) |
| + if err := s.RenameAppIn(from.ID, "shop", "store"); err != nil { |
| + t.Fatalf("RenameAppIn over a stale instance row: %v", err) |
| + } |
| + if err := s.MoveApp("cart", from.ID, to.ID); err != nil { |
| + t.Fatalf("MoveApp over a stale instance row: %v", err) |
| + } |
| +} |
| + |
| +// The previous release's purge statement, run against a database holding |
| +// an instance row, leaves the row behind; the new console neither lists it |
| +// while its app is gone nor lets it block a later rename into that name. |
| +func TestOldConsoleSQLLeavesHarmlessInstanceRows(t *testing.T) { |
| + s := openTestStore(t) |
| + acct := mustAccount(t, s, "amadan") |
| + for _, app := range []string{"shop", "cart"} { |
| + if _, err := s.CreateAppIn(acct.ID, app, "paul@example.test", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + raw := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn) |
| + if _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE kind IN ('app', 'service') AND account_id = ? AND app = ?`, acct.ID, "shop"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.db.Exec(`DELETE FROM apps WHERE account_id = ? AND app = ?`, acct.ID, "shop"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if liveRowCount(t, s, cliKindInstance) != 1 { |
| + t.Fatal("setup: the old purge should have left the instance row") |
| + } |
| + rows, err := s.ServicePrincipals(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, r := range rows { |
| + if r.ID == hashToken(raw) { |
| + t.Fatal("an orphan instance row is listed while its app is gone") |
| + } |
| + } |
| + mine, err := s.CLITokens("alice@keymail.dev") |
| + if err != nil || len(mine) != 0 { |
| + t.Fatalf("an orphan instance row is on its minter's page: %+v, %v", mine, err) |
| + } |
| + if err := s.RenameAppIn(acct.ID, "cart", "shop"); err != nil { |
| + t.Fatalf("RenameAppIn into the purged name: %v", err) |
| + } |
| + // The name exists again, so the row is listed (and revocable) again. |
| + rows, err = s.ServicePrincipals(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, r := range rows { |
| + found = found || r.ID == hashToken(raw) |
| + } |
| + if !found { |
| + t.Fatal("the instance row is not listed once its app name exists again") |
| + } |
| +} |
| + |
| +// The gated store: the mint and the revoke are admitted for their app, as |
| +// NewAppToken's insert is. |
| +func TestInstanceTokenMintAndRevokeAreAdmitted(t *testing.T) { |
| + cx := newClaimCase(t) |
| + raw, err := cx.st.NewInstanceTokenContext(context.Background(), OpsAccountID, "kass", "a.example.com", testBorn, claimFxOwner, "", time.Now()) |
| + if err != nil { |
| + t.Fatalf("gated mint: %v", err) |
| + } |
| + if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok { |
| + t.Fatal("gated mint does not resolve") |
| + } |
| + restore := cx.foreign(t, claimAppRef{Account: "bab", App: "kass"}) |
| + err = cx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", time.Now()) |
| + restore() |
| + if err == nil { |
| + t.Fatal("an instance token was revoked while its app was closed under a foreign exclusive operation") |
| + } |
| + if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok { |
| + t.Fatal("the refused revoke still revoked the token") |
| + } |
| + if err := cx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", time.Now()); err != nil { |
| + t.Fatalf("gated revoke: %v", err) |
| + } |
| + if _, ok := cx.st.CLIPrincipal(raw, time.Now()); ok { |
| + t.Fatal("the gated revoke did not revoke") |
| + } |
| +} |
diff --git a/internal/console/instancetoken_words.go b/internal/console/instancetoken_words.go| new file mode 100644 |
| index 000000000..f9e27fec4 |
| --- /dev/null |
| +++ b/internal/console/instancetoken_words.go |
| @@ -0,0 +1,7 @@ |
| +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" |
| +) |
diff --git a/internal/console/store.go b/internal/console/store.go| index e301a3ebc..169573723 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -1732,8 +1732,8 @@ func (s *Store) PurgeAppRow(accountID int64, app string) error { |
| // than deleted: the revision has to keep climbing past what boxes |
| // already admitted, and the re-sign pass publishes the now-empty |
| // record (apidoorkeys.go). |
| - if _, err := tx.ExecContext(ctx, `DELETE FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| - cliKindApp, cliKindService, accountID, app); err != nil { |
| + if _, err := tx.ExecContext(ctx, `DELETE FROM cli_tokens WHERE kind IN (?, ?, ?) AND account_id = ? AND app = ?`, |
| + cliKindApp, cliKindService, cliKindInstance, accountID, app); err != nil { |
| return err |
| } |
| if err := revokeAllDoorKeys(tx, accountID, app); err != nil { |
| @@ -1923,6 +1923,10 @@ func (s *Store) MoveApp(app string, from, to int64) error { |
| // LATER app of the same name inherits unminted (see |
| // TestRenameAppMovesServicePrincipalToo for the RenameAppIn twin of this |
| // hazard). |
| + // 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, and it must not block this (spec |
| + // 2026-10-16-instance-bound-tokens §3.4). |
| var staleTokens int |
| if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| cliKindApp, cliKindService, to, app).Scan(&staleTokens); err != nil { |
| @@ -1966,8 +1970,8 @@ LIMIT 1`, cliKindService, to, cliKindService, from, app).Scan(&collidingName) |
| to, app, from); err != nil { |
| return err |
| } |
| - if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| - to, cliKindApp, cliKindService, from, app); err != nil { |
| + if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind IN (?, ?, ?) AND account_id = ? AND app = ?`, |
| + to, cliKindApp, cliKindService, cliKindInstance, from, app); err != nil { |
| return err |
| } |
| // Door keys cannot follow the app: each key names its account's sqid |
| @@ -2069,6 +2073,10 @@ func (s *Store) renameAppIn(accountID int64, from, to string, undo bool) error { |
| if taken > 0 { |
| return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to) |
| } |
| + // 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, and it must not block this (spec |
| + // 2026-10-16-instance-bound-tokens §3.4). |
| var staleTokens int |
| if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| cliKindApp, cliKindService, accountID, to).Scan(&staleTokens); err != nil { |
| @@ -2085,8 +2093,8 @@ func (s *Store) renameAppIn(accountID int64, from, to string, undo bool) error { |
| to, accountID, from, createdAt); err != nil { |
| return err |
| } |
| - if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| - to, cliKindApp, cliKindService, accountID, from); err != nil { |
| + if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind IN (?, ?, ?) AND account_id = ? AND app = ?`, |
| + to, cliKindApp, cliKindService, cliKindInstance, accountID, from); err != nil { |
| return err |
| } |
| var sqid string |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index cdd8940c9..4753dcf4f 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -27,6 +27,7 @@ import ( |
| "strconv" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -57,6 +58,13 @@ type Principal struct { |
| // other role, and never empty for a deploy principal: CLIPrincipal |
| // refuses a deploy row it cannot read a scope from. |
| Rungs []string |
| + // 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; cliRoute and selfBinding read Instance != "" as |
| + // "this caller may act only on that host". |
| + Instance string |
| + InstanceBorn int64 |
| } |
| // ErrServiceNameTaken is returned by NewServicePrincipal when the account |
| @@ -80,11 +88,11 @@ func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) { |
| return Principal{}, false |
| } |
| hash := hashToken(rawToken) |
| - var storedHash, addr, kind, app, role, name, rungs string |
| - var accountID, expires, revoked int64 |
| - err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs |
| + var storedHash, addr, kind, app, role, name, rungs, host string |
| + var accountID, expires, revoked, born int64 |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs, host, host_born |
| FROM cli_tokens WHERE token_hash = ?`, hash). |
| - Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs) |
| + Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs, &host, &born) |
| if err != nil { |
| return Principal{}, false |
| } |
| @@ -146,6 +154,17 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| return Principal{}, false |
| } |
| p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope} |
| + 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 (spec 2026-10-16-instance-bound-tokens §3.2). |
| + 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} |
| default: |
| return Principal{}, false |
| } |
| @@ -279,9 +298,9 @@ VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`, |
| // stays out of CLITokens. |
| func (s *Store) ServicePrincipals(accountID int64) ([]CLIToken, error) { |
| rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| -role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs |
| -FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?) AND revoked_at = 0 |
| -ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp) |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, host |
| +FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?, ?) AND revoked_at = 0 AND `+liveInstanceAppClause+` |
| +ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp, cliKindInstance) |
| if err != nil { |
| return nil, err |
| } |
| @@ -423,6 +442,12 @@ func principalKey(p Principal) string { |
| if p.Kind == PrincipalUser { |
| return p.Address |
| } |
| + if p.Instance != "" { |
| + // Per incarnation, not per host: a dead incarnation's surviving token |
| + // must not spend its replacement's budget (spec §3.5, codex R3-1). |
| + // Before the Name test: an instance row has no name either. |
| + return "instance:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App + "/" + p.Instance + "/" + strconv.FormatInt(p.InstanceBorn, 10) |
| + } |
| if p.Name == "" { |
| return "app:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App |
| } |