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>
87 lines
3.0 KiB
Go
87 lines
3.0 KiB
Go
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
|
|
}
|