SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

accounts: modèles SQLite (§1.4), sécurité (argon2, clés hfmd_live_ hachées, sessions signées, garde CSRF), mailer Resend, service métier

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent 7192bda

5 changed files +669 −0

added hfmarketdata/api/accounts/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Accounts: users, e-mail tokens, API keys, usage aggregates, audit log (SQLite via core.db)."""
added hfmarketdata/api/accounts/mailer.py +98 −0
@@ -0,0 +1,98 @@
1 +"""Transactional e-mail through the Resend HTTP API (httpx).
2 +
3 +`send(kind, to, **ctx)` renders one of the English templates (plain text + minimal HTML) and posts it to
4 +Resend when `settings.resend_api_key` is set. Without a key the message is logged and `delivered=False` is
5 +returned, so the caller stores the action link in `email_tokens.meta` for the admin UI / CLI to print.
6 +API keys are NEVER included in an e-mail.
7 +
8 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
9 +"""
10 +from __future__ import annotations
11 +
12 +import html
13 +import logging
14 +from dataclasses import dataclass
15 +
16 +import httpx
17 +
18 +from core.config import settings
19 +
20 +log = logging.getLogger("hfmarketdata.mailer")
21 +
22 +RESEND_URL = "https://api.resend.com/emails"
23 +TIMEOUT_S = 10.0
24 +
25 +
26 +@dataclass(frozen=True)
27 +class Delivery:
28 + delivered: bool
29 + provider_id: str | None = None
30 + error: str | None = None
31 +
32 +
33 +def _template(kind: str, ctx: dict) -> tuple[str, str]:
34 + """Return (subject, body_text) for a template. `ctx` keys: name, link, key_name, key_prefix, expires."""
35 + name = ctx.get("name") or "there"
36 + link = ctx.get("link", "")
37 + brand = "HF Market Data"
38 + footer = (f"\n\n— {brand} · {settings.public_url}\nQuestions? Reply to this e-mail or write to "
39 + f"{settings.contact_email}.")
40 + if kind == "verify":
41 + return (f"Verify your e-mail for {brand}",
42 + f"Hi {name},\n\nConfirm your e-mail address to activate your {brand} account and create your "
43 + f"API key:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '48 hours')}. If you did not "
44 + f"sign up, you can ignore this message.{footer}")
45 + if kind == "invite":
46 + return (f"You are invited to {brand}",
47 + f"Hi {name},\n\nAn account has been created for you on {brand} (open high-frequency market data "
48 + f"API — 1-minute to daily bars since 2010, futures contracts, options chains).\n\nSet your "
49 + f"password to accept the invitation:\n\n{link}\n\nThe link is valid for "
50 + f"{ctx.get('expires', '7 days')}. Your API key is waiting in the dashboard once you are signed "
51 + f"in (keys are shown once, at creation — you can create a new one anytime).{footer}")
52 + if kind == "reset":
53 + return (f"Reset your {brand} password",
54 + f"Hi {name},\n\nSomeone asked to reset the password of your {brand} account. If it was you, "
55 + f"choose a new password here:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '1 hour')}. "
56 + f"Otherwise ignore this e-mail — your password stays unchanged.{footer}")
57 + if kind == "key_created":
58 + return (f"New API key on your {brand} account",
59 + f"Hi {name},\n\nA new API key named “{ctx.get('key_name', 'default')}” "
60 + f"(prefix {ctx.get('key_prefix', '')}…) was created on your account. For security the key "
61 + f"itself is only shown once, in the dashboard.\n\nIf this was not you, revoke it now: "
62 + f"{settings.public_url}/dashboard/keys{footer}")
63 + raise ValueError(f"unknown mail template {kind!r}")
64 +
65 +
66 +def render(kind: str, **ctx) -> tuple[str, str, str]:
67 + """(subject, text, html)"""
68 + subject, text = _template(kind, ctx)
69 + paragraphs = "".join(f"<p>{html.escape(p).replace(chr(10), '<br>')}</p>" for p in text.split("\n\n"))
70 + link = ctx.get("link")
71 + if link:
72 + esc = html.escape(link, quote=True)
73 + paragraphs = paragraphs.replace(html.escape(link), f'<a href="{esc}">{esc}</a>')
74 + body_html = (
75 + '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;'
76 + 'background:#0b0f14;color:#e6edf3;padding:24px"><div style="max-width:560px;margin:auto;background:#111823;'
77 + 'border:1px solid #1f2a37;border-radius:12px;padding:28px;line-height:1.55">'
78 + f'<h2 style="margin:0 0 16px;color:#7ee787">HF Market Data</h2>{paragraphs}</div></body></html>')
79 + return subject, text, body_html
80 +
81 +
82 +def send(kind: str, to: str, **ctx) -> Delivery:
83 + subject, text, body_html = render(kind, **ctx)
84 + if not settings.resend_api_key:
85 + log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s subject=%r link=%s",
86 + kind, to, subject, ctx.get("link"))
87 + return Delivery(False, error="no_provider")
88 + payload = {"from": settings.mail_from, "to": [to], "subject": subject, "text": text, "html": body_html}
89 + try:
90 + r = httpx.post(RESEND_URL, json=payload, timeout=TIMEOUT_S,
91 + headers={"Authorization": f"Bearer {settings.resend_api_key}"})
92 + if r.status_code >= 300:
93 + log.warning("resend error %s for %s: %s", r.status_code, to, r.text[:300])
94 + return Delivery(False, error=f"resend_{r.status_code}")
95 + return Delivery(True, provider_id=(r.json() or {}).get("id"))
96 + except Exception as exc:
97 + log.warning("resend unreachable for %s: %s", to, exc)
98 + return Delivery(False, error=str(exc)[:200])
added hfmarketdata/api/accounts/models.py +119 −0
@@ -0,0 +1,119 @@
1 +"""SQLAlchemy models — UPGRADE-PLAN §1.4 (users · email_tokens · api_keys · usage_daily · usage_minute · audit_log).
2 +
3 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 +"""
5 +from __future__ import annotations
6 +
7 +from datetime import date, datetime, timezone
8 +
9 +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, Text
10 +from sqlalchemy.orm import Mapped, mapped_column, relationship
11 +
12 +from core.db import Base, create_all
13 +
14 +ROLES = ("user", "admin")
15 +TIERS = ("free", "high_usage")
16 +USER_STATUSES = ("invited", "active", "disabled")
17 +KEY_STATUSES = ("active", "revoked")
18 +TOKEN_KINDS = ("verify", "reset", "invite")
19 +
20 +
21 +def utcnow() -> datetime:
22 + return datetime.now(timezone.utc).replace(tzinfo=None)
23 +
24 +
25 +class User(Base):
26 + __tablename__ = "users"
27 +
28 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
29 + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
30 + name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
31 + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # argon2; NULL until invite accepted
32 + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
33 + role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # user | admin
34 + tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free") # free | high_usage
35 + status: Mapped[str] = mapped_column(String(16), nullable=False, default="invited") # invited | active | disabled
36 + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
37 + last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
38 +
39 + keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
40 + tokens: Mapped[list[EmailToken]] = relationship(back_populates="user", cascade="all, delete-orphan")
41 +
42 + @property
43 + def is_admin(self) -> bool:
44 + return self.role == "admin"
45 +
46 +
47 +class EmailToken(Base):
48 + __tablename__ = "email_tokens"
49 +
50 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
51 + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
52 + kind: Mapped[str] = mapped_column(String(16), nullable=False) # verify | reset | invite
53 + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
54 + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
55 + used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
56 + # JSON: {"link": "…"} when the e-mail could not be delivered (no Resend key) so admins/CLI can print it
57 + meta: Mapped[str | None] = mapped_column(Text, nullable=True)
58 +
59 + user: Mapped[User] = relationship(back_populates="tokens")
60 +
61 +
62 +class ApiKey(Base):
63 + __tablename__ = "api_keys"
64 +
65 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
66 + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
67 + name: Mapped[str] = mapped_column(String(100), nullable=False, default="default")
68 + prefix: Mapped[str] = mapped_column(String(24), nullable=False) # hfmd_live_ab12cd34 (display)
69 + key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) # sha256(salt + key)
70 + tier_override: Mapped[str | None] = mapped_column(String(16), nullable=True)
71 + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active | revoked
72 + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
73 + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
74 + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
75 +
76 + user: Mapped[User] = relationship(back_populates="keys")
77 +
78 + @property
79 + def principal(self) -> str:
80 + return f"key:{self.id}"
81 +
82 +
83 +class UsageDaily(Base):
84 + __tablename__ = "usage_daily"
85 +
86 + day: Mapped[date] = mapped_column(Date, primary_key=True)
87 + principal: Mapped[str] = mapped_column(String(64), primary_key=True) # key:<id> | ip:<hash>
88 + requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
89 + rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
90 + rows_parquet: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
91 + bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
92 + status_2xx: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
93 + status_429: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
94 +
95 +
96 +class UsageMinute(Base):
97 + __tablename__ = "usage_minute"
98 +
99 + minute: Mapped[datetime] = mapped_column(DateTime, primary_key=True)
100 + principal: Mapped[str] = mapped_column(String(64), primary_key=True)
101 + requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
102 + rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
103 +
104 +
105 +class AuditLog(Base):
106 + __tablename__ = "audit_log"
107 +
108 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
109 + ts: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, index=True)
110 + actor: Mapped[str] = mapped_column(String(64), nullable=False) # user:<id> | cli | system
111 + action: Mapped[str] = mapped_column(String(64), nullable=False)
112 + target: Mapped[str | None] = mapped_column(String(64), nullable=True)
113 + meta: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON
114 +
115 +
116 +Index("ix_usage_daily_principal", UsageDaily.principal)
117 +Index("ix_usage_minute_principal", UsageMinute.principal)
118 +
119 +create_all()
added hfmarketdata/api/accounts/security.py +125 −0
@@ -0,0 +1,125 @@
1 +"""Passwords (argon2), API keys (hfmd_live_ + 32 base62, sha256(salt+key) stored), e-mail tokens,
2 +signed cookie sessions (itsdangerous) and the CSRF guard.
3 +
4 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
5 +"""
6 +from __future__ import annotations
7 +
8 +import hashlib
9 +import secrets
10 +import string
11 +
12 +from argon2 import PasswordHasher
13 +from argon2.exceptions import VerifyMismatchError
14 +from fastapi import Request, Response
15 +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
16 +
17 +from core.config import settings
18 +from core.errors import ApiError
19 +
20 +KEY_PREFIX = "hfmd_live_"
21 +KEY_BODY_LEN = 32
22 +KEY_DISPLAY_CHARS = 8
23 +BASE62 = string.digits + string.ascii_letters
24 +MIN_PASSWORD_LEN = 10
25 +SESSION_COOKIE = "hfmd_session"
26 +SESSION_MAX_AGE_S = 30 * 86400
27 +MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
28 +
29 +_ph = PasswordHasher()
30 +_serializer = URLSafeTimedSerializer(settings.secret_key, salt="hfmd-session")
31 +
32 +
33 +# ------------------------------------------------------------------------------------------ passwords
34 +
35 +def validate_password(password: str) -> None:
36 + if not password or len(password) < MIN_PASSWORD_LEN:
37 + raise ApiError(400, "WEAK_PASSWORD", f"Password must be at least {MIN_PASSWORD_LEN} characters.")
38 +
39 +
40 +def hash_password(password: str) -> str:
41 + return _ph.hash(password)
42 +
43 +
44 +def verify_password(password: str, password_hash: str | None) -> bool:
45 + if not password_hash:
46 + return False
47 + try:
48 + return _ph.verify(password_hash, password)
49 + except VerifyMismatchError:
50 + return False
51 + except Exception:
52 + return False
53 +
54 +
55 +# ------------------------------------------------------------------------------------------- API keys
56 +
57 +def generate_api_key() -> str:
58 + return KEY_PREFIX + "".join(secrets.choice(BASE62) for _ in range(KEY_BODY_LEN))
59 +
60 +
61 +def key_prefix(raw_key: str) -> str:
62 + """Display prefix: `hfmd_live_` + first 8 chars of the body."""
63 + return raw_key[: len(KEY_PREFIX) + KEY_DISPLAY_CHARS]
64 +
65 +
66 +def hash_key(raw_key: str) -> str:
67 + return hashlib.sha256((settings.key_hash_salt + raw_key).encode()).hexdigest()
68 +
69 +
70 +def looks_like_key(value: str) -> bool:
71 + body = value[len(KEY_PREFIX):]
72 + return value.startswith(KEY_PREFIX) and len(body) == KEY_BODY_LEN and all(c in BASE62 for c in body)
73 +
74 +
75 +# --------------------------------------------------------------------------------------- e-mail tokens
76 +
77 +def generate_token() -> str:
78 + return secrets.token_urlsafe(32)
79 +
80 +
81 +def hash_token(token: str) -> str:
82 + return hashlib.sha256((settings.key_hash_salt + "|token|" + token).encode()).hexdigest()
83 +
84 +
85 +# -------------------------------------------------------------------------------------------- sessions
86 +
87 +def session_value(user_id: int) -> str:
88 + return _serializer.dumps({"uid": int(user_id)})
89 +
90 +
91 +def set_session_cookie(response: Response, user_id: int) -> None:
92 + response.set_cookie(SESSION_COOKIE, session_value(user_id), max_age=SESSION_MAX_AGE_S, httponly=True,
93 + samesite="lax", secure=not settings.is_dev, path="/")
94 +
95 +
96 +def clear_session_cookie(response: Response) -> None:
97 + response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="lax", secure=not settings.is_dev)
98 +
99 +
100 +def read_session(request: Request) -> int | None:
101 + raw = request.cookies.get(SESSION_COOKIE)
102 + if not raw:
103 + return None
104 + try:
105 + data = _serializer.loads(raw, max_age=SESSION_MAX_AGE_S)
106 + except (BadSignature, SignatureExpired):
107 + return None
108 + uid = data.get("uid") if isinstance(data, dict) else None
109 + return int(uid) if isinstance(uid, int) else None
110 +
111 +
112 +# ------------------------------------------------------------------------------------------------ CSRF
113 +
114 +def csrf_guard(request: Request) -> None:
115 + """State-changing account requests must be JSON (`Content-Type: application/json`).
116 +
117 + Combined with SameSite=Lax HttpOnly cookies this defeats form-based CSRF: browsers cannot send a
118 + cross-site request with that content type without a CORS preflight, and credentials are never
119 + allowed cross-origin by our CORS policy.
120 + """
121 + if request.method in MUTATING:
122 + ctype = request.headers.get("content-type", "").split(";")[0].strip().lower()
123 + if ctype != "application/json":
124 + raise ApiError(415, "UNSUPPORTED_MEDIA_TYPE",
125 + "Send `Content-Type: application/json` (use `{}` as body when there is nothing to send).")
added hfmarketdata/api/accounts/service.py +326 −0
@@ -0,0 +1,326 @@
1 +"""Account operations shared by the HTTP routes, the admin API and the CLI.
2 +
3 +Every function takes an open SQLAlchemy `Session` and never commits itself (the caller's `session()`
4 +context manager commits). Side effects (mail) return their delivery status so callers can surface the
5 +action link when no mail provider is configured.
6 +
7 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
8 +"""
9 +from __future__ import annotations
10 +
11 +import json
12 +from dataclasses import dataclass
13 +from datetime import datetime, timedelta, timezone
14 +
15 +from sqlalchemy import func, select
16 +from sqlalchemy.orm import Session
17 +
18 +from core.config import settings
19 +from core.errors import ApiError
20 +
21 +from . import mailer, security
22 +from .models import ApiKey, AuditLog, EmailToken, User, utcnow
23 +
24 +MAX_ACTIVE_KEYS = 10
25 +TOKEN_TTL = {"verify": timedelta(hours=48), "invite": timedelta(days=7), "reset": timedelta(hours=1)}
26 +TOKEN_TTL_LABEL = {"verify": "48 hours", "invite": "7 days", "reset": "1 hour"}
27 +TOKEN_PATH = {"verify": "/verify", "invite": "/accept-invite", "reset": "/reset-password"}
28 +
29 +
30 +def now() -> datetime:
31 + return utcnow()
32 +
33 +
34 +def iso(dt: datetime | None) -> str | None:
35 + return dt.replace(tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if dt else None
36 +
37 +
38 +def normalize_email(email: str) -> str:
39 + e = (email or "").strip().lower()
40 + if "@" not in e or "." not in e.split("@")[-1] or len(e) > 254:
41 + raise ApiError(400, "INVALID_PARAMETER", "A valid e-mail address is required.")
42 + return e
43 +
44 +
45 +# ------------------------------------------------------------------------------------------------- audit
46 +
47 +def audit(s: Session, actor: str, action: str, target: str | None = None, **meta) -> None:
48 + s.add(AuditLog(actor=actor, action=action, target=target, meta=json.dumps(meta, default=str) if meta else None))
49 +
50 +
51 +# ------------------------------------------------------------------------------------------------ tokens
52 +
53 +@dataclass(frozen=True)
54 +class IssuedToken:
55 + token: EmailToken
56 + user: User
57 + raw: str
58 + link: str
59 + delivery: mailer.Delivery
60 +
61 +
62 +def action_link(kind: str, raw_token: str) -> str:
63 + return f"{settings.public_url}{TOKEN_PATH[kind]}?token={raw_token}"
64 +
65 +
66 +def issue_token(s: Session, user: User, kind: str, *, send_mail: bool = True, actor: str = "system") -> IssuedToken:
67 + """Create a single-use token, invalidate older ones of the same kind, and e-mail the action link."""
68 + if kind not in TOKEN_TTL:
69 + raise ValueError(kind)
70 + for old in s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind,
71 + EmailToken.used_at.is_(None))).scalars():
72 + old.used_at = now()
73 + raw = security.generate_token()
74 + tok = EmailToken(user_id=user.id, kind=kind, token_hash=security.hash_token(raw),
75 + expires_at=now() + TOKEN_TTL[kind])
76 + s.add(tok)
77 + link = action_link(kind, raw)
78 + delivery = mailer.Delivery(False, error="not_sent")
79 + if send_mail:
80 + delivery = mailer.send(kind, user.email, name=user.name, link=link, expires=TOKEN_TTL_LABEL[kind])
81 + if not delivery.delivered:
82 + tok.meta = json.dumps({"link": link, "reason": delivery.error})
83 + audit(s, actor, f"token.{kind}", f"user:{user.id}", delivered=delivery.delivered)
84 + return IssuedToken(tok, user, raw, link, delivery)
85 +
86 +
87 +def pending_link(s: Session, user: User, kind: str) -> str | None:
88 + """Undelivered, still valid action link stored in email_tokens.meta (for admins / CLI)."""
89 + tok = s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind,
90 + EmailToken.used_at.is_(None), EmailToken.expires_at > now())
91 + .order_by(EmailToken.id.desc())).scalars().first()
92 + if tok and tok.meta:
93 + try:
94 + return json.loads(tok.meta).get("link")
95 + except ValueError:
96 + return None
97 + return None
98 +
99 +
100 +def consume_token(s: Session, raw: str, kind: str) -> tuple[EmailToken, User]:
101 + tok = s.execute(select(EmailToken).where(EmailToken.token_hash == security.hash_token(raw or ""))).scalars().first()
102 + if tok is None or tok.kind != kind or tok.used_at is not None or tok.expires_at <= now():
103 + raise ApiError(400, "INVALID_TOKEN", "This link is invalid, expired or already used. Request a new one.")
104 + tok.used_at = now()
105 + user = s.get(User, tok.user_id)
106 + if user is None:
107 + raise ApiError(400, "INVALID_TOKEN", "This link is invalid.")
108 + return tok, user
109 +
110 +
111 +# ------------------------------------------------------------------------------------------------- users
112 +
113 +def get_user_by_email(s: Session, email: str) -> User | None:
114 + return s.execute(select(User).where(User.email == normalize_email(email))).scalars().first()
115 +
116 +
117 +def require_user(s: Session, user_id: int) -> User:
118 + u = s.get(User, user_id)
119 + if u is None:
120 + raise ApiError(404, "USER_NOT_FOUND", f"No user with id {user_id}.")
121 + return u
122 +
123 +
124 +def create_user(s: Session, email: str, name: str, *, password: str | None = None, tier: str = "free",
125 + role: str = "user", status: str | None = None, actor: str = "system") -> User:
126 + email = normalize_email(email)
127 + if get_user_by_email(s, email):
128 + raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Sign in or reset your password.")
129 + if tier not in ("free", "high_usage"):
130 + raise ApiError(400, "INVALID_PARAMETER", "tier must be free or high_usage")
131 + if role not in ("user", "admin"):
132 + raise ApiError(400, "INVALID_PARAMETER", "role must be user or admin")
133 + if password is not None:
134 + security.validate_password(password)
135 + u = User(email=email, name=(name or "").strip()[:200], tier=tier, role=role,
136 + password_hash=security.hash_password(password) if password else None,
137 + status=status or ("active" if password else "invited"))
138 + s.add(u)
139 + s.flush()
140 + audit(s, actor, "user.create", f"user:{u.id}", email=email, tier=tier, role=role)
141 + return u
142 +
143 +
144 +def signup(s: Session, email: str, name: str, password: str, actor: str = "self") -> IssuedToken:
145 + """Self-service signup: active-but-unverified user + verification mail. Re-sends for invited users."""
146 + email = normalize_email(email)
147 + security.validate_password(password)
148 + existing = get_user_by_email(s, email)
149 + if existing is not None:
150 + if existing.status == "invited":
151 + return issue_token(s, existing, "invite", actor=actor)
152 + if existing.email_verified_at is None and existing.status == "active":
153 + # signed up but never verified: refresh password and re-send verification
154 + existing.password_hash = security.hash_password(password)
155 + existing.name = (name or existing.name).strip()[:200]
156 + return issue_token(s, existing, "verify", actor=actor)
157 + raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Sign in or reset your password.")
158 + u = create_user(s, email, name, password=password, actor=actor)
159 + return issue_token(s, u, "verify", actor=actor)
160 +
161 +
162 +def invite(s: Session, email: str, name: str, *, tier: str = "free", role: str = "user", actor: str = "admin",
163 + key_name: str = "default", send_mail: bool = True) -> tuple[User, str | None, IssuedToken]:
164 + """Admin/CLI invitation: user (status invited) + one active key + invitation mail.
165 +
166 + Returns (user, raw_key or None if the user already had an active key, issued token)."""
167 + email = normalize_email(email)
168 + u = get_user_by_email(s, email)
169 + if u is None:
170 + u = create_user(s, email, name, tier=tier, role=role, status="invited", actor=actor)
171 + raw_key = None
172 + if not active_keys(s, u):
173 + raw_key, _ = create_key(s, u, key_name, actor=actor, notify=False)
174 + issued = issue_token(s, u, "invite", send_mail=send_mail and u.status == "invited", actor=actor)
175 + return u, raw_key, issued
176 +
177 +
178 +def accept_invite(s: Session, raw_token: str, password: str) -> User:
179 + security.validate_password(password)
180 + _, u = consume_token(s, raw_token, "invite")
181 + u.password_hash = security.hash_password(password)
182 + u.email_verified_at = u.email_verified_at or now()
183 + if u.status == "invited":
184 + u.status = "active"
185 + u.last_login_at = now()
186 + audit(s, f"user:{u.id}", "user.accept_invite", f"user:{u.id}")
187 + return u
188 +
189 +
190 +def verify_email(s: Session, raw_token: str) -> User:
191 + _, u = consume_token(s, raw_token, "verify")
192 + u.email_verified_at = u.email_verified_at or now()
193 + if u.status == "invited":
194 + u.status = "active"
195 + u.last_login_at = now()
196 + audit(s, f"user:{u.id}", "user.verify", f"user:{u.id}")
197 + return u
198 +
199 +
200 +def login(s: Session, email: str, password: str) -> User:
201 + u = get_user_by_email(s, email)
202 + if u is None or not security.verify_password(password, u.password_hash):
203 + raise ApiError(401, "INVALID_CREDENTIALS", "Unknown e-mail or wrong password.")
204 + if u.status == "disabled":
205 + raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.")
206 + if u.email_verified_at is None:
207 + raise ApiError(403, "EMAIL_NOT_VERIFIED", "Verify your e-mail first — check your inbox for the link, "
208 + "or sign up again to receive a new one.")
209 + u.last_login_at = now()
210 + return u
211 +
212 +
213 +def forgot(s: Session, email: str, actor: str = "self") -> IssuedToken | None:
214 + try:
215 + u = get_user_by_email(s, email)
216 + except ApiError:
217 + return None
218 + if u is None or u.status == "disabled":
219 + return None
220 + return issue_token(s, u, "reset", actor=actor)
221 +
222 +
223 +def reset_password(s: Session, raw_token: str, password: str) -> User:
224 + security.validate_password(password)
225 + _, u = consume_token(s, raw_token, "reset")
226 + u.password_hash = security.hash_password(password)
227 + u.email_verified_at = u.email_verified_at or now()
228 + if u.status == "invited":
229 + u.status = "active"
230 + u.last_login_at = now()
231 + audit(s, f"user:{u.id}", "user.reset_password", f"user:{u.id}")
232 + return u
233 +
234 +
235 +def update_user(s: Session, u: User, *, actor: str, name: str | None = None, tier: str | None = None,
236 + role: str | None = None, status: str | None = None) -> User:
237 + allowed = {"tier": ("free", "high_usage"), "role": ("user", "admin"), "status": ("invited", "active", "disabled")}
238 + changes: dict = {}
239 + if name is not None:
240 + changes["name"] = name.strip()[:200]
241 + for field, value in (("tier", tier), ("role", role), ("status", status)):
242 + if value is None:
243 + continue
244 + if value not in allowed[field]:
245 + raise ApiError(400, "INVALID_PARAMETER", f"{field} must be one of {', '.join(allowed[field])}")
246 + changes[field] = value
247 + for field, value in changes.items():
248 + setattr(u, field, value)
249 + if changes:
250 + audit(s, actor, "user.update", f"user:{u.id}", **changes)
251 + _invalidate_cache()
252 + return u
253 +
254 +
255 +# -------------------------------------------------------------------------------------------------- keys
256 +
257 +def active_keys(s: Session, u: User) -> list[ApiKey]:
258 + return list(s.execute(select(ApiKey).where(ApiKey.user_id == u.id, ApiKey.status == "active")
259 + .order_by(ApiKey.id)).scalars())
260 +
261 +
262 +def list_keys(s: Session, u: User) -> list[ApiKey]:
263 + return list(s.execute(select(ApiKey).where(ApiKey.user_id == u.id).order_by(ApiKey.id)).scalars())
264 +
265 +
266 +def create_key(s: Session, u: User, name: str = "default", *, actor: str, notify: bool = True) -> tuple[str, ApiKey]:
267 + """Create an active key. Returns (raw_key, row) — the raw key is shown exactly once."""
268 + n_active = s.execute(select(func.count()).select_from(ApiKey).where(ApiKey.user_id == u.id, ApiKey.status == "active")).scalar_one()
269 + if n_active >= MAX_ACTIVE_KEYS:
270 + raise ApiError(409, "KEY_LIMIT_REACHED", f"You already have {MAX_ACTIVE_KEYS} active keys. Revoke one first.")
271 + raw = security.generate_api_key()
272 + k = ApiKey(user_id=u.id, name=(name or "default").strip()[:100] or "default", prefix=security.key_prefix(raw),
273 + key_hash=security.hash_key(raw), status="active")
274 + s.add(k)
275 + s.flush()
276 + audit(s, actor, "key.create", f"key:{k.id}", user=u.id, prefix=k.prefix)
277 + if notify and u.email_verified_at is not None:
278 + mailer.send("key_created", u.email, name=u.name, key_name=k.name, key_prefix=k.prefix)
279 + return raw, k
280 +
281 +
282 +def get_key(s: Session, u: User, key_id: int) -> ApiKey:
283 + k = s.get(ApiKey, key_id)
284 + if k is None or k.user_id != u.id:
285 + raise ApiError(404, "KEY_NOT_FOUND", f"No API key with id {key_id} on this account.")
286 + return k
287 +
288 +
289 +def revoke_key(s: Session, k: ApiKey, *, actor: str) -> ApiKey:
290 + if k.status == "active":
291 + k.status = "revoked"
292 + k.revoked_at = now()
293 + audit(s, actor, "key.revoke", f"key:{k.id}", prefix=k.prefix)
294 + _invalidate_cache(k.key_hash)
295 + return k
296 +
297 +
298 +def rotate_key(s: Session, u: User, k: ApiKey, *, actor: str) -> tuple[str, ApiKey]:
299 + """Revoke `k` and create a fresh key with the same name."""
300 + revoke_key(s, k, actor=actor)
301 + return create_key(s, u, k.name, actor=actor, notify=False)
302 +
303 +
304 +def _invalidate_cache(key_hash: str | None = None) -> None:
305 + try:
306 + from ratelimit.middleware import invalidate_key_cache
307 + invalidate_key_cache(key_hash)
308 + except Exception: # pragma: no cover
309 + pass
310 +
311 +
312 +# -------------------------------------------------------------------------------------------- serialise
313 +
314 +def user_public(u: User, *, keys_count: int | None = None) -> dict:
315 + d = {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "tier": u.tier, "status": u.status,
316 + "email_verified": u.email_verified_at is not None, "created_at": iso(u.created_at),
317 + "last_login_at": iso(u.last_login_at)}
318 + if keys_count is not None:
319 + d["keys_active"] = keys_count
320 + return d
321 +
322 +
323 +def key_public(k: ApiKey) -> dict:
324 + return {"id": k.id, "name": k.name, "prefix": k.prefix, "status": k.status, "tier_override": k.tier_override,
325 + "created_at": iso(k.created_at), "last_used_at": iso(k.last_used_at), "revoked_at": iso(k.revoked_at),
326 + "principal": k.principal}
327