From 8b7ef07ba395fcd048333d1cedd8d214526ddbd5 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 23:12:40 +0200 Subject: [PATCH] feat(store): invitations table + create/peek/claim methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-1 email onboarding: Mathias mints an invite, the recipient claims it to set a Dex password. Invitations exist before their user, so the table carries no user_id FK and is deliberately outside RLS — the 32-byte crypto-random token is the capability (single-use, time-boxed). ClaimInvitation consumes atomically (UPDATE ... WHERE used_at IS NULL ... RETURNING) so concurrent claims of one token can't both succeed. PeekInvitation validates the link for the form without consuming it. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/store/invitations.go | 86 ++++++++++++++ internal/adapters/store/invitations_test.go | 110 ++++++++++++++++++ .../store/migrations/009_invitations.down.sql | 1 + .../store/migrations/009_invitations.up.sql | 22 ++++ 4 files changed, 219 insertions(+) create mode 100644 internal/adapters/store/invitations.go create mode 100644 internal/adapters/store/invitations_test.go create mode 100644 internal/adapters/store/migrations/009_invitations.down.sql create mode 100644 internal/adapters/store/migrations/009_invitations.up.sql diff --git a/internal/adapters/store/invitations.go b/internal/adapters/store/invitations.go new file mode 100644 index 0000000..1229665 --- /dev/null +++ b/internal/adapters/store/invitations.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Invitations are NOT routed through withUser: an invitation exists before its +// user does, so there is no user_id to scope by and no authenticated context when +// one is minted (host CLI) or claimed (the public /invite handler). The token is +// the capability — single-use, time-boxed, crypto-random. The invitations table +// is deliberately outside RLS for the same reason (see migration 009). + +// CreateInvitation mints a single-use invite for email, valid for ttl, and +// returns its token. The token is 32 bytes of crypto-random entropy, hex-encoded; +// it is the only secret a recipient needs to claim the invite. +func (s *Store) CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) { + token, err := newInviteToken() + if err != nil { + return "", err + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO invitations (email, token, expires_at) + VALUES ($1, $2, NOW() + $3::interval)`, + email, token, ttl.String()); err != nil { + return "", fmt.Errorf("store: create invitation: %w", err) + } + return token, nil +} + +// PeekInvitation returns the invited email for a token that is real, unexpired, +// and unused WITHOUT consuming it — the read the /invite form does to validate the +// link before showing the password fields. Returns ErrNotFound when the token is +// missing, expired, or already used. Use ClaimInvitation to consume. +func (s *Store) PeekInvitation(ctx context.Context, token string) (string, error) { + var email string + err := s.pool.QueryRow(ctx, + `SELECT email FROM invitations + WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`, + token).Scan(&email) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("store: peek invitation: %w", err) + } + return email, nil +} + +// ClaimInvitation atomically consumes a valid invite and returns its email. The +// UPDATE ... WHERE used_at IS NULL AND expires_at > NOW() guarded by RETURNING +// makes the claim a single round-trip race-free check-and-set: two concurrent +// claims of the same token, only one updates a row, the other gets no rows and so +// ErrNotFound. Same ErrNotFound for missing/expired/already-used tokens. +func (s *Store) ClaimInvitation(ctx context.Context, token string) (string, error) { + var email string + err := s.pool.QueryRow(ctx, + `UPDATE invitations + SET used_at = NOW() + WHERE token = $1 AND used_at IS NULL AND expires_at > NOW() + RETURNING email`, + token).Scan(&email) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("store: claim invitation: %w", err) + } + return email, nil +} + +// newInviteToken returns 32 bytes of crypto-random entropy, hex-encoded (64 +// chars). Hex keeps the token URL-safe with no escaping in /invite/{token}. +func newInviteToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("store: invite token: %w", err) + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/adapters/store/invitations_test.go b/internal/adapters/store/invitations_test.go new file mode 100644 index 0000000..e9ef35b --- /dev/null +++ b/internal/adapters/store/invitations_test.go @@ -0,0 +1,110 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// resetInvitations clears the invitations table between cases. It is not in the +// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to +// users), so the invite tests wipe it themselves. +func resetInvitations(t *testing.T, p *pgxpool.Pool) { + t.Helper() + _, err := p.Exec(context.Background(), `TRUNCATE invitations`) + require.NoError(t, err) +} + +func TestCreateInvitationReturnsUsableToken(t *testing.T) { + s, p := newStore(t), rawPool(t) + resetInvitations(t, p) + ctx := context.Background() + + token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour) + require.NoError(t, err) + require.Len(t, token, 64, "32 random bytes hex-encoded") + + // Peek does not consume: the same token previews twice. + email, err := s.PeekInvitation(ctx, token) + require.NoError(t, err) + require.Equal(t, "new@example.com", email) + email, err = s.PeekInvitation(ctx, token) + require.NoError(t, err) + require.Equal(t, "new@example.com", email) +} + +func TestCreateInvitationTokensAreUnique(t *testing.T) { + s, p := newStore(t), rawPool(t) + resetInvitations(t, p) + ctx := context.Background() + + t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour) + require.NoError(t, err) + t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour) + require.NoError(t, err) + require.NotEqual(t, t1, t2) +} + +func TestClaimInvitationHappyPath(t *testing.T) { + s, p := newStore(t), rawPool(t) + resetInvitations(t, p) + ctx := context.Background() + + token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour) + require.NoError(t, err) + + email, err := s.ClaimInvitation(ctx, token) + require.NoError(t, err) + require.Equal(t, "claim@example.com", email) +} + +func TestClaimInvitationIsSingleUse(t *testing.T) { + s, p := newStore(t), rawPool(t) + resetInvitations(t, p) + ctx := context.Background() + + token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour) + require.NoError(t, err) + + _, err = s.ClaimInvitation(ctx, token) + require.NoError(t, err) + + // Second claim fails — already used. + _, err = s.ClaimInvitation(ctx, token) + require.ErrorIs(t, err, store.ErrNotFound) + + // And a used token no longer previews. + _, err = s.PeekInvitation(ctx, token) + require.ErrorIs(t, err, store.ErrNotFound) +} + +func TestClaimInvitationExpired(t *testing.T) { + s, p := newStore(t), rawPool(t) + resetInvitations(t, p) + ctx := context.Background() + + // Negative ttl => already expired. + token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute) + require.NoError(t, err) + + _, err = s.PeekInvitation(ctx, token) + require.ErrorIs(t, err, store.ErrNotFound) + _, err = s.ClaimInvitation(ctx, token) + require.ErrorIs(t, err, store.ErrNotFound) +} + +func TestClaimInvitationNotFound(t *testing.T) { + s, p := newStore(t), rawPool(t) + resetInvitations(t, p) + ctx := context.Background() + + _, err := s.ClaimInvitation(ctx, "does-not-exist") + require.ErrorIs(t, err, store.ErrNotFound) + _, err = s.PeekInvitation(ctx, "does-not-exist") + require.ErrorIs(t, err, store.ErrNotFound) +} diff --git a/internal/adapters/store/migrations/009_invitations.down.sql b/internal/adapters/store/migrations/009_invitations.down.sql new file mode 100644 index 0000000..0df8f41 --- /dev/null +++ b/internal/adapters/store/migrations/009_invitations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS invitations; diff --git a/internal/adapters/store/migrations/009_invitations.up.sql b/internal/adapters/store/migrations/009_invitations.up.sql new file mode 100644 index 0000000..74446bc --- /dev/null +++ b/internal/adapters/store/migrations/009_invitations.up.sql @@ -0,0 +1,22 @@ +-- Migration 009: invitations — an email-based invite to join Tapir (Stage-1 +-- onboarding gate). Mathias mints one with `tapir invite `; the recipient +-- visits /invite/{token}, sets a password, and Tapir creates their Dex account. +-- +-- Deliberately NOT user-owned and NOT under RLS: an invitation exists BEFORE the +-- user does, so there is no user_id to scope by and no authenticated user context +-- when the invite is created (host CLI) or consumed (public /invite handler, no +-- Dex session). The token itself is the capability — a 32-byte crypto-random, +-- single-use, time-boxed secret. Hence no `user_id` FK and no ENABLE/FORCE ROW +-- LEVEL SECURITY here (unlike every user-owned table in migrations 003/005). +CREATE TABLE invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ +); + +-- Lookups are by token (both the claim and the form preview); the UNIQUE +-- constraint already creates an index, this names one explicitly for clarity. +CREATE INDEX idx_invitations_token ON invitations(token);