| 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: |