---
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`; `example/` is the worked
reference, 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 `sessions` and whichever identity plugin the app already chose.
Nor does it do tenancy: a CARLOS app serves **one team per instance**, and
separating teams is the platform's process-and-file boundary, never a
`WHERE` clause. idear decides 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

Everything below comes from Rastrillo's own packages plus this one:

```go
import (
	"amadan.net/rastrillo/idear"

	"github.com/carlosframework/rastrillo/csrf"
	"github.com/carlosframework/rastrillo/db"
	"github.com/carlosframework/rastrillo/flash"
	"github.com/carlosframework/rastrillo/migrate"
	"github.com/carlosframework/rastrillo/password" // or .../auth for keymail
	"github.com/carlosframework/rastrillo/sessions"
	"github.com/go-chi/chi/v5"
)
```

Even a **keymail-only** app links `rastrillo/password`: idear's own
refusal sentinel (`password.Refuse`, §5) comes from there regardless of
which plugin the app mounts — an import, not a call. Splitting it out
so a keymail-only app could drop the dependency is a v2 idea, not v1.

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` — and `BootSchema` is what gets applied at boot:

```go
var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema)

// in App(), before anything else runs:
if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
	return nil, err
}
```

idear's models **never** go in the app's `Models` list. Nothing stops you
putting them there — idear exports the *types* but not the *list*, so
`[]any{&Note{}, &idear.Member{}}` compiles — and only a test catches it
(§5, §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 (§5)
	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.
	EmailForSubject: emailForSubject(d.G), // SET IT on the password path (§5)
})
```

**3. The identity adapter** — one of two, never both (§5):

```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 (§5)
	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 (§5)
})
```

**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 §5.
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 §5
		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** — signed-out is the session
guard's business — and answers a non-member and a deactivated member
identically, via `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  (rate-limited; reconciliation)
```

**Both** public routes are rate-limited, not just the lookup: one reads a
secret and the other spends it, and the limiter is not optional — `RateLimit`
can only be widened, never switched off.

`POST /invitations/{token}` is the **reconciliation** route, not
decoration. Admission cannot be one transaction — `Admitting` calls the
app's opaque `Create`, then writes the Member — so a failure between
them (or a lost first-signup claim race) leaves a user row with no
membership: an orphan who signs in and 404s everywhere until they open
their invitation link while signed in.

What that route then asks of them depends on whether idear can learn
their address. Under keymail the Subject *is* the address. Under
password it's an opaque user id, so only the app can resolve it: set
**`Config.EmailForSubject`** and reconciliation applies admission's own
email match. Nil, and **possession of the token is the whole credential
there** — see §5.

## 4. Rendering

Two callbacks, following `password.Config.RenderSignin`. **Neither may
write a status** — idear always writes 400/403/404/500 first, and a
renderer's own `WriteHeader` is a logged no-op — **and neither may call
`flash.Take`**: the members page has already taken it and handed it back
as `Notice`/`Error` (a second `Take` shows the notice twice), and the
invitation page has no idear flash at all (a `Take` there eats an
unrelated notice the visitor was owed). Render what idear hands you.

- `MembersPage{Viewer, Members, Invitations, Grantable, Error, Notice}` —
  build the role selector from **`Grantable`**, never the three constants
  (§5).
- `InvitationPage{Role, Site, Token, Error, SignedIn, Reconcile}` — **no
  address field**: the public GET is an unauthenticated secret lookup and
  must not echo who was invited. Show accept when `Reconcile`, else 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 comes back **empty**, and
the *second* attempt is refused for holding none — "invited people can
never join," one step later than the mistake. `idear.TokenFrom(r)` hands
back what `CarryToken` lifted off that POST:

```go
func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
	render(w, r, "signup", signupView{PageData: d, Invite: idear.TokenFrom(r)})
}
```

It reads only what `CarryToken` stashed — never the query string, never
the body directly — so a missing `CarryToken` stays loud rather than
papered over. Write the test for this; see §7.

## 5. Roles, the store, and the traps

`Owner > Admin > Member`, one Owner always. `Role` is a string; parse
outside input with `ParseRole`. `MayActOn(actor, target)` — full rules
in `policy.go` — backs every mutation: actor active and at least Admin,
never the target, target strictly below actor's rank; nobody acts on
an Owner.

Every mutation is one transaction with its 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)           // plaintext token ONCE; SUPERSEDES any live one
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: `errors.Is` against `ErrInvalid`(400), `ErrForbidden`(403),
`ErrNotFound`(404), `ErrNoInvitation`, `ErrOwnerExists`; `ErrLastOwner`
unwraps to `ErrForbidden`. Helpers: `idear.From(r) *Member` (the viewer,
nil outside `Require`), `idear.Grantable(actor) []Role` (builds
`MembersPage.Grantable`, or any selector an app builds itself),
`idear.WithMember(r, m) *http.Request` (plant a viewer — tests, or an
app resolving membership its own way), `idear.TokenFrom(r)` (§4).

`Subject` is the join to the app's identity, a **`string` on every
path** — never an `int64`, never `sessions.UserID`. Password: the
decimal user id. Keymail: the verified address, lowercased.

`Config.Subject`'s default reads that value; **an override must
preserve its shape** (no `@` for password-like, `@` for keymail-like) —
`addressOf`, behind Claim's display email and reconciliation's email
match, decides "is this an address" by `strings.Contains(subject,
"@")` alone, so an opaque address-shaped override is silently read as
one.

**The traps.** Each cost a review round.

- **`CarryToken` is MANDATORY on the password path** — `Create` gets no
  `*http.Request`; skip it and every invited signup is refused.
- **`Require` outside a session group silently 404s everything**, Owner
  included, indistinguishable from a real refusal — only the log says
  otherwise.
- **`RequireRole` stacks INSIDE `Require`, never bare** — bare it 403s
  a stranger with no membership check. `Routes()` composes correctly;
  don't re-wrap it.
- **Deactivation is per-request, not at sign-in** — `password.Signin`
  mints regardless; only `Require` refuses a removed member. **Gate
  `/`** — an ungated landing page is where this leaks.
- **`Config.NotFound` must be the SAME function value as chi's own.**
  Two 404 pages that merely agree today is a membership oracle waiting
  to drift, undetectable at runtime.
- **One identity plugin per app** — both together gives one human two
  subjects (a decimal id and an address), two roster rows nothing
  reconciles.
- **Set `Config.EmailForSubject` on the password path.** It makes
  reconciliation require the invitation match the signed-in viewer.
  Nil, and that route trusts **possession of the token alone** — a
  leaked token (flash cookie, URL, logs) is enough. Keymail never
  needs the hook.
- **Under keymail the address IS the identity; idear never rebinds
  it.** Deactivate on offboarding (`/members/{id}/remove`) or a
  recycled address signs the new holder in as the old member. An
  address change orphans a non-Owner (re-invite them) or, for the
  **Owner**, needs §6's rebind — as does switching identity plugins,
  which orphans the whole roster at once.
- **`idear.Schema` merges into `BootSchema`, never `Schema`; models
  never go in `Models`.** Mixed in, `check` is permanently red and
  `generate` collides a second `CREATE TABLE idear_members`. Guard:
  `migrate.Generate(ctx, Schema.All(), Models)` zero changes (§7).
- **An Admin may grant only Member** — `checkInviteRole` refuses
  anything not strictly below the poster's rank; build selectors from
  `MembersPage.Grantable`, never the three constants.
- **Set `Site` in production** — the default is the request's `Host`,
  client-supplied, on a public unauthenticated page.
- **Set `ClientKey` behind a proxy**, or every request shares the
  proxy's address as one global bucket. idear never reads
  `X-Forwarded-For` itself — unverifiable is spoofable.

General discipline: never bind a form onto a struct (idear's own carry
`Role`/`DeactivatedAt`) — read named fields from `PostForm`, not
`Form`. `role` never comes from a form you build; `checkInviteRole`'s
strictly-below rule is what makes idear's own reads safe. 404, never
403, for a non-member. Allow-lists, not escaping, for an `ORDER BY` or
a `style` attribute — derive test payloads from the list under test.
Hiding a control is never the enforcement — the store refuses again
inside its own transaction. `refusedCopy` (admit.go) is a constant for
every refused address, never a format string — see that file for why.

## 6. Owner break-glass

A lost Owner credential means a permanently unadministrable instance:
nobody may act on an Owner, `role=owner` is refused everywhere, and
`Transfer` needs the Owner to run it. No API path out — the recovery is
SQL, written down rather than 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 only the *credential* is lost and the roster is fine, that's the app's
own table, not idear's: under password, overwrite `users.password_hash`
with a fresh `password.Hash(...)`; under keymail there's nothing to
reset — the address is the credential.

### Rebinding a subject

The other break-glass, for §5's hazards: `subject` is the join to the
app's identity and **idear never rewrites it**. A changed address (keymail)
or a plugin switch makes a member a stranger with no API path back for an
Owner. Rebinding is SQL too. **Stop the instance first**, same as above.

```sql
-- 1. Look before you write. BOTH rows matter: the one being moved, and any
--    row the NEW subject already has — subject is UNIQUE, so a rebind onto
--    a subject that already has one fails outright. If it does have one,
--    decide which of the two survives BEFORE touching either: the loser's
--    member id may be referenced by the app's own tables.
SELECT id, subject, email, role, deactivated_at FROM idear_members
 WHERE subject IN ('OLD-SUBJECT', 'NEW-SUBJECT');

