The schema with no address column, and the guard that says so
§13 step 2, Task 2, first half. The database handle and the secure_delete hook follow; this is the schema and its gate. - internal/home/migrations/0001_people.sql: person_ref, blind_index, created_at. No address column. Nothing writes it yet. - internal/home/models.go: Schema and BootSchema. rastrillo/auth's schema is deliberately absent — this app does not use that package, and auth's tables hold addresses. sessions.Schema carries none; what goes into its subject column is the part that matters, and that is the person ref, never the address, enforced where sessions are minted. - internal/guardstest/noaddress_test.go: gate 2's schema half. Two corrections to the plan's version of that gate, both found by writing a test for the matcher rather than trusting it: 1. `\b(address|email|...)\b` does not match `owner_email`. \b counts _ as a word character, and _ is exactly what separates words in a SQL column name, so the one column somebody would actually add slipped straight through. The boundary is now "not a letter or digit". 2. The guard read comments as code, so the first migration written failed on its own prose explaining why it has no address column. It now matches on the code left of `--`. Nothing hides there: a column definition inside a comment is not a column. Both exemptions — the `sealed` name and the comment strip — now have tests asserting they stay narrow, including that a column with an explanatory comment beside it still fails. Stated limit: a name with no separator (useraddress) is not matched. Widening to a substring search would flag emailed_at and every comment mentioning mail, and a guard that cries wolf gets silenced rather than fixed. The limit is a test, not a surprise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4uVkB6xnZJYuxTooPiUn4
6 files changed,
+242
−6
go.mod+5 −0go.sum+26 −6internal/guardstest/noaddress_test.go+128 −0internal/guardstest/repo_test.go+39 −0internal/home/migrations/0001_people.sql+13 −0internal/home/models.go+31 −0
diff --git a/go.mod b/go.mod| index cf7bf48..abc1c89 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -11,10 +11,15 @@ require ( |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // 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.47.0 // indirect |
| + golang.org/x/text v0.41.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 |
diff --git a/go.sum b/go.sum| index d2591f7..46d8a82 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -4,29 +4,49 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp |
| 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/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +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/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| 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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| 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/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= |
| +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= |
| +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= |
| +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +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/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
diff --git a/internal/guardstest/noaddress_test.go b/internal/guardstest/noaddress_test.go| new file mode 100644 |
| index 0000000..0883a77 |
| --- /dev/null |
| +++ b/internal/guardstest/noaddress_test.go |
| @@ -0,0 +1,128 @@ |
| +package guards |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// addressish matches a column name that would hold an email address. |
| +// §10: "people — person ref, blind index, created_at. No address column." |
| +// |
| +// The boundary is "not a letter or digit" rather than \b, because \b |
| +// counts _ as a word character and SQL column names are separated by it: |
| +// \bemail\b does not match owner_email, which is exactly the column |
| +// somebody would add. TestAddressishMatches caught that on the first |
| +// run, which is the whole reason that test exists. |
| +// |
| +// Known limit, stated rather than papered over: a name with no separator |
| +// at all — useraddress — is not matched. Widening to a bare substring |
| +// search would flag emailed_at and every comment mentioning mail, and a |
| +// guard that cries wolf gets a // nolint faster than it gets a fix. |
| +var addressish = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(address|email|e_mail|mail_to|recipient)(?:$|[^a-z0-9])`) |
| + |
| +// TestNoAddressColumn reads every migration and fails on a column whose |
| +// name says it holds an address. This is the cheap half of gate 2; the |
| +// expensive half — a full sign-in leaves no readable address anywhere, |
| +// WAL included — needs a running sign-in and lands with it. |
| +// |
| +// It is a tripwire, like gate 9: it passes trivially while there are no |
| +// migrations, and that is correct. A guard written after the schema it |
| +// guards is a guard written to pass. |
| +func TestNoAddressColumn(t *testing.T) { |
| + root := repoRoot(t) |
| + for _, path := range migrationFiles(t, root) { |
| + rel := relPath(t, root, path) |
| + for i, line := range strings.Split(readFile(t, path), "\n") { |
| + // Match on the code, not the comment. A migration explaining |
| + // why it has no address column is not an address column, and |
| + // the first one written tripped this guard on its own prose. |
| + // Nothing can hide here: a column definition inside a SQL |
| + // comment is not a column. |
| + code, _, _ := strings.Cut(line, "--") |
| + // A sealed blob is not an address, and says so in its name. |
| + if strings.Contains(strings.ToLower(code), "sealed") { |
| + continue |
| + } |
| + if addressish.MatchString(code) { |
| + t.Errorf("%s:%d: %q names an address column; §10 says there is none", |
| + rel, i+1, strings.TrimSpace(line)) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestAddressishMatches proves the matcher fires, and fires on the |
| +// shapes a schema would actually use. A tripwire that passes trivially |
| +// today is only worth having if it would still fire tomorrow, and this |
| +// repo has twice shipped a detector that could not detect. |
| +func TestAddressishMatches(t *testing.T) { |
| + shouldMatch := []string{ |
| + " address TEXT NOT NULL,", |
| + " email TEXT,", |
| + " e_mail TEXT,", |
| + " mail_to TEXT,", |
| + " recipient TEXT,", |
| + " EMAIL TEXT,", |
| + " owner_email TEXT,", |
| + } |
| + for _, line := range shouldMatch { |
| + if !addressish.MatchString(line) { |
| + t.Errorf("addressish missed %q", line) |
| + } |
| + } |
| + |
| + shouldNotMatch := []string{ |
| + " person_ref TEXT PRIMARY KEY,", |
| + " blind_index TEXT NOT NULL UNIQUE,", |
| + " created_at TEXT NOT NULL", |
| + "CREATE TABLE people (", |
| + " host TEXT NOT NULL,", |
| + // "mailer" and "emailed" are words, not columns: the \b anchors |
| + // are what keep them out, and removing them would make every |
| + // comment mentioning mail a failure. |
| + " -- the mailer sends this", |
| + " emailed_at TEXT,", |
| + // Stated limit: no separator, so not matched. Here so the limit |
| + // is a test somebody can read, not a surprise in review. |
| + " useraddress TEXT,", |
| + } |
| + for _, line := range shouldNotMatch { |
| + if addressish.MatchString(line) { |
| + t.Errorf("addressish wrongly matched %q", line) |
| + } |
| + } |
| +} |
| + |
| +// The two ways past this guard are the "sealed" name and the comment |
| +// strip. Both are narrow, and both are stated here as tests rather than |
| +// as trust. |
| +func TestGuardExemptionsAreNarrow(t *testing.T) { |
| + // The sealed exemption is per line, and only when the line says so. |
| + if !strings.Contains(strings.ToLower(" sealed_address BLOB NOT NULL,"), "sealed") { |
| + t.Fatal("the exemption test itself is wrong") |
| + } |
| + if !addressish.MatchString(" address TEXT,") { |
| + t.Fatal("a plain address column must still match, exemption or not") |
| + } |
| + |
| + // The comment strip takes the comment, never the code beside it. |
| + for _, line := range []string{ |
| + " address TEXT, -- not sealed, not allowed", |
| + " owner_email TEXT NOT NULL, -- explained away in a comment", |
| + } { |
| + code, _, _ := strings.Cut(line, "--") |
| + if !addressish.MatchString(code) { |
| + t.Errorf("a column commented out of trouble still slipped through: %q", line) |
| + } |
| + } |
| + for _, line := range []string{ |
| + "-- there is no address column here and that is the point", |
| + " person_ref TEXT PRIMARY KEY, -- not the email address", |
| + } { |
| + code, _, _ := strings.Cut(line, "--") |
| + if addressish.MatchString(code) { |
| + t.Errorf("prose about addresses was read as a column: %q", line) |
| + } |
| + } |
| +} |
diff --git a/internal/guardstest/repo_test.go b/internal/guardstest/repo_test.go| index 7e400c6..ab5b5ef 100644 |
| --- a/internal/guardstest/repo_test.go |
| +++ b/internal/guardstest/repo_test.go |
| @@ -130,3 +130,42 @@ func fileImports(t *testing.T, path string) []string { |
| } |
| return imports |
| } |
| + |
| +// migrationFiles returns the path of every *.sql file under root. It |
| +// walks rather than globbing one directory: a second migrations |
| +// directory added later must be covered by the schema guards, not fall |
| +// silently outside them. |
| +func migrationFiles(t *testing.T, root string) []string { |
| + t.Helper() |
| + var files []string |
| + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if path != root && skipDir(d.Name()) { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if strings.HasSuffix(path, ".sql") { |
| + files = append(files, path) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("guards: walk %s: %v", root, err) |
| + } |
| + return files |
| +} |
| + |
| +// readFile is os.ReadFile with the guards-package error-handling |
| +// boilerplate folded in. |
| +func readFile(t *testing.T, path string) string { |
| + t.Helper() |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("guards: read %s: %v", path, err) |
| + } |
| + return string(b) |
| +} |
diff --git a/internal/home/migrations/0001_people.sql b/internal/home/migrations/0001_people.sql| new file mode 100644 |
| index 0000000..9666a9d |
| --- /dev/null |
| +++ b/internal/home/migrations/0001_people.sql |
| @@ -0,0 +1,13 @@ |
| +-- A person is a person ref and a blind index. There is no address column |
| +-- here and there is not meant to be one: §10 says "people — person ref, |
| +-- blind index, created_at. No address column", and gate 2's schema half |
| +-- (internal/guardstest/noaddress_test.go) fails the build if one appears. |
| +-- |
| +-- blind_index is the only way back from an address to a person, which is |
| +-- why HOME_PEPPER is a one-way door: lose it and every row here is |
| +-- orphaned. Nothing writes this table yet. |
| +CREATE TABLE people ( |
| + person_ref TEXT PRIMARY KEY, |
| + blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index |
| + created_at TEXT NOT NULL |
| +); |
diff --git a/internal/home/models.go b/internal/home/models.go| new file mode 100644 |
| index 0000000..3b3291c |
| --- /dev/null |
| +++ b/internal/home/models.go |
| @@ -0,0 +1,31 @@ |
| +package home |
| + |
| +import ( |
| + "embed" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrationsFS embed.FS |
| + |
| +// Schema is this app's own migrations: forward-only, ledgered by ID and |
| +// checksum, named migrations/NNNN_lowercase_name.sql. |
| +var Schema = migrate.MustFromFS(migrationsFS, "home") |
| + |
| +// BootSchema is everything applied at boot. |
| +// |
| +// rastrillo/auth's schema is deliberately absent. This app does not use |
| +// that package — sign-in is hand-rolled on keymaildev/signin — and |
| +// auth's tables hold addresses, which must never be created here. An |
| +// app that merged auth.Schema "just in case" would create an address |
| +// column that gate 2's schema half cannot see, because that gate reads |
| +// this repo's migrations and auth's are a dependency's. |
| +// |
| +// sessions.Schema carries no address either: its columns are token_hash, |
| +// subject, method, auth_time, created_at, expires_at. What goes INTO |
| +// subject is the part that matters — it must be the person ref, never |
| +// the address — and that is enforced where sessions are minted, not |
| +// here. |
| +var BootSchema = migrate.Merge(sessions.Schema, Schema) |