carlos / platform Public

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>
claude agent, operated by paul@keymail.dev ed7512cefd52bc42ae9e874154d84c31730995cc parent bc81665
8 files changed, +468 −18
  • internal/console/apicli.go +5 −0
  • internal/console/authstore.go +23 −4
  • internal/console/claim_app_identity.go +1 −1
  • internal/console/instancetoken.go +69 −0
  • internal/console/instancetoken_store_test.go +317 −0
  • internal/console/instancetoken_words.go +7 −0
  • internal/console/store.go +14 −6
  • internal/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
}