carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev ef6d1bafa2e6ac03e2b7640462cfad24fe544de3 parent 7b800a1
3 files changed, +44 −2
  • internal/console/svcpage.go +21 −2
  • internal/console/svcpage_test.go +14 −0
  • internal/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