| 20 | value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client |
20 | value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never the first one, which the client |
| 21 | controls. The header is ignored altogether when the direct peer is not a loopback / private address. |
21 | controls. The header is ignored altogether when the direct peer is not a loopback / private address. |
| 22 | |
22 | |
| 23 | −Account endpoints (`/v1/limits`, `/v1/me/*`, `/v1/admin/*`) are not charged. `/health`, `/openapi.json` |
23 | +A signed-in browser (session cookie, no Bearer) is the `user:<id>` principal with the account's tier — the |
| |
24 | +site (charts, playground) gets account limits without pasting a key. Account endpoints (`/v1/limits`, `/v1/me/*`, |
| |
25 | +`/v1/admin/*`) are not charged. `/health`, `/openapi.json` |
| 24 | and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of |
26 | and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of |
| 25 | quotas. |
27 | quotas. |
| 26 | |
28 | |
| 77 | |
79 | |
| 78 | @dataclass(frozen=True) |
80 | @dataclass(frozen=True) |
| 79 | class Principal: |
81 | class Principal: |
| 80 | − id: str # key:<id> | ip:<hash> |
82 | + id: str # key:<id> | user:<id> | ip:<hash> |
| 81 | − kind: str # "key" | "keyless" |
83 | + kind: str # "key" | "session" | "keyless" |
| 82 | tier: Tier |
84 | tier: Tier |
| 83 | user_id: int | None = None |
85 | user_id: int | None = None |
| 84 | api_key_id: int | None = None |
86 | api_key_id: int | None = None |
| 191 | return info |
193 | return info |
| 192 | |
194 | |
| 193 | |
195 | |
| |
196 | +def _lookup_session_user(uid: int, version: int) -> tuple[str, str] | None: |
| |
197 | + """(tier, status) of a signed-in user whose cookie carries `version` — None when unknown or stale.""" |
| |
198 | + from sqlalchemy import select |
| |
199 | + |
| |
200 | + from accounts.models import User |
| |
201 | + from core.db import session |
| |
202 | + with session() as s: |
| |
203 | + row = s.execute(select(User.tier, User.status, User.session_version).where(User.id == uid)).first() |
| |
204 | + if row is None or int(row[2] or 1) != int(version): |
| |
205 | + return None |
| |
206 | + return row[0], row[1] |
| |
207 | + |
| |
208 | + |
| |
209 | +def resolve_session(scope: dict) -> Principal | None: |
| |
210 | + """Browser session (HttpOnly cookie) → the account's tier on data endpoints, principal `user:<id>`. |
| |
211 | + Cached like keys (same LRU + Redis generation, so tier/status/version changes apply within 60 s).""" |
| |
212 | + try: |
| |
213 | + from starlette.requests import Request |
| |
214 | + |
| |
215 | + from accounts import security |
| |
216 | + claims = security.read_session(Request(scope)) |
| |
217 | + except Exception: |
| |
218 | + return None |
| |
219 | + if claims is None: |
| |
220 | + return None |
| |
221 | + cache_key = f"sess:{claims.user_id}:{claims.version}" |
| |
222 | + gen = rl.keys_version() |
| |
223 | + hit, info = _key_cache.get(cache_key, gen) |
| |
224 | + if not hit: |
| |
225 | + info = _lookup_session_user(claims.user_id, claims.version) |
| |
226 | + _key_cache.put(cache_key, info, gen) |
| |
227 | + if info is None: |
| |
228 | + return None |
| |
229 | + tier_name, status = info |
| |
230 | + if status in ("disabled", "deleted"): |
| |
231 | + return None # anonymous, not an error: the browser keeps keyless access |
| |
232 | + return Principal(f"user:{claims.user_id}", "session", tier_for(tier_name), claims.user_id, None) |
| |
233 | + |
| |
234 | + |
| 194 | def extract_key(headers: Headers, query: QueryParams) -> str | None: |
235 | def extract_key(headers: Headers, query: QueryParams) -> str | None: |
| 195 | auth = headers.get("authorization", "") |
236 | auth = headers.get("authorization", "") |
| 196 | if auth[:7].lower() == "bearer ": |
237 | if auth[:7].lower() == "bearer ": |
| 242 | raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. " |
283 | raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. " |
| 243 | f"Contact {settings.contact_email}.") |
284 | f"Contact {settings.contact_email}.") |
| 244 | return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id) |
285 | return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id) |
| |
286 | + sess = resolve_session(scope) |
| |
287 | + if sess is not None: |
| |
288 | + return sess |
| 245 | return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) |
289 | return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) |
| 246 | |
290 | |
| 247 | |
291 | |
| 367 | except ApiError as exc: |
411 | except ApiError as exc: |
| 368 | return await exc.response()(scope, receive, send) |
412 | return await exc.response()(scope, receive, send) |
| 369 | tier = principal.tier |
413 | tier = principal.tier |
| |
414 | + # `user_id` is the API-key credential consumed by accounts.deps (Bearer only): a session principal must NOT |
| |
415 | + # populate it, otherwise a stale cookie (version bumped) would authenticate through the key fallback. |
| 370 | state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, |
416 | state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, |
| 371 | − max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id) |
417 | + max_rows=tier.max_rows_per_request, api_key_id=principal.api_key_id, |
| |
418 | + user_id=principal.user_id if principal.kind == "key" else None) |
| 372 | |
419 | |
| 373 | if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
420 | if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
| 374 | return await _auth_required_error().response()(scope, receive, send) |
421 | return await _auth_required_error().response()(scope, receive, send) |
| 461 | return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]}) |
508 | return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]}) |
| 462 | tier = principal.tier |
509 | tier = principal.tier |
| 463 | state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, |
510 | state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, |
| 464 | − max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id) |
511 | + max_rows=tier.max_rows_per_request, api_key_id=principal.api_key_id, |
| |
512 | + user_id=principal.user_id if principal.kind == "key" else None) |
| 465 | if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
513 | if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): |
| 466 | return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"}) |
514 | return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"}) |
| 467 | decision = rl.apply_tier(principal.id, tier, req_cost=1) |
515 | decision = rl.apply_tier(principal.id, tier, req_cost=1) |
| 473 | |
521 | |
| 474 | |
522 | |
| 475 | def _tier_for_principal(principal: str): |
523 | def _tier_for_principal(principal: str): |
| 476 | − """Tier of a `key:<id>` principal (SQLite lookup, 60 s cache) — keyless principals get the keyless tier.""" |
524 | + """Tier of a `key:<id>` / `user:<id>` principal (SQLite lookup, 60 s cache) — keyless principals get the keyless tier.""" |
| 477 | from . import tiers as _tiers |
525 | from . import tiers as _tiers |
| |
526 | + if principal.startswith("user:"): |
| |
527 | + gen = rl.keys_version() |
| |
528 | + hit, tier = _key_cache.get("tier:" + principal, gen) |
| |
529 | + if hit: |
| |
530 | + return tier |
| |
531 | + tier_name = "free" |
| |
532 | + try: |
| |
533 | + from sqlalchemy import select |
| |
534 | + from accounts.models import User |
| |
535 | + from core.db import session |
| |
536 | + with session() as s: |
| |
537 | + row = s.execute(select(User.tier).where(User.id == int(principal[5:]))).first() |
| |
538 | + if row and row[0]: |
| |
539 | + tier_name = row[0] |
| |
540 | + except Exception: |
| |
541 | + pass |
| |
542 | + tier = _tiers.TIERS.get(tier_name, _tiers.TIERS["free"]) |
| |
543 | + _key_cache.put("tier:" + principal, tier, gen) |
| |
544 | + return tier |
| 478 | if not principal.startswith("key:"): |
545 | if not principal.startswith("key:"): |
| 479 | return _tiers.TIERS["keyless"] |
546 | return _tiers.TIERS["keyless"] |
| 480 | gen = rl.keys_version() |
547 | gen = rl.keys_version() |