SPB Git forge

spb/hfmarketdata

Public

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

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

accounts: modèles — colonnes de sécurité (session_version, verrouillage, alertes quota, suppression douce, expiration/note/scopes des clés, usage_minute étendu) + migration idempotente ALTER TABLE ; codes ACCOUNT_LOCKED / SESSION_REQUIRED / LAST_ADMIN ; HFMD_TRUSTED_PROXY_HOPS

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

4 changed files +80 −9

modified hfmarketdata/api/accounts/models.py +73 −8
@@ -1,21 +1,30 @@
1 1 """SQLAlchemy models — UPGRADE-PLAN §1.4 (users · email_tokens · api_keys · usage_daily · usage_minute · audit_log).
2 2
3 +`create_all()` creates missing tables; `migrate()` adds the columns introduced after the first deployment
4 +with idempotent `ALTER TABLE … ADD COLUMN` statements (SQLite has no `IF NOT EXISTS` for columns, so the
5 +current schema is read with `PRAGMA table_info`). Both run at import time.
6 +
3 7 Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 8 """
5 9 from __future__ import annotations
6 10
11 +import logging
7 12 from datetime import date, datetime, timezone
8 13
9 −from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, Text
14 +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, Text, text
10 15 from sqlalchemy.orm import Mapped, mapped_column, relationship
11 16
12 −from core.db import Base, create_all
17 +from core.db import Base, create_all, engine
18 +
19 +log = logging.getLogger("hfmarketdata.accounts")
13 20
14 21 ROLES = ("user", "admin")
15 22 TIERS = ("free", "high_usage", "unlimited")
16 −USER_STATUSES = ("invited", "active", "disabled")
23 +USER_STATUSES = ("invited", "active", "disabled", "deleted")
17 24 KEY_STATUSES = ("active", "revoked")
18 −TOKEN_KINDS = ("verify", "reset", "invite")
25 +TOKEN_KINDS = ("verify", "reset", "invite", "email_change")
26 +KEY_SCOPES = ("data",) # reserved for the future; every key has ["data"] today
27 +DEFAULT_SCOPES = '["data"]'
19 28
20 29
21 30 def utcnow() -> datetime:
@@ -31,10 +40,16 @@ class User(Base):
31 40 password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # argon2; NULL until invite accepted
32 41 email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
33 42 role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # user | admin
34 − tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free") # free | high_usage
35 − status: Mapped[str] = mapped_column(String(16), nullable=False, default="invited") # invited | active | disabled
43 + tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free") # free | high_usage | unlimited
44 + status: Mapped[str] = mapped_column(String(16), nullable=False, default="invited") # invited | active | disabled | deleted
36 45 created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
37 46 last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
47 + # --- added 2026-09 (see migrate()) ---
48 + failed_logins: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
49 + locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
50 + session_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") # bumped to revoke every cookie
51 + quota_alerts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") # 1 = e-mail at 80 % / 100 % / first 429
52 + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
38 53
39 54 keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
40 55 tokens: Mapped[list[EmailToken]] = relationship(back_populates="user", cascade="all, delete-orphan")
@@ -49,11 +64,12 @@ class EmailToken(Base):
49 64
50 65 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
51 66 user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
52 − kind: Mapped[str] = mapped_column(String(16), nullable=False) # verify | reset | invite
67 + kind: Mapped[str] = mapped_column(String(16), nullable=False) # verify | reset | invite | email_change
53 68 token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
54 69 expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
55 70 used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
56 − # JSON: {"link": "…"} when the e-mail could not be delivered (no Resend key) so admins/CLI can print it
71 + # JSON: {"link": "…"} when the e-mail could not be delivered (no Resend key) so admins/CLI can print it;
72 + # {"new_email": "…"} for email_change tokens
57 73 meta: Mapped[str | None] = mapped_column(Text, nullable=True)
58 74
59 75 user: Mapped[User] = relationship(back_populates="tokens")
@@ -72,6 +88,11 @@ class ApiKey(Base):
72 88 created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
73 89 last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
74 90 revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
91 + # --- added 2026-09 (see migrate()) ---
92 + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # NULL = never
93 + last_used_ip: Mapped[str | None] = mapped_column(String(16), nullable=True) # hashed (same formula as ip:<hash>)
94 + note: Mapped[str | None] = mapped_column(String(500), nullable=True)
95 + scopes: Mapped[str] = mapped_column(String(200), nullable=False, default=DEFAULT_SCOPES, server_default=DEFAULT_SCOPES) # JSON list
75 96
76 97 user: Mapped[User] = relationship(back_populates="keys")
77 98
@@ -79,6 +100,9 @@ class ApiKey(Base):
79 100 def principal(self) -> str:
80 101 return f"key:{self.id}"
81 102
103 + def is_expired(self, at: datetime | None = None) -> bool:
104 + return self.expires_at is not None and self.expires_at <= (at or utcnow())
105 +
82 106
83 107 class UsageDaily(Base):
84 108 __tablename__ = "usage_daily"
@@ -100,6 +124,10 @@ class UsageMinute(Base):
100 124 principal: Mapped[str] = mapped_column(String(64), primary_key=True)
101 125 requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
102 126 rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
127 + # --- added 2026-09 (see migrate()) — 0 for minutes folded before the upgrade ---
128 + rows_parquet: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
129 + bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
130 + status_429: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
103 131
104 132
105 133 class AuditLog(Base):
@@ -115,5 +143,42 @@ class AuditLog(Base):
115 143
116 144 Index("ix_usage_daily_principal", UsageDaily.principal)
117 145 Index("ix_usage_minute_principal", UsageMinute.principal)
146 +Index("ix_audit_log_actor", AuditLog.actor)
147 +Index("ix_audit_log_target", AuditLog.target)
148 +
149 +# (table, column, SQL type + default) — every entry must be safe to re-run: the column is only added when absent.
150 +MIGRATIONS: tuple[tuple[str, str, str], ...] = (
151 + ("users", "failed_logins", "INTEGER NOT NULL DEFAULT 0"),
152 + ("users", "locked_until", "DATETIME"),
153 + ("users", "session_version", "INTEGER NOT NULL DEFAULT 1"),
154 + ("users", "quota_alerts", "INTEGER NOT NULL DEFAULT 1"),
155 + ("users", "deleted_at", "DATETIME"),
156 + ("api_keys", "expires_at", "DATETIME"),
157 + ("api_keys", "last_used_ip", "VARCHAR(16)"),
158 + ("api_keys", "note", "VARCHAR(500)"),
159 + ("api_keys", "scopes", f"VARCHAR(200) NOT NULL DEFAULT '{DEFAULT_SCOPES}'"),
160 + ("usage_minute", "rows_parquet", "INTEGER NOT NULL DEFAULT 0"),
161 + ("usage_minute", "bytes", "INTEGER NOT NULL DEFAULT 0"),
162 + ("usage_minute", "status_429", "INTEGER NOT NULL DEFAULT 0"),
163 +)
164 +
165 +
166 +def migrate() -> list[str]:
167 + """Add the columns listed in MIGRATIONS when they are missing. Idempotent; returns the columns added."""
168 + added: list[str] = []
169 + with engine.begin() as con:
170 + for table, column, ddl in MIGRATIONS:
171 + existing = {row[1] for row in con.execute(text(f"PRAGMA table_info({table})")).fetchall()}
172 + if not existing or column in existing:
173 + continue
174 + con.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
175 + added.append(f"{table}.{column}")
176 + for name, table, column in (("ix_audit_log_actor", "audit_log", "actor"), ("ix_audit_log_target", "audit_log", "target")):
177 + con.execute(text(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({column})"))
178 + if added:
179 + log.info("accounts schema migrated: added %s", ", ".join(added))
180 + return added
181 +
118 182
119 183 create_all()
184 +migrate()
modified hfmarketdata/api/core/config.py +2 −0
@@ -27,6 +27,8 @@ class Settings:
27 27 # rate limiting / sessions
28 28 redis_url: str = field(default_factory=lambda: _env("HFMD_REDIS_URL", "redis://127.0.0.1:6379/0"))
29 29 ratelimit_enabled: bool = field(default_factory=lambda: _env("HFMD_RATELIMIT", "1") not in ("0", "false", "no"))
30 + # number of trusted reverse proxies in front of the API (ngrok = 1): the client IP is the N-th hop from the END of X-Forwarded-For
31 + trusted_proxy_hops: int = field(default_factory=lambda: max(0, int(_env("HFMD_TRUSTED_PROXY_HOPS", "1"))))
30 32 # secrets
31 33 secret_key: str = field(default_factory=lambda: _env("HFMD_SECRET_KEY", "dev-only-change-me"))
32 34 key_hash_salt: str = field(default_factory=lambda: _env("HFMD_KEY_SALT", "hfmd-key-salt-dev"))
modified hfmarketdata/api/core/errors.py +3 −0
@@ -51,6 +51,9 @@ CODES: dict[str, str] = {
51 51 "KEY_NOT_FOUND": "No API key with this id on your account.",
52 52 "KEY_LIMIT_REACHED": "Maximum number of active API keys reached; revoke one first.",
53 53 "UNSUPPORTED_MEDIA_TYPE": "State-changing requests must send `Content-Type: application/json`.",
54 + "ACCOUNT_LOCKED": "Too many failed sign-in attempts; the account is temporarily locked (see `Retry-After`).",
55 + "SESSION_REQUIRED": "This action requires a signed-in browser session (cookie), not an API key.",
56 + "LAST_ADMIN": "The last active administrator cannot be demoted, disabled or deleted.",
54 57 # fundamentals / stream / bulk
55 58 "FUNDAMENTALS_NOT_AVAILABLE": "No SEC EDGAR fundamentals for this ticker (not an SEC filer, not yet ingested, or no data for the requested period).",
56 59 "INVALID_FILTER": "A screener filter expression is malformed or references an unknown field.",
modified hfmarketdata/api/openapi.py +2 −1
@@ -59,7 +59,8 @@ STATUS_FOR = {"INVALID_PARAMETER": 400, "VALIDATION_ERROR": 422, "NOT_FOUND": 40
59 59 "SERVICE_UNAVAILABLE": 503,
60 60 "EMAIL_TAKEN": 409, "INVALID_TOKEN": 400, "INVALID_CREDENTIALS": 401, "EMAIL_NOT_VERIFIED": 403,
61 61 "ACCOUNT_DISABLED": 403, "WEAK_PASSWORD": 400, "USER_NOT_FOUND": 404, "KEY_NOT_FOUND": 404,
62 − "KEY_LIMIT_REACHED": 409, "UNSUPPORTED_MEDIA_TYPE": 415,
62 + "KEY_LIMIT_REACHED": 409, "UNSUPPORTED_MEDIA_TYPE": 415, "ACCOUNT_LOCKED": 423, "SESSION_REQUIRED": 403,
63 + "LAST_ADMIN": 409,
63 64 "FUNDAMENTALS_NOT_AVAILABLE": 404, "INVALID_FILTER": 400,
64 65 "CONCEPT_NOT_FOUND": 404, "STREAM_CONNECTION_LIMIT": 429}
65 66
66 67