| 1 | --- |
| 2 | name: idear |
| 3 | description: Add roles and membership to a Rastrillo app: Owner/Admin/Member, invitations, the membership gate. |
| 4 | --- |
| 5 | |
| 6 | # idear |
| 7 | |
| 8 | The roster for a Rastrillo instance: who is in it, at what rank, and who |
| 9 | may change that. This file is the authoring doc — read it instead of the |
| 10 | source. Module `amadan.net/rastrillo/idear`; `example/` is the worked |
| 11 | reference, a complete app whose `app_test.go` drives the whole flow |
| 12 | through real HTTP. |
| 13 | |
| 14 | idear is an **addon, not core**: Rastrillo has no role concept, and idear |
| 15 | never mints a session, hashes a password, or renders a sign-in form — it |
| 16 | sits on `sessions` and whichever identity plugin the app already chose. |
| 17 | Nor does it do tenancy: a CARLOS app serves **one team per instance**, and |
| 18 | separating teams is the platform's process-and-file boundary, never a |
| 19 | `WHERE` clause. idear decides who may do what **inside** one instance. |
| 20 | |
| 21 | Read Rastrillo's own `SKILL.md` first; everything here assumes it. |
| 22 | |
| 23 | ## 1. Install |
| 24 | |
| 25 | ```sh |
| 26 | go get amadan.net/rastrillo/idear |
| 27 | ``` |
| 28 | |
| 29 | **No `replace` directive.** idear is a published module fetched by path, not |
| 30 | a local chassis you point at — a `replace` here pins the whole team to one |
| 31 | checkout and is never right. |
| 32 | |
| 33 | ## 2. Wire it |
| 34 | |
| 35 | Everything below comes from Rastrillo's own packages plus this one: |
| 36 | |
| 37 | ```go |
| 38 | import ( |
| 39 | "amadan.net/rastrillo/idear" |
| 40 | |
| 41 | "github.com/carlosframework/rastrillo/csrf" |
| 42 | "github.com/carlosframework/rastrillo/db" |
| 43 | "github.com/carlosframework/rastrillo/flash" |
| 44 | "github.com/carlosframework/rastrillo/migrate" |
| 45 | "github.com/carlosframework/rastrillo/password" // or .../auth for keymail |
| 46 | "github.com/carlosframework/rastrillo/sessions" |
| 47 | "github.com/go-chi/chi/v5" |
| 48 | ) |
| 49 | ``` |
| 50 | |
| 51 | Even a **keymail-only** app links `rastrillo/password`: idear's own |
| 52 | refusal sentinel (`password.Refuse`, §5) comes from there regardless of |
| 53 | which plugin the app mounts — an import, not a call. Splitting it out |
| 54 | so a keymail-only app could drop the dependency is a v2 idea, not v1. |
| 55 | |
| 56 | Five things, in this order. Every step is load-bearing; `example/app.go` is |
| 57 | this same list with the reasons attached. |
| 58 | |
| 59 | **1. Schema.** `idear.Schema` merges into **`BootSchema`**, never into the |
| 60 | app's own `Schema` — and `BootSchema` is what gets applied at boot: |
| 61 | |
| 62 | ```go |
| 63 | var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema) |
| 64 | |
| 65 | // in App(), before anything else runs: |
| 66 | if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| 67 | return nil, err |
| 68 | } |
| 69 | ``` |
| 70 | |
| 71 | idear's models **never** go in the app's `Models` list. Nothing stops you |
| 72 | putting them there — idear exports the *types* but not the *list*, so |
| 73 | `[]any{&Note{}, &idear.Member{}}` compiles — and only a test catches it |
| 74 | (§5, §7). |
| 75 | |
| 76 | **2. The roster**, once per process: |
| 77 | |
| 78 | ```go |
| 79 | rs, err := idear.New(idear.Config{ |
| 80 | DB: d.G, // required |
| 81 | OpenSignUp: false, // true admits any verified address at Member |
| 82 | InviteTTL: 0, // default 7 days |
| 83 | NotFound: notFound, // THE SAME func value chi's NotFound gets (§5) |
| 84 | Forbidden: forbidden, // 403 for a member who may not act |
| 85 | Logger: logger, |
| 86 | // Subject defaults to sessions.Current(r).Subject — correct for both |
| 87 | // shipped identity plugins. Override only if the viewer arrives some |
| 88 | // other way. |
| 89 | EmailForSubject: emailForSubject(d.G), // SET IT on the password path (§5) |
| 90 | }) |
| 91 | ``` |
| 92 | |
| 93 | **3. The identity adapter** — one of two, never both (§5): |
| 94 | |
| 95 | ```go |
| 96 | // password: wrap the app's own user-creating function. |
| 97 | ph, err := password.New(password.Config{ |
| 98 | Sessions: sess, Lookup: lookupUser(d.G), |
| 99 | Create: rs.Admitting(createUser(d.G)), |
| 100 | RenderSignin: renderSignin, RenderSignup: renderSignup, |
| 101 | }) |
| 102 | |
| 103 | // keymail (rastrillo/auth): answer "may this verified address have a session?" |
| 104 | ah, err := auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize}) |
| 105 | ``` |
| 106 | |
| 107 | **4. The handlers:** |
| 108 | |
| 109 | ```go |
| 110 | hs, err := idear.NewHandlers(idear.HandlerConfig{ |
| 111 | Roster: rs, // required |
| 112 | RenderMembers: renderMembers, // required |
| 113 | RenderInvitation: renderInvitation, // required |
| 114 | Site: "Acme's board", // SET IT (§5) |
| 115 | MembersPath: "/members", // where mutations 303 to |
| 116 | InvitationPath: "/invitations/", // link prefix: path + token |
| 117 | Deliver: mailInvitation, // nil flashes the link instead |
| 118 | RateLimit: idear.RateLimit{}, // zero value is the default |
| 119 | ClientKey: nil, // SET IT behind a proxy (§5) |
| 120 | }) |
| 121 | ``` |
| 122 | |
| 123 | **5. The mount:** |
| 124 | |
| 125 | ```go |
| 126 | r := chi.NewRouter() |
| 127 | r.Use(csrf.Protect(origin)) |
| 128 | r.Use(sess.Middleware) |
| 129 | r.NotFound(notFound) // the same func value as above |
| 130 | |
| 131 | r.Get("/signin", ph.SigninPage); r.Post("/signin", ph.Signin) |
| 132 | r.Get("/signup", ph.SignupPage) |
| 133 | // MANDATORY on the password path — see §5. |
| 134 | r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup))) |
| 135 | r.Post("/signout", ph.Signout) |
| 136 | |
| 137 | for _, rt := range hs.Routes() { // the two public invitation routes |
| 138 | if rt.Public { r.Method(rt.Method, rt.Pattern, rt.Handler) } |
| 139 | } |
| 140 | |
| 141 | r.Group(func(gr chi.Router) { |
| 142 | gr.Use(sess.Require) // the app's session guard |
| 143 | for _, rt := range hs.Routes() { // already Require+RequireRole wrapped |
| 144 | if !rt.Public { gr.Method(rt.Method, rt.Pattern, rt.Handler) } |
| 145 | } |
| 146 | gr.Group(func(mr chi.Router) { |
| 147 | mr.Use(rs.Require) // the membership gate |
| 148 | mr.Get("/", board) // "/" IS BEHIND IT — see §5 |
| 149 | mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/things/{id}/delete", del) |
| 150 | }) |
| 151 | }) |
| 152 | ``` |
| 153 | |
| 154 | `rs.Require` stashes the viewer; read it with `idear.From(r)` (`*Member`, |
| 155 | nil outside Require). It **never redirects** — signed-out is the session |
| 156 | guard's business — and answers a non-member and a deactivated member |
| 157 | identically, via `Config.NotFound`. |
| 158 | |
| 159 | ## 3. The route table |
| 160 | |
| 161 | `hs.Routes()` returns them already wrapped in the middleware each needs, in |
| 162 | the right order, with the rank it enforces. Paths are the app's; change them |
| 163 | and set `MembersPath`/`InvitationPath` to match. |
| 164 | |
| 165 | ``` |
| 166 | GET /members Member |
| 167 | POST /members/invitations Admin |
| 168 | POST /members/invitations/{id}/revoke Admin |
| 169 | POST /members/{id}/role Admin |
| 170 | POST /members/{id}/remove Admin (deactivate, never delete) |
| 171 | POST /members/{id}/restore Admin |
| 172 | POST /members/transfer Owner (the only path to Owner) |
| 173 | GET /invitations/{token} public (rate-limited) |
| 174 | POST /invitations/{token} public (rate-limited; reconciliation) |
| 175 | ``` |
| 176 | |
| 177 | **Both** public routes are rate-limited, not just the lookup: one reads a |
| 178 | secret and the other spends it, and the limiter is not optional — `RateLimit` |
| 179 | can only be widened, never switched off. |
| 180 | |
| 181 | `POST /invitations/{token}` is the **reconciliation** route, not |
| 182 | decoration. Admission cannot be one transaction — `Admitting` calls the |
| 183 | app's opaque `Create`, then writes the Member — so a failure between |
| 184 | them (or a lost first-signup claim race) leaves a user row with no |
| 185 | membership: an orphan who signs in and 404s everywhere until they open |
| 186 | their invitation link while signed in. |
| 187 | |
| 188 | What that route then asks of them depends on whether idear can learn |
| 189 | their address. Under keymail the Subject *is* the address. Under |
| 190 | password it's an opaque user id, so only the app can resolve it: set |
| 191 | **`Config.EmailForSubject`** and reconciliation applies admission's own |
| 192 | email match. Nil, and **possession of the token is the whole credential |
| 193 | there** — see §5. |
| 194 | |
| 195 | ## 4. Rendering |
| 196 | |
| 197 | Two callbacks, following `password.Config.RenderSignin`. **Neither may |
| 198 | write a status** — idear always writes 400/403/404/500 first, and a |
| 199 | renderer's own `WriteHeader` is a logged no-op — **and neither may call |
| 200 | `flash.Take`**: the members page has already taken it and handed it back |
| 201 | as `Notice`/`Error` (a second `Take` shows the notice twice), and the |
| 202 | invitation page has no idear flash at all (a `Take` there eats an |
| 203 | unrelated notice the visitor was owed). Render what idear hands you. |
| 204 | |
| 205 | - `MembersPage{Viewer, Members, Invitations, Grantable, Error, Notice}` — |
| 206 | build the role selector from **`Grantable`**, never the three constants |
| 207 | (§5). |
| 208 | - `InvitationPage{Role, Site, Token, Error, SignedIn, Reconcile}` — **no |
| 209 | address field**: the public GET is an unauthenticated secret lookup and |
| 210 | must not echo who was invited. Show accept when `Reconcile`, else a |
| 211 | signup form with `<input type="hidden" name="invite" value="{{.Token}}">`. |
| 212 | |
| 213 | `password.PageData` has nowhere to carry a token, so a signup that fails |
| 214 | validation re-renders a form whose hidden field comes back **empty**, and |
| 215 | the *second* attempt is refused for holding none — "invited people can |
| 216 | never join," one step later than the mistake. `idear.TokenFrom(r)` hands |
| 217 | back what `CarryToken` lifted off that POST: |
| 218 | |
| 219 | ```go |
| 220 | func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) { |
| 221 | render(w, r, "signup", signupView{PageData: d, Invite: idear.TokenFrom(r)}) |
| 222 | } |
| 223 | ``` |
| 224 | |
| 225 | It reads only what `CarryToken` stashed — never the query string, never |
| 226 | the body directly — so a missing `CarryToken` stays loud rather than |
| 227 | papered over. Write the test for this; see §7. |
| 228 | |
| 229 | ## 5. Roles, the store, and the traps |
| 230 | |
| 231 | `Owner > Admin > Member`, one Owner always. `Role` is a string; parse |
| 232 | outside input with `ParseRole`. `MayActOn(actor, target)` — full rules |
| 233 | in `policy.go` — backs every mutation: actor active and at least Admin, |
| 234 | never the target, target strictly below actor's rank; nobody acts on |
| 235 | an Owner. |
| 236 | |
| 237 | Every mutation is one transaction with its invariant enforced inside it: |
| 238 | |
| 239 | ```go |
| 240 | rs.Claim(ctx, subject, email, name) // first arrival ⇒ Owner; zero ROWS, not zero active |
| 241 | rs.Invite(ctx, actor, email, role) // plaintext token ONCE; SUPERSEDES any live one |
| 242 | rs.Revoke(ctx, actor, invitationID) |
| 243 | rs.Accept(ctx, token, subject, name) // compare-and-swap, never lookup-then-write |
| 244 | rs.SetRole(ctx, actor, target, role) |
| 245 | rs.Deactivate(ctx, actor, target) // removal is never a delete |
| 246 | rs.Reactivate(ctx, actor, target) // the ONLY way back in |
| 247 | rs.Transfer(ctx, owner, to) // demote + promote in one transaction |
| 248 | rs.BySubject / ByID / Members / PendingInvitations / IsEmpty |
| 249 | ``` |
| 250 | |
| 251 | Refusals: `errors.Is` against `ErrInvalid`(400), `ErrForbidden`(403), |
| 252 | `ErrNotFound`(404), `ErrNoInvitation`, `ErrOwnerExists`; `ErrLastOwner` |
| 253 | unwraps to `ErrForbidden`. Helpers: `idear.From(r) *Member` (the viewer, |
| 254 | nil outside `Require`), `idear.Grantable(actor) []Role` (builds |
| 255 | `MembersPage.Grantable`, or any selector an app builds itself), |
| 256 | `idear.WithMember(r, m) *http.Request` (plant a viewer — tests, or an |
| 257 | app resolving membership its own way), `idear.TokenFrom(r)` (§4). |
| 258 | |
| 259 | `Subject` is the join to the app's identity, a **`string` on every |
| 260 | path** — never an `int64`, never `sessions.UserID`. Password: the |
| 261 | decimal user id. Keymail: the verified address, lowercased. |
| 262 | |
| 263 | `Config.Subject`'s default reads that value; **an override must |
| 264 | preserve its shape** (no `@` for password-like, `@` for keymail-like) — |
| 265 | `addressOf`, behind Claim's display email and reconciliation's email |
| 266 | match, decides "is this an address" by `strings.Contains(subject, |
| 267 | "@")` alone, so an opaque address-shaped override is silently read as |
| 268 | one. |
| 269 | |
| 270 | **The traps.** Each cost a review round. |
| 271 | |
| 272 | - **`CarryToken` is MANDATORY on the password path** — `Create` gets no |
| 273 | `*http.Request`; skip it and every invited signup is refused. |
| 274 | - **`Require` outside a session group silently 404s everything**, Owner |
| 275 | included, indistinguishable from a real refusal — only the log says |
| 276 | otherwise. |
| 277 | - **`RequireRole` stacks INSIDE `Require`, never bare** — bare it 403s |
| 278 | a stranger with no membership check. `Routes()` composes correctly; |
| 279 | don't re-wrap it. |
| 280 | - **Deactivation is per-request, not at sign-in** — `password.Signin` |
| 281 | mints regardless; only `Require` refuses a removed member. **Gate |
| 282 | `/`** — an ungated landing page is where this leaks. |
| 283 | - **`Config.NotFound` must be the SAME function value as chi's own.** |
| 284 | Two 404 pages that merely agree today is a membership oracle waiting |
| 285 | to drift, undetectable at runtime. |
| 286 | - **One identity plugin per app** — both together gives one human two |
| 287 | subjects (a decimal id and an address), two roster rows nothing |
| 288 | reconciles. |
| 289 | - **Set `Config.EmailForSubject` on the password path.** It makes |
| 290 | reconciliation require the invitation match the signed-in viewer. |
| 291 | Nil, and that route trusts **possession of the token alone** — a |
| 292 | leaked token (flash cookie, URL, logs) is enough. Keymail never |
| 293 | needs the hook. |
| 294 | - **Under keymail the address IS the identity; idear never rebinds |
| 295 | it.** Deactivate on offboarding (`/members/{id}/remove`) or a |
| 296 | recycled address signs the new holder in as the old member. An |
| 297 | address change orphans a non-Owner (re-invite them) or, for the |
| 298 | **Owner**, needs §6's rebind — as does switching identity plugins, |
| 299 | which orphans the whole roster at once. |
| 300 | - **`idear.Schema` merges into `BootSchema`, never `Schema`; models |
| 301 | never go in `Models`.** Mixed in, `check` is permanently red and |
| 302 | `generate` collides a second `CREATE TABLE idear_members`. Guard: |
| 303 | `migrate.Generate(ctx, Schema.All(), Models)` zero changes (§7). |
| 304 | - **An Admin may grant only Member** — `checkInviteRole` refuses |
| 305 | anything not strictly below the poster's rank; build selectors from |
| 306 | `MembersPage.Grantable`, never the three constants. |
| 307 | - **Set `Site` in production** — the default is the request's `Host`, |
| 308 | client-supplied, on a public unauthenticated page. |
| 309 | - **Set `ClientKey` behind a proxy**, or every request shares the |
| 310 | proxy's address as one global bucket. idear never reads |
| 311 | `X-Forwarded-For` itself — unverifiable is spoofable. |
| 312 | |
| 313 | General discipline: never bind a form onto a struct (idear's own carry |
| 314 | `Role`/`DeactivatedAt`) — read named fields from `PostForm`, not |
| 315 | `Form`. `role` never comes from a form you build; `checkInviteRole`'s |
| 316 | strictly-below rule is what makes idear's own reads safe. 404, never |
| 317 | 403, for a non-member. Allow-lists, not escaping, for an `ORDER BY` or |
| 318 | a `style` attribute — derive test payloads from the list under test. |
| 319 | Hiding a control is never the enforcement — the store refuses again |
| 320 | inside its own transaction. `refusedCopy` (admit.go) is a constant for |
| 321 | every refused address, never a format string — see that file for why. |
| 322 | |
| 323 | ## 6. Owner break-glass |
| 324 | |
| 325 | A lost Owner credential means a permanently unadministrable instance: |
| 326 | nobody may act on an Owner, `role=owner` is refused everywhere, and |
| 327 | `Transfer` needs the Owner to run it. No API path out — the recovery is |
| 328 | SQL, written down rather than improvised. |
| 329 | |
| 330 | **Stop the instance first** — SQLite has one writer, and the running app |
| 331 | holds it. |
| 332 | |
| 333 | ```sql |
| 334 | -- Who is who. |
| 335 | SELECT id, subject, email, role, deactivated_at FROM idear_members ORDER BY id; |
| 336 | |
| 337 | -- Move ownership to member 4. Both statements, or neither: the invariant is |
| 338 | -- "exactly one active Owner", and half of this leaves zero or two. |
| 339 | BEGIN; |
| 340 | UPDATE idear_members SET role = 'admin' WHERE role = 'owner'; |
| 341 | UPDATE idear_members SET role = 'owner', deactivated_at = NULL WHERE id = 4; |
| 342 | COMMIT; |
| 343 | |
| 344 | -- Verify before restarting. Must be exactly 1, and NULL. |
| 345 | SELECT count(*), max(deactivated_at) FROM idear_members WHERE role = 'owner'; |
| 346 | ``` |
| 347 | |
| 348 | If only the *credential* is lost and the roster is fine, that's the app's |
| 349 | own table, not idear's: under password, overwrite `users.password_hash` |
| 350 | with a fresh `password.Hash(...)`; under keymail there's nothing to |
| 351 | reset — the address is the credential. |
| 352 | |
| 353 | ### Rebinding a subject |
| 354 | |
| 355 | The other break-glass, for §5's hazards: `subject` is the join to the |
| 356 | app's identity and **idear never rewrites it**. A changed address (keymail) |
| 357 | or a plugin switch makes a member a stranger with no API path back for an |
| 358 | Owner. Rebinding is SQL too. **Stop the instance first**, same as above. |
| 359 | |
| 360 | ```sql |
| 361 | -- 1. Look before you write. BOTH rows matter: the one being moved, and any |
| 362 | -- row the NEW subject already has — subject is UNIQUE, so a rebind onto |
| 363 | -- a subject that already has one fails outright. If it does have one, |
| 364 | -- decide which of the two survives BEFORE touching either: the loser's |
| 365 | -- member id may be referenced by the app's own tables. |
| 366 | SELECT id, subject, email, role, deactivated_at FROM idear_members |
| 367 | WHERE subject IN ('OLD-SUBJECT', 'NEW-SUBJECT'); |
| 368 | |
| 369 | -- 2. Rebind. email moves with the subject, because under keymail the |
| 370 | -- subject IS the address and a stale display cache misleads the members |
| 371 | -- page. deactivated_at is cleared for the same reason it is cleared in |
| 372 | -- the transfer above: a rebind onto a deactivated row hands the new |
| 373 | -- subject a membership that 404s on every route, which reads exactly |
| 374 | -- like the rebind not having worked. Drop that clause — deliberately — |
| 375 | -- if the person is meant to stay removed. updated_at is left alone on |
| 376 | -- purpose: it is a GORM timestamp, CURRENT_TIMESTAMP does not write |
| 377 | -- GORM's format, and this schema already has one column whose |
| 378 | -- comparison is a text comparison. |
| 379 | BEGIN; |
| 380 | UPDATE idear_members |
| 381 | SET subject = 'NEW-SUBJECT', email = 'NEW-EMAIL', deactivated_at = NULL |
| 382 | WHERE subject = 'OLD-SUBJECT'; |
| 383 | COMMIT; |
| 384 | |
| 385 | -- 3. Verify before restarting: exactly one row, at the role it had, with |
| 386 | -- deactivated_at NULL. NOTHING here means the UPDATE matched nothing — |
| 387 | -- check the OLD-SUBJECT spelling against step 1 rather than re-running. |
| 388 | SELECT id, subject, email, role, deactivated_at FROM idear_members |
| 389 | WHERE subject = 'NEW-SUBJECT'; |
| 390 | ``` |
| 391 | |
| 392 | `NEW-SUBJECT` is spelled the way the identity plugin mints it — the one |
| 393 | easy place to get wrong: **keymail**, the new address, lowercased and |
| 394 | trimmed; **password**, the decimal `users.id` of the row they'll sign in |
| 395 | as, not their address. Get it wrong and they sign in and 404 everywhere, |
| 396 | §5's silent trap by another door. |
| 397 | |
| 398 | `example/app_test.go`'s `TestBreakGlassRebindsASubject` runs this exact |
| 399 | block — read out of this file, only its three placeholders filled in — |
| 400 | against a database built by the example's migrations, then signs in over |
| 401 | real HTTP as the new subject and performs an Owner-only action with it. |
| 402 | |
| 403 | ## 7. Testing |
| 404 | |
| 405 | Drive the mounted app over real HTTP, with a cookie jar and a same-origin |
| 406 | `Origin` header — the path a browser and an attacker both take. A test |
| 407 | calling a handler directly proves only the last layer of a stack whose |
| 408 | whole job is the middle. `example/app_test.go` is the template. |
| 409 | |
| 410 | Cover at least: |
| 411 | |
| 412 | - a non-member is refused read **and** write on every route, with 404s |
| 413 | byte-identical to the app's own for a path that does not exist; |
| 414 | - a Member is refused every management action; an Admin cannot change, |
| 415 | demote or deactivate an Admin or the Owner; |
| 416 | - a posted `role=owner` never lands, on any path, for any actor; |
| 417 | - an invited address cannot be claimed **without the token**; |
| 418 | - a removed member still signs in and still gets 404 on `/`; |
| 419 | - an invited signup that fails validation re-renders a form still carrying |
| 420 | the token, and the **second** attempt succeeds; |
| 421 | - a cross-origin POST to an idear route is refused 403; |
| 422 | - the app's own Schema and Models agree (`migrate.Generate` returning zero |
| 423 | changes is `rastrillo migration check` in test form) — which is also what |
| 424 | catches an idear model added to `Models`. |
| 425 | |
| 426 | ## Checklist before you call a mount done |
| 427 | |
| 428 | 1. `idear.Schema` is in `BootSchema`; no idear model is in `Models`. |
| 429 | 2. `POST /signup` is wrapped in `rs.CarryToken` (password path). |
| 430 | 3. `Config.NotFound` and chi's `NotFound` are the same function value. |
| 431 | 4. `Require` is mounted inside the session guard, and `/` is behind it. |
| 432 | 5. `RequireRole` appears only inside `Require`. |
| 433 | 6. `Site` is set; `ClientKey` is set if there is a proxy. |
| 434 | 7. The role selector is built from `Grantable`. |
| 435 | 8. One identity plugin, not two. |
| 436 | 9. `RenderSignup` seeds its hidden `invite` field from `idear.TokenFrom(r)`, |
| 437 | **and a test posts a failing signup to prove it** — this is the piece a |
| 438 | rewritten signup page loses silently. |
| 439 | 10. `csrf.Protect(origin)` is mounted app-wide, above every group, so it |
| 440 | covers idear's routes as well as yours. |
| 441 | 11. `EmailForSubject` is set on the password path, or you have decided, |
| 442 | knowingly, that a signed-in orphan may spend any token they hold. |
| 443 | |