carlos / platform Public

console: the move-capable instance token: a moves column, Principal.Moves and TokenID, an owner-only mint

cli_tokens gains moves (default 0, idempotent ALTER). NewAppToken and
NewAppTokenContext take moves; every existing caller passes false. Both
listings and CLIPrincipal read it (Moves for kind='app' rows only), and
Principal.TokenID is the stored token_hash. The mint door takes
{"label","moves"}; moves needs RoleOwner, else 403, and its audit line names
the token id. The tokens page marks a move-capable app token.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a8833c6bb9554d011108fe8c5d3450ec6c52faf7 parent e8087b4
23 files changed, +292 −65
  • internal/console/apidb_test.go +1 −1
  • internal/console/apidomains_test.go +5 −5
  • internal/console/apidoorkeys_test.go +2 −2
  • internal/console/apiinstances.go +14 −2
  • internal/console/apiinstances_test.go +4 −4
  • internal/console/apiinstancesenable_test.go +2 −2
  • internal/console/apiroutes_test.go +1 −1
  • internal/console/apiservices_test.go +3 −3
  • internal/console/apisidecars_test.go +3 −3
  • internal/console/authstore.go +32 −12
  • internal/console/authstore_test.go +5 −5
  • internal/console/channelmove_test.go +1 −1
  • internal/console/claim_invariant_fixtures_test.go +1 −1
  • internal/console/claim_invariant_probes_test.go +1 −1
  • internal/console/claim_invariant_test.go +1 −1
  • internal/console/domaincatchall_test.go +1 −1
  • internal/console/domainplatform_test.go +1 −1
  • internal/console/instancetoken_moves_test.go +185 −0
  • internal/console/svcpage_test.go +4 −4
  • internal/console/svcprincipal.go +15 −5
  • internal/console/svcprincipal_test.go +5 −5
  • internal/console/svcscope_test.go +4 −4
  • internal/console/templates/settings.html +1 −1
