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
|
||||
}
|
||||
Reference in New Issue
Block a user