rastrillo / idear Public

idear: trim SKILL.md, fix dependency claims, IPv6 sizing, audit-trail gap (F3, F5-F8)

Documentation remediation from the adversarial review's remaining
findings (F1/F2/F4 already fixed by a prior pass).

- SKILL.md: collapse the old §5/§6/§7 essays into one §5 "Roles, the
  store, and the traps" that points at the code comments carrying the
  full "why" instead of re-deriving it. Every trap survives, stated
  once, mostly at the site a reader would already be looking (inline
  code comments in §2's wire-up, or the consolidated §5 list).
  25,471 -> 20,153 bytes. Section cross-references renumbered
  throughout; the §6 break-glass SQL (test-gated by placeholder lookup
  in example/app_test.go) is untouched.
- docs/.../2026-08-23-idear-design.md: §3's dependency list named
  rastrillo/form (unused; grep confirms) and omitted rastrillo/password
  (admit.go hard-imports it for the refusal sentinel) — corrected, with
  a dated amendment. "What this does not claim" gained a paragraph
  stating idear keeps no audit trail of role/deactivation/transfer
  changes (F7) — a deliberate v1 gap for an app to layer, not built
  here.
- ratelimit.go: clientIP's doc comment corrected — the /64 fold gives
  a single-/64 attacker one bucket, but a routed /48 or /56 supplies
  65,536 or 16,384 distinct /64s, easily enough to fill the default
  4,096-entry table and trip the fail-closed path against every unseen
  invitee. Comment-only; behaviour unchanged.
- handlers.go: field() now calls MultipartForm.RemoveAll() after a
  parse, so temp files a multipart mutation may have spilled don't
  linger for the rest of the request. The one authorized code change.

gofmt clean, go vet clean, go test (idear + idear/example) and
CGO_ENABLED=1 go test -race both green, including the break-glass
tests that execute SKILL.md's rebind SQL out of the file itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1903fb5177c5634f0b276bed8f36714bff5c138a parent 76af784
4 files changed, +233 −256
  • SKILL.md +170 −248
  • docs/superpowers/specs/2026-08-23-idear-design.md +27 −1
  • handlers.go +6 −0
  • ratelimit.go +30 −7
diff --git a/SKILL.md b/SKILL.md
index a37aac9..71f703b 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -5,18 +5,18 @@ description: Add roles and membership to a Rastrillo app: Owner/Admin/Member, in
# 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.
+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.
@@ -48,6 +48,11 @@ import (
)
```
+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.
@@ -63,11 +68,10 @@ if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
}
```
-idear's models **never** go in the app's `Models` list, and **nothing stops
-you putting them there**: idear exports the *types* (`idear.Member`,
-`idear.Invitation` — your templates will use them) but not the *list*. So
-`[]any{&Note{}, &idear.Member{}}` compiles, and only a test catches it. See
-§7 for what it costs and §9 for the test.
+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:
@@ -76,17 +80,17 @@ 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)
+ 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 (§7)
+ EmailForSubject: emailForSubject(d.G), // SET IT on the password path (§5)
})
```
-**3. The identity adapter** — one of two, never both (§7):
+**3. The identity adapter** — one of two, never both (§5):
```go
// password: wrap the app's own user-creating function.
@@ -107,12 +111,12 @@ hs, err := idear.NewHandlers(idear.HandlerConfig{
Roster: rs, // required
RenderMembers: renderMembers, // required
RenderInvitation: renderInvitation, // required
- Site: "Acme's board", // SET IT (§7)
+ 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 (§7)
+ ClientKey: nil, // SET IT behind a proxy (§5)
})
```
@@ -126,7 +130,7 @@ 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.
+// MANDATORY on the password path — see §5.
r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
r.Post("/signout", ph.Signout)
@@ -141,16 +145,16 @@ r.Group(func(gr chi.Router) {
}
gr.Group(func(mr chi.Router) {
mr.Use(rs.Require) // the membership gate
- mr.Get("/", board) // "/" IS BEHIND IT — see §7
+ 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** — a signed-out request is the
-session guard's business — and answers a non-member and a deactivated member
-identically, with `Config.NotFound`.
+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
@@ -174,53 +178,43 @@ POST /invitations/{token} public (rate-limited; reconciliation)
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 and it is not
+`POST /invitations/{token}` is the **reconciliation** route, 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.
-
-What that route asks of them depends on whether idear can learn their
-address. Under keymail the Subject *is* the address, so the invitation must
-be that address's. Under password the Subject is an opaque user id and only
-the app can resolve it: set **`Config.EmailForSubject`** and reconciliation
-applies the same email match admission does. Leave it nil and **possession
-of the token is the whole credential there** — any signed-in orphan who
-gets hold of a live token redeems it, at that token's role. See §7.
+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, and neither may call `flash.Take`.**
-
-No status, because idear writes 400/403/404/500 itself before calling out;
-the first `WriteHeader` wins and a second is a logged no-op.
-
-No `flash.Take`, for two different reasons. On the members page idear has
-*already* taken the flash and handed it back as `Notice`/`Error`, so a
-second `Take` reads the same unmutated request cookie again — harmless on
-its own, but it shows the notice **twice** on any page that also renders
-`MembersPage.Notice`, which is every page worth writing. On the invitation
-page there is no idear flash at all, and a `Take` there simply **eats an
-unrelated pending notice** the visitor was owed on their next page. Render
-what idear hands you.
-
-- `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}}">`.
+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 — a password under eight characters, say — re-renders a form
-whose hidden field comes back **empty**, and the invitee's *second* attempt
-is refused for holding no token. The symptom is "invited people can never
-join", one step later than the mistake. `idear.TokenFrom(r)` hands back what
-`CarryToken` lifted off that very POST:
+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) {
@@ -228,24 +222,23 @@ func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
}
```
-It reads only what `CarryToken` stashed — never the query string, where a
-live credential must not be, and never the body directly, so a missing
-`CarryToken` stays loud instead of being papered over. Write the test for
-this one; see §9.
+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, and the store
+## 5. Roles, the store, and the traps
-`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.
+`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 the invariant enforced **inside** it:
+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
+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)
@@ -255,153 +248,84 @@ rs.Transfer(ctx, owner, to) // demote + promote in one transact
rs.BySubject / ByID / Members / PendingInvitations / IsEmpty
```
-**Re-inviting supersedes.** `Invite` revokes any live invitation for the
-same address in the same transaction as it writes the new one, so an
-address has at most one redeemable invitation. Without that the two
-identity paths disagree — keymail redeems the *oldest* pending row, so a
-correction at a higher role would be silently ignored and a correction at a
-lower one would leave the stale higher one live — and password spends
-whichever of the coexisting links the invitee happens to click.
-
-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`.
-
-Four more exported helpers, none of them optional reading:
-
-```go
-idear.From(r) *Member // the viewer Require resolved; nil outside it
-idear.Grantable(actor *Member) []Role // what actor may hand out — the function
- // behind MembersPage.Grantable, for an app
- // building its own role selector elsewhere
-idear.WithMember(r, m) *http.Request // put a viewer on a request: for a test, or
- // an app that resolves membership its own way
-idear.TokenFrom(r) string // §4
-```
-
-`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.
-- **Set `Config.EmailForSubject` on the password path.** It is one lookup —
- `users.id` → address — and it is what makes reconciliation
- (`POST /invitations/{token}`) require that the invitation was issued to
- the signed-in viewer, which is the rule admission applies. Left nil,
- **that route trusts possession of the token alone**: any signed-in orphan
- — a create-succeeded-but-member-write-failed admission, or a claim-race
- loser — redeems a token issued to somebody else, at that token's role.
- The population is small, but a token is not hard to come by: with
- `Deliver` nil it lands in a browser flash cookie, and a token in a URL
- rides into history, referrers and logs. Under keymail the Subject is the
- address, so idear answers this itself and the hook is not consulted.
-- **Under keymail the address IS the identity, and idear never rebinds it.**
- Three consequences, all of them operational rather than theoretical:
- - **Offboarding MUST deactivate.** A departed member's row is keyed by
- their address, so an address the provider or the admin later **recycles**
- resolves that row — at its role, the Owner's included — and the new
- holder is signed in as the old member. Removal in idear is
- `POST /members/{id}/remove`; do it at the same moment the mailbox is
- closed, not at the next audit.
- - **An address change is a loss of access.** The person becomes a
- non-member and every Subject-keyed row is orphaned. For a non-Owner an
- admin can re-invite the new address (the old row stays as a record —
- deactivate it). For the **Owner** there is no API path at all: nobody
- may act on an Owner and `Transfer` requires the Owner to run it. The
- recovery is §8's rebind.
- - **Switching identity plugins orphans the whole roster at once**, since
- every Subject changes shape. That is a §8 rebind per member, planned
- before the switch and not after it.
-- **`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. The compiler will not stop you — `idear.Member` and
- `idear.Invitation` are exported types, and only the *list* is not — so a
- test is the guard: `migrate.Generate(ctx, Schema.All(), Models)` must
- return zero changes.
-- **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.
+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.
@@ -421,18 +345,17 @@ COMMIT;
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.
+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 the hazards §7 lists: `subject` is the join to
-the app's identity and **idear never rewrites it**. Under keymail a changed
-address makes a member a stranger, and for the Owner there is no API path
-back; a plugin switch does that to every row at once. Rebinding is SQL too.
-**Stop the instance first**, same as above.
+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
@@ -466,23 +389,22 @@ 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, and this is
-the one place that is easy to get wrong: under **keymail** it is the new
-address, lowercased and trimmed; under **password** it is the decimal
-`users.id` of the row the person will sign in as — not their address. Get it
-wrong and they sign in successfully and 404 on every route, which is §7's
-silent trap arriving by a different door.
+`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 very
-block — read out of this file, with only its three placeholders filled in —
+`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.
-## 9. Testing
+## 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 that
-calls a handler function directly proves only the last layer of a stack whose
+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:
diff --git a/docs/superpowers/specs/2026-08-23-idear-design.md b/docs/superpowers/specs/2026-08-23-idear-design.md
index 7429105..3d8ddec 100644
--- a/docs/superpowers/specs/2026-08-23-idear-design.md
+++ b/docs/superpowers/specs/2026-08-23-idear-design.md
@@ -91,10 +91,21 @@ or passkeys keeps them.
Module path `amadan.net/rastrillo/idear`, repository
`https://amadan.net/rastrillo/idear`. It depends on
-`rastrillo/{sessions,migrate,form,flash}` and `gorm.io/gorm`. It does
+`rastrillo/{sessions,migrate,password,flash}` and `gorm.io/gorm`. It does
**not** depend on `rastrillo/ui`: the example styles its pages, the
library does not own a look.
+**Amended 2026-08-24 (review finding F3).** The list above was wrong in
+both directions: it named `rastrillo/form`, which nothing in this
+package imports (`grep -rl 'rastrillo/form' *.go` is empty), and it
+omitted `rastrillo/password`, which `admit.go` hard-imports for
+`password.Refuse` — idear's refusal sentinel (§5's "Refusals need a
+channel"). That import is unconditional: a **keymail-only** app still
+links `rastrillo/password`, even though nothing on the keymail path
+calls it. Splitting the sentinel into its own subpackage so a
+keymail-only app could drop the dependency is a **v2 consideration**,
+not a v1 promise — not done here.
+
## 4. The data model
Two tables and one migration set, following the `sessions` / `auth` /
@@ -534,3 +545,18 @@ against live adversarial probing. Nothing here says Rastrillo was unsafe.
And the chassis's own caveat transfers wholesale: **a library buys the
mechanism, not the discipline.** idear makes the safe call the short
call. It does not remove the engineer at the seams it cannot cover.
+
+**Amended 2026-08-24 (review finding F7): idear keeps no audit trail.**
+`Invitation.InvitedBy` records who sent one invitation, and that is the
+whole of it. There is no record of who changed whose role, who
+deactivated or reactivated whom, or who ran a `Transfer` — the store
+mutates the row and moves on, and `HandlerConfig`'s handlers do not log
+a successful mutation either, only a failed one (and then without
+structured actor/target fields). After a rogue-admin incident or a
+compromised Owner session, an operator has the roster's *current* shape
+and nothing that answers "who made this member an Admin, and when."
+This is deliberately not built for v1: it is a genuine gap, not an
+oversight, and an app that needs one must layer it itself — a
+`SetRole`/`Deactivate`/`Transfer` wrapper that writes its own audit row
+before calling through, or database-level change tracking — rather
+than assume idear provides it.
diff --git a/handlers.go b/handlers.go
index 89ee895..3d0ac5a 100644
--- a/handlers.go
+++ b/handlers.go
@@ -859,6 +859,12 @@ func field(r *http.Request, name string) string {
// at all.
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
_ = r.ParseMultipartForm(multipartMemory)
+ // Values are already copied into PostForm; nothing below
+ // reads a file, so the temp files this may have spilled
+ // need not outlive the request.
+ if r.MultipartForm != nil {
+ r.MultipartForm.RemoveAll()
+ }
} else {
_ = r.ParseForm()
}
diff --git a/ratelimit.go b/ratelimit.go
index 8710388..dbceb07 100644
--- a/ratelimit.go
+++ b/ratelimit.go
@@ -43,6 +43,10 @@ type RateLimit struct {
// make Max the way around the limit. The cost is that a wide
// enough spray can lock out real invitees for as long as it lasts
// — stated so it is a decision and not a surprise.
+ //
+ // A routed IPv6 allocation (a /48 or /56) can supply thousands of
+ // the distinct keys this table tracks from ONE attacker; see
+ // clientIP for the sizing this implies for Max.
Max int
}
@@ -172,13 +176,32 @@ func (l *limiter) size() int {
// a /64: that is 2^64 source addresses, free to rotate, one per
// request. Keyed on the address, each one is an unseen client with a
// fresh burst, so the per-client limit does not exist at all — and it
-// is worse than a limit that merely leaks. Max distinct keys fill the
-// table, at which point allow FAILS CLOSED and refuses every UNSEEN
-// client: real invitees, and orphans, whose only healing path is
-// POST /invitations/{token}. One machine can hold an instance in that
-// state indefinitely. Failing closed is still the right direction on
-// a guessing-slowdown; the key was the bug. Folding to the /64 gives
-// that machine one bucket, which is what it should always have had.
+// is worse than a limit that merely leaks. Folding to the /64 gives a
+// SINGLE-/64 attacker one bucket, which is what it should always have
+// had.
+//
+// It does not give a ROUTED attacker one bucket. A /48 or /56 —
+// ordinary residential and business allocations, not an exotic
+// stretch — carries 65,536 or 16,384 distinct /64s respectively, and
+// every one folds to a different key. That is easily enough to fill
+// a default-sized (4,096-entry) table on its own, at which point
+// allow FAILS CLOSED and refuses every UNSEEN client on the table:
+// real invitees, and orphans, whose only healing path is
+// POST /invitations/{token}. One machine holding a /56 can hold an
+// instance in that state indefinitely. Failing closed is still the
+// right direction on a guessing-slowdown; the /64 fold is still
+// strictly better than keying on the bare address (2^64 keys instead
+// of 2^16). It is not, by itself, enough to assume one attacker means
+// one bucket.
+//
+// Size RateLimit.Max with that in mind: it bounds MEMORY, not
+// attacker-controlled prefixes, so raising it only buys headroom
+// against a /48-or-wider spray, never immunity from one — the fold is
+// fixed at /64 and is not configurable. An app that expects hostile
+// IPv6 traffic should set Max well past the default (each entry is a
+// handful of words, so a table sized in the hundreds of thousands
+// costs low-single-digit megabytes) and treat the fail-closed path as
+// a real operational outcome to monitor for, not a theoretical one.
//
// IPv4 is used whole (a /32), which is the same rule read the same
// way: the smallest unit an operator is routinely handed. Folding