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>
20 files changed,
+2009
−3
.gitignore+4 −0Makefile+10 −3README.md+43 −0SKILL.md+347 −0example/README.md+60 −0example/app.go+221 −0example/app_test.go+520 −0example/handlers.go+104 −0example/main.go+97 −0example/migrations/0001_init.sql+18 −0example/models.go+206 −0example/pages/board.html+20 −0example/pages/forbidden.html+4 −0example/pages/invitation.html+28 −0example/pages/layout.html+29 −0example/pages/members.html+78 −0example/pages/notfound.html+4 −0example/pages/signin.html+13 −0example/pages/signup.html+13 −0example/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}) |
| +} |