feat: an address becomes a stable actor id
Every authored column is a uint64 — Sheet.CreatedBy, Cell.UpdatedBy, Event.ActorID — and the session carries an email address. sessions.UserID cannot bridge the two, so members does. The row is minted on first sight rather than at boot from SHEETS_OWNERS: admission has already happened by then, and boot-time rows would go stale the moment the allowlist changed. The write is an upsert-then-read because two tabs arriving on one magic link is the realistic case, and read-then-create loses it. Address is plaintext per spec 2.2, which is also what makes the unique index possible: sealing uses a fresh nonce per call, so no index could ever match one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
7 files changed,
+265
−3
internal/sheets/helpers_test.go+58 −0internal/sheets/members.go+76 −0internal/sheets/members_test.go+98 −0internal/sheets/migrations.go+3 −3internal/sheets/migrations/0002_create_members.sql+4 −0internal/sheets/migrations/schema.sql+4 −0internal/sheets/models.go+22 −0
diff --git a/internal/sheets/helpers_test.go b/internal/sheets/helpers_test.go| new file mode 100644 |
| index 0000000..8bb1cf1 |
| --- /dev/null |
| +++ b/internal/sheets/helpers_test.go |
| @@ -0,0 +1,58 @@ |
| +package sheets |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "testing" |
| + |
| + rdb "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| +) |
| + |
| +// newTestApp builds an App over a real, migrated, throwaway database |
| +// and a deterministic all-zero DEK. |
| +// |
| +// Nil auth: these tests exercise storage, services and rendering, not |
| +// sign-in — appWithRecorder (begin_test.go) is the helper for that. A |
| +// nil auth means Router mounts no auth handlers and identity() answers |
| +// "nobody", which is why routes needing an actor take one explicitly in |
| +// these tests rather than reading a session. |
| +func newTestApp(t *testing.T) *App { |
| + t.Helper() |
| + d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), quietLogger()) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| + t.Fatalf("migrate: %v", err) |
| + } |
| + s, err := seal.New(make([]byte, dekBytes)) |
| + if err != nil { |
| + t.Fatalf("seal.New: %v", err) |
| + } |
| + return New(d.G, nil, s, func(string) bool { return true }) |
| +} |
| + |
| +// testMember is the actor most service tests write as. |
| +func testMember(t *testing.T, app *App) Member { |
| + t.Helper() |
| + m, err := app.memberFor("ada@example.com") |
| + if err != nil { |
| + t.Fatalf("memberFor: %v", err) |
| + } |
| + return m |
| +} |
| + |
| +// do runs one request through the app's router and hands back the |
| +// recorder, so a test reads as one line per request. |
| +func do(t *testing.T, app *App, req *http.Request) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + app.Router().ServeHTTP(rec, req) |
| + return rec |
| +} |
diff --git a/internal/sheets/members.go b/internal/sheets/members.go| new file mode 100644 |
| index 0000000..117dc5e |
| --- /dev/null |
| +++ b/internal/sheets/members.go |
| @@ -0,0 +1,76 @@ |
| +package sheets |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "strings" |
| + |
| + "gorm.io/gorm" |
| + "gorm.io/gorm/clause" |
| +) |
| + |
| +// errNoDB is what every service method answers when it is called on an |
| +// App built without storage. Tests that render pages do that |
| +// deliberately; production never can, because main refuses to boot |
| +// without a database. |
| +var errNoDB = errors.New("sheets: no database") |
| + |
| +// memberFor returns the Member row for an address, creating it the |
| +// first time that address is seen. |
| +// |
| +// First sight is the right moment because admission has already |
| +// happened: auth.Config.Authorize refused every address not on the |
| +// allowlist before a session could exist, so anything reaching here |
| +// belongs here. Minting rows at boot from SHEETS_OWNERS instead would |
| +// create members for people who never signed in, and would go stale the |
| +// moment the allowlist changed. |
| +// |
| +// The write is an upsert-then-read rather than a read-then-create: two |
| +// tabs signing in at the same moment would otherwise race, and the loser |
| +// would get a unique-constraint error instead of a member. |
| +func (a *App) memberFor(address string) (Member, error) { |
| + addr := strings.ToLower(strings.TrimSpace(address)) |
| + if addr == "" { |
| + // An empty address would mint one shared member that every |
| + // unauthenticated path then wrote edits as — the uid-0 trap |
| + // wearing a different hat. |
| + return Member{}, errors.New("sheets: refusing to mint a member for an empty address") |
| + } |
| + if a.db == nil { |
| + return Member{}, errNoDB |
| + } |
| + m := Member{Address: addr} |
| + if err := a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&m).Error; err != nil { |
| + return Member{}, err |
| + } |
| + if m.ID != 0 { |
| + return m, nil |
| + } |
| + // The insert conflicted, so the row is already there: read it. |
| + var existing Member |
| + if err := a.db.Where("address = ?", addr).First(&existing).Error; err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return Member{}, errors.New("sheets: member neither created nor found") |
| + } |
| + return Member{}, err |
| + } |
| + return existing, nil |
| +} |
| + |
| +// actor resolves the signed-in viewer to a Member. |
| +// |
| +// No session is not an error — it is "no actor" — so callers gate on ok |
| +// rather than on err. Every route that calls this has already been |
| +// through requireSession, so a false here in production means the |
| +// session went away between the middleware and the handler. |
| +func (a *App) actor(r *http.Request) (Member, bool) { |
| + address, ok := a.identity(r) |
| + if !ok { |
| + return Member{}, false |
| + } |
| + m, err := a.memberFor(address) |
| + if err != nil { |
| + return Member{}, false |
| + } |
| + return m, true |
| +} |
diff --git a/internal/sheets/members_test.go b/internal/sheets/members_test.go| new file mode 100644 |
| index 0000000..fd787fc |
| --- /dev/null |
| +++ b/internal/sheets/members_test.go |
| @@ -0,0 +1,98 @@ |
| +package sheets |
| + |
| +import "testing" |
| + |
| +func TestMemberForIsIdempotent(t *testing.T) { |
| + app := newTestApp(t) |
| + a, err := app.memberFor("Ada@example.com") |
| + if err != nil { |
| + t.Fatalf("memberFor: %v", err) |
| + } |
| + if a.ID == 0 { |
| + t.Fatal("memberFor returned id 0 — the uid-0 trap AGENTS.md names") |
| + } |
| + b, err := app.memberFor("ada@example.com") |
| + if err != nil { |
| + t.Fatalf("memberFor again: %v", err) |
| + } |
| + if b.ID != a.ID { |
| + t.Fatalf("the same person got two ids (%d then %d): the address "+ |
| + "comparison is case-sensitive, but the allowlist's is not", a.ID, b.ID) |
| + } |
| +} |
| + |
| +func TestMemberForSurroundingSpaceIsTheSamePerson(t *testing.T) { |
| + app := newTestApp(t) |
| + a, err := app.memberFor("ada@example.com") |
| + if err != nil { |
| + t.Fatalf("memberFor: %v", err) |
| + } |
| + b, err := app.memberFor(" ada@example.com ") |
| + if err != nil { |
| + t.Fatalf("memberFor padded: %v", err) |
| + } |
| + if a.ID != b.ID { |
| + t.Fatalf("a padded address minted a second member: %d then %d", a.ID, b.ID) |
| + } |
| +} |
| + |
| +func TestMemberForRejectsAnEmptyAddress(t *testing.T) { |
| + app := newTestApp(t) |
| + if _, err := app.memberFor(""); err == nil { |
| + t.Fatal(`memberFor("") = nil error; an empty address must never mint a member`) |
| + } |
| + if _, err := app.memberFor(" "); err == nil { |
| + t.Fatal("memberFor with only whitespace = nil error, want a refusal") |
| + } |
| +} |
| + |
| +func TestTwoPeopleGetTwoIDs(t *testing.T) { |
| + app := newTestApp(t) |
| + ada, err := app.memberFor("ada@example.com") |
| + if err != nil { |
| + t.Fatalf("memberFor ada: %v", err) |
| + } |
| + bob, err := app.memberFor("bob@example.com") |
| + if err != nil { |
| + t.Fatalf("memberFor bob: %v", err) |
| + } |
| + if ada.ID == bob.ID { |
| + t.Fatalf("two addresses share one actor id (%d); every edit would be misattributed", ada.ID) |
| + } |
| +} |
| + |
| +// A concurrent first sight is the realistic case — two tabs, one |
| +// person, both arriving on the same magic link — and read-then-create |
| +// loses it. |
| +func TestMemberForIsSafeUnderConcurrentFirstSight(t *testing.T) { |
| + app := newTestApp(t) |
| + const n = 8 |
| + ids := make(chan uint64, n) |
| + errs := make(chan error, n) |
| + start := make(chan struct{}) |
| + for i := 0; i < n; i++ { |
| + go func() { |
| + <-start |
| + m, err := app.memberFor("ada@example.com") |
| + if err != nil { |
| + errs <- err |
| + return |
| + } |
| + ids <- m.ID |
| + }() |
| + } |
| + close(start) |
| + var first uint64 |
| + for i := 0; i < n; i++ { |
| + select { |
| + case err := <-errs: |
| + t.Fatalf("concurrent memberFor: %v", err) |
| + case id := <-ids: |
| + if first == 0 { |
| + first = id |
| + } else if id != first { |
| + t.Fatalf("concurrent first sight minted two members: %d and %d", first, id) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go| index 23af022..c4fc717 100644 |
| --- a/internal/sheets/migrations.go |
| +++ b/internal/sheets/migrations.go |
| @@ -37,8 +37,8 @@ var Schema = migrate.MustFromFS(migrationsFS, "sheets") |
| var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema) |
| // Models lists every GORM model `rastrillo migration generate`/`check` |
| -// diff Schema against — Sheet, Tab, Row, Col, Cell and Format from |
| -// models.go, plus Event. |
| +// diff Schema against — Sheet, Tab, Row, Col, Cell, Format and Member |
| +// from models.go, plus Event. |
| // |
| // Event is declared in models.go, not internal/sheets/store/events.go |
| // where the rest of its behaviour (AppendInput, AppendRecalc, the Kind |
| @@ -56,5 +56,5 @@ var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema) |
| // keeps every existing store.Event call site, production and test, |
| // compiling unchanged. |
| var Models = []any{ |
| - Sheet{}, Tab{}, Row{}, Col{}, Cell{}, Format{}, Event{}, |
| + Sheet{}, Tab{}, Row{}, Col{}, Cell{}, Format{}, Event{}, Member{}, |
| } |
diff --git a/internal/sheets/migrations/0002_create_members.sql b/internal/sheets/migrations/0002_create_members.sql| new file mode 100644 |
| index 0000000..971adbf |
| --- /dev/null |
| +++ b/internal/sheets/migrations/0002_create_members.sql |
| @@ -0,0 +1,4 @@ |
| +CREATE TABLE `members` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text,`created_at` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_members_address` ON `members`(`address`); |
| + |
diff --git a/internal/sheets/migrations/schema.sql b/internal/sheets/migrations/schema.sql| index 878400f..dd67e05 100644 |
| --- a/internal/sheets/migrations/schema.sql |
| +++ b/internal/sheets/migrations/schema.sql |
| @@ -9,6 +9,8 @@ CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`tab_i |
| CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text); |
| +CREATE TABLE `members` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text,`created_at` datetime); |
| + |
| CREATE TABLE `rows` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`position` integer,`height` integer); |
| CREATE TABLE `sheets` (`id` integer PRIMARY KEY AUTOINCREMENT,`title_sealed` blob,`created_by` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime); |
| @@ -23,6 +25,8 @@ CREATE INDEX `idx_events_tab_id` ON `events`(`tab_id`); |
| CREATE UNIQUE INDEX `idx_formats_digest` ON `formats`(`digest`); |
| +CREATE UNIQUE INDEX `idx_members_address` ON `members`(`address`); |
| + |
| CREATE UNIQUE INDEX `idx_row_tab_pos` ON `rows`(`tab_id`,`position`); |
| CREATE INDEX `idx_sheets_deleted_at` ON `sheets`(`deleted_at`); |
diff --git a/internal/sheets/models.go b/internal/sheets/models.go| index 9a97fb4..b967cc6 100644 |
| --- a/internal/sheets/models.go |
| +++ b/internal/sheets/models.go |
| @@ -101,3 +101,25 @@ type Event struct { |
| DerivedCount int // recalc only: how many cells the pass touched |
| CreatedAt time.Time |
| } |
| + |
| +// Member is one person who may hold a session on this instance, and the |
| +// stable uint64 an edit records as its actor. |
| +// |
| +// Address is plaintext, deliberately. Spec §2.2 lists "membership, |
| +// grants, and roles — including readable email addresses" among the |
| +// things this design does NOT seal, and publishes that in the README |
| +// rather than leaving it to be discovered. Sealing it would also make |
| +// the uniqueIndex below impossible: seal uses a random nonce per call, |
| +// so the same address seals to different ciphertext every time and no |
| +// index could ever match one. |
| +// |
| +// This table exists because the session carries an address and every |
| +// authored column — Sheet.CreatedBy, Cell.UpdatedBy, Event.ActorID — is |
| +// a uint64. sessions.UserID cannot bridge the two: it returns |
| +// (0, false) for an email Subject, and dropping that ok scopes every |
| +// query to uid 0 (AGENTS.md / Mechanical rules). |
| +type Member struct { |
| + ID uint64 `gorm:"primaryKey"` |
| + Address string `gorm:"uniqueIndex;size:320"` |
| + CreatedAt time.Time |
| +} |