rastrillo / idear Public

idear: the example app, SKILL.md and README.md (Task 6)

The example is a complete app on rastrillo + idear — its own User and
password wiring, idear's nine routes, its own templates — and it is
part of the main module rather than a submodule: a nested go.mod would
need a replace directive pointing at its own parent, which is exactly
what an app consuming idear must never write. It adds no dependency;
go.mod and go.sum are unchanged.

Three things in it are load-bearing rather than decorative, and each
now has a test that goes red if it is undone. "/" is behind Require,
because password.Signin runs Lookup -> Verify -> mint with no idear
involvement and a removed member still mints a session; an ungated
landing page is the one place this design leaks. POST /signup is
wrapped in CarryToken, without which every invited signup is refused.
And ONE 404 renderer is bound once and handed to both
idear.Config.NotFound and chi's own NotFound — two of them is a
membership oracle, and it is the one misconfiguration idear cannot
detect at runtime.

app_test.go drives the whole flow over real HTTP with a cookie jar and
same-origin evidence: sign up, claim, invite, accept, the members page,
a refused role change and an allowed one, a removed member signing in
successfully and getting a byte-identical 404 anyway, the app's own
RequireRole route, the CSRF gate over idear's routes, and
`rastrillo migration check` in test form via migrate.Generate.

SKILL.md is the mechanism that makes an addon cheap: an agent loads it
instead of the source. Structure from carrillo-chassis, voice from
Rastrillo's own — the install line with no replace directive, the exact
route table, both identity adapters, the security discipline, the ten
traps that are silent when got wrong, and the Owner break-glass SQL,
because a lost Owner credential otherwise means a permanently
unadministrable instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f254985bd832aa85bc443cfda7320a1b7140e5bf parent 147a796
20 files changed, +2009 −3
  • .gitignore +4 −0
  • Makefile +10 −3
  • README.md +43 −0
  • SKILL.md +347 −0
  • example/README.md +60 −0
  • example/app.go +221 −0
  • example/app_test.go +520 −0
  • example/handlers.go +104 −0
  • example/main.go +97 −0
  • example/migrations/0001_init.sql +18 −0
  • example/models.go +206 −0
  • example/pages/board.html +20 −0
  • example/pages/forbidden.html +4 −0
  • example/pages/invitation.html +28 −0
  • example/pages/layout.html +29 −0
  • example/pages/members.html +78 −0
  • example/pages/notfound.html +4 −0
  • example/pages/signin.html +13 −0
  • example/pages/signup.html +13 −0
  • example/render.go +190 −0
diff --git a/.gitignore b/.gitignore
index ca6ec13..f417343 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,6 @@
# Build output only — nothing in here is source.
/releases/
+# The example's default database, if it is ever run from the repo root.
+/board.db
+/board.db-shm
+/board.db-wal
diff --git a/Makefile b/Makefile
index 4a6fedd..90f20ef 100644
--- a/Makefile
+++ b/Makefile
@@ -2,9 +2,16 @@
# ci is the one gate: what a runner executes and what you run before
# pushing are the same definition (amadan's own rule — CI steps delegate
-# to make targets, never keep their own copies of the commands). Later
-# tasks add migration-check the way the scaffold's own Makefile does,
-# once there is an app schema to check.
+# to make targets, never keep their own copies of the commands).
+#
+# There is no separate migration-check step. The scaffold's Makefile
+# shells out to the `rastrillo` CLI for one, which would make this
+# module's gate depend on building that binary; the example's
+# TestSchemaAndModelsAgree does the same work — replay the app's own
+# Schema, diff it against the app's own Models, fail on any pending
+# change — through migrate.Generate, inside `test`. idear's own
+# migrations are checked by schema_test.go, which is where a frozen
+# checksum belongs.
ci: vet fmt-check test race
vet:
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..949acb3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+# idear
+
+Roles and membership for a [Rastrillo](https://rastrillo.org) app: who is in
+this instance, at what rank, and who may change that.
+
+Owner / Admin / Member, exactly one Owner at all times. Invitations that are
+single-use, expiring, and stored only as a digest. A membership gate that
+answers a non-member and a removed member identically. Removal that is a
+deactivation and never a delete, so nothing in your tables dangles.
+
+```sh
+go get amadan.net/rastrillo/idear
+```
+
+No `replace` directive: idear is fetched by path like any other module.
+
+## What it is not
+
+It does not mint sessions, hash passwords, or render a sign-in form — it
+sits on top of `rastrillo/sessions` and whichever identity plugin the app
+already chose, `password` or `auth` (keymail). Pick one, not both.
+
+It does not do tenancy. A CARLOS app serves one team per instance;
+separating teams is the platform's process-and-file boundary, not a `WHERE`
+clause. idear decides who may do what *inside* one instance.
+
+It does not own your pages. Rendering goes through callbacks you supply, the
+way `password.Config.RenderSignin` does.
+
+## Where to start
+
+**[`SKILL.md`](SKILL.md)** is the authoring doc — read it instead of the
+source. It carries the wiring, the route table, the two identity adapters,
+the security discipline, and the traps that are silent when you get them
+wrong. An agent can fetch it directly:
+
+```sh
+curl -s https://amadan.net/rastrillo/idear/SKILL.md
+```
+
+**[`example/`](example/)** is a complete working app on rastrillo + idear,
+and `example/app_test.go` drives the whole flow — sign up, claim, invite,
+accept, members page, role change — through real HTTP.
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..5e0697c
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,347 @@
+---
+name: idear
+description: Add roles and membership to a Rastrillo app: Owner/Admin/Member, invitations, the membership gate.
+---
+
+# idear
+
+The roster for a Rastrillo instance: who is in it, at what rank, and who may
+change that. This file is the authoring doc — read it instead of the source.
+Module `amadan.net/rastrillo/idear`; the worked reference is `example/`, a
+complete app whose `app_test.go` drives the whole flow through real HTTP.
+
+idear is an **addon, not core**. Rastrillo has no role concept, and idear
+never mints a session, hashes a password, or renders a sign-in form: it sits
+on top of `sessions` and whichever identity plugin the app already chose. It
+does not do tenancy either — a CARLOS app serves **one team per instance**,
+and separating teams is the platform's process-and-file boundary, never a
+`WHERE` clause. What idear decides is who may do what **inside** one
+instance.
+
+Read Rastrillo's own `SKILL.md` first; everything here assumes it.
+
+## 1. Install
+
+```sh
+go get amadan.net/rastrillo/idear
+```
+
+**No `replace` directive.** idear is a published module fetched by path, not
+a local chassis you point at — a `replace` here pins the whole team to one
+checkout and is never right.
+
+## 2. Wire it
+
+Five things, in this order. Every step is load-bearing; `example/app.go` is
+this same list with the reasons attached.
+
+**1. Schema.** `idear.Schema` merges into **`BootSchema`**, never into the
+app's own `Schema`:
+
+```go
+var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema)
+```
+
+and idear's models **never** go in the app's `Models` list — there is no way
+to put them there, because idear does not export them. See §7.
+
+**2. The roster**, once per process:
+
+```go
+rs, err := idear.New(idear.Config{
+ DB: d.G, // required
+ OpenSignUp: false, // true admits any verified address at Member
+ InviteTTL: 0, // default 7 days
+ NotFound: notFound, // THE SAME func value chi's NotFound gets (§7)
+ Forbidden: forbidden, // 403 for a member who may not act
+ Logger: logger,
+ // Subject defaults to sessions.Current(r).Subject — correct for both
+ // shipped identity plugins. Override only if the viewer arrives some
+ // other way.
+})
+```
+
+**3. The identity adapter** — one of two, never both (§7):
+
+```go
+// password: wrap the app's own user-creating function.
+ph, err := password.New(password.Config{
+ Sessions: sess, Lookup: lookupUser(d.G),
+ Create: rs.Admitting(createUser(d.G)),
+ RenderSignin: renderSignin, RenderSignup: renderSignup,
+})
+
+// keymail (rastrillo/auth): answer "may this verified address have a session?"
+ah, err := auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize})
+```
+
+**4. The handlers:**
+
+```go
+hs, err := idear.NewHandlers(idear.HandlerConfig{
+ Roster: rs, // required
+ RenderMembers: renderMembers, // required
+ RenderInvitation: renderInvitation, // required
+ Site: "Acme's board", // SET IT (§7)
+ MembersPath: "/members", // where mutations 303 to
+ InvitationPath: "/invitations/", // link prefix: path + token
+ Deliver: mailInvitation, // nil flashes the link instead
+ RateLimit: idear.RateLimit{}, // zero value is the default
+ ClientKey: nil, // SET IT behind a proxy (§7)
+})
+```
+
+**5. The mount:**
+
+```go
+r := chi.NewRouter()
+r.Use(csrf.Protect(origin))
+r.Use(sess.Middleware)
+r.NotFound(notFound) // the same func value as above
+
+r.Get("/signin", ph.SigninPage); r.Post("/signin", ph.Signin)
+r.Get("/signup", ph.SignupPage)
+// MANDATORY on the password path — see §7.
+r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
+r.Post("/signout", ph.Signout)
+
+for _, rt := range hs.Routes() { // the two public invitation routes
+ if rt.Public { r.Method(rt.Method, rt.Pattern, rt.Handler) }
+}
+
+r.Group(func(gr chi.Router) {
+ gr.Use(sess.Require) // the app's session guard
+ for _, rt := range hs.Routes() { // already Require+RequireRole wrapped
+ if !rt.Public { gr.Method(rt.Method, rt.Pattern, rt.Handler) }
+ }
+ gr.Group(func(mr chi.Router) {
+ mr.Use(rs.Require) // the membership gate
+ mr.Get("/", board) // "/" IS BEHIND IT — see §7
+ mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/things/{id}/delete", del)
+ })
+})
+```
+
+`rs.Require` stashes the viewer; read it with `idear.From(r)` (`*Member`,
+nil outside Require). It **never redirects** — a signed-out request is the
+session guard's business — and answers a non-member and a deactivated member
+identically, with `Config.NotFound`.
+
+## 3. The route table
+
+`hs.Routes()` returns them already wrapped in the middleware each needs, in
+the right order, with the rank it enforces. Paths are the app's; change them
+and set `MembersPath`/`InvitationPath` to match.
+
+```
+GET /members Member
+POST /members/invitations Admin
+POST /members/invitations/{id}/revoke Admin
+POST /members/{id}/role Admin
+POST /members/{id}/remove Admin (deactivate, never delete)
+POST /members/{id}/restore Admin
+POST /members/transfer Owner (the only path to Owner)
+GET /invitations/{token} public (rate-limited)
+POST /invitations/{token} public (signed-in reconciliation)
+```
+
+`POST /invitations/{token}` is the **reconciliation** route and it is not
+decoration. Admission cannot be one transaction — `Admitting` calls the
+app's opaque `Create` and then writes the Member — so a failure between them
+leaves a user row with no membership. A retry does not heal it (the retry's
+`Create` fails on the now-duplicate email). The orphan signs in and 404s on
+every route until they open their invitation link while signed in.
+
+## 4. Rendering
+
+Two callbacks, following `password.Config.RenderSignin`. **Neither may write
+a status, and neither may call `flash.Take`** — idear writes 400/403/404/500
+itself before calling out, and it has already taken the flash and handed it
+over as `Notice`/`Error`.
+
+- `MembersPage{Viewer, Members, Invitations, Grantable, Error, Notice}`.
+ Build the role selector from **`Grantable`**, never from the three
+ constants (§7).
+- `InvitationPage{Role, Site, Token, Error, SignedIn, Reconcile}`. **There
+ is no address field.** The public GET is an unauthenticated lookup of a
+ secret; it names the instance and the role and must not echo who was
+ invited. Show the accept button when `Reconcile`, and otherwise a signup
+ form with `<input type="hidden" name="invite" value="{{.Token}}">`.
+
+`password.PageData` has nowhere to carry a token, so a signup that fails
+validation re-renders a form whose hidden field would come back empty. Read
+it back off the posted form in `RenderSignup` (`r.PostFormValue("invite")` —
+`CarryToken` has already parsed it), or the invitee's second attempt is
+refused.
+
+## 5. Roles, and the store
+
+`Owner > Admin > Member`, exactly one Owner at all times. `Role` is a string;
+anything from outside goes through `ParseRole`. `MayActOn(actor, target)` is
+the pure matrix, exhaustible in a table test: the actor must be active and
+at least Admin, must not be the target, and the target's rank must be
+**strictly below** the actor's. Nobody at any rank acts on an Owner.
+
+Every mutation is one transaction, with the invariant enforced **inside** it:
+
+```go
+rs.Claim(ctx, subject, email, name) // first arrival ⇒ Owner; zero ROWS, not zero active
+rs.Invite(ctx, actor, email, role) // returns the plaintext token ONCE
+rs.Revoke(ctx, actor, invitationID)
+rs.Accept(ctx, token, subject, name) // compare-and-swap, never lookup-then-write
+rs.SetRole(ctx, actor, target, role)
+rs.Deactivate(ctx, actor, target) // removal is never a delete
+rs.Reactivate(ctx, actor, target) // the ONLY way back in
+rs.Transfer(ctx, owner, to) // demote + promote in one transaction
+rs.BySubject / ByID / Members / PendingInvitations / IsEmpty
+```
+
+Refusals are classes, matched with `errors.Is`: `ErrInvalid` (400),
+`ErrForbidden` (403), `ErrNotFound` (404), plus `ErrNoInvitation` and
+`ErrOwnerExists` for the flows that produce them. `ErrLastOwner` unwraps to
+`ErrForbidden`.
+
+`Subject` is the join to the app's identity and is a **`string` on every
+path** — never an `int64`, never `sessions.UserID`. Under password it is the
+decimal user id (`strconv.FormatInt(id, 10)`); under keymail it is the
+verified address, lowercased. idear writes it itself on every admission
+path; only a seed that writes roster rows behind the HTTP flows has to spell
+it out.
+
+## 6. The security discipline
+
+idear makes these easy; it cannot make them automatic.
+
+1. **Never bind a form onto a struct.** Read named fields by name, from
+ `PostForm` and not `Form`. idear's own structs have `Role` and
+ `DeactivatedAt` on them; a binding helper is a way to set both.
+2. **`role` never comes from a form** on any path you build. idear reads a
+ posted `role` on exactly two routes and it is safe there only because
+ `checkInviteRole` refuses `owner` outright and refuses anything not
+ strictly below the poster's own rank — a posted role can only ever be
+ worth *less* than the poster already holds.
+3. **404, never 403, for a non-member.** A route someone may not know exists
+ answers exactly like a route that does not. 403 is for a member who may
+ see a page and may not act on it.
+4. **Allow-lists, not escaping**, for anything reaching an `ORDER BY` or a
+ `style` attribute — and derive a test's payloads from the list under
+ test, never restate it.
+5. **`password.Refuse`'s copy is a constant.** Its message reaches the page
+ verbatim, so it must not name the address, the reason, or anything that
+ varies with the visitor. idear's own is one string for every refusal:
+ `"Sign-up here is by invitation."` Wrapping the sentinel with context is
+ safe — password reads the message off the refusal itself, not off
+ `Error()`.
+6. **Hiding a control is never the enforcement.** The template hides what
+ `MayActOn` refuses; the store refuses it again inside its transaction.
+
+## 7. The traps
+
+Each of these cost a review round.
+
+- **`CarryToken` is MANDATORY on the password path.** `password.Config.
+ Create` is `func(ctx, email, hash)` — no `*http.Request` — so admission
+ cannot read the token off the form itself. Without the middleware every
+ invited signup is refused and the instance is closed to everyone but its
+ first account. Possession of the token is *required*: an email match alone
+ would let anyone who learns that `admin@corp.test` was invited register
+ that address with their own password first.
+- **`Require` mounted outside a session group silently 404s everything**,
+ the Owner included, because `Config.Subject` resolves nothing. The
+ response is indistinguishable from a real refusal; only the log says so.
+- **`RequireRole` must stack INSIDE `Require`**, never bare. Bare it reads a
+ viewer nobody put there and answers a **stranger** 403 — telling them the
+ route exists — and runs without the membership check. `Routes()` hands out
+ the correct composition; do not re-wrap it.
+- **Deactivation is per-request, not at sign-in.** `password.Signin` runs
+ Lookup → Verify → mint with no idear involvement, so a removed member
+ still mints a session. What refuses them is `Require`, on every route. **So
+ gate `/`.** An ungated landing page is the one place this design leaks.
+- **`Config.NotFound` must be the SAME renderer chi's own `NotFound` gets** —
+ the same function value, not two that agree today. Two 404 pages in one
+ mount is a membership oracle, and it is the one misconfiguration idear
+ cannot detect at runtime.
+- **One identity plugin per app.** Mounting password *and* keymail gives one
+ human two subjects — a decimal id and an address — and therefore two
+ roster rows, whose roles drift apart from the first role change. Neither
+ row knows about the other and nothing reconciles them.
+- **`idear.Schema` merges into `BootSchema`, never the app's own `Schema`,
+ and idear's models never go in the app's `Models`.** `migration
+ generate`/`check` diff one app's Schema against that same app's Models as
+ a matched pair. Mixed in, `check` is permanently red and `generate` emits
+ a second, GORM-flavoured `CREATE TABLE idear_members` that collides at
+ boot with idear's own.
+- **An Admin may grant only Member.** `checkInviteRole` is strictly-below,
+ so a selector offering Admin to an Admin 403s on every submit and one
+ offering Owner 403s for everybody. Build it from `MembersPage.Grantable`,
+ which asks the store's own predicate.
+- **Set `Site` in production.** The default is the request's `Host` — a
+ client-supplied header — on a public, unauthenticated page, so an attacker
+ who can steer it chooses what that page calls your instance.
+- **Set `ClientKey` behind a proxy.** The default keys the public routes'
+ limiter by the client's own network. Behind a proxy every request arrives
+ from the proxy's address, the limiter becomes one global bucket, and real
+ invitees are locked out. idear never reads `X-Forwarded-For` itself — a
+ header it cannot verify is a header an attacker can spoof — so pass your
+ own trusted one.
+
+## 8. Owner break-glass
+
+A lost Owner credential otherwise means a permanently unadministrable
+instance: nobody at any rank may act on an Owner, `role=owner` is refused on
+every route, and `Transfer` requires the Owner to run it. There is no API
+path out. The recovery is SQL, and it is deliberately written down rather
+than left to be improvised.
+
+**Stop the instance first** — SQLite has one writer, and the running app
+holds it.
+
+```sql
+-- Who is who.
+SELECT id, subject, email, role, deactivated_at FROM idear_members ORDER BY id;
+
+-- Move ownership to member 4. Both statements, or neither: the invariant is
+-- "exactly one active Owner", and half of this leaves zero or two.
+BEGIN;
+UPDATE idear_members SET role = 'admin' WHERE role = 'owner';
+UPDATE idear_members SET role = 'owner', deactivated_at = NULL WHERE id = 4;
+COMMIT;
+
+-- Verify before restarting. Must be exactly 1, and NULL.
+SELECT count(*), max(deactivated_at) FROM idear_members WHERE role = 'owner';
+```
+
+If the roster is fine and only the *credential* is lost, that is the app's
+own table, not idear's: under password, overwrite `users.password_hash` with
+a fresh `password.Hash(...)` value; under keymail there is nothing to reset,
+because the address is the credential.
+
+## 9. Testing
+
+Drive the mounted app over real HTTP with a cookie jar and a same-origin
+`Origin` header — the path a browser and an attacker both take. A test that
+calls a handler function directly proves only the last layer of a stack whose
+whole job is the middle. `example/app_test.go` is the template.
+
+Cover at least:
+
+- a non-member is refused read **and** write on every route, with 404s
+ byte-identical to the app's own for a path that does not exist;
+- a Member is refused every management action; an Admin cannot change,
+ demote or deactivate an Admin or the Owner;
+- a posted `role=owner` never lands, on any path, for any actor;
+- an invited address cannot be claimed **without the token**;
+- a removed member still signs in and still gets 404 on `/`;
+- the app's own Schema and Models agree (`migrate.Generate` returning zero
+ changes is `rastrillo migration check` in test form).
+
+## Checklist before you call a mount done
+
+1. `idear.Schema` is in `BootSchema`; no idear model is in `Models`.
+2. `POST /signup` is wrapped in `rs.CarryToken` (password path).
+3. `Config.NotFound` and chi's `NotFound` are the same function value.
+4. `Require` is mounted inside the session guard, and `/` is behind it.
+5. `RequireRole` appears only inside `Require`.
+6. `Site` is set; `ClientKey` is set if there is a proxy.
+7. The role selector is built from `Grantable`.
+8. One identity plugin, not two.
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 0000000..ea49ae3
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,60 @@
+# The example board
+
+A complete app on rastrillo + idear: a shared message board whose roster is
+idear's. It is the worked reference `../SKILL.md` points at, and it is part
+of the `amadan.net/rastrillo/idear` module rather than a submodule — a
+nested module would need a `replace` directive pointing at its own parent,
+which is exactly the thing an app must never write.
+
+## Run it
+
+```sh
+BOARD_SEED=1 go run ./example -addr 127.0.0.1:8080 -db /tmp/board.db
+```
+
+Then sign in at <http://127.0.0.1:8080/signin> as one of the seeded
+accounts, all with the password `demo-password`:
+
+| Address | Role | What you can do |
+| ------------------- | ------ | ------------------------------------------------- |
+| `ada@example.test` | Owner | everything, including transferring ownership |
+| `kim@example.test` | Admin | invite and manage **Members** only |
+| `sam@example.test` | Member | read the roster; post; nothing else |
+
+Three accounts at three roles, because a role gate you cannot click on is a
+role gate nobody checks. Sign in as Kim and the role selector offers Member
+and nothing else; sign in as Sam and the members page offers no controls at
+all.
+
+`BOARD_ORIGIN` sets the external origin (it decides the CSRF check and the
+cookie attributes); `BOARD_NAME` sets the instance's display name on the
+public invitation page. Both default loudly.
+
+There is no mail server here, so `HandlerConfig.Deliver` is nil and idear
+puts the invitation **link itself** in the flash notice shown to the admin
+who minted it. Copy it out of the page. A deployed app sets `Deliver` and
+keeps the token out of the browser entirely.
+
+## What to read
+
+| File | What it shows |
+| -------------- | ------------------------------------------------------------------- |
+| `app.go` | the whole wiring, in the order it has to happen, with the reasons |
+| `models.go` | the app's own `User`, `BootSchema`, and a seed through the real flows |
+| `render.go` | the two idear render callbacks, and why neither writes a status |
+| `handlers.go` | `idear.From(r)`, and an app route gated on Admin |
+| `app_test.go` | the whole flow through real HTTP — the thing to copy |
+
+Three things in here are load-bearing rather than stylistic, and each has a
+comment at the site saying so:
+
+- **`/` is behind `Require`.** Under the password plugin, deactivation is
+ enforced per request by `Require`, **not** at sign-in — `password.Signin`
+ runs Lookup → Verify → mint with no idear involvement. An ungated landing
+ page is a page a removed member can still read.
+- **`POST /signup` is wrapped in `rs.CarryToken`.** Without it the
+ invitation token never reaches admission and every invited signup is
+ refused.
+- **One 404 renderer**, bound once and handed to both `idear.Config.NotFound`
+ and chi's own `NotFound`. Two of them is a membership oracle, and it is
+ the one misconfiguration idear cannot detect at runtime.
diff --git a/example/app.go b/example/app.go
new file mode 100644
index 0000000..ff9b4c5
--- /dev/null
+++ b/example/app.go
@@ -0,0 +1,221 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/password"
+ "github.com/carlosframework/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/idear"
+)
+
+// app is the wired instance: the database, the roster, and the router.
+type app struct {
+ db *gorm.DB
+ roster *idear.Roster
+ mux *http.ServeMux
+ site string
+ logger *slog.Logger
+}
+
+// App builds the whole thing and hands back the mux
+// rastrillo.Options.Mux wants. site is the instance's display name.
+func App(d *db.DB, origin, site string, logger *slog.Logger) (*http.ServeMux, error) {
+ a, err := newApp(d, origin, site, logger)
+ if err != nil {
+ return nil, err
+ }
+ return a.mux, nil
+}
+
+// newApp is App with the *app kept, for main's seed and for the tests.
+//
+// The order below is the order the wiring has to happen in, and every
+// step of it is load-bearing:
+//
+// 1. BootSchema — sessions, idear, then this app (models.go).
+// 2. sessions, over the writer handle.
+// 3. the ROSTER, before the identity plugin, because the identity
+// plugin is configured with two of its methods.
+// 4. password, with Create wrapped in rs.Admitting.
+// 5. idear's handlers, with this app's renderers.
+// 6. the router: CSRF and session resolution app-wide, ONE 404
+// renderer shared with idear, the signup POST wrapped in
+// rs.CarryToken, and every app route inside rs.Require.
+func newApp(d *db.DB, origin, site string, logger *slog.Logger) (*app, error) {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
+ return nil, err
+ }
+ writer, err := d.G.DB()
+ if err != nil {
+ return nil, err
+ }
+ sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, Logger: logger})
+ if err != nil {
+ return nil, err
+ }
+
+ a := &app{db: d.G, site: site, logger: logger}
+
+ // notFound is bound ONCE and handed to two places: idear's
+ // Config.NotFound below, and chi's own NotFound further down. Two
+ // different 404 renderers in one mount is a membership oracle — a
+ // non-member could tell "this route exists but not for me" from
+ // "no such route" by the shape of the page — and it is the one
+ // misconfiguration idear cannot detect at runtime, because both
+ // hooks are valid functions and neither can see the other. Sharing
+ // the function VALUE, rather than writing two renderers that
+ // happen to agree today, is what makes it stay true.
+ notFound := a.renderNotFound
+
+ rs, err := idear.New(idear.Config{
+ DB: d.G,
+ // OpenSignUp stays false: this instance is invitation-only.
+ // Turn it on and any address may sign up, arriving at Member.
+ NotFound: notFound,
+ Forbidden: a.renderForbidden,
+ Logger: logger,
+ // Subject is left at its default, sessions.Current(r).Subject,
+ // which is correct for the password plugin: password mints the
+ // decimal user id as the Subject (models.go's subjectFor).
+ })
+ if err != nil {
+ return nil, err
+ }
+ a.roster = rs
+
+ ph, err := password.New(password.Config{
+ Sessions: sess,
+ Lookup: lookupUser(d.G),
+ // THE ADMISSION SEAM. Not createUser directly: Admitting
+ // decides the role first (claim / invitation / open sign-up /
+ // refusal), calls this app's create only if the answer is yes,
+ // and writes the Member row after it returns.
+ Create: rs.Admitting(createUser(d.G)),
+ RenderSignin: a.renderSignin,
+ RenderSignup: a.renderSignup,
+ Logger: logger,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ hs, err := idear.NewHandlers(idear.HandlerConfig{
+ Roster: rs,
+ RenderMembers: a.renderMembers,
+ RenderInvitation: a.renderInvitation,
+ // Site is set, not defaulted. The default is the request's
+ // Host header, which is client-supplied, and the invitation
+ // page is public and unauthenticated: an attacker who can
+ // steer Host gets to choose what that page calls this
+ // instance.
+ Site: site,
+ MembersPath: "/members",
+ InvitationPath: "/invitations/",
+ // Deliver is nil, so idear puts the invitation LINK in the
+ // flash notice shown to the admin who minted it. That is what
+ // makes this example runnable with no mail server, and it is
+ // not what a deployed app should do: the token crosses the
+ // wire in a cookie rastrillo/flash does not mark Secure. An
+ // app that can send mail sets Deliver and keeps the token out
+ // of the browser entirely. NewHandlers logs a warning here.
+ //
+ // ClientKey is nil too, which keys the public routes' rate
+ // limiter by the client's own network. That is right for a
+ // direct listener and WRONG behind a reverse proxy, where
+ // every request arrives from the proxy's address and the
+ // limiter collapses into one global bucket.
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ r := chi.NewRouter()
+ // App-wide, above every group: CSRF first, then session
+ // resolution. Middleware, not Require — the sign-in and invitation
+ // pages need to know whether there is a session without being
+ // redirected for lacking one.
+ r.Use(csrf.Protect(origin))
+ r.Use(sess.Middleware)
+ // The same function value idear.Config.NotFound got, above.
+ r.NotFound(notFound)
+
+ // The identity plugin's own routes. Public: this is the front
+ // door.
+ r.Get("/signin", ph.SigninPage)
+ r.Post("/signin", ph.Signin)
+ r.Get("/signup", ph.SignupPage)
+ // CarryToken IS MANDATORY on the password path. password.Config.
+ // Create receives (ctx, email, hash) and no *http.Request, so
+ // admission cannot read the invitation token off the form itself;
+ // this middleware reads the "invite" field and stashes it in the
+ // context Create does receive. Without it every invited signup is
+ // refused, and the instance is closed to everyone but its first
+ // account.
+ r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
+ r.Post("/signout", ph.Signout)
+
+ // idear's two PUBLIC routes, mounted outside every guard: the
+ // invitation lookup and the signed-in reconciliation POST. They
+ // carry their own rate limiter.
+ for _, rt := range hs.Routes() {
+ if rt.Public {
+ r.Method(rt.Method, rt.Pattern, rt.Handler)
+ }
+ }
+
+ r.Group(func(gr chi.Router) {
+ // The app's session guard. Everything below is signed-in, and
+ // a signed-out GET is redirected here rather than being 404ed
+ // by idear — idear's Require never redirects, and mounting it
+ // outside this group would 404 every request from everyone,
+ // the Owner included.
+ gr.Use(sess.Require)
+
+ // idear's guarded routes arrive ALREADY wrapped in
+ // Require + RequireRole, in the right order. Mount them; do
+ // not re-wrap them.
+ for _, rt := range hs.Routes() {
+ if !rt.Public {
+ gr.Method(rt.Method, rt.Pattern, rt.Handler)
+ }
+ }
+
+ // The app's own routes, behind the MEMBERSHIP gate.
+ gr.Group(func(mr chi.Router) {
+ mr.Use(rs.Require)
+ // "/" IS BEHIND Require, and that is not tidiness.
+ // password.Signin runs Lookup, Verify and mint with no
+ // idear involvement at all, so a member who was removed a
+ // moment ago can still MINT a session under password.
+ // Nothing at sign-in stops them. What stops them is
+ // Require, per request, on every route — so a landing page
+ // outside it is a page a removed member can still read.
+ // This app does not have one.
+ mr.Get("/", a.board)
+ mr.Post("/posts", a.createPost)
+ // RequireRole STACKS INSIDE Require: this group already
+ // has Require, so With() adds the rank floor on top of a
+ // viewer that Require has already resolved. Mounted bare
+ // it would answer a stranger 403 — telling them the route
+ // exists — and would run the handler with no membership
+ // check at all.
+ mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/posts/{id}/delete", a.deletePost)
+ })
+ })
+
+ mux := http.NewServeMux()
+ mux.Handle("/", r)
+ a.mux = mux
+ return a, nil
+}
diff --git a/example/app_test.go b/example/app_test.go
new file mode 100644
index 0000000..8530d4e
--- /dev/null
+++ b/example/app_test.go
@@ -0,0 +1,520 @@
+package main
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+
+ "amadan.net/rastrillo/idear"
+)
+
+// The reviewers' standing complaint about idear was that nothing had
+// been proven against a real app shell — every earlier test drove
+// idear's own handlers over idear's own harness. These tests drive THE
+// EXAMPLE: its router, its templates, its identity plugin, its
+// migrations, over a real HTTP server with a real cookie jar. If the
+// mount is wrong, they go red; that is the point of them.
+
+// testApp is the example, served.
+type testApp struct {
+ t *testing.T
+ app *app
+ server *httptest.Server
+ origin string
+}
+
+func newTestApp(t *testing.T) *testApp {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "board.db"), nil)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+
+ // The listener exists before Start, so the origin — which decides
+ // the CSRF check and the cookie attributes — is knowable before
+ // the app that has to be configured with it.
+ srv := httptest.NewUnstartedServer(nil)
+ origin := "http://" + srv.Listener.Addr().String()
+
+ a, err := newApp(d, origin, "The Example Board", slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("newApp: %v", err)
+ }
+ srv.Config.Handler = a.mux
+ srv.Start()
+ t.Cleanup(srv.Close)
+
+ return &testApp{t: t, app: a, server: srv, origin: origin}
+}
+
+// client is one browser: a cookie jar, and whatever session it holds.
+type client struct {
+ ta *testApp
+ http *http.Client
+}
+
+func (ta *testApp) visitor() *client {
+ ta.t.Helper()
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ ta.t.Fatalf("cookiejar.New: %v", err)
+ }
+ return &client{ta: ta, http: &http.Client{
+ Jar: jar,
+ // Redirects are not followed: the 303 IS the assertion on
+ // every successful mutation, and a client that chased it would
+ // report the destination's 200 instead.
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }}
+}
+
+type result struct {
+ Status int
+ Body string
+ Location string
+}
+
+func (c *client) get(path string) *result { return c.do(http.MethodGet, path, nil) }
+
+func (c *client) post(path string, form url.Values) *result {
+ return c.do(http.MethodPost, path, form)
+}
+
+func (c *client) do(method, path string, form url.Values) *result {
+ c.ta.t.Helper()
+ var body io.Reader
+ if form != nil {
+ body = strings.NewReader(form.Encode())
+ }
+ req, err := http.NewRequest(method, c.ta.origin+path, body)
+ if err != nil {
+ c.ta.t.Fatalf("%s %s: %v", method, path, err)
+ }
+ if form != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if method != http.MethodGet {
+ // The evidence a browser sends on a same-origin form
+ // submission. csrf.Protect is mounted app-wide, so a mutation
+ // without it is refused 403 — which is exactly what a
+ // cross-site forgery looks like. It is set for every mutation,
+ // body or no body: several of idear's routes (remove, restore,
+ // revoke) are buttons with nothing in the form at all.
+ req.Header.Set("Origin", c.ta.origin)
+ }
+ res, err := c.http.Do(req)
+ if err != nil {
+ c.ta.t.Fatalf("%s %s: %v", method, path, err)
+ }
+ defer res.Body.Close()
+ b, err := io.ReadAll(res.Body)
+ if err != nil {
+ c.ta.t.Fatalf("reading %s: %v", path, err)
+ }
+ return &result{Status: res.StatusCode, Body: string(b), Location: res.Header.Get("Location")}
+}
+
+// follow chases a redirect the way a browser would: the flash notice
+// is only readable on the page the 303 lands on.
+func (c *client) follow(res *result) *result {
+ c.ta.t.Helper()
+ if res.Location == "" {
+ c.ta.t.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body)
+ }
+ return c.get(res.Location)
+}
+
+// signUp posts the signup form, with an invitation token when there is
+// one. The "invite" field is what rs.CarryToken reads.
+func (c *client) signUp(email, invite string) *result {
+ c.ta.t.Helper()
+ form := url.Values{"email": {email}, "password": {"demo-password"}}
+ if invite != "" {
+ form.Set("invite", invite)
+ }
+ return c.post("/signup", form)
+}
+
+func (c *client) signIn(email string) *result {
+ c.ta.t.Helper()
+ return c.post("/signin", url.Values{"email": {email}, "password": {"demo-password"}})
+}
+
+// tokenPattern pulls the invitation link out of the flash notice.
+// HandlerConfig.Deliver is nil in this example, so idear puts the link
+// itself in the notice the inviting admin sees — see app.go.
+var tokenPattern = regexp.MustCompile(`/invitations/([0-9a-f]{64})`)
+
+func (ta *testApp) tokenFrom(res *result) string {
+ ta.t.Helper()
+ m := tokenPattern.FindStringSubmatch(res.Body)
+ if m == nil {
+ ta.t.Fatalf("no invitation link in the page: %q", res.Body)
+ }
+ return m[1]
+}
+
+// memberID resolves an address to its roster id, for building the
+// management URLs a real admin clicks.
+func (ta *testApp) memberID(email string) int64 {
+ ta.t.Helper()
+ members, err := ta.app.roster.Members(context.Background())
+ if err != nil {
+ ta.t.Fatalf("listing members: %v", err)
+ }
+ for _, m := range members {
+ if m.Email == email {
+ return m.ID
+ }
+ }
+ ta.t.Fatalf("no member with address %q in %+v", email, members)
+ return 0
+}
+
+func (ta *testApp) member(email string) idear.Member {
+ ta.t.Helper()
+ m, err := ta.app.roster.ByID(context.Background(), ta.memberID(email))
+ if err != nil {
+ ta.t.Fatalf("loading member %q: %v", email, err)
+ }
+ return *m
+}
+
+func want(t *testing.T, res *result, status int, what string) {
+ t.Helper()
+ if res.Status != status {
+ t.Fatalf("%s: status %d, want %d; body %q", what, res.Status, status, res.Body)
+ }
+}
+
+// TestSignUpClaimInviteAcceptMembersRoleChange is the whole flow the
+// example exists to prove, through real HTTP: sign up (claiming the
+// instance), invite, accept the invitation as a second browser, read
+// the members page, and change a role.
+func TestSignUpClaimInviteAcceptMembersRoleChange(t *testing.T) {
+ ta := newTestApp(t)
+
+ // Signed out, "/" is the app's session guard's problem, not
+ // idear's: a redirect to the sign-in page, never a 404. idear's
+ // Require never redirects — that division is why it mounts INSIDE
+ // this guard.
+ res := ta.visitor().get("/")
+ want(t, res, http.StatusSeeOther, "signed-out GET /")
+ if !strings.HasPrefix(res.Location, "/signin") {
+ t.Fatalf("signed-out GET / went to %q, want /signin", res.Location)
+ }
+
+ // The claim: the first account on an empty roster is the Owner,
+ // with no invitation involved.
+ ada := ta.visitor()
+ res = ada.signUp("ada@example.test", "")
+ want(t, res, http.StatusSeeOther, "first signup")
+ if owner := ta.member("ada@example.test"); owner.Role != idear.RoleOwner {
+ t.Fatalf("first account is %q, want owner", owner.Role)
+ }
+
+ // And now the instance is closed. A stranger with no token is
+ // refused at 403 with idear's one constant refusal copy — no
+ // mention of the address, and the same words whatever the reason.
+ res = ta.visitor().signUp("mallory@example.test", "")
+ want(t, res, http.StatusForbidden, "uninvited signup")
+ if !strings.Contains(res.Body, "Sign-up here is by invitation.") {
+ t.Fatalf("uninvited signup body %q, want the refusal copy", res.Body)
+ }
+
+ // The Owner reaches the board and the members page.
+ want(t, ada.get("/"), http.StatusOK, "owner GET /")
+ page := ada.get("/members")
+ want(t, page, http.StatusOK, "owner GET /members")
+ if !strings.Contains(page.Body, "ada@example.test") {
+ t.Fatalf("members page does not list the owner: %q", page.Body)
+ }
+ // Grantable for an Owner is Admin and Member, never Owner:
+ // ownership moves only by Transfer.
+ if !strings.Contains(page.Body, `<option value="admin">`) ||
+ !strings.Contains(page.Body, `<option value="member">`) {
+ t.Fatalf("owner's role selector is missing admin/member: %q", page.Body)
+ }
+ if strings.Contains(page.Body, `<option value="owner">`) {
+ t.Fatalf("owner's role selector offers owner: %q", page.Body)
+ }
+
+ // Invite an Admin. The link comes back in the flash notice.
+ res = ada.post("/members/invitations", url.Values{
+ "email": {"kim@example.test"}, "role": {"admin"},
+ })
+ want(t, res, http.StatusSeeOther, "invite")
+ if res.Location != "/members" {
+ t.Fatalf("invite redirected to %q, want /members", res.Location)
+ }
+ adminToken := ta.tokenFrom(ada.follow(res))
+
+ // The PUBLIC invitation page: it names the role and the instance,
+ // and it must not name the invited address.
+ kim := ta.visitor()
+ invitePage := kim.get("/invitations/" + adminToken)
+ want(t, invitePage, http.StatusOK, "public invitation page")
+ if !strings.Contains(invitePage.Body, "Admin") {
+ t.Fatalf("invitation page does not name the role: %q", invitePage.Body)
+ }
+ if strings.Contains(invitePage.Body, "kim@example.test") {
+ t.Fatalf("invitation page leaked the invited address: %q", invitePage.Body)
+ }
+ if !strings.Contains(invitePage.Body, `name="invite" value="`+adminToken+`"`) {
+ t.Fatalf("invitation page has no hidden invite field: %q", invitePage.Body)
+ }
+
+ // Accept it by signing up with the token, which is what the hidden
+ // field above posts and what rs.CarryToken lifts off the form.
+ res = kim.signUp("kim@example.test", adminToken)
+ want(t, res, http.StatusSeeOther, "invited signup")
+ if got := ta.member("kim@example.test"); got.Role != idear.RoleAdmin {
+ t.Fatalf("invited account is %q, want admin", got.Role)
+ }
+ want(t, kim.get("/"), http.StatusOK, "admin GET /")
+ want(t, kim.get("/members"), http.StatusOK, "admin GET /members")
+
+ // The Admin invites a plain Member. checkInviteRole is
+ // strictly-below, so this is the only role an Admin may grant.
+ res = kim.post("/members/invitations", url.Values{
+ "email": {"sam@example.test"}, "role": {"member"},
+ })
+ want(t, res, http.StatusSeeOther, "admin invites a member")
+ memberToken := ta.tokenFrom(kim.follow(res))
+
+ sam := ta.visitor()
+ res = sam.signUp("sam@example.test", memberToken)
+ want(t, res, http.StatusSeeOther, "member signup")
+ if got := ta.member("sam@example.test"); got.Role != idear.RoleMember {
+ t.Fatalf("second invited account is %q, want member", got.Role)
+ }
+
+ // A plain Member sees the roster and is offered nothing to do to
+ // it: Grantable is empty for anyone below Admin.
+ page = sam.get("/members")
+ want(t, page, http.StatusOK, "member GET /members")
+ if strings.Contains(page.Body, "Invite someone") {
+ t.Fatalf("a plain member is offered the invite form: %q", page.Body)
+ }
+ // And the management routes refuse them at 403 — they may see the
+ // page, they may not act on it.
+ res = sam.post("/members/invitations", url.Values{
+ "email": {"eve@example.test"}, "role": {"member"},
+ })
+ want(t, res, http.StatusForbidden, "member tries to invite")
+
+ samID := ta.memberID("sam@example.test")
+
+ // THE ROLE CHANGE. The Admin cannot make a peer: idear refuses a
+ // grant that is not strictly below the granter's rank, and it does
+ // so at 403 rather than silently doing nothing.
+ res = kim.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"admin"}})
+ want(t, res, http.StatusForbidden, "admin promotes to admin")
+ if got := ta.member("sam@example.test"); got.Role != idear.RoleMember {
+ t.Fatalf("refused promotion still landed: role is %q", got.Role)
+ }
+
+ // The Owner can.
+ res = ada.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"admin"}})
+ want(t, res, http.StatusSeeOther, "owner promotes to admin")
+ if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin {
+ t.Fatalf("owner's promotion did not land: role is %q", got.Role)
+ }
+ if body := ada.follow(res).Body; !strings.Contains(body, "Role updated.") {
+ t.Fatalf("members page after a role change: %q", body)
+ }
+
+ // role=owner never lands, on any path, for any actor — including
+ // the Owner.
+ res = ada.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"owner"}})
+ want(t, res, http.StatusForbidden, "owner posts role=owner")
+ if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin {
+ t.Fatalf("role=owner landed: role is %q", got.Role)
+ }
+}
+
+// TestDeactivationIsEnforcedPerRequestNotAtSignIn is the reason "/" is
+// behind Require, stated as a test.
+//
+// password.Signin runs Lookup, Verify and mint with no idear
+// involvement at all, so a removed member still signs in successfully.
+// What refuses them is Require, on every route, per request. An
+// ungated landing page is the one place this design leaks, and this
+// test is what would notice one appearing.
+func TestDeactivationIsEnforcedPerRequestNotAtSignIn(t *testing.T) {
+ ta := newTestApp(t)
+
+ ada := ta.visitor()
+ want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim")
+
+ res := ada.post("/members/invitations", url.Values{
+ "email": {"sam@example.test"}, "role": {"member"},
+ })
+ want(t, res, http.StatusSeeOther, "invite")
+ token := ta.tokenFrom(ada.follow(res))
+
+ sam := ta.visitor()
+ want(t, sam.signUp("sam@example.test", token), http.StatusSeeOther, "accept")
+ want(t, sam.get("/"), http.StatusOK, "member GET / before removal")
+
+ samID := ta.memberID("sam@example.test")
+ want(t, ada.post("/members/"+strconv.FormatInt(samID, 10)+"/remove", nil),
+ http.StatusSeeOther, "remove")
+
+ // The removed member's LIVE session stops resolving on the very
+ // next request.
+ want(t, sam.get("/"), http.StatusNotFound, "removed member GET / on the old session")
+
+ // And a fresh sign-in still SUCCEEDS — the password plugin knows
+ // nothing about the roster — yet buys nothing at all.
+ back := ta.visitor()
+ res = back.signIn("sam@example.test")
+ want(t, res, http.StatusSeeOther, "removed member signs in")
+ gated := back.get("/")
+ want(t, gated, http.StatusNotFound, "removed member GET / on a fresh session")
+ want(t, back.get("/members"), http.StatusNotFound, "removed member GET /members")
+
+ // THE 404 IS THE APP'S OWN, and it is byte-identical to the 404
+ // for a path that simply does not exist. Two different renderers
+ // here would let a stranger tell "this exists but not for you"
+ // from "no such route", which is the membership oracle the whole
+ // design is arranged around — and it is the one misconfiguration
+ // idear cannot detect at runtime.
+ nowhere := back.get("/no-such-path-at-all")
+ want(t, nowhere, http.StatusNotFound, "chi's own NotFound")
+ if gated.Body != nowhere.Body {
+ t.Fatalf("idear's 404 and chi's 404 differ:\nidear: %q\nchi: %q", gated.Body, nowhere.Body)
+ }
+ if !strings.Contains(nowhere.Body, "There is nothing here.") {
+ t.Fatalf("the 404 is not the app's own page: %q", nowhere.Body)
+ }
+
+ // Restored, they are back — at the role their row still carried.
+ want(t, ada.post("/members/"+strconv.FormatInt(samID, 10)+"/restore", nil),
+ http.StatusSeeOther, "restore")
+ want(t, back.get("/"), http.StatusOK, "restored member GET /")
+}
+
+// TestAppRouteRoleGate proves the app's OWN role-gated route, which is
+// the stacking every app has to get right: RequireRole inside Require,
+// never bare.
+func TestAppRouteRoleGate(t *testing.T) {
+ ta := newTestApp(t)
+ if err := Seed(context.Background(), ta.app.db, ta.app.roster); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+
+ member := ta.visitor()
+ want(t, member.signIn(SeedMember), http.StatusSeeOther, "member signs in")
+ board := member.get("/")
+ want(t, board, http.StatusOK, "member GET /")
+ // The template hides the control...
+ if strings.Contains(board.Body, "/delete") {
+ t.Fatalf("a plain member is shown a delete button: %q", board.Body)
+ }
+ // ...and the route refuses it anyway, which is the half that
+ // counts.
+ var post Post
+ if err := ta.app.db.First(&post).Error; err != nil {
+ t.Fatalf("loading the seeded post: %v", err)
+ }
+ id := strconv.FormatInt(post.ID, 10)
+ want(t, member.post("/posts/"+id+"/delete", nil), http.StatusForbidden, "member deletes a post")
+
+ admin := ta.visitor()
+ want(t, admin.signIn(SeedAdmin), http.StatusSeeOther, "admin signs in")
+ want(t, admin.post("/posts/"+id+"/delete", nil), http.StatusSeeOther, "admin deletes a post")
+
+ // A member may still post: Require admits them, RequireRole is
+ // only on the delete route.
+ want(t, member.post("/posts", url.Values{"body": {"hello"}}), http.StatusSeeOther, "member posts")
+}
+
+// TestCSRFRefusesACrossOriginMutation pins that csrf.Protect is
+// actually mounted app-wide, over idear's routes as well as the app's.
+func TestCSRFRefusesACrossOriginMutation(t *testing.T) {
+ ta := newTestApp(t)
+ ada := ta.visitor()
+ want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim")
+
+ req, err := http.NewRequest(http.MethodPost, ta.origin+"/members/invitations",
+ strings.NewReader(url.Values{"email": {"eve@example.test"}, "role": {"admin"}}.Encode()))
+ if err != nil {
+ t.Fatalf("building the request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://attacker.example")
+ res, err := ada.http.Do(req)
+ if err != nil {
+ t.Fatalf("posting: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusForbidden {
+ t.Fatalf("cross-origin POST to an idear route: status %d, want 403", res.StatusCode)
+ }
+}
+
+// TestSeedProducesThreeRoles: the seed is what makes the role gates
+// clickable, and it is idempotent.
+func TestSeedProducesThreeRoles(t *testing.T) {
+ ta := newTestApp(t)
+ ctx := context.Background()
+ if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil {
+ t.Fatalf("Seed again: %v", err)
+ }
+ members, err := ta.app.roster.Members(ctx)
+ if err != nil {
+ t.Fatalf("Members: %v", err)
+ }
+ if len(members) != 3 {
+ t.Fatalf("seed produced %d members, want 3: %+v", len(members), members)
+ }
+ byRole := map[idear.Role]string{}
+ for _, m := range members {
+ byRole[m.Role] = m.Email
+ }
+ for role, email := range map[idear.Role]string{
+ idear.RoleOwner: SeedOwner,
+ idear.RoleAdmin: SeedAdmin,
+ idear.RoleMember: SeedMember,
+ } {
+ if byRole[role] != email {
+ t.Errorf("%s is %q, want %q", role, byRole[role], email)
+ }
+ }
+}
+
+// TestSchemaAndModelsAgree is `rastrillo migration check` in test form:
+// replay this app's OWN Schema and diff the result against this app's
+// OWN Models. It goes red the moment a model changes without a
+// migration — and it is also what would catch an idear model being
+// added to Models, since idear's tables reach the database through
+// BootSchema and are not in Schema at all.
+func TestSchemaAndModelsAgree(t *testing.T) {
+ changes, err := migrate.Generate(context.Background(), Schema.All(), Models)
+ if err != nil {
+ t.Fatalf("migrate.Generate: %v", err)
+ }
+ if len(changes) != 0 {
+ for _, c := range changes {
+ t.Errorf("pending change: %+v", c)
+ }
+ t.Fatalf("Schema and Models disagree: %d pending change(s)", len(changes))
+ }
+}
diff --git a/example/handlers.go b/example/handlers.go
new file mode 100644
index 0000000..1163c27
--- /dev/null
+++ b/example/handlers.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/rastrillo/flash"
+
+ "amadan.net/rastrillo/idear"
+)
+
+// boardView is what board.html renders against.
+type boardView struct {
+ Posts []Post
+ CanDelete bool
+}
+
+// board is GET /, mounted inside idear's Require.
+//
+// idear.From(r) is the viewer Require resolved — never nil here, and
+// nil anywhere Require is not mounted, which is the check below. The
+// role is read off that Member and never off the session or a form.
+func (a *app) board(w http.ResponseWriter, r *http.Request) {
+ m := idear.From(r)
+ if m == nil {
+ // Defence in depth: this can only be a mount bug, and it is
+ // answered with the app's own 404 rather than a panic.
+ a.logger.Error("board: no viewer; / must be mounted inside idear's Require")
+ a.renderNotFound(w, r)
+ return
+ }
+ var posts []Post
+ if err := a.db.WithContext(r.Context()).Order("id DESC").Find(&posts).Error; err != nil {
+ a.logger.Error("board: listing posts", "err", err)
+ }
+ a.render(w, r, 0, "board", boardView{
+ Posts: posts,
+ // The template hides the delete button for a plain Member.
+ // The HIDING IS NOT THE ENFORCEMENT — the route itself stacks
+ // RequireRole(RoleAdmin) inside Require (app.go), and that is
+ // what refuses a Member who posts to it anyway.
+ CanDelete: m.Role.AtLeast(idear.RoleAdmin),
+ })
+}
+
+// createPost is POST /posts. Any active member may post.
+//
+// The author comes from the VIEWER, never from the form — the same
+// rule idear applies to role. A form field named author_id would
+// otherwise be a way to write posts under someone else's name.
+func (a *app) createPost(w http.ResponseWriter, r *http.Request) {
+ m := idear.From(r)
+ if m == nil {
+ a.renderNotFound(w, r)
+ return
+ }
+ body := strings.TrimSpace(r.PostFormValue("body"))
+ if body == "" {
+ flash.Set(w, "error", "A post needs some words in it.")
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ p := Post{AuthorID: m.ID, Author: m.Email, Body: body}
+ if err := a.db.WithContext(r.Context()).Create(&p).Error; err != nil {
+ a.logger.Error("createPost", "err", err)
+ flash.Set(w, "error", "Something went wrong. Please try again.")
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ flash.Set(w, "notice", "Posted.")
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// deletePost is POST /posts/{id}/delete — Admin and above, enforced by
+// the RequireRole stacked inside Require in app.go, not here.
+func (a *app) deletePost(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chiParam(r, "id"), 10, 64)
+ if err != nil || id <= 0 {
+ // A row that is not there is answered exactly like a row that
+ // never existed: the app's own 404, the same one idear's
+ // non-members get.
+ a.renderNotFound(w, r)
+ return
+ }
+ res := a.db.WithContext(r.Context()).Where("id = ?", id).Delete(&Post{})
+ if res.Error != nil {
+ a.logger.Error("deletePost", "err", res.Error)
+ flash.Set(w, "error", "Something went wrong. Please try again.")
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ if res.RowsAffected == 0 {
+ a.renderNotFound(w, r)
+ return
+ }
+ flash.Set(w, "notice", "Post deleted.")
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// chiParam reads a path wildcard. r.PathValue works because chi v5
+// populates it from its own route context, which is the same source
+// idear's handlers read {id} and {token} from.
+func chiParam(r *http.Request, name string) string { return r.PathValue(name) }
diff --git a/example/main.go b/example/main.go
new file mode 100644
index 0000000..48b8347
--- /dev/null
+++ b/example/main.go
@@ -0,0 +1,97 @@
+// Command board is a complete, working app on rastrillo + idear: a
+// shared message board whose roster — who is in this instance, at what
+// rank, and who may change that — is idear's.
+//
+// It is the worked reference SKILL.md points at. Read app.go for the
+// wiring, models.go for the app's own identity row and the seed, and
+// app_test.go for the whole flow driven through real HTTP.
+//
+// Run it:
+//
+// BOARD_SEED=1 go run ./example -addr 127.0.0.1:8080 -db /tmp/board.db
+//
+// then sign in at http://127.0.0.1:8080/signin as ada@example.test
+// (Owner), kim@example.test (Admin) or sam@example.test (Member), all
+// with the password "demo-password".
+package main
+
+import (
+ "context"
+ "log/slog"
+ "net/url"
+ "os"
+
+ "github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/db"
+)
+
+func main() {
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+ origin := os.Getenv("BOARD_ORIGIN")
+ if origin == "" {
+ origin = "http://localhost:8080"
+ // Loud on purpose: origin decides the Secure/__Host- cookie
+ // attributes and the CSRF same-origin check, so a silent
+ // default in a real deployment means http-grade cookies on an
+ // https app.
+ logger.Warn("BOARD_ORIGIN not set; defaulting", "origin", origin)
+ }
+
+ // The instance's display name, shown on the PUBLIC invitation
+ // page. Set it, always: idear falls back to the request's Host
+ // header, which the client supplies.
+ site := os.Getenv("BOARD_NAME")
+ if site == "" {
+ site = hostOf(origin)
+ }
+
+ // Resolve, not Run: this app opens its own database handle through
+ // db.Open — a *gorm.DB over the split reader/writer pool — so
+ // Options.DBPath must be blanked before Serve, or Serve opens a
+ // second connection to the same file.
+ opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "board.db", Logger: logger})
+ if err != nil {
+ logger.Error("resolve activation", "err", err)
+ os.Exit(1)
+ }
+
+ d, err := db.Open(opts.DBPath, logger)
+ if err != nil {
+ logger.Error("open database", "err", err)
+ os.Exit(1)
+ }
+ defer d.Close()
+
+ a, err := newApp(d, origin, site, logger)
+ if err != nil {
+ logger.Error("build app", "err", err)
+ os.Exit(1)
+ }
+
+ if os.Getenv("BOARD_SEED") == "1" {
+ if err := Seed(context.Background(), d.G, a.roster); err != nil {
+ logger.Error("seed", "err", err)
+ os.Exit(1)
+ }
+ }
+
+ opts.Mux = a.mux
+ opts.DBPath = ""
+ if err := rastrillo.Serve(opts); err != nil {
+ logger.Error("serve failed", "err", err)
+ os.Exit(1)
+ }
+}
+
+// hostOf is the host half of an absolute origin, used as the instance
+// name when BOARD_NAME is unset. It is NOT the Host header: it comes
+// from the app's own configured origin, so it is a name the operator
+// chose rather than one a visitor sent.
+func hostOf(origin string) string {
+ u, err := url.Parse(origin)
+ if err != nil || u.Host == "" {
+ return origin
+ }
+ return u.Host
+}
diff --git a/example/migrations/0001_init.sql b/example/migrations/0001_init.sql
new file mode 100644
index 0000000..6afd2b1
--- /dev/null
+++ b/example/migrations/0001_init.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT,
+ password_hash TEXT,
+ created_at DATETIME
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (email);
+
+CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ author_id INTEGER,
+ author TEXT,
+ body TEXT,
+ created_at DATETIME
+);
+
+CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts (author_id);
diff --git a/example/models.go b/example/models.go
new file mode 100644
index 0000000..eb58f47
--- /dev/null
+++ b/example/models.go
@@ -0,0 +1,206 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "errors"
+ "strconv"
+ "time"
+
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/password"
+ "github.com/carlosframework/rastrillo/sessions"
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/idear"
+)
+
+// Models is every model THIS APP's schema generator manages.
+//
+// idear's models are deliberately NOT in it, and there is no way to
+// put them in it — idear does not export them. `rastrillo migration
+// generate` and `rastrillo migration check` replay this app's Schema
+// into a scratch database and diff the result against this list, as a
+// matched pair. idear's tables are created by idear's OWN migrations,
+// which reach the database through BootSchema below and never through
+// Schema, so a list that named &idear.Member{} would be diffed against
+// a schema that has no idear migrations in it: `check` goes
+// permanently red, and `generate` writes a second, GORM-flavoured
+// CREATE TABLE idear_members into this app's migration file that
+// collides at boot with idear's own.
+//
+// TestSchemaAndModelsAgree is `migration check` in test form; it is
+// what catches this if it is ever got wrong.
+var Models = []any{&User{}, &Post{}}
+
+// User is the app's own identity row: an address and a password hash,
+// and nothing about membership.
+//
+// That split is the whole point of idear. idear owns who is IN this
+// instance and at what rank; the app owns credentials. The join
+// between them is the session Subject — see subjectFor.
+type User struct {
+ ID int64
+ Email string `gorm:"uniqueIndex"`
+ PasswordHash string
+ CreatedAt time.Time
+}
+
+// Post is the app's domain: one message on a shared board.
+//
+// Author is a display cache — the address as it was when the post was
+// written — for the same reason idear.Member.Email is one: the roster
+// row it names can be deactivated, renamed, or transferred, and a post
+// from two years ago should still say who wrote it. AuthorID is the
+// idear member id, and it is why removal in idear is a deactivation
+// and never a delete: a deleted row would dangle every one of these.
+type Post struct {
+ ID int64
+ AuthorID int64 `gorm:"index"`
+ Author string
+ Body string
+ CreatedAt time.Time
+}
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is THIS APP's own migrations and nothing else — the half
+// `migration generate` writes into and `migration check` diffs against
+// Models.
+var Schema = migrate.MustFromFS(migrationFS, "board")
+
+// BootSchema is everything applied at boot, in apply order: the shared
+// session core, then idear, then this app.
+//
+// idear.Schema is merged HERE and never into Schema. See Models for
+// what merging it into the wrong one costs.
+var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema)
+
+// subjectFor is the session Subject a User row has under the password
+// identity plugin.
+//
+// password mints its session as
+// sessions.Session{Subject: strconv.FormatInt(id, 10)} — one place,
+// signInAndRedirect, which both Signin and Signup pass through — so
+// this is the spelling a Member row must carry to be resolvable by any
+// session this app ever mints. Get it wrong and the person signs in
+// successfully and then 404s on every guarded route forever, because
+// idear's Require looks the Subject up and finds nothing.
+//
+// idear writes this spelling itself on the admission path (Admitting),
+// which is why nothing outside seeding needs this function. It is
+// unexported by idear, so a seed that writes roster rows behind the
+// HTTP flows has to restate it — noted in the friction log.
+func subjectFor(id int64) string { return strconv.FormatInt(id, 10) }
+
+// lookupUser is password.Config.Lookup. It knows nothing about
+// membership: a deactivated member still has a User row and still
+// verifies their password. What refuses them is idear's Require on
+// every route — which is why this app has no ungated landing page.
+func lookupUser(g *gorm.DB) func(context.Context, string) (int64, string, error) {
+ return func(ctx context.Context, email string) (int64, string, error) {
+ var u User
+ err := g.WithContext(ctx).Where("email = ?", email).First(&u).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return 0, "", sql.ErrNoRows
+ }
+ if err != nil {
+ return 0, "", err
+ }
+ return u.ID, u.PasswordHash, nil
+ }
+}
+
+// createUser is the app's half of password.Config.Create: it writes a
+// User row and nothing else.
+//
+// It is never wired to password directly. App() wraps it as
+// idear.Roster.Admitting(createUser(g)), which decides the ROLE first —
+// claim, invitation, open sign-up, or refusal — and writes the Member
+// row after this returns. See App.
+func createUser(g *gorm.DB) func(context.Context, string, string) (int64, error) {
+ return func(ctx context.Context, email, hash string) (int64, error) {
+ u := User{Email: email, PasswordHash: hash}
+ if err := g.WithContext(ctx).Create(&u).Error; err != nil {
+ return 0, err
+ }
+ return u.ID, nil
+ }
+}
+
+// The seeded accounts. Three, at three different roles, because a role
+// gate you cannot click on is a role gate nobody checks: signing in as
+// SeedAdmin and as SeedMember shows two visibly different members
+// pages, and SeedAdmin cannot promote anybody to Admin while SeedOwner
+// can.
+const (
+ SeedOwner = "ada@example.test"
+ SeedAdmin = "kim@example.test"
+ SeedMember = "sam@example.test"
+ SeedPassword = "demo-password"
+)
+
+// Seed writes those three accounts, and is idempotent: it does nothing
+// at all unless the roster is empty.
+//
+// It goes through the REAL flows rather than inserting roster rows —
+// Claim for the Owner, then Invite and Accept for the other two —
+// because those are the only paths that exist. The store will not mint
+// a second Owner, and it will not mint an Admin without an invitation
+// to spend; a seed that wrote the rows directly would be demonstrating
+// a way in that no running app has.
+func Seed(ctx context.Context, g *gorm.DB, rs *idear.Roster) error {
+ empty, err := rs.IsEmpty(ctx)
+ if err != nil {
+ return err
+ }
+ if !empty {
+ return nil
+ }
+
+ ownerID, err := seedUser(ctx, g, SeedOwner)
+ if err != nil {
+ return err
+ }
+ owner, err := rs.Claim(ctx, subjectFor(ownerID), SeedOwner, "Ada")
+ if err != nil {
+ return err
+ }
+
+ for _, want := range []struct {
+ email string
+ name string
+ role idear.Role
+ }{
+ {SeedAdmin, "Kim", idear.RoleAdmin},
+ {SeedMember, "Sam", idear.RoleMember},
+ } {
+ _, token, err := rs.Invite(ctx, owner, want.email, want.role)
+ if err != nil {
+ return err
+ }
+ id, err := seedUser(ctx, g, want.email)
+ if err != nil {
+ return err
+ }
+ if _, err := rs.Accept(ctx, token, subjectFor(id), want.name); err != nil {
+ return err
+ }
+ }
+
+ return g.WithContext(ctx).Create(&Post{
+ AuthorID: owner.ID,
+ Author: owner.Email,
+ Body: "Welcome to the board. Everyone here can post; admins can delete.",
+ }).Error
+}
+
+func seedUser(ctx context.Context, g *gorm.DB, email string) (int64, error) {
+ hash, err := password.Hash(SeedPassword)
+ if err != nil {
+ return 0, err
+ }
+ return createUser(g)(ctx, email, hash)
+}
diff --git a/example/pages/board.html b/example/pages/board.html
new file mode 100644
index 0000000..0513777
--- /dev/null
+++ b/example/pages/board.html
@@ -0,0 +1,20 @@
+{{define "content"}}
+<h1>The board</h1>
+<form method="post" action="/posts">
+<label>Say something<br><textarea name="body" rows="3" required></textarea></label>
+<button type="submit">Post</button>
+</form>
+<ul>
+{{range .Content.Posts}}
+<li>
+<p>{{.Body}}</p>
+<p><small>{{.Author}}</small></p>
+{{if $.Content.CanDelete}}
+<form method="post" action="/posts/{{.ID}}/delete"><button type="submit">Delete</button></form>
+{{end}}
+</li>
+{{else}}
+<li>Nothing here yet.</li>
+{{end}}
+</ul>
+{{end}}
diff --git a/example/pages/forbidden.html b/example/pages/forbidden.html
new file mode 100644
index 0000000..113a806
--- /dev/null
+++ b/example/pages/forbidden.html
@@ -0,0 +1,4 @@
+{{define "content"}}
+<h1>Not allowed</h1>
+<p>You may not do that.</p>
+{{end}}
diff --git a/example/pages/invitation.html b/example/pages/invitation.html
new file mode 100644
index 0000000..b569c5d
--- /dev/null
+++ b/example/pages/invitation.html
@@ -0,0 +1,28 @@
+{{define "content"}}
+<h1>Invitation</h1>
+{{with .Content}}
+{{if .Error}}
+<p data-error>{{.Error}}</p>
+{{else if .Reconcile}}
+{{/* Signed in, but with no roster row: the orphan this route exists
+ to heal. Accepting spends the token and writes the membership
+ against the session already in hand. */}}
+<p>You are invited to {{.Site}} as {{.Role.Title}}.</p>
+<form method="post" action="/invitations/{{.Token}}"><button type="submit">Accept</button></form>
+{{else if .SignedIn}}
+<p>You are already a member of {{.Site}}. <a href="/">Go to the board</a>.</p>
+{{else}}
+{{/* The invitation page never says WHO was invited: idear hands this
+ template no address, so it cannot leak one to whoever found the
+ link. The token rides the hidden field below, which is what
+ idear's CarryToken reads off the POST. */}}
+<p>You are invited to {{.Site}} as {{.Role.Title}}. Create your account:</p>
+<form method="post" action="/signup">
+<input type="hidden" name="invite" value="{{.Token}}">
+<label>Email <input type="email" name="email" required autocomplete="username"></label>
+<label>Password <input type="password" name="password" required minlength="8" autocomplete="new-password"></label>
+<button type="submit">Sign up</button>
+</form>
+{{end}}
+{{end}}
+{{end}}
diff --git a/example/pages/layout.html b/example/pages/layout.html
new file mode 100644
index 0000000..c13c0af
--- /dev/null
+++ b/example/pages/layout.html
@@ -0,0 +1,29 @@
+{{define "layout"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Site}}</title>
+</head>
+<body>
+<header>
+<nav>
+<a href="/">{{.Site}}</a>
+{{if .Viewer}}
+| <a href="/members">Members</a>
+| <span>{{.Viewer.Email}} ({{.Viewer.Role.Title}})</span>
+| <form method="post" action="/signout"><button type="submit">Sign out</button></form>
+{{else if .SignedIn}}
+| <form method="post" action="/signout"><button type="submit">Sign out</button></form>
+{{else}}
+| <a href="/signin">Sign in</a>
+{{end}}
+</nav>
+</header>
+{{if .HasFlash}}<p data-flash="{{.Flash.Kind}}">{{.Flash.Message}}</p>{{end}}
+<main>
+{{template "content" .}}
+</main>
+</body>
+</html>
+{{end}}
diff --git a/example/pages/members.html b/example/pages/members.html
new file mode 100644
index 0000000..9ef19a4
--- /dev/null
+++ b/example/pages/members.html
@@ -0,0 +1,78 @@
+{{define "content"}}
+{{$viewer := .Content.Viewer}}
+{{$grantable := .Content.Grantable}}
+<h1>Members</h1>
+
+<table>
+<thead><tr><th>Member</th><th>Role</th><th>Status</th><th>Actions</th></tr></thead>
+<tbody>
+{{range .Content.Members}}
+<tr>
+<td>{{.Email}}{{if .Name}} ({{.Name}}){{end}}</td>
+<td>{{.Role.Title}}</td>
+<td>{{if .Active}}Active{{else}}Removed{{end}}</td>
+<td>
+{{if mayActOn $viewer .}}
+{{/* The role selector is built from Grantable, never from the three
+ role constants. An Admin may grant only Member — idear refuses a
+ grant that is not strictly below the granter's own rank — so a
+ selector offering Admin to an Admin would 403 on every submit,
+ and one offering Owner would 403 for everybody including the
+ Owner. Hiding a control is never the enforcement; the store
+ refuses either way. */}}
+{{if $grantable}}
+<form method="post" action="/members/{{.ID}}/role">
+<select name="role" aria-label="Role">
+{{range $grantable}}<option value="{{.}}">{{.Title}}</option>{{end}}
+</select>
+<button type="submit">Set role</button>
+</form>
+{{end}}
+{{if .Active}}
+<form method="post" action="/members/{{.ID}}/remove"><button type="submit">Remove</button></form>
+{{else}}
+<form method="post" action="/members/{{.ID}}/restore"><button type="submit">Restore</button></form>
+{{end}}
+{{end}}
+</td>
+</tr>
+{{end}}
+</tbody>
+</table>
+
+{{if $grantable}}
+<h2>Invite someone</h2>
+<form method="post" action="/members/invitations">
+<label>Email <input type="email" name="email" required></label>
+<select name="role" aria-label="Role">
+{{range $grantable}}<option value="{{.}}">{{.Title}}</option>{{end}}
+</select>
+<button type="submit">Invite</button>
+</form>
+{{end}}
+
+{{with .Content.Invitations}}
+<h2>Pending invitations</h2>
+<ul>
+{{range .}}
+<li>{{.Email}} — {{.Role.Title}}
+<form method="post" action="/members/invitations/{{.ID}}/revoke"><button type="submit">Revoke</button></form>
+</li>
+{{end}}
+</ul>
+{{end}}
+
+{{if .Content.IsOwner}}
+<h2>Transfer ownership</h2>
+{{/* The only path to Owner. idear demotes the outgoing Owner and
+ promotes the incoming one in one transaction, so the instance is
+ never left with two Owners or none — and role=owner is refused on
+ every other route, for every actor. */}}
+<form method="post" action="/members/transfer">
+<select name="member" aria-label="New owner">
+{{range .Content.Members}}{{if and .Active (ne .ID $viewer.ID)}}<option value="{{.ID}}">{{.Email}}</option>{{end}}{{end}}
+</select>
+<button type="submit">Transfer ownership</button>
+</form>
+{{end}}
+{{end}}
diff --git a/example/pages/notfound.html b/example/pages/notfound.html
new file mode 100644
index 0000000..6c72839
--- /dev/null
+++ b/example/pages/notfound.html
@@ -0,0 +1,4 @@
+{{define "content"}}
+<h1>Not found</h1>
+<p>There is nothing here.</p>
+{{end}}
diff --git a/example/pages/signin.html b/example/pages/signin.html
new file mode 100644
index 0000000..faed063
--- /dev/null
+++ b/example/pages/signin.html
@@ -0,0 +1,13 @@
+{{define "content"}}
+<h1>Sign in</h1>
+{{with .Content}}
+{{if .Error}}<p data-error>{{.Error}}</p>{{end}}
+<form method="post" action="/signin">
+<input type="hidden" name="return_to" value="{{.ReturnTo}}">
+<label>Email <input type="email" name="email" value="{{.Email}}" required autocomplete="username"></label>
+<label>Password <input type="password" name="password" required autocomplete="current-password"></label>
+<button type="submit">Sign in</button>
+</form>
+{{end}}
+<p>Holding an invitation link? Open it instead — it carries the token that admits you.</p>
+{{end}}
diff --git a/example/pages/signup.html b/example/pages/signup.html
new file mode 100644
index 0000000..8a88e8a
--- /dev/null
+++ b/example/pages/signup.html
@@ -0,0 +1,13 @@
+{{define "content"}}
+<h1>Create an account</h1>
+{{with .Content}}
+{{if .Error}}<p data-error>{{.Error}}</p>{{end}}
+<form method="post" action="/signup">
+<input type="hidden" name="return_to" value="{{.ReturnTo}}">
+<label>Email <input type="email" name="email" value="{{.Email}}" required autocomplete="username"></label>
+<label>Password <input type="password" name="password" required minlength="8" autocomplete="new-password"></label>
+<label>Invitation token <input type="text" name="invite" value="{{.Invite}}"></label>
+<button type="submit">Sign up</button>
+</form>
+{{end}}
+{{end}}
diff --git a/example/render.go b/example/render.go
new file mode 100644
index 0000000..3456e67
--- /dev/null
+++ b/example/render.go
@@ -0,0 +1,190 @@
+package main
+
+import (
+ "bytes"
+ "embed"
+ "html/template"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo/flash"
+ "github.com/carlosframework/rastrillo/password"
+ "github.com/carlosframework/rastrillo/sessions"
+
+ "amadan.net/rastrillo/idear"
+)
+
+//go:embed pages
+var pagesFS embed.FS
+
+// funcs are the template helpers.
+//
+// mayActOn is idear.MayActOn with the error dropped — the SAME pure
+// predicate the store enforces inside its own transactions, asked
+// again so the page does not offer a control that would 403 on submit.
+// It takes the target by value because a range variable in a template
+// is a value and cannot be addressed.
+//
+// Asking the real predicate rather than writing "is the viewer an
+// admin" here is the point: the rule (nobody acts on an Owner, nobody
+// acts on an equal rank, nobody acts on themselves) lives in one
+// place, and a template that restated it would drift from it.
+var funcs = template.FuncMap{
+ "mayActOn": func(actor *idear.Member, target idear.Member) bool {
+ return idear.MayActOn(actor, &target) == nil
+ },
+}
+
+// pages is one template per page, each parsed together with the
+// layout, so every page can define "content" without the last one
+// silently winning.
+var pages = map[string]*template.Template{}
+
+func init() {
+ for _, name := range []string{"board", "members", "invitation", "signin", "signup", "notfound", "forbidden"} {
+ pages[name] = template.Must(template.New("layout").Funcs(funcs).
+ ParseFS(pagesFS, "pages/layout.html", "pages/"+name+".html"))
+ }
+}
+
+// view is what every template renders against.
+//
+// Viewer is idear's Member, not the session: it is nil on the public
+// pages and past Require it is the roster row, so the layout's nav can
+// show a rank without a second lookup.
+type view struct {
+ Site string
+ SignedIn bool
+ Viewer *idear.Member
+ Flash flash.Flash
+ HasFlash bool
+ Content any
+}
+
+// execute renders name into a buffer and only then touches the wire.
+//
+// The buffer earns its keep twice: a template error becomes a clean
+// 500 instead of garbage appended to a half-written page, and any
+// Set-Cookie a caller added lands before the status line, since
+// headers set after WriteHeader are silently dropped.
+//
+// status 0 means "write no status" — which is what every renderer
+// idear or password calls must do, because BOTH of them write the
+// status themselves before calling out (idear's refusals are 400/403/
+// 404/500; password's are 422/403/429). A renderer that wrote its own
+// would lose to the first WriteHeader and log a duplicate-header
+// warning for its trouble.
+func (a *app) execute(w http.ResponseWriter, r *http.Request, status int, name string, fl flash.Flash, hasFlash bool, content any) {
+ _, signedIn := sessions.Current(r)
+ d := view{
+ Site: a.site,
+ SignedIn: signedIn,
+ Viewer: idear.From(r),
+ Flash: fl,
+ HasFlash: hasFlash,
+ Content: content,
+ }
+ var buf bytes.Buffer
+ if err := pages[name].ExecuteTemplate(&buf, "layout", d); err != nil {
+ a.logger.Error("render", "page", name, "err", err)
+ http.Error(w, "something went wrong", http.StatusInternalServerError)
+ return
+ }
+ if status != 0 {
+ w.WriteHeader(status)
+ }
+ buf.WriteTo(w)
+}
+
+// render is execute for the app's OWN pages: it takes the flash, and
+// it may write a status because nothing upstream has.
+func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, content any) {
+ fl, ok := flash.Take(w, r)
+ a.execute(w, r, status, name, fl, ok, content)
+}
+
+// renderNotFound is the app's 404 page — and idear's, and chi's. One
+// function, three callers, deliberately: see app.go.
+//
+// It takes no flash. Not-found is the answer a non-member gets on
+// every idear route, and the fewer inputs its body has the easier it
+// is to keep byte-identical to the 404 for a path that simply does not
+// exist.
+func (a *app) renderNotFound(w http.ResponseWriter, r *http.Request) {
+ a.execute(w, r, http.StatusNotFound, "notfound", flash.Flash{}, false, nil)
+}
+
+// renderForbidden is idear.Config.Forbidden: a member who may see a
+// page but may not act on it. 403, not 404 — they already know the
+// route exists.
+func (a *app) renderForbidden(w http.ResponseWriter, r *http.Request) {
+ a.execute(w, r, http.StatusForbidden, "forbidden", flash.Flash{}, false, nil)
+}
+
+// membersView is idear.MembersPage plus the one thing the template
+// cannot work out for itself: whether the viewer may transfer
+// ownership. Embedding keeps Viewer, Members, Invitations and
+// Grantable reachable as .Content.Members and so on.
+type membersView struct {
+ idear.MembersPage
+ IsOwner bool
+}
+
+// renderMembers is idear.HandlerConfig.RenderMembers.
+//
+// It writes NO status: idear writes 400/403/404/500 before calling
+// this on a refusal, and 200 by omission on success.
+//
+// It also does not call flash.Take. idear took the flash itself, in
+// its Members handler, and handed it over as Notice or Error — a
+// second Take in the same request would read the same cookie again and
+// show the notice twice.
+func (a *app) renderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) {
+ fl, has := flash.Flash{}, false
+ switch {
+ case d.Error != "":
+ fl, has = flash.Flash{Kind: "error", Message: d.Error}, true
+ case d.Notice != "":
+ fl, has = flash.Flash{Kind: "notice", Message: d.Notice}, true
+ }
+ a.execute(w, r, 0, "members", fl, has, membersView{
+ MembersPage: d,
+ IsOwner: d.Viewer != nil && d.Viewer.Role == idear.RoleOwner,
+ })
+}
+
+// renderInvitation is idear.HandlerConfig.RenderInvitation — the
+// public invitation page.
+//
+// idear hands it a Role, a Site, a Token and two booleans, and NO
+// address: the page must not echo who was invited, so there is no
+// field for it to echo. Writes no status; idear wrote 404 or 403 or
+// 500 already where one was due.
+func (a *app) renderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) {
+ a.execute(w, r, 0, "invitation", flash.Flash{}, false, d)
+}
+
+// signupView is password.PageData plus the invitation token.
+//
+// password.PageData carries Error, Email and ReturnTo and has nowhere
+// to put a token, so a signup that fails validation — a password under
+// eight characters, say — re-renders a form whose hidden "invite"
+// field would come back empty, and the invitee's second attempt would
+// be refused for having no token. Reading it back off the posted form
+// (CarryToken has already parsed it) is what keeps the second attempt
+// working.
+type signupView struct {
+ password.PageData
+ Invite string
+}
+
+func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, d password.PageData) {
+ a.execute(w, r, 0, "signin", flash.Flash{}, false, d)
+}
+
+func (a *app) renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
+ invite := r.PostFormValue("invite")
+ if invite == "" {
+ invite = r.URL.Query().Get("invite")
+ }
+ a.execute(w, r, 0, "signup", flash.Flash{}, false, signupView{PageData: d, Invite: invite})
+}