idear: Member/Invitation models, migrations, and token discipline (Task 2)
Grows Task 1's minimal Member into the full GORM model (Subject, Email, Name, CreatedAt, UpdatedAt) without touching Active()'s semantics, adds Invitation with Pending(now), and ships the migration set: - token.go: newToken (32 bytes crypto/rand, hex) and hashToken (SHA-256, hex) — only the hash is ever persisted, matching sessions. - migrations/0001_init.sql: idear_members and idear_invitations, namespaced so they can't collide with an app's own tables, with the unique indexes on subject/token_hash and indexes on email/role that the structs declare. Verified structurally against a throwaway gorm.AutoMigrate probe of the same structs. - schema.go: Schema (migrate.MustFromFS, embedded migrations) and Models() for rastrillo's migration checker. TDD: tests written first and confirmed failing on undefined symbols, then implementation, then green. schema_test.go applies migrate.Merge(sessions.Schema, Schema) to a real db.Open database, checks both tables exist, checks a second Apply is a no-op, and pins the migration's migrate.Checksum as a frozen literal constant (shaped after rastrillo's own migrate/frozen_checksums_test.go). Pending's four independent reasons to refuse (accepted/revoked/expired) each get their own test, not one combined case. gofmt -l ., go vet ./..., go test ./... -count=1 all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+441
−6
member.go+50 −6member_test.go+93 −0migrations/0001_init.sql+29 −0schema.go+25 −0schema_test.go+132 −0token.go+28 −0token_test.go+84 −0
diff --git a/member.go b/member.go| index cd496b1..729023d 100644 |
| --- a/member.go |
| +++ b/member.go |
| @@ -3,15 +3,25 @@ package idear |
| import "time" |
| // Member is a row in the roster: one person, at one role, in one |
| -// instance. This is the minimal shape policy.go needs to compile and |
| -// be tested without a database — ID, Role, and DeactivatedAt are the |
| -// only fields MayActOn reads. Task 2 grows this into the full GORM |
| -// model (Subject, Email, Name, CreatedAt, UpdatedAt) once storage |
| -// exists to back them; nothing here should be read as the final shape. |
| +// instance. |
| +// |
| +// Subject is the join to the app's own identity: idear does not own |
| +// email and password, and Subject is the only identifier both the |
| +// password and keymail identity plugins produce (see |
| +// docs/superpowers/specs/2026-08-23-idear-design.md §4). Email and |
| +// Name are a display cache, not the source of truth for who someone |
| +// is. |
| +// |
| +// Removal is DeactivatedAt, never a delete — see Active. |
| type Member struct { |
| ID int64 |
| - Role Role |
| + 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 |
| } |
| // Active reports whether m exists and has not been deactivated. A nil |
| @@ -20,3 +30,37 @@ type Member struct { |
| func (m *Member) Active() bool { |
| return m != nil && m.DeactivatedAt == nil |
| } |
| + |
| +// TableName namespaces the table as idear_members, not GORM's default |
| +// members, so it cannot collide with an app's own table of that name. |
| +func (Member) TableName() string { return "idear_members" } |
| + |
| +// Invitation is an offer to join the roster at a given Role, redeemed |
| +// by the holder of the plaintext token whose SHA-256 digest is stored |
| +// in TokenHash. Only the digest is ever persisted — see token.go — |
| +// matching sessions, which holds nothing but token digests either. |
| +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 |
| +} |
| + |
| +// Pending reports whether i can still be redeemed as of now: not yet |
| +// accepted, not revoked, and not expired. All three must hold — a |
| +// single check standing for the other two would let a revoked or |
| +// expired invitation read as usable the moment the case that also |
| +// disqualifies it stops being exercised. |
| +func (i *Invitation) Pending(now time.Time) bool { |
| + return i.AcceptedAt == nil && i.RevokedAt == nil && i.ExpiresAt.After(now) |
| +} |
| + |
| +// TableName namespaces the table as idear_invitations, not GORM's |
| +// default invitations, so it cannot collide with an app's own table |
| +// of that name. |
| +func (Invitation) TableName() string { return "idear_invitations" } |
diff --git a/member_test.go b/member_test.go| new file mode 100644 |
| index 0000000..83091aa |
| --- /dev/null |
| +++ b/member_test.go |
| @@ -0,0 +1,93 @@ |
| +package idear |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// TestMember_Active_Nil checks that a nil Member is never active — the |
| +// contract Active() documents so callers can pass a lookup's zero value |
| +// straight in without a separate nil check. |
| +func TestMember_Active_Nil(t *testing.T) { |
| + var m *Member |
| + if m.Active() { |
| + t.Fatalf("nil.Active() = true, want false") |
| + } |
| +} |
| + |
| +// TestMember_Active_Deactivated checks that a member with a non-nil |
| +// DeactivatedAt is not active, regardless of how long ago. |
| +func TestMember_Active_Deactivated(t *testing.T) { |
| + at := time.Unix(0, 0) |
| + m := &Member{ID: 1, Role: RoleMember, DeactivatedAt: &at} |
| + if m.Active() { |
| + t.Fatalf("Active() = true for a deactivated member, want false") |
| + } |
| +} |
| + |
| +// TestMember_Active_True checks the remaining case: a real member with |
| +// no DeactivatedAt is active. |
| +func TestMember_Active_True(t *testing.T) { |
| + m := &Member{ID: 1, Role: RoleMember} |
| + if !m.Active() { |
| + t.Fatalf("Active() = false for a live member, want true") |
| + } |
| +} |
| + |
| +// The four Pending tests below each flip exactly one field away from |
| +// the "would otherwise be pending" baseline, so each one independently |
| +// proves its own reason for refusal. A single case combining all three |
| +// reasons would pass even if two of the three checks were missing from |
| +// the implementation. |
| + |
| +// TestInvitation_Pending_Accepted checks that an accepted invitation |
| +// is never pending, even though it is neither revoked nor expired. |
| +func TestInvitation_Pending_Accepted(t *testing.T) { |
| + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) |
| + acceptedAt := now.Add(-time.Hour) |
| + inv := &Invitation{ |
| + ExpiresAt: now.Add(24 * time.Hour), |
| + AcceptedAt: &acceptedAt, |
| + } |
| + if inv.Pending(now) { |
| + t.Fatalf("Pending(now) = true for an accepted invitation, want false") |
| + } |
| +} |
| + |
| +// TestInvitation_Pending_Revoked checks that a revoked invitation is |
| +// never pending, even though it is neither accepted nor expired. |
| +func TestInvitation_Pending_Revoked(t *testing.T) { |
| + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) |
| + revokedAt := now.Add(-time.Hour) |
| + inv := &Invitation{ |
| + ExpiresAt: now.Add(24 * time.Hour), |
| + RevokedAt: &revokedAt, |
| + } |
| + if inv.Pending(now) { |
| + t.Fatalf("Pending(now) = true for a revoked invitation, want false") |
| + } |
| +} |
| + |
| +// TestInvitation_Pending_Expired checks that an expired invitation is |
| +// never pending, even though it is neither accepted nor revoked. |
| +func TestInvitation_Pending_Expired(t *testing.T) { |
| + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) |
| + inv := &Invitation{ |
| + ExpiresAt: now.Add(-time.Second), |
| + } |
| + if inv.Pending(now) { |
| + t.Fatalf("Pending(now) = true for an expired invitation, want false") |
| + } |
| +} |
| + |
| +// TestInvitation_Pending_True checks the remaining case: unaccepted, |
| +// unrevoked, unexpired is pending. |
| +func TestInvitation_Pending_True(t *testing.T) { |
| + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) |
| + inv := &Invitation{ |
| + ExpiresAt: now.Add(24 * time.Hour), |
| + } |
| + if !inv.Pending(now) { |
| + t.Fatalf("Pending(now) = false for a live invitation, want true") |
| + } |
| +} |
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql| new file mode 100644 |
| index 0000000..87c25cb |
| --- /dev/null |
| +++ b/migrations/0001_init.sql |
| @@ -0,0 +1,29 @@ |
| +CREATE TABLE IF NOT EXISTS idear_members ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + subject TEXT, |
| + email TEXT, |
| + name TEXT, |
| + role TEXT NOT NULL, |
| + deactivated_at TEXT, |
| + created_at TEXT, |
| + updated_at TEXT |
| +); |
| + |
| +CREATE UNIQUE INDEX IF NOT EXISTS idx_idear_members_subject ON idear_members (subject); |
| +CREATE INDEX IF NOT EXISTS idx_idear_members_email ON idear_members (email); |
| +CREATE INDEX IF NOT EXISTS idx_idear_members_role ON idear_members (role); |
| + |
| +CREATE TABLE IF NOT EXISTS idear_invitations ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + email TEXT, |
| + role TEXT NOT NULL, |
| + token_hash TEXT, |
| + invited_by INTEGER, |
| + created_at TEXT, |
| + expires_at TEXT, |
| + accepted_at TEXT, |
| + revoked_at TEXT |
| +); |
| + |
| +CREATE UNIQUE INDEX IF NOT EXISTS idx_idear_invitations_token_hash ON idear_invitations (token_hash); |
| +CREATE INDEX IF NOT EXISTS idx_idear_invitations_email ON idear_invitations (email); |
diff --git a/schema.go b/schema.go| new file mode 100644 |
| index 0000000..4e94992 |
| --- /dev/null |
| +++ b/schema.go |
| @@ -0,0 +1,25 @@ |
| +package idear |
| + |
| +import ( |
| + "embed" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations |
| +var migrationFS embed.FS |
| + |
| +// Schema is idear's migration set, following the sessions / auth / |
| +// blobs convention exactly: an app merges it into its own BootSchema |
| +// — migrate.Merge(sessions.Schema, idear.Schema, Schema) — never into |
| +// its own Schema (see docs/superpowers/specs/2026-08-23-idear-design.md |
| +// §4), so `rastrillo migration check` never proposes dropping tables |
| +// Models does not know about. |
| +var Schema = migrate.MustFromFS(migrationFS, "idear") |
| + |
| +// Models is what an app hands rastrillo's migration checker alongside |
| +// its own models, so `rastrillo migration generate`/`check` can see |
| +// idear's tables too. |
| +func Models() []any { |
| + return []any{&Member{}, &Invitation{}} |
| +} |
diff --git a/schema_test.go b/schema_test.go| new file mode 100644 |
| index 0000000..67df2c2 |
| --- /dev/null |
| +++ b/schema_test.go |
| @@ -0,0 +1,132 @@ |
| +package idear_test |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + |
| + "amadan.net/rastrillo/idear" |
| +) |
| + |
| +// openDB is a fresh, on-disk (t.TempDir) rastrillo db, matching the |
| +// shape schema_test needs to apply migrations against — a real |
| +// database, not an in-memory stand-in, per the brief. |
| +func openDB(t *testing.T) *db.DB { |
| + t.Helper() |
| + d, err := db.Open(filepath.Join(t.TempDir(), "idear.db"), nil) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + return d |
| +} |
| + |
| +// tableExists reports whether name is a table in sqlite_master. |
| +func tableExists(t *testing.T, sqlDB *sql.DB, name string) bool { |
| + t.Helper() |
| + var got string |
| + err := sqlDB.QueryRow( |
| + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, name, |
| + ).Scan(&got) |
| + if err == sql.ErrNoRows { |
| + return false |
| + } |
| + if err != nil { |
| + t.Fatalf("querying sqlite_master for %s: %v", name, err) |
| + } |
| + return got == name |
| +} |
| + |
| +// TestSchema_Apply checks that merging idear.Schema in after |
| +// sessions.Schema — the documented BootSchema order — and applying it |
| +// to a fresh database creates both idear tables. |
| +func TestSchema_Apply(t *testing.T) { |
| + d := openDB(t) |
| + full := migrate.Merge(sessions.Schema, idear.Schema) |
| + if _, err := migrate.Apply(context.Background(), d, full); err != nil { |
| + t.Fatalf("migrate.Apply: %v", err) |
| + } |
| + |
| + sqlDB := d.Writer() |
| + if !tableExists(t, sqlDB, "idear_members") { |
| + t.Errorf("idear_members table was not created") |
| + } |
| + if !tableExists(t, sqlDB, "idear_invitations") { |
| + t.Errorf("idear_invitations table was not created") |
| + } |
| +} |
| + |
| +// TestSchema_Apply_Twice checks that a second Apply is a no-op: the |
| +// ledger records what already ran and refuses to re-run it. |
| +func TestSchema_Apply_Twice(t *testing.T) { |
| + d := openDB(t) |
| + full := migrate.Merge(sessions.Schema, idear.Schema) |
| + |
| + first, err := migrate.Apply(context.Background(), d, full) |
| + if err != nil { |
| + t.Fatalf("first migrate.Apply: %v", err) |
| + } |
| + if len(first.Applied) == 0 { |
| + t.Fatalf("first migrate.Apply applied nothing; test cannot tell a no-op second run apart from a broken first one") |
| + } |
| + |
| + second, err := migrate.Apply(context.Background(), d, full) |
| + if err != nil { |
| + t.Fatalf("second migrate.Apply: %v", err) |
| + } |
| + if len(second.Applied) != 0 { |
| + t.Errorf("second migrate.Apply applied %v, want none (ledger should have skipped every migration)", second.Applied) |
| + } |
| +} |
| + |
| +// TestModels checks that Models returns pointers to both idear model |
| +// types, in the shape an app hands to rastrillo's migration checker. |
| +func TestModels(t *testing.T) { |
| + models := idear.Models() |
| + if len(models) != 2 { |
| + t.Fatalf("len(Models()) = %d, want 2", len(models)) |
| + } |
| + if _, ok := models[0].(*idear.Member); !ok { |
| + t.Errorf("Models()[0] = %T, want *idear.Member", models[0]) |
| + } |
| + if _, ok := models[1].(*idear.Invitation); !ok { |
| + t.Errorf("Models()[1] = %T, want *idear.Invitation", models[1]) |
| + } |
| +} |
| + |
| +// frozenIdearChecksum is migrate.Checksum of idear's shipped |
| +// migrations/0001_init.sql, recorded the day it shipped. |
| +// |
| +// ==> THIS CONSTANT MAY NEVER BE UPDATED. <== |
| +// |
| +// A failure here does not mean the constant is stale — it means an |
| +// edit to a shipped migration file changed its checksum, and that |
| +// edit must be reverted. Every app that has applied this migration has |
| +// this checksum in its ledger; migrate.Apply compares the two on every |
| +// boot and refuses to start if they no longer match. The only |
| +// legitimate way to change a shipped migration's effect is a new |
| +// migration file beside it, with a new ID and a new entry here. See |
| +// rastrillo's own migrate/frozen_checksums_test.go, which this test is |
| +// deliberately shaped after. |
| +const frozenIdearChecksum = "61aeb6d8542eae10ca8d131f121f16252a395cb0af28f32972806d02c483dfd1" |
| + |
| +func TestSchema_FrozenChecksum(t *testing.T) { |
| + for _, m := range idear.Schema.All() { |
| + if m.ID != "idear/0001_init" { |
| + t.Fatalf("unexpected migration id %q; if idear has grown a second migration, "+ |
| + "this test must be extended, not just re-pointed at the new one", m.ID) |
| + } |
| + if got := migrate.Checksum(m.SQL); got != frozenIdearChecksum { |
| + t.Errorf("%s: checksum is now %s, was %s.\n"+ |
| + "A shipped migration file was edited. Revert the edit — do NOT update the "+ |
| + "constant. Every deployed app has the old checksum in its ledger and will "+ |
| + "refuse to boot with \"applied with different SQL\". To change what the schema "+ |
| + "becomes, add a new migration file instead.", m.ID, got, frozenIdearChecksum) |
| + } |
| + } |
| +} |
diff --git a/token.go b/token.go| new file mode 100644 |
| index 0000000..ded6952 |
| --- /dev/null |
| +++ b/token.go |
| @@ -0,0 +1,28 @@ |
| +package idear |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| +) |
| + |
| +// newToken mints an invitation credential: 32 bytes of crypto/rand, |
| +// hex-encoded (64 characters). The plaintext this returns exists only |
| +// in Invite's return value and the emitted link — only its hash is |
| +// ever persisted; see hashToken. |
| +func newToken() (string, error) { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return hex.EncodeToString(b), nil |
| +} |
| + |
| +// hashToken digests token with SHA-256, hex-encoded, lowercase. This |
| +// is the only form of the token idear ever writes to the database — |
| +// sessions already holds nothing but digests, and an addon must not |
| +// be laxer than the core it rides on. |
| +func hashToken(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
diff --git a/token_test.go b/token_test.go| new file mode 100644 |
| index 0000000..790eac9 |
| --- /dev/null |
| +++ b/token_test.go |
| @@ -0,0 +1,84 @@ |
| +package idear |
| + |
| +import "testing" |
| + |
| +// TestNewToken_Distinct checks that two calls don't hand back the same |
| +// value — the one property that matters most for a credential, and the |
| +// cheapest way to catch a broken or unseeded random source. |
| +func TestNewToken_Distinct(t *testing.T) { |
| + a, err := newToken() |
| + if err != nil { |
| + t.Fatalf("newToken() error = %v", err) |
| + } |
| + b, err := newToken() |
| + if err != nil { |
| + t.Fatalf("newToken() error = %v", err) |
| + } |
| + if a == b { |
| + t.Fatalf("newToken() returned the same value twice: %q", a) |
| + } |
| +} |
| + |
| +// TestNewToken_Shape checks the encoding: 32 bytes of crypto/rand, |
| +// hex-encoded, is 64 hex characters. |
| +func TestNewToken_Shape(t *testing.T) { |
| + tok, err := newToken() |
| + if err != nil { |
| + t.Fatalf("newToken() error = %v", err) |
| + } |
| + if len(tok) != 64 { |
| + t.Fatalf("newToken() length = %d, want 64", len(tok)) |
| + } |
| + for _, c := range tok { |
| + if !isLowerHex(c) { |
| + t.Fatalf("newToken() = %q, contains non-lowercase-hex character %q", tok, c) |
| + } |
| + } |
| +} |
| + |
| +// TestHashToken_Stable checks that hashing the same token twice gives |
| +// the same digest — hashToken must be a pure function of its input. |
| +func TestHashToken_Stable(t *testing.T) { |
| + tok, err := newToken() |
| + if err != nil { |
| + t.Fatalf("newToken() error = %v", err) |
| + } |
| + if hashToken(tok) != hashToken(tok) { |
| + t.Fatalf("hashToken(%q) is not stable across calls", tok) |
| + } |
| +} |
| + |
| +// TestHashToken_Shape checks the encoding: SHA-256, hex-encoded, is 64 |
| +// hex characters, lowercase. |
| +func TestHashToken_Shape(t *testing.T) { |
| + tok, err := newToken() |
| + if err != nil { |
| + t.Fatalf("newToken() error = %v", err) |
| + } |
| + h := hashToken(tok) |
| + if len(h) != 64 { |
| + t.Fatalf("hashToken(%q) length = %d, want 64", tok, len(h)) |
| + } |
| + for _, c := range h { |
| + if !isLowerHex(c) { |
| + t.Fatalf("hashToken(%q) = %q, contains non-lowercase-hex character %q", tok, h, c) |
| + } |
| + } |
| +} |
| + |
| +// TestHashToken_DiffersFromInput checks that the hash is not just an |
| +// echo of the token — a bug that would defeat the entire point of |
| +// storing only the hash. |
| +func TestHashToken_DiffersFromInput(t *testing.T) { |
| + tok, err := newToken() |
| + if err != nil { |
| + t.Fatalf("newToken() error = %v", err) |
| + } |
| + if hashToken(tok) == tok { |
| + t.Fatalf("hashToken(%q) == input token; hash must differ from its input", tok) |
| + } |
| +} |
| + |
| +func isLowerHex(c rune) bool { |
| + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') |
| +} |