-- ═══════════════════════════════════════════════════════════════ -- 003 AUTHENTICATION -- -- Two tables: who may sign in, and who currently is signed in. -- -- There is no self-signup and no registration endpoint. Accounts -- are created from the CLI, on the box, by someone with shell -- access. For a handful of staff that's the right trade: no -- invite flow, no email delivery, no password-reset surface for -- anyone to attack. -- ═══════════════════════════════════════════════════════════════ CREATE TABLE admin_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), -- Stored lowercased. The application lowercases on every read -- and write, so the UNIQUE index is genuinely case-insensitive -- without depending on a collation. email TEXT NOT NULL UNIQUE, name TEXT, -- Nullable so a Google-only account can exist later with no -- password at all. A row with both can use either route in. password_hash TEXT, -- Google's stable subject id. Nullable, unique when present — -- SQLite allows any number of NULLs in a unique index. google_sub TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'viewer')), is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), last_login_at TEXT ) STRICT; -- One row per active login. The cookie holds a random token; this -- table holds only its SHA-256, so a database leak doesn't hand -- anyone a working session. CREATE TABLE sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, user_agent TEXT, ip_hash TEXT ) STRICT; CREATE INDEX sessions_user_idx ON sessions (user_id); CREATE INDEX sessions_expiry_idx ON sessions (expires_at);