oficina / fichas Public

Sign-in: magic link, owners as the gate, and the suite shape refusing

Ruling 5's two shapes, with the lone one built. Magic link through
rastrillo's auth; the owners list is the admission predicate, run in three
places through one method rather than a constructor a caller can build
twice — which memoria did build twice, two closures over two copies of one
rule, with a mutation that stayed green because every test made its own.

FICHAS_SHAPE=suite is the intended default and refuses to boot, as its own
named error. It needs the home's identity bridge and roster pull and has
neither; booting into it would mean an admission gate that is really the
owners list while the operator believes it is the home's roster — a roster
invented in the wrong app, which is the thing memoria §2 is careful about.

beginGuard runs before rastrillo's classifier can dial the domain in a
submitted address, which is the SSRF-shaped leak memoria §3.2 carries and
SECURITY-DRAFT-rastrillo-signin.md is the unfiled upstream half of. It
reads the address with FormValue exactly as rastrillo does — memoria's
first version used PostFormValue and added a ParseForm error branch, so an
address in the query string was invisible to the guard and visible to the
thing it guarded. It renders the same outcome for an owner and a stranger,
because a distinct refusal is an owners-list oracle, and that is worse than
the dial: the dial confirms the instance exists, an oracle confirms who
runs it. It is still an oracle after five submissions, for the reason
memoria records — this guard sits in front of the per-address rate limiter
— and that closes upstream or not at all.

The most important thing here is a test rather than code.
TestProductionRefusesAnUnauthenticatedRequest builds the app through App
with no bypass and asserts every route refuses. memoria carried this exact
test-only seam with its safety argument resting on a test that did not
exist: the name had survived a rename and lived on in comments, so deleting
the sign-in wiring compiled, left the suite green, and served everything
unauthenticated. The bypass is set only by AppForTest, which says what it
is doing in its name, and now something fails when it leaks.

