carlos / platform Public

console: mint an instance token; delete revokes it; credential surfaces name the host (#155)

POST …/instances/{host}/token, people only, binds the token to the
record's CreatedAt. Delete revokes the host's tokens after the record is
gone, best effort (spec §3.4 rev 4 amended: revoke-first refused a
claim-mode delete retry; the incarnation check is the guarantee).
Instance rows take the app-token branch of the personal revoke, so a
former member cannot revoke them. The services API, the account pane,
the personal settings page and the activity feed name the host. The
PATCH Self refusal moves into instancetoken.go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e26f8e27f1b11e0df6889ed953a9243cd4da477d parent b780faf
13 files changed, +354 −17
  • docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md +7 −5
  • internal/console/apiinstances.go +3 −6
  • internal/console/apiservices.go +7 −0
  • internal/console/authcli.go +10 −2
  • internal/console/claim_invariant_fixtures_test.go +10 −0
  • internal/console/instancetoken.go +108 −0
  • internal/console/instancetoken_mint_test.go +194 −0
  • internal/console/instancetoken_words.go +4 −0
  • internal/console/server.go +4 −0
  • internal/console/svcroutes_test.go +4 −2
  • internal/console/templates/account_settings.html +1 −1
  • internal/console/templates/settings.html +1 −1
  • internal/console/testdata/claim-routes.txt +1 −0
diff --git a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md
index c2ab4cc6e..1931abedd 100644
--- a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md
+++ b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md
@@ -137,10 +137,12 @@ A token is bound to one incarnation of its host:
`provision.CreateRecord` and of `hostops.PrepareInstance`; a new caller
fails the test until it is reviewed against this rule and added.
- `handleAPIInstanceDelete` revokes every live `kind='instance'` row for
- (account, app, host) **before** deleting the record. If the revoke
- fails, the delete answers 500 and deletes nothing. A delete that fails
- after the revoke leaves a live instance with no token, which a member
- fixes by minting again.
+ (account, app, host) **after** the record is gone, best effort: a failed
+ revoke is logged and the delete still answers 200. (Rev 4 said "before,
+ and a failed revoke deletes nothing". Built that way, it refused a claim-mode
+ delete retry whose app is under the delete's own exclusive operation,
+ `TestClaimDeleteRetryWhileParkingIsNotDone`. The incarnation check above is
+ the guarantee, so the revoke cannot be a precondition.)
- Purge (`claim_app_identity.go:182`, `store.go:1736`) deletes instance
rows with the app's other credentials: its `kind IN (…)` list gains
`cliKindInstance`. Trash does not touch them, as it does not touch app
@@ -408,7 +410,7 @@ docs page section are copy review.
later rename or move by the new console is not refused.
- Every test that writes `cli_tokens` runs against `OpenGated` with
`HostClaim` on as well as the plain store.
-- Delete revokes; a revoke failure aborts the delete.
+- Delete revokes; a revoke failure still deletes, and the surviving token answers 404 once the host is created again.
- Purge, rename and account-move delete or carry instance rows the way
they do app rows.
- Audit keys are per host and never collapse onto the `app:` key.
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 80649af71..b3b780068 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1969,12 +1969,7 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if !readJSON(w, r, &req) {
return
}
- if p.Instance != "" && (req.Environment != nil || req.Steering != nil || req.Gate != nil) {
- // An instance's own credential may repoint its upstreams and probe,
- // nothing else (spec 2026-10-16-instance-bound-tokens §4.4.1):
- // environment alone would bind it to a sibling's bundle and its
- // secrets. Refused before any read or write.
- jsonError(w, http.StatusForbidden, selfPatchFieldsRefused)
+ if refuseSelfPatchFields(w, p, req.Environment != nil || req.Steering != nil || req.Gate != nil) {
return
}
if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil {
@@ -2225,6 +2220,7 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
}
if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered {
if finished {
+ s.revokeDeletedInstanceTokens(ctx, acct, app, host)
s.auditActivity(acct, app, principal, Activity{
Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
Detail: "deleted instance " + host + " (cli)",
@@ -2251,6 +2247,7 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadGateway, "could not delete the instance — try again")
return
}
+ s.revokeDeletedInstanceTokens(ctx, acct, app, host)
// DNS seam (spec §10): a multi-region deployment retires the instance's
// CNAME here, after the record is gone.
//
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index 7f23b4a6a..9ff2860e9 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -63,6 +63,9 @@ type serviceView struct {
// rendered rather than omitted so "bound to nothing" and "bound to an
// app whose name did not survive the wire" cannot be confused.
App string `json:"app"`
+ // Host is an instance token's instance (#155); omitted for every other
+ // row.
+ Host string `json:"host,omitempty"`
// Rungs is a deploy credential's ladder bound, canonical order. An
// empty array, never null, for every other row.
Rungs []string `json:"rungs"`
@@ -314,6 +317,7 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) {
Label: t.Label,
Role: t.Role,
App: t.App,
+ Host: t.Host,
Rungs: rungs,
Created: serviceStamp(t.CreatedAt),
Rotated: serviceStamp(t.RotatedAt),
@@ -449,6 +453,9 @@ func serviceWhat(h serviceHandle, row CLIToken, found bool) (subject, what strin
if row.Name != "" {
return row.Name, "service credential " + row.Name
}
+ if row.IsInstance() {
+ return row.App, fmt.Sprintf(instanceTokenRevokeWhat, row.Host, row.App)
+ }
what = "app credential for " + row.App
if row.Label != "" {
what = "app credential " + row.Label + " for " + row.App
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index cbb002ef5..4803c5631 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -761,6 +761,10 @@ func (s *Server) registerCLIRoutes() {
// credential gets here from 401 to 403. Pinned by
// TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor.
s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken)
+ // The instance-bound mint (#155, spec 2026-10-16-instance-bound-tokens
+ // §5): the zero Set for the app-token mint's reason, a credential never
+ // mints a credential.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/token", svcrole.Set{}, s.handleAPIInstanceTokenMint)
// Door keys (#175 P7): person-only, the instance-token rule. A door key
// reaches every instance of the app through every box's edge-local door.
s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList)
@@ -1506,7 +1510,7 @@ func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) {
func (s *Server) serviceAccountSqids(toks []CLIToken) map[int64]string {
out := map[int64]string{}
for _, t := range toks {
- if !t.IsService() || t.AccountID == 0 {
+ if !(t.IsService() || t.IsInstance()) || t.AccountID == 0 {
continue
}
if _, done := out[t.AccountID]; done {
@@ -1636,7 +1640,11 @@ func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) {
"recorded in that account's activity. Nothing was revoked.")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
- case svc.IsApp():
+ case svc.IsApp() || svc.IsInstance():
+ // An instance token takes this branch too (spec
+ // 2026-10-16-instance-bound-tokens §3.5): current membership of its
+ // account, then the same admitted delete.
+ //
// Fails closed on a read error, the same direction as the classify
// above, and is refused before the passkey for the same reason.
role, rerr := s.store.Role(svc.AccountID, addr)
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 215960ec9..8ba604b05 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1289,6 +1289,16 @@ var claimFixtures = map[string]claimFixture{
},
do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/channel", `{"channel":"beta"}`),
}},
+ // The instance-bound mint (#155): its insert is admitted for the app, as
+ // the app-token mint's is.
+ "route POST /api/cli/apps/{account}/{app}/instances/{host}/token": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) {
+ cx.instanceWorld(t)
+ cx.instance(t, `{"host":"web.go.tito.io","region":"ie"}`)
+ },
+ do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/token", `{}`),
+ }},
// Seeded instances wait for the claim's protocol (#250, seeded-instances
// spec §3.4): every import door refuses on a host-claim console before
// it reads the record (claimUnsupported).
diff --git a/internal/console/instancetoken.go b/internal/console/instancetoken.go
index 3b4e75122..863c20720 100644
--- a/internal/console/instancetoken.go
+++ b/internal/console/instancetoken.go
@@ -4,7 +4,12 @@ import (
"context"
"errors"
"fmt"
+ "log"
+ "net/http"
+ "os"
"time"
+
+ "github.com/carlosframework/platform/internal/provision"
)
// Instance-bound tokens: the minter, the revoker and the listing rule (spec
@@ -67,3 +72,106 @@ WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND revoked_at = 0`,
now.Unix(), cliKindInstance, accountID, app, host)
return err
}
+
+// InstanceScope is an instance token's scope as the account pane shows it.
+// A method because a template cannot format a Go const.
+func (t CLIToken) InstanceScope() string { return fmt.Sprintf(instanceTokenScope, t.App, t.Host) }
+
+// InstanceBadge is an instance token's note beside its label on a person's
+// own settings page, as "app token" is for an app token.
+func (t CLIToken) InstanceBadge() string { return fmt.Sprintf(instanceTokenBadge, t.Host) }
+
+// handleAPIInstanceTokenMint is POST …/instances/{host}/token: it mints a
+// credential bound to one incarnation of one instance (spec §5). People
+// only, for the app-token mint's reason: a credential never mints a
+// credential. The zero Set at cliRoute is the outer wall, apiPerson the
+// inner one, exactly as at handleAPIInstanceToken.
+func (s *Server) handleAPIInstanceTokenMint(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable, mainGateCopy().noBucket)
+ return
+ }
+ host := r.PathValue("host")
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var req struct {
+ Label string `json:"label"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the instance")
+ return
+ }
+ // The incarnation is the record's CreatedAt (spec §3.4); a record
+ // without one has nothing to bind to.
+ if rec.CreatedAt.IsZero() {
+ jsonError(w, http.StatusConflict, fmt.Sprintf(instanceTokenNoBirth, host))
+ return
+ }
+ raw, err := s.store.NewInstanceTokenContext(r.Context(), acct.ID, app, host, rec.CreatedAt.UnixNano(), addr, cleanLabel(req.Label), time.Now())
+ if err != nil {
+ log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusInternalServerError, "could not mint the token — try again")
+ return
+ }
+ s.audit(acct, app, addr, "instance-token", "minted an instance token for "+host+" (cli)")
+ // Shown exactly once: only the hash is stored.
+ writeJSON(w, http.StatusCreated, map[string]any{"token": raw, "account": acct.Sqid, "app": app, "host": host})
+}
+
+// revokeInstanceTokensFor is the delete door's revoke, through the test seam
+// when one is set.
+func (s *Server) revokeInstanceTokensFor(ctx context.Context, accountID int64, app, host string, now time.Time) error {
+ if s.revokeInstanceTokens != nil {
+ return s.revokeInstanceTokens(ctx, accountID, app, host, now)
+ }
+ return s.store.RevokeInstanceTokensContext(ctx, accountID, app, host, now)
+}
+
+// revokeDeletedInstanceTokens revokes host's instance tokens once its record
+// is gone (spec §3.4). Best effort, and after the delete rather than before
+// it: the incarnation check is the guarantee, so a token this misses can
+// only answer 404, while a revoke made a precondition would refuse a delete
+// retry whose app is under its own exclusive operation (claim mode).
+func (s *Server) revokeDeletedInstanceTokens(ctx context.Context, acct *Account, app, host string) {
+ if err := s.revokeInstanceTokensFor(ctx, acct.ID, app, host, time.Now()); err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: revoke instance tokens (they answer 404 regardless): %v", acct.Sqid, app, host, err)
+ }
+}
+
+// refuseSelfPatchFields is the PATCH door's Self rule (spec §4.4.1): an
+// instance's own credential may repoint its upstreams and probe, nothing
+// else, because environment alone would bind it to a sibling's bundle and
+// its secrets. It answers before any read or write. others reports whether
+// the request names environment, steering or gate.
+func refuseSelfPatchFields(w http.ResponseWriter, p Principal, others bool) bool {
+ if p.Instance == "" || !others {
+ return false
+ }
+ jsonError(w, http.StatusForbidden, selfPatchFieldsRefused)
+ return true
+}
diff --git a/internal/console/instancetoken_mint_test.go b/internal/console/instancetoken_mint_test.go
new file mode 100644
index 000000000..0b47c7981
--- /dev/null
+++ b/internal/console/instancetoken_mint_test.go
@@ -0,0 +1,194 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// The mint door, the revoke on delete and the credential surfaces (spec
+// 2026-10-16-instance-bound-tokens §3.4, §3.5, §5).
+
+func mintURL(w *selfWorld, host string) string {
+ return instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + host + "/token"
+}
+
+func TestInstanceTokenMint(t *testing.T) {
+ w := newSelfWorld(t)
+ st, _, body := rawReq(t, http.MethodPost, mintURL(w, selfHostB), w.member, `{"label":"b's own"}`)
+ if st != http.StatusCreated {
+ t.Fatalf("mint = %d %s", st, body)
+ }
+ var out struct{ Token, Account, App, Host string }
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Account != w.fx.sqid || out.App != "titogo" || out.Host != selfHostB || out.Token == "" {
+ t.Fatalf("mint answer = %+v", out)
+ }
+ p, ok := w.fx.st.CLIPrincipal(out.Token, time.Now())
+ if !ok || p.Role != svcrole.Self || p.Instance != selfHostB || p.InstanceBorn != w.b.CreatedAt.UnixNano() {
+ t.Fatalf("minted principal = %+v, %v (record born %d)", p, ok, w.b.CreatedAt.UnixNano())
+ }
+ var n int
+ if err := w.fx.st.db.QueryRow(`SELECT COUNT(*) FROM audit WHERE action = 'instance-token' AND detail LIKE ?`,
+ "%"+selfHostB+"%").Scan(&n); err != nil || n != 1 {
+ t.Fatalf("instance-token audit rows naming the host = %d, %v", n, err)
+ }
+ // The token works on its own record and nowhere else.
+ if st, _, body := rawReq(t, http.MethodGet, instancesURL(w.fx, w.fx.sqid, "titogo")+"/"+selfHostB, out.Token, ""); st != http.StatusOK {
+ t.Fatalf("minted token on its own record = %d %s", st, body)
+ }
+}
+
+func TestInstanceTokenMintRefusals(t *testing.T) {
+ w := newSelfWorld(t)
+ want := canonicalNotFound(t)
+ st, h, body := rawReq(t, http.MethodPost, mintURL(w, "c.example.com"), w.member, `{}`)
+ assertNotFound(t, "mint for a host with no record", want, st, h, body)
+
+ // A record with no creation time cannot be bound to.
+ bare := w.a
+ bare.Host, bare.Slug, bare.CreatedAt, bare.RecordSeq = "bare.example.com", "bare", time.Time{}, 0
+ raw, err := json.Marshal(bare)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := provision.WriteRecord(context.Background(), w.fx.rel,
+ provision.RecordKey(release.OpsAccount, "titogo", bare.Host), []byte(`{}`), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if st, _, body := rawReq(t, http.MethodPost, mintURL(w, bare.Host), w.member, `{}`); st != http.StatusConflict ||
+ !strings.Contains(body, strings.SplitN(instanceTokenNoBirth, "%s", 2)[1][:20]) {
+ t.Fatalf("mint for a record with no creation time = %d %s", st, body)
+ }
+
+ // No service credential mints one, whatever its role, its own included.
+ for _, role := range []svcrole.Role{svcrole.Admin, svcrole.Instance, svcrole.Publish} {
+ tok := serviceToken(t, w.fx.st, OpsAccountID, "m-"+string(role), "titogo", role)
+ if st, _, body := rawReq(t, http.MethodPost, mintURL(w, selfHostA), tok, `{}`); st != http.StatusForbidden {
+ t.Errorf("mint as %s = %d %s, want the closed-door 403", role, st, body)
+ }
+ }
+ if st, _, body := rawReq(t, http.MethodPost, mintURL(w, selfHostA), w.tok, `{}`); st != http.StatusForbidden {
+ t.Errorf("mint with an instance token = %d %s, want the closed-door 403", st, body)
+ }
+ // A person with no membership meets the family's 404.
+ stranger := cliToken(t, w.fx, "mallory@keymail.dev", "code-mint-stranger")
+ st, h, body = rawReq(t, http.MethodPost, mintURL(w, selfHostA), stranger, `{}`)
+ if st != http.StatusNotFound {
+ t.Errorf("a stranger's mint = %d %s, want 404", st, body)
+ }
+ _ = h
+}
+
+func TestDeleteRevokesInstanceTokens(t *testing.T) {
+ w := newSelfWorld(t)
+ base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA
+ if st, _, body := rawReq(t, http.MethodDelete, base, w.member, ""); st != http.StatusOK {
+ t.Fatalf("delete = %d %s", st, body)
+ }
+ if _, ok := w.fx.st.CLIPrincipal(w.tok, time.Now()); ok {
+ t.Fatal("deleting the instance left its token live")
+ }
+}
+
+// The revoke is best effort after the delete (spec §3.4): when it fails the
+// delete still lands, and the surviving token can only answer 404, even once
+// the host is created again.
+func TestDeleteSurvivesAFailedRevoke(t *testing.T) {
+ w := newSelfWorld(t)
+ w.fx.srv.revokeInstanceTokens = func(context.Context, int64, string, string, time.Time) error {
+ return errors.New("database is locked")
+ }
+ base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA
+ if st, _, body := rawReq(t, http.MethodDelete, base, w.member, ""); st != http.StatusOK {
+ t.Fatalf("delete with a failing revoke = %d %s, want 200", st, body)
+ }
+ if _, ok := w.fx.st.CLIPrincipal(w.tok, time.Now()); !ok {
+ t.Fatal("setup: the failed revoke should have left the token live")
+ }
+ time.Sleep(time.Millisecond)
+ w.create(t, selfHostA, "service")
+ st, h, body := rawReq(t, http.MethodGet, base, w.tok, "")
+ assertNotFound(t, "the unrevoked token on the re-created host", canonicalNotFound(t), st, h, body)
+}
+
+func TestInstanceTokensOnTheCredentialSurfaces(t *testing.T) {
+ w := newSelfWorld(t)
+ // The services API names the role and the host.
+ owner := cliToken(t, w.fx, "paul@keymail.dev", "code-surfaces-owner")
+ st, _, body := rawReq(t, http.MethodGet, w.fx.ts.URL+"/api/cli/services/"+w.fx.sqid, owner, "")
+ if st != http.StatusOK || !strings.Contains(body, `"role":"self"`) || !strings.Contains(body, `"host":"`+selfHostA+`"`) {
+ t.Fatalf("services list = %d %s", st, body)
+ }
+ // serviceWhat names the host for the activity feed.
+ rows, err := w.fx.st.ServicePrincipals(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range rows {
+ if r.IsInstance() {
+ if _, what := serviceWhat(serviceHandle{id: r.ID}, r, true); !strings.Contains(what, selfHostA) {
+ t.Fatalf("serviceWhat = %q, want the host named", what)
+ }
+ if !strings.Contains(r.InstanceScope(), selfHostA) || !strings.Contains(r.InstanceBadge(), selfHostA) {
+ t.Fatalf("row labels %q / %q do not name the host", r.InstanceScope(), r.InstanceBadge())
+ }
+ }
+ }
+}
+
+// A minting member removed from the account cannot revoke it from their own
+// settings; a current member can (spec §3.5, codex 4).
+func TestPersonalSettingsInstanceTokenRevoke(t *testing.T) {
+ fx := newConsoleFixture(t)
+ otherID := testAccountID()
+ if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Sam's", "sam@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "sam@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ secret, err := fx.st.NewInstanceTokenContext(context.Background(), otherID, "kass", "a.example.com", testBorn, "paul@keymail.dev", "", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ toks, err := fx.st.CLITokens("paul@keymail.dev")
+ if err != nil || len(toks) != 1 {
+ t.Fatalf("CLITokens = %d rows %v, want the instance token", len(toks), err)
+ }
+ id := toks[0].ID
+ c := signInPaul(t, fx)
+ _, _, page := get(t, c, fx.ts.URL+"/settings")
+ if !strings.Contains(page, toks[0].InstanceBadge()) {
+ t.Fatalf("/settings does not label the instance token:\n%s", page)
+ }
+ if err := fx.st.RemoveMember(otherID, "paul@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ csrf := sessionToken(t, fx, c)
+ status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"csrf": {csrf}, "id": {id}})
+ if status != http.StatusSeeOther || loc != "/settings" {
+ t.Fatalf("revoke after removal = %d -> %q:\n%s", status, loc, body)
+ }
+ if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok {
+ t.Fatal("a removed member revoked the account's instance token")
+ }
+ _, _, page = get(t, c, fx.ts.URL+"/settings")
+ if !strings.Contains(page, "no longer a member") {
+ t.Fatalf("/settings does not say why the revoke was refused:\n%s", page)
+ }
+}
diff --git a/internal/console/instancetoken_words.go b/internal/console/instancetoken_words.go
index a2896e31e..6a8378c16 100644
--- a/internal/console/instancetoken_words.go
+++ b/internal/console/instancetoken_words.go
@@ -5,4 +5,8 @@ package console
const (
instanceTokenDefaultLabel = "%s instance token"
selfPatchFieldsRefused = "an instance's own credential can change addrs and probe only. environment, steering and gate need a member of the app"
+ instanceTokenNoBirth = "%s has no creation time on its record, so a token cannot be bound to it. Delete and create it again"
+ instanceTokenRevokeWhat = "instance credential for %s in %s"
+ instanceTokenScope = "app %s, instance %s"
+ instanceTokenBadge = "instance token · %s"
)
diff --git a/internal/console/server.go b/internal/console/server.go
index 5f89d5076..635048816 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -853,6 +853,10 @@ type Server struct {
// it is made (a TEST SEAM: a release that fails after its log entry is
// queued, movesrecovery.go).
moveReleaseFault func() error
+ // revokeInstanceTokens, when set, replaces the delete door's revoke of
+ // the host's instance tokens (a TEST SEAM: a revoke that fails leaves a
+ // token that can only 404, instancetoken.go).
+ revokeInstanceTokens func(ctx context.Context, accountID int64, app, host string, now time.Time) error
// moveLogSettleHook, when set, runs as the drainer settles an
// unconfirmed declared_lost row, after it read the claim (a TEST SEAM:
// a declaration that commits in between, movesrecovery.go).
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index e59f12074..aeac4059d 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -195,6 +195,8 @@ var closedDoors = []string{
// whose rules it never met. Shut for the same reason the manifest door
// is — an app may not rewrite the bounds it runs inside.
"POST /api/cli/apps/{account}/{app}/instances/{host}/channel",
+ // The instance-bound mint (#155): a credential never mints a credential.
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/token",
// The three instance update settings doors left this list with #155:
// they admit the instance's own credential (AllowSelf) and still refuse
// every app-wide role, which TestAppWideCredentialsStillRefusedAtUpdateDoors
@@ -957,8 +959,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 57 {
- t.Errorf("walked %d closed doors, want all %d (and 57 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and the five run doors)",
+ if seen != len(closedDoors) || seen != 58 {
+ t.Errorf("walked %d closed doors, want all %d (and 58 today: instance-token, the instance-bound mint, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and the five run doors)",
seen, len(closedDoors))
}
}
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html
index 921d193de..1346c68f3 100644
--- a/internal/console/templates/account_settings.html
+++ b/internal/console/templates/account_settings.html
@@ -109,7 +109,7 @@
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="small">(unnamed)</span>{{end}}</td>
<td>{{.Role}}</td>
- <td>{{if .App}}app {{.App}}{{else}}account-wide{{end}}</td>
+ <td>{{if .IsInstance}}{{.InstanceScope}}{{else if .App}}app {{.App}}{{else}}account-wide{{end}}</td>
<td>{{emptyOrDash .RungList}}</td>
<td>{{emptyOrDash .Label}}</td>
<td>{{if .RotatedAt.IsZero}}—{{else}}{{fmtTime .RotatedAt}}{{end}}</td>
diff --git a/internal/console/templates/settings.html b/internal/console/templates/settings.html
index 396ec8d20..78ea00cb1 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}})</span>{{else if .IsInstance}} <span class="small">({{.InstanceBadge}})</span>{{end}}{{end}}</td>
<td>{{emptyOrDash .RemoteAddr}}</td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td>
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index fc667b937..b19e47dcd 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -178,6 +178,7 @@ route POST /api/cli/apps/{account}/{app}/features
route POST /api/cli/apps/{account}/{app}/instance-token
route POST /api/cli/apps/{account}/{app}/instances
route POST /api/cli/apps/{account}/{app}/instances/{host}/channel
+route POST /api/cli/apps/{account}/{app}/instances/{host}/token
route POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort
route POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit
route POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped