accounts: routes /v1/auth, /v1/me, /v1/limits, /v1/admin + CLI hfmd (users, keys, seed idempotent)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 changed files +857 −0
added
hfmarketdata/api/accounts/cli.py
+204 −0
@@ -0,0 +1,204 @@ | ||
| 1 | +"""`hfmd` — accounts administration from the shell (wrapper: scripts/hfmd). | |
| 2 | + | |
| 3 | + hfmd users add "Name" email [--tier free|high_usage] [--admin] [--show-key] [--no-mail] | |
| 4 | + hfmd users list | |
| 5 | + hfmd users set-tier <email> <free|high_usage> | |
| 6 | + hfmd users disable <email> | enable <email> | |
| 7 | + hfmd users invite-resend <email> [--no-mail] | |
| 8 | + hfmd keys list <email> | |
| 9 | + hfmd seed [--show-key] [--no-mail] # idempotent: 3 users + admin | |
| 10 | + | |
| 11 | +Full API keys are printed ONLY at creation and ONLY with `--show-key`. Invitation links are printed when | |
| 12 | +the e-mail could not be delivered (no HFMD_RESEND_API_KEY) so you can forward them. | |
| 13 | + | |
| 14 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import argparse | |
| 19 | +import sys | |
| 20 | +from collections.abc import Iterable | |
| 21 | + | |
| 22 | +from core.config import settings | |
| 23 | +from core.db import session | |
| 24 | +from core.errors import ApiError | |
| 25 | + | |
| 26 | +from . import models, service | |
| 27 | + | |
| 28 | +SEED_USERS = [ | |
| 29 | + {"name": "Luca Iannelli", "email": "ianez84@gmail.com", "tier": "free", "role": "user"}, | |
| 30 | + {"name": "Tim Erdmann", "email": "timerdmann.uni@gmail.com", "tier": "free", "role": "user"}, | |
| 31 | + {"name": "Javier Slaton", "email": "javier.slaton@gmail.com", "tier": "free", "role": "user"}, | |
| 32 | + {"name": "Simon-Pierre Boucher", "email": "contact@spboucher.ai", "tier": "high_usage", "role": "admin"}, | |
| 33 | +] | |
| 34 | +ACTOR = "cli" | |
| 35 | + | |
| 36 | + | |
| 37 | +def table(rows: Iterable[dict], columns: list[str], out=None) -> None: | |
| 38 | + out = out or sys.stdout | |
| 39 | + rows = list(rows) | |
| 40 | + widths = {c: max(len(c), *(len(str(r.get(c, "") or "")) for r in rows)) if rows else len(c) for c in columns} | |
| 41 | + line = " ".join(c.ljust(widths[c]) for c in columns) | |
| 42 | + print(line, file=out) | |
| 43 | + print(" ".join("-" * widths[c] for c in columns), file=out) | |
| 44 | + for r in rows: | |
| 45 | + print(" ".join(str(r.get(c, "") or "").ljust(widths[c]) for c in columns), file=out) | |
| 46 | + | |
| 47 | + | |
| 48 | +def _row(s, u: models.User, raw_key: str | None = None, link: str | None = None, show_key: bool = False) -> dict: | |
| 49 | + keys = service.active_keys(s, u) | |
| 50 | + prefix = ", ".join(k.prefix + "…" for k in keys) or "-" | |
| 51 | + invite = link or service.pending_link(s, u, "invite") | |
| 52 | + if u.status != "invited": | |
| 53 | + invite_col = "(account active)" | |
| 54 | + elif invite: | |
| 55 | + invite_col = invite | |
| 56 | + else: | |
| 57 | + invite_col = "(sent by e-mail)" | |
| 58 | + row = {"name": u.name, "email": u.email, "role": u.role, "tier": u.tier, "status": u.status, | |
| 59 | + "key_prefix": prefix, "invitation": invite_col} | |
| 60 | + if show_key and raw_key: | |
| 61 | + row["api_key"] = raw_key | |
| 62 | + return row | |
| 63 | + | |
| 64 | + | |
| 65 | +def provision(s, name: str, email: str, *, tier: str, admin: bool, send_mail: bool) -> tuple[models.User, str | None, str | None]: | |
| 66 | + """Idempotent: create-or-reuse the user, ensure an active key, ensure a pending invitation.""" | |
| 67 | + u = service.get_user_by_email(s, email) | |
| 68 | + created_key = None | |
| 69 | + link = None | |
| 70 | + if u is None: | |
| 71 | + u, created_key, issued = service.invite(s, email, name, tier=tier, role="admin" if admin else "user", | |
| 72 | + actor=ACTOR, send_mail=send_mail) | |
| 73 | + link = None if issued.delivery.delivered else issued.link | |
| 74 | + else: | |
| 75 | + # existing user: never touch the tier (use `users set-tier`), only promote to admin when asked | |
| 76 | + if admin and u.role != "admin": | |
| 77 | + u.role = "admin" | |
| 78 | + service.audit(s, ACTOR, "user.update", f"user:{u.id}", role=u.role) | |
| 79 | + if not service.active_keys(s, u): | |
| 80 | + created_key, _ = service.create_key(s, u, "default", actor=ACTOR, notify=False) | |
| 81 | + if u.status == "invited" and not service.pending_link(s, u, "invite"): | |
| 82 | + issued = service.issue_token(s, u, "invite", send_mail=send_mail, actor=ACTOR) | |
| 83 | + link = None if issued.delivery.delivered else issued.link | |
| 84 | + return u, created_key, link | |
| 85 | + | |
| 86 | + | |
| 87 | +def cmd_users_add(a) -> int: | |
| 88 | + with session() as s: | |
| 89 | + u, raw, link = provision(s, a.name, a.email, tier=a.tier, admin=a.admin, send_mail=not a.no_mail) | |
| 90 | + table([_row(s, u, raw, link, a.show_key)], ["name", "email", "role", "tier", "status", "key_prefix", "invitation"] | |
| 91 | + + (["api_key"] if a.show_key and raw else [])) | |
| 92 | + if raw and not a.show_key: | |
| 93 | + print("\nAPI key created (hidden). Re-run with --show-key at creation time to display it, or let the user " | |
| 94 | + "create one in the dashboard.", file=sys.stderr) | |
| 95 | + return 0 | |
| 96 | + | |
| 97 | + | |
| 98 | +def cmd_users_list(a) -> int: | |
| 99 | + from sqlalchemy import select | |
| 100 | + with session() as s: | |
| 101 | + users = list(s.execute(select(models.User).order_by(models.User.id)).scalars()) | |
| 102 | + table([{**_row(s, u), "id": u.id, "created": service.iso(u.created_at), "last_login": service.iso(u.last_login_at) or "-"} | |
| 103 | + for u in users], ["id", "name", "email", "role", "tier", "status", "key_prefix", "created", "last_login"]) | |
| 104 | + return 0 | |
| 105 | + | |
| 106 | + | |
| 107 | +def _user_or_die(s, email: str) -> models.User: | |
| 108 | + u = service.get_user_by_email(s, email) | |
| 109 | + if u is None: | |
| 110 | + raise ApiError(404, "USER_NOT_FOUND", f"no user {email}") | |
| 111 | + return u | |
| 112 | + | |
| 113 | + | |
| 114 | +def cmd_users_set_tier(a) -> int: | |
| 115 | + with session() as s: | |
| 116 | + u = _user_or_die(s, a.email) | |
| 117 | + service.update_user(s, u, actor=ACTOR, tier=a.tier) | |
| 118 | + print(f"{u.email}: tier = {u.tier}") | |
| 119 | + return 0 | |
| 120 | + | |
| 121 | + | |
| 122 | +def cmd_users_status(a, status: str) -> int: | |
| 123 | + with session() as s: | |
| 124 | + u = _user_or_die(s, a.email) | |
| 125 | + service.update_user(s, u, actor=ACTOR, status=status) | |
| 126 | + print(f"{u.email}: status = {u.status}") | |
| 127 | + return 0 | |
| 128 | + | |
| 129 | + | |
| 130 | +def cmd_users_invite_resend(a) -> int: | |
| 131 | + with session() as s: | |
| 132 | + u = _user_or_die(s, a.email) | |
| 133 | + if not service.active_keys(s, u): | |
| 134 | + service.create_key(s, u, "default", actor=ACTOR, notify=False) | |
| 135 | + issued = service.issue_token(s, u, "invite", send_mail=not a.no_mail, actor=ACTOR) | |
| 136 | + print(f"{u.email}: invitation " + ("sent by e-mail" if issued.delivery.delivered else f"link → {issued.link}")) | |
| 137 | + return 0 | |
| 138 | + | |
| 139 | + | |
| 140 | +def cmd_keys_list(a) -> int: | |
| 141 | + with session() as s: | |
| 142 | + u = _user_or_die(s, a.email) | |
| 143 | + table([{"id": k.id, "name": k.name, "prefix": k.prefix + "…", "status": k.status, "tier_override": k.tier_override or "-", | |
| 144 | + "created": service.iso(k.created_at), "last_used": service.iso(k.last_used_at) or "-"} | |
| 145 | + for k in service.list_keys(s, u)], ["id", "name", "prefix", "status", "tier_override", "created", "last_used"]) | |
| 146 | + return 0 | |
| 147 | + | |
| 148 | + | |
| 149 | +def cmd_seed(a) -> int: | |
| 150 | + rows = [] | |
| 151 | + with session() as s: | |
| 152 | + for spec in SEED_USERS: | |
| 153 | + u, raw, link = provision(s, spec["name"], spec["email"], tier=spec["tier"], admin=spec["role"] == "admin", | |
| 154 | + send_mail=not a.no_mail) | |
| 155 | + rows.append(_row(s, u, raw, link, a.show_key)) | |
| 156 | + cols = ["name", "email", "role", "tier", "status", "key_prefix", "invitation"] | |
| 157 | + if a.show_key and any("api_key" in r for r in rows): | |
| 158 | + cols.append("api_key") | |
| 159 | + table(rows, cols) | |
| 160 | + print(f"\nstate db: {settings.state_db} · mail provider: {'Resend' if settings.resend_api_key else 'none (links printed above)'}") | |
| 161 | + return 0 | |
| 162 | + | |
| 163 | + | |
| 164 | +def build_parser() -> argparse.ArgumentParser: | |
| 165 | + p = argparse.ArgumentParser(prog="hfmd", description="HF Market Data accounts administration") | |
| 166 | + sub = p.add_subparsers(dest="cmd", required=True) | |
| 167 | + | |
| 168 | + users = sub.add_parser("users", help="manage users").add_subparsers(dest="sub", required=True) | |
| 169 | + add = users.add_parser("add", help="create a user + key + invitation") | |
| 170 | + add.add_argument("name"); add.add_argument("email") | |
| 171 | + add.add_argument("--tier", choices=("free", "high_usage"), default="free") | |
| 172 | + add.add_argument("--admin", action="store_true") | |
| 173 | + add.add_argument("--show-key", action="store_true", help="print the full API key (creation only)") | |
| 174 | + add.add_argument("--no-mail", action="store_true", help="never send e-mail, print the link") | |
| 175 | + add.set_defaults(fn=cmd_users_add) | |
| 176 | + users.add_parser("list").set_defaults(fn=cmd_users_list) | |
| 177 | + st = users.add_parser("set-tier"); st.add_argument("email"); st.add_argument("tier", choices=("free", "high_usage")) | |
| 178 | + st.set_defaults(fn=cmd_users_set_tier) | |
| 179 | + d = users.add_parser("disable"); d.add_argument("email"); d.set_defaults(fn=lambda a: cmd_users_status(a, "disabled")) | |
| 180 | + e = users.add_parser("enable"); e.add_argument("email"); e.set_defaults(fn=lambda a: cmd_users_status(a, "active")) | |
| 181 | + ir = users.add_parser("invite-resend"); ir.add_argument("email"); ir.add_argument("--no-mail", action="store_true") | |
| 182 | + ir.set_defaults(fn=cmd_users_invite_resend) | |
| 183 | + | |
| 184 | + keys = sub.add_parser("keys", help="inspect API keys").add_subparsers(dest="sub", required=True) | |
| 185 | + kl = keys.add_parser("list"); kl.add_argument("email"); kl.set_defaults(fn=cmd_keys_list) | |
| 186 | + | |
| 187 | + seed = sub.add_parser("seed", help="create the initial users (idempotent)") | |
| 188 | + seed.add_argument("--show-key", action="store_true") | |
| 189 | + seed.add_argument("--no-mail", action="store_true") | |
| 190 | + seed.set_defaults(fn=cmd_seed) | |
| 191 | + return p | |
| 192 | + | |
| 193 | + | |
| 194 | +def main(argv: list[str] | None = None) -> int: | |
| 195 | + args = build_parser().parse_args(argv) | |
| 196 | + try: | |
| 197 | + return args.fn(args) | |
| 198 | + except ApiError as exc: | |
| 199 | + print(f"error: {exc.code}: {exc.message}", file=sys.stderr) | |
| 200 | + return 1 | |
| 201 | + | |
| 202 | + | |
| 203 | +if __name__ == "__main__": | |
| 204 | + sys.exit(main()) | |
added
hfmarketdata/api/accounts/deps.py
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +"""FastAPI dependencies: current user (session cookie or API key), admin role, CSRF guard, debug links. | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | +""" | |
| 5 | +from __future__ import annotations | |
| 6 | + | |
| 7 | +from fastapi import Depends, Request | |
| 8 | +from sqlalchemy.orm import Session | |
| 9 | + | |
| 10 | +from core.config import settings | |
| 11 | +from core.db import get_session | |
| 12 | +from core.errors import ApiError | |
| 13 | + | |
| 14 | +from . import security | |
| 15 | +from .models import User | |
| 16 | + | |
| 17 | + | |
| 18 | +def current_user_optional(request: Request, s: Session = Depends(get_session)) -> User | None: | |
| 19 | + """Session cookie first, then the API key resolved by the rate-limit middleware (`request.state.user_id`).""" | |
| 20 | + uid = security.read_session(request) | |
| 21 | + if uid is None: | |
| 22 | + uid = getattr(request.state, "user_id", None) | |
| 23 | + if uid is None: | |
| 24 | + return None | |
| 25 | + u = s.get(User, int(uid)) | |
| 26 | + if u is None: | |
| 27 | + return None | |
| 28 | + request.state.actor = f"user:{u.id}" | |
| 29 | + return u | |
| 30 | + | |
| 31 | + | |
| 32 | +def current_user(u: User | None = Depends(current_user_optional)) -> User: | |
| 33 | + if u is None: | |
| 34 | + raise ApiError(401, "AUTH_REQUIRED", "Sign in (session cookie) or pass your API key as " | |
| 35 | + "`Authorization: Bearer hfmd_live_…` to access your account.") | |
| 36 | + if u.status == "disabled": | |
| 37 | + raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.") | |
| 38 | + return u | |
| 39 | + | |
| 40 | + | |
| 41 | +def require_admin(u: User = Depends(current_user)) -> User: | |
| 42 | + if u.role != "admin": | |
| 43 | + raise ApiError(403, "FORBIDDEN", "This endpoint requires the admin role.") | |
| 44 | + return u | |
| 45 | + | |
| 46 | + | |
| 47 | +def csrf(request: Request) -> None: | |
| 48 | + security.csrf_guard(request) | |
| 49 | + | |
| 50 | + | |
| 51 | +def debug_links_enabled() -> bool: | |
| 52 | + """Action links are echoed in API responses only outside production AND without a mail provider.""" | |
| 53 | + return settings.is_dev and not settings.resend_api_key | |
added
hfmarketdata/api/accounts/routes.py
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +"""Accounts module entry point: aggregates `/v1/auth`, `/v1/me`, `/v1/limits`, `/v1/admin` into `router`. | |
| 2 | + | |
| 3 | +Loaded by main.py through V2_MODULES ("accounts.routes"). Tables are created on import (idempotent). | |
| 4 | + | |
| 5 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +""" | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +from fastapi import APIRouter | |
| 10 | + | |
| 11 | +from core.db import create_all | |
| 12 | + | |
| 13 | +from . import models # noqa: F401 (declares the tables on core.db.Base) | |
| 14 | +from .routes_admin import router as admin_router | |
| 15 | +from .routes_auth import router as auth_router | |
| 16 | +from .routes_me import limits_router | |
| 17 | +from .routes_me import router as me_router | |
| 18 | + | |
| 19 | +create_all() | |
| 20 | + | |
| 21 | +router = APIRouter() | |
| 22 | +router.include_router(auth_router) | |
| 23 | +router.include_router(me_router) | |
| 24 | +router.include_router(limits_router) | |
| 25 | +router.include_router(admin_router) | |
added
hfmarketdata/api/accounts/routes_admin.py
+257 −0
@@ -0,0 +1,257 @@ | ||
| 1 | +"""`/v1/admin` — user management, invitations, global usage, audit log (admin role only). | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | +""" | |
| 5 | +from __future__ import annotations | |
| 6 | + | |
| 7 | +import json | |
| 8 | +from typing import Literal | |
| 9 | + | |
| 10 | +from fastapi import APIRouter, Depends, Query | |
| 11 | +from pydantic import BaseModel, Field | |
| 12 | +from sqlalchemy import func, select | |
| 13 | +from sqlalchemy.orm import Session | |
| 14 | + | |
| 15 | +from core.db import get_session | |
| 16 | +from core.responses import clamp_limit, decode_cursor, encode_cursor, json_response | |
| 17 | +from ratelimit import usage | |
| 18 | + | |
| 19 | +from . import service | |
| 20 | +from .deps import csrf, require_admin | |
| 21 | +from .models import ApiKey, AuditLog, User | |
| 22 | + | |
| 23 | +router = APIRouter(prefix="/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)]) | |
| 24 | + | |
| 25 | +USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", | |
| 26 | + "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", | |
| 27 | + "last_login_at": "2026-09-04T14:05:40Z", "keys_active": 1} | |
| 28 | +ADMIN_ERRORS = ["AUTH_REQUIRED", "FORBIDDEN"] | |
| 29 | + | |
| 30 | + | |
| 31 | +class UserCreateBody(BaseModel): | |
| 32 | + email: str = Field(..., examples=["ada@example.com"]) | |
| 33 | + name: str = Field("", max_length=200) | |
| 34 | + tier: Literal["free", "high_usage"] = "free" | |
| 35 | + role: Literal["user", "admin"] = "user" | |
| 36 | + password: str | None = Field(None, description="Optional. Without it the user is *invited* (e-mail with a set-password link) " | |
| 37 | + "and receives an active API key.") | |
| 38 | + key_name: str = Field("default", max_length=100) | |
| 39 | + | |
| 40 | + | |
| 41 | +class UserUpdateBody(BaseModel): | |
| 42 | + name: str | None = Field(None, max_length=200) | |
| 43 | + tier: Literal["free", "high_usage"] | None = None | |
| 44 | + role: Literal["user", "admin"] | None = None | |
| 45 | + status: Literal["invited", "active", "disabled"] | None = None | |
| 46 | + | |
| 47 | + | |
| 48 | +class KeyCreateBody(BaseModel): | |
| 49 | + name: str = Field("default", max_length=100) | |
| 50 | + tier_override: Literal["free", "high_usage"] | None = None | |
| 51 | + | |
| 52 | + | |
| 53 | +def _actor(admin: User) -> str: | |
| 54 | + return f"user:{admin.id}" | |
| 55 | + | |
| 56 | + | |
| 57 | +def _keys_count(s: Session, user_ids: list[int]) -> dict[int, int]: | |
| 58 | + if not user_ids: | |
| 59 | + return {} | |
| 60 | + rows = s.execute(select(ApiKey.user_id, func.count()).where(ApiKey.user_id.in_(user_ids), ApiKey.status == "active") | |
| 61 | + .group_by(ApiKey.user_id)).all() | |
| 62 | + return {uid: n for uid, n in rows} | |
| 63 | + | |
| 64 | + | |
| 65 | +def _link_payload(issued: service.IssuedToken | None) -> dict: | |
| 66 | + if issued is None: | |
| 67 | + return {} | |
| 68 | + out = {"delivered": issued.delivery.delivered} | |
| 69 | + if not issued.delivery.delivered: | |
| 70 | + out["link"] = issued.link # admins may copy the link when no mail provider is configured | |
| 71 | + out["delivery_error"] = issued.delivery.error | |
| 72 | + return out | |
| 73 | + | |
| 74 | + | |
| 75 | +@router.get( | |
| 76 | + "/users", summary="List users", | |
| 77 | + description="Paginated (cursor on id), optional substring `search` on e-mail/name, filters on tier/status/role.", | |
| 78 | + responses={200: {"content": {"application/json": {"example": {"data": [USER_EXAMPLE], "meta": {"count": 1, "next_cursor": None}}}}}}, | |
| 79 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]}) | |
| 80 | +def list_users(search: str | None = None, tier: str | None = None, status: str | None = None, role: str | None = None, | |
| 81 | + limit: int | None = Query(None, ge=1, le=500), cursor: str | None = None, | |
| 82 | + s: Session = Depends(get_session), _: User = Depends(require_admin)): | |
| 83 | + lim = clamp_limit(limit, 100, 500) | |
| 84 | + q = select(User).order_by(User.id) | |
| 85 | + if search: | |
| 86 | + like = f"%{search.strip().lower()}%" | |
| 87 | + q = q.where(func.lower(User.email).like(like) | func.lower(User.name).like(like)) | |
| 88 | + if tier: | |
| 89 | + q = q.where(User.tier == tier) | |
| 90 | + if status: | |
| 91 | + q = q.where(User.status == status) | |
| 92 | + if role: | |
| 93 | + q = q.where(User.role == role) | |
| 94 | + after = decode_cursor(cursor) | |
| 95 | + if after is not None: | |
| 96 | + q = q.where(User.id > int(after)) | |
| 97 | + users = list(s.execute(q.limit(lim + 1)).scalars()) | |
| 98 | + nxt = encode_cursor(users[lim - 1].id) if len(users) > lim else None | |
| 99 | + users = users[:lim] | |
| 100 | + counts = _keys_count(s, [u.id for u in users]) | |
| 101 | + return json_response([service.user_public(u, keys_count=counts.get(u.id, 0)) for u in users], meta={"next_cursor": nxt}) | |
| 102 | + | |
| 103 | + | |
| 104 | +@router.post( | |
| 105 | + "/users", summary="Create or invite a user", status_code=201, | |
| 106 | + description=("With `password`: active + verified account. Without: **invitation** — user created with status " | |
| 107 | + "`invited`, an active API key is generated (its prefix is returned; the key itself is never shown to " | |
| 108 | + "admins) and a set-your-password link is e-mailed (valid 7 days). When no mail provider is configured, " | |
| 109 | + "`invitation.link` is returned so you can forward it."), | |
| 110 | + responses={201: {"content": {"application/json": {"example": {"data": { | |
| 111 | + "user": {**USER_EXAMPLE, "status": "invited", "email_verified": False}, | |
| 112 | + "key": {"id": 12, "prefix": "hfmd_live_ab12cd34", "name": "default"}, | |
| 113 | + "invitation": {"delivered": False, "link": "https://www.hfmarketdata.io/accept-invite?token=…", "delivery_error": "no_provider"}}, | |
| 114 | + "meta": {"count": 1}}}}}}, | |
| 115 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["EMAIL_TAKEN", "WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 116 | + dependencies=[Depends(csrf)]) | |
| 117 | +def create_user(body: UserCreateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 118 | + if body.password: | |
| 119 | + u = service.create_user(s, body.email, body.name, password=body.password, tier=body.tier, role=body.role, | |
| 120 | + status="active", actor=_actor(admin)) | |
| 121 | + u.email_verified_at = service.now() | |
| 122 | + return json_response({"user": service.user_public(u, keys_count=0), "key": None, "invitation": None}, status=201) | |
| 123 | + if service.get_user_by_email(s, body.email): | |
| 124 | + from core.errors import ApiError | |
| 125 | + raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Use POST /v1/admin/users/{id}/invite to re-send.") | |
| 126 | + u, _raw, issued = service.invite(s, body.email, body.name, tier=body.tier, role=body.role, actor=_actor(admin), | |
| 127 | + key_name=body.key_name) | |
| 128 | + keys = service.active_keys(s, u) | |
| 129 | + k = keys[0] if keys else None | |
| 130 | + return json_response({"user": service.user_public(u, keys_count=len(keys)), | |
| 131 | + "key": {"id": k.id, "prefix": k.prefix, "name": k.name} if k else None, | |
| 132 | + "invitation": _link_payload(issued)}, status=201) | |
| 133 | + | |
| 134 | + | |
| 135 | +@router.get( | |
| 136 | + "/users/{user_id}", summary="User detail with keys", | |
| 137 | + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "keys": [ | |
| 138 | + {"id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "status": "active", "tier_override": None, | |
| 139 | + "created_at": "2026-09-04T14:06:02Z", "last_used_at": None, "revoked_at": None, "principal": "key:12"}], | |
| 140 | + "pending_invite_link": None}, "meta": {"count": 1}}}}}}, | |
| 141 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND"]}) | |
| 142 | +def get_user(user_id: int, s: Session = Depends(get_session), _: User = Depends(require_admin)): | |
| 143 | + u = service.require_user(s, user_id) | |
| 144 | + keys = service.list_keys(s, u) | |
| 145 | + return json_response({"user": service.user_public(u, keys_count=sum(k.status == "active" for k in keys)), | |
| 146 | + "keys": [service.key_public(k) for k in keys], | |
| 147 | + "pending_invite_link": service.pending_link(s, u, "invite")}) | |
| 148 | + | |
| 149 | + | |
| 150 | +@router.patch( | |
| 151 | + "/users/{user_id}", summary="Update tier / role / status / name", | |
| 152 | + description="Setting `status: disabled` refuses the user's keys immediately (401 on their next call).", | |
| 153 | + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "tier": "high_usage"}, "meta": {"count": 1}}}}}}, | |
| 154 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 155 | + dependencies=[Depends(csrf)]) | |
| 156 | +def update_user(user_id: int, body: UserUpdateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 157 | + u = service.require_user(s, user_id) | |
| 158 | + service.update_user(s, u, actor=_actor(admin), name=body.name, tier=body.tier, role=body.role, status=body.status) | |
| 159 | + return json_response(service.user_public(u, keys_count=len(service.active_keys(s, u)))) | |
| 160 | + | |
| 161 | + | |
| 162 | +@router.post( | |
| 163 | + "/users/{user_id}/invite", summary="(Re-)send the invitation", | |
| 164 | + description="Creates a fresh 7-day invitation token (older ones are voided) and e-mails it. Ensures the user has an active key. Body `{}`.", | |
| 165 | + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "invitation": { | |
| 166 | + "delivered": True}}, "meta": {"count": 1}}}}}}, | |
| 167 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 168 | +def resend_invite(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 169 | + u = service.require_user(s, user_id) | |
| 170 | + if not service.active_keys(s, u): | |
| 171 | + service.create_key(s, u, "default", actor=_actor(admin), notify=False) | |
| 172 | + issued = service.issue_token(s, u, "invite", actor=_actor(admin)) | |
| 173 | + return json_response({"user": service.user_public(u), "invitation": _link_payload(issued)}) | |
| 174 | + | |
| 175 | + | |
| 176 | +@router.post( | |
| 177 | + "/users/{user_id}/reset-password", summary="Send a password-reset link to a user", | |
| 178 | + description="Creates a 1-hour reset token and e-mails it (link returned when no mail provider). Body `{}`.", | |
| 179 | + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "reset": {"delivered": True}}, "meta": {"count": 1}}}}}}, | |
| 180 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 181 | +def admin_reset(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 182 | + u = service.require_user(s, user_id) | |
| 183 | + issued = service.issue_token(s, u, "reset", actor=_actor(admin)) | |
| 184 | + return json_response({"user": service.user_public(u), "reset": _link_payload(issued)}) | |
| 185 | + | |
| 186 | + | |
| 187 | +@router.post( | |
| 188 | + "/users/{user_id}/keys", summary="Create an API key for a user (shown once)", status_code=201, | |
| 189 | + description="The full key is returned once to the admin (e.g. to hand over out of band). `tier_override` pins the key's tier.", | |
| 190 | + responses={201: {"content": {"application/json": {"example": {"data": { | |
| 191 | + "id": 14, "name": "default", "prefix": "hfmd_live_qq11ww22", "status": "active", "tier_override": None, | |
| 192 | + "created_at": "2026-09-04T16:00:00Z", "last_used_at": None, "revoked_at": None, "principal": "key:14", | |
| 193 | + "key": "hfmd_live_qq11ww22EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}}, | |
| 194 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 195 | + dependencies=[Depends(csrf)]) | |
| 196 | +def admin_create_key(user_id: int, body: KeyCreateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 197 | + u = service.require_user(s, user_id) | |
| 198 | + raw, k = service.create_key(s, u, body.name, actor=_actor(admin), notify=False) | |
| 199 | + if body.tier_override: | |
| 200 | + k.tier_override = body.tier_override | |
| 201 | + return json_response({**service.key_public(k), "key": raw}, status=201) | |
| 202 | + | |
| 203 | + | |
| 204 | +@router.delete( | |
| 205 | + "/users/{user_id}/keys/{key_id}", summary="Revoke a user's API key", | |
| 206 | + responses={200: {"content": {"application/json": {"example": {"data": {"id": 12, "status": "revoked"}, "meta": {"count": 1}}}}}}, | |
| 207 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 208 | + dependencies=[Depends(csrf)]) | |
| 209 | +def admin_revoke_key(user_id: int, key_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 210 | + u = service.require_user(s, user_id) | |
| 211 | + k = service.revoke_key(s, service.get_key(s, u, key_id), actor=_actor(admin)) | |
| 212 | + return json_response(service.key_public(k)) | |
| 213 | + | |
| 214 | + | |
| 215 | +@router.get( | |
| 216 | + "/usage", summary="Global usage: totals per day + top principals", | |
| 217 | + description="Folded data (usage_daily). `days` bounds both series; `top` limits the heaviest principals list. " | |
| 218 | + "Principals `key:<id>` are mapped to their user.", | |
| 219 | + responses={200: {"content": {"application/json": {"example": {"data": { | |
| 220 | + "per_day": [{"day": "2026-09-04", "requests": 18_240, "rows": 41_000_000, "rows_parquet": 2_000_000, "status_429": 12, "principals": 37}], | |
| 221 | + "top": [{"principal": "key:12", "requests": 5_120, "rows": 12_000_000, "status_429": 0, | |
| 222 | + "user": {"id": 7, "email": "ada@example.com", "tier": "free"}}]}, "meta": {"count": 1}}}}}}, | |
| 223 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]}) | |
| 224 | +def admin_usage(days: int = Query(30, ge=1, le=365), top: int = Query(20, ge=1, le=200), | |
| 225 | + s: Session = Depends(get_session), _: User = Depends(require_admin)): | |
| 226 | + tops = usage.top_principals(days=days, limit=top) | |
| 227 | + key_ids = [int(t["principal"].split(":")[1]) for t in tops if t["principal"].startswith("key:")] | |
| 228 | + owners: dict[str, dict] = {} | |
| 229 | + if key_ids: | |
| 230 | + for k, u in s.execute(select(ApiKey, User).join(User, User.id == ApiKey.user_id).where(ApiKey.id.in_(key_ids))).all(): | |
| 231 | + owners[f"key:{k.id}"] = {"id": u.id, "email": u.email, "tier": u.tier, "key_name": k.name, "prefix": k.prefix} | |
| 232 | + for t in tops: | |
| 233 | + t["user"] = owners.get(t["principal"]) | |
| 234 | + return json_response({"per_day": usage.totals_per_day(days=days), "top": tops}) | |
| 235 | + | |
| 236 | + | |
| 237 | +@router.get( | |
| 238 | + "/audit", summary="Audit log", | |
| 239 | + description="Newest first, cursor on id. Actions: user.create, user.update, user.login, key.create, key.revoke, token.*, …", | |
| 240 | + responses={200: {"content": {"application/json": {"example": {"data": [{ | |
| 241 | + "id": 91, "ts": "2026-09-04T16:00:00Z", "actor": "user:1", "action": "user.update", "target": "user:7", | |
| 242 | + "meta": {"tier": "high_usage"}}], "meta": {"count": 1, "next_cursor": None}}}}}}, | |
| 243 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]}) | |
| 244 | +def audit_log(limit: int | None = Query(None, ge=1, le=1000), cursor: str | None = None, action: str | None = None, | |
| 245 | + s: Session = Depends(get_session), _: User = Depends(require_admin)): | |
| 246 | + lim = clamp_limit(limit, 100, 1000) | |
| 247 | + q = select(AuditLog).order_by(AuditLog.id.desc()) | |
| 248 | + if action: | |
| 249 | + q = q.where(AuditLog.action.like(f"{action}%")) | |
| 250 | + before = decode_cursor(cursor) | |
| 251 | + if before is not None: | |
| 252 | + q = q.where(AuditLog.id < int(before)) | |
| 253 | + rows = list(s.execute(q.limit(lim + 1)).scalars()) | |
| 254 | + nxt = encode_cursor(rows[lim - 1].id) if len(rows) > lim else None | |
| 255 | + rows = rows[:lim] | |
| 256 | + return json_response([{"id": r.id, "ts": service.iso(r.ts), "actor": r.actor, "action": r.action, "target": r.target, | |
| 257 | + "meta": json.loads(r.meta) if r.meta else None} for r in rows], meta={"next_cursor": nxt}) | |
added
hfmarketdata/api/accounts/routes_auth.py
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +"""`/v1/auth` — signup, e-mail verification, login/logout, password reset, invitation acceptance. | |
| 2 | + | |
| 3 | +All endpoints are throttled per IP (10 requests / hour, see ratelimit.middleware) and answer with the | |
| 4 | +uniform error envelope. State-changing calls must be JSON (`Content-Type: application/json`). | |
| 5 | + | |
| 6 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 7 | +""" | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +from fastapi import APIRouter, Depends, Query, Request | |
| 11 | +from pydantic import BaseModel, Field | |
| 12 | +from sqlalchemy.orm import Session | |
| 13 | + | |
| 14 | +from core.config import settings | |
| 15 | +from core.db import get_session | |
| 16 | +from core.responses import json_response | |
| 17 | + | |
| 18 | +from . import security, service | |
| 19 | +from .deps import csrf, debug_links_enabled | |
| 20 | + | |
| 21 | +router = APIRouter(prefix="/v1/auth", tags=["auth"]) | |
| 22 | + | |
| 23 | +USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", | |
| 24 | + "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", | |
| 25 | + "last_login_at": "2026-09-04T14:05:40Z"} | |
| 26 | + | |
| 27 | + | |
| 28 | +class SignupBody(BaseModel): | |
| 29 | + email: str = Field(..., examples=["ada@example.com"]) | |
| 30 | + name: str = Field("", max_length=200, examples=["Ada Lovelace"]) | |
| 31 | + password: str = Field(..., min_length=1, max_length=256, description="At least 10 characters.") | |
| 32 | + | |
| 33 | + | |
| 34 | +class LoginBody(BaseModel): | |
| 35 | + email: str = Field(..., examples=["ada@example.com"]) | |
| 36 | + password: str | |
| 37 | + | |
| 38 | + | |
| 39 | +class EmailBody(BaseModel): | |
| 40 | + email: str = Field(..., examples=["ada@example.com"]) | |
| 41 | + | |
| 42 | + | |
| 43 | +class TokenPasswordBody(BaseModel): | |
| 44 | + token: str = Field(..., description="The `token` query parameter of the link received by e-mail.") | |
| 45 | + password: str = Field(..., min_length=1, max_length=256, description="At least 10 characters.") | |
| 46 | + | |
| 47 | + | |
| 48 | +def _with_link(payload: dict, issued: service.IssuedToken | None) -> dict: | |
| 49 | + if issued is not None and debug_links_enabled(): | |
| 50 | + payload["debug_link"] = issued.link | |
| 51 | + return payload | |
| 52 | + | |
| 53 | + | |
| 54 | +def _session_response(user, status: int = 200): | |
| 55 | + resp = json_response(service.user_public(user), status=status) | |
| 56 | + security.set_session_cookie(resp, user.id) | |
| 57 | + return resp | |
| 58 | + | |
| 59 | + | |
| 60 | +@router.post( | |
| 61 | + "/signup", summary="Create an account (sends a verification e-mail)", | |
| 62 | + description=( | |
| 63 | + "Creates an account and e-mails a verification link (valid 48 hours). The account cannot sign in " | |
| 64 | + "before the e-mail is verified. If the address was **invited** by an admin, the invitation is re-sent " | |
| 65 | + "instead. Passwords: 10 characters minimum, hashed with argon2.\n\n" | |
| 66 | + "Throttled: 10 requests per hour per IP on every `/v1/auth/*` endpoint."), | |
| 67 | + status_code=202, | |
| 68 | + responses={202: {"description": "Verification e-mail sent (or invitation re-sent).", "content": {"application/json": { | |
| 69 | + "example": {"data": {"status": "verification_sent", "email": "ada@example.com"}, "meta": {"count": 1}}}}}}, | |
| 70 | + openapi_extra={"x-errors": ["EMAIL_TAKEN", "WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 71 | + dependencies=[Depends(csrf)]) | |
| 72 | +def signup(body: SignupBody, s: Session = Depends(get_session)): | |
| 73 | + issued = service.signup(s, body.email, body.name, body.password) | |
| 74 | + status = "invitation_sent" if issued.token.kind == "invite" else "verification_sent" | |
| 75 | + return json_response(_with_link({"status": status, "email": issued.user.email}, issued), status=202) | |
| 76 | + | |
| 77 | + | |
| 78 | +@router.get( | |
| 79 | + "/verify", summary="Verify an e-mail address", | |
| 80 | + description=("Consumes the token of the verification link, activates the account and opens a session " | |
| 81 | + "(HttpOnly cookie `hfmd_session`). The web app calls this from `/verify?token=…`."), | |
| 82 | + responses={200: {"description": "Account verified; session cookie set.", "content": {"application/json": { | |
| 83 | + "example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, | |
| 84 | + openapi_extra={"x-errors": ["INVALID_TOKEN"]}) | |
| 85 | +def verify(token: str = Query(..., description="Token from the e-mail link"), s: Session = Depends(get_session)): | |
| 86 | + user = service.verify_email(s, token) | |
| 87 | + return _session_response(user) | |
| 88 | + | |
| 89 | + | |
| 90 | +@router.post( | |
| 91 | + "/login", summary="Sign in with e-mail + password", | |
| 92 | + description=("Opens a 30-day session (HttpOnly, SameSite=Lax cookie). Requires a verified e-mail. " | |
| 93 | + "Programmatic access does not need a session: use your API key as `Authorization: Bearer …`."), | |
| 94 | + responses={200: {"description": "Signed in.", "content": {"application/json": { | |
| 95 | + "example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, | |
| 96 | + openapi_extra={"x-errors": ["INVALID_CREDENTIALS", "EMAIL_NOT_VERIFIED", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 97 | + dependencies=[Depends(csrf)]) | |
| 98 | +def login(body: LoginBody, s: Session = Depends(get_session)): | |
| 99 | + user = service.login(s, body.email, body.password) | |
| 100 | + service.audit(s, f"user:{user.id}", "user.login", f"user:{user.id}") | |
| 101 | + return _session_response(user) | |
| 102 | + | |
| 103 | + | |
| 104 | +@router.post( | |
| 105 | + "/logout", summary="Sign out", description="Clears the session cookie. Send `{}` as JSON body.", | |
| 106 | + responses={200: {"content": {"application/json": {"example": {"data": {"status": "signed_out"}, "meta": {"count": 1}}}}}}, | |
| 107 | + openapi_extra={"x-errors": ["UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 108 | +def logout(request: Request): | |
| 109 | + resp = json_response({"status": "signed_out"}) | |
| 110 | + security.clear_session_cookie(resp) | |
| 111 | + return resp | |
| 112 | + | |
| 113 | + | |
| 114 | +@router.post( | |
| 115 | + "/forgot", summary="Request a password-reset link", | |
| 116 | + description=("Always answers 202 — whether or not the address exists — and e-mails a reset link valid " | |
| 117 | + "1 hour when it does."), | |
| 118 | + status_code=202, | |
| 119 | + responses={202: {"content": {"application/json": {"example": {"data": {"status": "reset_sent"}, "meta": {"count": 1}}}}}}, | |
| 120 | + openapi_extra={"x-errors": ["UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 121 | +def forgot(body: EmailBody, s: Session = Depends(get_session)): | |
| 122 | + issued = service.forgot(s, body.email) | |
| 123 | + return json_response(_with_link({"status": "reset_sent"}, issued), status=202) | |
| 124 | + | |
| 125 | + | |
| 126 | +@router.post( | |
| 127 | + "/reset", summary="Set a new password from a reset link", | |
| 128 | + description="Consumes the reset token, stores the new password and opens a session.", | |
| 129 | + responses={200: {"content": {"application/json": {"example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, | |
| 130 | + openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 131 | +def reset(body: TokenPasswordBody, s: Session = Depends(get_session)): | |
| 132 | + user = service.reset_password(s, body.token, body.password) | |
| 133 | + return _session_response(user) | |
| 134 | + | |
| 135 | + | |
| 136 | +@router.post( | |
| 137 | + "/accept-invite", summary="Accept an invitation (set your password)", | |
| 138 | + description=("Invited users (created by an admin or the CLI) choose their password here. The account " | |
| 139 | + "becomes active, the e-mail is considered verified and a session is opened. An API key was " | |
| 140 | + f"already created for them — visible in the dashboard ({settings.public_url}/dashboard/keys)."), | |
| 141 | + responses={200: {"content": {"application/json": {"example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, | |
| 142 | + openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 143 | +def accept_invite(body: TokenPasswordBody, s: Session = Depends(get_session)): | |
| 144 | + user = service.accept_invite(s, body.token, body.password) | |
| 145 | + return _session_response(user) | |
added
hfmarketdata/api/accounts/routes_me.py
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +"""`/v1/me` — profile, API keys, usage — and the public `/v1/limits`. | |
| 2 | + | |
| 3 | +Authentication: session cookie (web dashboard) or `Authorization: Bearer hfmd_live_…` (programmatic). | |
| 4 | +These endpoints are NOT charged against the data quota. | |
| 5 | + | |
| 6 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 7 | +""" | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +from typing import Literal | |
| 11 | + | |
| 12 | +from fastapi import APIRouter, Depends, Query, Request | |
| 13 | +from pydantic import BaseModel, Field | |
| 14 | +from sqlalchemy.orm import Session | |
| 15 | + | |
| 16 | +from core.config import settings | |
| 17 | +from core.db import get_session | |
| 18 | +from core.responses import json_response | |
| 19 | +from ratelimit import redis_limiter as rl | |
| 20 | +from ratelimit import usage | |
| 21 | +from ratelimit.middleware import snapshot as rl_snapshot | |
| 22 | +from ratelimit.tiers import TIERS, tier_for, tiers_public, upgrade_hint | |
| 23 | + | |
| 24 | +from . import service | |
| 25 | +from .deps import csrf, current_user | |
| 26 | +from .models import User | |
| 27 | + | |
| 28 | +router = APIRouter(prefix="/v1/me", tags=["me"]) | |
| 29 | +limits_router = APIRouter(prefix="/v1", tags=["meta"]) | |
| 30 | + | |
| 31 | +KEY_EXAMPLE = {"id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "status": "active", "tier_override": None, | |
| 32 | + "created_at": "2026-09-04T14:06:02Z", "last_used_at": "2026-09-04T15:31:09Z", "revoked_at": None, | |
| 33 | + "principal": "key:12"} | |
| 34 | +LIMITS_EXAMPLE = {"tier": "free", "window_seconds": 60, "requests": 120, "rows": 1_000_000, "max_rows_per_request": 50_000} | |
| 35 | + | |
| 36 | + | |
| 37 | +class KeyCreateBody(BaseModel): | |
| 38 | + name: str = Field("default", max_length=100, examples=["backtest-laptop"]) | |
| 39 | + | |
| 40 | + | |
| 41 | +def _limits_for(u: User) -> dict: | |
| 42 | + t = tier_for(u.tier) | |
| 43 | + return {"tier": t.name, "window_seconds": t.window_s, "requests": t.requests, "rows": t.rows, | |
| 44 | + "max_rows_per_request": t.max_rows_per_request, "upgrade": upgrade_hint(t)} | |
| 45 | + | |
| 46 | + | |
| 47 | +@router.get( | |
| 48 | + "", summary="Your profile, tier and limits", | |
| 49 | + description="Profile of the signed-in user (cookie session or API key), the tier limits that apply to their keys " | |
| 50 | + "and the number of active keys.", | |
| 51 | + responses={200: {"content": {"application/json": {"example": {"data": { | |
| 52 | + "user": {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", | |
| 53 | + "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", | |
| 54 | + "last_login_at": "2026-09-04T14:05:40Z", "keys_active": 1}, | |
| 55 | + "limits": LIMITS_EXAMPLE}, "meta": {"count": 1}}}}}}, | |
| 56 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "ACCOUNT_DISABLED"]}) | |
| 57 | +def me(u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 58 | + return json_response({"user": service.user_public(u, keys_count=len(service.active_keys(s, u))), | |
| 59 | + "limits": _limits_for(u)}) | |
| 60 | + | |
| 61 | + | |
| 62 | +@router.get( | |
| 63 | + "/keys", summary="List your API keys", | |
| 64 | + description="Active and revoked keys. Only the display prefix is stored — the full key is never retrievable.", | |
| 65 | + responses={200: {"content": {"application/json": {"example": {"data": [KEY_EXAMPLE], "meta": {"count": 1}}}}}}, | |
| 66 | + openapi_extra={"x-errors": ["AUTH_REQUIRED"]}) | |
| 67 | +def list_keys(u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 68 | + return json_response([service.key_public(k) for k in service.list_keys(s, u)]) | |
| 69 | + | |
| 70 | + | |
| 71 | +@router.post( | |
| 72 | + "/keys", summary="Create an API key (shown once)", status_code=201, | |
| 73 | + description=("Creates a key `hfmd_live_…` (32 base62 characters). **The full key is returned only in this " | |
| 74 | + f"response** — store it now. Up to {service.MAX_ACTIVE_KEYS} active keys per account. " | |
| 75 | + "A notice (without the key) is e-mailed to you."), | |
| 76 | + responses={201: {"content": {"application/json": {"example": {"data": { | |
| 77 | + **KEY_EXAMPLE, "key": "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}}, | |
| 78 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 79 | + dependencies=[Depends(csrf)]) | |
| 80 | +def create_key(body: KeyCreateBody, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 81 | + raw, k = service.create_key(s, u, body.name, actor=f"user:{u.id}") | |
| 82 | + return json_response({**service.key_public(k), "key": raw}, status=201) | |
| 83 | + | |
| 84 | + | |
| 85 | +@router.delete( | |
| 86 | + "/keys/{key_id}", summary="Revoke an API key", | |
| 87 | + description="Revocation is immediate (the lookup cache expires within 60 s on every worker). Idempotent.", | |
| 88 | + responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "status": "revoked", | |
| 89 | + "revoked_at": "2026-09-04T16:00:00Z"}, "meta": {"count": 1}}}}}}, | |
| 90 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 91 | +def revoke_key(key_id: int, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 92 | + k = service.revoke_key(s, service.get_key(s, u, key_id), actor=f"user:{u.id}") | |
| 93 | + return json_response(service.key_public(k)) | |
| 94 | + | |
| 95 | + | |
| 96 | +@router.post( | |
| 97 | + "/keys/{key_id}/rotate", summary="Rotate an API key (revoke + create)", status_code=201, | |
| 98 | + description="Revokes the key and creates a new one with the same name. The new key is shown once. Send `{}` as body.", | |
| 99 | + responses={201: {"content": {"application/json": {"example": {"data": { | |
| 100 | + **KEY_EXAMPLE, "id": 13, "prefix": "hfmd_live_zz98yy76", "key": "hfmd_live_zz98yy76EXAMPLEKEYnotARealOne00", | |
| 101 | + "rotated_from": 12}, "meta": {"count": 1}}}}}}, | |
| 102 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 103 | + dependencies=[Depends(csrf)]) | |
| 104 | +def rotate_key(key_id: int, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 105 | + old = service.get_key(s, u, key_id) | |
| 106 | + raw, k = service.rotate_key(s, u, old, actor=f"user:{u.id}") | |
| 107 | + return json_response({**service.key_public(k), "key": raw, "rotated_from": old.id}, status=201) | |
| 108 | + | |
| 109 | + | |
| 110 | +@router.get( | |
| 111 | + "/usage", summary="Your usage series (requests, rows)", | |
| 112 | + description=("Aggregated over all your keys (active and revoked). `24h` → per minute, `7d` → per hour, " | |
| 113 | + "`30d` → per day. Points are UTC ISO 8601; live (unfolded) minutes are included."), | |
| 114 | + responses={200: {"content": {"application/json": {"example": {"data": { | |
| 115 | + "range": "24h", "step_seconds": 60, "from": "2026-09-03T15:32:00Z", "to": "2026-09-04T15:32:00Z", | |
| 116 | + "points": [{"t": "2026-09-04T15:30:00Z", "requests": 12, "rows": 60_000}], | |
| 117 | + "totals": {"requests": 1_240, "rows": 3_100_000}, "principals": ["key:12"]}, "meta": {"count": 1}}}}}}, | |
| 118 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "INVALID_PARAMETER"]}) | |
| 119 | +def my_usage(range: Literal["24h", "7d", "30d"] = Query("24h"), u: User = Depends(current_user), | |
| 120 | + s: Session = Depends(get_session)): | |
| 121 | + principals = [k.principal for k in service.list_keys(s, u)] | |
| 122 | + series = usage.usage_series(principals, range) | |
| 123 | + series["principals"] = principals | |
| 124 | + return json_response(series) | |
| 125 | + | |
| 126 | + | |
| 127 | +@limits_router.get( | |
| 128 | + "/limits", summary="Tier table + your current counters (works without a key)", | |
| 129 | + description=("Public. Returns the three tiers (keyless · free · high_usage) and, for the principal making the " | |
| 130 | + "call (your IP without a key, your key otherwise), the remaining requests/rows and the reset time " | |
| 131 | + "of the current window. This call is free (not charged).\n\n" | |
| 132 | + "Costs reminder: Parquet responses count ½ row, bulk endpoints and HTTP 304 count 0, screener/frames " | |
| 133 | + "count 2 requests."), | |
| 134 | + responses={200: {"content": {"application/json": {"example": {"data": { | |
| 135 | + "tiers": {"keyless": {"name": "keyless", "scope": "ip", "window": "1h", "window_s": 3600, "requests": 30, | |
| 136 | + "rows": 100_000, "max_rows_per_request": 5_000}, | |
| 137 | + "free": {"name": "free", "scope": "key", "window": "1m", "window_s": 60, "requests": 120, | |
| 138 | + "rows": 1_000_000, "max_rows_per_request": 50_000, "how_to_get": "https://www.hfmarketdata.io/signup"}, | |
| 139 | + "high_usage": {"name": "high_usage", "scope": "key", "window": "1m", "window_s": 60, "requests": 600, | |
| 140 | + "rows": 10_000_000, "max_rows_per_request": 200_000, "how_to_get": "e-mail contact@spboucher.ai"}}, | |
| 141 | + "principal": {"principal": "ip:3f9a1c…", "kind": "keyless", "tier": "keyless", "window_seconds": 3600, | |
| 142 | + "max_rows_per_request": 5_000, "requests": {"limit": 30, "remaining": 29, "reset": 1788012345}, | |
| 143 | + "rows": {"limit": 100_000, "remaining": 97_488, "reset": 1788012345}, "redis": True}, | |
| 144 | + "costs": {"parquet_row_factor": 0.5, "bulk_rows": 0, "http_304_rows": 0, "expensive_endpoints_request_cost": 2}, | |
| 145 | + "upgrade": "Create a free account at https://www.hfmarketdata.io/signup …"}, "meta": {"count": 1}}}}}}, | |
| 146 | + openapi_extra={"x-errors": ["INVALID_API_KEY"]}) | |
| 147 | +def limits(request: Request): | |
| 148 | + snap = getattr(request.state, "ratelimit", None) | |
| 149 | + if snap is None: # middleware disabled or Redis down: still describe the tier | |
| 150 | + principal = getattr(request.state, "principal", None) | |
| 151 | + tier = TIERS.get(getattr(request.state, "tier", "keyless"), TIERS["keyless"]) | |
| 152 | + d = rl.peek_tier(principal, tier) if principal else None | |
| 153 | + from ratelimit.middleware import Principal | |
| 154 | + snap = rl_snapshot(d, tier, Principal(principal or "ip:unknown", getattr(request.state, "principal_kind", "keyless"), tier)) | |
| 155 | + tier = TIERS.get(snap["tier"], TIERS["keyless"]) | |
| 156 | + return json_response({"tiers": tiers_public(), "principal": snap, | |
| 157 | + "costs": {"parquet_row_factor": 0.5, "bulk_rows": 0, "http_304_rows": 0, | |
| 158 | + "expensive_endpoints_request_cost": 2}, | |
| 159 | + "upgrade": upgrade_hint(tier), "contact": settings.contact_email}) | |
added
scripts/hfmd
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# hfmd — accounts administration CLI (see hfmarketdata/api/accounts/cli.py). | |
| 3 | +# Uses the project venv (.venv or hfmarketdata/venv), falls back to python3. Honours HFMD_* env vars. | |
| 4 | +set -euo pipefail | |
| 5 | +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 6 | +PY="${HFMD_PYTHON:-}" | |
| 7 | +if [ -z "$PY" ]; then | |
| 8 | + for cand in "$ROOT/.venv/bin/python" "$ROOT/hfmarketdata/venv/bin/python" "$ROOT/venv/bin/python"; do | |
| 9 | + if [ -x "$cand" ]; then PY="$cand"; break; fi | |
| 10 | + done | |
| 11 | +fi | |
| 12 | +PY="${PY:-python3}" | |
| 13 | +export PYTHONPATH="$ROOT/hfmarketdata/api${PYTHONPATH:+:$PYTHONPATH}" | |
| 14 | +exec "$PY" -m accounts.cli "$@" | |
| 15 | ||