Plan and spec for idear
Task zero verified before planning: the amadan.net vanity path serves a correct go-import meta tag, and github.com/carlosframework/rastrillo resolves at the PR #91 merge with no replace directive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+1028
−0
.superpowers/sdd/2026-08-23-idear/progress.md+49 −0docs/superpowers/plans/2026-08-23-idear.md+398 −0docs/superpowers/specs/2026-08-23-idear-design.md+522 −0go.mod+24 −0go.sum+35 −0
diff --git a/.superpowers/sdd/2026-08-23-idear/progress.md b/.superpowers/sdd/2026-08-23-idear/progress.md| new file mode 100644 |
| index 0000000..82ac401 |
| --- /dev/null |
| +++ b/.superpowers/sdd/2026-08-23-idear/progress.md |
| @@ -0,0 +1,49 @@ |
| +# SDD ledger — plan: docs/superpowers/plans/2026-08-23-idear.md |
| + |
| +## Setup |
| + |
| +Build dir: scratchpad idear/ (fully writable — the user's ~/github.com tree is |
| +sandboxed read-only, which would have made every subagent fight the filesystem |
| +all night). Remote is https://amadan.net/rastrillo/idear (repo exists, public). |
| +Final tree gets installed to ~/github.com/rastrillo/idear at the end. |
| + |
| +TASK ZERO PASSED — the vanity path and the whole dependency chain are proven: |
| +- https://amadan.net/rastrillo/idear?go-get=1 serves |
| + <meta name="go-import" content="amadan.net/rastrillo/idear git https://amadan.net/rastrillo/idear"> |
| +- github.com/carlosframework/rastrillo is a live mirror of rastrilloorg/rastrillo, |
| + already at 7439afc (the PR #91 merge). |
| +- `go get github.com/carlosframework/rastrillo@7439afc` resolves with NO replace |
| + directive, and a smoke test confirmed password.Refuse, password.ErrRefused, |
| + sessions.Schema and migrate.Set are all reachable. |
| + |
| +Ruling: rastrillo is required at the pseudo-version v0.18.1-0.20260823225238-7439afc0d687 |
| +rather than waiting for a v0.19.0 tag. Cutting a release follows a ritual |
| +(release-prep commit, scaffold fallback version) that is Paul's to run, and a |
| +pseudo-version resolves publicly today. Cost if wrong: one `go get -u` when the |
| +tag lands. |
| + |
| +Ruling: Config carries NotFound and Forbidden hooks, not HandlerConfig as spec §5 |
| +shows. Require is a Roster method and is the main caller. Behaviour identical, |
| +field's home differs. Cost if wrong: a field move. |
| + |
| +Ruling: PR #91 squash-merged to rastrillo main as 7439afc after both CI jobs |
| +passed. idear's whole premise is that the seam is publicly resolvable, and it |
| +could not be until that landed. Cost if wrong: revert on rastrillo. |
| + |
| +## Pre-flight conflict scan |
| + |
| +| Pair / task | Produces vs consumes | Finding | |
| +|---|---|---| |
| +| T1 x T2 | T1 declares a minimal Member; T2 grows it | Sequenced, stated explicitly in T1. Clean. | |
| +| T2 x T3 | T3 stores Member/Invitation and uses Schema | Names pinned identically in both. Clean. | |
| +| T3 x T4 | T4's adapters call Claim/Accept/BySubject | Signatures pinned in T3's Interfaces block. Clean. | |
| +| T3 x T5 | T5's handlers call every store op | Same. Clean. | |
| +| T4 x T5 | T5 mounts Require/RequireRole/CarryToken | Same. Clean. | |
| +| T1 self | policy tests vs MayActOn's four rules | Four rules stated; the matrix enumerates every cell rather than looping. Clean. | |
| +| T3 self | race tests vs the transactional invariants | Each race test names the invariant it breaks. Clean. | |
| +| T5 self | 12-item suite vs the route table | Every route appears in both. Clean. | |
| + |
| +Ruling: the -race requirement in T3 needs CGO_ENABLED=1, which contradicts the |
| +global CGO_ENABLED=0. Scoped the exception to that one command in the task text, |
| +matching how rastrillo's own CI does it. Cost if wrong: the race step is skipped |
| +and the concurrency bugs it guards go unproven. |
diff --git a/docs/superpowers/plans/2026-08-23-idear.md b/docs/superpowers/plans/2026-08-23-idear.md| new file mode 100644 |
| index 0000000..1b84016 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-23-idear.md |
| @@ -0,0 +1,398 @@ |
| +# idear Implementation Plan |
| + |
| +> **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. |
| + |
| +**Goal:** Build `idear` — the roster for a Rastrillo instance: who is in it, at what role, and who may change that. |
| + |
| +**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. |
| + |
| +**Tech Stack:** Go 1.25, GORM, chi, `github.com/carlosframework/rastrillo` (`sessions`, `migrate`, `password`, `form`, `flash`), SQLite via `rastrillo/db`. |
| + |
| +**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. |
| + |
| +## Global Constraints |
| + |
| +- 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. |
| +- **Build environment (use this verbatim, every command):** |
| + ``` |
| + export GOFLAGS=-mod=mod CGO_ENABLED=0 \ |
| + GOPATH=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/go \ |
| + GOCACHE=/tmp/claude-1001/-home-paulca-github-com-rastrilloorg-rastrillo/a5746945-a081-4474-a890-44a32e59ccf9/scratchpad/gocache |
| + ``` |
| + 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. |
| +- Repo root is the scratchpad `idear/` directory. It is fully writable; you do **not** need to disable any sandbox. |
| +- `gofmt -l .` must be empty. `go vet ./...` clean. `go test ./... -count=1` green. |
| +- **Errors are values, and every store mutation is ONE transaction.** An invariant checked outside the transaction that maintains it is not an invariant. |
| +- **Never bind a form into a struct.** Read named fields explicitly. `Role` is never read from a form on any path. |
| +- **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. |
| +- Visitor-facing copy: sentence case, ending in a full stop. |
| +- Commit after each task. Do not push; do not create a PR. |
| + |
| +## Ruling that deviates from the spec, already decided |
| + |
| +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. |
| + |
| +--- |
| + |
| +### Task 1: Roles and policy — pure, no database, no HTTP |
| + |
| +**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`. |
| + |
| +**Interfaces produced** (later tasks depend on these exact names): |
| + |
| +```go |
| +type Role string |
| + |
| +const ( |
| + RoleOwner Role = "owner" |
| + RoleAdmin Role = "admin" |
| + RoleMember Role = "member" |
| +) |
| + |
| +func (r Role) Valid() bool |
| +func (r Role) AtLeast(min Role) bool |
| +func (r Role) Title() string // "Owner" / "Admin" / "Member"; "" for invalid |
| +func ParseRole(s string) (Role, bool) // accepts ONLY the three known roles |
| + |
| +var ErrForbidden = errors.New("idear: forbidden") |
| + |
| +// MayActOn reports whether actor may manage target, returning |
| +// ErrForbidden (wrapped with the reason) when it may not. |
| +func MayActOn(actor, target *Member) error |
| +``` |
| + |
| +`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. |
| + |
| +**`MayActOn` rules, all four required:** |
| +1. actor must be non-nil and active, else forbidden; |
| +2. actor must be at least Admin, else forbidden; |
| +3. actor and target must differ (compare `ID`) — **nobody acts on themselves**, including the Owner; |
| +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. |
| + |
| +`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. |
| + |
| +- [ ] **Step 1: `go mod init` is already done** — `go.mod` exists with the module path and requirements. Do not recreate it. |
| + |
| +- [ ] **Step 2: Write the failing tests.** |
| + |
| +`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. |
| + |
| +`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. |
| + |
| +- [ ] **Step 3: Run them, confirm they fail** for undefined symbols. |
| + |
| +- [ ] **Step 4: Implement `role.go` and `policy.go`.** |
| + |
| +- [ ] **Step 5: Run tests, confirm green.** |
| + |
| +- [ ] **Step 6: Add the build plumbing.** |
| + |
| +`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. |
| + |
| +- [ ] **Step 7: `gofmt -l .` empty, `go vet ./...`, `go test ./... -count=1` green. Commit.** |
| + |
| +--- |
| + |
| +### Task 2: Models, migrations, and the token discipline |
| + |
| +**Files:** Modify `member.go`. Create `migrations/0001_init.sql`, `schema.go`, `token.go`, `member_test.go`, `token_test.go`, `schema_test.go`. |
| + |
| +**Interfaces produced:** |
| + |
| +```go |
| +type Member struct { |
| + ID int64 |
| + Subject string `gorm:"uniqueIndex"` |
| + Email string `gorm:"index"` |
| + Name string |
| + Role Role `gorm:"not null;index"` |
| + DeactivatedAt *time.Time |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +func (m *Member) Active() bool // non-nil AND DeactivatedAt == nil |
| + |
| +type Invitation struct { |
| + ID int64 |
| + Email string `gorm:"index"` |
| + Role Role `gorm:"not null"` |
| + TokenHash string `gorm:"uniqueIndex"` |
| + InvitedBy int64 |
| + CreatedAt time.Time |
| + ExpiresAt time.Time |
| + AcceptedAt *time.Time |
| + RevokedAt *time.Time |
| +} |
| + |
| +func (i *Invitation) Pending(now time.Time) bool // not accepted, not revoked, not expired |
| + |
| +// Schema is the package's migration set, merged into the app's BootSchema. |
| +var Schema = migrate.MustFromFS(migrationFS, "idear") |
| + |
| +// Models is what an app hands rastrillo's migration checker. |
| +func Models() []any // []any{&Member{}, &Invitation{}} |
| +``` |
| + |
| +`token.go` (unexported except where noted): |
| +- `newToken() (token string, err error)` — **32 bytes** from `crypto/rand`, hex-encoded. |
| +- `hashToken(token string) string` — SHA-256, hex-encoded, lowercase. |
| + |
| +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. |
| + |
| +- [ ] **Step 1: Write the failing tests.** |
| + |
| +`token_test.go`: two `newToken()` calls differ; output is 64 hex characters; `hashToken` is stable, lowercase, 64 hex characters, and differs from its input. |
| + |
| +`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. |
| + |
| +`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. |
| + |
| +- [ ] **Step 2: Run them, confirm they fail.** |
| + |
| +- [ ] **Step 3: Write `migrations/0001_init.sql`.** |
| + |
| +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. |
| + |
| +- [ ] **Step 4: Implement, embedding the migrations with `//go:embed migrations`.** |
| + |
| +- [ ] **Step 5: Green. Commit.** |
| + |
| +--- |
| + |
| +### Task 3: The roster store — every invariant, in a transaction |
| + |
| +**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`). |
| + |
| +**Interfaces produced:** |
| + |
| +```go |
| +type Config struct { |
| + DB *gorm.DB |
| + OpenSignUp bool |
| + InviteTTL time.Duration // default 7 * 24h |
| + Subject func(*http.Request) (string, bool) // default: sessions.Current(r).Subject |
| + NotFound func(http.ResponseWriter, *http.Request) // default http.NotFound |
| + Forbidden func(http.ResponseWriter, *http.Request) // default 403 + plain text |
| + Logger *slog.Logger |
| +} |
| + |
| +type Roster struct{ /* unexported */ } |
| + |
| +func New(cfg Config) (*Roster, error) // errors when DB is nil |
| + |
| +var ( |
| + ErrOwnerExists = errors.New("idear: this instance already has an owner") |
| + ErrNoInvitation = errors.New("idear: no valid invitation for that address") |
| + ErrNotFound = errors.New("idear: no such member") |
| + ErrLastOwner = errors.New("idear: the owner cannot be removed") |
| +) |
| + |
| +func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) // ZERO ROWS, not zero ACTIVE rows |
| +func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error) |
| +func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (inv *Invitation, token string, err error) |
| +func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error |
| +func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error) |
| +func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error |
| +func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error |
| +func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error |
| +func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error |
| +func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error) |
| +func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error) |
| +func (rs *Roster) Members(ctx context.Context) ([]Member, error) |
| +func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error) |
| +``` |
| + |
| +**The invariants — each enforced INSIDE its own transaction:** |
| + |
| +- **`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`. |
| +- **`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`. |
| +- **`Accept`**: consumes by **compare-and-swap**, not lookup-then-write: |
| + ```sql |
| + UPDATE idear_invitations SET accepted_at = ? |
| + WHERE id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ? |
| + ``` |
| + 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. |
| +- **`SetRole`**: `MayActOn(actor, target)` must pass; the new role must parse; **`RoleOwner` is refused** — ownership moves only by `Transfer`. |
| +- **`Deactivate`**: `MayActOn` must pass; **the Owner can never be deactivated** (`ErrLastOwner`). |
| +- **`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. |
| +- **`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. |
| + |
| +- [ ] **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. |
| + |
| +- [ ] **Step 2: Write the failing tests**, one per invariant above, asserting the error values by `errors.Is`. |
| + |
| +Plus `race_test.go`, which is the point of this task and must use real concurrency, not sequential calls: |
| +- `TestConcurrentClaimYieldsOneOwner` — **six** goroutines call `Claim` at once; exactly one succeeds, five get `ErrOwnerExists`, and the table holds exactly one row. |
| +- `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. |
| +- `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. |
| +- `TestTransferRacingDeactivateNeverStrandsOwner` — concurrent `Transfer` to a target and `Deactivate` of that same target; assert the resulting Owner is always active. |
| + |
| +Run the race tests with `-race`. Note that `-race` needs cgo, so use `CGO_ENABLED=1` **for that one command only**. |
| + |
| +- [ ] **Step 3: Confirm they fail. Step 4: Implement. Step 5: Green, including `-race`. Commit.** |
| + |
| +--- |
| + |
| +### Task 4: Middleware and the two identity adapters |
| + |
| +**Files:** Create `middleware.go`, `admit.go`, `middleware_test.go`, `admit_test.go`. |
| + |
| +**Interfaces produced:** |
| + |
| +```go |
| +func (rs *Roster) Require(next http.Handler) http.Handler |
| +func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler |
| +func From(r *http.Request) *Member // nil when absent |
| +func WithMember(r *http.Request, m *Member) *http.Request |
| + |
| +func (rs *Roster) CarryToken(next http.Handler) http.Handler |
| +func (rs *Roster) Authorize(address string) bool |
| +func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error) |
| +``` |
| + |
| +**`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. |
| + |
| +**`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. |
| + |
| +**`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. |
| + |
| +**`Admitting`** decides the role **before reading anything else the form says**, in this order: |
| +1. roster has zero rows → `RoleOwner` (claim); |
| +2. a **valid token** from the context — unexpired, unrevoked, unaccepted — **whose `Email` equals the submitted address** → the invitation's role; |
| +3. `OpenSignUp` → `RoleMember`; |
| +4. otherwise → `password.Refuse(...)`. |
| + |
| +**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. |
| + |
| +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. |
| + |
| +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. |
| + |
| +On `ErrOwnerExists` from a racing claim, return `password.Refuse(...)` with the same constant copy. |
| + |
| +**`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. |
| + |
| +- [ ] **Step 1: Write the failing tests.** Required, at minimum: |
| + - `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. |
| + - `TestAdmittingRejectsMismatchedEmail` — a valid token, but a different submitted address, is refused. |
| + - `TestAdmittingRejectsExpiredAndRevokedTokens` — two cases, separately. |
| + - `TestAdmittingClaimsFirstAccountAsOwner`. |
| + - `TestAdmittingOpenSignUpJoinsAsMember`. |
| + - `TestAdmittingRefusalUsesConstantCopy` — refuse two different addresses, assert the two messages are byte-identical and neither contains either address. |
| + - `TestRequireAnswersNotFoundForNonMember` and `...ForDeactivatedMember`. |
| + - `TestRequireRoleForbidsBelowMinimum` (403, not 404). |
| + - `TestAuthorizeAdmitsInvitedAddressOnce` — second call for the same consumed invitation is false. |
| + |
| +- [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.** |
| + |
| +--- |
| + |
| +### Task 5: HTTP handlers, including the reconciliation route |
| + |
| +**Files:** Create `handlers.go`, `handlers_test.go`. |
| + |
| +**Interfaces produced:** |
| + |
| +```go |
| +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) |
| +} |
| + |
| +type MembersPage struct { |
| + Viewer *Member |
| + Members []Member |
| + Invitations []Invitation |
| + Error string |
| + Notice string |
| +} |
| + |
| +type InvitationPage struct { |
| + Role Role |
| + Site string |
| + Token string |
| + Error string |
| + SignedIn bool |
| + Reconcile bool // the viewer is signed in with no member row |
| +} |
| + |
| +type Handlers struct{ /* unexported */ } |
| + |
| +func NewHandlers(cfg HandlerConfig) (*Handlers, error) // errors unless Roster and BOTH renderers are set |
| +``` |
| + |
| +Handler methods, mounted by the app at these default paths: |
| + |
| +``` |
| +GET /members Handlers.Members (Require) |
| +POST /members/invitations Handlers.Invite (RequireRole admin) |
| +POST /members/invitations/{id}/revoke Handlers.Revoke (RequireRole admin) |
| +POST /members/{id}/role Handlers.SetRole (RequireRole admin) |
| +POST /members/{id}/remove Handlers.Remove (RequireRole admin) |
| +POST /members/{id}/restore Handlers.Restore (RequireRole admin) |
| +POST /members/transfer Handlers.Transfer (RequireRole owner) |
| +GET /invitations/{token} Handlers.Invitation (public) |
| +POST /invitations/{token} Handlers.Accept (public, signed-in reconciliation) |
| +``` |
| + |
| +**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. |
| + |
| +**`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. |
| + |
| +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. |
| + |
| +- [ ] **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: |
| + 1. a non-member is refused **read and write on every route**, with byte-identical 404s, including deeply nested ids; |
| + 2. a Member is refused every management action; |
| + 3. an Admin cannot change, demote, deactivate or restore an Admin or the Owner; |
| + 4. a posted `role=owner` never lands, on any path, for any actor — assert the database row, not just the response; |
| + 5. the single-owner invariant holds across concurrent transfers (already covered in Task 3; assert it through HTTP here too); |
| + 6. an invited address cannot be claimed without the token; |
| + 7. `Revoke` racing `Accept` never admits; |
| + 8. an expired invitation is refused and an accepted one cannot be replayed; |
| + 9. an orphaned user is healed by `POST /invitations/{token}` while signed in, and 404s on every route until they are; |
| + 10. `Transfer` racing `Deactivate` never yields a deactivated Owner; |
| + 11. a deactivated member can be reactivated and regains exactly their prior access and no more; |
| + 12. `GET /invitations/{token}` does not disclose the invited address. |
| + |
| +**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. |
| + |
| +- [ ] **Step 2: Confirm they fail. Step 3: Implement. Step 4: Green. Commit.** |
| + |
| +--- |
| + |
| +### Task 6: The example app, `SKILL.md`, and `README.md` |
| + |
| +**Files:** Create `example/main.go`, `example/models.go`, `example/pages/*.html`, `example/README.md`, `SKILL.md`, `README.md`. |
| + |
| +**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. |
| + |
| +It must seed at least two accounts at different roles so the role gates are something you can click on. |
| + |
| +**`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: |
| +- the one-line install (`go get amadan.net/rastrillo/idear`), with **no replace directive**; |
| +- `New`, `NewHandlers`, and the exact route table; |
| +- the two identity adapters, and that `CarryToken` is **mandatory** on the password path; |
| +- 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; |
| +- 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 `/`; |
| +- **one identity plugin per app** — mounting both password and keymail gives one human two subjects and two roster rows whose roles drift apart; |
| +- Owner break-glass: a lost Owner credential otherwise means a permanently unadministrable instance. Document the recovery (the SQL, plainly). |
| +- that `idear.Schema` merges into `BootSchema`, never into the app's own `Schema`. |
| + |
| +**`README.md`** is short: what idear is, what it is not, the install line, and a pointer to `SKILL.md`. |
| + |
| +- [ ] **Step 1: Write the example and get it building and serving.** |
| +- [ ] **Step 2: Write `SKILL.md` and `README.md`.** |
| +- [ ] **Step 3: Full gate green. Commit.** |
| + |
| +--- |
| + |
| +## Self-Review |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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`. |
diff --git a/docs/superpowers/specs/2026-08-23-idear-design.md b/docs/superpowers/specs/2026-08-23-idear-design.md| new file mode 100644 |
| index 0000000..9adc1d3 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-23-idear-design.md |
| @@ -0,0 +1,522 @@ |
| +# 🤖 idear: roles and membership as an addon |
| + |
| +Design, 2026-08-23. Approved by Paul the same evening. |
| + |
| +A second CARLOS authoring bake-off graded five stacks building the same |
| +team Kanban app. Rastrillo and `carrillo-chassis` **tied on the rubric** — |
| +49/50 each, identical on all four axes, both a perfect 20/20 on the |
| +weighted security axis with zero confirmed defects. The chassis was |
| +ranked first on a metric the rubric records but does not score: builder |
| +tokens, 165,944 against Rastrillo's 216,012. |
| + |
| +The gap has one cause, and it is not framework friction. Rastrillo's |
| +builder was *more* efficient per line — 61 output tokens per written line |
| +against the chassis's 95. It simply had to write 1,781 more of them: |
| +3,537 written lines against 1,756. That excess is almost exactly the |
| +accounts-roles-invitations layer the chassis inherits and Rastrillo has |
| +no answer for. The framework contributed nothing to the surface the |
| +bake-off grades hardest, because it has no role concept: `scope.Owned` |
| +is a per-user `WHERE user_id = ?`, and a team-global Kanban board is not |
| +an owned row. |
| + |
| +This builds that layer — **outside the framework**. |
| + |
| +## 1. Why an addon, and not core |
| + |
| +The obvious response to the bake-off is `rastrillo/team`. It is the wrong |
| +one. |
| + |
| +Two arguments for it are weak and worth discarding before they get |
| +repeated. The `SKILL.md` budget is one: the ceiling has already been |
| +raised twice (15k → 16k → 17k), and an addon still costs the same ~330-byte |
| +pointer a core package's `Full treatment:` line would. "Not every app has |
| +members" is the other — it applies equally to `passkey`, `jobs` and |
| +manifests, all of which are core. |
| + |
| +The real reasons are narrower and hold up. **Release cadence:** roles |
| +will churn (reactivation, expiry policy, break-glass) at a rhythm that |
| +should not drag the framework's tag along, and a v0.x framework already |
| +asks enough of its consumers. **Gate honesty:** |
| +`internal/docsite/symbols_test.go` requires every core package to carry a |
| +reference page naming every exported symbol; that gate is valuable |
| +exactly because it is expensive, and it should be spent on what every app |
| +mounts. |
| + |
| +What the split genuinely costs, stated so nobody rediscovers it later: |
| +MVS version skew (idear pins a rastrillo; a `sessions` or `migrate` |
| +change can block an app's upgrade until idear catches up), a second |
| +frozen-checksum and CI regime, and the vanity-path risk of §9. Those are |
| +accepted, not waved off. |
| + |
| +So idear ships as its own module, versioned separately, and the |
| +dependency arrow points one way: **idear imports rastrillo; rastrillo |
| +never imports idear.** Inside this repo the change is a documentation |
| +page, its nav entry, a pointer in `SKILL.md`, and one small seam in |
| +`password` that §5 forces and §8 pays for. |
| + |
| +### The tenancy ruling survives intact |
| + |
| +The 2026-08-22 ruling — instance-per-team, team tenancy is APP-level, no |
| +membership package — was about *cross-team isolation*, and it stands |
| +unchanged. `carrillo-chassis` agrees with it completely; its own skill |
| +doc opens "the instance IS the tenant… there is no tenant field, no |
| +tenant scope." |
| + |
| +Owner > Admin > Member *inside* one instance is a different question, and |
| +the ruling never answered it. idear is the intra-instance half. Nothing |
| +here reintroduces a tenant column, a tenant scope, or `/t/{slug}` |
| +routing. |
| + |
| +## 2. The seam this fills already exists |
| + |
| +`auth.Config.Authorize` is documented, in the framework, today: |
| + |
| +> Authorize is the admission gate: given a verified address, may it have |
| +> a session? Nil admits every verified address. **Membership models |
| +> (tables, roles, admin bootstrap) are app policy layered on this hook.** |
| + |
| +That hook was left open for precisely this. idear fills it rather than |
| +inventing a parallel one — which is the difference between an addon and |
| +a fork. |
| + |
| +## 3. What idear is |
| + |
| +The **roster** for a Rastrillo instance: who is in it, at what role, and |
| +who may change that. |
| + |
| +It is not an identity provider. It never mints a session, never hashes a |
| +password, never renders a sign-in form. It sits on top of `sessions` and |
| +whichever identity plugin the app already chose, so an app using keymail |
| +or passkeys keeps them. |
| + |
| +Module path `amadan.net/rastrillo/idear`, repository |
| +`https://amadan.net/rastrillo/idear`. It depends on |
| +`rastrillo/{sessions,migrate,form,flash}` and `gorm.io/gorm`. It does |
| +**not** depend on `rastrillo/ui`: the example styles its pages, the |
| +library does not own a look. |
| + |
| +## 4. The data model |
| + |
| +Two tables and one migration set, following the `sessions` / `auth` / |
| +`blobs` convention exactly. |
| + |
| +```go |
| +type Member struct { |
| + ID int64 |
| + Subject string `gorm:"uniqueIndex"` |
| + Email string `gorm:"index"` |
| + Name string |
| + Role Role `gorm:"not null;index"` |
| + DeactivatedAt *time.Time |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +type Invitation struct { |
| + ID int64 |
| + Email string `gorm:"index"` |
| + Role Role `gorm:"not null"` |
| + TokenHash string `gorm:"uniqueIndex"` |
| + InvitedBy int64 |
| + CreatedAt time.Time |
| + ExpiresAt time.Time |
| + AcceptedAt *time.Time |
| + RevokedAt *time.Time |
| +} |
| + |
| +var Schema = migrate.MustFromFS(migrationFS, "idear") |
| +``` |
| + |
| +Four decisions worth stating, because each has an alternative that looks |
| +better until you try it. |
| + |
| +**`Subject` is a string, and it is the join.** idear does not own email |
| +and password — the app keeps its own `User` row. The key between them is |
| +the session `Subject`, because it is the only identifier both identity |
| +plugins produce: the password plugin's Subject is a numeric user id, |
| +keymail's is a verified email address. `sessions.UserID` returns |
| +`(0, false)` under keymail, and a membership layer keyed on it would |
| +resolve every keymail viewer to member zero. `jobs` already set this |
| +precedent — its owner is the session Subject, for the same reason. |
| + |
| +**The token is hashed at rest, and it expires.** `TokenHash` stores a |
| +SHA-256 digest; the plaintext token exists only in the emitted link. |
| +`sessions` already holds nothing but `HashToken` digests, and an addon has |
| +no business being laxer than the core it rides on. `ExpiresAt` defaults to |
| +seven days. Round 1 of the bake-off penalised an arm for immortal, |
| +anonymously-fetchable invitation tokens that disclosed team and email; |
| +this is that finding, applied before it is earned a second time. Tokens |
| +are 32 bytes of `crypto/rand`, and `GET /invitations/{token}` does not |
| +echo the full invited address. |
| + |
| +**Removal is `DeactivatedAt`, never a delete.** A deleted row dangles |
| +every `AuthorID` in the app's own tables. The corollary is that |
| +reactivation must exist as a first-class operation (§5) — `Subject` is |
| +unique, so without it a removed person can never be readmitted by any |
| +path: keymail's `Authorize` sees the inactive row and refuses, password |
| +re-signup hits the app's duplicate-email check, and a fresh invitation's |
| +Member insert collides with the dead row. |
| + |
| +**Apps merge `idear.Schema` into `BootSchema`, never into `Schema`** — |
| +`migrate.Merge(sessions.Schema, idear.Schema, Schema)`. Merging it into |
| +the app's own `Schema` makes `rastrillo migration check` propose dropping |
| +tables that `Models` does not know about. This is the documented rule for |
| +every subsystem and idear is not an exception to it. |
| + |
| +## 5. The API |
| + |
| +Mirrors `jobs`: a core built at boot, then handlers that error unless |
| +their renderers are set. |
| + |
| +```go |
| +r, err := idear.New(idear.Config{DB: d.G, OpenSignUp: false}) |
| +h, err := idear.NewHandlers(idear.HandlerConfig{ |
| + Roster: r, |
| + RenderMembers: renderMembers, |
| + RenderInvitation: renderInvitation, |
| + NotFound: renderNotFound, |
| +}) |
| +``` |
| + |
| +`Config.Subject` defaults to reading `sessions.Current(r).Subject` and |
| +exists as an override for an app whose viewer arrives another way. |
| + |
| +### Middleware — the membership gate |
| + |
| +- `r.Require` — signed in *and* an active member. A non-member is |
| + answered by `HandlerConfig.NotFound`, which **must be the same renderer |
| + the app gives chi's own `NotFound`**. "Byte-identical 404" is otherwise |
| + unimplementable: an app with a custom 404 page makes idear's default |
| + `http.NotFound` distinguishable, and that delta *is* the membership |
| + oracle `SKILL.md` §3 forbids. The default is `http.NotFound`; an app |
| + with its own 404 page and no `NotFound` hook is the one misconfiguration |
| + idear cannot detect, so the skill doc leads with it. |
| +- `r.RequireRole(min)` — **403**, because a member can legitimately see |
| + the page and merely may not act. It **stacks inside** `Require`; mounted |
| + bare it would 403 a non-member and break the 404 rule. |
| +- `Config.Subject` reads a session the caller must already have resolved: |
| + mount `Require` *inside* a `sess.Require` (or `auth.RequireSession`) |
| + group. Mounted outside one, the Subject is empty and every request 404s |
| + — silently, and identically to a real refusal. Signed-out requests are |
| + the upstream middleware's business; idear does not redirect. |
| +- `idear.From(req) *Member` — the viewer, following `auth.From`. |
| + |
| +### Policy, as pure functions |
| + |
| +`Role.AtLeast`, `ParseRole` (which accepts only the three known roles — |
| +anything else is not a role, not a default), and: |
| + |
| +```go |
| +func MayActOn(actor, target *Member) error |
| +``` |
| + |
| +Admins manage Members only; nobody acts on themselves; the target's rank |
| +must be strictly below the actor's. Keeping this a pure function is what |
| +lets the role matrix be tested exhaustively without an HTTP server. |
| + |
| +### Store operations |
| + |
| +Each is one transaction and each enforces its own invariant, because an |
| +invariant checked outside the transaction that maintains it is not an |
| +invariant: |
| + |
| +`Claim` · `Invite` · `Revoke` · `Accept` · `SetRole` · `Deactivate` · |
| +`Reactivate` · `Transfer`. |
| + |
| +Three carry invariants that a naive implementation loses: |
| + |
| +**`Accept` consumes the invitation by CAS, not by lookup.** The update is |
| +`SET accepted_at = ? WHERE id = ? AND accepted_at IS NULL AND revoked_at |
| +IS NULL AND expires_at > ?`, with rows-affected checked, **inside** the |
| +same transaction as the Member write. Anything softer lets `Revoke` race |
| +acceptance and admits a revoked invitation. Single-use cannot be delegated |
| +to the app's unique-email index — idear can neither see nor enforce that |
| +index, so the CAS is the invariant. |
| + |
| +**`Transfer` checks the target is active inside its own transaction.** |
| +Otherwise a transfer racing a `Deactivate` of the same target produces a |
| +deactivated Owner: an instance with no one able to administer it and no |
| +one able to be promoted. |
| + |
| +**`Claim` means zero rows, not zero active rows.** A roster whose members |
| +have all been deactivated must not reopen the claim — that would hand a |
| +stranger Owner of an instance full of dormant data. |
| + |
| +### Admission: the token is the credential |
| + |
| +This is the part the first draft got wrong, and it was wrong in the one |
| +place that mattered. |
| + |
| +The rule is **possession of the invitation token, plus an email match** — |
| +not an email match alone. The chassis resolves the invitation *from the |
| +token* and then checks `inv.Email == email` |
| +(`carrillo-chassis/handlers_auth.go:115-129`). Email-match alone is |
| +exploitable under the password plugin, which never verifies an address: |
| +anyone who learns that `admin@corp.com` was invited registers that address |
| +with their own password first and lands at the invited role. |
| + |
| +`password.Config.Create` is `func(ctx, email, hash) (int64, error)` — it |
| +receives no `*http.Request`, so the token cannot be read from the form |
| +inside it. It does receive `r.Context()`. So idear supplies a middleware: |
| + |
| +```go |
| +r.Post("/signup", idear.CarryToken(ph.Signup)) |
| +``` |
| + |
| +`CarryToken` reads the `invite` field from the posted form and stashes it |
| +in the request context; `Admitting` reads it back. Mounting it is |
| +**mandatory** on the password path — without it every invited signup is |
| +refused, which is loud and safe rather than quiet and permissive. |
| + |
| +Admission then decides the role **before reading anything else the form |
| +says**, in this order: **claim** if the roster has zero rows; otherwise a |
| +**valid token** — unexpired, unrevoked, unaccepted — whose `Email` equals |
| +the submitted address; otherwise `OpenSignUp` at `RoleMember`; otherwise |
| +refuse. The role is never read from the form on any path. |
| + |
| +### Reconciliation: the signed-in half of the invitation routes |
| + |
| +`Admitting` cannot enlist the app's opaque `Create` into its transaction, |
| +so a failure between "app user created" and "Member written" leaves a |
| +user row with no membership. The first draft called this an honest edge |
| +and claimed a retry heals it. **It does not:** 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 |
| +forever. |
| + |
| +The same terminal state is reachable with no failure at all. Two |
| +concurrent first signups both observe an empty roster; one wins the |
| +`Claim`, and the `ErrOwnerExists` loser is an orphan. |
| + |
| +So reconciliation is a designed path, not a footnote, and it is what the |
| +public invitation routes are *for*: |
| + |
| +- `GET /invitations/{token}` — renders the invitation to anyone holding a |
| + valid token, naming the instance and the role but not the full address. |
| +- `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, resolves the `Claim`-race loser, and is the only path by |
| + which a Member row is created from an already-live session. |
| + |
| +Both public routes are rate-limited; `GET` is an unauthenticated lookup |
| +of a secret and must not be a free oracle. |
| + |
| +### The two identity adapters |
| + |
| +```go |
| +// keymail — fills the hook auth already documents as the app's |
| +auth.New(auth.Config{Sessions: sess, Authorize: r.Authorize}) |
| + |
| +// password — wraps the app's own Create |
| +password.New(password.Config{Sessions: sess, Create: r.Admitting(createUser(d.G))}) |
| +``` |
| + |
| +Two asymmetries between them must be documented rather than smoothed |
| +over, because a reader will otherwise assume the guarantee is uniform: |
| + |
| +**Refusals need a channel.** `password.Config.Create`'s contract is that |
| +*any* error means duplicate-email, so an uninvited visitor would be told |
| +their address is already registered — which is simply false. This design |
| +therefore adds `password.ErrRefused` to the **core** package: `Signup` |
| +checks `errors.Is(err, password.ErrRefused)` and renders the refusal's |
| +own message at 403. That makes §8 more than the three files the first |
| +draft promised, and the seam is worth it — anything gating signup needs |
| +it. |
| + |
| +Be honest about the direction of the enumeration argument, because the |
| +tempting version of it is backwards. The old behaviour was *less* |
| +distinguishable, not more: an invite-only app answered 422 "already |
| +registered" both to a registered address and to an uninvited |
| +unregistered one, and a prober could not tell them apart. The 403 |
| +**creates** that existence bit; it does not close one. The |
| +justification is that the duplicate message is false, and that a true |
| +answer someone can distinguish is worth more than a false answer they |
| +cannot. idear's refusal copy must therefore be one string for every |
| +refused address, never interpolating the address, or the 403 becomes a |
| +finer oracle than the outcome alone. Logging which address was refused |
| +is idear's job — the framework deliberately does not log refusals. |
| + |
| +**Deactivation is enforced per request, not at sign-in — under password.** |
| +`password.Signin` runs Lookup → Verify → mint with no idear involvement; |
| +only keymail's admission consults `Authorize`. So a deactivated member or |
| +an orphan can still *mint a session* under the password plugin; what stops |
| +them is `Require` on every route. The example must therefore gate `/` |
| +itself, and the skill doc must say that an ungated landing page is the |
| +one place this design leaks. The chassis refuses at sign-in because it |
| +owns the credential check; idear does not, and should not claim to. |
| + |
| +**`Authorize` returns a bool with no error channel** (`func(address |
| +string) bool`, verified in `auth/auth.go`). A database failure during |
| +admission is therefore indistinguishable from a policy denial to the |
| +visitor. idear logs the distinction even though it cannot render it. |
| +`Authorize` also runs *before* `SecondFactor`, so a Member row can be |
| +written for a sign-in that a 2FA gate never completes — self-healing on |
| +the next attempt, and stated so nobody reads it as a bug. |
| + |
| +### Handlers |
| + |
| +Paths belong to the app; these are the defaults the example mounts. |
| + |
| +``` |
| +GET /members |
| +POST /members/invitations |
| +POST /members/invitations/{id}/revoke |
| +POST /members/{id}/role |
| +POST /members/{id}/remove |
| +POST /members/{id}/restore |
| +POST /members/transfer |
| +GET /invitations/{token} (public) |
| +POST /invitations/{token} (public, signed-in reconciliation) |
| +``` |
| + |
| +Rendering goes through callbacks the app supplies, following |
| +`password.Config.RenderSignin`. idear owns the flows, where the role |
| +rules actually get enforced; the app owns its shell. |
| + |
| +## 6. Repository shape |
| + |
| +``` |
| +role.go Role, AtLeast, ParseRole, Title |
| +member.go Member, Invitation |
| +roster.go Config, New, the store operations |
| +policy.go MayActOn — pure, no net/http |
| +middleware.go Require, RequireRole, From |
| +admit.go Authorize, Admitting |
| +handlers.go NewHandlers, the eight handlers, the PageData types |
| +migrations/0001_init.sql |
| +example/ a working app on rastrillo + idear |
| +SKILL.md idear's own authoring doc |
| +Makefile, .amadan/ci, .amadan/ci.d/ |
| +``` |
| + |
| +idear ships its **own `SKILL.md`** on the same contract as Rastrillo's — |
| +an agent loads it instead of the source. That mechanism, not the code, is |
| +what made the chassis arm cheap: a 361-line skill doc carrying a |
| +~3,110-line platform layer the builder mostly did not have to read. (Not |
| +"never read" — round 2 records the carrillo builder's context as including |
| +"the chassis Go studied to use it." The saving is real and it is partial.) |
| + |
| +### How an agent gets it |
| + |
| +This is the part that decides whether the addon thesis holds at all, and |
| +it does not come for free the way the framework's does. Rastrillo's |
| +`SKILL.md` sits at the repo root the scaffold points to. idear's would |
| +land in a versioned module-cache directory nobody names, and |
| +`docs/addons` is a directory page, not a skill. |
| + |
| +So idear's `SKILL.md` is **served at a stable URL**, |
| +`https://amadan.net/rastrillo/idear/SKILL.md`, and `docs/site/addons.md` |
| +carries the exact `curl` line for it — matching the convention Rastrillo's |
| +own `SKILL.md` already uses for `curl -s |
| +https://rastrillo.org/docs/<page>.md`. No new machinery, and it |
| +generalises to every future addon: the directory page's job is to hand an |
| +agent a fetchable skill, not to describe one. |
| + |
| +An addon whose skill doc an agent cannot find saves an app the typing and |
| +none of the reading — which is the whole cost argument, lost. |
| + |
| +## 7. Testing |
| + |
| +Tests drive the HTTP surface with a cookie jar and real CSRF tokens |
| +scraped from rendered pages — the path a browser and an attacker both |
| +take. The authorization suite is the deliverable, not a supporting |
| +artifact: |
| + |
| +1. a non-member is refused **read and write on every route**, with |
| + byte-identical 404s, including deeply nested ids; |
| +2. a Member is refused every management action; |
| +3. an Admin cannot change, demote or deactivate an Admin or the Owner; |
| +4. a posted `role=owner` never lands, on any path, for any actor; |
| +5. the single-owner invariant holds **across six concurrent transfers**, |
| + run as an actual race — round 1 of the bake-off found exactly this bug |
| + in the hand-rolled version; |
| +6. migration checksums frozen, matching `migrate/frozen_checksums_test.go`. |
| + |
| +Six more, each pinning a defect this design was revised to close: |
| + |
| +7. an invited address cannot be claimed **without the token** — registering |
| + `admin@corp.com` on an invite-only instance with no `invite` field |
| + admits at no role, not the invited one; |
| +8. `Revoke` racing `Accept` never admits: the CAS is exercised |
| + concurrently, not asserted about; |
| +9. an expired invitation is refused, and an accepted one cannot be |
| + replayed; |
| +10. an orphaned user — created, then failed before the Member write — is |
| + healed by `POST /invitations/{token}` while signed in, and 404s on |
| + every route until they are; |
| +11. `Transfer` racing `Deactivate` of the same target never yields a |
| + deactivated Owner; |
| +12. a deactivated member can be reactivated and regains exactly their |
| + prior access, and no more. |
| + |
| +**One anti-pattern designed out.** Round 1's auditor found a test written |
| +to whitelist the very payload it listed, so a green suite passed over a |
| +live open redirect. Allow-list tests here derive their payloads from the |
| +shared list under test; they never restate it. A test that quotes the |
| +implementation proves the implementation equals itself. |
| + |
| +## 8. The changes in this repository |
| + |
| +More than the three files the first draft promised, because |
| +`password.ErrRefused` (§5) is a core change and has to be paid for |
| +honestly: |
| + |
| +**`password/handlers.go`** gains an exported `ErrRefused` sentinel and one |
| +branch in `Signup`: `errors.Is(err, password.ErrRefused)` renders the |
| +wrapped message at 403 instead of the duplicate-email copy at 422. With |
| +it come its tests and a line on `docs/site/reference/password.md`, which |
| +`symbols_test.go` requires for any new exported symbol. This is a seam, |
| +not a feature — anything gating signup needs it, and idear is simply the |
| +first. |
| + |
| +**`docs/site/addons.md`** plus its `nav.json` section. Written as a |
| +directory that scales past one entry: what an addon is — versioned |
| +separately, depends on rastrillo and never the reverse, ships its own |
| +namespaced `migrate.Set` and its own `SKILL.md` — then the idear entry |
| +with its module path, what it does, what it deliberately does not, and |
| +the wiring. Every Go fence must parse; that is a gate, not a style note. |
| + |
| +Living in `docs/site/` rather than the website repo means it inherits all |
| +six docsite gates and rides the existing vendoring. The URL is |
| +`rastrillo.org/docs/addons`. |
| + |
| +The addons page also carries the `curl` line for idear's own `SKILL.md` |
| +(§6) — without it the directory describes a skill instead of delivering |
| +one. |
| + |
| +**`SKILL.md`**, roughly 330 bytes into §3 — where the reader has just |
| +been told scoping separates users and not tenants, and immediately |
| +wonders how roles work: |
| + |
| +> **Roles and membership are an addon, not core.** Rastrillo has no role |
| +> concept: who is *in* this instance and at what rank is |
| +> `amadan.net/rastrillo/idear` — Owner/Admin/Member, invitations, and |
| +> the members UI, over `sessions` and either identity plugin. |
| +> Full treatment: docs/site/addons.md — rastrillo.org/docs/addons |
| + |
| +That lands the file near 16.4 KB against the 17 KB budget, buying the |
| +pointer without a trim, in the existing `Full treatment:` convention. |
| + |
| +## 9. Sequencing and open risk |
| + |
| +The in-repo documentation ships first, as its own pull request: it states |
| +the doctrine and is independently useful before any of idear exists. |
| +idear follows as its own plan against the amadan repository. |
| + |
| +**Open risk: the vanity import path.** `amadan.net/rastrillo/idear` |
| +requires amadan.net to serve a `go-import` meta tag at |
| +`/rastrillo/idear?go-get=1`. Paul believes it already does. Believing is |
| +not verifying, and the whole premise of the addon is that an unattended |
| +agent can `go get` it, so a smoke test against a throwaway module is task |
| +zero of the idear plan — before anything depends on the path. |
| + |
| +## What this does not claim |
| + |
| +idear closes a **cost** gap, not a correctness one. Both arms scored |
| +20/20 on security with zero confirmed defects; the hand-rolled gate held |
| +against live adversarial probing. Nothing here says Rastrillo was unsafe. |
| + |
| +And the chassis's own caveat transfers wholesale: **a library buys the |
| +mechanism, not the discipline.** idear makes the safe call the short |
| +call. It does not remove the engineer at the seams it cannot cover. |
diff --git a/go.mod b/go.mod| new file mode 100644 |
| index 0000000..c360f41 |
| --- /dev/null |
| +++ b/go.mod |
| @@ -0,0 +1,24 @@ |
| +module amadan.net/rastrillo/idear |
| + |
| +go 1.25.0 |
| + |
| +require github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 |
| + |
| +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 |
| + github.com/mattn/go-isatty v0.0.20 // indirect |
| + github.com/ncruces/go-strftime v1.0.0 // indirect |
| + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/text v0.20.0 // indirect |
| + gorm.io/gorm v1.31.2 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| + modernc.org/libc v1.74.1 // indirect |
| + modernc.org/mathutil v1.7.1 // indirect |
| + modernc.org/memory v1.11.0 // indirect |
| + modernc.org/sqlite v1.55.0 // indirect |
| +) |
diff --git a/go.sum b/go.sum| new file mode 100644 |
| index 0000000..f11315a |
| --- /dev/null |
| +++ b/go.sum |
| @@ -0,0 +1,35 @@ |
| +github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 h1:sfD7narHWcha0swHvv3vblvzxhbwgHRI8kI5F4P/YgQ= |
| +github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= |
| +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= |
| +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |