rastrillo / idear Public

idear: the HTTP handlers, the reconciliation route, and the authorization suite (Task 5)

The nine handlers the design lists, mounted through Routes(), which
hands out each one ALREADY wrapped in Require(RequireRole(min)(...)) —
both mis-mounts the design warns about are then unreachable, and the
Min/Public fields give the suite (and an app's route audit) a shared
list to derive from instead of a restated one.

POST /invitations/{token} is the reconciliation route: for a signed-in
viewer with no member row it claims an empty roster or spends a valid
token, always through the store, so the Subject is canonicalised on the
way in. A deactivated member is refused (readmission is Restore), and a
keymail-shaped Subject must match the invitation's address.

GET /invitations/{token} names the role and the instance and nothing
else — InvitationPage has no address field — and both public routes go
through a bounded in-memory token bucket that fails closed when its
table is full.

Mutations read each permitted field by name from PostForm, never a
struct and never the query string; ids come from the URL; success
flashes a notice and 303s back.

The suite is spec §7 in full, driving real HTTP through chi with a
cookie jar and rastrillo's real CSRF middleware. Every expectation is
derived from the mounted route table and Role.AtLeast; the one list it
quotes is the design's own, in TestRoutesMatchTheDesign, which is what
catches a rank floor lowered in both the guard and its declaration.
Fourteen mutations were run against the shipped code and the table of
which test caught which is in the task-5 report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 613642e9e8b1e6e389dd63da80a10bf0b8d62f0c parent e66b55c
7 files changed, +2902 −1
  • errors.go +7 −0
  • go.mod +1 −1
  • handlers.go +815 −0
  • handlers_test.go +1411 −0
  • internal/ideartest/app.go +354 −0
  • ratelimit.go +181 −0
  • ratelimit_internal_test.go +133 −0
diff --git a/errors.go b/errors.go
index c016a07..eb1c3c4 100644
--- a/errors.go
+++ b/errors.go
@@ -84,6 +84,13 @@ var (
// while a mistyped role renders 400.
ErrInvalidEmail error = classErr{"idear: not an email address", ErrInvalid}
+ // ErrInvalidID is an id that is not a positive decimal integer —
+ // a {id} wildcard carrying "1; DROP", "-1", or nothing at all. It
+ // is malformed input and renders 400, like its siblings above: a
+ // handler that let it through would hand the store a zero id and
+ // get ErrNotFound, rendering a typo as "no such member".
+ ErrInvalidID error = classErr{"idear: not an id", ErrInvalid}
+
// ErrInvalidSubject is a blank session Subject where one is
// required. Subject is the join to the app's own identity, so an
// empty one is a caller bug — usually idear's middleware mounted
diff --git a/go.mod b/go.mod
index 8f9b00f..ac1cc1b 100644
--- a/go.mod
+++ b/go.mod
@@ -4,12 +4,12 @@ go 1.25.0
require (
github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687
+ github.com/go-chi/chi/v5 v5.3.2
gorm.io/gorm v1.31.2
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/go-chi/chi/v5 v5.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
diff --git a/handlers.go b/handlers.go
new file mode 100644
index 0000000..b79e3d4
--- /dev/null
+++ b/handlers.go
@@ -0,0 +1,815 @@
+package idear
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/rastrillo/flash"
+)
+
+// The copy every refusal renders. Fixed strings, never the error's own
+// text: a store error can carry a subject, an address or a driver
+// message, and a page that echoed it would publish them to whoever
+// provoked it. The log line gets the detail; the page gets the class.
+const (
+ invalidCopy = "That request was not valid."
+ forbiddenCopy = "You may not do that."
+ lastOwnerCopy = "The owner cannot be removed. Transfer ownership first."
+ noInvitationCopy = "That invitation is no longer available."
+ failedCopy = "Something went wrong. Please try again."
+ signInFirstCopy = "Sign in first, then open this invitation link again."
+ rateLimitedCopy = "Too many requests. Please wait a moment and try again."
+)
+
+// The notices a successful mutation flashes. They are one-shot display
+// state (rastrillo/flash), read back by Members on the redirect that
+// follows.
+const (
+ invitedNotice = "Invitation created."
+ deliveredNotice = "Invitation sent."
+ undeliveredNotice = "The invitation was created but could not be sent. Revoke it and try again."
+ revokedNotice = "Invitation revoked."
+ roleNotice = "Role updated."
+ removedNotice = "Member removed."
+ restoredNotice = "Member restored."
+ transferNotice = "Ownership transferred."
+ joinedNotice = "Welcome — your membership is set up."
+ alreadyNotice = "You are already a member here."
+)
+
+// flashError is the flash Kind that lands in MembersPage.Error rather
+// than MembersPage.Notice. Anything else is a notice.
+const flashError = "error"
+
+// MembersPage is what RenderMembers receives.
+//
+// Grantable is the roles the VIEWER may hand out, and a role selector
+// must be built from it rather than from the three constants. An Admin
+// may grant only Member — checkInviteRole refuses a grant that is not
+// STRICTLY BELOW the actor's own rank — so a form that offered Admin
+// to an Admin would 403 on every submit, and one that offered Owner
+// would 403 for everybody including the Owner. It is computed by
+// Grantable, which asks the same predicate the store enforces, so the
+// selector cannot drift from the rule.
+type MembersPage struct {
+ Viewer *Member
+ Members []Member
+ Invitations []Invitation
+ Grantable []Role
+ Error string
+ Notice string
+}
+
+// InvitationPage is what RenderInvitation receives, on the public
+// invitation routes.
+//
+// THERE IS NO ADDRESS FIELD, and that is the design rather than an
+// omission. GET /invitations/{token} is an unauthenticated lookup of a
+// secret: it names the instance and the role so the holder knows what
+// they are accepting, and it must not echo the invited address, which
+// would turn a leaked or guessed link into a disclosure. A renderer
+// cannot print what it is never handed.
+//
+// SignedIn says the viewer has a session; Reconcile says they have one
+// AND no member row — the orphan POST is for. A template shows the
+// "accept" button to a Reconcile viewer, and a "sign up with this
+// link" form to everybody else.
+type InvitationPage struct {
+ Role Role
+ Site string
+ Token string
+ Error string
+ SignedIn bool
+ Reconcile bool
+}
+
+// HandlerConfig wires the flows to the app's own shell. Roster and
+// both renderers are required; everything else has a serviceable
+// default.
+//
+// There is deliberately NO NotFound here, though the design sketch
+// shows one: the 404 renderer lives on idear.Config, because Require
+// is a method on the Roster and answers non-members long before a
+// handler runs. Two hooks would mean two 404 pages, and a 404 that
+// varies with which layer refused is the membership oracle the whole
+// design is arranged to avoid. Set Config.NotFound to the same
+// renderer the app gives chi's own NotFound and every refusal in idear
+// renders through it.
+type HandlerConfig struct {
+ Roster *Roster
+ RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage)
+ RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage)
+
+ // Site names this instance on the invitation page — "Acme's
+ // board". Default: the request's Host, which is honest but
+ // unlovely.
+ Site string
+
+ // MembersPath is where a successful mutation redirects. Default
+ // "/members". An app that mounts the members page elsewhere must
+ // say so here, or every 303 lands on a 404.
+ MembersPath string
+
+ // InvitationPath is the prefix a redeemable link is built from:
+ // InvitationPath + token. Default "/invitations/".
+ InvitationPath string
+
+ // Deliver mails the invitation link. It is given the request (for
+ // an origin, a locale, a logger), the invitation and the link.
+ //
+ // When it is nil idear puts the LINK ITSELF in the flash notice,
+ // so a bare mount is usable: the token is shown exactly once, to
+ // the admin who minted it, and can then be pasted into whatever
+ // channel the team uses. That is also a credential in a cookie
+ // for sixty seconds, so an app that can send mail should set this
+ // and keep the token out of the browser entirely.
+ Deliver func(r *http.Request, inv *Invitation, link string) error
+
+ // RateLimit bounds the two public routes. See RateLimit — the
+ // zero value is the documented default, and there is no way to
+ // turn the limiter off.
+ RateLimit RateLimit
+
+ // ClientKey is the rate-limit key for a request. Default: the IP
+ // half of RemoteAddr. Behind a reverse proxy, set this to read
+ // the app's own TRUSTED forwarding header — see clientIP for why
+ // idear will not guess at one.
+ ClientKey func(r *http.Request) string
+}
+
+// Handlers is idear's HTTP surface: the members page, the six
+// management mutations, and the two public invitation routes. Build
+// one at boot and mount Routes.
+type Handlers struct {
+ cfg HandlerConfig
+ limit *limiter
+}
+
+// NewHandlers validates cfg and returns the handlers. It errors unless
+// the Roster and BOTH renderers are set, following jobs.NewHandlers:
+// a nil renderer is a nil call in a request, and a boot error is a
+// better place to learn about it than a production panic.
+func NewHandlers(cfg HandlerConfig) (*Handlers, error) {
+ if cfg.Roster == nil {
+ return nil, errors.New("idear: HandlerConfig.Roster is required")
+ }
+ if cfg.RenderMembers == nil {
+ return nil, errors.New("idear: HandlerConfig.RenderMembers is required")
+ }
+ if cfg.RenderInvitation == nil {
+ return nil, errors.New("idear: HandlerConfig.RenderInvitation is required")
+ }
+ if cfg.MembersPath == "" {
+ cfg.MembersPath = "/members"
+ }
+ if cfg.InvitationPath == "" {
+ cfg.InvitationPath = "/invitations/"
+ }
+ if cfg.ClientKey == nil {
+ cfg.ClientKey = clientIP
+ }
+ return &Handlers{cfg: cfg, limit: newLimiter(cfg.RateLimit)}, nil
+}
+
+// Route is one mounted handler: the method and pattern idear suggests,
+// the handler ALREADY WRAPPED in the middleware it requires, and the
+// rank that wrapping enforces.
+//
+// The handler is pre-guarded on purpose. Require and RequireRole have
+// a stacking order that is wrong in two different ways when it is got
+// wrong (RequireRole mounted bare 403s a stranger, which tells them
+// the route exists; Require mounted outside the app's session guard
+// 404s everyone including the Owner), so Routes hands out the correct
+// composition rather than a bare handler and a comment. What an app
+// still owns is the paths and the session guard these are mounted
+// inside.
+//
+// Min is the rank floor the handler enforces, and Public says the
+// route is unauthenticated. They are not decoration: they let a
+// caller — an app's route audit, or idear's own authorization suite —
+// DERIVE what each route should refuse instead of restating a list
+// that can drift from the one being mounted.
+type Route struct {
+ Method string
+ Pattern string
+ Handler http.Handler
+ Min Role
+ Public bool
+}
+
+// Routes is the whole HTTP surface, in the order the design lists it.
+//
+// The patterns are defaults; paths belong to the app. An app that
+// mounts them elsewhere must set MembersPath and InvitationPath to
+// match, since those are what the redirects and the invitation links
+// are built from.
+func (h *Handlers) Routes() []Route {
+ rs := h.cfg.Roster
+ guard := func(min Role, fn http.HandlerFunc) http.Handler {
+ return rs.Require(rs.RequireRole(min)(fn))
+ }
+ return []Route{
+ {http.MethodGet, "/members", guard(RoleMember, h.Members), RoleMember, false},
+ {http.MethodPost, "/members/invitations", guard(RoleAdmin, h.Invite), RoleAdmin, false},
+ {http.MethodPost, "/members/invitations/{id}/revoke", guard(RoleAdmin, h.Revoke), RoleAdmin, false},
+ {http.MethodPost, "/members/{id}/role", guard(RoleAdmin, h.SetRole), RoleAdmin, false},
+ {http.MethodPost, "/members/{id}/remove", guard(RoleAdmin, h.Remove), RoleAdmin, false},
+ {http.MethodPost, "/members/{id}/restore", guard(RoleAdmin, h.Restore), RoleAdmin, false},
+ {http.MethodPost, "/members/transfer", guard(RoleOwner, h.Transfer), RoleOwner, false},
+ {http.MethodGet, "/invitations/{token}", http.HandlerFunc(h.Invitation), "", true},
+ {http.MethodPost, "/invitations/{token}", http.HandlerFunc(h.Accept), "", true},
+ }
+}
+
+// Grantable is the roles actor may hand out, highest first — what a
+// role selector must be built from.
+//
+// It is derived from checkInviteRole, the same predicate Invite and
+// SetRole enforce inside their transactions, so the form and the store
+// cannot disagree. RoleOwner is never in it for anybody: ownership
+// moves only by Transfer.
+func Grantable(actor *Member) []Role {
+ if actor == nil {
+ return nil
+ }
+ var out []Role
+ for _, role := range []Role{RoleOwner, RoleAdmin, RoleMember} {
+ if checkInviteRole(actor, role) == nil {
+ out = append(out, role)
+ }
+ }
+ return out
+}
+
+// Members is GET /members: the roster, the pending invitations, and
+// whatever the last mutation flashed.
+func (h *Handlers) Members(w http.ResponseWriter, r *http.Request) {
+ viewer, ok := h.viewer(w, r)
+ if !ok {
+ return
+ }
+ d := h.membersPage(r.Context(), viewer)
+ if f, ok := flash.Take(w, r); ok {
+ if f.Kind == flashError {
+ d.Error = f.Message
+ } else {
+ d.Notice = f.Message
+ }
+ }
+ h.cfg.RenderMembers(w, r, d)
+}
+
+// Invite is POST /members/invitations.
+//
+// The role IS read from the form here, and that is safe for exactly
+// one reason: checkInviteRole refuses RoleOwner outright, for every
+// actor including the Owner, and refuses anything not strictly below
+// the actor's own rank. A posted role can therefore only ever be worth
+// LESS than the poster already holds. Every other field is read by
+// name too — there is no struct binding anywhere in this file, so a
+// field nobody named cannot arrive.
+func (h *Handlers) Invite(w http.ResponseWriter, r *http.Request) {
+ viewer, ok := h.viewer(w, r)
+ if !ok {
+ return
+ }
+ role, valid := ParseRole(field(r, "role"))
+ if !valid {
+ h.refuse(w, r, viewer, ErrInvalidRole)
+ return
+ }
+ inv, token, err := h.cfg.Roster.Invite(r.Context(), viewer, field(r, "email"), role)
+ if err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+
+ link := h.cfg.InvitationPath + token
+ if h.cfg.Deliver == nil {
+ // No delivery hook: the link is the notice. See
+ // HandlerConfig.Deliver.
+ h.done(w, r, invitedNotice+" "+link)
+ return
+ }
+ if err := h.cfg.Deliver(r, inv, link); err != nil {
+ h.log().Error("idear: an invitation was created but could not be delivered",
+ "invitation_id", inv.ID, "err", err)
+ h.flash(w, r, flashError, undeliveredNotice)
+ return
+ }
+ h.done(w, r, deliveredNotice)
+}
+
+// Revoke is POST /members/invitations/{id}/revoke. The id comes from
+// the URL, never from the body.
+func (h *Handlers) Revoke(w http.ResponseWriter, r *http.Request) {
+ viewer, ok := h.viewer(w, r)
+ if !ok {
+ return
+ }
+ id, err := pathID(r)
+ if err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ if err := h.cfg.Roster.Revoke(r.Context(), viewer, id); err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ h.done(w, r, revokedNotice)
+}
+
+// SetRole is POST /members/{id}/role — the target from the URL, the
+// new role from the form. See Invite for why a posted role is safe
+// here and cannot reach Owner.
+func (h *Handlers) SetRole(w http.ResponseWriter, r *http.Request) {
+ viewer, target, ok := h.target(w, r)
+ if !ok {
+ return
+ }
+ role, valid := ParseRole(field(r, "role"))
+ if !valid {
+ h.refuse(w, r, viewer, ErrInvalidRole)
+ return
+ }
+ if err := h.cfg.Roster.SetRole(r.Context(), viewer, target, role); err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ h.done(w, r, roleNotice)
+}
+
+// Remove is POST /members/{id}/remove: deactivation, never a delete.
+func (h *Handlers) Remove(w http.ResponseWriter, r *http.Request) {
+ viewer, target, ok := h.target(w, r)
+ if !ok {
+ return
+ }
+ if err := h.cfg.Roster.Deactivate(r.Context(), viewer, target); err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ h.done(w, r, removedNotice)
+}
+
+// Restore is POST /members/{id}/restore: readmission at the role the
+// row still carries. It is the only way back in for someone who was
+// removed — see Roster.Reactivate.
+func (h *Handlers) Restore(w http.ResponseWriter, r *http.Request) {
+ viewer, target, ok := h.target(w, r)
+ if !ok {
+ return
+ }
+ if err := h.cfg.Roster.Reactivate(r.Context(), viewer, target); err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ h.done(w, r, restoredNotice)
+}
+
+// Transfer is POST /members/transfer, the one mutation whose target id
+// comes from the FORM and not the URL: the path has no id in it,
+// because the actor is always the current Owner and the route is not
+// addressed by them.
+//
+// No role is read here at all. Transfer is the only path to RoleOwner,
+// and it decides both roles itself.
+func (h *Handlers) Transfer(w http.ResponseWriter, r *http.Request) {
+ viewer, ok := h.viewer(w, r)
+ if !ok {
+ return
+ }
+ id, err := parseID(field(r, "member"))
+ if err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ target, err := h.cfg.Roster.ByID(r.Context(), id)
+ if err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ if err := h.cfg.Roster.Transfer(r.Context(), viewer, target); err != nil {
+ h.refuse(w, r, viewer, err)
+ return
+ }
+ h.done(w, r, transferNotice)
+}
+
+// Invitation is GET /invitations/{token}: PUBLIC, unauthenticated, and
+// a lookup of a secret.
+//
+// It renders the ROLE and the INSTANCE, and never the invited address:
+// InvitationPage has no field for one. Every unusable token — no such
+// token, spent, revoked, expired — renders the SAME copy at the same
+// status, because ErrNoInvitation is deliberately one error and not
+// four, and a page that told them apart would answer questions the
+// holder is not entitled to ask.
+//
+// It is rate-limited. See RateLimit.
+func (h *Handlers) Invitation(w http.ResponseWriter, r *http.Request) {
+ if !h.allow(w, r) {
+ return
+ }
+ token := r.PathValue("token")
+ signedIn, reconcile := h.standing(r)
+
+ inv, err := h.cfg.Roster.pendingInvitation(r.Context(), token)
+ if err != nil {
+ h.deadInvitation(w, r, err, signedIn)
+ return
+ }
+ h.cfg.RenderInvitation(w, r, InvitationPage{
+ Role: inv.Role,
+ Site: h.site(r),
+ Token: token,
+ SignedIn: signedIn,
+ Reconcile: reconcile,
+ })
+}
+
+// Accept is POST /invitations/{token}: the RECONCILIATION route, and
+// the only path by which a member row is created from an already-live
+// session.
+//
+// It exists because admission cannot be one transaction. Roster.
+// Admitting calls the app's opaque Create and then writes the member;
+// a failure between them leaves a user row with no membership, and a
+// retry does NOT heal it — the retry's Create fails on the now
+// duplicate email and password.Signup renders that as 422 without ever
+// reaching the member write. The orphan can sign IN and 404s on every
+// guarded route forever. Two concurrent first signups reach the same
+// place with no failure at all: one wins the Claim and the
+// ErrOwnerExists loser is an orphan.
+//
+// What it does, in order, for a viewer who is SIGNED IN:
+//
+// 1. already a member: nothing to reconcile — a notice and a
+// redirect, and the token is NOT spent.
+// 2. a DEACTIVATED member: refused. Readmission is Restore, by an
+// admin; an invitation must not be a way for a removed person to
+// let themselves back in.
+// 3. the roster has ZERO ROWS: Claim, exactly as admission would
+// have. An unclaimed instance's first account is its Owner, and
+// the only way to be signed in against an empty roster is to be
+// the orphan whose Claim did not commit.
+// 4. otherwise: Accept the token — the CAS in the store, so a Revoke
+// racing this never admits.
+//
+// The member row is written by the STORE, from the live session
+// Subject, and never assembled here: Subject is canonicalised on every
+// write (normalizeSubject), and under keymail the raw subject is the
+// address as the visitor typed it. A Member literal built in this
+// handler would reintroduce exactly the bug that canonicalisation
+// exists to close.
+//
+// It needs a VALID TOKEN. A claim-race loser holds none, so they are
+// healed only after somebody invites them — at which point they redeem
+// here rather than through sign-up, which would fail on the duplicate
+// email. That is the design spec's corrected wording and it is the
+// reason this route is not a general "make me a member" button.
+//
+// It is rate-limited. See RateLimit.
+func (h *Handlers) Accept(w http.ResponseWriter, r *http.Request) {
+ if !h.allow(w, r) {
+ return
+ }
+ token := r.PathValue("token")
+ rs := h.cfg.Roster
+ ctx := r.Context()
+
+ subject, ok := rs.cfg.Subject(r)
+ subject = strings.TrimSpace(subject)
+ if !ok || subject == "" {
+ // Not signed in. Reconciliation writes a member for the
+ // session in hand; there is no session in hand. The page says
+ // so rather than pretending the token is bad — and it says it
+ // at 403, because this is a refusal and a 200 would let a
+ // caller read "sign in first" as "accepted". The token is not
+ // touched, so the invitee can redeem it once they have a
+ // session.
+ w.WriteHeader(http.StatusForbidden)
+ h.cfg.RenderInvitation(w, r, InvitationPage{
+ Site: h.site(r),
+ Token: token,
+ Error: signInFirstCopy,
+ })
+ return
+ }
+
+ // 1 and 2: an existing row, active or not.
+ switch m, err := rs.BySubject(ctx, subject); {
+ case err == nil && m.Active():
+ h.done(w, r, alreadyNotice)
+ return
+ case err == nil:
+ h.log().Info("idear: a deactivated member tried to redeem an invitation; readmission is Restore",
+ "subject", subject, "member_id", m.ID)
+ h.deadInvitation(w, r, ErrNoInvitation, true)
+ return
+ case errors.Is(err, ErrNotFound):
+ // The orphan. Carry on.
+ default:
+ h.log().Error("idear: reconciliation could not resolve the viewer", "subject", subject, "err", err)
+ h.failInvitation(w, r, token)
+ return
+ }
+
+ // 3: the unclaimed instance.
+ empty, err := rs.IsEmpty(ctx)
+ if err != nil {
+ h.log().Error("idear: reconciliation could not count the roster", "subject", subject, "err", err)
+ h.failInvitation(w, r, token)
+ return
+ }
+ if empty {
+ switch _, err := rs.Claim(ctx, subject, addressOf(subject), ""); {
+ case err == nil:
+ h.log().Info("idear: reconciliation claimed the instance for a signed-in orphan", "subject", subject)
+ h.done(w, r, joinedNotice)
+ return
+ case errors.Is(err, ErrOwnerExists):
+ // Somebody claimed it between the count and here. They
+ // may still hold an invitation of their own; fall through.
+ default:
+ h.log().Error("idear: reconciliation could not claim the instance", "subject", subject, "err", err)
+ h.failInvitation(w, r, token)
+ return
+ }
+ }
+
+ // 4: the token.
+ //
+ // When the session Subject IS an address — which is what keymail
+ // mints — the invitation must be THAT address's. idear knows the
+ // viewer's address only in that case; under password the Subject
+ // is an opaque user id and the token alone is the credential,
+ // which is the same rule admission applies (possession of the
+ // token, and an email match wherever there is an email to match).
+ if addr := addressOf(subject); addr != "" {
+ inv, err := rs.pendingInvitation(ctx, token)
+ if err != nil || normalizeEmail(inv.Email) != normalizeEmail(addr) {
+ if err == nil {
+ h.log().Warn("idear: reconciliation presented an invitation issued to another address",
+ "subject", subject)
+ }
+ h.deadInvitation(w, r, ErrNoInvitation, true)
+ return
+ }
+ }
+
+ m, err := rs.Accept(ctx, token, subject, "")
+ if err != nil {
+ if !errors.Is(err, ErrNoInvitation) {
+ h.log().Error("idear: reconciliation could not redeem an invitation", "subject", subject, "err", err)
+ h.failInvitation(w, r, token)
+ return
+ }
+ h.deadInvitation(w, r, err, true)
+ return
+ }
+ h.log().Info("idear: reconciliation healed an orphan", "subject", subject, "member_id", m.ID, "role", string(m.Role))
+ h.done(w, r, joinedNotice)
+}
+
+// viewer is the guarded handlers' first line: the member Require
+// resolved.
+//
+// A nil viewer here is a MOUNT BUG — the handler ran outside Require —
+// and it is answered with the app's 404 and a loud log line rather
+// than a panic or, worse, a nil actor handed to the store. Every store
+// mutation refuses a nil actor, so this is defence in depth; what it
+// buys is a log line that names the cause.
+func (h *Handlers) viewer(w http.ResponseWriter, r *http.Request) (*Member, bool) {
+ m := From(r)
+ if m == nil {
+ h.log().Error("idear: a handler ran with no viewer; it must be mounted INSIDE Roster.Require",
+ "path", r.URL.Path)
+ h.cfg.Roster.cfg.NotFound(w, r)
+ return nil, false
+ }
+ return m, true
+}
+
+// target resolves the viewer and the {id} in the path to a member row.
+// A miss renders the app's own 404 — the same page a non-member gets,
+// through the same hook.
+func (h *Handlers) target(w http.ResponseWriter, r *http.Request) (*Member, *Member, bool) {
+ viewer, ok := h.viewer(w, r)
+ if !ok {
+ return nil, nil, false
+ }
+ id, err := pathID(r)
+ if err != nil {
+ h.refuse(w, r, viewer, err)
+ return nil, nil, false
+ }
+ target, err := h.cfg.Roster.ByID(r.Context(), id)
+ if err != nil {
+ h.refuse(w, r, viewer, err)
+ return nil, nil, false
+ }
+ return viewer, target, true
+}
+
+// done is the success path: flash a notice, then 303 back to the
+// members page so a refresh cannot repost the mutation.
+func (h *Handlers) done(w http.ResponseWriter, r *http.Request, notice string) {
+ h.flash(w, r, "notice", notice)
+}
+
+func (h *Handlers) flash(w http.ResponseWriter, r *http.Request, kind, msg string) {
+ flash.Set(w, kind, msg)
+ http.Redirect(w, r, h.cfg.MembersPath, http.StatusSeeOther)
+}
+
+// refuse renders a store refusal at the status its CLASS earns.
+//
+// The classes are the whole point of errors.go: ErrInvalid is 400,
+// ErrForbidden is 403 — and ErrLastOwner unwraps to ErrForbidden, so
+// this package's most security-relevant refusal renders 403 and not
+// 500 — ErrNotFound is the app's own 404 page, and anything left is a
+// 500 with no detail on it. Matching the CLASS with errors.Is rather
+// than the sentinel is what keeps a new sentinel from silently
+// becoming a server error.
+func (h *Handlers) refuse(w http.ResponseWriter, r *http.Request, viewer *Member, err error) {
+ switch {
+ case errors.Is(err, ErrNotFound):
+ // A member id that resolves to nothing is answered exactly as
+ // a non-member is: same hook, same bytes.
+ h.cfg.Roster.cfg.NotFound(w, r)
+ case errors.Is(err, ErrNoInvitation):
+ h.page(w, r, viewer, http.StatusNotFound, noInvitationCopy)
+ case errors.Is(err, ErrLastOwner):
+ // Checked before the ErrForbidden arm it unwraps to, because
+ // the reason is true for every actor at every rank and the
+ // generic copy would imply somebody senior could do it.
+ h.page(w, r, viewer, http.StatusForbidden, lastOwnerCopy)
+ case errors.Is(err, ErrInvalid):
+ h.page(w, r, viewer, http.StatusBadRequest, invalidCopy)
+ case errors.Is(err, ErrForbidden):
+ h.page(w, r, viewer, http.StatusForbidden, forbiddenCopy)
+ default:
+ h.log().Error("idear: a members mutation failed", "path", r.URL.Path, "err", err)
+ h.page(w, r, viewer, http.StatusInternalServerError, failedCopy)
+ }
+}
+
+// page renders the members page carrying an error, at status.
+//
+// The status is written BEFORE the renderer runs, so an app's renderer
+// must not write its own — the first WriteHeader wins and a second one
+// is a logged no-op. That is the cost of keeping Status off MembersPage;
+// what it buys is that a renderer cannot accidentally answer 200 to a
+// refusal.
+func (h *Handlers) page(w http.ResponseWriter, r *http.Request, viewer *Member, status int, msg string) {
+ d := h.membersPage(r.Context(), viewer)
+ d.Error = msg
+ w.WriteHeader(status)
+ h.cfg.RenderMembers(w, r, d)
+}
+
+// membersPage gathers what the members page shows. A listing failure
+// is logged and rendered as an EMPTY list rather than as a 500: this
+// is also the failure path's own renderer, and a refusal that turned
+// into a server error because the list behind it could not be read
+// would report the wrong problem.
+func (h *Handlers) membersPage(ctx context.Context, viewer *Member) MembersPage {
+ d := MembersPage{Viewer: viewer, Grantable: Grantable(viewer)}
+ members, err := h.cfg.Roster.Members(ctx)
+ if err != nil {
+ h.log().Error("idear: listing the roster failed", "err", err)
+ }
+ d.Members = members
+ invitations, err := h.cfg.Roster.PendingInvitations(ctx)
+ if err != nil {
+ h.log().Error("idear: listing pending invitations failed", "err", err)
+ }
+ d.Invitations = invitations
+ return d
+}
+
+// deadInvitation renders the one answer every unusable invitation
+// gets. A storage failure is NOT routed here — it renders failedCopy
+// instead, because telling someone their live invitation is dead
+// because the database hiccuped sends them to an admin for a new one
+// that will fail the same way.
+func (h *Handlers) deadInvitation(w http.ResponseWriter, r *http.Request, err error, signedIn bool) {
+ if !errors.Is(err, ErrNoInvitation) {
+ h.log().Error("idear: looking up an invitation failed", "err", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: failedCopy, SignedIn: signedIn})
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: noInvitationCopy, SignedIn: signedIn})
+}
+
+// failInvitation is the invitation routes' 500: a storage failure, with
+// nothing about it on the page.
+func (h *Handlers) failInvitation(w http.ResponseWriter, r *http.Request, token string) {
+ w.WriteHeader(http.StatusInternalServerError)
+ h.cfg.RenderInvitation(w, r, InvitationPage{
+ Site: h.site(r),
+ Token: token,
+ Error: failedCopy,
+ SignedIn: true,
+ })
+}
+
+// standing answers the two questions the invitation page asks about
+// the viewer: are they signed in, and are they the orphan this route
+// is for. A deactivated member is NOT a reconcile candidate — they
+// have a row, and Accept refuses them.
+func (h *Handlers) standing(r *http.Request) (signedIn, reconcile bool) {
+ rs := h.cfg.Roster
+ subject, ok := rs.cfg.Subject(r)
+ if !ok || strings.TrimSpace(subject) == "" {
+ return false, false
+ }
+ switch _, err := rs.BySubject(r.Context(), subject); {
+ case err == nil:
+ return true, false
+ case errors.Is(err, ErrNotFound):
+ return true, true
+ default:
+ h.log().Error("idear: resolving the invitation viewer failed", "err", err)
+ return true, false
+ }
+}
+
+// allow spends one rate-limit token, answering 429 itself when there
+// is none. Plain text: this is the response an abusive client gets,
+// and rendering the app's own page for it would put a template render
+// on the cheapest path an attacker has.
+func (h *Handlers) allow(w http.ResponseWriter, r *http.Request) bool {
+ if h.limit.allow(h.cfg.ClientKey(r)) {
+ return true
+ }
+ h.log().Warn("idear: rate-limited a public invitation request", "path", r.URL.Path)
+ w.Header().Set("Retry-After", strconv.Itoa(int(h.limit.every.Seconds())))
+ http.Error(w, rateLimitedCopy, http.StatusTooManyRequests)
+ return false
+}
+
+// site names the instance on the invitation page.
+func (h *Handlers) site(r *http.Request) string {
+ if h.cfg.Site != "" {
+ return h.cfg.Site
+ }
+ return r.Host
+}
+
+// log is the Roster's logger. The handlers deliberately share it
+// rather than taking one of their own: the distinctions idear refuses
+// to render — which member was refused, whether a 404 was a stranger
+// or a deactivated member — are only ever visible in the log, and they
+// must all land in the same place.
+func (h *Handlers) log() *slog.Logger { return h.cfg.Roster.cfg.Logger }
+
+// field reads ONE named field from the POSTED BODY.
+//
+// By name, one at a time, never by binding a struct: a struct binding
+// accepts every field the struct has, and the fields idear's structs
+// have include Role and DeactivatedAt. It reads PostForm and not Form
+// as well, so a value in the QUERY STRING cannot stand in for a body
+// field — otherwise a link could carry ?role=owner into a POST whose
+// body never mentioned one.
+func field(r *http.Request, name string) string {
+ if r.PostForm == nil {
+ // An unparseable body leaves an empty form behind, which every
+ // caller here treats as a missing field: 400, not a default.
+ _ = r.ParseForm()
+ }
+ return r.PostForm.Get(name)
+}
+
+// pathID is the {id} wildcard, as an int64. Ids come from the URL on
+// every route that has one.
+func pathID(r *http.Request) (int64, error) {
+ return parseID(r.PathValue("id"))
+}
+
+func parseID(s string) (int64, error) {
+ id, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
+ if err != nil || id <= 0 {
+ return 0, ErrInvalidID
+ }
+ return id, nil
+}
+
+// addressOf returns subject when it is an ADDRESS — which is what
+// keymail mints as the session Subject — and "" when it is anything
+// else, such as password's decimal user id. It is the one place idear
+// decides whether it knows the viewer's address from the session
+// alone.
+func addressOf(subject string) string {
+ if strings.Contains(subject, "@") {
+ return subject
+ }
+ return ""
+}
diff --git a/handlers_test.go b/handlers_test.go
new file mode 100644
index 0000000..69f7ce2
--- /dev/null
+++ b/handlers_test.go
@@ -0,0 +1,1411 @@
+package idear_test
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/idear"
+ "amadan.net/rastrillo/idear/internal/ideartest"
+)
+
+// This file is the authorization suite the design calls the
+// deliverable. Every test drives REAL HTTP — chi, a cookie jar, real
+// sessions, rastrillo's real CSRF middleware — because the rules under
+// test are enforced by a stack and not by a function: routing, the
+// session guard, Require, RequireRole, the handler, and then the
+// store's own transaction. A test that called a handler directly would
+// prove the last layer and assume the rest.
+//
+// TWO RULES SHAPE HOW IT IS WRITTEN.
+//
+// First, nothing here restates the route table or the role matrix. The
+// expectations are DERIVED from idear.Handlers.Routes() and from
+// Role.AtLeast — the same list the app mounts and the same predicate
+// the middleware enforces. Round 1 of the bake-off found a test
+// written to whitelist the very payload it listed, and a suite that
+// quotes the implementation proves only that the implementation equals
+// itself. Add a tenth route and these tests exercise it without being
+// edited; change its rank floor and they change what they demand.
+//
+// Second, every claim of coverage here was checked by MUTATING the
+// handler and confirming the test goes red. The table is in
+// .superpowers/sdd/2026-08-23-idear/task-5-report.md.
+
+// ---------------------------------------------------------------
+// Derivation helpers: everything below reads the mounted route table.
+// ---------------------------------------------------------------
+
+// instantiate fills a route pattern's wildcards. It is the one place
+// a URL is built, so a route that grows a new wildcard fails loudly
+// here rather than quietly matching nothing.
+func instantiate(pattern, id, token string) string {
+ s := strings.ReplaceAll(pattern, "{id}", id)
+ return strings.ReplaceAll(s, "{token}", token)
+}
+
+// guarded is the routes behind the membership gate — everything that
+// is not public, taken from the mounted table.
+func guarded(app *ideartest.App) []idear.Route {
+ var out []idear.Route
+ for _, rt := range app.Handlers.Routes() {
+ if !rt.Public {
+ out = append(out, rt)
+ }
+ }
+ return out
+}
+
+// management is the routes that require a rank ABOVE plain membership:
+// derived with Role.AtLeast, the same comparison RequireRole makes, so
+// a route whose floor is raised or lowered moves between these sets on
+// its own.
+func management(app *ideartest.App) []idear.Route {
+ var out []idear.Route
+ for _, rt := range guarded(app) {
+ if !idear.RoleMember.AtLeast(rt.Min) {
+ out = append(out, rt)
+ }
+ }
+ return out
+}
+
+// wantStatus is what a viewer must get from rt when the request is
+// well-formed but its PAYLOAD IS NOT — an id of "0", an empty form.
+//
+// The invalid payload is what makes this a pure authorization probe.
+// An authorized actor gets 400 because the request is malformed, and
+// nothing in the database moves; an unauthorized one never gets far
+// enough to be told, so 403 and 404 still mean exactly what they mean.
+// That lets one probe be run against every route for every actor
+// without any of them mutating state the next probe depends on.
+func wantStatus(rt idear.Route, viewer *idear.Member) int {
+ switch {
+ case viewer == nil || !viewer.Active():
+ return http.StatusNotFound
+ case !viewer.Role.AtLeast(rt.Min):
+ return http.StatusForbidden
+ case rt.Method == http.MethodGet:
+ return http.StatusOK
+ default:
+ return http.StatusBadRequest
+ }
+}
+
+// probe issues the invalid-payload request for rt.
+func probe(c *ideartest.Client, rt idear.Route) *ideartest.Result {
+ path := instantiate(rt.Pattern, "0", "0")
+ if rt.Method == http.MethodGet {
+ return c.Get(path)
+ }
+ return c.Post(path, url.Values{})
+}
+
+// checkAccess walks every guarded route with the invalid payload and
+// demands exactly the status wantStatus derives for this viewer — no
+// more access and no less. It returns the bodies of every 404 it saw,
+// for the byte-identity assertion.
+func checkAccess(t *testing.T, app *ideartest.App, c *ideartest.Client, viewer *idear.Member, what string) []string {
+ t.Helper()
+ var notFound []string
+ for _, rt := range guarded(app) {
+ res := probe(c, rt)
+ want := wantStatus(rt, viewer)
+ if res.Status != want {
+ t.Errorf("%s: %s %s → %d, want %d; body %q", what, rt.Method, rt.Pattern, res.Status, want, res.Body)
+ }
+ switch res.Status {
+ case http.StatusNotFound:
+ notFound = append(notFound, res.Body)
+ case http.StatusForbidden:
+ if res.Body != ideartest.AppForbidden {
+ t.Errorf("%s: %s %s answered 403 with %q, want the app's own 403 page", what, rt.Method, rt.Pattern, res.Body)
+ }
+ }
+ }
+ return notFound
+}
+
+// ---------------------------------------------------------------
+// Invitation plumbing: a Deliver hook, so a test can hold the token.
+// ---------------------------------------------------------------
+
+// collector is the app's mail: it records the links idear hands it.
+// Mutex-guarded because the race tests invite from several goroutines.
+type collector struct {
+ mu sync.Mutex
+ links []string
+}
+
+func (c *collector) deliver(r *http.Request, inv *idear.Invitation, link string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.links = append(c.links, link)
+ return nil
+}
+
+// last is the token from the most recent link.
+func (c *collector) last(t *testing.T) string {
+ t.Helper()
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(c.links) == 0 {
+ t.Fatal("no invitation was delivered")
+ }
+ return tokenOf(t, c.links[len(c.links)-1])
+}
+
+func tokenOf(t *testing.T, link string) string {
+ t.Helper()
+ const prefix = "/invitations/"
+ if !strings.HasPrefix(link, prefix) {
+ t.Fatalf("invitation link %q does not start with %q", link, prefix)
+ }
+ return strings.TrimPrefix(link, prefix)
+}
+
+// newApp is an instance whose invitations are delivered to col.
+func newApp(t *testing.T) (*ideartest.App, *collector) {
+ t.Helper()
+ col := &collector{}
+ app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{Deliver: col.deliver})
+ return app, col
+}
+
+// invite posts an invitation through the HTTP route and returns the
+// plaintext token, failing the test if the invite did not take.
+func invite(t *testing.T, app *ideartest.App, col *collector, c *ideartest.Client, email string, role idear.Role) string {
+ t.Helper()
+ res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}})
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("inviting %s as %s: status %d, body %q", email, role, res.Status, res.Body)
+ }
+ return col.last(t)
+}
+
+// memberBySubject reads the row straight out of the database. Every
+// assertion about what a request DID goes through here: a 303 says the
+// handler thought it worked, and only the row says whether it did.
+func memberBySubject(t *testing.T, app *ideartest.App, subject string) *idear.Member {
+ t.Helper()
+ m, err := app.H.Roster.BySubject(app.H.Ctx(), subject)
+ if err != nil {
+ return nil
+ }
+ return m
+}
+
+// ---------------------------------------------------------------
+// §7.1 — a non-member is refused read and write on every route.
+// ---------------------------------------------------------------
+
+func TestNonMemberIsRefusedEveryRouteWithIdenticalNotFounds(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ admin := app.H.Member(idear.RoleAdmin)
+ gone := app.H.Deactivated(idear.RoleMember)
+
+ // Three ways of not being an active member, all of which must be
+ // answered identically: never a member, signed out entirely, and
+ // removed.
+ strangers := map[string]*ideartest.Client{
+ "a signed-in stranger": app.SignIn("stranger-with-no-row"),
+ "a signed-out visitor": app.Visitor(),
+ "a removed member": app.As(gone),
+ }
+
+ var bodies []string
+ for what, c := range strangers {
+ bodies = append(bodies, checkAccess(t, app, c, nil, what)...)
+ }
+
+ // Deeply nested and oversized ids, on every route that takes one:
+ // a stranger must not be able to tell a real id from a fabricated
+ // one, or a route that exists from a path that does not.
+ stranger := strangers["a signed-in stranger"]
+ for _, rt := range guarded(app) {
+ for _, id := range []string{
+ fmt.Sprint(owner.ID),
+ fmt.Sprint(admin.ID),
+ "999999999999",
+ "-1",
+ "1/2/3",
+ "..%2F..%2Fmembers",
+ } {
+ path := instantiate(rt.Pattern, id, id)
+ var res *ideartest.Result
+ if rt.Method == http.MethodGet {
+ res = stranger.Get(path)
+ } else {
+ res = stranger.Post(path, url.Values{"role": {"owner"}, "member": {fmt.Sprint(owner.ID)}})
+ }
+ if res.Status != http.StatusNotFound {
+ t.Errorf("stranger: %s %s → %d, want 404; body %q", rt.Method, path, res.Status, res.Body)
+ }
+ bodies = append(bodies, res.Body)
+ }
+ }
+
+ // Byte-identical: one distinct body across every refusal above,
+ // and it is the app's own 404 page. A 404 that varies with WHY it
+ // was refused is the membership oracle the design forbids.
+ distinct := map[string]int{}
+ for _, b := range bodies {
+ distinct[b]++
+ }
+ if len(distinct) != 1 {
+ t.Fatalf("refusals rendered %d distinct bodies, want 1: %v", len(distinct), distinct)
+ }
+ for b := range distinct {
+ if b != ideartest.AppNotFound {
+ t.Fatalf("refusal body = %q, want the app's own 404 page %q", b, ideartest.AppNotFound)
+ }
+ }
+
+ // And nothing the stranger posted moved a row.
+ if got := app.H.CountMembers(); got != 3 {
+ t.Errorf("the roster holds %d rows, want the 3 it was seeded with", got)
+ }
+ app.H.TheOwner()
+}
+
+// ---------------------------------------------------------------
+// §7.2 — a Member is refused every management action.
+// ---------------------------------------------------------------
+
+func TestMemberIsRefusedEveryManagementAction(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ victim := app.H.Member(idear.RoleMember)
+ plain := app.H.Member(idear.RoleMember)
+ c := app.As(plain)
+
+ // The whole guarded table, derived: a Member may read the members
+ // page and may do nothing else.
+ checkAccess(t, app, c, plain, "a plain member")
+
+ if len(management(app)) == 0 {
+ t.Fatal("no management routes were derived from the route table; the derivation is broken")
+ }
+
+ // The same routes again with REAL ids and real payloads, so the
+ // refusal is not an artifact of the malformed probe.
+ for _, rt := range management(app) {
+ path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0")
+ res := c.Post(path, url.Values{
+ "role": {string(idear.RoleAdmin)},
+ "email": {"newcomer@example.test"},
+ "member": {fmt.Sprint(victim.ID)},
+ })
+ if res.Status != http.StatusForbidden {
+ t.Errorf("%s %s by a member → %d, want 403; body %q", rt.Method, path, res.Status, res.Body)
+ }
+ }
+
+ // Nothing moved: same roles, same activity, no invitations.
+ if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() {
+ t.Errorf("the victim is now %s/%s; a member's refused actions still landed", got.Role, ideartest.AppNotFound)
+ }
+ if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner {
+ t.Errorf("the owner is now %s", got.Role)
+ }
+ invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
+ if err != nil {
+ t.Fatalf("listing invitations: %v", err)
+ }
+ if len(invs) != 0 {
+ t.Errorf("a member's refused invite created %d invitations", len(invs))
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.3 — an Admin cannot touch an Admin or the Owner.
+// ---------------------------------------------------------------
+
+func TestAdminCannotActOnAdminOrOwner(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ actor := app.H.Member(idear.RoleAdmin)
+ peer := app.H.Member(idear.RoleAdmin)
+ goneAdmin := app.H.Deactivated(idear.RoleAdmin)
+ c := app.As(actor)
+
+ // Every target an Admin may not act on, including themselves —
+ // self-management is how an instance ends up with nobody able to
+ // administer it.
+ targets := map[string]*idear.Member{
+ "the owner": owner,
+ "a peer admin": peer,
+ "a deactivated admin": goneAdmin,
+ "themselves": actor,
+ "themselves, demotion": actor,
+ "the owner, restoration": owner,
+ }
+ // The three target-shaped mutations, derived from the route table
+ // rather than listed: every management route that carries an {id}.
+ var byID []idear.Route
+ for _, rt := range management(app) {
+ if strings.Contains(rt.Pattern, "{id}") && !strings.Contains(rt.Pattern, "invitations") {
+ byID = append(byID, rt)
+ }
+ }
+ if len(byID) == 0 {
+ t.Fatal("no id-addressed management routes were derived; the derivation is broken")
+ }
+
+ for what, target := range targets {
+ for _, rt := range byID {
+ path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0")
+ res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}})
+ if res.Status != http.StatusForbidden {
+ t.Errorf("admin → %s: %s %s → %d, want 403; body %q", what, rt.Method, path, res.Status, res.Body)
+ }
+ }
+ }
+
+ // The rows, not the responses.
+ if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner || !got.Active() {
+ t.Errorf("the owner is now %s/%v after an admin's attempts", got.Role, got.Active())
+ }
+ if got := app.H.Reload(peer.ID); got.Role != idear.RoleAdmin || !got.Active() {
+ t.Errorf("the peer admin is now %s/%v after an admin's attempts", got.Role, got.Active())
+ }
+ if got := app.H.Reload(goneAdmin.ID); got.Active() {
+ t.Error("an admin restored a deactivated admin; only the owner may act on an admin")
+ }
+ if got := app.H.Reload(actor.ID); got.Role != idear.RoleAdmin || !got.Active() {
+ t.Errorf("the acting admin acted on themselves: now %s/%v", got.Role, got.Active())
+ }
+
+ // What the admin CAN do, so the test is not green because the
+ // admin can do nothing at all.
+ plain := app.H.Member(idear.RoleMember)
+ res := c.Post(fmt.Sprintf("/members/%d/remove", plain.ID), url.Values{})
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("an admin removing a member → %d, want 303; body %q", res.Status, res.Body)
+ }
+ if app.H.Reload(plain.ID).Active() {
+ t.Error("an admin's legitimate removal did not land")
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.4 — a posted role=owner never lands, on any path, for any actor.
+// ---------------------------------------------------------------
+
+func TestPostedOwnerRoleNeverLands(t *testing.T) {
+ // One instance per actor, so an attempt by one cannot be masked by
+ // a refusal for another.
+ actors := []struct {
+ name string
+ role idear.Role
+ }{
+ {"the owner", idear.RoleOwner},
+ {"an admin", idear.RoleAdmin},
+ {"a member", idear.RoleMember},
+ {"a stranger", ""},
+ }
+ for _, actor := range actors {
+ t.Run(actor.name, func(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ admin := app.H.Member(idear.RoleAdmin)
+ plain := app.H.Member(idear.RoleMember)
+ gone := app.H.Deactivated(idear.RoleMember)
+
+ var c *ideartest.Client
+ switch actor.role {
+ case idear.RoleOwner:
+ c = app.As(owner)
+ case idear.RoleAdmin:
+ c = app.As(admin)
+ case idear.RoleMember:
+ c = app.As(plain)
+ default:
+ c = app.SignIn("stranger-with-no-row")
+ }
+
+ // The payload is ONE form for every route: every field
+ // idear reads, all of them carrying the escalation.
+ //
+ // "member" names a row that does not exist, so the ONE
+ // route that may legitimately mint an owner cannot
+ // succeed here and no 303 below can be a real transfer.
+ // (That Transfer works at all, and reads no role while
+ // doing it, is TestTransferIsTheOnlyPathToOwner.)
+ attack := url.Values{
+ "role": {string(idear.RoleOwner)},
+ "email": {"escalation@example.test"},
+ "member": {"999999999"},
+ }
+ // Every route the app mounts, public ones included, at
+ // every interesting target. Some of these legitimately
+ // answer 303 — an admin really may remove a member — so
+ // the status is not the assertion; the rows below are.
+ for _, rt := range app.Handlers.Routes() {
+ if rt.Method != http.MethodPost {
+ continue
+ }
+ for _, target := range []*idear.Member{owner, admin, plain, gone} {
+ path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0")
+ c.Post(path, attack)
+ }
+ }
+
+ // THE ROWS. Exactly one owner, and it is the row that was
+ // seeded as owner — no promotion, and no new row.
+ if got := app.H.TheOwner(); got.ID != owner.ID {
+ t.Fatalf("the owner is now member %d (%s), want the seeded owner %d", got.ID, got.Subject, owner.ID)
+ }
+ for _, m := range []*idear.Member{admin, plain, gone} {
+ if got := app.H.Reload(m.ID); got.Role == idear.RoleOwner {
+ t.Errorf("member %d was promoted to owner by a posted role", m.ID)
+ }
+ }
+ // And no invitation carries the role either: an
+ // owner-role invitation is a delayed escalation, so the
+ // refusal has to hold at the mint and not only at the
+ // redemption.
+ var invs []idear.Invitation
+ if err := app.H.DB.G.Where("role = ?", idear.RoleOwner).Find(&invs).Error; err != nil {
+ t.Fatalf("listing owner invitations: %v", err)
+ }
+ if len(invs) != 0 {
+ t.Errorf("%d invitations were minted at role owner", len(invs))
+ }
+ })
+ }
+}
+
+// TestTransferIsTheOnlyPathToOwner is the other half of §7.4: the one
+// route that MAY mint an owner does so from its own rules and never
+// from the posted role, and the instance still has exactly one.
+func TestTransferIsTheOnlyPathToOwner(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ admin := app.H.Member(idear.RoleAdmin)
+ c := app.As(owner)
+
+ res := c.Post("/members/transfer", url.Values{
+ "member": {fmt.Sprint(admin.ID)},
+ // Posted, and irrelevant: Transfer reads no role at all.
+ "role": {string(idear.RoleMember)},
+ })
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("transfer → %d, want 303; body %q", res.Status, res.Body)
+ }
+ if got := app.H.TheOwner(); got.ID != admin.ID {
+ t.Fatalf("ownership landed on member %d, want %d", got.ID, admin.ID)
+ }
+ if got := app.H.Reload(owner.ID); got.Role != idear.RoleAdmin {
+ t.Errorf("the outgoing owner is %s, want admin", got.Role)
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.5 — the single-owner invariant across concurrent transfers.
+// ---------------------------------------------------------------
+
+func TestConcurrentTransfersThroughHTTPLeaveOneOwner(t *testing.T) {
+ const (
+ n = 6
+ rounds = 5
+ )
+ for round := range rounds {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ targets := make([]*idear.Member, n)
+ for i := range targets {
+ targets[i] = app.H.Member(idear.RoleAdmin)
+ }
+ c := app.As(owner)
+
+ gate, wait := release()
+ codes := make([]int, n)
+ errs := make([]error, n)
+ var wg sync.WaitGroup
+ for i := range n {
+ wg.Add(1)
+ // The spawn order alternates with i's parity for the same
+ // reason pair() flips: closing a channel readies waiters
+ // FIFO and the last one readied runs first, so a fixed
+ // order biases which racer wins.
+ go func() {
+ defer wg.Done()
+ wait()
+ res, err := c.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(targets[i].ID)}})
+ if err != nil {
+ errs[i] = err
+ return
+ }
+ codes[i] = res.Status
+ }()
+ }
+ close(gate)
+ wg.Wait()
+
+ won := 0
+ for i, code := range codes {
+ if errs[i] != nil {
+ t.Fatalf("round %d: transfer %d failed in transport: %v", round, i, errs[i])
+ }
+ if code == http.StatusSeeOther {
+ won++
+ }
+ }
+ if won != 1 {
+ t.Fatalf("round %d: %d of %d concurrent transfers were accepted, want exactly 1 (codes %v)", round, won, n, codes)
+ }
+ got := app.H.TheOwner()
+ if !got.Active() {
+ t.Fatalf("round %d: the surviving owner %d is deactivated", round, got.ID)
+ }
+ if got.ID == owner.ID {
+ t.Fatalf("round %d: a transfer was accepted but ownership did not move", round)
+ }
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.6 — an invited address cannot be claimed without the token.
+// ---------------------------------------------------------------
+
+func TestInvitedAddressCannotBeClaimedWithoutTheToken(t *testing.T) {
+ app, col := newApp(t)
+ owner := app.H.Owner()
+ oc := app.As(owner)
+
+ const invited = "admin@corp.test"
+ token := invite(t, app, col, oc, invited, idear.RoleAdmin)
+
+ // The attacker knows the address — it is the whole premise — and
+ // signs in as it (which is what keymail's subject looks like).
+ // Every public route, without the token, must leave them out.
+ attacker := app.SignIn(invited)
+ for _, rt := range app.Handlers.Routes() {
+ if !rt.Public {
+ continue
+ }
+ for _, tok := range []string{"", "not-a-token", strings.Repeat("0", 64)} {
+ path := instantiate(rt.Pattern, "0", url.PathEscape(tok))
+ var res *ideartest.Result
+ if rt.Method == http.MethodGet {
+ res = attacker.Get(path)
+ } else {
+ res = attacker.Post(path, url.Values{"role": {"owner"}, "email": {invited}})
+ }
+ if res.Status == http.StatusSeeOther {
+ t.Errorf("%s %s admitted an address with no token", rt.Method, path)
+ }
+ }
+ }
+ if m := memberBySubject(t, app, invited); m != nil {
+ t.Fatalf("the invited address was admitted at %s with no token", m.Role)
+ }
+ if got := app.H.CountMembers(); got != 1 {
+ t.Fatalf("the roster holds %d rows, want just the owner", got)
+ }
+
+ // The token still works afterwards — the refusals above did not
+ // consume it, which is what makes them refusals rather than a
+ // broken flow.
+ res := attacker.Post("/invitations/"+token, url.Values{})
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("redeeming the real token → %d, want 303; body %q", res.Status, res.Body)
+ }
+ m := memberBySubject(t, app, invited)
+ if m == nil || m.Role != idear.RoleAdmin {
+ t.Fatalf("after redeeming the token the member is %+v, want an admin", m)
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.7 — Revoke racing Accept never admits.
+// ---------------------------------------------------------------
+
+func TestRevokeRacingAcceptThroughHTTPNeverAdmits(t *testing.T) {
+ const rounds = 24
+ admitted, killed := 0, 0
+
+ for round := range rounds {
+ app, col := newApp(t)
+ owner := app.H.Owner()
+ oc := app.As(owner)
+
+ const invitee = "racer@example.test"
+ token := invite(t, app, col, oc, invitee, idear.RoleMember)
+ invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
+ if err != nil || len(invs) != 1 {
+ t.Fatalf("round %d: pending invitations = %v, %v", round, invs, err)
+ }
+ inv := invs[0]
+
+ // The orphan: an app user with a session and no member row,
+ // which is exactly who this route is for.
+ orphan := app.SignIn(fmt.Sprintf("orphan-%d", round))
+
+ var acceptRes, revokeRes *ideartest.Result
+ var acceptErr, revokeErr error
+ pair(round%2 == 0,
+ func() {
+ acceptRes, acceptErr = orphan.TryPost("/invitations/"+token, url.Values{})
+ },
+ func() {
+ revokeRes, revokeErr = oc.TryPost(fmt.Sprintf("/members/invitations/%d/revoke", inv.ID), url.Values{})
+ })
+ if acceptErr != nil || revokeErr != nil {
+ t.Fatalf("round %d: transport failed: accept %v, revoke %v", round, acceptErr, revokeErr)
+ }
+
+ row := app.H.Invitation(inv.ID)
+ member := memberBySubject(t, app, orphan.Subject)
+
+ switch {
+ case acceptRes.Status == http.StatusSeeOther:
+ admitted++
+ if member == nil {
+ t.Fatalf("round %d: acceptance answered 303 but wrote no member row", round)
+ }
+ if row.AcceptedAt == nil {
+ t.Fatalf("round %d: a member was admitted from an invitation that was never marked accepted", round)
+ }
+ if row.RevokedAt != nil {
+ t.Fatalf("round %d: THE INVARIANT BROKE — a REVOKED invitation admitted a member", round)
+ }
+ if revokeRes.Status == http.StatusSeeOther {
+ t.Fatalf("round %d: both the accept and the revoke were accepted", round)
+ }
+ default:
+ killed++
+ if member != nil {
+ t.Fatalf("round %d: acceptance was refused (%d) but a member row exists: %+v", round, acceptRes.Status, member)
+ }
+ if revokeRes.Status != http.StatusSeeOther {
+ t.Fatalf("round %d: neither side won: accept %d, revoke %d", round, acceptRes.Status, revokeRes.Status)
+ }
+ if row.RevokedAt == nil {
+ t.Fatalf("round %d: the revoke was accepted but the row is not revoked", round)
+ }
+ }
+ }
+
+ // Both branches must actually have been exercised. A race test
+ // that only ever resolves one way is green for a reason unrelated
+ // to the property it claims to check.
+ t.Logf("the race split %d admitted / %d revoked over %d rounds", admitted, killed, rounds)
+ if admitted == 0 || killed == 0 {
+ t.Fatalf("the race never split: %d admitted, %d revoked over %d rounds", admitted, killed, rounds)
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.8 — expiry is refused, and an acceptance cannot be replayed.
+// ---------------------------------------------------------------
+
+func TestExpiredInvitationIsRefused(t *testing.T) {
+ app, col := newApp(t)
+ oc := app.As(app.H.Owner())
+ token := invite(t, app, col, oc, "late@example.test", idear.RoleMember)
+
+ invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
+ if err != nil || len(invs) != 1 {
+ t.Fatalf("pending invitations = %v, %v", invs, err)
+ }
+ app.H.Expire(invs[0].ID)
+
+ // The public GET stops offering it...
+ visitor := app.Visitor()
+ if res := visitor.Get("/invitations/" + token); res.Status != http.StatusNotFound {
+ t.Errorf("GET an expired invitation → %d, want 404; body %q", res.Status, res.Body)
+ }
+ // ...and the redemption is refused, with no row written.
+ orphan := app.SignIn("late-orphan")
+ res := orphan.Post("/invitations/"+token, url.Values{})
+ if res.Status != http.StatusNotFound {
+ t.Errorf("POST an expired invitation → %d, want 404; body %q", res.Status, res.Body)
+ }
+ if m := memberBySubject(t, app, "late-orphan"); m != nil {
+ t.Fatalf("an expired invitation admitted %+v", m)
+ }
+}
+
+func TestAcceptedInvitationCannotBeReplayed(t *testing.T) {
+ app, col := newApp(t)
+ oc := app.As(app.H.Owner())
+ token := invite(t, app, col, oc, "first@example.test", idear.RoleMember)
+
+ first := app.SignIn("first-orphan")
+ if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
+ t.Fatalf("the first redemption → %d, want 303; body %q", res.Status, res.Body)
+ }
+ if m := memberBySubject(t, app, "first-orphan"); m == nil {
+ t.Fatal("the first redemption wrote no member row")
+ }
+
+ // A DIFFERENT session replaying the same link gets nothing.
+ second := app.SignIn("second-orphan")
+ res := second.Post("/invitations/"+token, url.Values{})
+ if res.Status != http.StatusNotFound {
+ t.Errorf("replaying a spent invitation → %d, want 404; body %q", res.Status, res.Body)
+ }
+ if m := memberBySubject(t, app, "second-orphan"); m != nil {
+ t.Fatalf("a spent invitation admitted a second person: %+v", m)
+ }
+
+ // And the ORIGINAL redeemer replaying it changes nothing: they are
+ // already a member, and no second row appears.
+ if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
+ t.Errorf("a member reopening their own link → %d, want 303", res.Status)
+ }
+ if got := app.H.CountMembers(); got != 2 {
+ t.Fatalf("the roster holds %d rows, want the owner and one redeemer", got)
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.9 — the orphan, healed by POST /invitations/{token}.
+// ---------------------------------------------------------------
+
+func TestOrphanIs404edEverywhereUntilReconciled(t *testing.T) {
+ app, col := newApp(t)
+ owner := app.H.Owner()
+ oc := app.As(owner)
+
+ // The orphan's subject is MIXED CASE and address-shaped, which is
+ // what keymail mints — auth takes the address the visitor typed.
+ // The member row this route writes must be canonicalised by the
+ // store, or the person signs in forever and 404s forever.
+ const typed = "Orphan@Example.Test"
+ orphan := app.SignIn(typed)
+
+ // Every guarded route, derived: 404, byte-identical, before.
+ for _, body := range checkAccess(t, app, orphan, nil, "an orphan") {
+ if body != ideartest.AppNotFound {
+ t.Fatalf("an orphan's 404 body = %q, want the app's own page", body)
+ }
+ }
+
+ // Somebody invites them. Only then can the route heal them — it
+ // needs a valid token, which is the spec's corrected wording.
+ token := invite(t, app, col, oc, "orphan@example.test", idear.RoleAdmin)
+
+ res := orphan.Post("/invitations/"+token, url.Values{})
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("reconciliation → %d, want 303; body %q", res.Status, res.Body)
+ }
+ if res.Location != "/members" {
+ t.Errorf("reconciliation redirected to %q, want /members", res.Location)
+ }
+
+ // The row: written from the LIVE SESSION SUBJECT, through the
+ // store, and therefore canonicalised.
+ m := memberBySubject(t, app, typed)
+ if m == nil {
+ t.Fatal("reconciliation wrote no member row")
+ }
+ if m.Subject != strings.ToLower(typed) {
+ t.Errorf("the member's subject is %q, want the canonical %q; a Member built in the handler would carry the typed form", m.Subject, strings.ToLower(typed))
+ }
+ if m.Role != idear.RoleAdmin {
+ t.Errorf("the healed member is %s, want the invitation's admin", m.Role)
+ }
+
+ // And afterwards they have exactly the access their role earns —
+ // derived, so "exactly" means every route.
+ checkAccess(t, app, orphan, m, "a healed orphan")
+}
+
+func TestReconciliationClaimsAnUnclaimedInstance(t *testing.T) {
+ // The other orphan: admission created the app user and its Claim
+ // did not commit, so the roster is still empty and there is nobody
+ // to invite them.
+ app, _ := newApp(t)
+ if got := app.H.CountMembers(); got != 0 {
+ t.Fatalf("a fresh instance holds %d rows", got)
+ }
+ orphan := app.SignIn("1") // password's subject: a decimal user id
+
+ res := orphan.Post("/invitations/anything", url.Values{})
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("reconciliation on an empty roster → %d, want 303; body %q", res.Status, res.Body)
+ }
+ got := app.H.TheOwner()
+ if got.Subject != "1" {
+ t.Fatalf("the claim wrote subject %q, want the live session's %q", got.Subject, "1")
+ }
+
+ // And it does not reopen: the next signed-in stranger is not an
+ // owner, and holds no token.
+ stranger := app.SignIn("2")
+ if res := stranger.Post("/invitations/anything", url.Values{}); res.Status == http.StatusSeeOther {
+ t.Fatal("a second signed-in stranger was admitted by the reconciliation route")
+ }
+ app.H.TheOwner()
+ if got := app.H.CountMembers(); got != 1 {
+ t.Fatalf("the roster holds %d rows, want 1", got)
+ }
+}
+
+func TestReconciliationRefusesAnotherAddressesInvitation(t *testing.T) {
+ // Under keymail the session subject IS a verified address, so idear
+ // knows who the viewer is and the invitation must be theirs.
+ app, col := newApp(t)
+ oc := app.As(app.H.Owner())
+ token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
+
+ interloper := app.SignIn("someone.else@example.test")
+ res := interloper.Post("/invitations/"+token, url.Values{})
+ if res.Status != http.StatusNotFound {
+ t.Errorf("redeeming another address's invitation → %d, want 404; body %q", res.Status, res.Body)
+ }
+ if m := memberBySubject(t, app, "someone.else@example.test"); m != nil {
+ t.Fatalf("another address's invitation admitted %+v", m)
+ }
+ // The invitation is untouched, so the intended recipient can still
+ // use it.
+ intended := app.SignIn("intended@example.test")
+ if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
+ t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
+ }
+}
+
+func TestReconciliationRefusesASignedOutVisitor(t *testing.T) {
+ // The route writes a member row FOR THE SESSION IN HAND. With no
+ // session there is nobody to write it for, and a token holder who
+ // is merely holding a link must not be able to conjure a
+ // membership out of it — under password they would have no app
+ // user either, and the row would join to nothing.
+ app, col := newApp(t)
+ oc := app.As(app.H.Owner())
+ token := invite(t, app, col, oc, "expected@example.test", idear.RoleMember)
+
+ res := app.Visitor().Post("/invitations/"+token, url.Values{})
+ if res.Status != http.StatusForbidden {
+ t.Fatalf("a signed-out redemption → %d, want a 403 refusal; body %q", res.Status, res.Body)
+ }
+ if got := app.H.CountMembers(); got != 1 {
+ t.Fatalf("the roster holds %d rows, want just the owner", got)
+ }
+
+ // And the token was not spent by the refusal: the person it was
+ // meant for can still redeem it once they are signed in.
+ invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
+ if err != nil || len(invs) != 1 {
+ t.Fatalf("pending invitations = %v, %v; the refusal consumed the invitation", invs, err)
+ }
+ signedIn := app.SignIn("expected@example.test")
+ if res := signedIn.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
+ t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
+ }
+}
+
+func TestReconciliationRefusesADeactivatedMember(t *testing.T) {
+ app, col := newApp(t)
+ oc := app.As(app.H.Owner())
+ gone := app.H.Deactivated(idear.RoleAdmin)
+ token := invite(t, app, col, oc, gone.Email, idear.RoleMember)
+
+ c := app.As(gone)
+ if res := c.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusNotFound {
+ t.Errorf("a removed member redeeming an invitation → %d, want 404; body %q", res.Status, res.Body)
+ }
+ if app.H.Reload(gone.ID).Active() {
+ t.Fatal("a removed member let themselves back in with an invitation; readmission is Restore")
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.10 — Transfer racing Deactivate never yields a deactivated Owner.
+// ---------------------------------------------------------------
+
+func TestTransferRacingRemoveThroughHTTP(t *testing.T) {
+ const rounds = 24
+ transferred, removed := 0, 0
+
+ for round := range rounds {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ admin := app.H.Member(idear.RoleAdmin)
+ target := app.H.Member(idear.RoleMember)
+
+ oc := app.As(owner)
+ ac := app.As(admin)
+
+ var transferRes, removeRes *ideartest.Result
+ var transferErr, removeErr error
+ pair(round%2 == 0,
+ func() {
+ transferRes, transferErr = oc.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(target.ID)}})
+ },
+ func() {
+ removeRes, removeErr = ac.TryPost(fmt.Sprintf("/members/%d/remove", target.ID), url.Values{})
+ })
+ if transferErr != nil || removeErr != nil {
+ t.Fatalf("round %d: transport failed: transfer %v, remove %v", round, transferErr, removeErr)
+ }
+
+ // The invariant, whichever way it resolved: exactly one owner,
+ // and that owner is ACTIVE. A deactivated owner is an instance
+ // nobody can administer and nobody can be promoted out of.
+ got := app.H.TheOwner()
+ if !got.Active() {
+ t.Fatalf("round %d: THE INVARIANT BROKE — the owner (member %d) is deactivated", round, got.ID)
+ }
+ row := app.H.Reload(target.ID)
+ if transferRes.Status == http.StatusSeeOther {
+ transferred++
+ if got.ID != target.ID {
+ t.Fatalf("round %d: the transfer was accepted but ownership is on member %d", round, got.ID)
+ }
+ if !row.Active() {
+ t.Fatalf("round %d: the new owner is deactivated", round)
+ }
+ } else {
+ removed++
+ if got.ID != owner.ID {
+ t.Fatalf("round %d: the transfer was refused (%d) but ownership moved to %d", round, transferRes.Status, got.ID)
+ }
+ if removeRes.Status != http.StatusSeeOther {
+ t.Fatalf("round %d: neither side won: transfer %d, remove %d", round, transferRes.Status, removeRes.Status)
+ }
+ if row.Active() {
+ t.Fatalf("round %d: the removal was accepted but the target is still active", round)
+ }
+ }
+ }
+ t.Logf("the race split %d transfers / %d removals over %d rounds", transferred, removed, rounds)
+ if transferred == 0 || removed == 0 {
+ t.Fatalf("the race never split: %d transfers, %d removals over %d rounds", transferred, removed, rounds)
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.11 — reactivation restores exactly the prior access.
+// ---------------------------------------------------------------
+
+func TestReactivatedMemberRegainsExactlyTheirPriorAccess(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ admin := app.H.Member(idear.RoleAdmin)
+ oc := app.As(owner)
+ c := app.As(admin)
+
+ // Before: the whole route table, recorded.
+ before := map[string]int{}
+ for _, rt := range guarded(app) {
+ res := probe(c, rt)
+ before[rt.Method+" "+rt.Pattern] = res.Status
+ if want := wantStatus(rt, admin); res.Status != want {
+ t.Fatalf("before removal: %s %s → %d, want %d", rt.Method, rt.Pattern, res.Status, want)
+ }
+ }
+
+ // Removed: nothing at all, and the session still exists — under
+ // password a removed member can still hold a session, and what
+ // stops them is Require on every route.
+ if res := oc.Post(fmt.Sprintf("/members/%d/remove", admin.ID), url.Values{}); res.Status != http.StatusSeeOther {
+ t.Fatalf("removing the admin → %d; body %q", res.Status, res.Body)
+ }
+ checkAccess(t, app, c, nil, "a removed admin")
+
+ // Restored: the SAME statuses as before, route for route. More
+ // would be an escalation; fewer would make Restore useless.
+ if res := oc.Post(fmt.Sprintf("/members/%d/restore", admin.ID), url.Values{}); res.Status != http.StatusSeeOther {
+ t.Fatalf("restoring the admin → %d; body %q", res.Status, res.Body)
+ }
+ restored := app.H.Reload(admin.ID)
+ if !restored.Active() || restored.Role != idear.RoleAdmin {
+ t.Fatalf("the restored member is %s/%v", restored.Role, restored.Active())
+ }
+ for _, rt := range guarded(app) {
+ res := probe(c, rt)
+ key := rt.Method + " " + rt.Pattern
+ if res.Status != before[key] {
+ t.Errorf("after restore: %s → %d, but before removal it was %d", key, res.Status, before[key])
+ }
+ }
+
+ // "And no more": the one thing an admin could never do is still
+ // refused, and the owner is still the owner.
+ if res := c.Post("/members/transfer", url.Values{"member": {fmt.Sprint(admin.ID)}}); res.Status != http.StatusForbidden {
+ t.Errorf("a restored admin transferring ownership → %d, want 403", res.Status)
+ }
+ if got := app.H.TheOwner(); got.ID != owner.ID {
+ t.Fatalf("ownership moved to %d", got.ID)
+ }
+}
+
+// ---------------------------------------------------------------
+// §7.12 — the public GET is not an address oracle.
+// ---------------------------------------------------------------
+
+func TestInvitationPageDoesNotDiscloseTheAddress(t *testing.T) {
+ app, col := newApp(t)
+ oc := app.As(app.H.Owner())
+
+ const (
+ local = "secret.person"
+ domain = "hidden.example"
+ invited = local + "@" + domain
+ )
+ token := invite(t, app, col, oc, invited, idear.RoleAdmin)
+
+ // The renderer prints every field of InvitationPage by reflection,
+ // so this is a test of what the HANDLER passed and not of what a
+ // template chose to show.
+ for what, c := range map[string]*ideartest.Client{
+ "an anonymous visitor": app.Visitor(),
+ "a signed-in stranger": app.SignIn("nosy@example.test"),
+ } {
+ res := c.Get("/invitations/" + token)
+ if res.Status != http.StatusOK {
+ t.Fatalf("%s: GET the invitation → %d; body %q", what, res.Status, res.Body)
+ }
+ for _, secret := range []string{invited, local, domain} {
+ if strings.Contains(strings.ToLower(res.Body), secret) {
+ t.Errorf("%s: the invitation page disclosed %q; body %q", what, secret, res.Body)
+ }
+ }
+ // It does say what the holder is entitled to know.
+ if !strings.Contains(res.Body, string(idear.RoleAdmin)) {
+ t.Errorf("%s: the invitation page does not name the role; body %q", what, res.Body)
+ }
+ if !strings.Contains(res.Body, app.Server.Listener.Addr().String()) {
+ t.Errorf("%s: the invitation page does not name the instance; body %q", what, res.Body)
+ }
+ }
+
+ // An unusable token answers the SAME way whichever way it is
+ // unusable, so the page cannot be used to tell a real token from a
+ // spent one.
+ visitor := app.Visitor()
+ bodies := map[string]bool{}
+ for _, tok := range []string{"never-existed", strings.Repeat("a", 64)} {
+ res := visitor.Get("/invitations/" + tok)
+ if res.Status != http.StatusNotFound {
+ t.Errorf("GET %q → %d, want 404", tok, res.Status)
+ }
+ bodies[res.Body] = true
+ }
+ // ...including one that WAS real and has been revoked.
+ invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
+ if err != nil || len(invs) != 1 {
+ t.Fatalf("pending invitations = %v, %v", invs, err)
+ }
+ if res := oc.Post(fmt.Sprintf("/members/invitations/%d/revoke", invs[0].ID), url.Values{}); res.Status != http.StatusSeeOther {
+ t.Fatalf("revoking → %d", res.Status)
+ }
+ res := visitor.Get("/invitations/" + token)
+ if res.Status != http.StatusNotFound {
+ t.Errorf("GET a revoked invitation → %d, want 404", res.Status)
+ }
+ bodies[res.Body] = true
+ if len(bodies) != 1 {
+ t.Fatalf("unusable invitations rendered %d distinct bodies, want 1: %v", len(bodies), bodies)
+ }
+}
+
+// ---------------------------------------------------------------
+// The role selector, CSRF, and the wiring.
+// ---------------------------------------------------------------
+
+// TestGrantableIsTheAllowList checks the role selector against the
+// store, in both directions, for every actor.
+//
+// The payloads are DERIVED: the roles it tries are the ones Grantable
+// offers and the ones it does not, and the expectation flips on
+// membership of that list rather than on a hand-written table. An
+// Admin offered Admin would 403 on every submit — the selector and the
+// store have to agree, and this is what makes them.
+func TestGrantableIsTheAllowList(t *testing.T) {
+ all := []idear.Role{idear.RoleOwner, idear.RoleAdmin, idear.RoleMember}
+
+ for _, actorRole := range []idear.Role{idear.RoleOwner, idear.RoleAdmin} {
+ t.Run(string(actorRole), func(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ actor := owner
+ if actorRole != idear.RoleOwner {
+ actor = app.H.Member(actorRole)
+ }
+ c := app.As(actor)
+
+ offered := map[idear.Role]bool{}
+ for _, role := range idear.Grantable(actor) {
+ offered[role] = true
+ }
+ if offered[idear.RoleOwner] {
+ t.Error("the selector offers Owner; ownership moves only by Transfer")
+ }
+
+ // The page must show exactly what Grantable says.
+ page := c.Get("/members")
+ for _, role := range all {
+ line := "grantable " + string(role)
+ if got := strings.Contains(page.Body, line); got != offered[role] {
+ t.Errorf("the members page %s %s, but Grantable says %v", map[bool]string{true: "offers", false: "does not offer"}[got], role, offered[role])
+ }
+ }
+
+ // And the store agrees with the page, both ways.
+ for i, role := range all {
+ email := fmt.Sprintf("candidate-%d@example.test", i)
+ res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}})
+ if offered[role] {
+ if res.Status != http.StatusSeeOther {
+ t.Errorf("inviting at an OFFERED role %s → %d, want 303; body %q", role, res.Status, res.Body)
+ }
+ continue
+ }
+ if res.Status != http.StatusForbidden {
+ t.Errorf("inviting at an UNOFFERED role %s → %d, want 403; body %q", role, res.Status, res.Body)
+ }
+ }
+
+ // Nothing was minted above the actor's own rank.
+ var invs []idear.Invitation
+ if err := app.H.DB.G.Find(&invs).Error; err != nil {
+ t.Fatalf("listing invitations: %v", err)
+ }
+ for _, inv := range invs {
+ if !offered[inv.Role] {
+ t.Errorf("an invitation was minted at %s, which %s may not grant", inv.Role, actorRole)
+ }
+ }
+ })
+ }
+}
+
+// TestCrossOriginPostsAreRefused drives every mutating route from
+// another origin — the shape a CSRF attack actually takes against an
+// origin-checking framework — and demands that none of them run.
+func TestCrossOriginPostsAreRefused(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ victim := app.H.Member(idear.RoleMember)
+ c := app.As(owner)
+
+ for _, rt := range app.Handlers.Routes() {
+ if rt.Method != http.MethodPost {
+ continue
+ }
+ path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0")
+ res := c.PostFrom("https://evil.example", path, url.Values{
+ "role": {string(idear.RoleAdmin)},
+ "email": {"attacker@evil.example"},
+ "member": {fmt.Sprint(victim.ID)},
+ })
+ if res.Status != http.StatusForbidden {
+ t.Errorf("cross-origin %s %s → %d, want 403; body %q", rt.Method, path, res.Status, res.Body)
+ }
+ }
+ if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() {
+ t.Errorf("a cross-origin post landed: the victim is %s/%v", got.Role, got.Active())
+ }
+ invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
+ if err != nil {
+ t.Fatalf("listing invitations: %v", err)
+ }
+ if len(invs) != 0 {
+ t.Errorf("a cross-origin post minted %d invitations", len(invs))
+ }
+}
+
+func TestNewHandlersRequiresItsSeams(t *testing.T) {
+ h := ideartest.New(t)
+ renderMembers := func(http.ResponseWriter, *http.Request, idear.MembersPage) {}
+ renderInvitation := func(http.ResponseWriter, *http.Request, idear.InvitationPage) {}
+
+ for what, cfg := range map[string]idear.HandlerConfig{
+ "no roster": {RenderMembers: renderMembers, RenderInvitation: renderInvitation},
+ "no members": {Roster: h.Roster, RenderInvitation: renderInvitation},
+ "no invitation": {Roster: h.Roster, RenderMembers: renderMembers},
+ } {
+ if _, err := idear.NewHandlers(cfg); err == nil {
+ t.Errorf("NewHandlers with %s returned no error", what)
+ }
+ }
+ if _, err := idear.NewHandlers(idear.HandlerConfig{
+ Roster: h.Roster, RenderMembers: renderMembers, RenderInvitation: renderInvitation,
+ }); err != nil {
+ t.Errorf("NewHandlers with everything set: %v", err)
+ }
+}
+
+// TestInviteWithoutDeliverFlashesTheLink covers the default wiring: an
+// app with no Deliver hook still gets a usable link, exactly once, and
+// it works.
+func TestInviteWithoutDeliverFlashesTheLink(t *testing.T) {
+ app := ideartest.NewApp(t)
+ c := app.As(app.H.Owner())
+
+ res := c.Post("/members/invitations", url.Values{
+ "email": {"linked@example.test"},
+ "role": {string(idear.RoleMember)},
+ })
+ if res.Status != http.StatusSeeOther {
+ t.Fatalf("invite → %d; body %q", res.Status, res.Body)
+ }
+ page := c.Follow(res)
+ link := ""
+ for _, f := range strings.Fields(page.Body) {
+ if strings.HasPrefix(f, "/invitations/") {
+ link = f
+ }
+ }
+ if link == "" {
+ t.Fatalf("the members page carries no invitation link; body %q", page.Body)
+ }
+ if got := c.Get("/members"); strings.Contains(got.Body, link) {
+ t.Error("the flash notice survived a second page load; it must be one-shot")
+ }
+ if got := app.Visitor().Get(link); got.Status != http.StatusOK {
+ t.Fatalf("the flashed link answers %d, so it is not usable; body %q", got.Status, got.Body)
+ }
+}
+
+// TestPublicRoutesAreRateLimited proves the limiter is mounted on BOTH
+// public routes and on neither guarded one.
+func TestPublicRoutesAreRateLimited(t *testing.T) {
+ const burst = 4
+ app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{
+ // One token an hour back, so nothing refills mid-test.
+ RateLimit: idear.RateLimit{Burst: burst, Every: time.Hour},
+ })
+ owner := app.H.Owner()
+ c := app.As(owner)
+
+ var public []idear.Route
+ for _, rt := range app.Handlers.Routes() {
+ if rt.Public {
+ public = append(public, rt)
+ }
+ }
+ if len(public) != 2 {
+ t.Fatalf("derived %d public routes, want the 2 the design lists", len(public))
+ }
+
+ // The budget is shared across both public routes, because it is
+ // per-client and not per-route.
+ spent := 0
+ for _, rt := range public {
+ path := instantiate(rt.Pattern, "0", "no-such-token")
+ for range burst {
+ var res *ideartest.Result
+ if rt.Method == http.MethodGet {
+ res = c.Get(path)
+ } else {
+ res = c.Post(path, url.Values{})
+ }
+ spent++
+ if spent <= burst && res.Status == http.StatusTooManyRequests {
+ t.Fatalf("request %d of a burst of %d was rate-limited", spent, burst)
+ }
+ if spent > burst && res.Status != http.StatusTooManyRequests {
+ t.Fatalf("request %d → %d, want 429 once the burst is spent", spent, res.Status)
+ }
+ }
+ }
+
+ // The guarded routes are untouched by the limiter: a signed-in
+ // owner is not throttled off the members page by somebody else's
+ // guessing.
+ if res := c.Get("/members"); res.Status != http.StatusOK {
+ t.Errorf("the members page → %d while the public budget is spent", res.Status)
+ }
+}
+
+// TestRoutesMatchTheDesign pins the route table to the DESIGN, which
+// is the one thing the derived tests above cannot do for themselves.
+//
+// Everything else in this file reads Route.Min and asks whether the
+// mounted middleware agrees with it. That catches a guard that drifts
+// from its declaration — but not a floor LOWERED in both places at
+// once, which is the change that quietly turns "admins invite" into
+// "anybody invites". So this test states the floors from
+// docs/superpowers/specs/2026-08-23-idear-design.md §5 and compares
+// them exhaustively, in both directions: a route the design does not
+// list is a failure, and a route the design lists that is not mounted
+// is a failure too.
+//
+// It is the one place in the suite that quotes a list, and what it
+// quotes is the spec rather than the implementation.
+func TestRoutesMatchTheDesign(t *testing.T) {
+ type pin struct {
+ min idear.Role
+ public bool
+ }
+ design := map[string]pin{
+ "GET /members": {min: idear.RoleMember},
+ "POST /members/invitations": {min: idear.RoleAdmin},
+ "POST /members/invitations/{id}/revoke": {min: idear.RoleAdmin},
+ "POST /members/{id}/role": {min: idear.RoleAdmin},
+ "POST /members/{id}/remove": {min: idear.RoleAdmin},
+ "POST /members/{id}/restore": {min: idear.RoleAdmin},
+ "POST /members/transfer": {min: idear.RoleOwner},
+ "GET /invitations/{token}": {public: true},
+ "POST /invitations/{token}": {public: true},
+ }
+
+ app := ideartest.NewApp(t)
+ seen := map[string]bool{}
+ for _, rt := range app.Handlers.Routes() {
+ key := rt.Method + " " + rt.Pattern
+ want, ok := design[key]
+ if !ok {
+ t.Errorf("%s is mounted but the design does not list it", key)
+ continue
+ }
+ seen[key] = true
+ if rt.Public != want.public {
+ t.Errorf("%s: Public = %v, the design says %v", key, rt.Public, want.public)
+ }
+ if rt.Min != want.min {
+ t.Errorf("%s: rank floor = %q, the design says %q", key, rt.Min, want.min)
+ }
+ }
+ for key := range design {
+ if !seen[key] {
+ t.Errorf("%s is in the design and is not mounted", key)
+ }
+ }
+}
+
+// TestFieldsComeFromTheBodyNotTheQuery pins where a mutation's fields
+// are allowed to come from.
+//
+// idear reads PostForm and not Form. The difference is a real
+// escalation route: with Form, a link ending "?role=admin" would
+// supply the field for a POST whose body never mentioned one, so a
+// crafted link plus any submitted form on that page would grant a
+// rank the submitter never typed.
+func TestFieldsComeFromTheBodyNotTheQuery(t *testing.T) {
+ app, _ := newApp(t)
+ owner := app.H.Owner()
+ target := app.H.Member(idear.RoleMember)
+ c := app.As(owner)
+
+ path := fmt.Sprintf("/members/%d/role?role=%s", target.ID, idear.RoleAdmin)
+
+ // The body wins: it says member, the query says admin.
+ if res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}}); res.Status != http.StatusSeeOther {
+ t.Fatalf("setting a role → %d; body %q", res.Status, res.Body)
+ }
+ if got := app.H.Reload(target.ID); got.Role != idear.RoleMember {
+ t.Fatalf("the target is %s; the query string supplied the role", got.Role)
+ }
+
+ // And with no role in the body it is a malformed request, not one
+ // the query string may complete.
+ res := c.Post(path, url.Values{})
+ if res.Status != http.StatusBadRequest {
+ t.Errorf("a POST with no role in its body → %d, want 400; body %q", res.Status, res.Body)
+ }
+ if got := app.H.Reload(target.ID); got.Role != idear.RoleMember {
+ t.Fatalf("the target is %s; the query string completed a bodyless post", got.Role)
+ }
+}
diff --git a/internal/ideartest/app.go b/internal/ideartest/app.go
new file mode 100644
index 0000000..3242f0c
--- /dev/null
+++ b/internal/ideartest/app.go
@@ -0,0 +1,354 @@
+package ideartest
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/rastrillo/idear"
+)
+
+// App is idear mounted the way an app mounts it: a real chi router
+// over a real HTTP server, with rastrillo's real session store and its
+// real CSRF middleware, driven by a real http.Client with a cookie
+// jar.
+//
+// None of that is ceremony. The authorization rules this module exists
+// for are enforced by a STACK — chi's routing, the app's session
+// guard, idear's Require, idear's RequireRole, then the handler, then
+// the store's own transaction — and a test that calls a handler
+// function directly proves only the last layer of it. The refusals
+// that matter (a non-member's byte-identical 404, a member's 403, a
+// posted role that must never land) are all decided somewhere in the
+// middle.
+//
+// AppNotFound and AppForbidden are the app's OWN pages, deliberately
+// unlike http.NotFound's and http.Error's defaults: idear's 404 must
+// be byte-identical to the app's, and a harness that left the stdlib
+// defaults in place could not tell whether the hook was consulted at
+// all. chi's own NotFound is given the same renderer, which is the
+// mounting contract the design states.
+const (
+ AppNotFound = "the app's own 404 page"
+ AppForbidden = "the app's own 403 page"
+)
+
+// SignInPath is the test app's stand-in for whatever identity plugin
+// the real app chose: GET /test/signin?subject=X mints a real session
+// row for X and sets the real cookie.
+//
+// It is a GET so that signing in never needs the CSRF dance, and it is
+// the ONLY route in this harness that idear does not own. Everything a
+// test does after it goes through idear's own mounted routes.
+const SignInPath = "/test/signin"
+
+// App is one instance, served.
+type App struct {
+ T *testing.T
+ H *Harness
+ Handlers *idear.Handlers
+ Sessions *sessions.Sessions
+ Server *httptest.Server
+ Origin string
+ Router *chi.Mux
+}
+
+// NewApp mounts idear over a fresh instance with the default config.
+func NewApp(t *testing.T) *App {
+ t.Helper()
+ return NewAppWith(t, idear.Config{}, idear.HandlerConfig{})
+}
+
+// NewAppWith is NewApp with the caller's Config and HandlerConfig.
+//
+// Anything the caller leaves unset gets the harness's own: the app's
+// 404 and 403 pages, the two recording renderers below, and a rate
+// limit wide enough that an ordinary test does not trip it (the test
+// that PROVES the limiter sets its own narrow one). DB, Roster and
+// ClientKey are always the harness's — the point of the harness is
+// that those are the harness's.
+func NewAppWith(t *testing.T, cfg idear.Config, hcfg idear.HandlerConfig) *App {
+ t.Helper()
+
+ if cfg.NotFound == nil {
+ cfg.NotFound = func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ io.WriteString(w, AppNotFound)
+ }
+ }
+ if cfg.Forbidden == nil {
+ cfg.Forbidden = func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ io.WriteString(w, AppForbidden)
+ }
+ }
+ h := NewWith(t, cfg)
+
+ hcfg.Roster = h.Roster
+ if hcfg.RenderMembers == nil {
+ hcfg.RenderMembers = RenderMembers
+ }
+ if hcfg.RenderInvitation == nil {
+ hcfg.RenderInvitation = RenderInvitation
+ }
+ if hcfg.RateLimit == (idear.RateLimit{}) {
+ // Wide enough that the race tests below, which post hundreds
+ // of public requests from one loopback address, are limited by
+ // the store and not by the bucket. TestRateLimit sets its own.
+ hcfg.RateLimit = idear.RateLimit{Burst: 100000, Every: time.Millisecond}
+ }
+ hs, err := idear.NewHandlers(hcfg)
+ if err != nil {
+ t.Fatalf("idear.NewHandlers: %v", err)
+ }
+
+ // The listener exists before Start, so the origin is knowable
+ // before the handler that has to be configured with it.
+ srv := httptest.NewUnstartedServer(nil)
+ origin := "http://" + srv.Listener.Addr().String()
+
+ sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: origin})
+ if err != nil {
+ t.Fatalf("sessions.New: %v", err)
+ }
+
+ r := chi.NewRouter()
+ // The SAME renderer idear's Config.NotFound got. This is the
+ // mounting contract: two different 404 pages are a membership
+ // oracle, and this line is what makes "byte-identical" testable.
+ r.NotFound(cfg.NotFound)
+ r.Use(csrf.Protect(origin))
+ // Middleware, not Require: a signed-out request must reach idear's
+ // own Require and be answered 404 like any other non-member,
+ // rather than being redirected to a sign-in page by the layer
+ // above. A real app is free to stack sessions.Require outside
+ // this; the refusals under test are the same either way.
+ r.Use(sess.Middleware)
+ r.Get(SignInPath, func(w http.ResponseWriter, r *http.Request) {
+ subject := r.URL.Query().Get("subject")
+ if err := sess.SignIn(w, r, sessions.Session{
+ Subject: subject,
+ Method: "test",
+ AuthTime: time.Now(),
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ io.WriteString(w, "signed in as "+subject)
+ })
+ for _, rt := range hs.Routes() {
+ r.Method(rt.Method, rt.Pattern, rt.Handler)
+ }
+
+ srv.Config.Handler = r
+ srv.Start()
+ t.Cleanup(srv.Close)
+
+ return &App{T: t, H: h, Handlers: hs, Sessions: sess, Server: srv, Origin: origin, Router: r}
+}
+
+// RenderMembers writes the members page as deterministic lines, so a
+// test can assert on what the handler ACTUALLY passed rather than on
+// what a template chose to show.
+func RenderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) {
+ var b strings.Builder
+ b.WriteString("=== members ===\n")
+ if d.Viewer != nil {
+ fmt.Fprintf(&b, "viewer %d %s %s %s\n", d.Viewer.ID, d.Viewer.Role, state(d.Viewer), d.Viewer.Email)
+ }
+ for _, role := range d.Grantable {
+ fmt.Fprintf(&b, "grantable %s\n", role)
+ }
+ for _, m := range d.Members {
+ fmt.Fprintf(&b, "member %d %s %s %s %s\n", m.ID, m.Role, state(&m), m.Subject, m.Email)
+ }
+ for _, inv := range d.Invitations {
+ fmt.Fprintf(&b, "invitation %d %s %s\n", inv.ID, inv.Role, inv.Email)
+ }
+ if d.Error != "" {
+ fmt.Fprintf(&b, "error %s\n", d.Error)
+ }
+ if d.Notice != "" {
+ fmt.Fprintf(&b, "notice %s\n", d.Notice)
+ }
+ io.WriteString(w, b.String())
+}
+
+func state(m *idear.Member) string {
+ if m.Active() {
+ return "active"
+ }
+ return "deactivated"
+}
+
+// RenderInvitation writes EVERY FIELD of the InvitationPage it is
+// given, by reflection.
+//
+// Reflection, and not a hand-written line per field, is the whole
+// point. The public GET must not disclose the invited address, and it
+// does not because InvitationPage has no field for one — but a test
+// that searched a hand-written template's output for that address
+// would pass just as well against a template that simply forgot to
+// print a field it was handed. This renderer prints whatever it is
+// given, so the day somebody adds an Email to InvitationPage and fills
+// it in, the disclosure test goes red instead of staying green over a
+// new leak.
+func RenderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) {
+ var b strings.Builder
+ b.WriteString("=== invitation ===\n")
+ v := reflect.ValueOf(d)
+ t := v.Type()
+ for i := range t.NumField() {
+ fmt.Fprintf(&b, "%s %v\n", strings.ToLower(t.Field(i).Name), v.Field(i).Interface())
+ }
+ io.WriteString(w, b.String())
+}
+
+// Client is one browser: a cookie jar, and whatever session it signed
+// in with.
+type Client struct {
+ App *App
+ Subject string
+ HTTP *http.Client
+}
+
+// Visitor is a signed-out browser.
+func (a *App) Visitor() *Client {
+ a.T.Helper()
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ a.T.Fatalf("cookiejar.New: %v", err)
+ }
+ return &Client{App: a, HTTP: &http.Client{
+ Jar: jar,
+ // Redirects are NOT followed: a 303 to the members page is
+ // the assertion in every successful mutation, and a client
+ // that chased it would report the members page's 200 instead.
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }}
+}
+
+// SignIn is a browser holding a real session for subject — the app's
+// identity plugin, stood in for.
+func (a *App) SignIn(subject string) *Client {
+ a.T.Helper()
+ c := a.Visitor()
+ res := c.Get(SignInPath + "?subject=" + url.QueryEscape(subject))
+ if res.Status != http.StatusOK {
+ a.T.Fatalf("signing in as %q: status %d, body %q", subject, res.Status, res.Body)
+ }
+ c.Subject = subject
+ return c
+}
+
+// As is SignIn for a seeded member.
+func (a *App) As(m *idear.Member) *Client {
+ a.T.Helper()
+ return a.SignIn(m.Subject)
+}
+
+// Result is one response, read to the end.
+type Result struct {
+ Status int
+ Body string
+ Location string
+ Header http.Header
+}
+
+// Get issues a GET and fails the test if the transport does.
+func (c *Client) Get(path string) *Result {
+ c.App.T.Helper()
+ res, err := c.TryGet(path)
+ if err != nil {
+ c.App.T.Fatalf("GET %s: %v", path, err)
+ }
+ return res
+}
+
+// Post issues a same-origin form POST — the Origin header a browser
+// sends, which is what rastrillo's CSRF middleware checks — and fails
+// the test if the transport does.
+func (c *Client) Post(path string, form url.Values) *Result {
+ c.App.T.Helper()
+ res, err := c.TryPost(path, form)
+ if err != nil {
+ c.App.T.Fatalf("POST %s: %v", path, err)
+ }
+ return res
+}
+
+// TryGet and TryPost are the GOROUTINE-SAFE halves: they return the
+// transport error instead of calling t.Fatalf, which is legal only on
+// the test goroutine. Every race test below drives these.
+func (c *Client) TryGet(path string) (*Result, error) {
+ return c.do(http.MethodGet, path, nil, c.App.Origin)
+}
+
+func (c *Client) TryPost(path string, form url.Values) (*Result, error) {
+ return c.do(http.MethodPost, path, form, c.App.Origin)
+}
+
+// PostFrom is TryPost with a chosen Origin header: the cross-origin
+// form submission a CSRF attack actually looks like.
+func (c *Client) PostFrom(origin, path string, form url.Values) *Result {
+ c.App.T.Helper()
+ res, err := c.do(http.MethodPost, path, form, origin)
+ if err != nil {
+ c.App.T.Fatalf("POST %s from %s: %v", path, origin, err)
+ }
+ return res
+}
+
+func (c *Client) do(method, path string, form url.Values, origin string) (*Result, error) {
+ var body io.Reader
+ if form != nil {
+ body = strings.NewReader(form.Encode())
+ }
+ req, err := http.NewRequest(method, c.App.Origin+path, body)
+ if err != nil {
+ return nil, err
+ }
+ if form != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ // The evidence a browser sends on a form POST. csrf.SameOrigin
+ // prefers Sec-Fetch-Site and falls back to this; sending only
+ // Origin exercises the header an attacker's page cannot forge.
+ req.Header.Set("Origin", origin)
+ }
+ res, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ b, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, err
+ }
+ return &Result{
+ Status: res.StatusCode,
+ Body: string(b),
+ Location: res.Header.Get("Location"),
+ Header: res.Header,
+ }, nil
+}
+
+// Follow chases a 303 the way a browser would — the flash notice is
+// only readable on the page the redirect lands on.
+func (c *Client) Follow(res *Result) *Result {
+ c.App.T.Helper()
+ if res.Location == "" {
+ c.App.T.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body)
+ }
+ return c.Get(res.Location)
+}
diff --git a/ratelimit.go b/ratelimit.go
new file mode 100644
index 0000000..024038f
--- /dev/null
+++ b/ratelimit.go
@@ -0,0 +1,181 @@
+package idear
+
+import (
+ "net"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// RateLimit bounds how often ONE client may hit idear's two public
+// routes. Both of them read a secret: GET /invitations/{token} answers
+// questions about an invitation to anyone holding the token, and POST
+// /invitations/{token} spends one. An unauthenticated lookup of a
+// secret that answers as fast as the network allows is a free oracle,
+// so the limiter is not optional and there is no way to switch it off
+// — only to widen it.
+//
+// The defaults are a token bucket of Burst requests that refills one
+// token every Every: twenty at once, then one every three seconds
+// (twenty a minute sustained). A person opening their invitation link
+// and posting it never notices; a script walking the 2^256 token space
+// gets twenty guesses a minute out of one address, which is not a
+// meaningful improvement on none.
+type RateLimit struct {
+ // Burst is how many requests a client may make back to back.
+ // Default 20.
+ Burst int
+
+ // Every is how long one token takes to come back. Default 3s.
+ Every time.Duration
+
+ // Max is the ceiling on how many clients are tracked at once —
+ // the memory bound. Default 4096.
+ //
+ // When the table is full, idear first drops every bucket that has
+ // refilled to Burst (such a bucket is indistinguishable from a
+ // client that has never been seen, so dropping it costs nothing
+ // and forgives nobody). If it is STILL full, the request is
+ // REFUSED rather than admitted: on a route whose whole job is to
+ // slow down guessing, an overflowing table is evidence of the
+ // attack the limiter exists for, and failing open there would
+ // 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.
+ Max int
+}
+
+const (
+ defaultRateBurst = 20
+ defaultRateEvery = 3 * time.Second
+ defaultRateMax = 4096
+)
+
+// limiter is the bounded in-memory token bucket behind RateLimit. One
+// per *Handlers, so it is per-process and NOT shared between replicas:
+// two instances behind a load balancer give a client two budgets. That
+// is a real weakening and it is accepted, because the alternative is a
+// database write on every unauthenticated request — which is the
+// resource the limiter is trying to protect.
+type limiter struct {
+ burst float64
+ every time.Duration
+ max int
+
+ // now is the clock, injectable so a test can prove the refill
+ // without sleeping through it.
+ now func() time.Time
+
+ mu sync.Mutex
+ buckets map[string]*bucket
+}
+
+type bucket struct {
+ tokens float64
+ seen time.Time
+}
+
+func newLimiter(rl RateLimit) *limiter {
+ if rl.Burst <= 0 {
+ rl.Burst = defaultRateBurst
+ }
+ if rl.Every <= 0 {
+ rl.Every = defaultRateEvery
+ }
+ if rl.Max <= 0 {
+ rl.Max = defaultRateMax
+ }
+ return &limiter{
+ burst: float64(rl.Burst),
+ every: rl.Every,
+ max: rl.Max,
+ now: func() time.Time { return time.Now() },
+ buckets: make(map[string]*bucket),
+ }
+}
+
+// allow spends one token for key, reporting whether the request may
+// proceed. It is safe for concurrent use: the two public routes are
+// exactly where concurrent unauthenticated traffic arrives.
+func (l *limiter) allow(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ now := l.now()
+ b, ok := l.buckets[key]
+ if !ok {
+ if len(l.buckets) >= l.max {
+ l.sweep(now)
+ }
+ if len(l.buckets) >= l.max {
+ // Fail closed. See RateLimit.Max.
+ return false
+ }
+ b = &bucket{tokens: l.burst, seen: now}
+ l.buckets[key] = b
+ } else {
+ l.refill(b, now)
+ }
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+// refill credits a bucket for the time since it was last touched,
+// capped at burst.
+func (l *limiter) refill(b *bucket, now time.Time) {
+ if elapsed := now.Sub(b.seen); elapsed > 0 {
+ b.tokens += float64(elapsed) / float64(l.every)
+ if b.tokens > l.burst {
+ b.tokens = l.burst
+ }
+ }
+ b.seen = now
+}
+
+// sweep drops every bucket that has refilled to full. A full bucket
+// carries no state a fresh one would not have, so this frees memory
+// without forgiving anybody: a client mid-penalty is never swept.
+//
+// Called only when the table is at Max, which is why there is no
+// background goroutine here — a limiter with no traffic needs no
+// sweeper, and a *Handlers must not leak one for the life of the
+// process.
+func (l *limiter) sweep(now time.Time) {
+ for k, b := range l.buckets {
+ l.refill(b, now)
+ if b.tokens >= l.burst {
+ delete(l.buckets, k)
+ }
+ }
+}
+
+// size is the number of tracked clients — for the test that proves the
+// table is actually bounded.
+func (l *limiter) size() int {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return len(l.buckets)
+}
+
+// clientIP is the default rate-limit key: the IP half of RemoteAddr,
+// so a client behind one address shares one budget across ports.
+//
+// It is "per-IP-ish" and not per-person on purpose — there is nobody
+// to identify on an unauthenticated route. Behind a reverse proxy
+// EVERY request arrives from the proxy's address and they would share
+// a single bucket, which turns the limiter into a global one and locks
+// out real invitees; an app in that shape must set
+// HandlerConfig.ClientKey to read its own trusted forwarding header.
+// idear does not read X-Forwarded-For itself, because a header idear
+// cannot verify is a header an attacker can spoof to mint unlimited
+// budgets.
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
diff --git a/ratelimit_internal_test.go b/ratelimit_internal_test.go
new file mode 100644
index 0000000..71c4872
--- /dev/null
+++ b/ratelimit_internal_test.go
@@ -0,0 +1,133 @@
+package idear
+
+import (
+ "fmt"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+)
+
+// The limiter's own tests live inside the package because the thing
+// worth proving about it — that the table is BOUNDED — is not visible
+// from outside: a caller can only observe 429s, and a limiter that
+// leaked a bucket per client would answer exactly the same 429s while
+// growing until the process died.
+
+func TestLimiterSpendsAndRefills(t *testing.T) {
+ now := time.Now()
+ l := newLimiter(RateLimit{Burst: 3, Every: time.Second})
+ l.now = func() time.Time { return now }
+
+ for i := range 3 {
+ if !l.allow("a") {
+ t.Fatalf("request %d of a burst of 3 was refused", i+1)
+ }
+ }
+ if l.allow("a") {
+ t.Fatal("the fourth request of a burst of 3 was allowed")
+ }
+ // Another client has their own budget: the limit is per client and
+ // not global, or one prober would lock out every invitee.
+ if !l.allow("b") {
+ t.Fatal("a second client was refused on their first request")
+ }
+
+ // One token comes back per Every, and no more than Burst ever
+ // accumulates.
+ now = now.Add(time.Second)
+ if !l.allow("a") {
+ t.Fatal("no token came back after one refill interval")
+ }
+ if l.allow("a") {
+ t.Fatal("more than one token came back in one interval")
+ }
+ now = now.Add(time.Hour)
+ for i := range 3 {
+ if !l.allow("a") {
+ t.Fatalf("request %d after a long idle was refused", i+1)
+ }
+ }
+ if l.allow("a") {
+ t.Fatal("an idle client accumulated more than Burst tokens")
+ }
+}
+
+func TestLimiterTableIsBounded(t *testing.T) {
+ now := time.Now()
+ const max = 8
+ l := newLimiter(RateLimit{Burst: 2, Every: time.Second, Max: max})
+ l.now = func() time.Time { return now }
+
+ // Far more clients than the table may hold, each spending their
+ // whole burst so none of them is sweepable.
+ for i := range max * 10 {
+ key := fmt.Sprintf("client-%d", i)
+ l.allow(key)
+ l.allow(key)
+ if got := l.size(); got > max {
+ t.Fatalf("the table holds %d buckets, above the bound of %d", got, max)
+ }
+ }
+ if got := l.size(); got != max {
+ t.Fatalf("the table holds %d buckets, want it filled to %d", got, max)
+ }
+
+ // A full table FAILS CLOSED for an unseen client — see RateLimit.Max.
+ if l.allow("someone-new") {
+ t.Error("a full table admitted an unseen client; it must fail closed")
+ }
+ // And a client already in the table is unaffected by the crowd.
+ if l.allow("client-0") {
+ t.Error("client-0 had spent its burst and was allowed anyway")
+ }
+
+ // Once the crowd's buckets refill they are swept, and the table
+ // takes new clients again — the bound is on live clients, not a
+ // permanent cap on how many the process may ever see.
+ now = now.Add(time.Hour)
+ if !l.allow("someone-new") {
+ t.Error("a swept table still refused a new client")
+ }
+ if got := l.size(); got > max {
+ t.Fatalf("the table holds %d buckets after a sweep", got)
+ }
+}
+
+func TestLimiterIsConcurrencySafe(t *testing.T) {
+ l := newLimiter(RateLimit{Burst: 1000, Every: time.Hour, Max: 16})
+ var wg sync.WaitGroup
+ for i := range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := range 50 {
+ l.allow(fmt.Sprintf("client-%d", (i+j)%16))
+ }
+ }()
+ }
+ wg.Wait()
+ if got := l.size(); got > 16 {
+ t.Fatalf("the table holds %d buckets, above the bound of 16", got)
+ }
+}
+
+func TestClientIPKeysByAddressNotPort(t *testing.T) {
+ r := httptest.NewRequest("GET", "/invitations/x", nil)
+ r.RemoteAddr = "203.0.113.9:51234"
+ if got := clientIP(r); got != "203.0.113.9" {
+ t.Errorf("clientIP = %q, want the address without the port", got)
+ }
+ // A browser making a second request from a new source port must
+ // share the first one's budget, or the limit is no limit at all.
+ r.RemoteAddr = "203.0.113.9:51235"
+ if got := clientIP(r); got != "203.0.113.9" {
+ t.Errorf("clientIP = %q for a second port, want the same key", got)
+ }
+ // An address with no port at all (a unix socket, a test) is used
+ // whole rather than dropped, so it still keys to something.
+ r.RemoteAddr = "@"
+ if got := clientIP(r); got != "@" {
+ t.Errorf("clientIP = %q, want the raw RemoteAddr when it has no port", got)
+ }
+}