Accounts Phase 1 foundation: schema + WAL, auth core, email sender
Groundwork for self-hosted accounts (magic link + Google later), no third parties. - db: account tables (users, identities, login_tokens, sessions, saved_articles, user_history, user_prefs); identities link multiple sign-in methods to one user by verified email. connect() now enables WAL + busy_timeout so the API can write account data alongside the host ingestion cycle. - auth.py: users/identities (find-or-create + link), single-use magic-link tokens, opaque sessions — all secrets stored only as SHA-256 hashes. - email_send.py: minimal STARTTLS SMTP sender + the magic-link email. Secrets (SMTP, Google, session) live in the API container's env_file, not git. API endpoints + sign-in UI come next. 105 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+76
-1
@@ -127,15 +127,90 @@ CREATE TABLE IF NOT EXISTS daily_brief_items (
|
||||
PRIMARY KEY (brief_id, article_id),
|
||||
UNIQUE (brief_id, rank)
|
||||
);
|
||||
|
||||
-- ---- Accounts ----------------------------------------------------------------
|
||||
-- Self-hosted, minimal-PII. The host ingestion owns the content tables above;
|
||||
-- the API owns these (writes happen via the API, so the DB runs in WAL mode).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per sign-in method linked to a user; lets Google + magic-link
|
||||
-- (same verified email) resolve to a single account.
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL, -- 'email' | 'google' | 'apple'
|
||||
provider_subject TEXT NOT NULL, -- email address, or the provider's stable user id
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (provider, provider_subject)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_identities_user ON identities(user_id);
|
||||
|
||||
-- Single-use, short-lived magic-link tokens (stored hashed).
|
||||
CREATE TABLE IF NOT EXISTS login_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_tokens_email ON login_tokens(email);
|
||||
|
||||
-- Active sessions (opaque token stored hashed); validated for cookie or bearer.
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_articles (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, article_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_history (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
event TEXT NOT NULL DEFAULT 'seen', -- 'seen' | 'dismissed'
|
||||
at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, article_id, event)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
prefs_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def connect(db_path: Path | str) -> sqlite3.Connection:
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn = sqlite3.connect(path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
# WAL lets the API write account data while the ingestion cycle writes content
|
||||
# concurrently (readers never block the writer). busy_timeout rides out the
|
||||
# brief moments the single writer lock is held. Both are no-ops if already set.
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
if str(path) != ":memory:":
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user