-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6). -- -- This release replaces v0.3.0's `allowed_emails` allowlist as the -- admission control. Anyone with a valid email can sign in via the -- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'` -- until an admin grants access. The first-OTC flow captures three -- profile fields (first name, last name, free-text "why I should be -- included in the beta") that the admin sees when triaging the -- request queue. The `allowed_emails` table stays in the schema as a -- fast-path bypass — populated rows are still readable by the -- existing admin UI; the OTC `/request` handler no longer consults -- it. v0.9.0's admin user-management page will replace the -- allowlist UI entirely. -- -- Schema additions: -- -- * `permission_state` — three-state CHECK: 'pending' | 'granted' | -- 'revoked'. Default 'granted' so every row at migration time -- passes through unaffected; only newly provisioned OTC users -- land in 'pending' (the OTC verify path sets the column -- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the -- admin gesture for an account that earned a grant then later -- lost it; v0.8.0 doesn't surface a revoke UI, but the schema -- slot is here so v0.9.0's admin user-management page can flip -- the column without another migration. -- -- * `first_name`, `last_name` — nullable TEXT. Captured on the -- first OTC sign-in via `POST /auth/me/beta-request`. Existing -- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry -- NULL through the migration; the admin queue treats an -- unpopulated capture as "auto-grandfathered" since the row's -- `permission_state` is already 'granted'. -- -- * `beta_request_reason` — nullable TEXT. The free-text "why I -- should be included" from the capture form. Bounded to ~4000 -- chars at the endpoint layer (no DB-level constraint — -- SQLite's TEXT is unbounded). -- -- * `permission_decided_by` — nullable INTEGER. The `users.id` of -- the admin who flipped `permission_state` from 'pending' to -- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered -- rows (they were never decided — they passed through at -- migration). ON DELETE SET NULL because losing the admin row -- should not cascade-delete the user whose access they granted. -- -- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601, -- same shape as the existing `created_at` / `last_seen_at`). -- Co-populated with `permission_decided_by` on each decision. -- -- Grandfathered-row invariant: -- -- Every row that exists at migration time has -- `permission_state='granted'` and `permission_decided_by=NULL` -- (the column default + NULL preservation). v0.8.0's auth gate -- reads `permission_state='granted'` as the admission check, so -- no existing user is locked out by the upgrade. v0.7.0's OTC -- path is patched in the same release to set -- `permission_state='pending'` explicitly on a fresh row, so the -- gate engages only for users provisioned after the upgrade. ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted' CHECK (permission_state IN ('pending', 'granted', 'revoked')); ALTER TABLE users ADD COLUMN first_name TEXT; ALTER TABLE users ADD COLUMN last_name TEXT; ALTER TABLE users ADD COLUMN beta_request_reason TEXT; ALTER TABLE users ADD COLUMN permission_decided_by INTEGER REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE users ADD COLUMN permission_decided_at TEXT; -- Index for the v0.9.0 admin queue: list pending requests ordered by -- when the user's row was created (the implicit "request received at" -- timestamp, since v0.8.0 sets pending at the same moment as the row -- itself is inserted via the OTC verify path). CREATE INDEX idx_users_permission_state ON users (permission_state);