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: 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>
Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent dfcc35f

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