console: say what a reload does next to the secret, and why revoke has no confirm
C47 — the re-post trade is accepted, so the page states it. create and rotate answer 200 with the page rather than redirecting, because a redirect would have to carry the plaintext somewhere the next request could read it, and the two alternatives both cost more than the hazard: a flash cookie would write a live credential into the browser's cookie jar, and a single-use form nonce is real machinery on a page used a handful of times a year. The honest half of accepting it is that the page says so, next to the secret and differently per verb — a reload of rotate retires the secret just copied, a reload of create is refused by the name's uniqueness and leaves it valid. An operator who has just pasted a credential into a config must not discover this by losing it. Pinned by assertions on both branches. C48 — the absence of a typed confirmation on revoke is now a comment rather than an omission. Every gate in front of the emergency control is friction at exactly the wrong moment, and a confirm while the passkey has been removed would be incoherent: it would say the act is grave enough to interrupt but not grave enough to re-authenticate. It is also the most recoverable destructive act on this surface — mint a replacement and redistribute — where app deletion, which the idiom guards, is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
3 files changed,
+44
−2
internal/console/svcpage.go+21 −2internal/console/svcpage_test.go+14 −0internal/console/templates/account_settings.html+9 −0
diff --git a/internal/console/svcpage.go b/internal/console/svcpage.go| index 75e4f3544..00c65be8c 100644 |
| --- a/internal/console/svcpage.go |
| +++ b/internal/console/svcpage.go |
| @@ -57,9 +57,17 @@ |
| // worse than the thing they would fix. The cost is that a browser RELOAD |
| // re-posts. On create that is refused by the name's uniqueness |
| // (ErrServiceNameTaken); on ROTATE it rotates again, which retires the |
| -// secret the operator has just copied. The browser's own "confirm form |
| -// resubmission" prompt is the guard, and it is the same trade |
| +// secret the operator has just copied. The same trade |
| // handleFleetRotateBoxPage already makes. Do not "fix" it with a flash. |
| +// |
| +// The trade is ACCEPTED, and the honest half of accepting it is that the |
| +// PAGE SAYS SO, next to the secret and differently for each verb (C47, |
| +// account_settings.html). The browser's own "confirm form resubmission" |
| +// prompt is not a guard anyone reads, and an operator who has just pasted a |
| +// credential into a config must not discover this by losing it. A single-use |
| +// form nonce would remove the hazard and is real machinery on a page used a |
| +// handful of times a year; the sentence is the proportionate answer. If that |
| +// line is ever edited away, this trade stops being one. |
| package console |
| import ( |
| @@ -366,6 +374,17 @@ func (s *Server) handleServiceRotatePage(w http.ResponseWriter, r *http.Request) |
| // different words, the same distinction `carlos services revoke` draws |
| // between "revoked" and "nothing to revoke". |
| // |
| +// THERE IS DELIBERATELY NO TYPED CONFIRMATION HERE (C48), though this repo |
| +// has the idiom and uses it for app and account deletion. Every gate in front |
| +// of the emergency control is friction at exactly the wrong moment, and a |
| +// confirm while the passkey has been REMOVED would be incoherent besides: it |
| +// would say the act is grave enough to interrupt but not grave enough to |
| +// re-authenticate. The cost is real — a mis-click kills a live credential — |
| +// and it is the most recoverable destructive act on this surface: mint a |
| +// replacement and redistribute. App deletion, which the typed confirmation |
| +// guards, is not recoverable that way, which is why the idiom belongs there |
| +// and not here. The audit row names who pressed it. |
| +// |
| // Gate order: session → CSRF → owner 404 → act. No requireFreshAuth — see |
| // this file's doc comment. |
| func (s *Server) handleServiceRevokePage(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/console/svcpage_test.go b/internal/console/svcpage_test.go| index 068d89012..9ea00df50 100644 |
| --- a/internal/console/svcpage_test.go |
| +++ b/internal/console/svcpage_test.go |
| @@ -307,6 +307,14 @@ func TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse(t *testing.T) { |
| if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { |
| t.Fatal("the secret the page showed does not resolve — it is not the credential's own") |
| } |
| + // C47: the page answers 200 rather than redirecting, because a redirect |
| + // would have to carry the plaintext somewhere the next request could read |
| + // it. That trade is accepted — and the honest half of accepting it is |
| + // saying, next to the secret, what a reload does. Create's reload is |
| + // refused by the name's uniqueness, so the secret above survives it. |
| + if !strings.Contains(body, "Reloading this page") { |
| + t.Fatalf("the mint response does not say what a reload does:\n%s", body) |
| + } |
| if strings.Count(body, secret) != 1 { |
| t.Fatalf("the fresh secret appears %d times in its own response, want exactly 1", strings.Count(body, secret)) |
| } |
| @@ -334,6 +342,12 @@ func TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse(t *testing.T) { |
| if fresh == secret { |
| t.Fatal("rotate showed the same secret again") |
| } |
| + // C47, the sharp end: a reload here rotates AGAIN and retires the secret |
| + // the operator has just pasted into a config. An operator must not |
| + // discover that by losing it. |
| + if !strings.Contains(body, "Reloading this page rotates again") { |
| + t.Fatalf("the rotate response does not warn that a reload retires this secret:\n%s", body) |
| + } |
| if _, ok := fx.st.CLIPrincipal(secret, time.Now()); ok { |
| t.Fatal("the pre-rotation secret still resolves") |
| } |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index 8f322c474..e9335dc76 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -28,6 +28,15 @@ |
| <p><code>CARLOS_SERVICE_TOKEN={{.Token}}</code></p> |
| <p class="small">Shown once. Only its hash is stored — a lost secret means rotate, not another |
| look at this page.</p> |
| + {{if .Rotated}} |
| + <p class="small"><strong>Reloading this page rotates again</strong> and retires the secret |
| + above. Copy it before you reload — if you lose it, rotate once more and redistribute; nothing |
| + is destroyed, but this secret will not come back.</p> |
| + {{else}} |
| + <p class="small"><strong>Reloading this page</strong> tries to mint {{.Name}} a second time and |
| + is refused, because the name is taken. The secret above stays valid — but it is still shown |
| + only here, so copy it before you leave.</p> |
| + {{end}} |
| <p class="small">Deliver it: <code>{{.DeliverCommand}}</code></p> |
| {{if .Rotated}} |
| <p class="small">ROTATION IS NOT REVOCATION: this refuses the credential's NEXT call with the |