Add database schema and Kysely packages
- Create initial database migration with full auth schema: - users, sessions, passkeys, devices tables - orgs, org_members, org_sites, org_invites tables - email_verifications, password_resets, login_requests tables - Indexes for common lookups and cleanup jobs - Add @reviq/db-schema package with kysely-codegen - Add @reviq/db package with Kysely client Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
220
db/migrations/001_initial_schema.sql
Normal file
220
db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,220 @@
|
||||
-- migrate:up
|
||||
|
||||
-- Enums
|
||||
CREATE TYPE org_role AS ENUM ('owner', 'admin', 'member');
|
||||
CREATE TYPE passkey_device_type AS ENUM ('singleDevice', 'multiDevice');
|
||||
|
||||
-- Users (authentication)
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
email_verified_at TIMESTAMPTZ, -- NULL until verified
|
||||
full_name TEXT, -- Legal/full name (set in profile setup)
|
||||
display_name TEXT, -- Preferred display name (set in profile setup)
|
||||
phone_number TEXT, -- E.164 format, validated with libphonenumber-js
|
||||
avatar_url TEXT,
|
||||
password_hash TEXT, -- NULL if using passkey only
|
||||
require_passkey BOOLEAN NOT NULL DEFAULT false, -- If true, must use passkey to login
|
||||
is_superuser BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Email verification tokens
|
||||
CREATE TABLE email_verifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Password reset tokens
|
||||
CREATE TABLE password_resets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ, -- Set when token is used
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- WebAuthn challenges (short-lived, for passkey registration/authentication)
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
options JSONB NOT NULL, -- Full options object from simplewebauthn
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Passkeys (WebAuthn credentials)
|
||||
CREATE TABLE passkeys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id BYTEA UNIQUE NOT NULL,
|
||||
public_key BYTEA NOT NULL,
|
||||
webauthn_user_id TEXT UNIQUE NOT NULL,
|
||||
counter BIGINT NOT NULL DEFAULT 0,
|
||||
device_type passkey_device_type NOT NULL,
|
||||
backup_eligible BOOLEAN NOT NULL,
|
||||
backup_status BOOLEAN NOT NULL,
|
||||
transports JSONB, -- ['usb', 'ble', 'nfc', 'internal']
|
||||
rpid TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- e.g. "MacBook Touch ID"
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- User devices (tracks all devices a user has logged in from)
|
||||
-- Note: device_fingerprint is NOT unique - multiple users can share the same
|
||||
-- browser/device (e.g., shared family computer, work terminal)
|
||||
CREATE TABLE user_devices (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_fingerprint TEXT NOT NULL, -- Random ID stored in cookie
|
||||
name TEXT, -- e.g. "Chrome on MacOS"
|
||||
is_trusted BOOLEAN NOT NULL DEFAULT false,
|
||||
user_agent TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
city TEXT,
|
||||
region TEXT,
|
||||
country TEXT,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(user_id, device_fingerprint) -- One device record per user+fingerprint
|
||||
);
|
||||
|
||||
-- Sessions (token_hash stores SHA-256 hash of token, not raw token)
|
||||
-- Sessions are kept for audit history, not deleted by cron
|
||||
CREATE TABLE sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id BIGINT REFERENCES user_devices(id) ON DELETE SET NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL, -- SHA-256 hash of session token
|
||||
trusted_mode BOOLEAN NOT NULL, -- True if passkey or email-confirmed login
|
||||
ip_address TEXT,
|
||||
city TEXT,
|
||||
region TEXT,
|
||||
country TEXT,
|
||||
user_agent TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ, -- NULL if active, set when logged out
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- API tokens (for CLI and programmatic access)
|
||||
CREATE TABLE api_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, -- e.g. "CLI token", "CI/CD"
|
||||
token_hash TEXT UNIQUE NOT NULL, -- SHA-256 hash of token
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Login requests (tracks login attempts for polling and email confirmation)
|
||||
-- Deleted after successful login via loginIfRequestIsCompleted()
|
||||
-- Note: Only created for existing users. Non-existent users get a fake token (anti-enumeration).
|
||||
CREATE TABLE login_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL, -- Email entered by user (normalized lowercase)
|
||||
token TEXT UNIQUE, -- Token sent in email link (NULL until email sent)
|
||||
device_fingerprint TEXT, -- From cookie at login request time
|
||||
ip_address TEXT,
|
||||
city TEXT,
|
||||
region TEXT,
|
||||
country TEXT,
|
||||
user_agent TEXT,
|
||||
completed_at TIMESTAMPTZ, -- NULL until login completed
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Orgs (companies/accounts)
|
||||
CREATE TABLE orgs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
logo_url TEXT, -- Cloudflare R2 URL
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Org members (users belonging to orgs)
|
||||
CREATE TABLE org_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role org_role NOT NULL DEFAULT 'member',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(org_id, user_id)
|
||||
);
|
||||
|
||||
-- Org sites (maps orgs to domains for report access)
|
||||
CREATE TABLE org_sites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
||||
domain TEXT NOT NULL, -- key used to filter reports
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(domain)
|
||||
);
|
||||
|
||||
-- Org invites (pending invitations)
|
||||
CREATE TABLE org_invites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role org_role NOT NULL DEFAULT 'member',
|
||||
invited_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(org_id, email)
|
||||
);
|
||||
|
||||
-- Indexes (UNIQUE constraints already create indexes, so we skip those)
|
||||
-- Note: orgs.slug has an index via UNIQUE constraint (used for all org API lookups)
|
||||
-- Lookup indexes
|
||||
CREATE INDEX idx_passkeys_user ON passkeys(user_id);
|
||||
CREATE INDEX idx_user_devices_user ON user_devices(user_id);
|
||||
CREATE INDEX idx_user_devices_fingerprint ON user_devices(device_fingerprint);
|
||||
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_device ON sessions(device_id);
|
||||
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX idx_login_requests_user ON login_requests(user_id);
|
||||
CREATE INDEX idx_password_resets_user ON password_resets(user_id);
|
||||
CREATE INDEX idx_org_members_user ON org_members(user_id);
|
||||
CREATE INDEX idx_org_members_org ON org_members(org_id);
|
||||
CREATE INDEX idx_org_sites_org ON org_sites(org_id);
|
||||
CREATE INDEX idx_org_invites_email ON org_invites(email);
|
||||
|
||||
-- Active session lookup (excludes revoked sessions)
|
||||
CREATE INDEX idx_sessions_active ON sessions(token_hash) WHERE revoked_at IS NULL;
|
||||
|
||||
-- Cleanup job indexes (for deleting expired rows)
|
||||
-- Note: sessions are NOT deleted, kept for audit history (use revoked_at instead)
|
||||
CREATE INDEX idx_api_tokens_expires ON api_tokens(expires_at);
|
||||
CREATE INDEX idx_email_verifications_expires ON email_verifications(expires_at);
|
||||
CREATE INDEX idx_password_resets_expires ON password_resets(expires_at);
|
||||
CREATE INDEX idx_webauthn_challenges_created ON webauthn_challenges(created_at);
|
||||
CREATE INDEX idx_org_invites_expires ON org_invites(expires_at);
|
||||
CREATE INDEX idx_login_requests_expires ON login_requests(expires_at);
|
||||
|
||||
-- migrate:down
|
||||
|
||||
DROP TABLE IF EXISTS org_invites;
|
||||
DROP TABLE IF EXISTS org_sites;
|
||||
DROP TABLE IF EXISTS org_members;
|
||||
DROP TABLE IF EXISTS orgs;
|
||||
DROP TABLE IF EXISTS login_requests;
|
||||
DROP TABLE IF EXISTS api_tokens;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS user_devices;
|
||||
DROP TABLE IF EXISTS passkeys;
|
||||
DROP TABLE IF EXISTS webauthn_challenges;
|
||||
DROP TABLE IF EXISTS password_resets;
|
||||
DROP TABLE IF EXISTS email_verifications;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TYPE IF EXISTS passkey_device_type;
|
||||
DROP TYPE IF EXISTS org_role;
|
||||
1080
db/schema.sql
Normal file
1080
db/schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user