carlos / platform Public

console: password gate section on app Settings — set, change, turn off

Adds Task 6 (spec §5.5): a member-visible "Password gate" section on
the app Settings page, one row per instance record showing Open /
Password set, with set/change and turn-off forms. New POST
/a/{sqid}/apps/{app}/gate handler (handleAppGate) mirrors the API
PATCH's read-mutate-validate-write-audit sequence for the single Gate
field, CSRF-checked first. SettingsView gains Gates/GatesUnavailable
(settingsGates lists provision records, never exposing the hash to
the template). The password is never logged, audited, or echoed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 962c560ebaf605dcf62416f4b7357f47aa233d6f parent b4dee57
4 files changed, +255 −0
  • internal/console/appsettings.go +123 −0
  • internal/console/appsettings_gate_test.go +91 −0
  • internal/console/server.go +3 −0
  • internal/console/templates/app_settings.html +38 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index ba6703eab..c6af24e27 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -34,12 +34,15 @@ import (
"log"
"net/http"
"net/url"
+ "os"
"sort"
"strings"
"time"
+ "github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -110,6 +113,20 @@ type SettingsView struct {
// readyRouteHost skips Platform rows, so it still takes the
// first-route branch and still needs somewhere to type a channel.
NeedsChannel bool
+
+ // Gates is the password-gate section (spec §5.5): one row per instance
+ // record, with whether a password is set. GatesUnavailable carries the
+ // reason when the records could not be read (no bucket, a store error)
+ // and the section says so instead of rendering an empty list as "no
+ // instances".
+ Gates []GateRow
+ GatesUnavailable string
+}
+
+// GateRow is one instance on the Settings page's password-gate section.
+type GateRow struct {
+ Host string
+ Gated bool
}
// StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue
@@ -238,6 +255,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
} else {
sv.NeedsChannel = route == ""
}
+ sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName)
if s.cfg.Releases == nil {
sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run."
return sv
@@ -267,6 +285,25 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
return sv
}
+// settingsGates lists the app's instance records for the password-gate
+// section. The hash never reaches the template: GateRow carries a bool.
+func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) {
+ if s.cfg.Releases == nil {
+ return nil, "No deployment bucket on this host, so instance records cannot be read."
+ }
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err)
+ return nil, "Instance records could not be read just now."
+ }
+ rows := make([]GateRow, 0, len(recs))
+ for _, rec := range recs {
+ rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""})
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
+ return rows, ""
+}
+
// FeaturesCard is the Settings page's read of the app's feature flags.
type FeaturesCard struct {
Flags []FeatureFlag // key-sorted
@@ -634,6 +671,92 @@ func renameAdvice(err error, to string) string {
}
}
+// handleAppGate is the Settings page's password-gate form: set a password
+// on one instance host, or turn it off. Member-gated, matching the API
+// PATCH's door — the CLI already lets a member do this, and a form that
+// refuses what the CLI allows is a rule in the wrong place. CSRF first,
+// then the same read-mutate-validate-write-audit sequence as the PATCH,
+// against one field. The password is never logged, audited or echoed.
+func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings",
+ http.StatusSeeOther)
+ }
+ if s.cfg.Releases == nil {
+ back("err", "No deployment bucket on this host, so the gate cannot be changed here.")
+ return
+ }
+ host := strings.TrimSpace(r.FormValue("host"))
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var hash string
+ switch r.FormValue("action") {
+ case "clear":
+ case "set":
+ p := r.FormValue("password")
+ if err := gate.CheckPassword(p); err != nil {
+ back("err", err.Error()+".")
+ return
+ }
+ h, err := gate.Hash(p)
+ if err != nil {
+ log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not set the password — try again.")
+ return
+ }
+ hash = h
+ default:
+ back("err", "Choose set or clear.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, err := provision.GetRecordETag(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: gate read %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not read the instance — try again.")
+ return
+ }
+ rec.Gate = hash
+ if err := provision.ValidateShape(*rec); err != nil {
+ back("err", err.Error()+".")
+ return
+ }
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ back("err", "The instance changed while this request was in flight — try again.")
+ return
+ }
+ log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", "Could not update the instance — try again.")
+ return
+ }
+ if hash == "" {
+ s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", "Password turned off on "+host+".")
+ return
+ }
+ s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", "Password set on "+host+".")
+}
+
// handleAppDelete puts the app in the trash: the deletion-policy marker in
// the bucket, then this console's row — the same order and the same clock
// discipline as the API door (handleAPITrashApp), behind the account-owner
diff --git a/internal/console/appsettings_gate_test.go b/internal/console/appsettings_gate_test.go
new file mode 100644
index 000000000..8b2bd2e89
--- /dev/null
+++ b/internal/console/appsettings_gate_test.go
@@ -0,0 +1,91 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestAppSettingsGateSetAndClear(t *testing.T) {
+ fx, token := instanceFixture(t, "code-settings-gate")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-gate-browser")
+ csrf := sessionToken(t, fx, c)
+
+ // The page shows the host as open, with a form.
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") {
+ t.Fatalf("settings page lacks the gate section: %s", body)
+ }
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ t.Fatalf("set = %d -> %q", status, loc)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(rec.Gate, "$2a$10$") {
+ t.Fatalf("stored gate = %q", rec.Gate)
+ }
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") {
+ t.Fatalf("after set: %s", body)
+ }
+ if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") {
+ t.Fatal("page leaks the hash or the password")
+ }
+
+ // A short password is refused with the rule, and nothing changes.
+ postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}})
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "at least 8 characters") {
+ t.Fatalf("short password not refused on the page: %s", body)
+ }
+
+ status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("clear = %d", status)
+ }
+ rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if rec.Gate != "" {
+ t.Fatalf("gate after clear = %q", rec.Gate)
+ }
+ events, _ := fx.st.Events(OpsAccountID, "titogo", 20)
+ var saw int
+ for _, e := range events {
+ if e.Action == "gate" {
+ saw++
+ if strings.Contains(e.Detail, "correct horse") {
+ t.Fatal("audit carries the password")
+ }
+ }
+ }
+ if saw != 2 {
+ t.Fatalf("gate audit rows = %d, want 2", saw)
+ }
+}
+
+func TestAppSettingsGateNeedsCSRF(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-gate-csrf")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-gate-csrf-browser")
+ status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"),
+ url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}})
+ if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("got %d %s", status, body)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index c5ce9c38f..1a311c2a1 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1068,6 +1068,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// The app Settings writes (appsettings.go): owner-only, and delete/move
// behind passkey step-up besides.
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace)
+ // The password-gate form (appsettings.go's handleAppGate): member-gated,
+ // unlike the owner-only writes above it.
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 5dafd1603..22e788bf8 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -84,6 +84,44 @@ certificate itself and renews it, so nothing has to visit the domain
first.</p>
{{end}}
+<h2>Password gate</h2>
+<p class="small">One shared password in front of a hostname — for a static
+site or a prototype that should not be world-readable. Visitors see a plain
+page asking for it and stay signed in for a week. It is set per instance.</p>
+{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p>
+{{else if .Settings.Gates}}
+<div class="tablewrap">
+<table>
+ <tr><th>Host</th><th>State</th><th></th></tr>
+ {{range $i, $g := .Settings.Gates}}
+ <tr>
+ <td><code>{{$g.Host}}</code></td>
+ <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="set">
+ <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label>
+ <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72"
+ autocomplete="new-password" placeholder="password" required>
+ <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button>
+ </form>
+ {{if $g.Gated}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="clear">
+ <button type="submit" class="btn-sm btn-danger">Turn off</button>
+ </form>
+ {{end}}
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
+
{{if and .Settings .Settings.MismatchTarget}}
<h2>Account</h2>
<p>This app's hosts read releases from account