carlos / platform Public

console: the delete revoke takes a token snapshot, not a record read (#155)

The first fix for Astra's finding 1 read the record before retirement,
which moved the delete door's own record reads and broke two import-delete
race tests in CI. The delete now snapshots the host's live instance token
ids from the console database before retiring the record and revokes
exactly those after it, so a replacement's token is still spared. Census
overlay 155 regenerated: three new SQL units, still PROPOSED-155.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3e90e650a1bdbb32a8fbc54f4648c762f7da2f1e parent 8f8867d
8 files changed, +114 −62
  • docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md +6 −2
  • internal/console/apiinstances.go +6 −5
  • internal/console/instancetoken.go +62 −35
  • internal/console/instancetoken_mint_test.go +3 −3
  • internal/console/instancetoken_store_test.go +27 −9
  • internal/console/server.go +1 −1
  • internal/provision/plan14_root_reads_test.go +2 −2
  • internal/provision/testdata/plan14-root-reads-155.tsv +7 −5

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	294.472s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.577s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.189s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.710s
ok  	github.com/carlosframework/platform/internal/activator	249.417s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.440s
ok  	github.com/carlosframework/platform/internal/apperr	0.069s
ok  	github.com/carlosframework/platform/internal/applog	0.137s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.612s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.003s
ok  	github.com/carlosframework/platform/internal/cgroup	0.691s
ok  	github.com/carlosframework/platform/internal/changelog	0.312s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	397.041s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.789s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.008s
ok  	github.com/carlosframework/platform/internal/dbverify	0.006s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.005s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.313s
ok  	github.com/carlosframework/platform/internal/doorkey	0.007s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.504s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.129s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.011s
ok  	github.com/carlosframework/platform/internal/geoip	0.031s
ok  	github.com/carlosframework/platform/internal/host	161.931s
ok  	github.com/carlosframework/platform/internal/hostactors	0.533s
ok  	github.com/carlosframework/platform/internal/hostbox	2.433s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.466s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.013s
ok  	github.com/carlosframework/platform/internal/hostexec	4.749s
ok  	github.com/carlosframework/platform/internal/hostfence	0.032s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.439s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.013s
ok  	github.com/carlosframework/platform/internal/hostguard	1.007s
ok  	github.com/carlosframework/platform/internal/hostops	1.559s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.013s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.013s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.440s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.131s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.013s
ok  	github.com/carlosframework/platform/internal/infraview	0.013s
ok  	github.com/carlosframework/platform/internal/instancectl	4.179s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.689s
ok  	github.com/carlosframework/platform/internal/instancefs	0.018s
ok  	github.com/carlosframework/platform/internal/instancekey	0.015s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.022s
ok  	github.com/carlosframework/platform/internal/layout	1.517s
ok  	github.com/carlosframework/platform/internal/lease	9.805s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.018s
ok  	github.com/carlosframework/platform/internal/ledger	0.011s
ok  	github.com/carlosframework/platform/internal/mail	0.148s
ok  	github.com/carlosframework/platform/internal/maintenance	0.078s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.493s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.070s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	20.944s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.767s
ok  	github.com/carlosframework/platform/internal/objstore	0.167s
ok  	github.com/carlosframework/platform/internal/ops	0.163s
ok  	github.com/carlosframework/platform/internal/park	0.205s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	13.615s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.870s
ok  	github.com/carlosframework/platform/internal/relay	7.222s
ok  	github.com/carlosframework/platform/internal/release	19.163s
ok  	github.com/carlosframework/platform/internal/rollout	3.627s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.024s
ok  	github.com/carlosframework/platform/internal/runs	0.572s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.487s
ok  	github.com/carlosframework/platform/internal/seeds	0.010s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	8.673s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.594s
ok  	github.com/carlosframework/platform/internal/status	0.038s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.195s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.003s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.537s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 47120546 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.023s
=== RUN   TestT4StorageProbeMinio
2026/10/16 07:15:13 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEF1357558427B, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
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 db823f2b8..517e8a671 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
@@ -136,8 +136,12 @@ A token is bound to one incarnation of its host:
pinned by an AST test listing every non-test caller of
`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) **after** the record is gone, best effort: a failed
+- `handleAPIInstanceDelete` snapshots the host's live `kind='instance'` row
+ ids from the console database before it retires the record, and revokes
+ exactly those **after** the record is gone, best effort (so a token minted
+ for a replacement in between is spared, Astra final review 1; a snapshot
+ rather than a bucket read of the record, so the delete's own reads of the
+ record are unchanged): 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,
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 2ed8ca050..611ede623 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -2218,12 +2218,13 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
if s.refuseDeleteDuringMove(ctx, w, acct.Sqid, app, host) {
return
}
- // The incarnation this delete retires, so the token revoke after it
- // spares any replacement (spec 2026-10-16-instance-bound-tokens §3.4).
- born := s.deletingIncarnation(ctx, acct, app, host)
+ // The tokens this delete will revoke, snapshotted from the console's
+ // own database before the record goes, so the revoke after it spares
+ // any replacement (spec 2026-10-16-instance-bound-tokens §3.4).
+ tokens := s.deletingInstanceTokens(acct, app, host)
if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered {
if finished {
- s.revokeDeletedInstanceTokens(ctx, acct, app, host, born)
+ s.revokeDeletedInstanceTokens(ctx, acct, app, host, tokens)
s.auditActivity(acct, app, principal, Activity{
Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
Detail: "deleted instance " + host + " (cli)",
@@ -2250,7 +2251,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, born)
+ s.revokeDeletedInstanceTokens(ctx, acct, app, host, tokens)
// DNS seam (spec §10): a multi-region deployment retires the instance's
// CNAME here, after the record is gone.
//
diff --git a/internal/console/instancetoken.go b/internal/console/instancetoken.go
index 38f7a02d5..1426da7de 100644
--- a/internal/console/instancetoken.go
+++ b/internal/console/instancetoken.go
@@ -57,21 +57,52 @@ VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?, ?)`,
return raw, nil
}
-// RevokeInstanceTokensContext soft-revokes every live token bound to one
-// incarnation of host, and no other: a replacement created since keeps its
-// own (Astra final review, 1). 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, born int64, now time.Time) error {
+// LiveInstanceTokenIDs lists the listing ids of host's live instance
+// tokens: the snapshot the delete door takes before it retires the record,
+// so the revoke after it touches exactly those and never a token minted
+// for a replacement in between (Astra final review, 1). A read of the
+// console's own database; no bucket read, so the delete's own reads of the
+// record are unchanged.
+func (s *Store) LiveInstanceTokenIDs(accountID int64, app, host string) ([]string, error) {
+ rows, err := s.db.Query(`SELECT token_hash FROM cli_tokens
+WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND revoked_at = 0 ORDER BY token_hash`,
+ cliKindInstance, accountID, app, host)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+// RevokeInstanceTokenIDsContext soft-revokes the live instance tokens of
+// (account, app) among ids, and nothing else: an id of another kind or
+// another app is left alone. No ids is not an error.
+func (s *Store) RevokeInstanceTokenIDsContext(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error {
+ if len(ids) == 0 {
+ return nil
+ }
return s.withAutoAppSQL(ctx, accountID, app, "sql.RevokeInstanceTokens", func(view *Store) error {
- return view.claimRawRevokeInstanceTokens(accountID, app, host, born, now)
+ return view.claimRawRevokeInstanceTokenIDs(accountID, app, ids, now)
})
}
-func (s *Store) claimRawRevokeInstanceTokens(accountID int64, app, host string, born int64, now time.Time) error {
- _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ?
-WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND host_born = ? AND revoked_at = 0`,
- now.Unix(), cliKindInstance, accountID, app, host, born)
- return err
+func (s *Store) claimRawRevokeInstanceTokenIDs(accountID int64, app string, ids []string, now time.Time) error {
+ for _, id := range ids {
+ if _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ?
+WHERE token_hash = ? AND kind = ? AND account_id = ? AND app = ? AND revoked_at = 0`,
+ now.Unix(), id, cliKindInstance, accountID, app); err != nil {
+ return err
+ }
+ }
+ return nil
}
// InstanceScope is an instance token's scope as the account pane shows it.
@@ -146,37 +177,33 @@ func (s *Server) handleAPIInstanceTokenMint(w http.ResponseWriter, r *http.Reque
// 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, born int64, now time.Time) error {
+func (s *Server) revokeInstanceTokensFor(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error {
if s.revokeInstanceTokens != nil {
- return s.revokeInstanceTokens(ctx, accountID, app, host, born, now)
+ return s.revokeInstanceTokens(ctx, accountID, app, ids, now)
}
- return s.store.RevokeInstanceTokensContext(ctx, accountID, app, host, born, now)
+ return s.store.RevokeInstanceTokenIDsContext(ctx, accountID, app, ids, now)
}
-// deletingIncarnation reads the incarnation a delete is about to retire, so
-// the revoke after it touches that one only. Zero when there is no record
-// (the delete is idempotent) or it could not be read; the revoke then does
-// nothing, and the incarnation check still holds for any token left.
-func (s *Server) deletingIncarnation(ctx context.Context, acct *Account, app, host string) int64 {
- rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
- if err != nil || rec.CreatedAt.IsZero() {
- return 0
+// deletingInstanceTokens snapshots host's live instance tokens before the
+// delete retires the record, so the revoke after it spares any token minted
+// for a replacement in between. A read failure snapshots nothing; the
+// incarnation check still holds for any token left.
+func (s *Server) deletingInstanceTokens(acct *Account, app, host string) []string {
+ ids, err := s.store.LiveInstanceTokenIDs(acct.ID, app, host)
+ if err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: list instance tokens (they answer 404 regardless): %v", acct.Sqid, app, host, err)
+ return nil
}
- return rec.CreatedAt.UnixNano()
+ return ids
}
-// revokeDeletedInstanceTokens revokes the retired incarnation'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). Scoped to born, so a replacement
-// created in between keeps its tokens.
-func (s *Server) revokeDeletedInstanceTokens(ctx context.Context, acct *Account, app, host string, born int64) {
- if born == 0 {
- return
- }
- if err := s.revokeInstanceTokensFor(ctx, acct.ID, app, host, born, time.Now()); err != nil {
+// revokeDeletedInstanceTokens revokes the snapshot's tokens once the 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, ids []string) {
+ if err := s.revokeInstanceTokensFor(ctx, acct.ID, app, ids, 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)
}
}
diff --git a/internal/console/instancetoken_mint_test.go b/internal/console/instancetoken_mint_test.go
index c361c80d9..246135bf2 100644
--- a/internal/console/instancetoken_mint_test.go
+++ b/internal/console/instancetoken_mint_test.go
@@ -107,7 +107,7 @@ func TestDeleteRevokesInstanceTokens(t *testing.T) {
// the host is created again.
func TestDeleteSurvivesAFailedRevoke(t *testing.T) {
w := newSelfWorld(t)
- w.fx.srv.revokeInstanceTokens = func(context.Context, int64, string, string, int64, time.Time) error {
+ 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
@@ -199,11 +199,11 @@ func TestPersonalSettingsInstanceTokenRevoke(t *testing.T) {
func TestDeleteRevokeSparesAReplacement(t *testing.T) {
w := newSelfWorld(t)
var replacement string
- w.fx.srv.revokeInstanceTokens = func(ctx context.Context, accountID int64, app, host string, born int64, now time.Time) error {
+ w.fx.srv.revokeInstanceTokens = func(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error {
time.Sleep(time.Millisecond)
rec := w.create(t, selfHostA, "service")
replacement = mintBoundToken(t, w.fx.st, OpsAccountID, "titogo", selfHostA, rec.CreatedAt.UnixNano())
- return w.fx.st.RevokeInstanceTokensContext(ctx, accountID, app, host, born, now)
+ return w.fx.st.RevokeInstanceTokenIDsContext(ctx, accountID, app, ids, now)
}
base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA
if st, _, body := rawReq(t, http.MethodDelete, base, w.member, ""); st != http.StatusOK {
diff --git a/internal/console/instancetoken_store_test.go b/internal/console/instancetoken_store_test.go
index 033aaab70..df16331f9 100644
--- a/internal/console/instancetoken_store_test.go
+++ b/internal/console/instancetoken_store_test.go
@@ -142,19 +142,36 @@ func TestInstanceTokensListRevokeAndNeverExpire(t *testing.T) {
if err != nil || len(mine) != 1 || mine[0].Host != "a.example.com" {
t.Fatalf("CLITokens = %+v, %v", mine, err)
}
- other := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn+1)
- if err := s.RevokeInstanceTokensContext(context.Background(), acct.ID, "shop", "a.example.com", testBorn, time.Now()); err != nil {
+ ids, err := s.LiveInstanceTokenIDs(acct.ID, "shop", "a.example.com")
+ if err != nil || len(ids) != 1 || ids[0] != hashToken(raw) {
+ t.Fatalf("LiveInstanceTokenIDs = %v, %v", ids, err)
+ }
+ // Minted after the snapshot: a replacement's, which the revoke spares.
+ later := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn+1)
+ if err := s.RevokeInstanceTokenIDsContext(context.Background(), acct.ID, "shop", ids, time.Now()); err != nil {
t.Fatal(err)
}
if _, ok := s.CLIPrincipal(raw, time.Now()); ok {
t.Fatal("revoked instance token still resolves")
}
- if _, ok := s.CLIPrincipal(other, time.Now()); !ok {
- t.Fatal("revoking one incarnation took another's token")
+ if _, ok := s.CLIPrincipal(later, time.Now()); !ok {
+ t.Fatal("the revoke took a token minted after its snapshot")
+ }
+ // No ids is not an error: delete calls it for every host.
+ if err := s.RevokeInstanceTokenIDsContext(context.Background(), acct.ID, "shop", nil, time.Now()); err != nil {
+ t.Fatalf("revoking no tokens: %v", err)
+ }
+ // The ids are scoped to the account and app given: an app token's id,
+ // or another app's instance token, is never revoked through here.
+ appTok, err := s.NewAppToken(acct.ID, "shop", "alice@keymail.dev", "router", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RevokeInstanceTokenIDsContext(context.Background(), acct.ID, "shop", []string{hashToken(appTok)}, time.Now()); err != nil {
+ t.Fatal(err)
}
- // Zero rows is not an error: delete calls it for every host.
- if err := s.RevokeInstanceTokensContext(context.Background(), acct.ID, "shop", "never.example.com", testBorn, time.Now()); err != nil {
- t.Fatalf("revoking a host with no tokens: %v", err)
+ if _, ok := s.CLIPrincipal(appTok, time.Now()); !ok {
+ t.Fatal("the instance revoke took an app token")
}
}
@@ -303,8 +320,9 @@ func TestInstanceTokenMintAndRevokeAreAdmitted(t *testing.T) {
if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok {
t.Fatal("gated mint does not resolve")
}
+ ids := []string{hashToken(raw)}
restore := cx.foreign(t, claimAppRef{Account: "bab", App: "kass"})
- err = cx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", testBorn, time.Now())
+ err = cx.st.RevokeInstanceTokenIDsContext(context.Background(), OpsAccountID, "kass", ids, time.Now())
restore()
if err == nil {
t.Fatal("an instance token was revoked while its app was closed under a foreign exclusive operation")
@@ -312,7 +330,7 @@ func TestInstanceTokenMintAndRevokeAreAdmitted(t *testing.T) {
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", testBorn, time.Now()); err != nil {
+ if err := cx.st.RevokeInstanceTokenIDsContext(context.Background(), OpsAccountID, "kass", ids, time.Now()); err != nil {
t.Fatalf("gated revoke: %v", err)
}
if _, ok := cx.st.CLIPrincipal(raw, time.Now()); ok {
diff --git a/internal/console/server.go b/internal/console/server.go
index 52612e619..eff0c64e9 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -856,7 +856,7 @@ type Server struct {
// 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, born int64, now time.Time) error
+ revokeInstanceTokens func(ctx context.Context, accountID int64, app string, ids []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/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 64ff17d7d..1a193385a 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -792,13 +792,13 @@ func TestPlan14RootReadCensus(t *testing.T) {
}
// Census overlay 155 (amadan #155 half 1, instance-bound tokens): the
// console's cli_tokens gains an instance kind; eleven changed units are
- // re-frozen at main's dispositions, read sets unchanged in kind, and two
+ // re-frozen at main's dispositions, read sets unchanged in kind, and three
// new SQL units are proposed at their siblings' disposition as
// PROPOSED-155, awaiting the Plan 14 controller's ruling on #280.
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-155.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
- seal = "78159fba3932168c05430821bd5490d319d3a92ca3e066cab504c58784bc402d"
+ seal = "dd8f3289d5a81d1c2961c707709d65e2eed2708270dfea7d636f57815972b109"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-155.tsv b/internal/provision/testdata/plan14-root-reads-155.tsv
index 823031f10..77e4ce7d4 100644
--- a/internal/provision/testdata/plan14-root-reads-155.tsv
+++ b/internal/provision/testdata/plan14-root-reads-155.tsv
@@ -1,5 +1,5 @@
-# Plan 14 census overlay 155 (amadan #155 half 1, instance-bound tokens; loaded last, after census-ruling-home-part2). PROPOSED, awaiting the Plan 14 controller's ruling on #280: every changed unit is re-frozen at main's disposition with its read set unchanged in kind; the two new units are proposed at their siblings' disposition (claimRawNewAppToken, claimRawRevokeCLIToken: accepted-legacy;rev3=J4;basis=root-input) as rev3=PROPOSED-155.
-# What changed. The console's own SQLite: cli_tokens gains host and host_born (authstore migrateCLIAuth), a kind 'instance' that CLIPrincipal resolves and both listings (CLITokens, ServicePrincipals) select, the latter two leaving out an instance row whose app row is gone; purge (PurgeAppRow, PurgeClaimAppRow) deletes instance rows, rename (renameAppIn) and account move (MoveApp) carry them, and their stale-credential counts do not count them. New: claimRawNewInstanceToken (INSERT) and claimRawRevokeInstanceTokens (UPDATE revoked_at, scoped to host_born), both behind withAutoAppSQL. Templates: one more kind branch on the two credential tables. cli.go runInstances: one more sub-verb dispatch ("token"). No bucket, file, process or registry read is added or changed: every new read is the console database it already reads.
+# Plan 14 census overlay 155 (amadan #155 half 1, instance-bound tokens; loaded last, after census-ruling-home-part2). PROPOSED, awaiting the Plan 14 controller's ruling on #280: every changed unit is re-frozen at main's disposition with its read set unchanged in kind; the three new units are proposed at their siblings' disposition (claimRawNewAppToken, claimRawRevokeCLIToken, CLITokens: accepted-legacy;rev3=J4;basis=root-input) as rev3=PROPOSED-155.
+# What changed. The console's own SQLite: cli_tokens gains host and host_born (authstore migrateCLIAuth), a kind 'instance' that CLIPrincipal resolves and both listings (CLITokens, ServicePrincipals) select, the latter two leaving out an instance row whose app row is gone; purge (PurgeAppRow, PurgeClaimAppRow) deletes instance rows, rename (renameAppIn) and account move (MoveApp) carry them, and their stale-credential counts do not count them. New: claimRawNewInstanceToken (INSERT), LiveInstanceTokenIDs (SELECT of a host's live instance token ids, the delete door's snapshot) and claimRawRevokeInstanceTokenIDs (UPDATE revoked_at for exactly those ids, kind, account and app), the two writes behind withAutoAppSQL. Templates: one more kind branch on the two credential tables. cli.go runInstances: one more sub-verb dispatch ("token"). No bucket, file, process or registry read is added or changed: every new read is the console database it already reads.
# Envelopes: . (cli.go, apimode.go, instances_cli.go), internal/console, internal/console/templates, internal/instanceupdates (a comment) and internal/svcrole (Self and AllowSelf).
@legacy 81fa050f7f80e18bef87fd647dcbb5444284e4590ca77bae141f1eaf63c360d3 cli.go runInstances legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the carlos CLI: its API verbs on a person's machine as that person, its local listing by hand on a box as whoever runs it (no box unit): main's body with one more dispatch arm, import, which goes to the API-mode import verbs; read sites unchanged file:Open@1398,registry:App@3271,registry:Backing@3353,registry:Channel@3286,registry:Environment@3297,registry:Hibernate@3162,registry:Host@3263,registry:Kind@3278,registry:Replication@3338,registry:Status@3312,registry:Unit@3364,registry:Version@3322,whole:Route@3136 90ac75c402c0f9387224b37638ac1f71735938f8267f8de224cdac69a8d5c4c4
cli.go runInstances legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the carlos CLI: its API verbs on a person's machine as that person, its local listing by hand on a box as whoever runs it (no box unit): main's body with one more dispatch arm, import, which goes to the API-mode import verbs; read sites unchanged file:Open@1398,registry:App@3271,registry:Backing@3353,registry:Channel@3286,registry:Environment@3297,registry:Hibernate@3162,registry:Host@3263,registry:Kind@3278,registry:Replication@3338,registry:Status@3312,registry:Unit@3364,registry:Version@3322,whole:Route@3136 90ac75c402c0f9387224b37638ac1f71735938f8267f8de224cdac69a8d5c4c4
@@ -9,10 +9,12 @@ internal/console/authstore.go Store.CLITokens legacy accepted-legacy accepted-le
internal/console/authstore.go Store.migrateCLIAuth legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@1447,file:Exec@2798,file:Exec@3236,file:Exec@4078 7c64bb4da8ff85da69894d8d35e0d2c4f6f2bf16aa569e8525739181a9405c56
@legacy 2f88505a2a3c35013a519410b4626d5cadaeb2f4d843d4baf09413dd95612791 internal/console/claim_app_identity.go Store.PurgeClaimAppRow legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:QueryRowContext@218,file:QueryRowContext@790 c8fc2e007f44c86f2d187a44cd717a5ec942e325a00996c47e52b6b6254da04a
internal/console/claim_app_identity.go Store.PurgeClaimAppRow legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:QueryRowContext@218,file:QueryRowContext@790 c8fc2e007f44c86f2d187a44cd717a5ec942e325a00996c47e52b6b6254da04a
+@legacy 5b36aac0ad61c28d6decdf7b252c9614f4660864d09d4e916a6cdf56a0df710d internal/console/instancetoken.go Store.LiveInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Query@106 0de6abf3e12dadd071c4656c4be38efe60dcb5b72956597d2c830dfc13378760
+internal/console/instancetoken.go Store.LiveInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Query@106 0de6abf3e12dadd071c4656c4be38efe60dcb5b72956597d2c830dfc13378760
@legacy 2f560698310d14a181885fb9780eb328cc17d223c899164fe0cc1412fefbd285 internal/console/instancetoken.go Store.claimRawNewInstanceToken legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@223 83b922e4c913313143b8269be2f7f51a2dd34871fc9be52bdf638a33c272d280
internal/console/instancetoken.go Store.claimRawNewInstanceToken legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@223 83b922e4c913313143b8269be2f7f51a2dd34871fc9be52bdf638a33c272d280
-@legacy 04a44845920e2b89e5bfc403fa02a76c0f67ec2eb08fe6239af4c3b6bffa5e6e internal/console/instancetoken.go Store.claimRawRevokeInstanceTokens legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@126 e16ae4c17aba3dd29ccb1b606f7bbfe8ddc57adf9ef9e3fd4ad4d182f3b9621e
-internal/console/instancetoken.go Store.claimRawRevokeInstanceTokens legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@126 e16ae4c17aba3dd29ccb1b606f7bbfe8ddc57adf9ef9e3fd4ad4d182f3b9621e
+@legacy d84fef2ccc67f10283a0c11e3b9fa32452ed399bc39c31b918fa9748b000f690 internal/console/instancetoken.go Store.claimRawRevokeInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@154 01868be0799ae3bd587a352c541742aa83716d327368328529e771e79131a960
+internal/console/instancetoken.go Store.claimRawRevokeInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@154 01868be0799ae3bd587a352c541742aa83716d327368328529e771e79131a960
@legacy e870152e6181307e7debc3fff4a8ba98c9ff2820f2bf131c18100b7feb0beee0 internal/console/store.go Store.MoveApp legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@3969,file:Exec@4105,file:QueryRow@173,file:QueryRow@1945,file:QueryRow@311,file:QueryRow@3231,file:QueryRow@3821,file:QueryRow@5176,file:QueryRow@5293,file:QueryRow@756 821a6190e38573136c099740017b4603f2cd576b19d3ebceef8bacb5e9e1c3f9
internal/console/store.go Store.MoveApp legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@3969,file:Exec@4105,file:QueryRow@173,file:QueryRow@1945,file:QueryRow@311,file:QueryRow@3231,file:QueryRow@3821,file:QueryRow@5176,file:QueryRow@5293,file:QueryRow@756 821a6190e38573136c099740017b4603f2cd576b19d3ebceef8bacb5e9e1c3f9
@legacy 1d9c3495448e25b6e236261229acf5f135b08b486540f53bad919fa410a80fd8 internal/console/store.go Store.PurgeAppRow legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:QueryRowContext@549 c05fdfb05d250105b76c86249a09f53ce1d786900f0647cb5fed92671a1c7c82
@@ -28,7 +30,7 @@ internal/console/templates/account_settings.html external legacy accepted-legacy
@legacy 51707c74b09db7a82b3d7e002be5ca4128222ad2982e1f17c86115deab569613 internal/console/templates/settings.html external legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:external@0 548151bab281e2104f47c4640ab3d6ed794e8031b1d5deaacc718d5636110d70
internal/console/templates/settings.html external legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:external@0 548151bab281e2104f47c4640ab3d6ed794e8031b1d5deaacc718d5636110d70
@coverage . 884fc3f51bc79c14ed09322cadd6adf2f927f9af98a38cc3705c16c8f7868637
-@coverage internal/console ee1b6db9a0848b9759d260a8fdebc3ab542a792aa0705a8e0599fc872f9f3a2f
+@coverage internal/console dfc881b3ea8f79f92a166a697ff5e66c12ee28cdf1a5b0858cef642f61efd9f2
@coverage internal/console/templates 4797ceb2096a72c8f5c00f2fcb4eb3c67c4604114e3a785e2d18cb57cdefd5e7
@coverage internal/instanceupdates f896490a4a844dfa372d66510c1e2e510d4d7511b9db025ce22ecb565f4d6c0d
@coverage internal/svcrole 9dffb494a915f18aba94a903d1c6db4bb3687299f5888f394294ce95d21b3f2d