carlos / platform Public

provision: pin that the converge log prints open/set, never the hash

The converge line already said "gate open -> set", but nothing held it
there: printing rec.Gate instead of gateWord(rec.Gate) is a one-token edit
that would ship every host's bcrypt hash into an operations log, where it
is copied and pasted far more freely than the registry row is.
TestReconcileConvergesGate now captures the log across both transitions and
asserts the words appear and the hash does not.

Also: Record.Gate's doc named convergeGate, which is not what does the
converging — say Reconcile's owned-converge arm, in reconcile.go.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 283e7c5312a6761d20fae3111780147ebf30d1a2 parent 53537d5
2 files changed, +23 −2
  • internal/provision/provision.go +2 −2
  • internal/provision/reconcile_test.go +21 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 029876490..83b848e31 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -131,8 +131,8 @@ type Record struct {
// (internal/gate.Hash), and is never stored, logged or echoed — the
// console's record views shadow this field (apiinstances.go). A
// string, not a pointer: absent and "" are the same fact, an open
- // host. Converged into routes.gate by convergeGate and read by the
- // edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md.
+ // host. Converged into routes.gate by Reconcile's owned-converge arm
+ // (reconcile.go) and read by the edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md.
Gate string `json:"gate,omitempty"`
}
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index ed0a87d9a..d4c389278 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -582,12 +582,26 @@ func TestReconcileConvergesGate(t *testing.T) {
t.Fatalf("minted Gate = %q, want open", before.Gate)
}
+ // From here on the log is captured: the converge line names the STATE,
+ // never the secret. A bcrypt hash is not a password, but it is the
+ // material an offline cracker works on, and an operations log is copied,
+ // shipped and pasted far more freely than the registry row is.
+ var logged bytes.Buffer
+ log.SetOutput(&logged)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
rec.Gate = hash
putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
if err := Reconcile(ctx, s, reg, cfg); err != nil {
t.Fatal(err)
}
+ if !strings.Contains(logged.String(), "converged acme.go.tito.io gate open -> set") {
+ t.Errorf("no open -> set line logged; log was:\n%s", logged.String())
+ }
+ if strings.Contains(logged.String(), hash) {
+ t.Errorf("the hash reached the log:\n%s", logged.String())
+ }
after := *mustGet(t, reg, "acme.go.tito.io")
if after.Gate != hash {
t.Errorf("gate = %q, want the hash", after.Gate)
@@ -602,12 +616,19 @@ func TestReconcileConvergesGate(t *testing.T) {
// row through SetGate.
rec.Gate = ""
putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ logged.Reset()
if err := Reconcile(ctx, s, reg, cfg); err != nil {
t.Fatal(err)
}
if got := mustGet(t, reg, "acme.go.tito.io").Gate; got != "" {
t.Errorf("gate = %q, want it cleared", got)
}
+ if !strings.Contains(logged.String(), "converged acme.go.tito.io gate set -> open") {
+ t.Errorf("no set -> open line logged; log was:\n%s", logged.String())
+ }
+ if strings.Contains(logged.String(), hash) {
+ t.Errorf("the hash reached the log:\n%s", logged.String())
+ }
}
// An explicit Hibernate converges; an absent one never does. Absent means