vignette/apps/api/app/auth_sessions.py
2026-06-27 16:08:41 +09:00

827 lines
29 KiB
Python

"""Server-side browser sessions for the BFF auth boundary.
Sessions are DB-backed when PostgreSQL is available and fall back to the
process-local store for local degraded development. The browser only receives an
opaque HttpOnly cookie; roles and user identity stay on the server.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Literal
from .config import settings
from .db import get_pool
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
@dataclass(slots=True)
class SessionUser:
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
expires_at: float
@dataclass(slots=True)
class ManagedUser:
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
affiliation: str
created_at: float
last_seen_at: float
class InactiveUserError(Exception):
"""Raised when an inactive managed user attempts to create a login session."""
_sessions: dict[str, SessionUser] = {}
_users: dict[str, ManagedUser] = {}
_email_index: dict[str, str] = {}
_inactive_emails: set[str] = set()
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
RoleName = Literal["learner", "teacher", "admin"]
DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"}
APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"}
def _sid_hash(raw_sid: str) -> str:
return hmac.new(
settings.session_secret.encode("utf-8"),
raw_sid.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def user_id_from_email(email: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user:{email.strip().lower()}"))
def user_id_from_external_id(external_id: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}"))
def _normalize_email(email: str) -> str:
return email.strip().lower()
def _normalize_external_id(external_id: str | None, email: str) -> str:
value = (external_id or "").strip().lower()
return value or f"email:{_normalize_email(email)}"
def _db_role(role: str) -> str:
return DB_ROLE_BY_APP.get(role, role)
def _app_role(role: str) -> str:
return APP_ROLE_BY_DB.get(role, role)
def _ts(value: datetime | None) -> float:
return (value or datetime.now(timezone.utc)).timestamp()
def _cohort_ids(cohort: str | None) -> list[str]:
return [cohort] if cohort else []
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
return (cohort_ids or [None])[0]
async def _runtime_tables_ready(conn) -> bool:
row = await conn.fetchrow(
"""
SELECT
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
GROUP BY table_schema, table_name
HAVING count(*) = 3
) AS has_user_columns,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'sessions'
AND column_name IN (
'runtime_case_id',
'persona_code',
'persona_display_name',
'persona_difficulty',
'prev_rapport_credit'
)
GROUP BY table_schema, table_name
HAVING count(*) = 5
) AS has_session_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'session_state'
AND column_name = 'turns_in_stage'
) AS has_state_columns,
(
SELECT count(*) = 4
FROM app.stage_def
WHERE stage_code IN ('라포','탐색','개입','정리')
) AS has_stage_defs,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'sessions'
AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete')
GROUP BY schemaname, tablename
HAVING count(*) = 3
) AS has_session_write_policies,
NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'sessions'
AND policyname = 'p_sessions_modify'
) AS removed_old_session_policy,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'turns'
AND policyname IN ('p_turns_insert','p_turns_update','p_turns_delete')
GROUP BY schemaname, tablename
HAVING count(*) = 3
) AS has_turn_write_policies,
NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'turns'
AND policyname = 'p_turns_modify'
) AS removed_old_turn_policy
"""
)
return bool(
row
and row["has_user_columns"]
and row["has_auth_session"]
and row["has_preferences"]
and row["has_engine_config"]
and row["has_session_columns"]
and row["has_state_columns"]
and row["has_stage_defs"]
and row["has_session_write_policies"]
and row["removed_old_session_policy"]
and row["has_turn_write_policies"]
and row["removed_old_turn_policy"]
)
async def ensure_runtime_tables() -> None:
"""Ensure DB-backed auth/user runtime tables exist when a pool is available."""
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name = 'affiliation'
) THEN
ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT '';
END IF;
END $$;
"""
)
if await _runtime_tables_ready(conn):
return
await conn.execute(
"""
ALTER TABLE app.app_user
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.auth_session (
sid_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE,
role TEXT NOT NULL,
display_name TEXT NOT NULL,
cohort_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
ON app.auth_session(user_id, expires_at)
WHERE revoked_at IS NULL
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.user_preferences (
user_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE,
theme TEXT NOT NULL DEFAULT 'system',
voice_preset_id TEXT NOT NULL DEFAULT 'soft-young-fem',
voice_rate REAL NOT NULL DEFAULT 1.0,
notifications JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.admin_engine_config (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
engine_mode TEXT NOT NULL,
engine_url TEXT NOT NULL,
model TEXT NOT NULL,
updated_by TEXT,
updated_at TIMESTAMPTZ
)
"""
)
await conn.execute(
"""
ALTER TABLE app.sessions
ADD COLUMN IF NOT EXISTS runtime_case_id UUID,
ADD COLUMN IF NOT EXISTS persona_code TEXT,
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0
"""
)
await conn.execute(
"""
ALTER TABLE app.session_state
ADD COLUMN IF NOT EXISTS turns_in_stage INT NOT NULL DEFAULT 0
"""
)
await conn.execute(
"""
INSERT INTO app.stage_def (stage_code, display_name, seq, base_openness)
VALUES
('라포', '라포', 1, 0.15),
('탐색', '탐색', 2, 0.35),
('개입', '개입', 3, 0.55),
('정리', '정리', 4, 0.45)
ON CONFLICT (stage_code) DO UPDATE SET
display_name = EXCLUDED.display_name,
seq = EXCLUDED.seq,
base_openness = EXCLUDED.base_openness
"""
)
await conn.execute(
"""
DROP POLICY IF EXISTS p_sessions_modify ON app.sessions;
DROP POLICY IF EXISTS p_sessions_insert ON app.sessions;
DROP POLICY IF EXISTS p_sessions_update ON app.sessions;
DROP POLICY IF EXISTS p_sessions_delete ON app.sessions;
CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
) WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
)
"""
)
await conn.execute(
"""
DROP POLICY IF EXISTS p_case_insert ON app.case_profile;
DROP POLICY IF EXISTS p_case_update ON app.case_profile;
CREATE POLICY p_case_insert ON app.case_profile FOR INSERT WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_case_update ON app.case_profile FOR UPDATE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
) WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
)
"""
)
await conn.execute(
"""
DROP POLICY IF EXISTS p_turns_modify ON app.turns;
DROP POLICY IF EXISTS p_turns_insert ON app.turns;
DROP POLICY IF EXISTS p_turns_update ON app.turns;
DROP POLICY IF EXISTS p_turns_delete ON app.turns;
CREATE POLICY p_turns_insert ON app.turns FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_turns_update ON app.turns FOR UPDATE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
) WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_turns_delete ON app.turns FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
def _managed_user_from_row(row) -> ManagedUser:
return ManagedUser(
user_id=str(row["user_id"]),
email=row["email"],
display_name=row["display_name"] or row["email"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
created_at=_ts(row["created_at"]),
last_seen_at=_ts(row["last_seen_at"]),
)
def _memory_upsert_managed_user(
*,
email: str,
display_name: str,
role: str,
cohort_ids: list[str] | None = None,
user_id: str | None = None,
affiliation: str | None = None,
reactivate: bool = False,
) -> ManagedUser:
now = time.time()
normalized_email = _normalize_email(email)
if normalized_email in _inactive_emails and not reactivate:
raise InactiveUserError("user is inactive")
if reactivate:
_inactive_emails.discard(normalized_email)
uid = user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)
current = _users.get(uid)
user = ManagedUser(
user_id=uid,
email=normalized_email,
display_name=(display_name.strip() if display_name else "") or normalized_email,
role=role,
cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []),
affiliation=(
affiliation.strip()
if affiliation
else (current.affiliation if current else DEFAULT_AFFILIATION)
),
created_at=current.created_at if current else now,
last_seen_at=now,
)
_users[uid] = user
_email_index[normalized_email] = uid
return user
async def upsert_managed_user(
*,
email: str,
display_name: str,
role: str,
cohort_ids: list[str] | None = None,
user_id: str | None = None,
external_id: str | None = None,
affiliation: str | None = None,
reactivate: bool = False,
) -> ManagedUser:
normalized_email = _normalize_email(email)
normalized_external_id = _normalize_external_id(external_id, normalized_email)
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.app_user (
external_id, email, display_name, role, cohort, affiliation, last_seen_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, now(), now())
ON CONFLICT (external_id) DO UPDATE SET
email = EXCLUDED.email,
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
role = EXCLUDED.role,
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation),
is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END,
last_seen_at = now(),
updated_at = now()
WHERE app.app_user.is_active OR $7
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
""",
normalized_external_id,
normalized_email,
(display_name.strip() if display_name else normalized_email),
_db_role(role),
_cohort_value(cohort_ids),
affiliation or DEFAULT_AFFILIATION,
reactivate,
)
if row is None:
_inactive_emails.add(normalized_email)
raise InactiveUserError("user is inactive")
user = _managed_user_from_row(row)
_memory_upsert_managed_user(
email=user.email,
display_name=user.display_name,
role=user.role,
cohort_ids=user.cohort_ids,
user_id=user.user_id,
affiliation=user.affiliation,
reactivate=True,
)
return user
except InactiveUserError:
raise
except Exception:
require_runtime_fallback_allowed("managed user")
return _memory_upsert_managed_user(
email=normalized_email,
display_name=display_name,
role=role,
cohort_ids=cohort_ids,
user_id=user_id or user_id_from_external_id(normalized_external_id),
affiliation=affiliation,
reactivate=reactivate,
)
async def get_managed_user(user_id: str) -> ManagedUser | None:
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
FROM app.app_user
WHERE user_id = $1::uuid AND is_active
""",
user_id,
)
if row is not None:
return _managed_user_from_row(row)
except Exception:
require_runtime_fallback_allowed("managed user")
if not runtime_fallback_allowed():
return None
return _users.get(user_id)
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
try:
pool = get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
FROM app.app_user
WHERE is_active
ORDER BY last_seen_at DESC
"""
)
return [_managed_user_from_row(row) for row in rows], True
except Exception:
require_runtime_fallback_allowed("managed user list")
return sorted(_users.values(), key=lambda u: u.last_seen_at, reverse=True), False
async def update_managed_user(
user_id: str,
*,
display_name: str | None = None,
role: RoleName | None = None,
affiliation: str | None = None,
cohort_ids: list[str] | None = None,
) -> ManagedUser | None:
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE app.app_user SET
display_name = COALESCE($2, display_name),
role = COALESCE($3, role),
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
affiliation = COALESCE($6, affiliation),
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
""",
user_id,
display_name.strip() if display_name is not None else None,
_db_role(role) if role is not None else None,
cohort_ids is not None,
_cohort_value(cohort_ids),
affiliation.strip() if affiliation is not None else None,
)
if row is not None:
next_user = _managed_user_from_row(row)
_memory_upsert_managed_user(
email=next_user.email,
display_name=next_user.display_name,
role=next_user.role,
cohort_ids=next_user.cohort_ids,
user_id=next_user.user_id,
affiliation=next_user.affiliation,
)
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
return next_user
except Exception:
require_runtime_fallback_allowed("managed user update")
if not runtime_fallback_allowed():
return None
current = _users.get(user_id)
if current is None:
return None
next_user = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=display_name.strip() if display_name is not None else current.display_name,
role=role if role is not None else current.role,
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
created_at=current.created_at,
last_seen_at=time.time(),
)
_users[user_id] = next_user
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
return next_user
async def deactivate_managed_user(user_id: str) -> bool:
"""Deactivate a managed user and revoke their active browser sessions."""
changed = False
inactive_email: str | None = None
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT email FROM app.app_user WHERE user_id = $1::uuid",
user_id,
)
if row is not None and row["email"]:
inactive_email = _normalize_email(row["email"])
result = await conn.execute(
"""
UPDATE app.app_user SET
is_active = FALSE,
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
""",
user_id,
)
changed = result.endswith(" 1")
await conn.execute(
"UPDATE app.auth_session SET revoked_at = now() WHERE user_id = $1::uuid",
user_id,
)
except Exception:
require_runtime_fallback_allowed("managed user deactivation")
if not runtime_fallback_allowed():
return changed
memory_user = _users.pop(user_id, None)
if memory_user is not None:
inactive_email = _normalize_email(memory_user.email)
if inactive_email:
_inactive_emails.add(inactive_email)
for email, uid in list(_email_index.items()):
if uid == user_id:
_email_index.pop(email, None)
for sid_hash, session in list(_sessions.items()):
if session.user_id == user_id:
_sessions.pop(sid_hash, None)
return changed or memory_user is not None
async def active_session_count(user_id: str) -> int:
try:
pool = get_pool()
async with pool.acquire() as conn:
return int(
await conn.fetchval(
"""
SELECT count(*)
FROM app.auth_session
WHERE user_id = $1::uuid
AND revoked_at IS NULL
AND expires_at > now()
""",
user_id,
)
or 0
)
except Exception:
require_runtime_fallback_allowed("auth session count")
now = time.time()
count = 0
for session in _sessions.values():
if session.user_id == user_id and session.expires_at > now:
count += 1
return count
async def create_session(
*,
email: str,
display_name: str,
role: str,
cohort_ids: list[str] | None = None,
user_id: str | None = None,
external_id: str | None = None,
) -> tuple[str, SessionUser]:
raw_sid = secrets.token_urlsafe(32)
normalized_email = _normalize_email(email)
managed = await upsert_managed_user(
email=normalized_email,
display_name=display_name,
role=role,
cohort_ids=cohort_ids,
user_id=user_id,
external_id=external_id,
reactivate=False,
)
expires_at = time.time() + settings.session_ttl_seconds
user = SessionUser(
user_id=managed.user_id,
email=normalized_email,
display_name=managed.display_name,
role=managed.role,
cohort_ids=list(managed.cohort_ids),
expires_at=expires_at,
)
sid_hash = _sid_hash(raw_sid)
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.auth_session (
sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at
)
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, now())
ON CONFLICT (sid_hash) DO UPDATE SET
user_id = EXCLUDED.user_id,
role = EXCLUDED.role,
display_name = EXCLUDED.display_name,
cohort_ids = EXCLUDED.cohort_ids,
expires_at = EXCLUDED.expires_at,
revoked_at = NULL,
last_seen_at = now()
""",
sid_hash,
managed.user_id,
managed.role,
managed.display_name,
list(managed.cohort_ids),
datetime.fromtimestamp(expires_at, tz=timezone.utc),
)
except Exception:
require_runtime_fallback_allowed("browser session")
_sessions[sid_hash] = user
return raw_sid, user
async def get_session(raw_sid: str | None) -> SessionUser | None:
if not raw_sid:
return None
key = _sid_hash(raw_sid)
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
s.expires_at,
u.user_id,
u.email,
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role,
u.cohort
FROM app.auth_session s
JOIN app.app_user u ON u.user_id = s.user_id
WHERE s.sid_hash = $1
AND s.revoked_at IS NULL
AND s.expires_at > now()
AND u.is_active
""",
key,
)
if row is not None:
await conn.execute(
"UPDATE app.auth_session SET last_seen_at = now() WHERE sid_hash = $1",
key,
)
await conn.execute(
"UPDATE app.app_user SET last_seen_at = now() WHERE user_id = $1",
row["user_id"],
)
return SessionUser(
user_id=str(row["user_id"]),
email=row["email"],
display_name=row["display_name"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row["cohort"]),
expires_at=_ts(row["expires_at"]),
)
except Exception:
require_runtime_fallback_allowed("browser session")
if not runtime_fallback_allowed():
return None
user = _sessions.get(key)
if user is None:
return None
if user.expires_at <= time.time():
_sessions.pop(key, None)
return None
managed = _users.get(user.user_id)
if managed is not None:
managed.last_seen_at = time.time()
return user
async def revoke_session(raw_sid: str | None) -> None:
if not raw_sid:
return
key = _sid_hash(raw_sid)
_sessions.pop(key, None)
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE app.auth_session SET revoked_at = now() WHERE sid_hash = $1",
key,
)
except Exception:
require_runtime_fallback_allowed("browser session revoke")