-- 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;