-- 2. Rebind. email moves with the subject, because under keymail the
--    subject IS the address and a stale display cache misleads the members
--    page. deactivated_at is cleared for the same reason it is cleared in
--    the transfer above: a rebind onto a deactivated row hands the new
--    subject a membership that 404s on every route, which reads exactly
--    like the rebind not having worked. Drop that clause — deliberately —
--    if the person is meant to stay removed. updated_at is left alone on
--    purpose: it is a GORM timestamp, CURRENT_TIMESTAMP does not write
--    GORM's format, and this schema already has one column whose
--    comparison is a text comparison.
BEGIN;
UPDATE idear_members
   SET subject = 'NEW-SUBJECT', email = 'NEW-EMAIL', deactivated_at = NULL
 WHERE subject = 'OLD-SUBJECT';
COMMIT;

-- 3. Verify before restarting: exactly one row, at the role it had, with
--    deactivated_at NULL. NOTHING here means the UPDATE matched nothing —
--    check the OLD-SUBJECT spelling against step 1 rather than re-running.
SELECT id, subject, email, role, deactivated_at FROM idear_members
 WHERE subject = 'NEW-SUBJECT';
```

`NEW-SUBJECT` is spelled the way the identity plugin mints it — the one
easy place to get wrong: **keymail**, the new address, lowercased and
trimmed; **password**, the decimal `users.id` of the row they'll sign in
as, not their address. Get it wrong and they sign in and 404 everywhere,
§5's silent trap by another door.

`example/app_test.go`'s `TestBreakGlassRebindsASubject` runs this exact
block — read out of this file, only its three placeholders filled in —
against a database built by the example's migrations, then signs in over
real HTTP as the new subject and performs an Owner-only action with it.

## 7. 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
calling a handler 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 `/`;
- an invited signup that fails validation re-renders a form still carrying
  the token, and the **second** attempt succeeds;
- a cross-origin POST to an idear route is refused 403;
- the app's own Schema and Models agree (`migrate.Generate` returning zero
  changes is `rastrillo migration check` in test form) — which is also what
  catches an idear model added to `Models`.

## 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.
9. `RenderSignup` seeds its hidden `invite` field from `idear.TokenFrom(r)`,
   **and a test posts a failing signup to prove it** — this is the piece a
   rewritten signup page loses silently.
10. `csrf.Protect(origin)` is mounted app-wide, above every group, so it
    covers idear's routes as well as yours.
11. `EmailForSubject` is set on the password path, or you have decided,
    knowingly, that a signed-in orphan may spend any token they hold.