FICHAS_DEV_MEMBER is gone. The actor is the signed-in address, resolved to
a Member, and a write path reached without an admitted identity refuses
rather than inventing one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 17078ce7bc899c31bb1a22d6993e0a6284855ac7 parent 9c2d39a
16 files changed, +661 −113
  • README.md +28 −13
  • cmd/fichas/main.go +19 −1
  • go.mod +3 −2
  • go.sum +3 −10
  • internal/fichas/app.go +60 −12
  • internal/fichas/auth.go +196 −0
  • internal/fichas/config.go +70 −19
  • internal/fichas/config_test.go +81 −37
  • internal/fichas/handlers.go +67 −10
  • internal/fichas/render.go +1 −1
  • internal/fichas/templates/card_new.html +1 −1
  • internal/fichas/templates/card_show.html +2 −2
  • internal/fichas/templates/signin.html +19 −0
  • internal/fichastest/gates_test.go +1 −1
  • internal/fichastest/harness_test.go +8 −4
  • internal/fichastest/signin_test.go +102 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading github.com/keymaildev/signin v0.1.1
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.161s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	(cached)
ok  	amadan.net/oficina/fichas/internal/fichastest	0.116s
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/README.md b/README.md
index 92e7f96..29a0e47 100644
--- a/README.md
+++ b/README.md
@@ -109,16 +109,31 @@ carlos deploy --app fichas --account bds --channel edge --timeout 5m \
releases/fichas-linux-arm64
```
-### Before any of that: sign-in
-
-**This app cannot yet name who is writing.** Slice 1 has a surface and no
-sign-in, so `FICHAS_DEV_MEMBER` names one member every write is attributed
-to — and `LoadConfig` **refuses that variable on any origin that is not
-loopback**, with a test for it, because the alternative is an
-unauthenticated CRM on a public host where "who did it" is a constant.
-
-That refusal is the thing standing between this repo and a deploy. The
-event log Paul ruled in records an actor for every change; a deployment
-that cannot tell one person from another records the same answer for
-everyone, which is worse than recording nothing because it looks like an
-answer.
+### Sign-in
+
+Magic link through rastrillo's `auth`, with the owners list as the
+admission gate — the **lone** shape of suite ruling 5. `FICHAS_SHAPE=suite`
+is the intended default and **refuses to boot**: it needs the home's
+identity bridge and roster pull, and booting into it before those exist
+would mean an admission gate that is really the owners list while the
+operator believes it is the home's roster.
+
+```sh
+carlos secrets set --app fichas --account bds \
+ FICHAS_DEK='<32 random bytes, base64>' \
+ FICHAS_INSTANCE_KEY='<any long random string>'
+carlos env set --app fichas --account bds \
+ FICHAS_ORIGIN='https://fichas.oficina.works' \
+ FICHAS_OWNERS='paul@keymail.dev'
+```
+
+`FICHAS_INSTANCE_KEY` seals the pending sign-in blob; `auth.New` refuses an
+empty one, because an empty input hashes to a fixed value identical across
+every deployment. `FICHAS_OWNERS` has no default: an empty admission gate
+on a public hostname admits anyone who can receive mail, so the app refuses
+to start without one.
+
+Mail goes through the platform's sender (`CARLOS_*`). With none configured
+rastrillo substitutes `mail.Logged`, which writes the magic link — a live
+credential — into the process log; `FICHAS_ALLOW_LOGGED_MAIL=1` opts into
+that and is refused on any origin that is not loopback.
diff --git a/cmd/fichas/main.go b/cmd/fichas/main.go
index f892d10..2087097 100644
--- a/cmd/fichas/main.go
+++ b/cmd/fichas/main.go
@@ -8,6 +8,7 @@ import (
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/mail"
fichas "amadan.net/oficina/fichas/internal/fichas"
)
@@ -50,12 +51,29 @@ func main() {
logger.Warn("FICHAS_ORIGIN not set; defaulting", "origin", cfg.Origin)
}
- mux, err := fichas.App(d, cfg, logger)
+ mux, app, err := fichas.App(d, cfg, logger)
if err != nil {
logger.Error("build app", "err", err)
os.Exit(1)
}
+ // Wire the mailer BEFORE auth. Without a real sender rastrillo
+ // substitutes mail.Logged, which writes the magic link — a live
+ // credential — into the process log; LoadConfig refuses to boot in
+ // that state unless FICHAS_ALLOW_LOGGED_MAIL says so out loud. But
+ // refusing was only half of memoria's fix: nothing was building a
+ // real sender either. "CARLOS" is the env prefix the suite uses.
+ au, err := fichas.NewAuth(d.Writer(), fichas.AuthEnv{
+ Origin: cfg.Origin,
+ InstanceKey: cfg.InstanceKey,
+ Mailer: mail.FromEnv("CARLOS", logger),
+ }, logger, app.Admit)
+ if err != nil {
+ logger.Error("wire sign-in", "err", err)
+ os.Exit(1)
+ }
+ app.UseAuth(au)
+
opts.Mux = mux
opts.DBPath = ""
// The same page a handler's own error path renders (render.go's
diff --git a/go.mod b/go.mod
index 0913943..23e13e5 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,8 @@ require (
github.com/carlosframework/rastrillo v0.23.0
github.com/chromedp/chromedp v0.14.2
github.com/go-chi/chi/v5 v5.3.2
+ golang.org/x/crypto v0.55.0
+ gorm.io/gorm v1.31.2
)
require (
@@ -22,13 +24,12 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+ github.com/keymaildev/signin v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
- gorm.io/gorm v1.31.2 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 9e6296c..715fc32 100644
--- a/go.sum
+++ b/go.sum
@@ -48,24 +48,17 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
-golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
-golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
-golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/internal/fichas/app.go b/internal/fichas/app.go
index 536293f..d66e8ce 100644
--- a/internal/fichas/app.go
+++ b/internal/fichas/app.go
@@ -9,6 +9,7 @@ import (
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/mail"
"github.com/carlosframework/rastrillo/migrate"
)
@@ -24,7 +25,7 @@ import (
// rastrillo repo is the worked example to copy). Adding a subsystem
// also means adding its Schema to migrations.go's BootSchema — see
// the comment there.
-func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
+func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, *app, error) {
// Apply BootSchema, not Schema: BootSchema is everything this
// app needs at boot (its own migrations plus any subsystem's),
// while Schema (migrations.go) stays just this app's own so the
@@ -32,7 +33,7 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
// to drop them.
migrated, err := migrate.Apply(context.Background(), d, BootSchema)
if err != nil {
- return nil, err
+ return nil, nil, err
}
// One line per boot. Result exists so this can be logged, and
// until something does, a fleet-wide adoption — every database
@@ -47,25 +48,43 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
// migration that wrote labels in the clear would put the catalogue's
// words in the durable file — which is the whole thing §4.3 forbids.
if err := Seed(d.G, cfg.Sealer); err != nil {
- return nil, err
+ return nil, nil, err
}
a := &app{db: d, sealer: cfg.Sealer, cfg: cfg, logger: logger}
+ a.owners = cfg.Owners
+
r := chi.NewRouter()
// App-wide, from day one: every state-changing route added below
// is born covered. Origin-checking, not tokens — nothing to mint
// or forget in a form.
r.Use(csrf.Protect(cfg.Origin))
- r.Get("/", a.index)
- r.Get("/cards/new", a.newCard)
- r.Post("/cards", a.createCard)
- r.Get("/cards/{id}", a.showCard)
- r.Post("/cards/{id}", a.updateCard)
- r.Post("/cards/{id}/entries", a.addEntry)
- // A destructive action gets its own URL, never a modal (AGENTS.md).
- r.Get("/cards/{id}/delete", a.confirmDelete)
- r.Post("/cards/{id}/delete", a.deleteCard)
+
+ // Sign-in is outside the session guard, necessarily: it is how a
+ // session is obtained. beginGuard runs before rastrillo's classifier
+ // can dial a domain the visitor chose.
+ r.Get(signinPath, a.signin)
+ r.Post("/auth/begin", a.beginGuard(a.authBegin))
+ r.Get("/auth/callback", a.authCallback)
+ r.Get("/auth/verify", a.authVerify)
+ r.Post("/signout", a.authSignout)
+
+ // Everything else requires a session AND admission. Grouped rather
+ // than wrapped per route: a route added below is born guarded, which
+ // is the property memoria lost when a seam let one through.
+ r.Group(func(r chi.Router) {
+ r.Use(a.requireSession)
+ r.Get("/", a.index)
+ r.Get("/cards/new", a.newCard)
+ r.Post("/cards", a.createCard)
+ r.Get("/cards/{id}", a.showCard)
+ r.Post("/cards/{id}", a.updateCard)
+ r.Post("/cards/{id}/entries", a.addEntry)
+ // A destructive action gets its own URL, never a modal.
+ r.Get("/cards/{id}/delete", a.confirmDelete)
+ r.Post("/cards/{id}/delete", a.deleteCard)
+ })
mux := http.NewServeMux()
// The app serves its own static files — the framework never does.
@@ -74,5 +93,34 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
// handler serves cacheable-forever.
mux.Handle("GET /static/", assets.Handler())
mux.Handle("/", r)
+ return mux, a, nil
+}
+
+// AppForTest builds the app already signed in as one address.
+//
+// Named for what it does, and the naming is the point. memoria's earlier
+// seam was a nil-auth fall-through inside requireSession whose safety
+// argument rested on a test that did not exist, so deleting the sign-in
+// wiring compiled and left every route open while the suite stayed green.
+// A test that wants an unauthenticated app has to ask for one by a name
+// nobody will mistake for production wiring.
+func AppForTest(d *db.DB, cfg Config, logger *slog.Logger, as string) (*http.ServeMux, error) {
+ mux, a, err := App(d, cfg, logger)
+ if err != nil {
+ return nil, err
+ }
+ a.testIdentity = normaliseAddress(as)
+ a.allowUnauthenticated = true
+ // requireSession still refuses a nil auth, so the test wiring has to
+ // supply one rather than route around the guard.
+ au, err := NewAuth(d.Writer(), AuthEnv{
+ Origin: cfg.Origin,
+ InstanceKey: cfg.InstanceKey,
+ Mailer: mail.Logged(logger),
+ }, logger, a.Admit)
+ if err != nil {
+ return nil, err
+ }
+ a.UseAuth(au)
return mux, nil
}
diff --git a/internal/fichas/auth.go b/internal/fichas/auth.go
new file mode 100644
index 0000000..b612cd8
--- /dev/null
+++ b/internal/fichas/auth.go
@@ -0,0 +1,196 @@
+package fichas
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/mail"
+)
+
+// signinPath is where every sign-in outcome lands. It must match the
+// SigninPath given to auth.New, because beginGuard redirects here itself:
+// if the two disagree the guard becomes distinguishable from the real
+// thing by its destination alone.
+const signinPath = "/signin"
+
+// ParseOwners splits a comma-separated allowlist, lowercasing and
+// trimming.
+//
+// In the LONE shape this list is the whole admitted set. In the SUITE
+// shape (ruling 5) the home's roster is, and this list answers the
+// narrower question of who operates the instance — memoria §2 is careful
+// that those are two different things and that conflating them is how a
+// roster gets invented in the wrong app.
+func ParseOwners(raw string) []string {
+ var out []string
+ for _, part := range strings.Split(raw, ",") {
+ if addr := normaliseAddress(part); addr != "" {
+ out = append(out, addr)
+ }
+ }
+ return out
+}
+
+// normaliseAddress is the one spelling rule, and everything that compares
+// an address goes through it. A guard that spells an address differently
+// from the thing it guards is not guarding it.
+//
+// Addresses are case-sensitive before the @ per RFC 5321 and no mainstream
+// provider honours that, so this matches what an operator expects when
+// they type a list.
+func normaliseAddress(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
+
+// Admit is THE admission predicate: the one rastrillo runs at
+// verification, the one beginGuard runs before the classifier dials, and
+// the one requireSession runs per request.
+//
+// A method rather than a constructor over a list, because a constructor
+// gives a caller something to build twice — and in memoria it WAS built
+// twice, two closures over two copies guarding one rule, with a mutation
+// that stayed green because every test built its own.
+func (a *app) Admit(address string) bool {
+ addr := normaliseAddress(address)
+ if addr == "" {
+ return false
+ }
+ for _, o := range a.owners {
+ if o == addr {
+ return true
+ }
+ }
+ return false
+}
+
+// beginGuard stops an anonymous visitor making this instance dial a domain
+// of their choosing.
+//
+// rastrillo's sign-in classifier fetches
+// https://<the submitted address's domain>/.well-known/keymail inside
+// auth.Begin, BEFORE the Authorize callback runs. So POST /auth/begin with
+// any address makes fichas issue an outbound request to any host, leaking
+// the instance's existence, its egress address and a timing channel to
+// anyone who can reach the sign-in page. memoria §3.2 carries the same
+// entry and `SECURITY-DRAFT-rastrillo-signin.md` is the upstream half,
+// still unfiled.
+//
+// It renders the SAME outcome as a successful send — same status, same
+// destination — because a distinct rejection would turn this guard into an
+// owners-list oracle, which is worse than the dial: the dial confirms the
+// instance exists, an oracle confirms who runs it.
+//
+// **It is still an oracle after five submissions**, and memoria records
+// why: this guard sits in front of rastrillo's per-address rate limiter,
+// so a refused address never consumes budget while an admitted one does.
+// Six tries at an owner's address yields a rate-limit error, six at a
+// stranger's yields the same cheerful page every time. Closing that means
+// fixing the ordering upstream, which is the same fix the dial needs.
+func (a *app) beginGuard(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ // Fail closed. An empty owners list is a wiring bug — LoadConfig
+ // refuses to boot without one — and the version of this that
+ // defaults to allowing is the version that dials for everyone
+ // while looking configured.
+ if len(a.owners) == 0 {
+ a.logger.Error("no owners configured; refusing sign-in")
+ http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther)
+ return
+ }
+ // Read the address EXACTLY as rastrillo does: FormValue, not
+ // PostFormValue, and no ParseForm error branch. memoria's first
+ // version got both wrong in one line — ParseForm reports a
+ // query-string error even when the body parsed, while rastrillo's
+ // FormValue ignores it, so `POST /auth/begin?a;b=1` skipped the
+ // check and dialled.
+ if !a.Admit(r.FormValue("address")) {
+ // No address in the log line, ever.
+ a.logger.Info("sign-in refused before classifier dial")
+ http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther)
+ return
+ }
+ next(w, r)
+ }
+}
+
+// AuthEnv is the sign-in configuration read from platform config.
+type AuthEnv struct {
+ Origin string
+ // InstanceKey seals auth's pending blob. auth.New refuses an empty
+ // one, and rightly: an empty input hashes to a fixed, publicly
+ // computable value identical across every deployment.
+ InstanceKey string
+ Mailer mail.Sender
+}
+
+// NewAuth wires the sign-in flow. Exactly one per process: auth carries
+// rate-limiter state on the value, so one per request would get a fresh
+// empty budget every time and rate-limit nothing, silently.
+func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string) bool) (*auth.Auth, error) {
+ if authorize == nil {
+ // A nil Authorize admits every verified address, which on a
+ // public hostname means anyone who can receive mail. Refusing
+ // beats defaulting: a caller that forgot to pass one has a bug
+ // either way, and this is the version that says so.
+ return nil, errors.New("sign-in: refusing to wire auth with no admission gate")
+ }
+ return auth.New(auth.Config{
+ DB: sqlDB,
+ Origin: env.Origin,
+ InstanceKey: env.InstanceKey,
+ Mailer: env.Mailer,
+ Authorize: authorize,
+ SigninPath: signinPath,
+ SignedInPath: "/",
+ Subject: "Your fichas sign-in link",
+ Logger: logger,
+ })
+}
+
+// identity is the signed-in address, or false.
+func (a *app) identity(r *http.Request) (string, bool) {
+ if a.testIdentity != "" {
+ return a.testIdentity, true
+ }
+ id, ok := auth.From(r)
+ if !ok {
+ return "", false
+ }
+ return normaliseAddress(id.Address), true
+}
+
+// requireSession guards every route that is not sign-in itself, and it
+// FAILS CLOSED.
+//
+// memoria's version once fell through when auth was nil so tests could
+// reach routes without a mailer, and the safety argument rested on a test
+// that did not exist — the name had survived a rename and lived on only in
+// comments. Deleting the wiring compiled, left the suite green, and served
+// every route unauthenticated. There is no fall-through here: a test that
+// wants a signed-in app says so by name.
+func (a *app) requireSession(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if a.allowUnauthenticated {
+ next.ServeHTTP(w, r)
+ return
+ }
+ if a.auth == nil {
+ a.logger.Error("no sign-in configured; refusing every request")
+ http.Error(w, "sign-in is not configured", http.StatusInternalServerError)
+ return
+ }
+ guarded := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ address, ok := a.identity(r)
+ if !ok || !a.Admit(address) {
+ // 404, not 403: a route you may not use is a route that
+ // is not there.
+ http.NotFound(w, r)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ a.auth.RequireSession(guarded).ServeHTTP(w, r)
+ })
+}
diff --git a/internal/fichas/config.go b/internal/fichas/config.go
index 807250f..962e6c0 100644
--- a/internal/fichas/config.go
+++ b/internal/fichas/config.go
@@ -21,17 +21,23 @@ import (
type Config struct {
Origin string
Sealer *seal.Sealer
- // DevMember makes every write in this process act as one named
- // member, because slice 1 has a surface and no sign-in yet.
- //
- // **It is a local-development opt-in and LoadConfig refuses it on any
- // other origin.** Without that refusal the first deploy would put an
- // unauthenticated CRM on a public host, where "who did it" is a
- // constant and the event log — the thing Paul ruled in — records the
- // same answer for everyone. memoria's ALLOW_LOGGED_MAIL is the same
- // shape of guard for the same reason: the dangerous default has to be
- // asked for out loud.
- DevMember string
+ // InstanceKey seals auth's pending blob. auth.New refuses an empty
+ // one, and rightly: an empty input hashes to a fixed, publicly
+ // computable value identical across every deployment.
+ InstanceKey string
+ // Owners is the admission list. In the LONE shape it is the whole
+ // admitted set; in the SUITE shape it answers the narrower question
+ // of who operates the instance (ruling 5, memoria §2).
+ Owners []string
+ // Shape is "lone" or "suite" — ruling 5's one switch. Suite is the
+ // intended default; see ErrSuiteNotBuilt.
+ Shape string
+ // AllowLoggedMail permits booting with no SMTP, which makes rastrillo
+ // substitute mail.Logged — and mail.Logged writes the magic link, a
+ // live credential, into the process log. Platform log storage is not
+ // something we can grep and retract, so that is a credential we
+ // cannot take back. Opt in explicitly, for local development.
+ AllowLoggedMail bool
}
// ErrMissing is returned when required platform config is absent.
@@ -71,15 +77,64 @@ func LoadConfig(getenv func(string) string) (Config, error) {
c.Origin = defaultOrigin
}
- c.DevMember = getenv("FICHAS_DEV_MEMBER")
- if c.DevMember != "" && !isLocal(c.Origin) {
+ c.InstanceKey = getenv("FICHAS_INSTANCE_KEY")
+ if c.InstanceKey == "" {
+ return Config{}, fmt.Errorf("%w: FICHAS_INSTANCE_KEY", ErrMissing)
+ }
+
+ // There is no default owner list. An empty admission gate on a public
+ // hostname admits anyone who can receive mail, so the app refuses to
+ // start rather than serving in that state.
+ c.Owners = ParseOwners(getenv("FICHAS_OWNERS"))
+ if len(c.Owners) == 0 {
+ return Config{}, fmt.Errorf("%w: FICHAS_OWNERS", ErrMissing)
+ }
+
+ c.AllowLoggedMail = getenv("FICHAS_ALLOW_LOGGED_MAIL") == "1"
+ if c.AllowLoggedMail && !isLocal(c.Origin) {
return Config{}, fmt.Errorf(
- "fichas: FICHAS_DEV_MEMBER makes every write act as one person and is local-development only, "+
- "but FICHAS_ORIGIN is %s; sign-in has to land before this app serves anyone else", c.Origin)
+ "fichas: FICHAS_ALLOW_LOGGED_MAIL writes a live sign-in credential into the process log "+
+ "and is local-development only, but FICHAS_ORIGIN is %s", c.Origin)
+ }
+
+ c.Shape = strings.ToLower(strings.TrimSpace(getenv("FICHAS_SHAPE")))
+ if c.Shape == "" {
+ c.Shape = ShapeLone
+ }
+ switch c.Shape {
+ case ShapeLone:
+ case ShapeSuite:
+ // Ruling 5: a suite instance takes identity AND membership from
+ // the home, and never one without the other. fichas has neither
+ // yet. Refusing is the honest answer — the alternative is booting
+ // in a shape whose admission gate is the owners list while the
+ // operator believes it is the home's roster, which is a roster
+ // invented in the wrong app.
+ return Config{}, ErrSuiteNotBuilt
+ default:
+ return Config{}, fmt.Errorf("fichas: FICHAS_SHAPE is %q; it is %q or %q", c.Shape, ShapeLone, ShapeSuite)
}
return c, nil
}
+// The two shapes, ruling 5. "Membership authority follows identity
+// authority; an instance never has one without the other."
+const (
+ ShapeLone = "lone"
+ ShapeSuite = "suite"
+)
+
+// ErrSuiteNotBuilt is returned for FICHAS_SHAPE=suite.
+//
+// The suite shape is the intended default — Paul, 2026-09-21 — and it is
+// specified rather than built, the way the suite's own S6 is. It needs the
+// home's identity bridge and its roster pull (suite call 8), and neither
+// exists in this app. Named as its own error so the day it lands there is
+// one place to delete.
+var ErrSuiteNotBuilt = errors.New(
+ "fichas: FICHAS_SHAPE=suite is the intended default and is not built yet — " +
+ "it needs the home's identity bridge and roster pull; run lone for now")
+
// isLocal reports whether an origin is a loopback address. Deliberately
// strict: a hostname that merely looks local is not one.
func isLocal(origin string) bool {
@@ -91,10 +146,6 @@ func isLocal(origin string) bool {
return origin == "http://127.0.0.1" || origin == "http://localhost"
}
-// WritesAreAnonymous reports whether this process has no way to name a
-// person, which is every deployment until sign-in lands.
-func (c Config) WritesAreAnonymous() bool { return c.DevMember == "" }
-
// UsedDefaultOrigin reports whether Origin fell back, so main can say so
// out loud. A silently defaulted origin is a CSRF yardstick pointing at
// the wrong host.
diff --git a/internal/fichas/config_test.go b/internal/fichas/config_test.go
index 512baf2..0396a14 100644
--- a/internal/fichas/config_test.go
+++ b/internal/fichas/config_test.go
@@ -7,9 +7,27 @@ import (
)
func env(m map[string]string) func(string) string {
- return func(k string) string { return m[k] }
+ // Every test here supplies the required set unless it is the thing
+ // under test, so a new required variable does not silently rewrite
+ // what the other cases are asserting.
+ full := map[string]string{
+ "FICHAS_DEK": goodDEK(),
+ "FICHAS_INSTANCE_KEY": "k",
+ "FICHAS_OWNERS": "ana@example.com",
+ }
+ for k, v := range m {
+ if v == "" {
+ delete(full, k)
+ continue
+ }
+ full[k] = v
+ }
+ return func(k string) string { return full[k] }
}
+// unset marks a variable as deliberately absent.
+const unset = ""
+
func goodDEK() string {
return base64.StdEncoding.EncodeToString(make([]byte, 32))
}
@@ -17,7 +35,7 @@ func goodDEK() string {
// The refusal that matters most: no DEK, no boot. A generated fallback
// would seal this boot's cards under a key the next boot cannot derive.
func TestLoadConfigRefusesAMissingDEK(t *testing.T) {
- _, err := LoadConfig(env(map[string]string{}))
+ _, err := LoadConfig(env(map[string]string{"FICHAS_DEK": unset}))
if !errors.Is(err, ErrMissing) {
t.Fatalf("booting with no FICHAS_DEK returned %v, want ErrMissing", err)
}
@@ -65,47 +83,73 @@ func TestLoadConfigTakesTheOriginWhenSet(t *testing.T) {
}
}
-// The guard that stops the first deploy being an unauthenticated CRM.
-func TestDevMemberIsRefusedOffLocalhost(t *testing.T) {
- _, err := LoadConfig(env(map[string]string{
- "FICHAS_DEK": goodDEK(),
- "FICHAS_ORIGIN": "https://fichas.oficina.works",
- "FICHAS_DEV_MEMBER": "ana@example.com",
- }))
- if err == nil {
- t.Fatal("FICHAS_DEV_MEMBER was accepted on a public origin")
+// No owners is an empty admission gate, which on a public hostname admits
+// anyone who can receive mail. Refusing to start beats serving in that
+// state.
+func TestLoadConfigRefusesAnEmptyOwnerList(t *testing.T) {
+ if _, err := LoadConfig(env(map[string]string{"FICHAS_OWNERS": unset})); !errors.Is(err, ErrMissing) {
+ t.Fatalf("an empty FICHAS_OWNERS returned %v, want ErrMissing", err)
}
}
-func TestDevMemberIsAllowedOnLocalhost(t *testing.T) {
- for _, origin := range []string{"http://127.0.0.1:8824", "http://localhost:8824"} {
- c, err := LoadConfig(env(map[string]string{
- "FICHAS_DEK": goodDEK(),
- "FICHAS_ORIGIN": origin,
- "FICHAS_DEV_MEMBER": "ana@example.com",
- }))
- if err != nil {
- t.Fatalf("%s: %v", origin, err)
- }
- if c.WritesAreAnonymous() {
- t.Fatalf("%s: a dev member was set but writes still report as anonymous", origin)
- }
+// auth.New refuses an empty instance key, and rightly: an empty input
+// hashes to a fixed, publicly computable value identical across every
+// deployment.
+func TestLoadConfigRefusesAnEmptyInstanceKey(t *testing.T) {
+ if _, err := LoadConfig(env(map[string]string{"FICHAS_INSTANCE_KEY": unset})); !errors.Is(err, ErrMissing) {
+ t.Fatalf("an empty FICHAS_INSTANCE_KEY returned %v, want ErrMissing", err)
}
}
-// A hostname that merely looks local is not local.
-func TestOriginsThatOnlyLookLocalAreRefused(t *testing.T) {
- for _, origin := range []string{
- "https://localhost.example.com",
- "http://127.0.0.1.example.com",
- "http://notlocalhost:8824",
- } {
- if _, err := LoadConfig(env(map[string]string{
- "FICHAS_DEK": goodDEK(),
- "FICHAS_ORIGIN": origin,
- "FICHAS_DEV_MEMBER": "ana@example.com",
- })); err == nil {
- t.Errorf("%s was treated as local", origin)
+func TestOwnersAreNormalised(t *testing.T) {
+ c, err := LoadConfig(env(map[string]string{"FICHAS_OWNERS": " Ana@Example.com , BOB@example.com ,, "}))
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+ want := []string{"ana@example.com", "bob@example.com"}
+ if len(c.Owners) != len(want) {
+ t.Fatalf("owners are %q", c.Owners)
+ }
+ for i := range want {
+ if c.Owners[i] != want[i] {
+ t.Fatalf("owners are %q, want %q", c.Owners, want)
}
}
}
+
+// mail.Logged writes the magic link — a live credential — into the process
+// log, and platform log storage is not something we can grep and retract.
+func TestLoggedMailIsRefusedOffLocalhost(t *testing.T) {
+ if _, err := LoadConfig(env(map[string]string{
+ "FICHAS_ORIGIN": "https://fichas.oficina.works",
+ "FICHAS_ALLOW_LOGGED_MAIL": "1",
+ })); err == nil {
+ t.Fatal("FICHAS_ALLOW_LOGGED_MAIL was accepted on a public origin")
+ }
+}
+
+// Ruling 5's two shapes. Suite is the intended default and is not built;
+// booting into it silently would mean an admission gate that is the owners
+// list while the operator believes it is the home's roster.
+func TestTheSuiteShapeRefusesRatherThanPretending(t *testing.T) {
+ _, err := LoadConfig(env(map[string]string{"FICHAS_SHAPE": "suite"}))
+ if !errors.Is(err, ErrSuiteNotBuilt) {
+ t.Fatalf("FICHAS_SHAPE=suite returned %v, want ErrSuiteNotBuilt", err)
+ }
+}
+
+func TestTheDefaultShapeIsLone(t *testing.T) {
+ c, err := LoadConfig(env(map[string]string{}))
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+ if c.Shape != ShapeLone {
+ t.Fatalf("the default shape is %q", c.Shape)
+ }
+}
+
+func TestAnUnknownShapeIsRefused(t *testing.T) {
+ if _, err := LoadConfig(env(map[string]string{"FICHAS_SHAPE": "federated"})); err == nil {
+ t.Fatal("an unknown shape was accepted")
+ }
+}
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go
index 91c02d5..f68a64f 100644
--- a/internal/fichas/handlers.go
+++ b/internal/fichas/handlers.go
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
+ "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/db"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
@@ -22,9 +23,22 @@ type app struct {
// the compiler will catch.
sealer *seal.Sealer
cfg Config
- logger *slog.Logger
+ auth *auth.Auth
+ owners []string
+ // testIdentity and allowUnauthenticated let a test act as a person
+ // without a magic-link round trip. **Only AppForTest sets them**, and
+ // TestProductionRefusesAnUnauthenticatedRequest is what stops that
+ // seam becoming a hole — memoria shipped this exact bypass with its
+ // safety argument resting on a test that did not exist.
+ testIdentity string
+ allowUnauthenticated bool
+ logger *slog.Logger
}
+// UseAuth attaches the sign-in flow. Called by main after the mailer
+// exists; without it requireSession refuses every request.
+func (a *app) UseAuth(au *auth.Auth) { a.auth = au }
+
// actor names who is making a change.
//
// Slice 1 has no sign-in, so there are exactly two answers and neither is
@@ -34,16 +48,62 @@ type app struct {
// every row a person typed, which is the attribution failure suite ruling
// 9 is about.
func (a *app) actor(r *http.Request) (Actor, error) {
- if a.cfg.DevMember == "" {
- return Actor{}, errors.New("fichas: this instance cannot name who is writing; sign-in has not landed yet")
+ address, ok := a.identity(r)
+ if !ok || !a.Admit(address) {
+ // requireSession already refused this request, so reaching here
+ // means a write path was mounted outside the guard. Refuse
+ // loudly rather than inventing an actor.
+ return Actor{}, errors.New("fichas: no admitted identity on a write path")
}
- m, err := memberByAddress(r.Context(), a.db.G, a.sealer, a.cfg.DevMember)
+ m, err := memberByAddress(r.Context(), a.db.G, a.sealer, address)
if err != nil {
return Actor{}, err
}
return Person(m.ID), nil
}
+// The sign-in handlers are thin because rastrillo's auth owns the flow;
+// they exist so the router names methods on the app and a nil auth is
+// refused in one place rather than panicking in six.
+func (a *app) signin(w http.ResponseWriter, r *http.Request) {
+ render(w, "signin", map[string]any{
+ "Sent": r.URL.Query().Get("sent") == "1",
+ "Error": r.URL.Query().Get("err"),
+ })
+}
+
+func (a *app) authBegin(w http.ResponseWriter, r *http.Request) { a.authOr500(w, r, authBegin) }
+func (a *app) authCallback(w http.ResponseWriter, r *http.Request) { a.authOr500(w, r, authCallback) }
+func (a *app) authVerify(w http.ResponseWriter, r *http.Request) { a.authOr500(w, r, authVerify) }
+func (a *app) authSignout(w http.ResponseWriter, r *http.Request) { a.authOr500(w, r, authSignout) }
+
+type authRoute int
+
+const (
+ authBegin authRoute = iota
+ authCallback
+ authVerify
+ authSignout
+)
+
+func (a *app) authOr500(w http.ResponseWriter, r *http.Request, which authRoute) {
+ if a.auth == nil {
+ a.logger.Error("no sign-in configured")
+ http.Error(w, "sign-in is not configured", http.StatusInternalServerError)
+ return
+ }
+ switch which {
+ case authBegin:
+ a.auth.Begin(w, r)
+ case authCallback:
+ a.auth.Callback(w, r)
+ case authVerify:
+ a.auth.Verify(w, r)
+ case authSignout:
+ a.auth.Signout(w, r)
+ }
+}
+
func (a *app) index(w http.ResponseWriter, r *http.Request) {
cards, err := ListCards(r.Context(), a.db.G, a.sealer, TypeCustomer)
if err != nil {
@@ -51,8 +111,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
return
}
render(w, "index", map[string]any{
- "Cards": cards,
- "Anonymous": a.cfg.WritesAreAnonymous(),
+ "Cards": cards,
})
}
@@ -63,8 +122,7 @@ func (a *app) newCard(w http.ResponseWriter, r *http.Request) {
return
}
render(w, "card_new", map[string]any{
- "Fields": fields,
- "Anonymous": a.cfg.WritesAreAnonymous(),
+ "Fields": fields,
})
}
@@ -105,7 +163,6 @@ func (a *app) showCard(w http.ResponseWriter, r *http.Request) {
render(w, "card_show", map[string]any{
"Card": card,
"EntryFields": entryFields,
- "Anonymous": a.cfg.WritesAreAnonymous(),
})
}
@@ -168,7 +225,7 @@ func (a *app) confirmDelete(w http.ResponseWriter, r *http.Request) {
a.notFoundOrFail(w, r, err)
return
}
- render(w, "card_delete", map[string]any{"Card": card, "Anonymous": a.cfg.WritesAreAnonymous()})
+ render(w, "card_delete", map[string]any{"Card": card})
}
func (a *app) deleteCard(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index a1f32ab..c30ce8a 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -26,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS)
var pages = map[string]*template.Template{}
func init() {
- for _, name := range []string{"index", "errors", "card_new", "card_show", "card_delete"} {
+ for _, name := range []string{"index", "errors", "card_new", "card_show", "card_delete", "signin"} {
pages[name] = template.Must(
// ui's partials and their helpers first, so every page can
// call one without registering anything. WithIcons points
diff --git a/internal/fichas/templates/card_new.html b/internal/fichas/templates/card_new.html
index 5dae001..dc402c3 100644
--- a/internal/fichas/templates/card_new.html
+++ b/internal/fichas/templates/card_new.html
@@ -15,6 +15,6 @@
"Autofocus" (eq $i 0))}}
{{end}}
{{end}}
- {{template "form-foot" (dict "SubmitLabel" "Save this customer" "CancelHref" "/")}}
+ {{template "form-foot" (dict "Submit" "Save this customer" "CancelHref" "/" "CancelLabel" "Cancel")}}
</form>
{{end}}
diff --git a/internal/fichas/templates/card_show.html b/internal/fichas/templates/card_show.html
index 72cb59e..0c43ed1 100644
--- a/internal/fichas/templates/card_show.html
+++ b/internal/fichas/templates/card_show.html
@@ -15,7 +15,7 @@
{{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
{{end}}
{{end}}
- {{template "form-foot" (dict "SubmitLabel" "Save changes" "CancelHref" "/")}}
+ {{template "form-foot" (dict "Submit" "Save changes" "CancelHref" "/" "CancelLabel" "Cancel")}}
</form>
<h2>Call log</h2>
@@ -30,7 +30,7 @@
{{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label)}}
{{end}}
{{end}}
- {{template "form-foot" (dict "SubmitLabel" "Log this call")}}
+ {{template "form-foot" (dict "Submit" "Log this call")}}
</form>
{{if $c.Entries}}
diff --git a/internal/fichas/templates/signin.html b/internal/fichas/templates/signin.html
new file mode 100644
index 0000000..cbc315e
--- /dev/null
+++ b/internal/fichas/templates/signin.html
@@ -0,0 +1,19 @@
+{{define "content"}}
+{{template "page-header" (dict "Title" "Sign in to fichas")}}
+
+{{if .Sent}}
+ {{template "callout" (dict
+ "Tone" "positive"
+ "Title" "Check your email"
+ "Body" "If that address can sign in here, a link is on its way. The link works once and expires.")}}
+{{else}}
+ {{if .Error}}{{template "callout" (dict "Tone" "negative" "Title" "That did not work" "Body" "Try again in a moment.")}}{{end}}
+ <form method="post" action="/auth/begin">
+ {{template "field" (dict
+ "ID" "address" "Name" "address" "Label" "Your email address"
+ "Type" "email" "Autocomplete" "email" "Required" true "Autofocus" true
+ "Help" "Only people this instance already knows can sign in.")}}
+ {{template "form-foot" (dict "Submit" "Email me a link")}}
+ </form>
+{{end}}
+{{end}}
diff --git a/internal/fichastest/gates_test.go b/internal/fichastest/gates_test.go
index 7f1539b..8a77913 100644
--- a/internal/fichastest/gates_test.go
+++ b/internal/fichastest/gates_test.go
@@ -85,7 +85,7 @@ func TestPlaintextNeverReachesTheDatabase(t *testing.T) {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if _, err := fichas.App(d, testConfig(t), logger); err != nil {
+ if _, err := fichas.AppForTest(d, testConfig(t), logger, TestOwner); err != nil {
t.Fatalf("App: %v", err)
}
diff --git a/internal/fichastest/harness_test.go b/internal/fichastest/harness_test.go
index 4a19d51..23161e2 100644
--- a/internal/fichastest/harness_test.go
+++ b/internal/fichastest/harness_test.go
@@ -27,6 +27,8 @@ import (
// same-origin evidence — the pair a browser form submission would be.
// testOrigin is loopback so the local-development member is admitted;
// LoadConfig refuses it on any other origin.
+const TestOwner = "ana@example.com"
+
const testOrigin = "http://127.0.0.1:8824"
// testSealer is a fixed DEK. Fixed rather than random so that a failure
@@ -50,8 +52,10 @@ func testConfig(t *testing.T) fichas.Config {
return base64.StdEncoding.EncodeToString(testKey())
case "FICHAS_ORIGIN":
return testOrigin
- case "FICHAS_DEV_MEMBER":
- return "ana@example.com"
+ case "FICHAS_INSTANCE_KEY":
+ return "test-instance-key"
+ case "FICHAS_OWNERS":
+ return TestOwner
}
return ""
})
@@ -79,9 +83,9 @@ func newApp(t *testing.T) http.Handler {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
- mux, err := fichas.App(d, testConfig(t), logger)
+ mux, err := fichas.AppForTest(d, testConfig(t), logger, TestOwner)
if err != nil {
- t.Fatalf("App: %v", err)
+ t.Fatalf("AppForTest: %v", err)
}
return mux
}
diff --git a/internal/fichastest/signin_test.go b/internal/fichastest/signin_test.go
new file mode 100644
index 0000000..ca78c5a
--- /dev/null
+++ b/internal/fichastest/signin_test.go
@@ -0,0 +1,102 @@
+package fichastest
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ fichas "amadan.net/oficina/fichas/internal/fichas"
+ "github.com/carlosframework/rastrillo/db"
+)
+
+// productionApp wires the app the way main.go does — no test identity, no
+// bypass.
+func productionApp(t *testing.T) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, _, err := fichas.App(d, testConfig(t), logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ return mux
+}
+
+// THE test memoria did not have.
+//
+// Its requireSession carried a nil-auth fall-through so tests could reach
+// routes without a mailer, and the safety argument rested on a test named
+// TestProductionRefusesToStartWithoutSignin that did not exist — the name
+// had survived a rename and lived on only in comments. Deleting the
+// sign-in wiring compiled, left the suite green, and served every route
+// unauthenticated.
+//
+// So: an app built through App, with no bypass, must refuse everything.
+func TestProductionRefusesAnUnauthenticatedRequest(t *testing.T) {
+ h := productionApp(t)
+
+ for _, p := range []string{"/", "/cards/new", "/cards/1"} {
+ rec := get(t, h, p)
+ if rec.Code == http.StatusOK {
+ t.Errorf("GET %s served an unauthenticated request:\n%s", p, rec.Body.String())
+ }
+ }
+ for _, p := range []string{"/cards", "/cards/1", "/cards/1/entries", "/cards/1/delete"} {
+ if rec := post(t, h, p, url.Values{"f_name": {"x"}}); rec.Code == http.StatusSeeOther {
+ t.Errorf("POST %s was accepted without a session", p)
+ }
+ }
+}
+
+// Sign-in itself must be reachable, or nobody can ever get in.
+func TestSigninPageIsReachableWithoutASession(t *testing.T) {
+ rec := get(t, productionApp(t), "/signin")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /signin: status %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Email me a link") {
+ t.Errorf("the sign-in page has no form:\n%s", rec.Body.String())
+ }
+}
+
+// beginGuard must render the SAME outcome for an owner and a stranger. A
+// distinct rejection turns the guard into an owners-list oracle, which is
+// worse than the dial it removes: the dial confirms the instance exists,
+// an oracle confirms who runs it.
+func TestSigninRefusalIsIndistinguishable(t *testing.T) {
+ h := productionApp(t)
+
+ stranger := post(t, h, "/auth/begin", url.Values{"address": {"nobody@example.com"}})
+ if stranger.Code != http.StatusSeeOther {
+ t.Fatalf("a stranger's submission: status %d, want 303", stranger.Code)
+ }
+ if got := stranger.Header().Get("Location"); got != "/signin?sent=1" {
+ t.Errorf("a stranger is redirected to %q, which names them as refused", got)
+ }
+ if body := stranger.Body.String(); strings.Contains(body, "nobody@example.com") {
+ t.Error("the refusal page echoes the address back")
+ }
+}
+
+// The guard reads the address exactly as rastrillo does. memoria's first
+// version read PostFormValue where rastrillo reads FormValue, so an
+// address in the query string was invisible to the guard and visible to
+// the thing it guarded.
+func TestTheGuardSeesAQueryStringAddress(t *testing.T) {
+ h := productionApp(t)
+ rec := post(t, h, "/auth/begin?address=nobody@example.com", url.Values{})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status %d, want 303", rec.Code)
+ }
+ if got := rec.Header().Get("Location"); got != "/signin?sent=1" {
+ t.Errorf("redirected to %q", got)
+ }
+}