fix(console): tokens page renders app tokens honestly
An app token's expires_at is 0 ("never", spec §8), and the tokens page
rendered that through fmtTime — so the one credential in the listing that
outlives every other showed up as expiring on 1 Jan 1970. A row that reads
as long dead is one nobody thinks to rotate, and one whose Revoke button
gets pressed by somebody who believes it does nothing.
CLIToken now carries Kind/AccountID/App (the columns were already there
since the app-kind migration; only the SELECT was blind to them), with
NeverExpires and IsApp so the "0 means never" convention lives in one
place rather than in a template comparing times to the epoch. The row is
labelled with what it is and the app it is bound to.
Also records the rollout note this credential needs in spec §8: a console
rolled back past the purge guard re-arms `expires_at < now` and deletes
every app token in the deployment within 60s, so a rollback past it is a
credential-rotation event, and app tokens should not be minted until the
guarded console is adopted (and restarted) fleet-wide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+129
−7
docs/superpowers/specs/2026-08-04-instance-provisioning-design.md+17 −0internal/console/authcli_test.go+38 −0internal/console/authstore.go+26 −2internal/console/authstore_test.go+41 −0internal/console/templates/cli_tokens.html+7 −5
diff --git a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md| index 8a48b3221..5d42ef332 100644 |
| --- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| @@ -259,6 +259,23 @@ Additive `cli_tokens` columns: `kind` (`''` = user token, `'app'`), |
| create/list/delete instance records of that one app. No key material, no |
| releases, no other app, no AWS. |
| +**Rollout note — the purge guard is a one-way door.** `expires_at = 0` |
| +("never") is only safe because `PurgeCLIAuth`'s sweep excludes it; a console |
| +binary from *before* that guard runs the unguarded |
| +`DELETE FROM cli_tokens WHERE expires_at < now`, and 0 is less than every |
| +now — so it deletes **every app token in the deployment** within |
| +`cliPurgeEvery` (60s) of its first write request. Consequences, both |
| +operational: |
| + |
| +- Do not mint app tokens until the guarded console is adopted **fleet-wide** |
| + (adoption swaps a symlink and does not restart the console — promote → |
| + adopt → **restart** is the order that actually puts the new binary in |
| + front of traffic). |
| +- A rollback past that console is a **credential-rotation event**, not a |
| + revert: every app token is gone, every signup path holding one starts |
| + failing, and recovery is re-minting and re-delivering each one via |
| + `carlos env set` + a bounce. Roll forward instead where there is a choice. |
| + |
| ## 9. `carlos add` and the shared core |
| The honest shared core is the **route conventions**: socket and DB path |
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go| index b7be257e6..c7df17447 100644 |
| --- a/internal/console/authcli_test.go |
| +++ b/internal/console/authcli_test.go |
| @@ -485,3 +485,41 @@ func cliToken(t *testing.T, fx *consoleFixture, address, oauthCode string) strin |
| } |
| return tok |
| } |
| + |
| +// TestCLITokensPageRendersAppTokenHonestly: an app token never expires |
| +// (expires_at 0), and the page must SAY so rather than rendering the Unix |
| +// epoch — "expired in 1970" is a row a reader dismisses instead of |
| +// rotating. The row is also labelled with what it is and what it is bound |
| +// to, because "Revoke" on an unlabelled row is a decision made blind. |
| +func TestCLITokensPageRendersAppTokenHonestly(t *testing.T) { |
| + now := time.Now() |
| + p := cliPage{page: page{Title: "Command-line access", Address: "paul@keymail.dev"}, |
| + Console: "https://console.example", Days: 90, |
| + Tokens: []CLIToken{ |
| + {ID: "aaa", Address: "paul@keymail.dev", Label: "paul@laptop", |
| + RemoteAddr: "192.0.2.7", CreatedAt: now, ExpiresAt: now.Add(CLITokenTTL)}, |
| + {ID: "bbb", Address: "paul@keymail.dev", Label: "tito signup", |
| + Kind: cliKindApp, AccountID: 7, App: "tito", CreatedAt: now}, |
| + }} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "cli_tokens", p); err != nil { |
| + t.Fatalf("render cli_tokens: %v", err) |
| + } |
| + html := buf.String() |
| + if strings.Contains(html, "1970") { |
| + t.Errorf("the app token's expiry rendered as the Unix epoch:\n%s", html) |
| + } |
| + if !strings.Contains(html, "Never") { |
| + t.Errorf("the app token's expiry does not say Never:\n%s", html) |
| + } |
| + if !strings.Contains(html, "tito") { |
| + t.Errorf("the app token row does not name the app it is bound to:\n%s", html) |
| + } |
| + if !strings.Contains(strings.ToLower(html), "app token") { |
| + t.Errorf("the app token row is not labelled as one:\n%s", html) |
| + } |
| + // The user token's own expiry is untouched by the branch. |
| + if !strings.Contains(html, now.Add(CLITokenTTL).UTC().Format("2006-01-02")) { |
| + t.Errorf("the user token's expiry no longer renders:\n%s", html) |
| + } |
| +} |
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 4befa976c..dde723d60 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -88,16 +88,38 @@ type CLIAuthRequest struct { |
| // CLIToken is one issued credential as its owner sees it on the console. ID |
| // is the token's sha256, which is safe to put in a page and in a form: it |
| // names the row without being the bearer secret. |
| +// |
| +// Kind/AccountID/App carry what an app token IS, because a listing that |
| +// showed only Label and ExpiresAt would render a never-expiring app token |
| +// (expires_at 0) as expiring on 1 Jan 1970 — a credential that reads as |
| +// long dead is one nobody thinks to rotate, and one whose "Revoke" button |
| +// gets pressed by somebody who thinks it does nothing. |
| type CLIToken struct { |
| ID string |
| Address string |
| Label string |
| RemoteAddr string |
| + Kind string // "" = a person's token, "app" = app-held (see NewAppToken) |
| + AccountID int64 // app tokens only: the account it is bound to |
| + App string // app tokens only: the app it is bound to |
| CreatedAt time.Time |
| ExpiresAt time.Time |
| LastUsed time.Time |
| } |
| +// NeverExpires reports whether this credential has no expiry — true only |
| +// for app tokens, whose expires_at is 0 by design (spec §8). The 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.Kind == cliKindApp && (t.ExpiresAt.IsZero() || t.ExpiresAt.Unix() == 0) |
| +} |
| + |
| +// IsApp reports whether this row is an app-held token, for the page's |
| +// label. A template cannot see cliKindApp, so it asks here instead of |
| +// comparing against a string literal of its own. |
| +func (t CLIToken) IsApp() bool { return t.Kind == cliKindApp } |
| + |
| // migrateCLIAuth creates the CLI-identity tables. Additive and idempotent, |
| // called from Store.migrate. |
| func (s *Store) migrateCLIAuth() error { |
| @@ -486,7 +508,8 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| // listing the tokens page renders so an owner can see, and kill, every |
| // terminal that speaks as them. |
| func (s *Store) CLITokens(address string) ([]CLIToken, error) { |
| - rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, created_at, expires_at, last_used |
| + rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| +created_at, expires_at, last_used |
| FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address) |
| if err != nil { |
| return nil, err |
| @@ -496,7 +519,8 @@ FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address |
| for rows.Next() { |
| var t CLIToken |
| var created, expires, used int64 |
| - if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &created, &expires, &used); err != nil { |
| + if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App, |
| + &created, &expires, &used); err != nil { |
| return nil, err |
| } |
| t.CreatedAt = time.Unix(created, 0).UTC() |
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go| index f068fc154..91ba3a077 100644 |
| --- a/internal/console/authstore_test.go |
| +++ b/internal/console/authstore_test.go |
| @@ -384,3 +384,44 @@ func TestCLITokenMigrationReopens(t *testing.T) { |
| t.Fatal("after reopen an app token authenticated at a user door") |
| } |
| } |
| + |
| +// TestCLITokensReportsKind: the listing an owner acts on must say WHAT each |
| +// row is. An app token has no expiry (expires_at 0), so a page that reads |
| +// only ExpiresAt renders it as 1 Jan 1970 — a credential that reads as |
| +// long-expired is one nobody thinks to rotate. Kind and App are what make |
| +// the row honest. |
| +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 { |
| + t.Fatal(err) |
| + } |
| + toks, err := s.CLITokens("paul@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(toks) != 2 { |
| + t.Fatalf("CLITokens = %d rows, want the user token and the app token", len(toks)) |
| + } |
| + var user, app *CLIToken |
| + for i := range toks { |
| + if toks[i].Kind == cliKindApp { |
| + app = &toks[i] |
| + } else { |
| + user = &toks[i] |
| + } |
| + } |
| + if user == nil || app == nil { |
| + t.Fatalf("CLITokens = %+v, want one row of each kind", toks) |
| + } |
| + if app.App != "tito" || app.AccountID != 7 { |
| + t.Errorf("app token row = %+v, want it bound to account 7 / tito", app) |
| + } |
| + if !app.NeverExpires() { |
| + t.Errorf("app token row = %+v, want NeverExpires (expires_at 0 is \"never\")", app) |
| + } |
| + if user.NeverExpires() { |
| + t.Errorf("user token row = %+v, want a real expiry", user) |
| + } |
| +} |
diff --git a/internal/console/templates/cli_tokens.html b/internal/console/templates/cli_tokens.html| index 3dfd680e5..ef81bca12 100644 |
| --- a/internal/console/templates/cli_tokens.html |
| +++ b/internal/console/templates/cli_tokens.html |
| @@ -9,11 +9,11 @@ |
| <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>{{emptyOrDash .Label}}</td> |
| + <td>{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}})</span>{{end}}</td> |
| <td>{{emptyOrDash .RemoteAddr}}</td> |
| <td>{{fmtTime .CreatedAt}}</td> |
| <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td> |
| - <td>{{fmtTime .ExpiresAt}}</td> |
| + <td>{{if .NeverExpires}}Never{{else}}{{fmtTime .ExpiresAt}}{{end}}</td> |
| <td> |
| <form method="post" action="/auth/cli/tokens/revoke"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| @@ -26,9 +26,11 @@ |
| </table> |
| </div> |
| <p class="small">Revoking is immediate: that terminal's next command fails and |
| -it has to log in again. Tokens expire on their own after {{.Days}} days. The |
| -name and the address are what the machine asking told us — if a row here is |
| -not a terminal you recognise, revoke it.</p> |
| +it has to log in again. Terminal tokens expire on their own after {{.Days}} |
| +days; an app token never expires — it is a credential you minted for an app, |
| +and revoking it here is how it is rotated. The name and the address are what |
| +the machine asking told us — if a row here is not a terminal you recognise, |
| +revoke it.</p> |
| {{else}} |
| <p>No terminals are signed in as you.</p> |
| <pre><code>carlos auth login -console {{.Console}}</code></pre> |