Add auth helpers, SMTP emailer, and user/session repository layer

- handler.authn: stdlib scrypt password hashing + opaque token helpers
  (tokens stored only as SHA-256).
- handler.emailer: plain-SMTP delivery for invites/resets; unconfigured
  SMTP degrades to returning links instead of mailing them.
- config: SMTP_*, PUBLIC_BASE_URL, session/reset/invite TTLs.
- repository: user/session/one-shot-token CRUD, lockout-guard counts, and
  shared-plus-mine visibility filters (VISIBLE_ALL sentinel) on projects,
  skills, connectors, plugins, models, commands, and memory notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
Claude
2026-08-12 19:13:33 +00:00
parent a414a18fde
commit deb67c17cf
4 changed files with 455 additions and 12 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Password hashing and opaque-token helpers for user accounts.
Everything here is stdlib on purpose (``hashlib.scrypt`` + ``secrets``): no new
dependency for a code path every deployment runs. Passwords are stored as a
self-describing string carrying the scrypt parameters, so they can be raised later
without invalidating existing hashes. Session and reset tokens are random URL-safe
strings handed to the client; the database only ever stores their SHA-256, so a dump
never contains a usable credential.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
# Interactive-login scrypt parameters (~16 MB memory, fast enough for a login form,
# expensive enough to make offline cracking of a leaked hash unattractive).
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SALT_BYTES = 16
_KEY_BYTES = 32
MIN_PASSWORD_LENGTH = 8
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def hash_password(password: str) -> str:
"""``scrypt$N$r$p$salt$key`` for storage in ``users.password_hash``."""
salt = secrets.token_bytes(_SALT_BYTES)
key = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=_KEY_BYTES,
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${_b64(salt)}${_b64(key)}"
def verify_password(password: str, stored: str | None) -> bool:
"""Constant-time verification; False for malformed/absent hashes (an invited user
who never set a password can't log in with anything)."""
if not stored:
return False
try:
scheme, n, r, p, salt, key = stored.split("$")
if scheme != "scrypt":
return False
expected = _unb64(key)
computed = hashlib.scrypt(
password.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p),
dklen=len(expected),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(computed, expected)
def new_token() -> str:
"""An opaque bearer credential (session / reset / invite) for the client to hold."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""What the database stores in place of the token itself."""
return hashlib.sha256(token.encode()).hexdigest()
+25 -1
View File
@@ -19,7 +19,10 @@ class Settings(BaseSettings):
# "is it sqlite" except db.upsert. # "is it sqlite" except db.upsert.
database_url: str = "sqlite:///./handler.db" database_url: str = "sqlite:///./handler.db"
# The single global bearer token gating every API route (README 3.3). # Legacy/machine bearer token gating every API route (README 3.3). Human operators
# now sign in with email + password (user accounts, ``/auth``); this token remains
# for scripts/CI and as a break-glass credential, and may be left unset once
# accounts exist.
auth_token: str = "" auth_token: str = ""
# Optional higher-trust token for PUT /shared/context/:key. Falls back to # Optional higher-trust token for PUT /shared/context/:key. Falls back to
@@ -32,6 +35,27 @@ class Settings(BaseSettings):
# token, like auth_token — per-user RBAC is future work. # token, like auth_token — per-user RBAC is future work.
admin_token: str | None = None admin_token: str | None = None
# ---- User accounts (email + password sign-in for the dashboard/API).
# Browser session lifetime, and the validity windows for the one-shot links: a
# password reset is short-lived; an invite (set your first password) gets a week.
session_ttl_days: int = 30
reset_token_ttl_hours: int = 2
invite_token_ttl_hours: int = 168
# ---- Outbound email (invites + password resets). Unset SMTP_HOST => email off:
# admin flows return the invite/reset link in the response instead of mailing it.
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from: str = ""
smtp_starttls: bool = True # STARTTLS on a plain connection (the common 587 setup)
smtp_ssl: bool = False # implicit TLS from byte one (the 465 setup)
# Base URL the emailed links point at, e.g. "https://handler.example.com". Falls
# back to the request's own origin when unset (right for the same-origin UI).
public_base_url: str = ""
# Optional generic webhook target for the Notification hook. No-op when unset. # Optional generic webhook target for the Notification hook. No-op when unset.
webhook_url: str | None = None webhook_url: str | None = None
+302 -11
View File
@@ -26,6 +26,8 @@ from .tables import (
agent_runs, agent_runs,
agents, agents,
approvals, approvals,
auth_sessions,
auth_tokens,
checkmarks, checkmarks,
claude_config, claude_config,
claude_connectors, claude_connectors,
@@ -43,6 +45,7 @@ from .tables import (
schedules, schedules,
session_archives, session_archives,
shared_context, shared_context,
users,
workers, workers,
) )
from .upsert import upsert_checkmark from .upsert import upsert_checkmark
@@ -52,6 +55,21 @@ def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
# Sentinel for "no ownership filter" (admins, legacy tokens, and internal callers).
# ``visible_to=<user id>`` narrows a listing to shared rows (owner NULL) plus that
# user's own; ``visible_to=None`` means shared rows only (launches of a shared project).
VISIBLE_ALL = object()
def _owner_scope(owner_column, visible_to):
"""WHERE clause for the shared-plus-mine visibility rule, or None for no filter."""
if visible_to is VISIBLE_ALL:
return None
if visible_to is None:
return owner_column.is_(None)
return owner_column.is_(None) | (owner_column == visible_to)
def _row_to_dict(row) -> dict[str, Any] | None: def _row_to_dict(row) -> dict[str, Any] | None:
return dict(row._mapping) if row is not None else None return dict(row._mapping) if row is not None else None
@@ -59,8 +77,12 @@ def _row_to_dict(row) -> dict[str, Any] | None:
# --------------------------------------------------------------------------- reads # --------------------------------------------------------------------------- reads
def list_projects(conn: Connection) -> list[dict]: def list_projects(conn: Connection, visible_to=VISIBLE_ALL) -> list[dict]:
rows = conn.execute(select(projects).order_by(projects.c.id)).all() stmt = select(projects)
scope = _owner_scope(projects.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(projects.c.id)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -192,6 +214,7 @@ def create_project(
root_dir: str, root_dir: str,
git_remote: str | None = None, git_remote: str | None = None,
credential_ref: str | None = None, credential_ref: str | None = None,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
conn.execute( conn.execute(
projects.insert().values( projects.insert().values(
@@ -199,6 +222,7 @@ def create_project(
root_dir=root_dir, root_dir=root_dir,
git_remote=git_remote, git_remote=git_remote,
credential_ref=credential_ref, credential_ref=credential_ref,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -362,7 +386,7 @@ def update_project(conn: Connection, project_id: str, **fields: Any) -> dict | N
Only known columns are applied; an empty patch is a no-op read. Returns the row. Only known columns are applied; an empty patch is a no-op read. Returns the row.
""" """
allowed = {"root_dir", "git_remote", "credential_ref"} allowed = {"root_dir", "git_remote", "credential_ref", "owner_user_id"}
values = {k: v for k, v in fields.items() if k in allowed} values = {k: v for k, v in fields.items() if k in allowed}
if values: if values:
conn.execute(projects.update().where(projects.c.id == project_id).values(**values)) conn.execute(projects.update().where(projects.c.id == project_id).values(**values))
@@ -499,11 +523,24 @@ def get_command(conn: Connection, command_id: int) -> dict | None:
def list_commands( def list_commands(
conn: Connection, project_id: str | None = None, limit: int = 100, offset: int = 0 conn: Connection,
project_id: str | None = None,
limit: int = 100,
offset: int = 0,
restrict_to_projects: list[str] | None = None,
or_requested_by: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""The activity feed. ``restrict_to_projects`` scopes a non-admin user's view to
commands on projects they can see — plus, via ``or_requested_by``, non-project
commands they enqueued themselves (e.g. a skill install), so they can track them."""
stmt = select(commands) stmt = select(commands)
if project_id is not None: if project_id is not None:
stmt = stmt.where(commands.c.project_id == project_id) stmt = stmt.where(commands.c.project_id == project_id)
if restrict_to_projects is not None:
scope = commands.c.project_id.in_(restrict_to_projects)
if or_requested_by is not None:
scope = scope | (commands.c.requested_by == or_requested_by)
stmt = stmt.where(scope)
rows = conn.execute( rows = conn.execute(
stmt.order_by(commands.c.id.desc()).limit(limit).offset(offset) stmt.order_by(commands.c.id.desc()).limit(limit).offset(offset)
).all() ).all()
@@ -971,10 +1008,15 @@ def get_runtime_secret(conn: Connection, key: str) -> dict | None:
# --------------------------------------------------- claude management (web-managed) # --------------------------------------------------- claude management (web-managed)
def list_claude_skills(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_skills(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_skills) stmt = select(claude_skills)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_skills.c.enabled.is_(True)) stmt = stmt.where(claude_skills.c.enabled.is_(True))
scope = _owner_scope(claude_skills.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(claude_skills.c.name)).all() rows = conn.execute(stmt.order_by(claude_skills.c.name)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -995,6 +1037,7 @@ def create_claude_skill(
content: str, content: str,
description: str | None = None, description: str | None = None,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
now = _now() now = _now()
result = conn.execute( result = conn.execute(
@@ -1003,6 +1046,7 @@ def create_claude_skill(
description=description, description=description,
content=content, content=content,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -1047,10 +1091,15 @@ def set_claude_skill_files(conn: Connection, skill_id: int, files: dict[str, str
) )
def list_claude_connectors(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_connectors(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_connectors) stmt = select(claude_connectors)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_connectors.c.enabled.is_(True)) stmt = stmt.where(claude_connectors.c.enabled.is_(True))
scope = _owner_scope(claude_connectors.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(claude_connectors.c.name)).all() rows = conn.execute(stmt.order_by(claude_connectors.c.name)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -1079,6 +1128,7 @@ def create_claude_connector(
url: str | None = None, url: str | None = None,
headers: dict | None = None, headers: dict | None = None,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
result = conn.execute( result = conn.execute(
claude_connectors.insert().values( claude_connectors.insert().values(
@@ -1090,6 +1140,7 @@ def create_claude_connector(
url=url, url=url,
headers=headers, headers=headers,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -1115,10 +1166,15 @@ def delete_claude_connector(conn: Connection, connector_id: int) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def list_claude_plugins(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_plugins(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_plugins) stmt = select(claude_plugins)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_plugins.c.enabled.is_(True)) stmt = stmt.where(claude_plugins.c.enabled.is_(True))
scope = _owner_scope(claude_plugins.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute( rows = conn.execute(
stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name) stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name)
).all() ).all()
@@ -1145,6 +1201,7 @@ def create_claude_plugin(
marketplace: str, marketplace: str,
marketplace_repo: str, marketplace_repo: str,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
result = conn.execute( result = conn.execute(
claude_plugins.insert().values( claude_plugins.insert().values(
@@ -1152,6 +1209,7 @@ def create_claude_plugin(
marketplace=marketplace, marketplace=marketplace,
marketplace_repo=marketplace_repo, marketplace_repo=marketplace_repo,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -1173,10 +1231,15 @@ def delete_claude_plugin(conn: Connection, plugin_id: int) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def list_claude_models(conn: Connection, enabled_only: bool = False) -> list[dict]: def list_claude_models(
conn: Connection, enabled_only: bool = False, visible_to=VISIBLE_ALL
) -> list[dict]:
stmt = select(claude_models) stmt = select(claude_models)
if enabled_only: if enabled_only:
stmt = stmt.where(claude_models.c.enabled.is_(True)) stmt = stmt.where(claude_models.c.enabled.is_(True))
scope = _owner_scope(claude_models.c.owner_user_id, visible_to)
if scope is not None:
stmt = stmt.where(scope)
rows = conn.execute(stmt.order_by(claude_models.c.name)).all() rows = conn.execute(stmt.order_by(claude_models.c.name)).all()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
@@ -1201,6 +1264,7 @@ def create_claude_model(
harness: str = "claude", harness: str = "claude",
env: dict | None = None, env: dict | None = None,
enabled: bool = True, enabled: bool = True,
owner_user_id: int | None = None,
) -> dict: ) -> dict:
result = conn.execute( result = conn.execute(
claude_models.insert().values( claude_models.insert().values(
@@ -1212,6 +1276,7 @@ def create_claude_model(
harness=harness, harness=harness,
env=env, env=env,
enabled=enabled, enabled=enabled,
owner_user_id=owner_user_id,
created_at=_now(), created_at=_now(),
) )
) )
@@ -1278,16 +1343,24 @@ def list_memory_notes(
include_global: bool = True, include_global: bool = True,
limit: int = 200, limit: int = 200,
offset: int = 0, offset: int = 0,
visible_project_ids: list[str] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Notes in scope, newest first. ``project_id=None`` means everything (the dashboard """Notes in scope, newest first. ``project_id=None`` means everything (the dashboard
graph); a project id narrows to that project — plus the global notes unless told not graph); a project id narrows to that project — plus the global notes unless told not
to (the MCP server's read scope: my project + what everyone shares).""" to (the MCP server's read scope: my project + what everyone shares).
``visible_project_ids`` further restricts project notes to those projects (a
non-admin user's view); global notes always pass."""
stmt = select(memory_notes) stmt = select(memory_notes)
if project_id is not None: if project_id is not None:
scope = memory_notes.c.project_id == project_id scope = memory_notes.c.project_id == project_id
if include_global: if include_global:
scope = scope | memory_notes.c.project_id.is_(None) scope = scope | memory_notes.c.project_id.is_(None)
stmt = stmt.where(scope) stmt = stmt.where(scope)
if visible_project_ids is not None:
stmt = stmt.where(
memory_notes.c.project_id.is_(None)
| memory_notes.c.project_id.in_(visible_project_ids)
)
rows = conn.execute( rows = conn.execute(
stmt.order_by(memory_notes.c.id.desc()).limit(limit).offset(offset) stmt.order_by(memory_notes.c.id.desc()).limit(limit).offset(offset)
).all() ).all()
@@ -1305,6 +1378,7 @@ def search_memory_notes(
project_id: str | None = None, project_id: str | None = None,
include_global: bool = True, include_global: bool = True,
limit: int = 20, limit: int = 20,
visible_project_ids: list[str] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Case-insensitive substring search over title/body/kind, every term required. """Case-insensitive substring search over title/body/kind, every term required.
@@ -1319,6 +1393,11 @@ def search_memory_notes(
if include_global: if include_global:
scope = scope | memory_notes.c.project_id.is_(None) scope = scope | memory_notes.c.project_id.is_(None)
stmt = stmt.where(scope) stmt = stmt.where(scope)
if visible_project_ids is not None:
stmt = stmt.where(
memory_notes.c.project_id.is_(None)
| memory_notes.c.project_id.in_(visible_project_ids)
)
for term in terms: for term in terms:
pattern = f"%{term}%" pattern = f"%{term}%"
stmt = stmt.where( stmt = stmt.where(
@@ -1429,10 +1508,222 @@ def delete_memory_link(conn: Connection, link_id: int) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def memory_graph(conn: Connection, project_id: str | None = None) -> dict: # ------------------------------------------------------------- user accounts & sessions
def count_users(conn: Connection) -> int:
from sqlalchemy import func as sqlfunc
return conn.execute(select(sqlfunc.count()).select_from(users)).scalar_one()
def list_users(conn: Connection) -> list[dict]:
rows = conn.execute(select(users).order_by(users.c.id)).all()
return [dict(r._mapping) for r in rows]
def get_user(conn: Connection, user_id: int) -> dict | None:
row = conn.execute(select(users).where(users.c.id == user_id)).first()
return _row_to_dict(row)
def get_user_by_email(conn: Connection, email: str) -> dict | None:
row = conn.execute(
select(users).where(users.c.email == email.strip().lower())
).first()
return _row_to_dict(row)
def create_user(
conn: Connection,
email: str,
password_hash: str | None = None,
is_admin: bool = False,
) -> dict:
result = conn.execute(
users.insert().values(
email=email.strip().lower(),
password_hash=password_hash,
is_admin=is_admin,
disabled=False,
created_at=_now(),
)
)
return get_user(conn, result.inserted_primary_key[0])
def update_user(conn: Connection, user_id: int, **fields: Any) -> dict | None:
allowed = {"password_hash", "is_admin", "disabled"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(users.update().where(users.c.id == user_id).values(**values))
return get_user(conn, user_id)
def count_active_admins(conn: Connection, exclude_user_id: int | None = None) -> int:
"""Enabled admin accounts with a usable password — the lockout guard's input (an
invited admin who never set a password can't sign in, so they don't count)."""
from sqlalchemy import func as sqlfunc
stmt = (
select(sqlfunc.count())
.select_from(users)
.where(
users.c.is_admin.is_(True),
users.c.disabled.is_(False),
users.c.password_hash.is_not(None),
)
)
if exclude_user_id is not None:
stmt = stmt.where(users.c.id != exclude_user_id)
return conn.execute(stmt).scalar_one()
def delete_user(conn: Connection, user_id: int) -> bool:
"""Remove an account. Rows it owned become *shared* (owner NULL) rather than
disappearing — the ownership columns carry no FK precisely so a deleted user can
never orphan a project or break an agent's next launch. Sessions and one-shot
links die with the account."""
for table in (projects, claude_skills, claude_connectors, claude_plugins, claude_models):
conn.execute(
table.update()
.where(table.c.owner_user_id == user_id)
.values(owner_user_id=None)
)
conn.execute(auth_sessions.delete().where(auth_sessions.c.user_id == user_id))
conn.execute(auth_tokens.delete().where(auth_tokens.c.user_id == user_id))
result = conn.execute(users.delete().where(users.c.id == user_id))
return result.rowcount > 0
def create_auth_session(
conn: Connection, user_id: int, token_hash: str, expires_at: datetime
) -> dict:
result = conn.execute(
auth_sessions.insert().values(
user_id=user_id,
token_hash=token_hash,
created_at=_now(),
expires_at=expires_at,
)
)
row = conn.execute(
select(auth_sessions).where(auth_sessions.c.id == result.inserted_primary_key[0])
).first()
return dict(row._mapping)
def get_session_user(conn: Connection, token_hash: str) -> dict | None:
"""The (enabled) user behind a live session token hash, or None. The session row's
``expires_at``/``last_used_at`` ride along under prefixed keys for the caller."""
row = conn.execute(
select(
users,
auth_sessions.c.expires_at.label("session_expires_at"),
auth_sessions.c.last_used_at.label("session_last_used_at"),
)
.select_from(auth_sessions.join(users, auth_sessions.c.user_id == users.c.id))
.where(
auth_sessions.c.token_hash == token_hash,
auth_sessions.c.expires_at > _now(),
users.c.disabled.is_(False),
)
).first()
return _row_to_dict(row)
def touch_auth_session(conn: Connection, token_hash: str) -> None:
conn.execute(
auth_sessions.update()
.where(auth_sessions.c.token_hash == token_hash)
.values(last_used_at=_now())
)
def delete_auth_session(conn: Connection, token_hash: str) -> bool:
result = conn.execute(
auth_sessions.delete().where(auth_sessions.c.token_hash == token_hash)
)
return result.rowcount > 0
def delete_user_sessions(
conn: Connection, user_id: int, keep_token_hash: str | None = None
) -> int:
"""Log a user out everywhere (password change / reset), optionally keeping the
session doing the changing."""
stmt = auth_sessions.delete().where(auth_sessions.c.user_id == user_id)
if keep_token_hash is not None:
stmt = stmt.where(auth_sessions.c.token_hash != keep_token_hash)
return conn.execute(stmt).rowcount
def purge_expired_sessions(conn: Connection) -> int:
"""Housekeeping, piggybacked on logins so no scheduler is needed."""
now = _now()
expired = conn.execute(
auth_sessions.delete().where(auth_sessions.c.expires_at <= now)
).rowcount
conn.execute(auth_tokens.delete().where(auth_tokens.c.expires_at <= now))
return expired
def create_auth_token(
conn: Connection, user_id: int, token_hash: str, purpose: str, expires_at: datetime
) -> dict:
"""Mint a one-shot link token (``reset`` or ``invite``), superseding any earlier
unused ones of the same purpose so only the latest emailed link works."""
conn.execute(
auth_tokens.delete().where(
auth_tokens.c.user_id == user_id,
auth_tokens.c.purpose == purpose,
auth_tokens.c.used_at.is_(None),
)
)
result = conn.execute(
auth_tokens.insert().values(
user_id=user_id,
token_hash=token_hash,
purpose=purpose,
expires_at=expires_at,
created_at=_now(),
)
)
row = conn.execute(
select(auth_tokens).where(auth_tokens.c.id == result.inserted_primary_key[0])
).first()
return dict(row._mapping)
def consume_auth_token(conn: Connection, token_hash: str) -> dict | None:
"""Atomically spend a valid, unused link token; None if unknown/expired/spent."""
result = conn.execute(
auth_tokens.update()
.where(
auth_tokens.c.token_hash == token_hash,
auth_tokens.c.used_at.is_(None),
auth_tokens.c.expires_at > _now(),
)
.values(used_at=_now())
)
if result.rowcount != 1:
return None
row = conn.execute(
select(auth_tokens).where(auth_tokens.c.token_hash == token_hash)
).first()
return _row_to_dict(row)
def memory_graph(
conn: Connection,
project_id: str | None = None,
visible_project_ids: list[str] | None = None,
) -> dict:
"""The whole graph in one read — what the /memory page draws. Scoping to a project """The whole graph in one read — what the /memory page draws. Scoping to a project
keeps its notes plus the global ones, and only edges with both endpoints in scope.""" keeps its notes plus the global ones, and only edges with both endpoints in scope."""
notes = list_memory_notes(conn, project_id=project_id, limit=1000) notes = list_memory_notes(
conn, project_id=project_id, limit=1000, visible_project_ids=visible_project_ids
)
ids = [n["id"] for n in notes] ids = [n["id"] for n in notes]
links = list_memory_links(conn, note_ids=ids) links = list_memory_links(conn, note_ids=ids)
in_scope = set(ids) in_scope = set(ids)
+55
View File
@@ -0,0 +1,55 @@
"""Outbound email (invites + password resets) over plain SMTP.
Stdlib ``smtplib`` behind one function, configured entirely from the environment
(``SMTP_HOST`` et al see ``config.Settings``). When SMTP is not configured the API
degrades gracefully: admin-facing flows return the invite/reset *link* in the response
instead of mailing it, and the self-serve forgot-password flow reports that email is
unavailable. Nothing in the control layer depends on this module.
"""
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from email.utils import formatdate
from .config import Settings, get_settings
class EmailError(Exception):
"""SMTP delivery failed (or email is not configured)."""
def configured(settings: Settings | None = None) -> bool:
settings = settings or get_settings()
return bool(settings.smtp_host and settings.smtp_from)
def send(to: str, subject: str, body: str, settings: Settings | None = None) -> None:
"""Deliver one plain-text message; raises :class:`EmailError` on any failure."""
settings = settings or get_settings()
if not configured(settings):
raise EmailError("SMTP is not configured (set SMTP_HOST and SMTP_FROM)")
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = to
message["Subject"] = subject
message["Date"] = formatdate(localtime=True)
message.set_content(body)
try:
if settings.smtp_ssl:
client: smtplib.SMTP = smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, timeout=15
)
else:
client = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15)
with client:
if settings.smtp_starttls and not settings.smtp_ssl:
client.starttls()
if settings.smtp_username:
client.login(settings.smtp_username, settings.smtp_password or "")
client.send_message(message)
except (OSError, smtplib.SMTPException) as exc:
raise EmailError(f"could not send email via {settings.smtp_host}: {exc}") from exc