feat(store): invitations table + create/peek/claim methods
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS invitations;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration 009: invitations — an email-based invite to join Tapir (Stage-1
|
||||
-- onboarding gate). Mathias mints one with `tapir invite <email>`; 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);
|
||||
Reference in New Issue
Block a user