diff --git a/internal/console/apidb_test.go b/internal/console/apidb_test.go
index bf7501a20..cbc07f785 100644
--- a/internal/console/apidb_test.go
+++ b/internal/console/apidb_test.go
@@ -358,7 +358,7 @@ func TestAPIDBStrangersMeetTheByteIdentical404(t *testing.T) {
func TestAPIDBRefusesEveryServicePrincipalTheSameWay(t *testing.T) {
fx := dbFixture(t)
fx.replicate(t, dbTestHost, 1)
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index a8088c186..cb6a512ce 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -174,11 +174,11 @@ func TestAPIDomainAttachAppToken(t *testing.T) {
if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
t.Fatal(err)
}
- mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
- foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now)
+ foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -932,7 +932,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) {
if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
t.Fatal(err)
}
- foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now)
+ foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -1381,11 +1381,11 @@ func TestLegacyAppTokensHaveSeparateWildFailBudgets(t *testing.T) {
if _, err := fx.st.CreateAppIn(OpsAccountID, "widgets", "paul@keymail.dev", now); err != nil {
t.Fatal(err)
}
- tokTitogo, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router-titogo", now)
+ tokTitogo, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router-titogo", false, now)
if err != nil {
t.Fatalf("NewAppToken(titogo): %v", err)
}
- tokWidgets, err := fx.st.NewAppToken(OpsAccountID, "widgets", "paul@keymail.dev", "router-widgets", now)
+ tokWidgets, err := fx.st.NewAppToken(OpsAccountID, "widgets", "paul@keymail.dev", "router-widgets", false, now)
if err != nil {
t.Fatalf("NewAppToken(widgets): %v", err)
}
diff --git a/internal/console/apidoorkeys_test.go b/internal/console/apidoorkeys_test.go
index 0e224b2fd..db836d10a 100644
--- a/internal/console/apidoorkeys_test.go
+++ b/internal/console/apidoorkeys_test.go
@@ -456,7 +456,7 @@ func TestPurgeAppRowLeavesNoCredentialsForTheName(t *testing.T) {
if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@keymail.dev", now); err != nil {
t.Fatal(err)
}
- appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", now)
+ appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", false, now)
if err != nil {
t.Fatal(err)
}
@@ -524,7 +524,7 @@ func TestPurgeAppRowKeepsALiveAppsCredentials(t *testing.T) {
if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@keymail.dev", now); err != nil {
t.Fatal(err)
}
- appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", now)
+ appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", false, now)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 52eea7e84..64ffce062 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -2360,10 +2360,18 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request)
}
var req struct {
Label string `json:"label"`
+ // Moves mints a move-capable token (self-serve moves, spec §5): the
+ // credential the self-serve move doors require. Owner only. When
+ // #155's instance-bound tokens arrive, moves is refused on them here.
+ Moves bool `json:"moves"`
}
if !readJSON(w, r, &req) {
return
}
+ if req.Moves && acct.Role != RoleOwner {
+ jsonError(w, http.StatusForbidden, "only an account owner can mint a move-capable instance token")
+ return
+ }
// Same de-fanging every terminal label gets: this string is rendered on
// the minter's tokens page, where it is the one field a human reads to
// decide whether to revoke.
@@ -2371,13 +2379,17 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request)
if label == "" {
label = app + " instance token"
}
- raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, time.Now())
+ raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, req.Moves, time.Now())
if err != nil {
log.Printf("console: api instance token %s/%s: %v", acct.Sqid, app, 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 "+app+" (cli)")
+ if req.Moves {
+ s.audit(acct, app, addr, "instance-token", "minted a move-capable instance token for "+app+" (cli), token "+hashToken(raw))
+ } else {
+ s.audit(acct, app, addr, "instance-token", "minted an instance token for "+app+" (cli)")
+ }
// Shown exactly once: only the hash is stored, so there is no second
// copy to hand out and no way to recover this one. Delivery to the app
// is the config-env layer, like any other app secret —
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 7bd168219..c43f23f88 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -150,7 +150,7 @@ func TestAPIInstanceRefusalMatrix(t *testing.T) {
t.Fatal(err)
}
// An app token bound to (bab, titogo) — the honest binding.
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -221,7 +221,7 @@ func TestAPIInstanceNoToken(t *testing.T) {
func TestAPIInstanceAppTokenContainment(t *testing.T) {
fx, _ := instanceFixture(t, "code-inst-contain")
enableInstances(t, fx, "titogo", titoManifest())
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
@@ -1255,7 +1255,7 @@ func TestAPIInstanceUpdateRefusalMatrix(t *testing.T) {
if _, err := fx.st.CreateAppIn(42, "titogo", "bob@keymail.dev", now); err != nil {
t.Fatal(err)
}
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -2800,7 +2800,7 @@ func TestAPIInstanceUpstreamsAreOperatorOnly(t *testing.T) {
}
// A service credential is not a person, operator or not.
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "ci", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "ci", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/apiinstancesenable_test.go b/internal/console/apiinstancesenable_test.go
index 2a9c6857a..97cc987cd 100644
--- a/internal/console/apiinstancesenable_test.go
+++ b/internal/console/apiinstancesenable_test.go
@@ -198,7 +198,7 @@ func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) {
// The app token meets the same wall: it authorizes titogo's records,
// and a sibling's apex is not one of them.
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -264,7 +264,7 @@ func TestAPIInstanceEnableRefusals(t *testing.T) {
fx.srv.cfg.AppsDomain = "oncarlos.test"
want := canonicalNotFound(t)
eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-enable-eve")
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/apiroutes_test.go b/internal/console/apiroutes_test.go
index 4c8740738..9dd4f1e96 100644
--- a/internal/console/apiroutes_test.go
+++ b/internal/console/apiroutes_test.go
@@ -280,7 +280,7 @@ func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) {
// and the instance family's.
func TestAPIRoutesAppTokenIsRefused(t *testing.T) {
fx, _ := routesFixture(t, "code-routes-apptoken")
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go
index eadb5b618..49317bcef 100644
--- a/internal/console/apiservices_test.go
+++ b/internal/console/apiservices_test.go
@@ -319,7 +319,7 @@ func sortedStrings(s []string) bool {
func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) {
fx := newConsoleFixture(t)
owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-legacy")
- if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil {
+ if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", false, time.Now()); err != nil {
t.Fatalf("NewAppToken: %v", err)
}
code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
@@ -446,7 +446,7 @@ func TestRotateAnUnknownNameIs404(t *testing.T) {
// A legacy app token has no name, so an empty-ish name must not reach
// it: rotating "the nameless row" would hand out one secret while
// invalidating several credentials.
- if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil {
+ if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", false, time.Now()); err != nil {
t.Fatal(err)
}
if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/%20/rotate",
@@ -688,7 +688,7 @@ func TestUnusableRevokeHandleIsAudited(t *testing.T) {
func TestRevokeAddressesARowByItsListingID(t *testing.T) {
fx := newConsoleFixture(t)
owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-id")
- legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now())
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/apisidecars_test.go b/internal/console/apisidecars_test.go
index 62ff24636..0d36559b7 100644
--- a/internal/console/apisidecars_test.go
+++ b/internal/console/apisidecars_test.go
@@ -85,7 +85,7 @@ func TestAPISidecarRefusalMatrix(t *testing.T) {
t.Fatal(err)
}
enableSidecars(t, fx, "sibling", titoSidecarManifest())
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -179,7 +179,7 @@ func TestAPISidecarEnableRefusalMatrix(t *testing.T) {
fx, member := instanceFixture(t, "code-side-enable-ref")
want := canonicalNotFound(t)
eve := cliToken(t, fx, "eve@keymail.dev", "code-side-enable-eve")
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
@@ -233,7 +233,7 @@ func TestAPISidecarNoToken(t *testing.T) {
func TestAPISidecarAppTokenContainment(t *testing.T) {
fx, _ := instanceFixture(t, "code-side-contain")
enableSidecars(t, fx, "titogo", titoSidecarManifest())
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index 252792a48..a259eaba7 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -126,6 +126,9 @@ 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
+ // Moves marks a move-capable instance token (spec §5): a kind='app' row
+ // an account owner minted with moves. False for every other row.
+ Moves bool
}
// RungList is Rungs for a person to read: "canary, edge".
@@ -222,6 +225,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 ''`,
+ // moves marks a move-capable instance token (self-serve moves, spec
+ // §5): only NewAppToken writes 1, at an owner's mint. Every row
+ // written before it reads 0, and nothing upgrades a row.
+ `ALTER TABLE cli_tokens ADD COLUMN moves INTEGER NOT NULL DEFAULT 0`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -503,15 +510,25 @@ VALUES (?, ?, ?, ?, ?, ?)`,
// credential whose rotation is one `carlos env set` + bounce away. Revocation
// (the minting member's tokens page, where it lists under their address) is
// the control.
-func (s *Store) claimRawNewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (string, error) {
+//
+// moves mints a move-capable token (self-serve moves, spec §5): the mint
+// door passes true only for an account owner, and every other caller passes
+// false. The token stays app-wide; #155's instance-bound tokens, when they
+// exist, are refused moves at the mint (a token bound to one instance moving
+// that instance is the member-minted shape M18 closed).
+func (s *Store) claimRawNewAppToken(accountID int64, app, mintedBy, label string, moves bool, now time.Time) (string, error) {
raw, err := randomToken()
if err != nil {
return "", err
}
+ movesCol := 0
+ if moves {
+ movesCol = 1
+ }
if _, err := s.db.Exec(`INSERT INTO cli_tokens
-(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app)
-VALUES (?, ?, ?, '', ?, 0, ?, ?, ?)`,
- hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app); err != nil {
+(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, moves)
+VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?)`,
+ hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app, movesCol); err != nil {
return "", err
}
return raw, nil
@@ -558,7 +575,7 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) {
// scanCLITokenRows drains a `cli_tokens` listing query into CLIToken values.
// CLITokens (by address) and ServicePrincipals (by account) select the same
-// fifteen columns in the same order and need the same legacy-row
+// sixteen columns in the same order and need the same legacy-row
// normalization — a kind='app' row's `role` column is empty in the database but
// IS the instance role (spec §5). This is the one place that normalization
// happens, so neither caller, nor any reader downstream of them, has to
@@ -568,11 +585,14 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) {
var out []CLIToken
for rows.Next() {
var t CLIToken
- var created, expires, used, revoked, rotated int64
+ var created, expires, used, revoked, rotated, moves 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, &moves); err != nil {
return nil, err
}
+ // Only a kind='app' row is ever minted move-capable; any other row
+ // lists as not, whatever its column says.
+ t.Moves = moves != 0 && t.Kind == cliKindApp
t.CreatedAt = time.Unix(created, 0).UTC()
t.ExpiresAt = time.Unix(expires, 0).UTC()
if used != 0 {
@@ -601,7 +621,7 @@ 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
+role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, moves
FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address)
if err != nil {
return nil, err
@@ -696,12 +716,12 @@ func (s *Store) claimRawRevokeCLITokenValue(hash string) error {
_, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hash)
return err
}
-func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (claimResult0 string, claimResult1 error) {
- return s.NewAppTokenContext(context.Background(), accountID, app, mintedBy, label, now)
+func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, moves bool, now time.Time) (claimResult0 string, claimResult1 error) {
+ return s.NewAppTokenContext(context.Background(), accountID, app, mintedBy, label, moves, now)
}
-func (s *Store) NewAppTokenContext(claimContext context.Context, accountID int64, app, mintedBy, label string, now time.Time) (claimResult0 string, claimResult1 error) {
+func (s *Store) NewAppTokenContext(claimContext context.Context, accountID int64, app, mintedBy, label string, moves bool, now time.Time) (claimResult0 string, claimResult1 error) {
claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewAppToken", func(claimView *Store) error {
- claimResult0, claimResult1 = claimView.claimRawNewAppToken(accountID, app, mintedBy, label, now)
+ claimResult0, claimResult1 = claimView.claimRawNewAppToken(accountID, app, mintedBy, label, moves, now)
return claimResult1
})
return
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go
index 68b083351..7cc17a92e 100644
--- a/internal/console/authstore_test.go
+++ b/internal/console/authstore_test.go
@@ -243,7 +243,7 @@ func TestAppTokenContainment(t *testing.T) {
s := openTestStore(t)
now := time.Now()
- app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -307,7 +307,7 @@ VALUES (?, 'paul@keymail.dev', 'dated', '', ?, ?, 'app', 9, 'tito')`,
func TestAppTokenNeverExpires(t *testing.T) {
s := openTestStore(t)
now := time.Now()
- app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -336,7 +336,7 @@ func TestAppTokenNeverExpires(t *testing.T) {
func TestAppTokenRevocation(t *testing.T) {
s := openTestStore(t)
now := time.Now()
- app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -373,7 +373,7 @@ func TestCLITokenMigrationReopens(t *testing.T) {
t.Fatal(err)
}
user := mintUserToken(t, first, "paul@keymail.dev", now)
- app, err := first.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ app, err := first.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now)
if err != nil {
t.Fatal(err)
}
@@ -406,7 +406,7 @@ func TestCLITokensReportsKind(t *testing.T) {
s := openTestStore(t)
now := time.Now()
mintUserToken(t, s, "paul@keymail.dev", now)
- if _, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now); err != nil {
+ if _, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now); err != nil {
t.Fatal(err)
}
toks, err := s.CLITokens("paul@keymail.dev")
diff --git a/internal/console/channelmove_test.go b/internal/console/channelmove_test.go
index f3165c061..1e3376d6d 100644
--- a/internal/console/channelmove_test.go
+++ b/internal/console/channelmove_test.go
@@ -253,7 +253,7 @@ func TestAPIInstanceChannelRefusesAppToken(t *testing.T) {
fx, _ := instanceFixture(t, "code-chan-apptok")
const host = "web.go.tito.io"
recordOn(t, fx, "titogo", host, "edge")
- appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 99e4abf25..02ea1539d 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -127,7 +127,7 @@ func claimLegacyAppTokenRevokeDoor(path string) *claimDoor {
return &claimDoor{
apps: claimRefs("bab/kass"),
setup: func(t *testing.T, cx *claimCase) {
- raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census legacy app token", time.Now())
+ raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census legacy app token", false, time.Now())
if err != nil {
t.Fatalf("stage legacy app token: %v", err)
}
diff --git a/internal/console/claim_invariant_probes_test.go b/internal/console/claim_invariant_probes_test.go
index 77914218c..0f8c5c1ca 100644
--- a/internal/console/claim_invariant_probes_test.go
+++ b/internal/console/claim_invariant_probes_test.go
@@ -687,7 +687,7 @@ func TestClaimProbeRunnerDetectsAHollowRequest(t *testing.T) {
t.Run("an app token at the logout door", func(t *testing.T) {
// A bearer the door does not admit is a 401, and a 401 never counts.
cx := newClaimCase(t)
- raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", time.Now())
+ raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/claim_invariant_test.go b/internal/console/claim_invariant_test.go
index 9a32ada29..3703ca8e7 100644
--- a/internal/console/claim_invariant_test.go
+++ b/internal/console/claim_invariant_test.go
@@ -1073,7 +1073,7 @@ func TestClaimConsoleMutationInvariantWitnesses(t *testing.T) {
// row that names an app is admitted for that app first.
t.Run("an app token revoked by its own value is admitted", func(t *testing.T) {
cx := newClaimCase(t)
- raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", time.Now())
+ raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/domaincatchall_test.go b/internal/console/domaincatchall_test.go
index b657a70a5..b2da334bc 100644
--- a/internal/console/domaincatchall_test.go
+++ b/internal/console/domaincatchall_test.go
@@ -145,7 +145,7 @@ func TestInstanceCatchallRefusals(t *testing.T) {
// A person's act: the app's own credential cannot set or remove one.
func TestInstanceCatchallIsAPersonsAct(t *testing.T) {
fx, member := catchallFixture(t, "code-ca-person")
- tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/domainplatform_test.go b/internal/console/domainplatform_test.go
index 9a31576cc..643dad7ff 100644
--- a/internal/console/domainplatform_test.go
+++ b/internal/console/domainplatform_test.go
@@ -113,7 +113,7 @@ func TestPlatformAttachRefusesMembers(t *testing.T) {
// minter's address).
func TestPlatformAttachRefusesServices(t *testing.T) {
fx, _, _ := platformFixture(t, "code-plat-svc")
- tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "signup", time.Now())
+ tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "signup", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/instancetoken_moves_test.go b/internal/console/instancetoken_moves_test.go
new file mode 100644
index 000000000..a956b1666
--- /dev/null
+++ b/internal/console/instancetoken_moves_test.go
@@ -0,0 +1,185 @@
+package console
+
+import (
+ "encoding/json"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The move-capable instance token (self-serve moves plan 2, Task 1; spec
+// §5): an app-wide instance token with moves=1, minted only by an account
+// owner through the existing person-only mint door.
+
+const msgMoveTokenNeedsOwner = "only an account owner can mint a move-capable instance token"
+
+// mintURL is shop's mint door on the fixture account.
+func mintURL(l lifetimesFx) string {
+ return l.fx.ts.URL + "/api/cli/apps/bab/" + ltApp + "/instance-token"
+}
+
+// mintedToken decodes a 201 mint answer.
+func mintedToken(t *testing.T, body string) string {
+ t.Helper()
+ var m struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal([]byte(body), &m); err != nil || m.Token == "" {
+ t.Fatalf("mint body %s: %v", body, err)
+ }
+ return m.Token
+}
+
+// appTokenListed is the one kind='app' row minted by addr for shop in the
+// minter's listing, and in the account's.
+func appTokenListed(t *testing.T, l lifetimesFx, addr string) (mine, account CLIToken) {
+ t.Helper()
+ toks, err := l.fx.st.CLITokens(addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, tk := range toks {
+ if tk.Kind == cliKindApp && tk.App == ltApp {
+ mine = tk
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatalf("%s lists %d app tokens for shop, want 1: %+v", addr, n, toks)
+ }
+ svcs, err := l.fx.st.ServicePrincipals(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tk := range svcs {
+ if tk.ID == mine.ID {
+ return mine, tk
+ }
+ }
+ t.Fatalf("the account's listing lacks the token %s: %+v", mine.ID, svcs)
+ return
+}
+
+// instanceTokenAudits are shop's instance-token audit details, newest first.
+func instanceTokenAudits(t *testing.T, l lifetimesFx) []Event {
+ t.Helper()
+ events, err := l.fx.st.Events(OpsAccountID, ltApp, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out []Event
+ for _, e := range events {
+ if e.Action == "instance-token" {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+func TestOwnerMintsAMoveCapableToken(t *testing.T) {
+ l := newLifetimesFx(t)
+ code, _, body := rawReq(t, http.MethodPost, mintURL(l), l.operator, `{"label":"router","moves":true}`)
+ if code != http.StatusCreated {
+ t.Fatalf("the owner's move-capable mint = %d %s, want 201", code, body)
+ }
+ raw := mintedToken(t, body)
+ mine, account := appTokenListed(t, l, "paul@keymail.dev")
+ if !mine.Moves || !account.Moves {
+ t.Fatalf("listings: minter's %+v, account's %+v, want both move-capable", mine, account)
+ }
+ if mine.ID != hashToken(raw) {
+ t.Fatalf("listing id %s is not the token's hash", mine.ID)
+ }
+ p, ok := l.fx.st.CLIPrincipal(raw, time.Now())
+ if !ok {
+ t.Fatal("the minted token does not resolve")
+ }
+ if !p.Moves || p.TokenID != mine.ID || p.Kind != PrincipalService || p.App != ltApp || p.AccountID != OpsAccountID {
+ t.Fatalf("principal = %+v, want a move-capable instance principal of bab/shop with TokenID %s", p, mine.ID)
+ }
+ audits := instanceTokenAudits(t, l)
+ want := "minted a move-capable instance token for shop (cli), token " + mine.ID
+ if len(audits) != 1 || audits[0].Detail != want || audits[0].Address != "paul@keymail.dev" {
+ t.Fatalf("audit = %+v, want one row by paul: %q", audits, want)
+ }
+ // The tokens page marks it.
+ if code, _, page := get(t, signInPaul(t, l.fx), l.fx.ts.URL+"/settings"); code != http.StatusOK || !strings.Contains(page, "(app token · shop · move-capable)") {
+ t.Fatalf("GET /settings = %d, want the token marked move-capable:\n%s", code, page)
+ }
+ // The column survives a reopen of the database (the migration is
+ // idempotent) and the token still reads as move-capable.
+ path := filepath.Join(t.TempDir(), "console.db")
+ for i := 0; i < 2; i++ {
+ st, err := Open(path)
+ if err != nil {
+ t.Fatalf("open %d: %v", i, err)
+ }
+ if i == 0 {
+ if _, err := st.NewAppToken(OpsAccountID, ltApp, "paul@keymail.dev", "router", true, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ toks, err := st.CLITokens("paul@keymail.dev")
+ if err != nil || len(toks) != 1 || !toks[0].Moves {
+ t.Fatalf("after a reopen: %+v %v, want one move-capable token", toks, err)
+ }
+ }
+ st.Close()
+ }
+}
+
+func TestMemberCannotMintAMoveCapableToken(t *testing.T) {
+ l := newLifetimesFx(t)
+ code, _, body := rawReq(t, http.MethodPost, mintURL(l), l.member, `{"label":"router","moves":true}`)
+ if code != http.StatusForbidden || cutoverErrorOf(body) != msgMoveTokenNeedsOwner {
+ t.Fatalf("a member's move-capable mint = %d %s, want 403 %q", code, body, msgMoveTokenNeedsOwner)
+ }
+ toks, err := l.fx.st.CLITokens("alice@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tk := range toks {
+ if tk.Kind == cliKindApp {
+ t.Fatalf("the refused mint left a token: %+v", tk)
+ }
+ }
+ if a := instanceTokenAudits(t, l); len(a) != 0 {
+ t.Fatalf("the refused mint was audited: %+v", a)
+ }
+}
+
+func TestPlainMintIsUnchanged(t *testing.T) {
+ l := newLifetimesFx(t)
+ for _, c := range []struct{ who, token, addr, body string }{
+ {"owner, no moves field", l.operator, "paul@keymail.dev", `{"label":"router"}`},
+ {"member, moves false", l.member, "alice@keymail.dev", `{"label":"router","moves":false}`},
+ } {
+ t.Run(c.who, func(t *testing.T) {
+ code, _, body := rawReq(t, http.MethodPost, mintURL(l), c.token, c.body)
+ if code != http.StatusCreated {
+ t.Fatalf("plain mint = %d %s, want 201", code, body)
+ }
+ raw := mintedToken(t, body)
+ mine, account := appTokenListed(t, l, c.addr)
+ if mine.Moves || account.Moves {
+ t.Fatalf("a plain token lists as move-capable: %+v %+v", mine, account)
+ }
+ p, ok := l.fx.st.CLIPrincipal(raw, time.Now())
+ if !ok || p.Moves || p.TokenID != mine.ID {
+ t.Fatalf("principal = %+v %v, want not move-capable, TokenID %s", p, ok, mine.ID)
+ }
+ if code, _, page := get(t, firstOf(signInAs(t, l.fx, c.addr, "code-page-"+c.addr)), l.fx.ts.URL+"/settings"); code != http.StatusOK || strings.Contains(page, "move-capable") {
+ t.Fatalf("GET /settings = %d, want no token marked move-capable:\n%s", code, page)
+ }
+ audits := instanceTokenAudits(t, l)
+ if len(audits) == 0 || audits[0].Detail != "minted an instance token for shop (cli)" || audits[0].Address != c.addr {
+ t.Fatalf("audit = %+v, want main's line by %s", audits, c.addr)
+ }
+ })
+ }
+}
+
+func firstOf(c *http.Client, _ string) *http.Client { return c }
diff --git a/internal/console/svcpage_test.go b/internal/console/svcpage_test.go
index 407d6b093..6b1a13b62 100644
--- a/internal/console/svcpage_test.go
+++ b/internal/console/svcpage_test.go
@@ -230,7 +230,7 @@ func TestRevokeSaysWhenNothingMatched(t *testing.T) {
// kill it from there.
func TestPaneRevokesALegacyAppTokenByItsListingID(t *testing.T) {
fx := newConsoleFixture(t)
- tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now())
+ tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", false, time.Now())
if err != nil {
t.Fatal(err)
}
@@ -268,7 +268,7 @@ func TestThePageNeverRendersASecret(t *testing.T) {
fx := newConsoleFixture(t)
a := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
b := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate)
- legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now())
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", false, time.Now())
if err != nil {
t.Fatal(err)
}
@@ -490,7 +490,7 @@ func TestPersonalSettingsMirrorsServiceCredentialsReadOnly(t *testing.T) {
// revocable from this page, and its minter may not be an account owner.
func TestPersonalSettingsStillRevokesALegacyAppToken(t *testing.T) {
fx := newConsoleFixture(t)
- if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()); err != nil {
+ if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", false, time.Now()); err != nil {
t.Fatal(err)
}
c := signInPaul(t, fx)
@@ -567,7 +567,7 @@ func TestPersonalSettingsRefusesAnAppTokenRevokeAfterRemoval(t *testing.T) {
if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleMember, time.Now()); err != nil {
t.Fatal(err)
}
- secret, err := fx.st.NewAppToken(otherID, "kass", "paul@keymail.dev", "router", time.Now())
+ secret, err := fx.st.NewAppToken(otherID, "kass", "paul@keymail.dev", "router", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go
index cdd8940c9..61c8271ce 100644
--- a/internal/console/svcprincipal.go
+++ b/internal/console/svcprincipal.go
@@ -57,6 +57,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
+ // Moves marks a move-capable instance token (self-serve moves, spec §5):
+ // a legacy kind='app' row an account owner minted with moves. False for
+ // a person and for every other service row.
+ Moves bool
+ // TokenID is the credential's stored token_hash: the id its listings
+ // show and its audit lines name.
+ TokenID string
}
// ErrServiceNameTaken is returned by NewServicePrincipal when the account
@@ -81,10 +88,10 @@ func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) {
}
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 accountID, expires, revoked, moves int64
+ err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs, moves
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, &moves)
if err != nil {
return Principal{}, false
}
@@ -126,7 +133,9 @@ FROM cli_tokens WHERE token_hash = ?`, hash).
// The migration guarantee: identical authority to what
// apiInstancePrincipal has always granted this row, expressed in the
// new vocabulary.
- p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance}
+ // Moves is read from this kind alone: it is the only kind the mint
+ // door ever marks.
+ p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance, Moves: moves != 0}
case cliKindService:
r, ok := svcrole.Parse(role)
if !ok {
@@ -149,6 +158,7 @@ FROM cli_tokens WHERE token_hash = ?`, hash).
default:
return Principal{}, false
}
+ p.TokenID = storedHash
// Best-effort: "last used" is for the owner's benefit on the tokens page,
// and a failed write must not fail the request it is annotating.
s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash)
@@ -279,7 +289,7 @@ 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
+role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, moves
FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?) AND revoked_at = 0
ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp)
if err != nil {
diff --git a/internal/console/svcprincipal_test.go b/internal/console/svcprincipal_test.go
index 2ab0ebc13..fe5be3305 100644
--- a/internal/console/svcprincipal_test.go
+++ b/internal/console/svcprincipal_test.go
@@ -26,7 +26,7 @@ func TestCLIPrincipalLegacyAppTokenIsInstanceRole(t *testing.T) {
s := openTestStore(t)
now := time.Now()
acct := mustAccount(t, s, "legacy")
- raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
+ raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now)
if err != nil {
t.Fatalf("NewAppToken: %v", err)
}
@@ -206,7 +206,7 @@ func TestServicePrincipalsListsBothKinds(t *testing.T) {
if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
t.Fatalf("NewServicePrincipal: %v", err)
}
- if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now); err != nil {
+ if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now); err != nil {
t.Fatalf("NewAppToken: %v", err)
}
toks, err := s.ServicePrincipals(acct.ID)
@@ -244,7 +244,7 @@ func TestRotateAndRevokeIgnoreLegacyNamelessRows(t *testing.T) {
s := openTestStore(t)
now := time.Now()
acct := mustAccount(t, s, "amadan")
- app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
+ app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now)
if err != nil {
t.Fatalf("NewAppToken: %v", err)
}
@@ -268,7 +268,7 @@ func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) {
now := time.Now()
acct := mustAccount(t, s, "amadan")
other := mustAccount(t, s, "other")
- app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
+ app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now)
if err != nil {
t.Fatalf("NewAppToken: %v", err)
}
@@ -616,7 +616,7 @@ func TestRevokeAccountTokenSoftRevokesNotDeletes(t *testing.T) {
s := openTestStore(t)
now := time.Now()
acct := mustAccount(t, s, "amadan")
- app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
+ app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now)
if err != nil {
t.Fatalf("NewAppToken: %v", err)
}
diff --git a/internal/console/svcscope_test.go b/internal/console/svcscope_test.go
index 03c9493d7..b1abe32cd 100644
--- a/internal/console/svcscope_test.go
+++ b/internal/console/svcscope_test.go
@@ -194,7 +194,7 @@ func TestAppsListForAServiceIsItsOwnScope(t *testing.T) {
}
// A legacy kind='app' credential is a service principal too, and reads
// the same way here — its scope is its one app.
- legacy, err := fx.st.NewAppToken(OpsAccountID, "two", serviceMinter, "router", now)
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "two", serviceMinter, "router", false, now)
if err != nil {
t.Fatal(err)
}
@@ -270,7 +270,7 @@ func TestServiceAuditActorIsNotABareAddress(t *testing.T) {
// move — it is the exact failure mode the collapse could have introduced.
func TestLegacyBindingWithNoAppIsNotAWildcard(t *testing.T) {
fx := newConsoleFixture(t)
- raw, err := fx.st.NewAppToken(OpsAccountID, "", serviceMinter, "bound to nothing", time.Now())
+ raw, err := fx.st.NewAppToken(OpsAccountID, "", serviceMinter, "bound to nothing", false, time.Now())
if err != nil {
t.Fatal(err)
}
@@ -313,7 +313,7 @@ func TestEnableStaysAHumanAct(t *testing.T) {
fx, _ := instanceFixture(t, "code-scope-enable-human")
fx.srv.cfg.AppsDomain = "oncarlos.test"
named := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance)
- legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", serviceMinter, "router", time.Now())
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", serviceMinter, "router", false, time.Now())
if err != nil {
t.Fatal(err)
}
@@ -450,7 +450,7 @@ func TestAppScopedServiceCannotClaimANewApp(t *testing.T) {
// walls are not confused for one — if the role table ever put `instance`
// on this door, THIS is the assertion that would move, and the guard
// above is what would then be doing the work.
- legacy, err := fx.st.NewAppToken(OpsAccountID, "one", serviceMinter, "router", time.Now())
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "one", serviceMinter, "router", false, time.Now())
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/templates/settings.html b/internal/console/templates/settings.html
index 396ec8d20..24750a5e7 100644
--- a/internal/console/templates/settings.html
+++ b/internal/console/templates/settings.html
@@ -14,7 +14,7 @@
<tr><th>Terminal</th><th>From</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr>
{{range .Tokens}}
<tr>
- <td>{{if .IsService}}{{.Name}} <span class="small">(service credential{{if .App}} · {{.App}}{{end}})</span>{{else}}{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}})</span>{{end}}{{end}}</td>
+ <td>{{if .IsService}}{{.Name}} <span class="small">(service credential{{if .App}} · {{.App}}{{end}})</span>{{else}}{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}}{{if .Moves}} · move-capable{{end}})</span>{{end}}{{end}}</td>
<td>{{emptyOrDash .RemoteAddr}}</td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td>