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 — POST /v1/auth/verify (GET redirige sans consommer), reset avec revoke_keys, throttle forgot par e-mail, /v1/me/{limits,usage?key_id,usage.csv,password,email,sessions/revoke-all,DELETE}, PATCH clés, admin session obligatoire + suppression + filtres audit + usage 7 j + admins_active

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent 74b0747

3 changed files +414 −118

modified hfmarketdata/api/accounts/routes_admin.py +88 −37
@@ -1,5 +1,9 @@
1 1 """`/v1/admin` — user management, invitations, global usage, audit log (admin role only).
2 2
3 +The whole area requires a **browser session** with the admin role: an API key — even an admin's — is refused
4 +(`403 SESSION_REQUIRED`). Admins cannot change their own role/status and the last active admin can never be
5 +demoted, disabled or deleted (`409 LAST_ADMIN`).
6 +
3 7 Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 8 """
5 9 from __future__ import annotations
@@ -13,6 +17,7 @@ from sqlalchemy import func, select
13 17 from sqlalchemy.orm import Session
14 18
15 19 from core.db import get_session
20 +from core.errors import ApiError
16 21 from core.responses import clamp_limit, decode_cursor, encode_cursor, json_response
17 22 from ratelimit import usage
18 23
@@ -24,8 +29,14 @@ router = APIRouter(prefix="/v1/admin", tags=["admin"], dependencies=[Depends(req
24 29
25 30 USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free",
26 31 "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"]
32 + "last_login_at": "2026-09-04T14:05:40Z", "quota_alerts": True, "locked_until": None, "keys_active": 1}
33 +KEY_EXAMPLE = {"id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "status": "active", "tier_override": None,
34 + "created_at": "2026-09-04T14:06:02Z", "last_used_at": None, "last_used_ip": None, "revoked_at": None,
35 + "expires_at": None, "expired": False, "note": None, "scopes": ["data"], "principal": "key:12"}
36 +ADMIN_ERRORS = ["AUTH_REQUIRED", "SESSION_REQUIRED", "FORBIDDEN"]
37 +AUDIT_ACTIONS = ("user.create, user.update, user.login, user.login_failed, user.verify, user.reset_password, "
38 + "user.signup_existing, account.password_change, account.email_change, account.delete, "
39 + "session.revoke_all, key.create, key.revoke, key.rotate, key.update, token.*")
29 40
30 41
31 42 class UserCreateBody(BaseModel):
@@ -33,8 +44,8 @@ class UserCreateBody(BaseModel):
33 44 name: str = Field("", max_length=200)
34 45 tier: Literal["free", "high_usage", "unlimited"] = "free"
35 46 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.")
47 + password: str | None = Field(None, max_length=256, description="Optional. Without it the user is *invited* (e-mail with a set-password link) "
48 + "and receives an active API key.")
38 49 key_name: str = Field("default", max_length=100)
39 50
40 51
@@ -48,6 +59,8 @@ class UserUpdateBody(BaseModel):
48 59 class KeyCreateBody(BaseModel):
49 60 name: str = Field("default", max_length=100)
50 61 tier_override: Literal["free", "high_usage", "unlimited"] | None = None
62 + note: str | None = Field(None, max_length=500)
63 + expires_in_days: int | None = Field(None, ge=1, le=service.MAX_KEY_EXPIRY_DAYS)
51 64
52 65
53 66 def _actor(admin: User) -> str:
@@ -63,10 +76,11 @@ def _keys_count(s: Session, user_ids: list[int]) -> dict[int, int]:
63 76
64 77
65 78 def _link_payload(issued: service.IssuedToken | None) -> dict:
79 + """`delivered` (bool), `queued` (mail handed to the provider after commit), `link` only when nothing is sent."""
66 80 if issued is None:
67 81 return {}
68 − out = {"delivered": issued.delivery.delivered}
69 − if not issued.delivery.delivered:
82 + out: dict = {"delivered": issued.delivery.delivered, "queued": issued.delivery.queued}
83 + if not issued.delivery.delivered and not issued.delivery.queued:
70 84 out["link"] = issued.link # admins may copy the link when no mail provider is configured
71 85 out["delivery_error"] = issued.delivery.error
72 86 return out
@@ -74,8 +88,9 @@ def _link_payload(issued: service.IssuedToken | None) -> dict:
74 88
75 89 @router.get(
76 90 "/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}}}}}},
91 + description="Paginated (cursor on id), optional substring `search` on e-mail/name, filters on tier/status/role. "
92 + "`meta.admins_active` = number of active administrators (show a warning when it is 1).",
93 + responses={200: {"content": {"application/json": {"example": {"data": [USER_EXAMPLE], "meta": {"count": 1, "next_cursor": None, "admins_active": 2}}}}}},
79 94 openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]})
80 95 def list_users(search: str | None = None, tier: str | None = None, status: str | None = None, role: str | None = None,
81 96 limit: int | None = Query(None, ge=1, le=500), cursor: str | None = None,
@@ -98,7 +113,8 @@ def list_users(search: str | None = None, tier: str | None = None, status: str |
98 113 nxt = encode_cursor(users[lim - 1].id) if len(users) > lim else None
99 114 users = users[:lim]
100 115 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})
116 + return json_response([service.user_public(u, keys_count=counts.get(u.id, 0)) for u in users],
117 + meta={"next_cursor": nxt, "admins_active": service.active_admins(s)})
102 118
103 119
104 120 @router.post(
@@ -106,11 +122,12 @@ def list_users(search: str | None = None, tier: str | None = None, status: str |
106 122 description=("With `password`: active + verified account. Without: **invitation** — user created with status "
107 123 "`invited`, an active API key is generated (its prefix is returned; the key itself is never shown to "
108 124 "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."),
125 + "`invitation.link` is returned so you can forward it; with a provider the mail is queued after commit "
126 + "(`invitation.queued`)."),
110 127 responses={201: {"content": {"application/json": {"example": {"data": {
111 128 "user": {**USER_EXAMPLE, "status": "invited", "email_verified": False},
112 129 "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"}},
130 + "invitation": {"delivered": False, "queued": False, "link": "https://www.hfmarketdata.io/accept-invite?token=…", "delivery_error": "no_provider"}},
114 131 "meta": {"count": 1}}}}}},
115 132 openapi_extra={"x-errors": ADMIN_ERRORS + ["EMAIL_TAKEN", "WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]},
116 133 dependencies=[Depends(csrf)])
@@ -121,7 +138,6 @@ def create_user(body: UserCreateBody, s: Session = Depends(get_session), admin:
121 138 u.email_verified_at = service.now()
122 139 return json_response({"user": service.user_public(u, keys_count=0), "key": None, "invitation": None}, status=201)
123 140 if service.get_user_by_email(s, body.email):
124 − from core.errors import ApiError
125 141 raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Use POST /v1/admin/users/{id}/invite to re-send.")
126 142 u, _raw, issued = service.invite(s, body.email, body.name, tier=body.tier, role=body.role, actor=_actor(admin),
127 143 key_name=body.key_name)
@@ -133,40 +149,64 @@ def create_user(body: UserCreateBody, s: Session = Depends(get_session), admin:
133 149
134 150
135 151 @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}}}}}},
152 + "/users/{user_id}", summary="User detail: keys, pending links, 7-day usage",
153 + description="`usage` is the 7-day hourly series over all the user's keys (same shape as `GET /v1/me/usage?range=7d`).",
154 + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "keys": [KEY_EXAMPLE],
155 + "pending_invite_link": None, "pending_reset_link": None,
156 + "usage": {"range": "7d", "step_seconds": 3600, "points": [{"t": "2026-09-04T15:00:00Z", "requests": 12, "rows": 60_000, "status_429": 0, "bytes": 0, "rows_parquet": 0}],
157 + "totals": {"requests": 12, "rows": 60_000, "status_429": 0, "bytes": 0, "rows_parquet": 0}}}, "meta": {"count": 1}}}}}},
141 158 openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND"]})
142 159 def get_user(user_id: int, s: Session = Depends(get_session), _: User = Depends(require_admin)):
143 160 u = service.require_user(s, user_id)
144 161 keys = service.list_keys(s, u)
145 162 return json_response({"user": service.user_public(u, keys_count=sum(k.status == "active" for k in keys)),
146 163 "keys": [service.key_public(k) for k in keys],
147 − "pending_invite_link": service.pending_link(s, u, "invite")})
164 + "pending_invite_link": service.pending_link(s, u, "invite"),
165 + "pending_reset_link": service.pending_link(s, u, "reset"),
166 + "usage": usage.usage_series([k.principal for k in keys], "7d")})
148 167
149 168
150 169 @router.patch(
151 170 "/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).",
171 + description=("Setting `status: disabled` refuses the user's keys immediately (401 on their next call) and signs out "
172 + "their sessions; a role change signs them out too. You cannot change your own role or status "
173 + "(`403 FORBIDDEN`), and the last active admin cannot be demoted or disabled (`409 LAST_ADMIN`)."),
153 174 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"]},
175 + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "INVALID_PARAMETER", "LAST_ADMIN", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]},
155 176 dependencies=[Depends(csrf)])
156 177 def update_user(user_id: int, body: UserUpdateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)):
157 178 u = service.require_user(s, user_id)
179 + if u.id == admin.id and (body.role is not None or body.status is not None):
180 + raise ApiError(403, "FORBIDDEN", "You cannot change your own role or status. Ask another administrator.")
158 181 service.update_user(s, u, actor=_actor(admin), name=body.name, tier=body.tier, role=body.role, status=body.status)
159 182 return json_response(service.user_public(u, keys_count=len(service.active_keys(s, u))))
160 183
161 184
185 +@router.delete(
186 + "/users/{user_id}", summary="Delete (anonymise) a user",
187 + description="Soft delete: keys revoked, sessions signed out, pending links voided, e-mail and name removed, status "
188 + "`deleted`. Usage counters stay (anonymous). Refused for yourself and for the last active admin.",
189 + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "email": "deleted-7@deleted.invalid", "name": "", "status": "deleted"}, "meta": {"count": 1}}}}}},
190 + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "LAST_ADMIN", "UNSUPPORTED_MEDIA_TYPE"]},
191 + dependencies=[Depends(csrf)])
192 +def delete_user(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)):
193 + u = service.require_user(s, user_id)
194 + if u.id == admin.id:
195 + raise ApiError(403, "FORBIDDEN", "Delete your own account from the dashboard (Account → Delete), not from the admin area.")
196 + service.delete_account(s, u, actor=_actor(admin))
197 + return json_response(service.user_public(u, keys_count=0))
198 +
199 +
162 200 @router.post(
163 201 "/users/{user_id}/invite", summary="(Re-)send the invitation",
164 202 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 203 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)])
204 + "delivered": False, "queued": True}}, "meta": {"count": 1}}}}}},
205 + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)])
168 206 def resend_invite(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)):
169 207 u = service.require_user(s, user_id)
208 + if u.status == "deleted":
209 + raise ApiError(409, "CONFLICT", "Deleted accounts cannot be invited.")
170 210 if not service.active_keys(s, u):
171 211 service.create_key(s, u, "default", actor=_actor(admin), notify=False)
172 212 issued = service.issue_token(s, u, "invite", actor=_actor(admin))
@@ -176,34 +216,36 @@ def resend_invite(user_id: int, s: Session = Depends(get_session), admin: User =
176 216 @router.post(
177 217 "/users/{user_id}/reset-password", summary="Send a password-reset link to a user",
178 218 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)])
219 + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "reset": {"delivered": False, "queued": True}}, "meta": {"count": 1}}}}}},
220 + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)])
181 221 def admin_reset(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)):
182 222 u = service.require_user(s, user_id)
223 + if u.status in ("deleted", "disabled"):
224 + raise ApiError(409, "CONFLICT", "This account cannot receive a reset link (disabled or deleted).")
183 225 issued = service.issue_token(s, u, "reset", actor=_actor(admin))
184 226 return json_response({"user": service.user_public(u), "reset": _link_payload(issued)})
185 227
186 228
187 229 @router.post(
188 230 "/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.",
231 + 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; "
232 + "`expires_in_days` gives it a lifetime.",
190 233 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"]},
234 + **KEY_EXAMPLE, "id": 14, "prefix": "hfmd_live_qq11ww22", "key": "hfmd_live_qq11ww22EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}},
235 + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_LIMIT_REACHED", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]},
195 236 dependencies=[Depends(csrf)])
196 237 def admin_create_key(user_id: int, body: KeyCreateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)):
197 238 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
239 + if u.status == "deleted":
240 + raise ApiError(409, "CONFLICT", "Deleted accounts cannot receive keys.")
241 + raw, k = service.create_key(s, u, body.name, actor=_actor(admin), notify=False, note=body.note,
242 + expires_at=service.validate_expiry(None, body.expires_in_days), tier_override=body.tier_override)
201 243 return json_response({**service.key_public(k), "key": raw}, status=201)
202 244
203 245
204 246 @router.delete(
205 247 "/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}}}}}},
248 + responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "status": "revoked"}, "meta": {"count": 1}}}}}},
207 249 openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]},
208 250 dependencies=[Depends(csrf)])
209 251 def admin_revoke_key(user_id: int, key_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)):
@@ -215,11 +257,12 @@ def admin_revoke_key(user_id: int, key_id: int, s: Session = Depends(get_session
215 257 @router.get(
216 258 "/usage", summary="Global usage: totals per day + top principals",
217 259 description="Folded data (usage_daily). `days` bounds both series; `top` limits the heaviest principals list. "
218 − "Principals `key:<id>` are mapped to their user.",
260 + "Principals `key:<id>` are mapped to their user (`user` is null for keyless `ip:<hash>` principals).",
219 261 responses={200: {"content": {"application/json": {"example": {"data": {
220 262 "per_day": [{"day": "2026-09-04", "requests": 18_240, "rows": 41_000_000, "rows_parquet": 2_000_000, "status_429": 12, "principals": 37}],
221 263 "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}}}}}},
264 + "user": {"id": 7, "email": "ada@example.com", "tier": "free", "key_name": "default", "prefix": "hfmd_live_ab12cd34"}}],
265 + "totals": {"requests": 18_240, "rows": 41_000_000, "rows_parquet": 2_000_000, "status_429": 12}}, "meta": {"count": 1}}}}}},
223 266 openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]})
224 267 def admin_usage(days: int = Query(30, ge=1, le=365), top: int = Query(20, ge=1, le=200),
225 268 s: Session = Depends(get_session), _: User = Depends(require_admin)):
@@ -231,22 +274,30 @@ def admin_usage(days: int = Query(30, ge=1, le=365), top: int = Query(20, ge=1,
231 274 owners[f"key:{k.id}"] = {"id": u.id, "email": u.email, "tier": u.tier, "key_name": k.name, "prefix": k.prefix}
232 275 for t in tops:
233 276 t["user"] = owners.get(t["principal"])
234 − return json_response({"per_day": usage.totals_per_day(days=days), "top": tops})
277 + per_day = usage.totals_per_day(days=days)
278 + totals = {f: sum(d[f] for d in per_day) for f in ("requests", "rows", "rows_parquet", "status_429")}
279 + return json_response({"per_day": per_day, "top": tops, "totals": totals})
235 280
236 281
237 282 @router.get(
238 283 "/audit", summary="Audit log",
239 − description="Newest first, cursor on id. Actions: user.create, user.update, user.login, key.create, key.revoke, token.*, …",
284 + description=f"Newest first, cursor on id. Filters: `action` (prefix), `actor` (exact, e.g. `user:7` or `cli`), "
285 + f"`target` (exact, e.g. `user:7` or `key:12`). Actions: {AUDIT_ACTIONS}.",
240 286 responses={200: {"content": {"application/json": {"example": {"data": [{
241 287 "id": 91, "ts": "2026-09-04T16:00:00Z", "actor": "user:1", "action": "user.update", "target": "user:7",
242 288 "meta": {"tier": "high_usage"}}], "meta": {"count": 1, "next_cursor": None}}}}}},
243 289 openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]})
244 290 def audit_log(limit: int | None = Query(None, ge=1, le=1000), cursor: str | None = None, action: str | None = None,
291 + actor: str | None = Query(None, max_length=64), target: str | None = Query(None, max_length=64),
245 292 s: Session = Depends(get_session), _: User = Depends(require_admin)):
246 293 lim = clamp_limit(limit, 100, 1000)
247 294 q = select(AuditLog).order_by(AuditLog.id.desc())
248 295 if action:
249 296 q = q.where(AuditLog.action.like(f"{action}%"))
297 + if actor:
298 + q = q.where(AuditLog.actor == actor.strip())
299 + if target:
300 + q = q.where(AuditLog.target == target.strip())
250 301 before = decode_cursor(cursor)
251 302 if before is not None:
252 303 q = q.where(AuditLog.id < int(before))
modified hfmarketdata/api/accounts/routes_auth.py +105 −44
@@ -1,28 +1,38 @@
1 1 """`/v1/auth` — signup, e-mail verification, login/logout, password reset, invitation acceptance.
2 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`).
3 +Throttled per IP and per endpoint (login 20/h, signup 5/h, forgot 5/h + 5/h per e-mail, verify 30/h; see
4 +ratelimit.middleware); login/signup/forgot fail closed when Redis is down. Answers use the uniform error
5 +envelope. State-changing calls must be JSON (`Content-Type: application/json`) or carry `X-Requested-With: hfmd`.
6 +Signup and forgot never reveal whether an address exists.
5 7
6 8 Author: Simon-Pierre Boucher <contact@spboucher.ai>
7 9 """
8 10 from __future__ import annotations
9 11
12 +from urllib.parse import quote
13 +
10 14 from fastapi import APIRouter, Depends, Query, Request
15 +from fastapi.responses import RedirectResponse
11 16 from pydantic import BaseModel, Field
12 17 from sqlalchemy.orm import Session
13 18
14 19 from core.config import settings
15 20 from core.db import get_session
21 +from core.errors import ApiError
16 22 from core.responses import json_response
23 +from ratelimit import redis_limiter as rl
24 +from ratelimit.tiers import AUTH_FAIL_CLOSED_RETRY_S, AUTH_WINDOW_S, FORGOT_PER_EMAIL_PER_HOUR
17 25
18 26 from . import security, service
19 −from .deps import csrf, debug_links_enabled
27 +from .deps import client_ip_hash, csrf, debug_links_enabled
20 28
21 29 router = APIRouter(prefix="/v1/auth", tags=["auth"])
22 30
23 31 USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free",
24 32 "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z",
25 − "last_login_at": "2026-09-04T14:05:40Z"}
33 + "last_login_at": "2026-09-04T14:05:40Z", "quota_alerts": True, "locked_until": None}
34 +THROTTLE_NOTE = ("\n\nThrottled per IP: see the limit of each endpoint in its description; `429 RATE_LIMIT_EXCEEDED` "
35 + "with `Retry-After`. `415 UNSUPPORTED_MEDIA_TYPE` when the body is not JSON.")
26 36
27 37
28 38 class SignupBody(BaseModel):
@@ -32,28 +42,37 @@ class SignupBody(BaseModel):
32 42
33 43
34 44 class LoginBody(BaseModel):
35 − email: str = Field(..., examples=["ada@example.com"])
36 − password: str
45 + email: str = Field(..., examples=["ada@example.com"], max_length=254)
46 + password: str = Field(..., max_length=256)
37 47
38 48
39 49 class EmailBody(BaseModel):
40 − email: str = Field(..., examples=["ada@example.com"])
50 + email: str = Field(..., examples=["ada@example.com"], max_length=254)
51 +
52 +
53 +class TokenBody(BaseModel):
54 + token: str = Field(..., max_length=256, description="The `token` query parameter of the link received by e-mail.")
41 55
42 56
43 57 class TokenPasswordBody(BaseModel):
44 − token: str = Field(..., description="The `token` query parameter of the link received by e-mail.")
58 + token: str = Field(..., max_length=256, description="The `token` query parameter of the link received by e-mail.")
45 59 password: str = Field(..., min_length=1, max_length=256, description="At least 10 characters.")
46 60
47 61
62 +class ResetBody(TokenPasswordBody):
63 + revoke_keys: bool = Field(True, description="Also revoke every active API key of the account (recommended: a reset "
64 + "usually means the credentials may have leaked). Default true.")
65 +
66 +
48 67 def _with_link(payload: dict, issued: service.IssuedToken | None) -> dict:
49 68 if issued is not None and debug_links_enabled():
50 69 payload["debug_link"] = issued.link
51 70 return payload
52 71
53 72
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)
73 +def _session_response(user, status: int = 200, extra: dict | None = None):
74 + resp = json_response({**service.user_public(user), **(extra or {})}, status=status)
75 + security.set_session_cookie(resp, user.id, user.session_version or 1)
57 76 return resp
58 77
59 78
@@ -61,48 +80,74 @@ def _session_response(user, status: int = 200):
61 80 "/signup", summary="Create an account (sends a verification e-mail)",
62 81 description=(
63 82 "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."),
83 + "before the e-mail is verified. Passwords: 10 characters minimum, hashed with argon2.\n\n"
84 + "**The answer is always `202 verification_sent`**, whatever the state of the address: a new address gets a "
85 + "verification mail, an *invited* address gets its invitation again, an unverified one a fresh verification "
86 + "mail, an existing verified account a notice (\"you already have an account\"). An existing account is never "
87 + "modified by a signup (no password or name change).\n\n"
88 + "Throttled: 5 requests per hour per IP." + THROTTLE_NOTE),
67 89 status_code=202,
68 − responses={202: {"description": "Verification e-mail sent (or invitation re-sent).", "content": {"application/json": {
90 + responses={202: {"description": "Verification e-mail sent (uniform answer).", "content": {"application/json": {
69 91 "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"]},
92 + openapi_extra={"x-errors": ["WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]},
71 93 dependencies=[Depends(csrf)])
72 94 def signup(body: SignupBody, s: Session = Depends(get_session)):
73 95 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)
96 + return json_response(_with_link({"status": "verification_sent", "email": service.normalize_email(body.email)}, issued),
97 + status=202)
76 98
77 99
78 100 @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)
101 + "/verify", summary="Verification link landing (redirects to the web page, does not consume the token)",
102 + description=("Legacy entry point kept for compatibility: answers **302** to the web page `/verify?token=…` "
103 + "without consuming the token, so mail pre-fetchers and link scanners cannot burn it. The page "
104 + "then calls `POST /v1/auth/verify`."),
105 + status_code=302, responses={302: {"description": "Redirect to the web page."}},
106 + openapi_extra={"x-errors": []})
107 +def verify_redirect(token: str = Query(..., max_length=256, description="Token from the e-mail link")):
108 + return RedirectResponse(f"{settings.public_url}/verify?token={quote(token, safe='')}", status_code=302)
109 +
110 +
111 +@router.post(
112 + "/verify", summary="Verify an e-mail address (or confirm an e-mail change)",
113 + description=("Consumes the token of a verification link (`kind: verify` → activates the account) or of an "
114 + "e-mail-change confirmation (`kind: email_change` → the new address becomes effective, every other "
115 + "session is signed out) and opens a session (HttpOnly cookie `hfmd_session`). Called by the web page "
116 + "`/verify?token=…`, which removes the token from the address bar first.\n\nThrottled: 30 per hour per IP."
117 + + THROTTLE_NOTE),
118 + responses={200: {"description": "Verified; session cookie set.", "content": {"application/json": {
119 + "example": {"data": {**USER_EXAMPLE, "kind": "verify"}, "meta": {"count": 1}}}}}},
120 + openapi_extra={"x-errors": ["INVALID_TOKEN", "ACCOUNT_DISABLED", "EMAIL_TAKEN", "UNSUPPORTED_MEDIA_TYPE"]},
121 + dependencies=[Depends(csrf)])
122 +def verify(body: TokenBody, s: Session = Depends(get_session)):
123 + kind, user = service.verify_any(s, body.token)
124 + return _session_response(user, extra={"kind": kind})
88 125
89 126
90 127 @router.post(
91 128 "/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 …`."),
129 + description=("Opens a 30-day session (HttpOnly, SameSite=Lax cookie). Requires a verified e-mail. The check costs "
130 + "the same for unknown and known addresses. After 5 wrong passwords the account is locked with a "
131 + "progressive delay (30 s, 2 min, 10 min, then 1 h) → `423 ACCOUNT_LOCKED` with `Retry-After`; the "
132 + "counter resets on a successful sign-in and a warning e-mail is sent at the 10th failure.\n\n"
133 + "Programmatic access does not need a session: use your API key as `Authorization: Bearer …` "
134 + "(read-only on the account). Throttled: 20 per hour per IP; fails closed (429) when the rate limiter "
135 + "is unavailable." + THROTTLE_NOTE),
94 136 responses={200: {"description": "Signed in.", "content": {"application/json": {
95 137 "example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}},
96 − openapi_extra={"x-errors": ["INVALID_CREDENTIALS", "EMAIL_NOT_VERIFIED", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]},
138 + openapi_extra={"x-errors": ["INVALID_CREDENTIALS", "ACCOUNT_LOCKED", "EMAIL_NOT_VERIFIED", "ACCOUNT_DISABLED",
139 + "UNSUPPORTED_MEDIA_TYPE"]},
97 140 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}")
141 +def login(body: LoginBody, request: Request, s: Session = Depends(get_session)):
142 + user = service.login(s, body.email, body.password, ip_hash=client_ip_hash(request),
143 + user_agent=request.headers.get("user-agent"))
101 144 return _session_response(user)
102 145
103 146
104 147 @router.post(
105 − "/logout", summary="Sign out", description="Clears the session cookie. Send `{}` as JSON body.",
148 + "/logout", summary="Sign out (this browser)",
149 + description="Clears the session cookie. Send `{}` as JSON body (or an empty body with `X-Requested-With: hfmd`). "
150 + "To sign out every browser at once use `POST /v1/me/sessions/revoke-all`.",
106 151 responses={200: {"content": {"application/json": {"example": {"data": {"status": "signed_out"}, "meta": {"count": 1}}}}}},
107 152 openapi_extra={"x-errors": ["UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)])
108 153 def logout(request: Request):
@@ -113,33 +158,49 @@ def logout(request: Request):
113 158
114 159 @router.post(
115 160 "/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."),
161 + description=("Always answers `202 reset_sent` — whether or not the address exists — and e-mails a reset link valid "
162 + "1 hour when it does (active or invited account). Throttled: 5 per hour per IP **and** 5 per hour per "
163 + "e-mail address; fails closed (429) when the rate limiter is unavailable." + THROTTLE_NOTE),
118 164 status_code=202,
119 165 responses={202: {"content": {"application/json": {"example": {"data": {"status": "reset_sent"}, "meta": {"count": 1}}}}}},
120 166 openapi_extra={"x-errors": ["UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)])
121 167 def forgot(body: EmailBody, s: Session = Depends(get_session)):
168 + email_hash = security.hash_token("forgot|" + (body.email or "").strip().lower())
169 + d = rl.throttle(f"auth:forgot:email:{email_hash}", limit=FORGOT_PER_EMAIL_PER_HOUR, window_s=AUTH_WINDOW_S, namespace="rl")
170 + if d is None:
171 + raise ApiError(429, "RATE_LIMIT_EXCEEDED", "Password reset is temporarily unavailable (rate limiter offline). "
172 + f"Retry in {AUTH_FAIL_CLOSED_RETRY_S} s.", type="requests_per_hour",
173 + headers={"Retry-After": str(AUTH_FAIL_CLOSED_RETRY_S)})
174 + if not d.allowed_requests:
175 + retry = max(1, d.reset_requests - rl.now_ms() // 1000)
176 + raise ApiError(429, "RATE_LIMIT_EXCEEDED", f"Too many reset requests for this address: {FORGOT_PER_EMAIL_PER_HOUR} "
177 + f"per hour. Retry in {retry} s.", type="requests_per_hour", headers={"Retry-After": str(retry)})
122 178 issued = service.forgot(s, body.email)
123 179 return json_response(_with_link({"status": "reset_sent"}, issued), status=202)
124 180
125 181
126 182 @router.post(
127 183 "/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)
184 + description=("Consumes the reset token, stores the new password, **signs out every existing session** and, unless "
185 + "`revoke_keys` is false, revokes every active API key. Opens a fresh session. Throttled: 10 per hour per IP."
186 + + THROTTLE_NOTE),
187 + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "keys_revoked": 2}, "meta": {"count": 1}}}}}},
188 + openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]},
189 + dependencies=[Depends(csrf)])
190 +def reset(body: ResetBody, s: Session = Depends(get_session)):
191 + user, revoked = service.reset_password(s, body.token, body.password, revoke_keys=body.revoke_keys)
192 + return _session_response(user, extra={"keys_revoked": revoked})
134 193
135 194
136 195 @router.post(
137 196 "/accept-invite", summary="Accept an invitation (set your password)",
138 197 description=("Invited users (created by an admin or the CLI) choose their password here. The account "
139 198 "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)."),
199 + f"already created for them — visible in the dashboard ({settings.public_url}/dashboard/keys). "
200 + "Throttled: 10 per hour per IP." + THROTTLE_NOTE),
141 201 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)])
202 + openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]},
203 + dependencies=[Depends(csrf)])
143 204 def accept_invite(body: TokenPasswordBody, s: Session = Depends(get_session)):
144 205 user = service.accept_invite(s, body.token, body.password)
145 206 return _session_response(user)
modified hfmarketdata/api/accounts/routes_me.py +221 −37
@@ -1,12 +1,15 @@
1 −"""`/v1/me` — profile, API keys, usage — and the public `/v1/limits`.
1 +"""`/v1/me` — profile, API keys, usage, live quotas, account security — and the public `/v1/limits`.
2 2
3 3 Authentication: session cookie (web dashboard) or `Authorization: Bearer hfmd_live_…` (programmatic).
4 +An API key is **read-only** here: it may call `GET /v1/me`, `GET /v1/me/usage`, `GET /v1/me/usage.csv` and
5 +`GET /v1/me/limits`; every other endpoint requires a browser session (`403 SESSION_REQUIRED`).
4 6 These endpoints are NOT charged against the data quota.
5 7
6 8 Author: Simon-Pierre Boucher <contact@spboucher.ai>
7 9 """
8 10 from __future__ import annotations
9 11
12 +from datetime import datetime
10 13 from typing import Literal
11 14
12 15 from fastapi import APIRouter, Depends, Query, Request
@@ -15,27 +18,64 @@ from sqlalchemy.orm import Session
15 18
16 19 from core.config import settings
17 20 from core.db import get_session
21 +from core.errors import ApiError
18 22 from core.responses import json_response
23 +from fastapi.responses import PlainTextResponse
19 24 from ratelimit import redis_limiter as rl
20 25 from ratelimit import usage
26 +from ratelimit.middleware import Principal
21 27 from ratelimit.middleware import snapshot as rl_snapshot
22 28 from ratelimit.tiers import TIERS, tier_for, tiers_public, upgrade_hint
23 29
24 −from . import service
25 −from .deps import csrf, current_user
30 +from . import security, service
31 +from .deps import csrf, current_user, require_session
26 32 from .models import User
27 33
28 34 router = APIRouter(prefix="/v1/me", tags=["me"])
29 35 limits_router = APIRouter(prefix="/v1", tags=["meta"])
30 36
31 37 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"}
38 + "created_at": "2026-09-04T14:06:02Z", "last_used_at": "2026-09-04T15:31:09Z", "last_used_ip": "3f9a1c0b7d2e4f61",
39 + "revoked_at": None, "expires_at": None, "expired": False, "note": None, "scopes": ["data"], "principal": "key:12"}
34 40 LIMITS_EXAMPLE = {"tier": "free", "window_seconds": 60, "requests": 120, "rows": 1_000_000, "max_rows_per_request": 50_000}
41 +USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free",
42 + "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z",
43 + "last_login_at": "2026-09-04T14:05:40Z", "quota_alerts": True, "locked_until": None, "keys_active": 1}
44 +POINT_EXAMPLE = {"t": "2026-09-04T15:30:00Z", "requests": 12, "rows": 60_000, "status_429": 0, "bytes": 1_843_200, "rows_parquet": 0}
45 +SESSION_ERRORS = ["AUTH_REQUIRED", "SESSION_REQUIRED", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]
46 +SESSION_NOTE = "\n\nRequires a **browser session** (cookie) — refused with `403 SESSION_REQUIRED` when called with an API key."
35 47
36 48
37 49 class KeyCreateBody(BaseModel):
38 50 name: str = Field("default", max_length=100, examples=["backtest-laptop"])
51 + note: str | None = Field(None, max_length=500, description="Free text shown in the dashboard (where the key lives, what for).")
52 + expires_in_days: int | None = Field(None, ge=1, le=service.MAX_KEY_EXPIRY_DAYS, description="Optional lifetime; the key stops working afterwards.")
53 + expires_at: datetime | None = Field(None, description="Alternative to `expires_in_days`: absolute UTC instant (≤ 365 days ahead).")
54 + scopes: list[str] | None = Field(None, description='Reserved. Only `["data"]` is accepted today (default).')
55 +
56 +
57 +class KeyUpdateBody(BaseModel):
58 + name: str | None = Field(None, max_length=100)
59 + note: str | None = Field(None, max_length=500)
60 +
61 +
62 +class PasswordChangeBody(BaseModel):
63 + current_password: str = Field(..., max_length=256)
64 + new_password: str = Field(..., min_length=1, max_length=256, description="At least 10 characters.")
65 +
66 +
67 +class EmailChangeBody(BaseModel):
68 + new_email: str = Field(..., max_length=254, examples=["ada.lovelace@example.com"])
69 + password: str = Field(..., max_length=256, description="Current password (re-authentication).")
70 +
71 +
72 +class DeleteBody(BaseModel):
73 + password: str = Field(..., max_length=256, description="Current password (re-authentication).")
74 +
75 +
76 +class ProfileBody(BaseModel):
77 + name: str | None = Field(None, max_length=200)
78 + quota_alerts: bool | None = Field(None, description="E-mail at 80 % / 100 % of the rows quota or first 429 of the day (max 1 mail/day).")
39 79
40 80
41 81 def _limits_for(u: User) -> dict:
@@ -44,86 +84,231 @@ def _limits_for(u: User) -> dict:
44 84 "max_rows_per_request": t.max_rows_per_request, "upgrade": upgrade_hint(t)}
45 85
46 86
87 +def _session_refresh(resp, u: User):
88 + """After a session_version bump the caller's own cookie must be re-issued or it would be signed out too."""
89 + security.set_session_cookie(resp, u.id, u.session_version or 1)
90 + return resp
91 +
92 +
93 +def _principals(s: Session, u: User, key_id: int | None) -> list[str]:
94 + if key_id is not None:
95 + return [service.get_key(s, u, key_id).principal]
96 + return [k.principal for k in service.list_keys(s, u)]
97 +
98 +
99 +# ------------------------------------------------------------------------------------------------ profile
100 +
47 101 @router.get(
48 102 "", 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.",
103 + description="Profile of the signed-in user (cookie session or API key), the tier limits that apply to their keys, "
104 + "the number of active keys and `auth` = how this call was authenticated (`session` | `key`).",
51 105 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}}}}}},
106 + "user": USER_EXAMPLE, "limits": LIMITS_EXAMPLE, "auth": "session"}, "meta": {"count": 1}}}}}},
56 107 openapi_extra={"x-errors": ["AUTH_REQUIRED", "ACCOUNT_DISABLED"]})
57 −def me(u: User = Depends(current_user), s: Session = Depends(get_session)):
108 +def me(request: Request, u: User = Depends(current_user), s: Session = Depends(get_session)):
58 109 return json_response({"user": service.user_public(u, keys_count=len(service.active_keys(s, u))),
59 − "limits": _limits_for(u)})
110 + "limits": _limits_for(u), "auth": getattr(request.state, "auth_kind", None)})
111 +
112 +
113 +@router.patch(
114 + "", summary="Update your profile (name, quota alerts)",
115 + description="Change your display name and/or the quota-alert e-mails opt-in." + SESSION_NOTE,
116 + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "quota_alerts": False}, "meta": {"count": 1}}}}}},
117 + openapi_extra={"x-errors": SESSION_ERRORS}, dependencies=[Depends(csrf)])
118 +def update_profile(body: ProfileBody, u: User = Depends(require_session), s: Session = Depends(get_session)):
119 + if body.name is not None:
120 + u.name = body.name.strip()[:200]
121 + service.audit(s, f"user:{u.id}", "account.name_change", f"user:{u.id}")
122 + if body.quota_alerts is not None:
123 + service.set_quota_alerts(s, u, body.quota_alerts)
124 + return json_response(service.user_public(u, keys_count=len(service.active_keys(s, u))))
125 +
126 +
127 +@router.post(
128 + "/password", summary="Change your password",
129 + description="Requires the current password. Every **other** session is signed out (this browser stays signed in) "
130 + "and a notice is e-mailed." + SESSION_NOTE,
131 + responses={200: {"content": {"application/json": {"example": {"data": {"status": "password_changed"}, "meta": {"count": 1}}}}}},
132 + openapi_extra={"x-errors": SESSION_ERRORS + ["INVALID_CREDENTIALS", "WEAK_PASSWORD"]}, dependencies=[Depends(csrf)])
133 +def change_password(body: PasswordChangeBody, u: User = Depends(require_session), s: Session = Depends(get_session)):
134 + service.change_password(s, u, body.current_password, body.new_password)
135 + return _session_refresh(json_response({"status": "password_changed"}), u)
136 +
137 +
138 +@router.post(
139 + "/email", summary="Change your e-mail address (confirmation link sent to the new address)",
140 + description=("Requires the current password. A confirmation link (valid 48 hours) is e-mailed to the **new** "
141 + "address; the change only becomes effective when it is opened (`POST /v1/auth/verify`), and every "
142 + "session is then signed out. The answer is the same whether or not the new address is already used by "
143 + "another account (that account receives a notice instead)." + SESSION_NOTE),
144 + status_code=202,
145 + responses={202: {"content": {"application/json": {"example": {"data": {"status": "confirmation_sent", "new_email": "ada.lovelace@example.com"}, "meta": {"count": 1}}}}}},
146 + openapi_extra={"x-errors": SESSION_ERRORS + ["INVALID_CREDENTIALS", "INVALID_PARAMETER"]}, dependencies=[Depends(csrf)])
147 +def change_email(body: EmailChangeBody, u: User = Depends(require_session), s: Session = Depends(get_session)):
148 + issued = service.request_email_change(s, u, body.new_email, body.password)
149 + payload = {"status": "confirmation_sent", "new_email": service.normalize_email(body.new_email)}
150 + from .deps import debug_links_enabled
151 + if issued is not None and debug_links_enabled():
152 + payload["debug_link"] = issued.link
153 + return json_response(payload, status=202)
154 +
155 +
156 +@router.post(
157 + "/sessions/revoke-all", summary="Sign out everywhere",
158 + description="Invalidates every session cookie of the account (including other browsers and devices). This browser "
159 + "receives a fresh cookie and stays signed in. Body `{}`." + SESSION_NOTE,
160 + responses={200: {"content": {"application/json": {"example": {"data": {"status": "sessions_revoked"}, "meta": {"count": 1}}}}}},
161 + openapi_extra={"x-errors": SESSION_ERRORS}, dependencies=[Depends(csrf)])
162 +def revoke_all_sessions(u: User = Depends(require_session), s: Session = Depends(get_session)):
163 + service.revoke_all_sessions(s, u)
164 + return _session_refresh(json_response({"status": "sessions_revoked"}), u)
60 165
61 166
167 +@router.delete(
168 + "", summary="Delete your account",
169 + description=("Requires the current password. Revokes every API key, signs out every session, voids pending links and "
170 + "anonymises the account (e-mail and name removed, status `deleted`). Aggregated usage counters are kept "
171 + "anonymously. The last active administrator cannot delete their account." + SESSION_NOTE),
172 + responses={200: {"content": {"application/json": {"example": {"data": {"status": "deleted"}, "meta": {"count": 1}}}}}},
173 + openapi_extra={"x-errors": SESSION_ERRORS + ["INVALID_CREDENTIALS", "LAST_ADMIN"]}, dependencies=[Depends(csrf)])
174 +def delete_me(body: DeleteBody, u: User = Depends(require_session), s: Session = Depends(get_session)):
175 + if not security.verify_password(body.password, u.password_hash):
176 + raise ApiError(401, "INVALID_CREDENTIALS", "The current password is wrong.")
177 + service.delete_account(s, u, actor=f"user:{u.id}")
178 + resp = json_response({"status": "deleted"})
179 + security.clear_session_cookie(resp)
180 + return resp
181 +
182 +
183 +# --------------------------------------------------------------------------------------------------- keys
184 +
62 185 @router.get(
63 186 "/keys", summary="List your API keys",
64 − description="Active and revoked keys. Only the display prefix is stored — the full key is never retrievable.",
187 + description="Active and revoked keys with creation / last use (time + hashed IP) / expiry. Only the display prefix is "
188 + "stored — the full key is never retrievable." + SESSION_NOTE,
65 189 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)):
190 + openapi_extra={"x-errors": ["AUTH_REQUIRED", "SESSION_REQUIRED"]})
191 +def list_keys(u: User = Depends(require_session), s: Session = Depends(get_session)):
68 192 return json_response([service.key_public(k) for k in service.list_keys(s, u)])
69 193
70 194
71 195 @router.post(
72 196 "/keys", summary="Create an API key (shown once)", status_code=201,
73 197 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."),
198 + f"response** — store it now. Up to {service.MAX_ACTIVE_KEYS} active keys per account. Optional `note`, "
199 + "`expires_in_days` / `expires_at` (the key stops working afterwards) and `scopes` (reserved, `[\"data\"]`). "
200 + "A notice (without the key) is e-mailed to you." + SESSION_NOTE),
76 201 responses={201: {"content": {"application/json": {"example": {"data": {
77 202 **KEY_EXAMPLE, "key": "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}},
78 − openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]},
203 + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_LIMIT_REACHED", "INVALID_PARAMETER"]},
79 204 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}")
205 +def create_key(body: KeyCreateBody, u: User = Depends(require_session), s: Session = Depends(get_session)):
206 + expires = service.validate_expiry(body.expires_at, body.expires_in_days)
207 + raw, k = service.create_key(s, u, body.name, actor=f"user:{u.id}", expires_at=expires, note=body.note, scopes=body.scopes)
82 208 return json_response({**service.key_public(k), "key": raw}, status=201)
83 209
84 210
211 +@router.patch(
212 + "/keys/{key_id}", summary="Rename a key / edit its note",
213 + responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "note": "CI runner"}, "meta": {"count": 1}}}}}},
214 + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_NOT_FOUND"]}, dependencies=[Depends(csrf)])
215 +def update_key(key_id: int, body: KeyUpdateBody, u: User = Depends(require_session), s: Session = Depends(get_session)):
216 + k = service.get_key(s, u, key_id)
217 + if body.name is not None:
218 + k.name = body.name.strip()[:100] or k.name
219 + if body.note is not None:
220 + k.note = body.note.strip()[:500] or None
221 + service.audit(s, f"user:{u.id}", "key.update", f"key:{k.id}")
222 + return json_response(service.key_public(k))
223 +
224 +
85 225 @router.delete(
86 226 "/keys/{key_id}", summary="Revoke an API key",
87 − description="Revocation is immediate (the lookup cache expires within 60 s on every worker). Idempotent.",
227 + description="Revocation is immediate on every worker (shared cache generation). Idempotent. A notice is e-mailed." + SESSION_NOTE,
88 228 responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "status": "revoked",
89 229 "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)):
230 + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_NOT_FOUND"]}, dependencies=[Depends(csrf)])
231 +def revoke_key(key_id: int, u: User = Depends(require_session), s: Session = Depends(get_session)):
92 232 k = service.revoke_key(s, service.get_key(s, u, key_id), actor=f"user:{u.id}")
93 233 return json_response(service.key_public(k))
94 234
95 235
96 236 @router.post(
97 237 "/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.",
238 + description="Revokes the key and creates a new one with the same name, note, scopes and expiry. The new key is shown "
239 + "once. A notice is e-mailed. Send `{}` as body." + SESSION_NOTE,
99 240 responses={201: {"content": {"application/json": {"example": {"data": {
100 241 **KEY_EXAMPLE, "id": 13, "prefix": "hfmd_live_zz98yy76", "key": "hfmd_live_zz98yy76EXAMPLEKEYnotARealOne00",
101 242 "rotated_from": 12}, "meta": {"count": 1}}}}}},
102 − openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]},
243 + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_NOT_FOUND", "KEY_LIMIT_REACHED"]},
103 244 dependencies=[Depends(csrf)])
104 −def rotate_key(key_id: int, u: User = Depends(current_user), s: Session = Depends(get_session)):
245 +def rotate_key(key_id: int, u: User = Depends(require_session), s: Session = Depends(get_session)):
105 246 old = service.get_key(s, u, key_id)
106 247 raw, k = service.rotate_key(s, u, old, actor=f"user:{u.id}")
107 248 return json_response({**service.key_public(k), "key": raw, "rotated_from": old.id}, status=201)
108 249
109 250
251 +# -------------------------------------------------------------------------------------------------- usage
252 +
110 253 @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."),
254 + "/usage", summary="Your usage series (requests, rows, 429s)",
255 + description=("Aggregated over all your keys (active and revoked) or one key (`key_id`). `24h` → per minute, `7d` → "
256 + "per hour, `30d` → per day. Each point: `requests`, `rows`, `status_429`, `bytes`, `rows_parquet`. Points "
257 + "are UTC ISO 8601; the live (unfolded) minute is included. Works with an API key."),
114 258 responses={200: {"content": {"application/json": {"example": {"data": {
115 259 "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)]
260 + "points": [POINT_EXAMPLE], "totals": {"requests": 1_240, "rows": 3_100_000, "status_429": 2, "bytes": 90_000_000, "rows_parquet": 0},
261 + "principals": ["key:12"], "key_id": None}, "meta": {"count": 1}}}}}},
262 + openapi_extra={"x-errors": ["AUTH_REQUIRED", "INVALID_PARAMETER", "KEY_NOT_FOUND"]})
263 +def my_usage(range: Literal["24h", "7d", "30d"] = Query("24h"), key_id: int | None = Query(None, description="Restrict to one key"),
264 + u: User = Depends(current_user), s: Session = Depends(get_session)):
265 + principals = _principals(s, u, key_id)
122 266 series = usage.usage_series(principals, range)
123 267 series["principals"] = principals
268 + series["key_id"] = key_id
124 269 return json_response(series)
125 270
126 271
272 +@router.get(
273 + "/usage.csv", summary="Your usage series as CSV",
274 + description="Same data as `GET /v1/me/usage`, as `text/csv` (`t,requests,rows,status_429,bytes,rows_parquet`, one line per "
275 + "point, UTC). `X-Row-Count` carries the number of points. Works with an API key.",
276 + responses={200: {"content": {"text/csv": {"example": "t,requests,rows,status_429,bytes,rows_parquet\n2026-09-04T00:00:00Z,120,55000,0,1600000,0\n"}}}},
277 + openapi_extra={"x-errors": ["AUTH_REQUIRED", "INVALID_PARAMETER", "KEY_NOT_FOUND"]})
278 +def my_usage_csv(range: Literal["24h", "7d", "30d"] = Query("30d"), key_id: int | None = Query(None),
279 + u: User = Depends(current_user), s: Session = Depends(get_session)):
280 + series = usage.usage_series(_principals(s, u, key_id), range)
281 + body = usage.usage_csv(series)
282 + return PlainTextResponse(body, media_type="text/csv", headers={
283 + "X-Row-Count": str(len(series["points"])),
284 + "Content-Disposition": f'attachment; filename="hfmd-usage-{range}{f"-key{key_id}" if key_id else ""}.csv"'})
285 +
286 +
287 +@router.get(
288 + "/limits", summary="Live quota of your keys (remaining requests / rows in the current window)",
289 + description=("For each active key (or the one given by `key_id`): the tier that applies (`tier_override` or the "
290 + "account tier), the window and the **remaining** requests and rows right now with the reset instant "
291 + "(unix seconds). `redis: false` means the limiter is unavailable and counters are unknown. Not charged; "
292 + "works with an API key."),
293 + responses={200: {"content": {"application/json": {"example": {"data": {
294 + "tier": LIMITS_EXAMPLE, "keys": [{"key_id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "principal": "key:12",
295 + "kind": "key", "tier": "free", "window_seconds": 60, "max_rows_per_request": 50_000,
296 + "requests": {"limit": 120, "remaining": 118, "reset": 1788012345},
297 + "rows": {"limit": 1_000_000, "remaining": 940_000, "reset": 1788012345}, "redis": True}]},
298 + "meta": {"count": 1}}}}}},
299 + openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND"]})
300 +def my_limits(key_id: int | None = Query(None), u: User = Depends(current_user), s: Session = Depends(get_session)):
301 + keys = [service.get_key(s, u, key_id)] if key_id is not None else service.active_keys(s, u)
302 + out = []
303 + for k in keys:
304 + tier = tier_for(k.tier_override or u.tier)
305 + snap = rl_snapshot(rl.peek_tier(k.principal, tier), tier, Principal(k.principal, "key", tier, u.id, k.id))
306 + out.append({"key_id": k.id, "name": k.name, "prefix": k.prefix, "status": k.status, "expires_at": service.iso(k.expires_at), **snap})
307 + return json_response({"tier": _limits_for(u), "keys": out})
308 +
309 +
310 +# ------------------------------------------------------------------------------------------- public limits
311 +
127 312 @limits_router.get(
128 313 "/limits", summary="Tier table + your current counters (works without a key)",
129 314 description=("Public. Returns the three tiers (keyless · free · high_usage) and, for the principal making the "
@@ -150,7 +335,6 @@ def limits(request: Request):
150 335 principal = getattr(request.state, "principal", None)
151 336 tier = TIERS.get(getattr(request.state, "tier", "keyless"), TIERS["keyless"])
152 337 d = rl.peek_tier(principal, tier) if principal else None
153 − from ratelimit.middleware import Principal
154 338 snap = rl_snapshot(d, tier, Principal(principal or "ip:unknown", getattr(request.state, "principal_kind", "keyless"), tier))
155 339 tier = TIERS.get(snap["tier"], TIERS["keyless"])
156 340 return json_response({"tiers": tiers_public(), "principal": snap,
157 341