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 }