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>
23 lines
1.2 KiB
SQL
23 lines
1.2 KiB
SQL
-- 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);
|