carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import "time"
4
5// Models is every model the schema generator manages. Keep it in step
6// with the structs below: `rastrillo migration generate` reads
7// it to work out what the database should look like, and
8// `rastrillo migration check` fails CI when it and the
9// migrations disagree.
10var Models = []any{
11 &Signup{},
12 &SpentNonce{},
13 &MailCounter{},
14}
15
16// Stance is which of the three positions somebody picked. The stored value is
17// an internal key, never the label they read: labels are copy, copy changes,
18// and a column that changes with it is a migration for every wording tweak
19// and a corrupted history for the rows already written.
20type Stance string
21
22const (
23 StanceNever Stance = "never"
24 StanceCurious Stance = "curious"
25 StanceEnthusiast Stance = "enthusiast"
26)
27
28// Stances is the closed set, in the order the landing page offers them.
29var Stances = []Stance{StanceNever, StanceCurious, StanceEnthusiast}
30
31// Slugs are the web addresses people land on and share, reviewed alongside
32// the rest of the copy. They are separate from the stored keys on purpose:
33// a slug is copy and copy changes, while the value in the database has to
34// stay put or every stored row means something different afterwards.
35var slugs = map[string]Stance{
36 "no-ai": StanceNever,
37 "uneasy": StanceCurious,
38 "all-in": StanceEnthusiast,
39}
40
41// Slug returns the address for a stance.
42func (s Stance) Slug() string {
43 for slug, k := range slugs {
44 if k == s {
45 return slug
46 }
47 }
48 return ""
49}
50
51// StanceForSlug resolves an incoming address to a stored key.
52func StanceForSlug(slug string) (Stance, bool) {
53 s, ok := slugs[slug]
54 return s, ok
55}
56
57// ValidStance reports whether s is one of the three. Anything else renders
58// the same page as every other rejection — a made-up stance is a submission
59// we refuse, not an error we explain.
60func ValidStance(s string) bool {
61 for _, k := range Stances {
62 if string(k) == s {
63 return true
64 }
65 }
66 return false
67}
68
69// Signup is one person's answer.
70//
71// Name joined on 2026-09-03. It widens what a row is: an address and a
72// stance was already a stated position attached to a contactable person, and
73// a name makes that person identifiable rather than merely reachable. Every
74// rule about the sensitive pair applies to it, and it never reaches a log.
75//
76// There is no owner column and no User model, because nothing here is owned:
77// the public surface writes rows nobody is signed in for, and the admin view
78// reads all of them. Worth naming, because rastrillo's most expensive trap is
79// sessions.UserID silently returning (0, false) under the auth plugin, where
80// the session subject is a verified address rather than a numeric id. This
81// app never calls it. Reading the whole table is correct here and would be a
82// severe bug in an app with tenants.
83type Signup struct {
84 ID int64
85 Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe
86
87 Name string // what they call themselves; capped and stripped
88 Stance string
89 Background string // capped and stripped of control characters on the way in
90
91 // A row is inert until Confirmed. It is never exported, never counted,
92 // and never mailed beyond its own confirmation — because proof of work
93 // and honeypots say nothing about whether an address is real or whether
94 // its owner agreed. Only a click from inside that inbox does.
95 Confirmed bool
96 ConfirmedAt *time.Time
97
98 TokenHash []byte // SHA-256 of a random token; cleared when it is burned
99 TokenExpiry time.Time
100
101 // The per-address mail budget. These two columns are deliberately
102 // excluded from the pending-row update allowlist: a counter somebody can
103 // reset by submitting the form again is not a budget.
104 LastMailedAt *time.Time
105 MailCount int
106
107 CreatedAt time.Time
108 UpdatedAt time.Time
109}
110
111// MailCounter is the global hourly budget.
112//
113// It exists because the real damage from a bulk attack is not junk rows, it
114// is thousands of messages to strangers from our own verified sending
115// identity, followed by the sending domain being suspended. It is persisted
116// rather than in memory because this process hibernates as its normal state,
117// and an in-memory counter would forget everything on the next wake — which
118// is precisely when an attacker would like it to.
119type MailCounter struct {
120 HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15"
121 Count int
122}
123
124// SpentNonce records a challenge that has already been accepted.
125//
126// Nothing is written when a challenge is minted — only when one is spent.
127// Written at mint time, this table would take a row per page view, which
128// means a crawler, a link scanner or a browser prefetching links becomes a
129// stream of serialised writes against a single-writer SQLite file, starving
130// the one resource every signup needs. Written on acceptance, every row has
131// already cost somebody a proof-of-work solve, so the table is bounded by
132// work an attacker had to do rather than by traffic anyone can generate.
133//
134// Without it, a single solved challenge is replayable for its whole two-hour
135// window, and each replay sends another confirmation mail to whatever
136// address it names — a mail-bombing primitive pointed at a stranger's inbox,
137// sent from our own verified identity, with the sending reputation as the
138// collateral.
139//
140// It lives in the database rather than in memory because this process
141// hibernates as its normal state, and an in-memory set would forget
142// everything on the next wake.
143type SpentNonce struct {
144 Nonce string `gorm:"primaryKey"`
145 ExpiresAt time.Time `gorm:"index"`
146}
147