rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "database/sql"
6 "embed"
7 "errors"
8 "strconv"
9 "time"
10
11 "github.com/carlosframework/rastrillo/migrate"
12 "github.com/carlosframework/rastrillo/password"
13 "github.com/carlosframework/rastrillo/sessions"
14 "gorm.io/gorm"
15
16 "amadan.net/rastrillo/idear"
17)
18
19// Models is every model THIS APP's schema generator manages.
20//
21// idear's models are deliberately NOT in it, and NOTHING STOPS YOU
22// PUTTING THEM THERE: idear exports the types (idear.Member,
23// idear.Invitation — this app's own templates use them) but not the
24// list, so `[]any{&User{}, &Post{}, &idear.Member{}}` compiles fine.
25//
26// It is wrong because `rastrillo migration generate` and `rastrillo
27// migration check` replay this app's Schema into a scratch database
28// and diff the result against this list, as a MATCHED PAIR. idear's
29// tables are created by idear's OWN migrations, which reach the
30// database through BootSchema below and never through Schema, so a
31// list naming &idear.Member{} is diffed against a schema that has no
32// idear migrations in it: `check` goes permanently red, and `generate`
33// writes a second, GORM-flavoured CREATE TABLE idear_members into this
34// app's migration file that collides at boot with idear's own.
35//
36// TestSchemaAndModelsAgree is `migration check` in test form, and it
37// is the only thing that catches this — the compiler will not.
38var Models = []any{&User{}, &Post{}}
39
40// User is the app's own identity row: an address and a password hash,
41// and nothing about membership.
42//
43// That split is the whole point of idear. idear owns who is IN this
44// instance and at what rank; the app owns credentials. The join
45// between them is the session Subject — see subjectFor.
46type User struct {
47 ID int64
48 Email string `gorm:"uniqueIndex"`
49 PasswordHash string
50 CreatedAt time.Time
51}
52
53// Post is the app's domain: one message on a shared board.
54//
55// Author is a display cache — the address as it was when the post was
56// written — for the same reason idear.Member.Email is one: the roster
57// row it names can be deactivated, renamed, or transferred, and a post
58// from two years ago should still say who wrote it. AuthorID is the
59// idear member id, and it is why removal in idear is a deactivation
60// and never a delete: a deleted row would dangle every one of these.
61type Post struct {
62 ID int64
63 AuthorID int64 `gorm:"index"`
64 Author string
65 Body string
66 CreatedAt time.Time
67}
68
69//go:embed migrations/*.sql
70var migrationFS embed.FS
71
72// Schema is THIS APP's own migrations and nothing else — the half
73// `migration generate` writes into and `migration check` diffs against
74// Models.
75var Schema = migrate.MustFromFS(migrationFS, "board")
76
77// BootSchema is everything applied at boot, in apply order: the shared
78// session core, then idear, then this app.
79//
80// idear.Schema is merged HERE and never into Schema. See Models for
81// what merging it into the wrong one costs.
82var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema)
83
84// subjectFor is the session Subject a User row has under the password
85// identity plugin.
86//
87// password mints its session as
88// sessions.Session{Subject: strconv.FormatInt(id, 10)} — one place,
89// signInAndRedirect, which both Signin and Signup pass through — so
90// this is the spelling a Member row must carry to be resolvable by any
91// session this app ever mints. Get it wrong and the person signs in
92// successfully and then 404s on every guarded route forever, because
93// idear's Require looks the Subject up and finds nothing.
94//
95// idear writes this spelling itself on the admission path (Admitting),
96// which is why nothing outside seeding needs this function. It is
97// unexported by idear, so a seed that writes roster rows behind the
98// HTTP flows has to restate it — noted in the friction log.
99func subjectFor(id int64) string { return strconv.FormatInt(id, 10) }
100
101// lookupUser is password.Config.Lookup. It knows nothing about
102// membership: a deactivated member still has a User row and still
103// verifies their password. What refuses them is idear's Require on
104// every route — which is why this app has no ungated landing page.
105func lookupUser(g *gorm.DB) func(context.Context, string) (int64, string, error) {
106 return func(ctx context.Context, email string) (int64, string, error) {
107 var u User
108 err := g.WithContext(ctx).Where("email = ?", email).First(&u).Error
109 if errors.Is(err, gorm.ErrRecordNotFound) {
110 return 0, "", sql.ErrNoRows
111 }
112 if err != nil {
113 return 0, "", err
114 }
115 return u.ID, u.PasswordHash, nil
116 }
117}
118
119// emailForSubject is idear.Config.EmailForSubject: it answers "which
120// address is this session subject?" out of THIS APP's user table.
121//
122// It is one lookup, and it is what makes reconciliation apply the same
123// rule admission does. Without it, POST /invitations/{token} under the
124// password plugin has no address to match the invitation against, so
125// possession of the token is the whole credential and any signed-in
126// orphan may spend a token issued to somebody else — at whatever role
127// that token carries. idear cannot resolve a decimal user id itself;
128// only this table can.
129//
130// The two "not this app's" answers are ("", nil), not an error: a
131// subject that is not a decimal id, and a decimal id with no row
132// behind it. idear refuses the redemption for both. An error is
133// reserved for a database that failed, which idear answers 500 rather
134// than rendering as a policy refusal.
135func emailForSubject(g *gorm.DB) func(context.Context, string) (string, error) {
136 return func(ctx context.Context, subject string) (string, error) {
137 id, err := strconv.ParseInt(subject, 10, 64)
138 if err != nil {
139 return "", nil
140 }
141 var u User
142 switch err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; {
143 case errors.Is(err, gorm.ErrRecordNotFound):
144 return "", nil
145 case err != nil:
146 return "", err
147 }
148 return u.Email, nil
149 }
150}
151
152// createUser is the app's half of password.Config.Create: it writes a
153// User row and nothing else.
154//
155// It is never wired to password directly. App() wraps it as
156// idear.Roster.Admitting(createUser(g)), which decides the ROLE first —
157// claim, invitation, open sign-up, or refusal — and writes the Member
158// row after this returns. See App.
159func createUser(g *gorm.DB) func(context.Context, string, string) (int64, error) {
160 return func(ctx context.Context, email, hash string) (int64, error) {
161 u := User{Email: email, PasswordHash: hash}
162 if err := g.WithContext(ctx).Create(&u).Error; err != nil {
163 return 0, err
164 }
165 return u.ID, nil
166 }
167}
168
169// The seeded accounts. Three, at three different roles, because a role
170// gate you cannot click on is a role gate nobody checks: signing in as
171// SeedAdmin and as SeedMember shows two visibly different members
172// pages, and SeedAdmin cannot promote anybody to Admin while SeedOwner
173// can.
174const (
175 SeedOwner = "ada@example.test"
176 SeedAdmin = "kim@example.test"
177 SeedMember = "sam@example.test"
178 SeedPassword = "demo-password"
179)
180
181// Seed writes those three accounts, and is idempotent: it does nothing
182// at all unless the roster is empty.
183//
184// It goes through the REAL flows rather than inserting roster rows —
185// Claim for the Owner, then Invite and Accept for the other two —
186// because those are the only paths that exist. The store will not mint
187// a second Owner, and it will not mint an Admin without an invitation
188// to spend; a seed that wrote the rows directly would be demonstrating
189// a way in that no running app has.
190func Seed(ctx context.Context, g *gorm.DB, rs *idear.Roster) error {
191 empty, err := rs.IsEmpty(ctx)
192 if err != nil {
193 return err
194 }
195 if !empty {
196 return nil
197 }
198
199 ownerID, err := seedUser(ctx, g, SeedOwner)
200 if err != nil {
201 return err
202 }
203 owner, err := rs.Claim(ctx, subjectFor(ownerID), SeedOwner, "Ada")
204 if err != nil {
205 return err
206 }
207
208 for _, want := range []struct {
209 email string
210 name string
211 role idear.Role
212 }{
213 {SeedAdmin, "Kim", idear.RoleAdmin},
214 {SeedMember, "Sam", idear.RoleMember},
215 } {
216 _, token, err := rs.Invite(ctx, owner, want.email, want.role)
217 if err != nil {
218 return err
219 }
220 id, err := seedUser(ctx, g, want.email)
221 if err != nil {
222 return err
223 }
224 if _, err := rs.Accept(ctx, token, subjectFor(id), want.name); err != nil {
225 return err
226 }
227 }
228
229 return g.WithContext(ctx).Create(&Post{
230 AuthorID: owner.ID,
231 Author: owner.Email,
232 Body: "Welcome to the board. Everyone here can post; admins can delete.",
233 }).Error
234}
235
236func seedUser(ctx context.Context, g *gorm.DB, email string) (int64, error) {
237 hash, err := password.Hash(SeedPassword)
238 if err != nil {
239 return 0, err
240 }
241 return createUser(g)(ctx, email, hash)
242}
243