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:
RevIQ
2026-01-09 11:44:36 +08:00
parent 322155b4a1
commit 392d976812
12 changed files with 1676 additions and 0 deletions

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