-- §6.2 / v0.7.0: email + one-time-code sign-in. -- -- Replaces the Gitea OAuth gesture as the primary human-auth path. -- The Gitea bot user + token are still needed for server-side git -- operations (repo reads, PR creation); only the operator-facing -- sign-in surface moves. The /auth/callback OAuth route remains -- functional during migration as a fallback, scheduled for removal -- in a future release once every active user has signed in via OTC -- at least once. -- -- A row in `otc_codes` represents an outstanding 6-digit code that -- was emailed to `email`. Codes are stored hashed (bcrypt) rather -- than plaintext, so a database compromise does not expose the -- in-flight code. TTL is enforced by `expires_at`. Each `verify` -- success stamps `consumed_at` and refuses every later attempt -- against the same row. -- -- The §6.2 identity model under v0.7.0: -- -- * `users.email` is the primary identity key for new sign-ins. -- * `users.gitea_id` stays populated for users grandfathered in -- via the OAuth-era flow; new users have `gitea_id = NULL`. -- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it -- was `INTEGER UNIQUE NOT NULL`) to permit the NULL. -- * `users.email` becomes a (case-insensitive) unique key. An -- existing OAuth user whose Gitea profile carried an email is -- linked on first OTC sign-in; if no row matches, a fresh -- contributor row is provisioned. -- -- New env vars (v0.7.0): -- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid. -- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate -- limit between successive `/auth/otc/request` calls. CREATE TABLE otc_codes ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL COLLATE NOCASE, code_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, consumed_at TEXT ); CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at); -- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable -- column with a partial unique index that ignores nulls. SQLite does -- not support ALTER COLUMN, so we rebuild the table. -- -- A few defensive notes: -- * Every foreign key into `users(id)` continues to resolve — `id` -- is the same INTEGER PRIMARY KEY in the rebuilt table. -- * `email` is now declared NOCASE so a `WHERE email = ?` match -- is case-insensitive without changing every read site. The -- prior column accepted any text; existing rows pass through -- unchanged. -- * `gitea_login` likewise relaxes from NOT NULL to nullable, so -- users provisioned by OTC alone don't carry a synthetic login. CREATE TABLE users_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, gitea_id INTEGER, gitea_login TEXT, email TEXT COLLATE NOCASE, display_name TEXT NOT NULL, avatar_url TEXT, role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')), muted INTEGER NOT NULL DEFAULT 0, email_personal_direct INTEGER NOT NULL DEFAULT 1, email_watched_structural INTEGER NOT NULL DEFAULT 0, email_admin_actionable INTEGER NOT NULL DEFAULT 1, email_opt_out_all INTEGER NOT NULL DEFAULT 0, digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')), notification_quiet_hours_start TEXT, notification_quiet_hours_end TEXT, notification_quiet_hours_timezone TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) ); INSERT INTO users_new ( id, gitea_id, gitea_login, email, display_name, avatar_url, role, muted, email_personal_direct, email_watched_structural, email_admin_actionable, email_opt_out_all, digest_cadence, notification_quiet_hours_start, notification_quiet_hours_end, notification_quiet_hours_timezone, created_at, last_seen_at ) SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, muted, email_personal_direct, email_watched_structural, email_admin_actionable, email_opt_out_all, digest_cadence, notification_quiet_hours_start, notification_quiet_hours_end, notification_quiet_hours_timezone, created_at, last_seen_at FROM users; DROP TABLE users; ALTER TABLE users_new RENAME TO users; CREATE INDEX idx_users_role ON users (role); -- Partial unique indexes so NULLs are permitted but populated values -- collide. Gitea linkage stays unique per gitea_id; OTC-era identity -- is keyed on email (case-insensitive via NOCASE on the column). CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL; CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL; CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';