| 1 | # idear Implementation Plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. |
| 4 | |
| 5 | **Goal:** Build `idear` — the roster for a Rastrillo instance: who is in it, at what role, and who may change that. |
| 6 | |
| 7 | **Architecture:** A Go module depending on Rastrillo (never the reverse). Pure policy functions, a GORM-backed store whose every mutation is one transaction, two middlewares, two identity adapters, and mountable handlers that render through app-supplied callbacks. |
| 8 | |
| 9 | **Tech Stack:** Go 1.25, GORM, chi, `github.com/carlosframework/rastrillo` (`sessions`, `migrate`, `password`, `form`, `flash`), SQLite via `rastrillo/db`. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-08-23-idear-design.md` — read it. It is the binding authority; where this plan and the spec disagree, the spec wins and you should say so. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - Module path is `amadan.net/rastrillo/idear`. Never add a `replace` directive — the published `github.com/carlosframework/rastrillo` resolves, and a replace would hide a broken dependency. |
| 16 | - **Build environment (use this verbatim, every command):** |
| 17 | ``` |
| 18 | export GOFLAGS=-mod=mod CGO_ENABLED=0 \ |
| 19 | GOPATH=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/go \ |
| 20 | GOCACHE=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/gocache |
| 21 | ``` |
| 22 | Without `GOPATH`/`GOCACHE` pointed at scratch, the Go toolchain fails writing to a read-only filesystem. That is an environment artifact, not a build error. |
| 23 | - Repo root is the scratchpad `idear/` directory. It is fully writable; you do **not** need to disable any sandbox. |
| 24 | - `gofmt -l .` must be empty. `go vet ./...` clean. `go test ./... -count=1` green. |
| 25 | - **Errors are values, and every store mutation is ONE transaction.** An invariant checked outside the transaction that maintains it is not an invariant. |
| 26 | - **Never bind a form into a struct.** Read named fields explicitly. `Role` is never read from a form on any path. |
| 27 | - **Not-a-member answers exactly like doesn't-exist**, byte-identically, via the configured `NotFound` hook. `Forbidden` (403) is only for an action on something the caller may legitimately see. |
| 28 | - Visitor-facing copy: sentence case, ending in a full stop. |
| 29 | - Commit after each task. Do not push; do not create a PR. |
| 30 | |
| 31 | ## Ruling that deviates from the spec, already decided |
| 32 | |
| 33 | The spec §5 shows `NotFound` on `HandlerConfig`. It belongs on `Config` (the `Roster`), because `Require` — which is a `Roster` method — is the main caller. `Config` therefore carries **both** `NotFound` and `Forbidden` hooks, each defaulting to the stdlib behaviour, and `Handlers` uses the same ones. Behaviour is exactly what the spec describes; only the field's home differs. |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ### Task 1: Roles and policy — pure, no database, no HTTP |
| 38 | |
| 39 | **Files:** Create `role.go`, `policy.go`, `role_test.go`, `policy_test.go`, `Makefile`, `.amadan/ci`, `.amadan/ci.d/{10-vet,20-fmt,30-test}`, `.gitignore`. |
| 40 | |
| 41 | **Interfaces produced** (later tasks depend on these exact names): |
| 42 | |
| 43 | ```go |
| 44 | type Role string |
| 45 | |
| 46 | const ( |
| 47 | RoleOwner Role = "owner" |
| 48 | RoleAdmin Role = "admin" |
| 49 | RoleMember Role = "member" |
| 50 | ) |
| 51 | |
| 52 | func (r Role) Valid() bool |
| 53 | func (r Role) AtLeast(min Role) bool |
| 54 | func (r Role) Title() string // "Owner" / "Admin" / "Member"; "" for invalid |
| 55 | func ParseRole(s string) (Role, bool) // accepts ONLY the three known roles |
| 56 | |
| 57 | var ErrForbidden = errors.New("idear: forbidden") |
| 58 | |
| 59 | // MayActOn reports whether actor may manage target, returning |
| 60 | // ErrForbidden (wrapped with the reason) when it may not. |
| 61 | func MayActOn(actor, target *Member) error |
| 62 | ``` |
| 63 | |
| 64 | `rank()` is unexported: Owner 3, Admin 2, Member 1, anything else 0. `AtLeast` must return false for an invalid role even against an invalid minimum. |
| 65 | |
| 66 | **`MayActOn` rules, all four required:** |
| 67 | 1. actor must be non-nil and active, else forbidden; |
| 68 | 2. actor must be at least Admin, else forbidden; |
| 69 | 3. actor and target must differ (compare `ID`) — **nobody acts on themselves**, including the Owner; |
| 70 | 4. target's rank must be **strictly below** actor's — so Admin manages Member only, Owner manages Admin and Member, and nobody manages an Owner. |
| 71 | |
| 72 | `Member` is defined in Task 2; for this task declare it in `member.go` as a minimal struct with `ID int64`, `Role Role`, `DeactivatedAt *time.Time` and an `Active() bool` method, and let Task 2 grow it. Say so in your report. |
| 73 | |
| 74 | - [ ] **Step 1: `go mod init` is already done** — `go.mod` exists with the module path and requirements. Do not recreate it. |
| 75 | |
| 76 | - [ ] **Step 2: Write the failing tests.** |
| 77 | |
| 78 | `role_test.go` must include a table covering **every ordered pair** of `{owner, admin, member, "", "OWNER", "root"}` against `AtLeast`, and assert `ParseRole` rejects `""`, `"OWNER"`, `"root"`, `"Owner"` and accepts exactly the three lowercase spellings. |
| 79 | |
| 80 | `policy_test.go` must assert the full 3×3 actor/target matrix plus self-action and inactive-actor cases. Write it as a table with a `want error` column; every cell must be stated, not derived in a loop that mirrors the implementation. Required cells include: owner→admin allowed, owner→member allowed, owner→owner(other) refused, admin→member allowed, admin→admin(other) refused, admin→owner refused, member→anyone refused, anyone→self refused, deactivated-owner→member refused. |
| 81 | |
| 82 | - [ ] **Step 3: Run them, confirm they fail** for undefined symbols. |
| 83 | |
| 84 | - [ ] **Step 4: Implement `role.go` and `policy.go`.** |
| 85 | |
| 86 | - [ ] **Step 5: Run tests, confirm green.** |
| 87 | |
| 88 | - [ ] **Step 6: Add the build plumbing.** |
| 89 | |
| 90 | `Makefile` with `ci`, `vet`, `fmt-check`, `test` targets — CI steps delegate to make targets, never their own copies of the commands (amadan's rule). `.amadan/ci` is an executable single-script fallback; `.amadan/ci.d/{10-vet,20-fmt,30-test}` are executable steps delegating to make. `.gitignore` ignores build output only. |
| 91 | |
| 92 | - [ ] **Step 7: `gofmt -l .` empty, `go vet ./...`, `go test ./... -count=1` green. Commit.** |
| 93 | |
| 94 | --- |
| 95 | |
| 96 | ### Task 2: Models, migrations, and the token discipline |
| 97 | |
| 98 | **Files:** Modify `member.go`. Create `migrations/0001_init.sql`, `schema.go`, `token.go`, `member_test.go`, `token_test.go`, `schema_test.go`. |
| 99 | |
| 100 | **Interfaces produced:** |
| 101 | |
| 102 | ```go |
| 103 | type Member struct { |
| 104 | ID int64 |
| 105 | Subject string `gorm:"uniqueIndex"` |
| 106 | Email string `gorm:"index"` |
| 107 | Name string |
| 108 | Role Role `gorm:"not null;index"` |
| 109 | DeactivatedAt *time.Time |
| 110 | CreatedAt time.Time |
| 111 | UpdatedAt time.Time |
| 112 | } |
| 113 | |
| 114 | func (m *Member) Active() bool // non-nil AND DeactivatedAt == nil |
| 115 | |
| 116 | type Invitation struct { |
| 117 | ID int64 |
| 118 | Email string `gorm:"index"` |
| 119 | Role Role `gorm:"not null"` |
| 120 | TokenHash string `gorm:"uniqueIndex"` |
| 121 | InvitedBy int64 |
| 122 | CreatedAt time.Time |
| 123 | ExpiresAt time.Time |
| 124 | AcceptedAt *time.Time |
| 125 | RevokedAt *time.Time |
| 126 | } |
| 127 | |
| 128 | func (i *Invitation) Pending(now time.Time) bool // not accepted, not revoked, not expired |
| 129 | |
| 130 | // Schema is the package's migration set, merged into the app's BootSchema. |
| 131 | var Schema = migrate.MustFromFS(migrationFS, "idear") |
| 132 | |
| 133 | // NOTE (ruled 2026-08-24): do NOT export a Models() for apps to consume. |
| 134 | // rastrillo's dump.Compute diffs the app's OWN Schema against the app's OWN |
| 135 | // Models; handing it idear's models against a Schema that has no idear |
| 136 | // migrations makes `migration check` permanently red and makes `generate` |
| 137 | // write a second, colliding CREATE TABLE into the app's migrations. No core |
| 138 | // subsystem (sessions, auth, blobs, passkey) exports one, for this reason. |
| 139 | // If idear's own tests want the model list, keep it unexported. |
| 140 | ``` |
| 141 | |
| 142 | `token.go` (unexported except where noted): |
| 143 | - `newToken() (token string, err error)` — **32 bytes** from `crypto/rand`, hex-encoded. |
| 144 | - `hashToken(token string) string` — SHA-256, hex-encoded, lowercase. |
| 145 | |
| 146 | The plaintext token exists only in the value `Invite` returns and in the emitted link. **Only the hash is stored.** `sessions` already holds nothing but digests; an addon must not be laxer than the core it rides on. |
| 147 | |
| 148 | - [ ] **Step 1: Write the failing tests.** |
| 149 | |
| 150 | `token_test.go`: two `newToken()` calls differ; output is 64 hex characters; `hashToken` is stable, lowercase, 64 hex characters, and differs from its input. |
| 151 | |
| 152 | `member_test.go`: `Active()` is false for nil, false when `DeactivatedAt` is set, true otherwise. `Pending(now)` is false when accepted, false when revoked, false when `ExpiresAt` is before `now`, true otherwise — assert each independently, not one combined case. |
| 153 | |
| 154 | `schema_test.go`: applying `migrate.Merge(sessions.Schema, Schema)` to a fresh `db.Open` succeeds and creates `idear_members` and `idear_invitations`; applying twice is a no-op (the ledger prevents re-run). Add a **frozen checksum** test in the shape of rastrillo's `migrate/frozen_checksums_test.go`: the committed migration's checksum is pinned, so editing a shipped migration fails loudly. |
| 155 | |
| 156 | - [ ] **Step 2: Run them, confirm they fail.** |
| 157 | |
| 158 | - [ ] **Step 3: Write `migrations/0001_init.sql`.** |
| 159 | |
| 160 | Table names `idear_members` and `idear_invitations` (namespaced, so they cannot collide with an app's own). Columns must match the structs exactly, including the unique index on `subject`, the unique index on `token_hash`, and indexes on `email` and `role`. Read `sessions/migrations/0001_init.sql` in the module cache first and match its SQL style. |
| 161 | |
| 162 | - [ ] **Step 4: Implement, embedding the migrations with `//go:embed migrations`.** |
| 163 | |
| 164 | - [ ] **Step 5: Green. Commit.** |
| 165 | |
| 166 | --- |
| 167 | |
| 168 | ### Task 3: The roster store — every invariant, in a transaction |
| 169 | |
| 170 | **Files:** Create `roster.go`, `errors.go`, `roster_test.go`, `race_test.go`. Create `internal/ideartest/harness.go` (a shared test harness: temp SQLite, migrations applied, a `*Roster`). |
| 171 | |
| 172 | **Interfaces produced:** |
| 173 | |
| 174 | ```go |
| 175 | type Config struct { |
| 176 | DB *gorm.DB |
| 177 | OpenSignUp bool |
| 178 | InviteTTL time.Duration // default 7 * 24h |
| 179 | Subject func(*http.Request) (string, bool) // default: sessions.Current(r).Subject |
| 180 | NotFound func(http.ResponseWriter, *http.Request) // default http.NotFound |
| 181 | Forbidden func(http.ResponseWriter, *http.Request) // default 403 + plain text |
| 182 | Logger *slog.Logger |
| 183 | } |
| 184 | |
| 185 | type Roster struct{ /* unexported */ } |
| 186 | |
| 187 | func New(cfg Config) (*Roster, error) // errors when DB is nil |
| 188 | |
| 189 | var ( |
| 190 | ErrOwnerExists = errors.New("idear: this instance already has an owner") |
| 191 | ErrNoInvitation = errors.New("idear: no valid invitation for that address") |
| 192 | ErrNotFound = errors.New("idear: no such member") |
| 193 | ErrLastOwner = errors.New("idear: the owner cannot be removed") |
| 194 | ) |
| 195 | |
| 196 | func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) // ZERO ROWS, not zero ACTIVE rows |
| 197 | func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error) |
| 198 | func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (inv *Invitation, token string, err error) |
| 199 | func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error |
| 200 | func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error) |
| 201 | func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error |
| 202 | func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error |
| 203 | func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error |
| 204 | func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error |
| 205 | func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error) |
| 206 | func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error) |
| 207 | func (rs *Roster) Members(ctx context.Context) ([]Member, error) |
| 208 | func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error) |
| 209 | ``` |
| 210 | |
| 211 | **The invariants — each enforced INSIDE its own transaction:** |
| 212 | |
| 213 | - **`Claim`**: succeeds only when the members table has **zero rows**. A roster whose members are all deactivated must NOT reopen the claim — that would hand a stranger Owner of an instance full of dormant data. A racing second claim gets `ErrOwnerExists`. The claimant becomes `RoleOwner`. |
| 214 | - **`Invite`**: `actor` must pass `MayActOn`-style authority (at least Admin); **refuses `RoleOwner` outright, on every path, for every actor**. Ownership moves only by `Transfer`. Returns the plaintext token exactly once; stores only its hash. `ExpiresAt = now + InviteTTL`. |
| 215 | - **`Accept`**: consumes by **compare-and-swap**, not lookup-then-write: |
| 216 | ```sql |
| 217 | UPDATE idear_invitations SET accepted_at = ? |
| 218 | WHERE id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ? |
| 219 | ``` |
| 220 | with **rows-affected checked**, inside the same transaction as the `Member` insert. Anything softer lets `Revoke` race acceptance and admit a revoked invitation. Single-use cannot be delegated to the app's unique-email index — idear can neither see nor enforce that index. |
| 221 | - **`SetRole`**: `MayActOn(actor, target)` must pass; the new role must parse; **`RoleOwner` is refused** — ownership moves only by `Transfer`. |
| 222 | - **`Deactivate`**: `MayActOn` must pass; **the Owner can never be deactivated** (`ErrLastOwner`). |
| 223 | - **`Reactivate`**: `MayActOn` must pass. Required, because `Subject` is unique and removal is deactivation — without it a removed person can never be readmitted by any path. |
| 224 | - **`Transfer`**: Owner-initiated only. In ONE transaction: re-read both rows, confirm the actor is still Owner, **confirm the target is still active** (otherwise a transfer racing a `Deactivate` yields a deactivated Owner and an instance nobody can administer), demote the old Owner to Admin, promote the target to Owner. Exactly one Owner must exist at every commit boundary. |
| 225 | |
| 226 | - [ ] **Step 1: Build `internal/ideartest/harness.go` first** — `New(t)` returning a `*Roster` over a temp SQLite file with `migrate.Merge(sessions.Schema, idear.Schema)` applied, plus helpers to seed a member at a given role. Every later task uses it. |
| 227 | |
| 228 | - [ ] **Step 2: Write the failing tests**, one per invariant above, asserting the error values by `errors.Is`. |
| 229 | |
| 230 | Plus `race_test.go`, which is the point of this task and must use real concurrency, not sequential calls: |
| 231 | - `TestConcurrentClaimYieldsOneOwner` — **six** goroutines call `Claim` at once; exactly one succeeds, five get `ErrOwnerExists`, and the table holds exactly one row. |
| 232 | - `TestConcurrentTransfersKeepOneOwner` — **six** concurrent `Transfer` calls; afterwards `SELECT count(*) WHERE role='owner'` is exactly 1. Round 1 of the bake-off found precisely this bug in a hand-rolled version. |
| 233 | - `TestRevokeRacingAcceptNeverAdmits` — start `Revoke` and `Accept` concurrently on one invitation, repeated across many iterations; assert that a successful `Accept` and a successful `Revoke` never both happen for the same invitation. |
| 234 | - `TestTransferRacingDeactivateNeverStrandsOwner` — concurrent `Transfer` to a target and `Deactivate` of that same target; assert the resulting Owner is always active. |
| 235 | |
| 236 | Run the race tests with `-race`. Note that `-race` needs cgo, so use `CGO_ENABLED=1` **for that one command only**. |
| 237 | |
| 238 | - [ ] **Step 3: Confirm they fail. Step 4: Implement. Step 5: Green, including `-race`. Commit.** |
| 239 | |
| 240 | --- |
| 241 | |
| 242 | ### Task 4: Middleware and the two identity adapters |
| 243 | |
| 244 | **Files:** Create `middleware.go`, `admit.go`, `middleware_test.go`, `admit_test.go`. |
| 245 | |
| 246 | **Interfaces produced:** |
| 247 | |
| 248 | ```go |
| 249 | func (rs *Roster) Require(next http.Handler) http.Handler |
| 250 | func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler |
| 251 | func From(r *http.Request) *Member // nil when absent |
| 252 | func WithMember(r *http.Request, m *Member) *http.Request |
| 253 | |
| 254 | func (rs *Roster) CarryToken(next http.Handler) http.Handler |
| 255 | func (rs *Roster) Authorize(address string) bool |
| 256 | func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error) |
| 257 | ``` |
| 258 | |
| 259 | **`Require`** resolves the subject via `Config.Subject`, loads the member, and calls `Config.NotFound` unless that member exists **and is active**. It never redirects — signed-out handling belongs to the upstream `sessions.Require`. Mounted outside a session group the subject is empty and every request 404s; that is correct-but-silent, so say it loudly in the doc comment. |
| 260 | |
| 261 | **`RequireRole(min)`** answers `Config.Forbidden` (**403**) when the member's role is below `min`. It **stacks inside `Require`**; mounted bare it would 403 a non-member and break the 404 rule. Document that. |
| 262 | |
| 263 | **`CarryToken`** reads the `invite` field from the posted form and stashes it in the request context for `Admitting` to read. This exists because `password.Config.Create` receives no `*http.Request` — only `r.Context()`. Mounting it on the signup route is **mandatory** on the password path; without it every invited signup is refused, which is loud and safe rather than quiet and permissive. |
| 264 | |
| 265 | **`Admitting`** decides the role **before reading anything else the form says**, in this order: |
| 266 | 1. roster has zero rows → `RoleOwner` (claim); |
| 267 | 2. a **valid token** from the context — unexpired, unrevoked, unaccepted — **whose `Email` equals the submitted address** → the invitation's role; |
| 268 | 3. `OpenSignUp` → `RoleMember`; |
| 269 | 4. otherwise → `password.Refuse(...)`. |
| 270 | |
| 271 | **Possession of the token is required. An email match alone is NOT enough** — `password.Signup` never verifies an address, so email-match admission would let anyone who learns an invited address register it first and land at the invited role. |
| 272 | |
| 273 | The refusal message must be **one constant string, identical for every refused address, and must never interpolate the submitted address** — `password.Refuse`'s own contract now says so. |
| 274 | |
| 275 | Then: call the app's `create`, and on success write the `Member` with `Subject = strconv.FormatInt(id, 10)` (matching what `password` mints as the session subject), CAS-accepting the invitation in the same transaction. A failure between `create` and the member write leaves an orphan; that is fail-closed and is healed by the reconciliation route in Task 5. |
| 276 | |
| 277 | On `ErrOwnerExists` from a racing claim, return `password.Refuse(...)` with the same constant copy. |
| 278 | |
| 279 | **`Authorize(address)`** is the keymail adapter, matching `auth.Config.Authorize`'s `func(string) bool`. Active member → true. Roster empty → claim as Owner → true. Valid pending invitation for that address → accept, creating the member with `Subject = address` → true. Otherwise false. It has **no error channel**, so a database failure is indistinguishable from a policy denial to the visitor: log the distinction even though you cannot render it. |
| 280 | |
| 281 | - [ ] **Step 1: Write the failing tests.** Required, at minimum: |
| 282 | - `TestAdmittingRequiresTheToken` — an invitation exists for `admin@corp.test`; a signup for that address **with no `invite` field** is refused, and no member row is created at the invited role. This is the blocker the Fable review caught; it must be pinned. |
| 283 | - `TestAdmittingRejectsMismatchedEmail` — a valid token, but a different submitted address, is refused. |
| 284 | - `TestAdmittingRejectsExpiredAndRevokedTokens` — two cases, separately. |
| 285 | - `TestAdmittingClaimsFirstAccountAsOwner`. |
| 286 | - `TestAdmittingOpenSignUpJoinsAsMember`. |
| 287 | - `TestAdmittingRefusalUsesConstantCopy` — refuse two different addresses, assert the two messages are byte-identical and neither contains either address. |
| 288 | - `TestRequireAnswersNotFoundForNonMember` and `...ForDeactivatedMember`. |
| 289 | - `TestRequireRoleForbidsBelowMinimum` (403, not 404). |
| 290 | - `TestAuthorizeAdmitsInvitedAddressOnce` — second call for the same consumed invitation is false. |
| 291 | |
| 292 | - [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.** |
| 293 | |
| 294 | --- |
| 295 | |
| 296 | ### Task 5: HTTP handlers, including the reconciliation route |
| 297 | |
| 298 | **Files:** Create `handlers.go`, `handlers_test.go`. |
| 299 | |
| 300 | **Interfaces produced:** |
| 301 | |
| 302 | ```go |
| 303 | type HandlerConfig struct { |
| 304 | Roster *Roster |
| 305 | RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage) |
| 306 | RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage) |
| 307 | } |
| 308 | |
| 309 | type MembersPage struct { |
| 310 | Viewer *Member |
| 311 | Members []Member |
| 312 | Invitations []Invitation |
| 313 | Error string |
| 314 | Notice string |
| 315 | } |
| 316 | |
| 317 | type InvitationPage struct { |
| 318 | Role Role |
| 319 | Site string |
| 320 | Token string |
| 321 | Error string |
| 322 | SignedIn bool |
| 323 | Reconcile bool // the viewer is signed in with no member row |
| 324 | } |
| 325 | |
| 326 | type Handlers struct{ /* unexported */ } |
| 327 | |
| 328 | func NewHandlers(cfg HandlerConfig) (*Handlers, error) // errors unless Roster and BOTH renderers are set |
| 329 | ``` |
| 330 | |
| 331 | Handler methods, mounted by the app at these default paths: |
| 332 | |
| 333 | ``` |
| 334 | GET /members Handlers.Members (Require) |
| 335 | POST /members/invitations Handlers.Invite (RequireRole admin) |
| 336 | POST /members/invitations/{id}/revoke Handlers.Revoke (RequireRole admin) |
| 337 | POST /members/{id}/role Handlers.SetRole (RequireRole admin) |
| 338 | POST /members/{id}/remove Handlers.Remove (RequireRole admin) |
| 339 | POST /members/{id}/restore Handlers.Restore (RequireRole admin) |
| 340 | POST /members/transfer Handlers.Transfer (RequireRole owner) |
| 341 | GET /invitations/{token} Handlers.Invitation (public) |
| 342 | POST /invitations/{token} Handlers.Accept (public, signed-in reconciliation) |
| 343 | ``` |
| 344 | |
| 345 | **The reconciliation route is the point of `POST /invitations/{token}`.** For a signed-in viewer with **no member row**, given a valid token matching their session, or a roster with zero rows, it writes the member from the live session `Subject`. This heals the orphan `Admitting` can leave, resolves the `Claim`-race loser, and is the only path by which a member row is created from an already-live session. Without it an orphaned user can sign in and 404 forever — the retry cannot help, because the app's `Create` now fails on the duplicate email and `password.Signup` renders that as 422 without ever reaching the member write. |
| 346 | |
| 347 | **`GET /invitations/{token}` must not be a free oracle:** it is an unauthenticated lookup of a secret. It must not echo the full invited address (show the role and the instance, not the address), and both public routes must be rate-limited per-IP-ish in memory, bounded and documented. |
| 348 | |
| 349 | Mutations read each permitted field by name and never bind a struct; ids come from the URL; after a successful mutation, `flash` a notice and 303 back to the members page. |
| 350 | |
| 351 | - [ ] **Step 1: Write the failing tests, driving real HTTP** through a `chi` router with a cookie jar and real CSRF, using `internal/ideartest`. This suite IS the deliverable — the spec §7 list, in full: |
| 352 | 1. a non-member is refused **read and write on every route**, with byte-identical 404s, including deeply nested ids; |
| 353 | 2. a Member is refused every management action; |
| 354 | 3. an Admin cannot change, demote, deactivate or restore an Admin or the Owner; |
| 355 | 4. a posted `role=owner` never lands, on any path, for any actor — assert the database row, not just the response; |
| 356 | 5. the single-owner invariant holds across concurrent transfers (already covered in Task 3; assert it through HTTP here too); |
| 357 | 6. an invited address cannot be claimed without the token; |
| 358 | 7. `Revoke` racing `Accept` never admits; |
| 359 | 8. an expired invitation is refused and an accepted one cannot be replayed; |
| 360 | 9. an orphaned user is healed by `POST /invitations/{token}` while signed in, and 404s on every route until they are; |
| 361 | 10. `Transfer` racing `Deactivate` never yields a deactivated Owner; |
| 362 | 11. a deactivated member can be reactivated and regains exactly their prior access and no more; |
| 363 | 12. `GET /invitations/{token}` does not disclose the invited address. |
| 364 | |
| 365 | **Test-quality rule, enforced:** allow-list and payload tests must derive their payloads from the shared list under test, never restate it. Round 1 of the bake-off found a test written to whitelist the very payload it listed, so a green suite passed over a live vulnerability. A test that quotes the implementation proves the implementation equals itself. |
| 366 | |
| 367 | - [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.** |
| 368 | |
| 369 | --- |
| 370 | |
| 371 | ### Task 6: The example app, `SKILL.md`, and `README.md` |
| 372 | |
| 373 | **Files:** Create `example/main.go`, `example/models.go`, `example/pages/*.html`, `example/README.md`, `SKILL.md`, `README.md`. |
| 374 | |
| 375 | **The example** is a complete working app on rastrillo + idear: its own `User` model with email and password hash, `password.New` wired with `Create: roster.Admitting(createUser(db))` and the signup route wrapped in `roster.CarryToken`, the members and invitation pages rendered through the callbacks, and **`/` behind `Require`**. That last point is load-bearing: under the password plugin, deactivation is enforced per-request by `Require`, **not at sign-in** — `password.Signin` runs Lookup → Verify → mint with no idear involvement. An ungated landing page is the one place this design leaks, so the example must not have one, and `SKILL.md` must say why. |
| 376 | |
| 377 | It must seed at least two accounts at different roles so the role gates are something you can click on. |
| 378 | |
| 379 | **`SKILL.md`** is the authoring doc an agent loads instead of the source — the mechanism that makes an addon cheap. Model it on `carrillo-chassis/SKILL.md` in structure and on Rastrillo's `SKILL.md` in voice. It must cover, at minimum: |
| 380 | - the one-line install (`go get amadan.net/rastrillo/idear`), with **no replace directive**; |
| 381 | - `New`, `NewHandlers`, and the exact route table; |
| 382 | - the two identity adapters, and that `CarryToken` is **mandatory** on the password path; |
| 383 | - the security discipline: never bind a form, `role` never from a form, 404-not-403 for non-members, allow-lists, `Refuse`'s constant-copy rule; |
| 384 | - the traps: `Require` mounted outside a session group silently 404s everything; `RequireRole` must stack inside `Require`; deactivation is per-request under password, not at sign-in, so gate `/`; |
| 385 | - **one identity plugin per app** — mounting both password and keymail gives one human two subjects and two roster rows whose roles drift apart; |
| 386 | - Owner break-glass: a lost Owner credential otherwise means a permanently unadministrable instance. Document the recovery (the SQL, plainly). |
| 387 | - that `idear.Schema` merges into `BootSchema`, never into the app's own `Schema`. |
| 388 | |
| 389 | **`README.md`** is short: what idear is, what it is not, the install line, and a pointer to `SKILL.md`. |
| 390 | |
| 391 | - [ ] **Step 1: Write the example and get it building and serving.** |
| 392 | - [ ] **Step 2: Write `SKILL.md` and `README.md`.** |
| 393 | - [ ] **Step 3: Full gate green. Commit.** |
| 394 | |
| 395 | --- |
| 396 | |
| 397 | ## Self-Review |
| 398 | |
| 399 | **Spec coverage.** §4 model → Task 2. §5 middleware/policy/store/adapters/handlers → Tasks 1, 3, 4, 5. §5 reconciliation → Task 5. §6 repo shape and the SKILL.md delivery mechanism → Tasks 1 and 6. §7 test list → Tasks 3 and 5. §9's "one identity plugin per app", invitation expiry and Owner break-glass → Tasks 2 and 6. |
| 400 | |
| 401 | **Deliberately deferred:** rebind-on-address-change, which the spec names as an open question and does not answer. Do not invent an answer; if you touch it, note it in your report. |
| 402 | |
| 403 | **Type consistency.** `Role`, `Member`, `Invitation`, `Roster`, `Config`, `HandlerConfig`, `MembersPage`, `InvitationPage` and every method name above are spelled once here and must be spelled identically everywhere. `Subject` is a `string` on every path — never an `int64`, never `sessions.UserID`. |
| 404 | |