Spaces:
Running
Running
| """Auth + anonymous-quota middleware for TuringDNA. | |
| Two responsibilities, glued together because the endpoints that need them | |
| overlap completely: | |
| 1. Verify Supabase access tokens on /api/run (and any future protected | |
| endpoint). The token rides in the `Authorization: Bearer <jwt>` | |
| header. We validate signature + expiry against the Supabase JWT | |
| secret, then expose the resulting claims via flask.g.user. | |
| 2. Enforce the one-free-anonymous-run quota. Anonymous users get exactly | |
| one /api/run before they need an account. We track this server-side | |
| via a signed cookie (`td_anon_runs`) so a sophisticated user clearing | |
| localStorage in the iframe wrapper still hits the wall here. | |
| Both are best-effort: missing/invalid auth degrades to anonymous-mode | |
| processing rather than 401, EXCEPT when require_auth_or_quota() rejects | |
| the call because the user has used their free run AND has no valid JWT. | |
| Env vars (set on the HF Space): | |
| SUPABASE_URL e.g. https://abcdefg.supabase.co | |
| SUPABASE_JWT_SECRET The "JWT Secret" from Supabase Settings β API | |
| SUPABASE_SERVICE_KEY Service-role key (for inserting runs + aggregates) | |
| SUPABASE_ANON_KEY Public anon/publishable key (the SAME one shipped | |
| in the frontend). Used server-side so per-user | |
| reads/writes run AS THE USER and Row-Level | |
| Security enforces isolation in Postgres. If unset, | |
| those calls fall back to the service-role key and | |
| RLS is not enforced at the row level (a startup | |
| warning fires). Strongly recommended in prod. | |
| TURINGDNA_ANON_SECRET Random 32+ byte string for cookie signing | |
| (any reasonably-strong value, doesn't need | |
| to match Supabase). Generate with: | |
| python -c "import secrets; print(secrets.token_urlsafe(48))" | |
| If env vars are missing, auth is OFF and every request is treated as | |
| anonymous with unlimited runs β keeps local dev easy. The startup log | |
| warns visibly so this isn't accidental in production. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import hmac | |
| import json | |
| import logging | |
| import os | |
| import threading | |
| import time | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, Optional | |
| from flask import Flask, Response, g, jsonify, make_response, request | |
| logger = logging.getLogger("dee.auth") | |
| # βββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SUPABASE_URL = os.environ.get("SUPABASE_URL", "").rstrip("/") | |
| SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "") | |
| SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "") | |
| # Public "anon" / publishable key. Safe to expose β it's already shipped in | |
| # the frontend. We use it SERVER-SIDE too, paired with the requester's own | |
| # JWT, so per-user reads/writes run *as the user* and Row-Level Security | |
| # enforces isolation in the database (see _user_pgrst_headers). This means a | |
| # forgotten `user_id=eq.` filter can no longer leak another user's rows β | |
| # Postgres refuses. If unset, those calls fall back to the service-role key | |
| # (current behaviour) and RLS is NOT enforced at the row level; a startup | |
| # warning fires so this isn't silent. | |
| SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "") | |
| ANON_SECRET = os.environ.get("TURINGDNA_ANON_SECRET", "") | |
| # A Supabase auth.users.id is always a UUID. We validate the JWT `sub` | |
| # against this before trusting it as a user_id β defence-in-depth so a | |
| # malformed/crafted `sub` can never be interpolated into a PostgREST filter | |
| # URL (the design/library ids are already regex-checked; this closes the | |
| # same gap for user_id at the single chokepoint where it enters the system). | |
| import re as _re_mod | |
| _UUID_RE = _re_mod.compile( | |
| r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" | |
| r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" | |
| ) | |
| def _is_uuid(value: object) -> bool: | |
| return isinstance(value, str) and bool(_UUID_RE.match(value)) | |
| # Cookie name + lifetime for the anonymous-trial tracker. 365d is fine β | |
| # the cookie records "this browser started a trial at timestamp X" and | |
| # is replaced on sign-in. | |
| ANON_COOKIE_NAME = "td_anon_runs" | |
| ANON_COOKIE_MAX_AGE_S = 365 * 24 * 3600 | |
| # Length of the anonymous trial window, in seconds. The user can run | |
| # however many sequences they want during this window β when it expires, | |
| # /api/run returns 402 and the frontend pops the sign-in modal. | |
| # | |
| # Lean-startup framing: 5 minutes is plenty to feel the magic but not | |
| # enough to do real exploratory work. Catches users while they're still | |
| # engaged. Was previously a count-based "1 free run" hard wall, swapped | |
| # for time-based per user request 2026-05-26. | |
| ANON_TRIAL_SECONDS = int(os.environ.get("TURINGDNA_ANON_TRIAL_SECONDS", "300")) | |
| # How long a generated library stays in Supabase Storage for free-tier | |
| # users. Matches PRIVACY.md Β§7 ("Sequences: deleted ... 90 days after | |
| # your last access"). Pro-tier users get no expiry (NULL in | |
| # public.libraries.expires_at). Cleanup runs lazily on every new | |
| # /api/run via cleanup_expired_libraries() β no cron required. | |
| FREE_LIBRARY_TTL_DAYS = int(os.environ.get("TURINGDNA_FREE_LIBRARY_TTL_DAYS", "45")) | |
| # Whether auth is configured. If False, every request is anonymous and | |
| # unlimited β we log a warning at startup so production doesn't silently | |
| # ship without auth. | |
| AUTH_ENABLED = bool(SUPABASE_JWT_SECRET and ANON_SECRET) | |
| # βββ User context exposed to handlers βββββββββββββββββββββββββββββββββ | |
| class AuthContext: | |
| """What we know about the requester. Exactly one of (user_id, anon_id) | |
| is populated. anonymous=True is the catch-all for "no JWT, treat as | |
| anon" β and anon_runs counts how many free runs this anon has used.""" | |
| user_id: Optional[str] = None # Supabase auth.users.id | |
| email: Optional[str] = None | |
| anonymous: bool = True | |
| # The raw, already-verified Supabase access token. Stored so per-user | |
| # PostgREST calls can be made AS THE USER (anon apikey + this Bearer | |
| # token) and have RLS enforce row isolation. Never logged, never sent | |
| # anywhere except back to Supabase's own REST endpoint. | |
| token: Optional[str] = None | |
| # Unix timestamp when this anon visitor's trial started (i.e., when | |
| # they kicked off their first /api/run). 0 means "trial hasn't begun". | |
| anon_first_at: int = 0 | |
| # Count of anon runs in the trial, informational only β gate is | |
| # time-based, not count-based. | |
| anon_runs: int = 0 | |
| def get_auth() -> AuthContext: | |
| """Convenience: returns the AuthContext attached to the current Flask | |
| request by load_auth_into_g(). Returns a fresh anonymous one if no | |
| middleware ran yet (defensive).""" | |
| return getattr(g, "auth", AuthContext()) | |
| # βββ JWT verification (no external deps) ββββββββββββββββββββββββββββββ | |
| # We use PyJWT when available (clean signature/expiry handling), and fall | |
| # back to a tiny in-house verifier so the auth module works even if PyJWT | |
| # isn't installed yet. Supabase signs with HS256 (HMAC-SHA256) using the | |
| # JWT Secret β straightforward to verify by hand. | |
| def _b64url_decode(data: str) -> bytes: | |
| # JWT base64url: '-' '_' instead of '+' '/', no padding. | |
| pad = '=' * (-len(data) % 4) | |
| return _b64_translate(data + pad) | |
| def _b64_translate(data: str) -> bytes: | |
| import base64 | |
| return base64.urlsafe_b64decode(data.encode("ascii")) | |
| def _verify_jwt_hs256(token: str, secret: str) -> Optional[Dict[str, Any]]: | |
| """Verify an HS256-signed JWT and return its claims, or None on failure. | |
| LEGACY path. Supabase used to sign everything with HS256 against the | |
| Legacy JWT Secret. After May 2026 Supabase migrated this project to | |
| asymmetric ES256 signing β those tokens are verified by the JWKS | |
| path below (_verify_jwt_jwks). We keep HS256 alive because: | |
| - The Supabase anon + service_role API keys are still HS256-signed | |
| against the Legacy Secret (per Supabase's own UI copy). | |
| - Projects that haven't migrated yet, or that revert, still need it. | |
| Validates: signature, exp (expiry), nbf (not-before if present), and | |
| iat (issued-at, sanity-checks not-in-future-by-more-than-60s). Does | |
| not strictly verify `aud` or `iss` β Supabase JWTs have iss=supabase | |
| and aud=authenticated, which we treat as informational only. | |
| """ | |
| try: | |
| header_b64, payload_b64, sig_b64 = token.split(".") | |
| except ValueError: | |
| return None | |
| try: | |
| # Verify signature first β constant-time compare. | |
| signing_input = (header_b64 + "." + payload_b64).encode("ascii") | |
| expected = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest() | |
| actual = _b64url_decode(sig_b64) | |
| if not hmac.compare_digest(expected, actual): | |
| return None | |
| # Parse claims. | |
| claims = json.loads(_b64url_decode(payload_b64).decode("utf-8")) | |
| now = int(time.time()) | |
| exp = claims.get("exp") | |
| if isinstance(exp, (int, float)) and now > int(exp): | |
| return None | |
| nbf = claims.get("nbf") | |
| if isinstance(nbf, (int, float)) and now < int(nbf): | |
| return None | |
| iat = claims.get("iat") | |
| # Allow some clock skew on iat β 5 minutes is generous. | |
| if isinstance(iat, (int, float)) and int(iat) > now + 300: | |
| return None | |
| return claims | |
| except Exception: | |
| return None | |
| # βββ ES256 / RS256 verification via JWKS βββββββββββββββββββββββββββββββ | |
| # 2026-05-28: Supabase migrated this project's access-token signing from | |
| # HS256 (Legacy Secret) to ES256 (ECDSA P-256, asymmetric keys). The | |
| # private key stays with Supabase; we fetch the matching public key from | |
| # their JWKS endpoint and verify against it. | |
| # | |
| # Endpoint shape: | |
| # https://<project>.supabase.co/auth/v1/.well-known/jwks.json | |
| # | |
| # PyJWKClient handles HTTP fetch + caching + kid-based key lookup. We | |
| # cache one client instance at module level β it caches keys in memory | |
| # and refetches the JWKS only on kid miss (e.g., after Supabase rotates). | |
| # | |
| # Verification uses PyJWT with the cryptography backend (the [crypto] | |
| # extra in requirements.txt). If PyJWT isn't installed, we degrade to | |
| # returning None for asymmetric tokens β the legacy HS256 path still | |
| # works for any users on projects that haven't migrated. | |
| _jwks_client = None # lazy-initialized PyJWKClient | |
| _pyjwt_module = None # lazy-imported jwt module | |
| def _ensure_pyjwt(): | |
| """Lazy-import PyJWT so the auth module loads even if PyJWT isn't | |
| installed (graceful local-dev fallback).""" | |
| global _pyjwt_module | |
| if _pyjwt_module is None: | |
| try: | |
| import jwt as _jwt # PyJWT | |
| _pyjwt_module = _jwt | |
| except ImportError: | |
| logger.warning( | |
| "PyJWT not installed; ES256/RS256 Supabase tokens cannot be " | |
| "verified. Install with: pip install 'PyJWT[crypto]'" | |
| ) | |
| _pyjwt_module = False # sentinel: don't retry import | |
| return _pyjwt_module or None | |
| def _get_jwks_client(): | |
| """Lazy-init a PyJWKClient pointing at this project's JWKS endpoint. | |
| Returns None if Supabase URL isn't configured or PyJWT is missing.""" | |
| global _jwks_client | |
| if _jwks_client is not None: | |
| return _jwks_client | |
| if not SUPABASE_URL: | |
| return None | |
| pyjwt = _ensure_pyjwt() | |
| if pyjwt is None: | |
| return None | |
| try: | |
| _jwks_client = pyjwt.PyJWKClient( | |
| f"{SUPABASE_URL}/auth/v1/.well-known/jwks.json", | |
| cache_keys=True, | |
| max_cached_keys=8, | |
| ) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Failed to initialize JWKS client: %s", exc) | |
| _jwks_client = None | |
| return _jwks_client | |
| def _verify_jwt_jwks(token: str, alg: str) -> Optional[Dict[str, Any]]: | |
| """Verify an ES256 / RS256 / ES384 / RS512 etc. JWT against the | |
| Supabase JWKS public keys. Returns claims or None. | |
| Same validation surface as _verify_jwt_hs256: signature, exp, nbf, | |
| iat (with 5-min clock-skew tolerance). audience NOT enforced | |
| (Supabase uses aud="authenticated" which is informational here).""" | |
| pyjwt = _ensure_pyjwt() | |
| if pyjwt is None: | |
| return None | |
| client = _get_jwks_client() | |
| if client is None: | |
| return None | |
| try: | |
| signing_key = client.get_signing_key_from_jwt(token) | |
| claims = pyjwt.decode( | |
| token, | |
| signing_key.key, | |
| algorithms=[alg], | |
| options={ | |
| # iss/aud verification disabled β matches the HS256 path. | |
| # Supabase tokens carry aud="authenticated" which isn't | |
| # something we need to enforce at the API layer; the user_id | |
| # in `sub` is what we attribute work to. | |
| "verify_aud": False, | |
| "verify_iss": False, | |
| }, | |
| # 5-min leeway covers minor clock skew between the Supabase | |
| # auth servers and the HF Space container. | |
| leeway=300, | |
| ) | |
| return claims | |
| except pyjwt.exceptions.InvalidTokenError as exc: | |
| logger.info("JWKS verify rejected token: %s", exc) | |
| return None | |
| except Exception as exc: # noqa: BLE001 | |
| # Network errors fetching JWKS, malformed kid, etc. | |
| logger.warning("JWKS verify error: %s", exc) | |
| return None | |
| def _verify_jwt_any(token: str) -> Optional[Dict[str, Any]]: | |
| """Dispatch JWT verification based on the algorithm in the header. | |
| Branches: | |
| - HS256 β legacy HMAC path using SUPABASE_JWT_SECRET | |
| - ES256/RS256/etc. β JWKS-based asymmetric verification | |
| Returns claims on success, None on failure. Always None if AUTH_ENABLED | |
| is False (caller already checks this, defensive double-guard).""" | |
| if not token: | |
| return None | |
| # Decode header WITHOUT verification to learn the algorithm. We do | |
| # this with PyJWT if available (cleaner) and fall back to manual | |
| # base64 + JSON parse otherwise. | |
| alg = "" | |
| pyjwt = _ensure_pyjwt() | |
| if pyjwt is not None: | |
| try: | |
| alg = pyjwt.get_unverified_header(token).get("alg", "") | |
| except Exception: | |
| alg = "" | |
| if not alg: | |
| try: | |
| header_b64 = token.split(".", 2)[0] | |
| alg = json.loads(_b64url_decode(header_b64).decode("utf-8")).get("alg", "") | |
| except Exception: | |
| return None | |
| if alg == "HS256": | |
| if not SUPABASE_JWT_SECRET: | |
| return None | |
| return _verify_jwt_hs256(token, SUPABASE_JWT_SECRET) | |
| if alg in ("ES256", "ES384", "ES512", "RS256", "RS384", "RS512"): | |
| return _verify_jwt_jwks(token, alg) | |
| # Unknown algorithm β refuse. | |
| logger.info("JWT uses unsupported alg=%r; rejecting.", alg) | |
| return None | |
| # βββ Signed anon cookie βββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Format: "<first_at>.<runs>.<issued_at>.<hex_hmac>" | |
| # | |
| # first_at = unix seconds when this visitor's trial began (0 if first | |
| # request hasn't happened yet β gate not yet armed) | |
| # runs = informational run count (gate is time-based, this is for | |
| # analytics convenience) | |
| # issued_at = cookie issuance time (for max-age sanity check) | |
| # | |
| # We sign with ANON_SECRET so the client can't reset first_at to 0 by | |
| # editing the cookie. They CAN clear the cookie entirely β at which | |
| # point they get a fresh trial β so cookie-clearing remains a free path | |
| # around the wall. Tightening further (server-side IP fingerprint) is | |
| # a deliberate later choice, not done here. | |
| def _sign_anon_value(first_at: int, runs: int, issued: int) -> str: | |
| body = f"{first_at}.{runs}.{issued}" | |
| mac = hmac.new( | |
| ANON_SECRET.encode("utf-8"), | |
| body.encode("ascii"), | |
| hashlib.sha256, | |
| ).hexdigest() | |
| return f"{body}.{mac}" | |
| def _parse_anon_cookie(raw: Optional[str]) -> tuple[int, int]: | |
| """Returns (first_at, runs). Both 0 if cookie missing/forged. Also | |
| rejects cookies issued more than ANON_COOKIE_MAX_AGE_S ago.""" | |
| if not raw or not ANON_SECRET: | |
| return (0, 0) | |
| try: | |
| first_s, runs_s, issued_s, mac = raw.split(".") | |
| first_at = int(first_s) | |
| runs = int(runs_s) | |
| issued = int(issued_s) | |
| except (ValueError, AttributeError): | |
| return (0, 0) | |
| if runs < 0 or runs > 1_000_000: | |
| return (0, 0) | |
| if first_at < 0 or first_at > 4_000_000_000: | |
| return (0, 0) | |
| if int(time.time()) - issued > ANON_COOKIE_MAX_AGE_S: | |
| return (0, 0) | |
| expected = _sign_anon_value(first_at, runs, issued) | |
| if not hmac.compare_digest(expected, raw): | |
| return (0, 0) | |
| return (first_at, runs) | |
| def _set_anon_cookie(response: Response, first_at: int, runs: int) -> None: | |
| if not ANON_SECRET: | |
| return | |
| issued = int(time.time()) | |
| response.set_cookie( | |
| ANON_COOKIE_NAME, | |
| _sign_anon_value(first_at, runs, issued), | |
| max_age=ANON_COOKIE_MAX_AGE_S, | |
| httponly=True, | |
| secure=True, | |
| # Cross-site (iframe β landing origin) requires SameSite=None + | |
| # Secure. Lax used to work because /app was same-origin in dev, | |
| # but in production the engine lives at *.hf.space inside an | |
| # iframe at turingdna.com, so the cookie has to cross sites. | |
| samesite="None", | |
| ) | |
| # βββ Flask integration βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_auth_into_g() -> None: | |
| """before_request hook. Parses Authorization header + anon cookie and | |
| populates g.auth. Always populates something β handlers should read | |
| g.auth and decide what to do; this hook never returns a response.""" | |
| auth = AuthContext() | |
| # 1) Try Authorization: Bearer <jwt> | |
| # _verify_jwt_any dispatches on the JWT's `alg`: | |
| # HS256 β legacy SUPABASE_JWT_SECRET path | |
| # ES256/RS256 β JWKS-based asymmetric verification | |
| # Either path returns the validated claims or None. | |
| auth_header = request.headers.get("Authorization", "") | |
| if AUTH_ENABLED and auth_header.lower().startswith("bearer "): | |
| token = auth_header[7:].strip() | |
| claims = _verify_jwt_any(token) | |
| if claims: | |
| sub = claims.get("sub") | |
| role = claims.get("role") | |
| # The signature is valid, but defence-in-depth on two fronts: | |
| # (1) a Supabase user id is ALWAYS a uuid β if `sub` isn't one, | |
| # refuse to treat it as a user_id (it would otherwise be | |
| # interpolated into PostgREST filter URLs); | |
| # (2) only a real user access token carries role="authenticated". | |
| # Other token types Supabase signs with the same secret | |
| # (anon/service_role API keys) have a different role and no | |
| # uuid sub β requiring both closes any gap where such a token | |
| # could be presented as a Bearer session. | |
| if _is_uuid(sub) and role == "authenticated": | |
| auth.user_id = sub | |
| auth.email = claims.get("email") | |
| auth.anonymous = False | |
| auth.token = token | |
| else: | |
| logger.warning("JWT verified but sub/role not a user session (sub uuid=%s, role=%r); treating as anon.", | |
| _is_uuid(sub), role) | |
| else: | |
| logger.info("Invalid or expired JWT received; treating as anon.") | |
| # 2) If still anonymous, read the signed trial cookie. | |
| if auth.anonymous: | |
| first_at, runs = _parse_anon_cookie(request.cookies.get(ANON_COOKIE_NAME)) | |
| auth.anon_first_at = first_at | |
| auth.anon_runs = runs | |
| g.auth = auth | |
| # Engagement: mark signed-in users active (throttled to 1/hour/user, | |
| # fire-and-forget). Powers profiles.last_seen_at β previously never written. | |
| if not auth.anonymous and auth.user_id: | |
| try: | |
| touch_last_seen_async(auth.user_id) | |
| except Exception: # noqa: BLE001 β telemetry must never block a request | |
| pass | |
| def require_auth_or_quota() -> Optional[Response]: | |
| """Call from a route handler BEFORE doing any work. Returns a 402 JSON | |
| response (intercept) if the user is anonymous AND their trial window | |
| has expired. Returns None to let the handler proceed. | |
| The gate is **time-based**: from the moment of the first anon /api/run, | |
| the user has ANON_TRIAL_SECONDS of unlimited access. After that, | |
| further /api/run calls 402 until they sign in. | |
| 402 instead of 401 because the user IS welcome to use this β they | |
| just need to sign up. The frontend interprets 402 specifically as | |
| "show the sign-up gate" rather than a hard auth error.""" | |
| if not AUTH_ENABLED: | |
| return None # local dev β never block | |
| auth = get_auth() | |
| if not auth.anonymous: | |
| return None # signed in, proceed | |
| if not auth.anon_first_at: | |
| return None # trial hasn't started yet β first run is always free | |
| elapsed = int(time.time()) - auth.anon_first_at | |
| if elapsed >= ANON_TRIAL_SECONDS: | |
| return jsonify({ | |
| "error": ( | |
| f"Your {ANON_TRIAL_SECONDS // 60}-minute free trial has " | |
| "ended. Sign in to keep going β no password, just an " | |
| "emailed link." | |
| ), | |
| "kind": "auth_required", | |
| # Default redirect target points to /signin since most repeat | |
| # visitors already have an account. /signup is a 1-click away | |
| # from /signin via the "Need an account?" link. | |
| "signup_url": "https://turingdna.com/signin/?from=app", | |
| "trial_seconds": ANON_TRIAL_SECONDS, | |
| "trial_started_at": auth.anon_first_at, | |
| }), 402 | |
| return None | |
| def increment_anon_runs_on_response(response: Response) -> Response: | |
| """Called from inside a /api/run handler that has decided to start a | |
| pipeline FOR an anonymous user. Stamps the first_at timestamp on the | |
| cookie if this is their first run; otherwise just bumps the | |
| informational run count without resetting the trial timer. | |
| Idempotent against being called twice on the same request β the | |
| response cookie just gets overwritten with the same data. | |
| """ | |
| auth = get_auth() | |
| if not (auth.anonymous and AUTH_ENABLED): | |
| return response | |
| now = int(time.time()) | |
| first_at = auth.anon_first_at or now | |
| runs = auth.anon_runs + 1 | |
| _set_anon_cookie(response, first_at, runs) | |
| # Also surface the trial state in a custom response header so the | |
| # frontend can read it without parsing the cookie itself. The frontend | |
| # uses this to start its own client-side countdown to the in-session | |
| # "sign in to keep going" modal. | |
| elapsed = now - first_at | |
| remaining = max(0, ANON_TRIAL_SECONDS - elapsed) | |
| response.headers["X-TD-Trial-Remaining-S"] = str(remaining) | |
| response.headers["X-TD-Trial-Started-At"] = str(first_at) | |
| return response | |
| # βββ Run logging (best-effort) ββββββββββββββββββββββββββββββββββββββββ | |
| # After a /api/run starts we POST a row into Supabase public.runs via the | |
| # REST API using the service-role key. Never blocks the user's request β | |
| # fire-and-forget in a daemon thread, log on error. | |
| def log_run_async( | |
| *, | |
| job_id: str, | |
| sequence_hash: str, | |
| sequence_length: int, | |
| model: Optional[str], | |
| host_organism: Optional[str], | |
| ) -> None: | |
| """Fire-and-forget insert into public.runs. Captures user_id from | |
| g.auth if signed in; otherwise stores anon_fingerprint = IP+UA hash.""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return # not configured; skip silently | |
| auth = get_auth() | |
| user_id = auth.user_id | |
| anon_fingerprint = None | |
| if auth.anonymous: | |
| anon_fingerprint = _anon_fingerprint() | |
| # Data-minimization: persist only a /24-anonymized IP (host bits zeroed) | |
| # and a short UA. We never store a full client IP β it shrinks the blast | |
| # radius of any DB compromise and matches PRIVACY.md's minimization stance. | |
| raw_ip = request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip() | |
| request_ip = _anonymize_ip(raw_ip) | |
| request_ua = request.headers.get("User-Agent", "")[:200] | |
| payload = { | |
| "user_id": user_id, | |
| "anon_fingerprint": anon_fingerprint, | |
| "job_id": job_id, | |
| "sequence_hash": sequence_hash, | |
| "sequence_length": sequence_length, | |
| "model": model, | |
| "host_organism": host_organism, | |
| "status": "started", | |
| "request_ip": request_ip or None, | |
| "request_ua": request_ua or None, | |
| } | |
| import threading | |
| threading.Thread( | |
| target=_post_run_row, args=(payload,), daemon=True, | |
| ).start() | |
| def _post_run_row(payload: Dict[str, Any]) -> None: | |
| try: | |
| import urllib.request | |
| import urllib.error | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/runs", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers={ | |
| "Content-Type": "application/json", | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| "Prefer": "return=minimal", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=4.0) as resp: | |
| if resp.status not in (200, 201, 204): | |
| logger.warning("Supabase runs insert returned %s", resp.status) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Could not log run to Supabase: %s", exc) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ENGAGEMENT TELEMETRY (activation / retention / dwell) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # All writers below are fire-and-forget and best-effort: they spawn a daemon | |
| # thread, never raise into a request handler, and no-op when Supabase isn't | |
| # configured. Privacy stance matches public.runs β no sequence content, only a | |
| # /24-anonymized IP fingerprint for anonymous visitors. See 0011_analytics.sql. | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _iso_now() -> str: | |
| """Current UTC time as an ISO-8601 string PostgREST stores into timestamptz.""" | |
| from datetime import datetime, timezone | |
| return datetime.now(timezone.utc).isoformat() | |
| def _post_supabase(table: str, payload: Dict[str, Any]) -> None: | |
| """Fire-and-forget POST to PostgREST (table insert or rpc/<fn>) with the | |
| service key. Best-effort; logs and swallows any failure.""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| try: | |
| import urllib.request | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/{table}", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers=_supabase_headers({ | |
| "Content-Type": "application/json", | |
| "Prefer": "return=minimal", | |
| }), | |
| ) | |
| with urllib.request.urlopen(req, timeout=4.0) as resp: | |
| if resp.status not in (200, 201, 204): | |
| logger.warning("Supabase POST %s returned %s", table, resp.status) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Supabase POST %s failed: %s", table, exc) | |
| def _patch_supabase(path_query: str, payload: Dict[str, Any]) -> None: | |
| """Fire-and-forget PATCH to PostgREST with the service key. Best-effort.""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| try: | |
| import urllib.request | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/{path_query}", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="PATCH", | |
| headers=_supabase_headers({ | |
| "Content-Type": "application/json", | |
| "Prefer": "return=minimal", | |
| }), | |
| ) | |
| with urllib.request.urlopen(req, timeout=4.0) as resp: | |
| if resp.status not in (200, 204): | |
| logger.warning("Supabase PATCH %s returned %s", path_query, resp.status) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Supabase PATCH %s failed: %s", path_query, exc) | |
| # Throttle: at most one profiles.last_seen_at UPDATE per user per hour, so a | |
| # burst of API calls doesn't hammer PostgREST. In-memory; resets on process | |
| # restart (worst case: one extra touch per user after a redeploy). | |
| _LAST_SEEN_TOUCHED: Dict[str, float] = {} | |
| _LAST_SEEN_LOCK = threading.Lock() | |
| _LAST_SEEN_THROTTLE_S = 3600.0 | |
| def touch_last_seen_async(user_id: Optional[str]) -> None: | |
| """Mark a signed-in user active now (profiles.last_seen_at = now), throttled | |
| to once/hour/user. Called from the auth chokepoint on every authed request.""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| now = time.time() | |
| with _LAST_SEEN_LOCK: | |
| if now - _LAST_SEEN_TOUCHED.get(user_id, 0.0) < _LAST_SEEN_THROTTLE_S: | |
| return | |
| _LAST_SEEN_TOUCHED[user_id] = now | |
| # Bound the cache so a churn of distinct users can't grow it forever. | |
| if len(_LAST_SEEN_TOUCHED) > 50000: | |
| cutoff = now - _LAST_SEEN_THROTTLE_S | |
| for k in [k for k, v in _LAST_SEEN_TOUCHED.items() if v < cutoff]: | |
| _LAST_SEEN_TOUCHED.pop(k, None) | |
| threading.Thread( | |
| target=_patch_supabase, | |
| args=(f"profiles?id=eq.{user_id}", {"last_seen_at": _iso_now()}), | |
| daemon=True, | |
| ).start() | |
| def mark_run_finished_async( | |
| *, job_id: str, status: str, | |
| elapsed_seconds: Optional[float] = None, | |
| n_variants: Optional[int] = None, | |
| ) -> None: | |
| """PATCH the public.runs row for job_id with its terminal state β the row | |
| inserted by log_run_async only ever had status='started'. Best-effort.""" | |
| if not job_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| payload: Dict[str, Any] = { | |
| "status": status if status in ("done", "error") else "done", | |
| "finished_at": _iso_now(), | |
| } | |
| if elapsed_seconds is not None: | |
| payload["elapsed_seconds"] = round(float(elapsed_seconds), 3) | |
| if n_variants is not None: | |
| payload["n_variants"] = int(n_variants) | |
| threading.Thread( | |
| target=_patch_supabase, | |
| args=(f"runs?job_id=eq.{job_id}", payload), | |
| daemon=True, | |
| ).start() | |
| def log_event(kind: str, *, meta: Optional[Dict[str, Any]] = None) -> None: | |
| """Fire-and-forget insert into public.events β one row per meaningful tool | |
| action (plasmid map, CRISPR design, save, β¦) so engagement covers all tools, | |
| not just /api/run. Attributes to user_id, or an anon fingerprint. Must be | |
| called from a request context (reads g.auth + request headers).""" | |
| if not kind or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| try: | |
| auth = get_auth() | |
| except Exception: # noqa: BLE001 β no request context | |
| return | |
| payload = { | |
| "user_id": auth.user_id, | |
| "anon_fingerprint": _anon_fingerprint() if auth.anonymous else None, | |
| "kind": kind[:64], | |
| "meta": meta or {}, | |
| } | |
| threading.Thread(target=_post_supabase, args=("events", payload), daemon=True).start() | |
| def record_ping_async(*, session_id: str, visible_ms: int) -> None: | |
| """Upsert a dwell-session row via the record_ping() RPC (atomic insert-or- | |
| update keyed by session_id). Attributes to user_id or anon fingerprint. | |
| Best-effort; call from a request context.""" | |
| if not session_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| try: | |
| auth = get_auth() | |
| except Exception: # noqa: BLE001 | |
| return | |
| body = { | |
| "p_session_id": session_id[:64], | |
| "p_user_id": auth.user_id, | |
| "p_anon": _anon_fingerprint() if auth.anonymous else None, | |
| "p_visible_ms": max(0, int(visible_ms)), | |
| } | |
| threading.Thread(target=_post_supabase, args=("rpc/record_ping", body), daemon=True).start() | |
| # βββ Library save (signed-in users only) βββββββββββββββββββββββββββββ | |
| # After /api/run finishes, the worker thread calls save_library_async() | |
| # with the per-job CSV path and a few metadata fields. We upload the CSV | |
| # to the `libraries` Supabase Storage bucket and insert a metadata row | |
| # into public.libraries pointing at it. Anonymous users are skipped β | |
| # they don't have a user_id to attribute the library to. | |
| def save_library_async( | |
| *, | |
| user_id: Optional[str], | |
| job_id: str, | |
| csv_path: str, | |
| name: str, | |
| wt_identifier: Optional[str], | |
| wt_protein: Optional[str], | |
| host_organism: Optional[str], | |
| n_variants: int, | |
| top_fitness: Optional[float], | |
| library_id: Optional[str] = None, | |
| ) -> None: | |
| """Fire-and-forget save of a generated library to Supabase Storage. | |
| No-op for anonymous users β they have no user_id to bind to. Also | |
| no-op if Supabase isn't configured. Spawns a daemon thread so the | |
| /api/run response isn't held up by the upload. ``library_id`` lets the | |
| caller pre-assign the id (so it can be returned to the client for the | |
| learning-flywheel outcome logging); a fresh hex id is minted if omitted. | |
| """ | |
| if not user_id: | |
| return # anonymous run; nothing to attribute | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| import threading | |
| threading.Thread( | |
| target=_do_save_library, | |
| kwargs={ | |
| "user_id": user_id, | |
| "job_id": job_id, | |
| "csv_path": csv_path, | |
| "name": name, | |
| "wt_identifier": wt_identifier, | |
| "wt_protein": wt_protein, | |
| "host_organism": host_organism, | |
| "n_variants": n_variants, | |
| "top_fitness": top_fitness, | |
| "library_id": library_id, | |
| }, | |
| daemon=True, | |
| ).start() | |
| def _do_save_library( | |
| *, | |
| user_id: str, | |
| job_id: str, | |
| csv_path: str, | |
| name: str, | |
| wt_identifier: Optional[str], | |
| wt_protein: Optional[str], | |
| host_organism: Optional[str], | |
| n_variants: int, | |
| top_fitness: Optional[float], | |
| library_id: Optional[str] = None, | |
| ) -> None: | |
| """Worker for save_library_async. Two-step: upload CSV to Storage, | |
| then insert metadata row. Both steps log on failure but don't raise | |
| β analytics plumbing must never break user-facing runs.""" | |
| import os as _os | |
| import uuid as _uuid | |
| import urllib.request | |
| import urllib.error | |
| if not _os.path.exists(csv_path): | |
| logger.warning("save_library: CSV not found at %s", csv_path) | |
| return | |
| try: | |
| with open(csv_path, "rb") as f: | |
| csv_bytes = f.read() | |
| except OSError as exc: | |
| logger.warning("save_library: couldn't read %s: %s", csv_path, exc) | |
| return | |
| csv_size = len(csv_bytes) | |
| library_id = library_id or _uuid.uuid4().hex | |
| # Path convention: <user_id>/<library_id>.csv | |
| # The user_id prefix lets the storage RLS policy do a simple | |
| # first-folder-segment check to gate access. | |
| storage_path = f"{user_id}/{library_id}.csv" | |
| # ββ TTL computation per user plan ββ | |
| # Free tier: 90-day retention (matches PRIVACY.md Β§7). Pro tier: no | |
| # expiry (storage cap is the only limit). The cleanup that actually | |
| # deletes expired rows runs in cleanup_expired_libraries() β fired | |
| # off as part of every new /api/run so users don't get charged for | |
| # data they're not using. | |
| expires_at_iso = None | |
| if not has_pro_plan(user_id): | |
| import datetime as _dt | |
| expiry = _dt.datetime.utcnow() + _dt.timedelta(days=FREE_LIBRARY_TTL_DAYS) | |
| # ISO 8601 with explicit Z for UTC β PostgREST/Postgres parse it | |
| # into timestamptz cleanly. | |
| expires_at_iso = expiry.strftime("%Y-%m-%dT%H:%M:%SZ") | |
| # ββ 1. Upload CSV to libraries bucket ββ | |
| # Supabase Storage REST endpoint: | |
| # POST /storage/v1/object/<bucket>/<path> | |
| storage_url = f"{SUPABASE_URL}/storage/v1/object/libraries/{storage_path}" | |
| try: | |
| req = urllib.request.Request( | |
| storage_url, | |
| data=csv_bytes, | |
| method="POST", | |
| headers={ | |
| "Content-Type": "text/csv", | |
| "Cache-Control": "max-age=3600", | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| # 'x-upsert' allows re-running the same job_id to | |
| # overwrite without erroring. | |
| "x-upsert": "true", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=15.0) as resp: | |
| if resp.status not in (200, 201): | |
| logger.warning("Storage upload returned %s for %s", | |
| resp.status, storage_path) | |
| return | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Storage upload failed for %s: %s", storage_path, exc) | |
| return | |
| # ββ 2. Insert metadata row into public.libraries ββ | |
| payload = { | |
| "id": library_id, | |
| "user_id": user_id, | |
| "job_id": job_id, | |
| "name": name, | |
| "wt_identifier": wt_identifier, | |
| "wt_protein": wt_protein, | |
| "host_organism": host_organism, | |
| "n_variants": n_variants, | |
| "top_fitness": top_fitness, | |
| "storage_path": storage_path, | |
| "csv_size_bytes": csv_size, | |
| # NULL for Pro = never expires; ISO timestamp for Free = 90 days | |
| # from now per FREE_LIBRARY_TTL_DAYS. | |
| "expires_at": expires_at_iso, | |
| } | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/libraries", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers={ | |
| "Content-Type": "application/json", | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| "Prefer": "return=minimal", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=4.0) as resp: | |
| if resp.status not in (200, 201, 204): | |
| logger.warning("Libraries insert returned %s", resp.status) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Libraries insert failed: %s", exc) | |
| # βββ Library expiry cleanup (lazy, runs on new /api/run) βββββββββββββ | |
| # When a user kicks off a new run, we opportunistically delete any of | |
| # their OWN libraries that are past expires_at. Two-step: remove the | |
| # CSV blob from Storage, then drop the metadata row. Best-effort: | |
| # failures are logged but never block the user-facing run. | |
| # | |
| # Why lazy and not cron: | |
| # - Active users get cleanup essentially for free (piggybacked on | |
| # /api/run, which they're already waiting on). | |
| # - Dormant users' libraries linger forever in the DB, but they cost | |
| # nothing if no one's hitting the system. A small price for not | |
| # needing pg_cron (Pro tier feature) or an Edge Function. | |
| # - Founder can run a manual sweep any time: | |
| # delete from public.libraries where expires_at < now() returning id; | |
| def cleanup_expired_libraries_async(user_id: Optional[str]) -> None: | |
| """Fire-and-forget delete of this user's expired libraries. | |
| No-op for anonymous users (no saved libraries to clean).""" | |
| if not user_id: | |
| return | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| import threading | |
| threading.Thread( | |
| target=_do_cleanup_expired_libraries, | |
| args=(user_id,), | |
| daemon=True, | |
| ).start() | |
| def _do_cleanup_expired_libraries(user_id: str) -> None: | |
| """Worker for cleanup_expired_libraries_async. Best-effort. | |
| 1. SELECT id, storage_path FROM libraries WHERE user_id = ? AND expires_at < now() | |
| 2. DELETE the storage objects (batched in one HTTP call) | |
| 3. DELETE the table rows | |
| """ | |
| import urllib.request | |
| import urllib.error | |
| # ββ 1. Find expired rows ββ | |
| try: | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/libraries" | |
| f"?user_id=eq.{user_id}" | |
| f"&expires_at=lt.now()" | |
| f"&select=id,storage_path" | |
| ) | |
| req = urllib.request.Request( | |
| url, | |
| method="GET", | |
| headers={ | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=4.0) as resp: | |
| expired = json.loads(resp.read().decode("utf-8")) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Expired-library lookup failed for %s: %s", user_id, exc) | |
| return | |
| if not expired: | |
| return | |
| # ββ 2. Remove storage objects in one batch ββ | |
| # Supabase Storage REST: DELETE /storage/v1/object/<bucket> with | |
| # JSON body {"prefixes": [...paths...]} | |
| paths = [r["storage_path"] for r in expired if r.get("storage_path")] | |
| if paths: | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/storage/v1/object/libraries", | |
| data=json.dumps({"prefixes": paths}).encode("utf-8"), | |
| method="DELETE", | |
| headers={ | |
| "Content-Type": "application/json", | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| if resp.status not in (200, 204): | |
| logger.warning( | |
| "Storage cleanup returned %s for %s", | |
| resp.status, paths, | |
| ) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Storage cleanup failed for %s: %s", user_id, exc) | |
| # Continue β we'll still remove the DB rows so we don't | |
| # leave dangling pointers. Re-running this later will retry | |
| # the storage delete (no row β no future attempt; manual | |
| # sweep would be needed to truly forget those bytes). | |
| # ββ 3. Drop the metadata rows ββ | |
| ids = [r["id"] for r in expired] | |
| try: | |
| # PostgREST 'in.(uuid1,uuid2,...)' filter | |
| ids_filter = ",".join(ids) | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/libraries?id=in.({ids_filter})", | |
| method="DELETE", | |
| headers={ | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| "Prefer": "return=minimal", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=4.0) as resp: | |
| if resp.status not in (200, 204): | |
| logger.warning("Libraries delete returned %s", resp.status) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Libraries delete failed for %s: %s", user_id, exc) | |
| logger.info("Cleaned %d expired libraries for user %s", len(expired), user_id) | |
| # βββ Plan helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Pro-tier features call has_pro_plan() to gate themselves. Today the | |
| # plan is read from profiles.plan via the service-role REST API on each | |
| # call β cheap (single Postgres index lookup) and works without any | |
| # client-side claim. A future optimization is to bake the plan into the | |
| # Supabase JWT via a custom hook so we don't have to round-trip. | |
| def has_pro_plan(user_id: Optional[str]) -> bool: | |
| """True if this user is on the 'pro' plan, False otherwise (including | |
| anonymous users). Never raises; on Supabase failure, returns False so | |
| paid features fail closed.""" | |
| if not user_id: | |
| return False | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return False | |
| try: | |
| import urllib.request | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/profiles" | |
| f"?id=eq.{user_id}&select=plan&limit=1" | |
| ) | |
| req = urllib.request.Request( | |
| url, | |
| method="GET", | |
| headers={ | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| "Accept": "application/json", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req, timeout=2.0) as resp: | |
| if resp.status != 200: | |
| return False | |
| data = json.loads(resp.read().decode("utf-8")) | |
| if isinstance(data, list) and data: | |
| return (data[0] or {}).get("plan") == "pro" | |
| return False | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("plan lookup failed for %s: %s", user_id, exc) | |
| return False | |
| # βββ CRISPR design save / list / get (Phase 3, M4) βββββββββββββββββββ | |
| # Per-user saved guide sets in public.crispr_designs (migration 0004). | |
| # Synchronous (the user waits on the Save modal); service-role REST with | |
| # server-side user_id ownership filtering. Free users get a 90-day TTL; | |
| # pro users keep designs forever (expires_at = NULL). Designs are small | |
| # JSON, so β unlike libraries β there's no Storage upload, just a row. | |
| _CRISPR_DESIGNS_MAX = 200 # max guides stored per saved design | |
| _CRISPR_DESIGNS_LIST_LIMIT = 100 # max designs returned by the list call | |
| def _supabase_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: | |
| h = { | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| "Accept": "application/json", | |
| } | |
| if extra: | |
| h.update(extra) | |
| return h | |
| def _user_pgrst_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: | |
| """Headers for a per-user PostgREST call made AS THE USER. | |
| Uses the public anon apikey + the requester's verified JWT as the Bearer | |
| token. PostgREST then evaluates Row-Level Security with ``auth.uid()`` set | |
| to the user's id, so the database itself guarantees a request can only ever | |
| touch that user's own rows β even if a handler forgets its ``user_id=eq.`` | |
| filter. This is the structural backstop that turns RLS from dead weight | |
| (bypassed by the service key) into a real boundary. | |
| Graceful fallback: if the anon key isn't configured, or we're outside a | |
| request context, or the requester is anonymous / has no token, this returns | |
| the service-role headers (current behaviour). That keeps the per-user | |
| ``user_id=eq.`` filters β which we always also include β as the second line | |
| of defence, and makes rollout reversible (unset SUPABASE_ANON_KEY to | |
| revert). Only ever call this from a synchronous request-context handler; | |
| background threads have no ``g`` and must use ``_supabase_headers``. | |
| """ | |
| h: Dict[str, str] | |
| try: | |
| auth = get_auth() | |
| except Exception: # noqa: BLE001 β defensive: no request context | |
| auth = AuthContext() | |
| if SUPABASE_ANON_KEY and auth.token and not auth.anonymous: | |
| h = { | |
| "apikey": SUPABASE_ANON_KEY, | |
| "Authorization": f"Bearer {auth.token}", | |
| "Accept": "application/json", | |
| } | |
| else: | |
| h = { | |
| "apikey": SUPABASE_SERVICE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", | |
| "Accept": "application/json", | |
| } | |
| if extra: | |
| h.update(extra) | |
| return h | |
| def save_crispr_design( | |
| user_id: Optional[str], *, | |
| label: str, mode: str, enzyme: Optional[str], base_editor: Optional[str], | |
| organism: Optional[str], gene_symbol: Optional[str], | |
| input_length: Optional[int], guides: list, | |
| ) -> Dict[str, Any]: | |
| """Insert a saved CRISPR design. Returns {ok, id} or {ok:False, error}. | |
| Synchronous so the UI can confirm + show the new design immediately.""" | |
| if not user_id: | |
| return {"ok": False, "error": "Sign in to save designs."} | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "Saving isn't configured on this server."} | |
| guides = (guides or [])[:_CRISPR_DESIGNS_MAX] | |
| from datetime import datetime, timezone, timedelta | |
| expires_at = (None if has_pro_plan(user_id) else | |
| (datetime.now(timezone.utc) + timedelta(days=FREE_LIBRARY_TTL_DAYS)).isoformat()) | |
| payload = { | |
| "user_id": user_id, | |
| "label": (label or "Untitled design")[:120], | |
| "mode": mode or "knockout", | |
| "enzyme": enzyme or None, | |
| "base_editor": base_editor or None, | |
| "organism": organism or None, | |
| "gene_symbol": gene_symbol or None, | |
| "input_length": input_length, | |
| "n_guides": len(guides), | |
| "guides": guides, | |
| "expires_at": expires_at, | |
| } | |
| import urllib.request | |
| import urllib.error | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/crispr_designs", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers=_user_pgrst_headers({"Content-Type": "application/json", | |
| "Prefer": "return=representation"}), | |
| ) | |
| with urllib.request.urlopen(req, timeout=8.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| new_id = (rows[0] or {}).get("id") if isinstance(rows, list) and rows else None | |
| return {"ok": True, "id": new_id} | |
| except urllib.error.HTTPError as exc: | |
| body = "" | |
| try: | |
| body = exc.read().decode("utf-8")[:200] | |
| except Exception: # noqa: BLE001 | |
| pass | |
| logger.warning("crispr_designs insert HTTP %s: %s", exc.code, body) | |
| # 404/PGRST means the table is missing β guide the operator. | |
| if exc.code in (404, 406) or "PGRST" in body: | |
| return {"ok": False, "error": "Saved designs aren't enabled yet (database migration pending)."} | |
| return {"ok": False, "error": "Couldn't save the design. Please try again."} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("crispr_designs insert failed: %s", exc) | |
| return {"ok": False, "error": "Couldn't save the design. Please try again."} | |
| def list_crispr_designs(user_id: Optional[str]) -> list: | |
| """The user's saved designs (metadata only, newest first). [] on error.""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/crispr_designs" | |
| f"?user_id=eq.{user_id}" | |
| f"&select=id,label,created_at,expires_at,mode,enzyme,base_editor,gene_symbol,organism,n_guides,input_length" | |
| f"&order=created_at.desc&limit={_CRISPR_DESIGNS_LIST_LIMIT}") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("crispr_designs list failed for %s: %s", user_id, exc) | |
| return [] | |
| def get_crispr_design(user_id: Optional[str], design_id: str) -> Optional[Dict[str, Any]]: | |
| """One full design incl. guides, enforcing ownership server-side. | |
| Returns None if not found / not owned / malformed id.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return None | |
| if not _re.match(r"^[0-9a-fA-F-]{36}$", design_id or ""): | |
| return None | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/crispr_designs" | |
| f"?id=eq.{design_id}&user_id=eq.{user_id}&select=*&limit=1") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| return rows[0] if isinstance(rows, list) and rows else None | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("crispr_designs get failed: %s", exc) | |
| return None | |
| def cleanup_expired_crispr_designs_async(user_id: Optional[str]) -> None: | |
| """Fire-and-forget delete of this user's expired designs (lazy sweep, | |
| piggybacks the save call). Mirrors cleanup_expired_libraries_async.""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| import threading | |
| threading.Thread(target=_do_cleanup_expired_crispr_designs, | |
| args=(user_id,), daemon=True).start() | |
| def _do_cleanup_expired_crispr_designs(user_id: str) -> None: | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/crispr_designs" | |
| f"?user_id=eq.{user_id}&expires_at=lt.now()") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_supabase_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=4.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("crispr_designs cleanup failed for %s: %s", user_id, exc) | |
| _PLASMIDS_LIST_LIMIT = 100 # max plasmids returned by the list call | |
| _PLASMID_MAX_LEN = 500_000 # max sequence length stored per plasmid | |
| _PLASMID_FEATURES_MAX = 2000 # max features stored per plasmid | |
| def save_plasmid( | |
| user_id: Optional[str], *, | |
| name: str, topology: str, length: Optional[int], gc: Optional[float], | |
| source: Optional[str], sequence: str, features: list, | |
| ) -> Dict[str, Any]: | |
| """Insert a saved plasmid. Returns {ok, id} or {ok:False, error}. | |
| Synchronous so the UI can confirm + show it in the library immediately.""" | |
| if not user_id: | |
| return {"ok": False, "error": "Sign in to save plasmids."} | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "Saving isn't configured on this server."} | |
| sequence = (sequence or "")[:_PLASMID_MAX_LEN] | |
| if not sequence: | |
| return {"ok": False, "error": "Nothing to save β import a plasmid first."} | |
| features = (features or [])[:_PLASMID_FEATURES_MAX] | |
| from datetime import datetime, timezone, timedelta | |
| expires_at = (None if has_pro_plan(user_id) else | |
| (datetime.now(timezone.utc) + timedelta(days=FREE_LIBRARY_TTL_DAYS)).isoformat()) | |
| payload = { | |
| "user_id": user_id, | |
| "name": (name or "Untitled plasmid")[:120], | |
| "topology": "linear" if topology == "linear" else "circular", | |
| "length": length if isinstance(length, int) else len(sequence), | |
| "gc": gc, | |
| "source": source or None, | |
| "sequence": sequence, | |
| "features": features, | |
| "expires_at": expires_at, | |
| } | |
| import urllib.request | |
| import urllib.error | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/plasmids", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers=_user_pgrst_headers({"Content-Type": "application/json", | |
| "Prefer": "return=representation"}), | |
| ) | |
| with urllib.request.urlopen(req, timeout=8.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| new_id = (rows[0] or {}).get("id") if isinstance(rows, list) and rows else None | |
| return {"ok": True, "id": new_id} | |
| except urllib.error.HTTPError as exc: | |
| body = "" | |
| try: | |
| body = exc.read().decode("utf-8")[:200] | |
| except Exception: # noqa: BLE001 | |
| pass | |
| logger.warning("plasmids insert HTTP %s: %s", exc.code, body) | |
| if exc.code in (404, 406) or "PGRST" in body: | |
| return {"ok": False, "error": "Saved plasmids aren't enabled yet (database migration pending)."} | |
| return {"ok": False, "error": "Couldn't save the plasmid. Please try again."} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("plasmids insert failed: %s", exc) | |
| return {"ok": False, "error": "Couldn't save the plasmid. Please try again."} | |
| def list_plasmids(user_id: Optional[str]) -> list: | |
| """The user's saved plasmids (metadata only, newest first). [] on error.""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/plasmids" | |
| f"?user_id=eq.{user_id}" | |
| f"&select=id,name,created_at,expires_at,topology,length,gc,source" | |
| f"&order=created_at.desc&limit={_PLASMIDS_LIST_LIMIT}") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("plasmids list failed for %s: %s", user_id, exc) | |
| return [] | |
| def get_plasmid(user_id: Optional[str], plasmid_id: str) -> Optional[Dict[str, Any]]: | |
| """One full plasmid incl. sequence + features, enforcing ownership. | |
| Returns None if not found / not owned / malformed id.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return None | |
| if not _re.match(r"^[0-9a-fA-F-]{36}$", plasmid_id or ""): | |
| return None | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/plasmids" | |
| f"?id=eq.{plasmid_id}&user_id=eq.{user_id}&select=*&limit=1") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| return rows[0] if isinstance(rows, list) and rows else None | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("plasmids get failed: %s", exc) | |
| return None | |
| def delete_plasmid(user_id: Optional[str], plasmid_id: str) -> Dict[str, Any]: | |
| """Delete one plasmid, owner-enforced.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "Not configured."} | |
| if not _re.match(r"^[0-9a-fA-F-]{36}$", plasmid_id or ""): | |
| return {"ok": False, "error": "Bad id."} | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/plasmids" | |
| f"?id=eq.{plasmid_id}&user_id=eq.{user_id}") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_user_pgrst_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=6.0): | |
| return {"ok": True} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("plasmids delete failed: %s", exc) | |
| return {"ok": False, "error": "Couldn't delete."} | |
| def cleanup_expired_plasmids_async(user_id: Optional[str]) -> None: | |
| """Fire-and-forget delete of this user's expired plasmids (lazy sweep).""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| import threading | |
| threading.Thread(target=_do_cleanup_expired_plasmids, | |
| args=(user_id,), daemon=True).start() | |
| def _do_cleanup_expired_plasmids(user_id: str) -> None: | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/plasmids" | |
| f"?user_id=eq.{user_id}&expires_at=lt.now()") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_supabase_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=4.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("plasmids cleanup failed for %s: %s", user_id, exc) | |
| # βββ Primer Analysis: per-user saved sets (unified "My designs" hub) ββ | |
| # Mirrors the crispr_designs CRUD. Table: public.primer_analyses | |
| # (migration 0006). The full analyze result is stored as JSONB so a saved | |
| # set re-renders without re-running the in-silico PCR. | |
| _PRIMER_ANALYSES_LIST_LIMIT = 100 # max sets returned by the list call | |
| _PRIMER_RESULT_MAX_BYTES = 600_000 # cap the stored result JSON (~0.6 MB) | |
| def save_primer_analysis( | |
| user_id: Optional[str], *, | |
| label: str, organism: Optional[str], | |
| template_len: Optional[int], n_primers: Optional[int], | |
| result: Dict[str, Any], | |
| ) -> Dict[str, Any]: | |
| """Insert a saved primer set. Returns {ok, id} or {ok:False, error}.""" | |
| if not user_id: | |
| return {"ok": False, "error": "Sign in to save primer sets."} | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "Saving isn't configured on this server."} | |
| # Guard against an oversized result blob (a runaway off-target product | |
| # list): keep the ranked primers, drop the bulk. | |
| try: | |
| if len(json.dumps(result or {})) > _PRIMER_RESULT_MAX_BYTES: | |
| result = {"primers": (result or {}).get("primers", [])} | |
| except Exception: # noqa: BLE001 | |
| result = {} | |
| from datetime import datetime, timezone, timedelta | |
| expires_at = (None if has_pro_plan(user_id) else | |
| (datetime.now(timezone.utc) + timedelta(days=FREE_LIBRARY_TTL_DAYS)).isoformat()) | |
| payload = { | |
| "user_id": user_id, | |
| "label": (label or "Untitled primer set")[:120], | |
| "organism": organism or None, | |
| "template_len": template_len, | |
| "n_primers": n_primers, | |
| "result": result or {}, | |
| "expires_at": expires_at, | |
| } | |
| import urllib.request | |
| import urllib.error | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/primer_analyses", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers=_user_pgrst_headers({"Content-Type": "application/json", | |
| "Prefer": "return=representation"}), | |
| ) | |
| with urllib.request.urlopen(req, timeout=8.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| new_id = (rows[0] or {}).get("id") if isinstance(rows, list) and rows else None | |
| return {"ok": True, "id": new_id} | |
| except urllib.error.HTTPError as exc: | |
| body = "" | |
| try: | |
| body = exc.read().decode("utf-8")[:200] | |
| except Exception: # noqa: BLE001 | |
| pass | |
| logger.warning("primer_analyses insert HTTP %s: %s", exc.code, body) | |
| if exc.code in (404, 406) or "PGRST" in body: | |
| return {"ok": False, "error": "Saved primer sets aren't enabled yet (database migration pending)."} | |
| return {"ok": False, "error": "Couldn't save the primer set. Please try again."} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("primer_analyses insert failed: %s", exc) | |
| return {"ok": False, "error": "Couldn't save the primer set. Please try again."} | |
| def list_primer_analyses(user_id: Optional[str]) -> list: | |
| """The user's saved primer sets (metadata only, newest first). [] on error.""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/primer_analyses" | |
| f"?user_id=eq.{user_id}" | |
| f"&select=id,label,created_at,expires_at,organism,template_len,n_primers" | |
| f"&order=created_at.desc&limit={_PRIMER_ANALYSES_LIST_LIMIT}") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("primer_analyses list failed for %s: %s", user_id, exc) | |
| return [] | |
| def get_primer_analysis(user_id: Optional[str], analysis_id: str) -> Optional[Dict[str, Any]]: | |
| """One full saved primer set incl. result, enforcing ownership server-side.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return None | |
| if not _re.match(r"^[0-9a-fA-F-]{36}$", analysis_id or ""): | |
| return None | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/primer_analyses" | |
| f"?id=eq.{analysis_id}&user_id=eq.{user_id}&select=*&limit=1") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| return rows[0] if isinstance(rows, list) and rows else None | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("primer_analyses get failed: %s", exc) | |
| return None | |
| def delete_primer_analysis(user_id: Optional[str], analysis_id: str) -> Dict[str, Any]: | |
| """Delete one saved primer set, owner-enforced.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "Not configured."} | |
| if not _re.match(r"^[0-9a-fA-F-]{36}$", analysis_id or ""): | |
| return {"ok": False, "error": "Bad id."} | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/primer_analyses" | |
| f"?id=eq.{analysis_id}&user_id=eq.{user_id}") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_user_pgrst_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=6.0): | |
| return {"ok": True} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("primer_analyses delete failed: %s", exc) | |
| return {"ok": False, "error": "Couldn't delete."} | |
| def cleanup_expired_primer_analyses_async(user_id: Optional[str]) -> None: | |
| """Fire-and-forget delete of this user's expired primer sets (lazy sweep).""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| import threading | |
| threading.Thread(target=_do_cleanup_expired_primer_analyses, | |
| args=(user_id,), daemon=True).start() | |
| def _do_cleanup_expired_primer_analyses(user_id: str) -> None: | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/primer_analyses" | |
| f"?user_id=eq.{user_id}&expires_at=lt.now()") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_supabase_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=4.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("primer_analyses cleanup failed for %s: %s", user_id, exc) | |
| # βββ DE outcomes: per-library wet-lab results (the learning flywheel) ββ | |
| # One row per measured variant; the surrogate fits on these for round 2. | |
| # Table: public.de_outcomes (migration 0007). | |
| _DE_OUTCOMES_MAX = 2000 # max measured variants stored per library | |
| _UUIDISH = r"^[0-9a-fA-F-]{32,36}$" # accept 32-hex or dashed uuid (libraries.id form) | |
| def save_de_outcomes(user_id: Optional[str], library_id: str, rows: list) -> Dict[str, Any]: | |
| """Replace this user's logged outcomes for a library with `rows` | |
| (each {mutations, measured_value, outcome}). Returns {ok, n} or error.""" | |
| import re as _re | |
| if not user_id: | |
| return {"ok": False, "error": "Sign in to log results."} | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "Saving isn't configured on this server."} | |
| if not _re.match(_UUIDISH, library_id or ""): | |
| return {"ok": False, "error": "Bad library id."} | |
| rows = (rows or [])[:_DE_OUTCOMES_MAX] | |
| from datetime import datetime, timezone, timedelta | |
| expires_at = (None if has_pro_plan(user_id) else | |
| (datetime.now(timezone.utc) + timedelta(days=FREE_LIBRARY_TTL_DAYS)).isoformat()) | |
| payload = [] | |
| for r in rows: | |
| if not isinstance(r, dict): | |
| continue | |
| outcome = str(r.get("outcome", "measured") or "measured")[:16] | |
| mv = r.get("measured_value") | |
| try: | |
| mv = float(mv) if mv is not None and mv != "" else None | |
| except (TypeError, ValueError): | |
| mv = None | |
| payload.append({ | |
| "user_id": user_id, | |
| "library_id": library_id, | |
| "mutations": str(r.get("mutations", "") or "")[:300], | |
| "measured_value": mv, | |
| "outcome": outcome, | |
| "expires_at": expires_at, | |
| }) | |
| import urllib.request | |
| import urllib.error | |
| # Replace semantics: clear this library's prior outcomes, then insert. | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/de_outcomes" | |
| f"?user_id=eq.{user_id}&library_id=eq.{library_id}") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_user_pgrst_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=6.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("de_outcomes clear failed: %s", exc) | |
| if not payload: | |
| return {"ok": True, "n": 0} | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/de_outcomes", | |
| data=json.dumps(payload).encode("utf-8"), | |
| method="POST", | |
| headers=_user_pgrst_headers({"Content-Type": "application/json", | |
| "Prefer": "return=minimal"}), | |
| ) | |
| with urllib.request.urlopen(req, timeout=8.0): | |
| return {"ok": True, "n": len(payload)} | |
| except urllib.error.HTTPError as exc: | |
| body = "" | |
| try: | |
| body = exc.read().decode("utf-8")[:200] | |
| except Exception: # noqa: BLE001 | |
| pass | |
| logger.warning("de_outcomes insert HTTP %s: %s", exc.code, body) | |
| if exc.code in (404, 406) or "PGRST" in body: | |
| return {"ok": False, "error": "Result logging isn't enabled yet (database migration pending)."} | |
| return {"ok": False, "error": "Couldn't save your results. Please try again."} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("de_outcomes insert failed: %s", exc) | |
| return {"ok": False, "error": "Couldn't save your results. Please try again."} | |
| def list_de_outcomes(user_id: Optional[str], library_id: str) -> list: | |
| """This user's logged outcomes for one library (newest first). [] on error.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| if not _re.match(_UUIDISH, library_id or ""): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/de_outcomes" | |
| f"?user_id=eq.{user_id}&library_id=eq.{library_id}" | |
| f"&select=id,mutations,measured_value,outcome,created_at" | |
| f"&order=created_at.desc&limit={_DE_OUTCOMES_MAX}") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("de_outcomes list failed: %s", exc) | |
| return [] | |
| def cleanup_expired_de_outcomes_async(user_id: Optional[str]) -> None: | |
| """Fire-and-forget delete of this user's expired outcome rows (lazy sweep).""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return | |
| import threading | |
| threading.Thread(target=_do_cleanup_expired_de_outcomes, | |
| args=(user_id,), daemon=True).start() | |
| def _do_cleanup_expired_de_outcomes(user_id: str) -> None: | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/de_outcomes" | |
| f"?user_id=eq.{user_id}&expires_at=lt.now()") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_supabase_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=4.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("de_outcomes cleanup failed for %s: %s", user_id, exc) | |
| # βββ Cross-user AGGREGATE prior (the data moat) ββββββββββββββββββββββ | |
| # Table: public.mutation_priors (migration 0008). De-identified, pooled | |
| # substitution-effect statistics. The functions below use the service-role | |
| # key (which bypasses RLS) β the unfiltered de_outcomes read is the ONLY | |
| # place we read across users, and it pulls just the fields the aggregation | |
| # needs (no IPs, no sequences). See dee/core/aggregate.py for the math + the | |
| # 2026-07-08 execution gate. | |
| _AGG_OUTCOME_SCAN_MAX = 100000 # hard ceiling on rows scanned per rebuild | |
| def list_all_de_outcomes_grouped() -> list: | |
| """ALL users' outcomes, grouped per (user_id, library_id), for aggregation. | |
| Returns [(user_id, [(mutations, measured_value), ...]), ...]. Service-role | |
| read with NO user filter (this is the aggregation path). Only mutations + | |
| measured_value are pulled. [] on error/misconfig. | |
| """ | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/de_outcomes" | |
| f"?select=user_id,library_id,mutations,measured_value" | |
| f"&measured_value=not.is.null" | |
| f"&order=user_id,library_id" | |
| f"&limit={_AGG_OUTCOME_SCAN_MAX}") | |
| req = urllib.request.Request(url, method="GET", headers=_supabase_headers()) | |
| with urllib.request.urlopen(req, timeout=20.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("de_outcomes aggregate scan failed: %s", exc) | |
| return [] | |
| grouped: Dict[tuple, list] = {} | |
| for r in rows: | |
| key = (r.get("user_id"), r.get("library_id")) | |
| if not key[0] or not key[1]: | |
| continue | |
| grouped.setdefault(key, []).append((r.get("mutations") or "", r.get("measured_value"))) | |
| # collapse to (user_id, measurements) β aggregation keys k-anon by user_id, | |
| # so each (user, library) is one contributing unit. | |
| return [(uid, meas) for (uid, _lib), meas in grouped.items()] | |
| def list_all_de_outcomes_grouped_with_lib() -> list: | |
| """Like list_all_de_outcomes_grouped but keeps library_id so the richer | |
| (ESM-binned) rebuild can fetch each library's WT. [(user_id, library_id, | |
| measurements), β¦].""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/de_outcomes" | |
| f"?select=user_id,library_id,mutations,measured_value" | |
| f"&measured_value=not.is.null&order=user_id,library_id" | |
| f"&limit={_AGG_OUTCOME_SCAN_MAX}") | |
| req = urllib.request.Request(url, method="GET", headers=_supabase_headers()) | |
| with urllib.request.urlopen(req, timeout=20.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("de_outcomes lib-aware scan failed: %s", exc) | |
| return [] | |
| grouped: Dict[tuple, list] = {} | |
| for r in rows: | |
| key = (r.get("user_id"), r.get("library_id")) | |
| if key[0] and key[1]: | |
| grouped.setdefault(key, []).append((r.get("mutations") or "", r.get("measured_value"))) | |
| return [(uid, lib, meas) for (uid, lib), meas in grouped.items()] | |
| def get_library_wt(library_id: str) -> Optional[str]: | |
| """The wild-type protein stored with a saved DE library (service-role).""" | |
| import re as _re | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY) or not _re.match(_UUIDISH, library_id or ""): | |
| return None | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/libraries" | |
| f"?id=eq.{library_id}&select=wt_protein&limit=1") | |
| req = urllib.request.Request(url, method="GET", headers=_supabase_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) or [] | |
| return (data[0].get("wt_protein") or None) if data else None | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("library wt fetch failed: %s", exc) | |
| return None | |
| def get_mutation_priors() -> list: | |
| """The stored aggregate rows (service-role read). [] if none/misconfig.""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/mutation_priors" | |
| f"?select=substitution,n_users,n_obs,mean_effect&order=substitution") | |
| req = urllib.request.Request(url, method="GET", headers=_supabase_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("mutation_priors read failed: %s", exc) | |
| return [] | |
| def replace_mutation_priors(rows: list) -> Dict[str, Any]: | |
| """Replace the aggregate table with a fresh set of de-identified rows. | |
| Each row: {substitution, n_users, n_obs, mean_effect}. Service-role. | |
| """ | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "supabase-not-configured"} | |
| import urllib.request | |
| # wipe then upsert (the aggregate is fully recomputed each run) | |
| try: | |
| del_url = f"{SUPABASE_URL}/rest/v1/mutation_priors?substitution=neq.__none__" | |
| req = urllib.request.Request(del_url, method="DELETE", | |
| headers=_supabase_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=10.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("mutation_priors clear failed: %s", exc) | |
| if not rows: | |
| return {"ok": True, "written": 0} | |
| try: | |
| payload = json.dumps(rows).encode("utf-8") | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/mutation_priors", | |
| data=payload, method="POST", | |
| headers=_supabase_headers({"Content-Type": "application/json", | |
| "Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=15.0): | |
| return {"ok": True, "written": len(rows)} | |
| except urllib.error.HTTPError as exc: # noqa: BLE001 | |
| body = exc.read().decode("utf-8", "replace")[:300] | |
| logger.warning("mutation_priors insert HTTP %s: %s", exc.code, body) | |
| return {"ok": False, "error": f"http-{exc.code}", "detail": body} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("mutation_priors insert failed: %s", exc) | |
| return {"ok": False, "error": str(exc)} | |
| # βββ Unified outcomes framework (public.outcomes / public.outcome_priors) βββββ | |
| # Generic, tool-tagged version of the DE outcome CRUD above. Any tool's capture | |
| # UI POSTs here; the per-tool aggregate (dee/core/outcomes.py) reads across users | |
| # and writes outcome_priors. See migration 0009. DE keeps its dedicated tables. | |
| _OUTCOMES_MAX = 2000 | |
| _TOOL_RE = __import__("re").compile(r"^[a-z][a-z0-9_]{1,23}$") # valid tool slug | |
| def save_outcomes(user_id: Optional[str], tool: str, design_id: str, rows: list) -> Dict[str, Any]: | |
| """Replace this user's outcomes for (tool, design_id). rows: [{subject, | |
| measured_value?, outcome?}]. Mirrors save_de_outcomes.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return {"ok": False, "error": "not-configured"} | |
| if not _TOOL_RE.match(tool or ""): | |
| return {"ok": False, "error": "bad-tool"} | |
| if not _re.match(_UUIDISH, design_id or ""): | |
| return {"ok": False, "error": "bad-design-id"} | |
| rows = (rows or [])[:_OUTCOMES_MAX] | |
| from datetime import datetime as _dtt, timezone as _tz, timedelta as _td | |
| expires_at = (None if has_pro_plan(user_id) | |
| else (_dtt.now(_tz.utc) + _td(days=FREE_LIBRARY_TTL_DAYS)).isoformat()) | |
| payload = [] | |
| for r in rows: | |
| if not isinstance(r, dict): | |
| continue | |
| mv = r.get("measured_value") | |
| try: | |
| mv = float(mv) if mv is not None and mv != "" else None | |
| except (TypeError, ValueError): | |
| mv = None | |
| payload.append({ | |
| "user_id": user_id, "tool": tool, "design_id": design_id, | |
| "subject": str(r.get("subject", ""))[:512], | |
| "measured_value": mv, | |
| "outcome": str(r.get("outcome", "measured"))[:32], | |
| "expires_at": expires_at, | |
| }) | |
| import urllib.request | |
| try: # replace-on-save: clear this user's rows for the design, then insert | |
| url = (f"{SUPABASE_URL}/rest/v1/outcomes" | |
| f"?user_id=eq.{user_id}&tool=eq.{tool}&design_id=eq.{design_id}") | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_user_pgrst_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=6.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcomes clear failed: %s", exc) | |
| if not payload: | |
| return {"ok": True, "saved": 0} | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/outcomes", data=json.dumps(payload).encode("utf-8"), | |
| method="POST", headers=_user_pgrst_headers({"Content-Type": "application/json", | |
| "Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=10.0): | |
| return {"ok": True, "saved": len(payload)} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcomes insert failed: %s", exc) | |
| return {"ok": False, "error": str(exc)} | |
| def list_outcomes(user_id: Optional[str], tool: str, design_id: str) -> list: | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| if not _TOOL_RE.match(tool or "") or not _re.match(_UUIDISH, design_id or ""): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/outcomes" | |
| f"?user_id=eq.{user_id}&tool=eq.{tool}&design_id=eq.{design_id}" | |
| f"&select=id,subject,measured_value,outcome,created_at" | |
| f"&order=created_at.desc&limit={_OUTCOMES_MAX}") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcomes list failed: %s", exc) | |
| return [] | |
| def list_all_outcomes_grouped(tool: str) -> list: | |
| """ALL users' outcomes for one tool, grouped per (user, design) for the | |
| aggregate. [(user_id, [(subject, measured_value), β¦]), β¦]. Service-role.""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY) or not _TOOL_RE.match(tool or ""): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/outcomes" | |
| f"?tool=eq.{tool}&measured_value=not.is.null" | |
| f"&select=user_id,design_id,subject,measured_value" | |
| f"&order=user_id,design_id&limit=100000") | |
| req = urllib.request.Request(url, method="GET", headers=_supabase_headers()) | |
| with urllib.request.urlopen(req, timeout=20.0) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcomes aggregate scan failed: %s", exc) | |
| return [] | |
| grouped: Dict[tuple, list] = {} | |
| for r in data: | |
| key = (r.get("user_id"), r.get("design_id")) | |
| if key[0] and key[1]: | |
| grouped.setdefault(key, []).append((r.get("subject") or "", r.get("measured_value"))) | |
| return [(uid, rows) for (uid, _d), rows in grouped.items()] | |
| def get_outcome_priors(tool: str) -> list: | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY) or not _TOOL_RE.match(tool or ""): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/outcome_priors" | |
| f"?tool=eq.{tool}&select=tool,feature_key,n_users,n_obs,mean_effect&order=feature_key") | |
| req = urllib.request.Request(url, method="GET", headers=_supabase_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcome_priors read failed: %s", exc) | |
| return [] | |
| def replace_outcome_priors(tool: str, rows: list) -> Dict[str, Any]: | |
| """Replace one tool's aggregate rows. rows: [{tool, feature_key, n_users, | |
| n_obs, mean_effect}]. Service-role.""" | |
| if not (SUPABASE_URL and SUPABASE_SERVICE_KEY) or not _TOOL_RE.match(tool or ""): | |
| return {"ok": False, "error": "not-configured-or-bad-tool"} | |
| import urllib.request | |
| try: | |
| url = f"{SUPABASE_URL}/rest/v1/outcome_priors?tool=eq.{tool}" | |
| req = urllib.request.Request(url, method="DELETE", | |
| headers=_supabase_headers({"Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=10.0): | |
| pass | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcome_priors clear failed: %s", exc) | |
| if not rows: | |
| return {"ok": True, "written": 0} | |
| try: | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/outcome_priors", data=json.dumps(rows).encode("utf-8"), | |
| method="POST", headers=_supabase_headers({"Content-Type": "application/json", | |
| "Prefer": "return=minimal"})) | |
| with urllib.request.urlopen(req, timeout=15.0): | |
| return {"ok": True, "written": len(rows)} | |
| except urllib.error.HTTPError as exc: # noqa: BLE001 | |
| body = exc.read().decode("utf-8", "replace")[:300] | |
| logger.warning("outcome_priors insert HTTP %s: %s", exc.code, body) | |
| return {"ok": False, "error": f"http-{exc.code}", "detail": body} | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("outcome_priors insert failed: %s", exc) | |
| return {"ok": False, "error": str(exc)} | |
| # βββ Directed-Evolution libraries: per-user list + signed download ββββ | |
| # DE runs already persist to public.libraries + the `libraries` Storage | |
| # bucket (see _do_save_library). These surface them in the unified hub β | |
| # list metadata + mint a short-lived signed URL for the CSV download. | |
| _LIBRARIES_LIST_LIMIT = 100 | |
| def list_libraries(user_id: Optional[str]) -> list: | |
| """The user's saved DE libraries (metadata, newest first, non-expired).""" | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return [] | |
| import urllib.request | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/libraries" | |
| f"?user_id=eq.{user_id}" | |
| f"&select=id,name,created_at,expires_at,n_variants,top_fitness,csv_size_bytes" | |
| f"&or=(expires_at.gte.now,expires_at.is.null)" | |
| f"&order=created_at.desc&limit={_LIBRARIES_LIST_LIMIT}") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| return json.loads(resp.read().decode("utf-8")) or [] | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("libraries list failed for %s: %s", user_id, exc) | |
| return [] | |
| def library_csv_url(user_id: Optional[str], library_id: str) -> Optional[str]: | |
| """Signed, time-limited download URL for a library's CSV, owner-enforced. | |
| Returns an absolute URL or None. Never exposes the service key.""" | |
| import re as _re | |
| if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY): | |
| return None | |
| if not _re.match(r"^[0-9a-fA-F-]{36}$", library_id or ""): | |
| return None | |
| import urllib.request | |
| # 1. Fetch the row to confirm ownership and get the storage_path. | |
| try: | |
| url = (f"{SUPABASE_URL}/rest/v1/libraries" | |
| f"?id=eq.{library_id}&user_id=eq.{user_id}&select=storage_path&limit=1") | |
| req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers()) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| rows = json.loads(resp.read().decode("utf-8")) | |
| storage_path = (rows[0] if isinstance(rows, list) and rows else {}).get("storage_path") | |
| if not storage_path: | |
| return None | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("library_csv_url lookup failed: %s", exc) | |
| return None | |
| # 2. Mint a signed URL (valid 5 min) from Storage. | |
| try: | |
| sign_url = f"{SUPABASE_URL}/storage/v1/object/sign/libraries/{storage_path}" | |
| req = urllib.request.Request( | |
| sign_url, | |
| data=json.dumps({"expiresIn": 300}).encode("utf-8"), | |
| method="POST", | |
| headers=_supabase_headers({"Content-Type": "application/json"}), | |
| ) | |
| with urllib.request.urlopen(req, timeout=6.0) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) | |
| signed = (data or {}).get("signedURL") or (data or {}).get("signedUrl") | |
| if not signed: | |
| return None | |
| if signed.startswith("http"): | |
| return signed | |
| if not signed.startswith("/"): | |
| signed = "/" + signed | |
| # Storage returns "/object/sign/libraries/<path>?token=β¦" β prefix /storage/v1. | |
| return f"{SUPABASE_URL}{signed}" if signed.startswith("/storage/") else f"{SUPABASE_URL}/storage/v1{signed}" | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("library sign failed: %s", exc) | |
| return None | |
| def _anonymize_ip(ip: str) -> Optional[str]: | |
| """Drop the host-identifying bits of an IP so we keep coarse network | |
| provenance (useful for abuse triage) without persisting a full, | |
| person-linkable address. Standard GDPR IP-anonymization: zero the last | |
| IPv4 octet (/24) or collapse IPv6 to its first three hextets (~/48). | |
| Stays a *valid IP literal* so it's safe whether the runs.request_ip column | |
| is text or inet. Returns None for empty/garbage input. | |
| """ | |
| ip = (ip or "").strip() | |
| if not ip: | |
| return None | |
| if ":" in ip: # IPv6 | |
| parts = ip.split(":") | |
| head = [p for p in parts[:3] if p != ""] | |
| return ":".join(head) + "::" if head else None | |
| if "." in ip: # IPv4 | |
| octets = ip.split(".") | |
| if len(octets) == 4 and all(o.isdigit() and 0 <= int(o) <= 255 for o in octets): | |
| return f"{octets[0]}.{octets[1]}.{octets[2]}.0" | |
| return None # not a recognizable IP β don't store anything | |
| def _anon_fingerprint() -> str: | |
| """Stable-ish hash of IP + UA, salted with ANON_SECRET. Used only to | |
| rough-deduplicate anonymous runs in analytics. Not identity-grade β | |
| same household + same browser β same fingerprint.""" | |
| ip = request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip() | |
| ua = request.headers.get("User-Agent", "")[:512] | |
| data = f"{ip}|{ua}".encode("utf-8") | |
| return hmac.new( | |
| (ANON_SECRET or "no-secret").encode("utf-8"), | |
| data, | |
| hashlib.sha256, | |
| ).hexdigest()[:32] | |
| # βββ App registration βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def init_app(app: Flask) -> None: | |
| """Wire up the before_request hook on the Flask app.""" | |
| app.before_request(load_auth_into_g) | |
| if AUTH_ENABLED: | |
| logger.info( | |
| "Auth enabled: Supabase JWT + anon trial timer (%ds free window).", | |
| ANON_TRIAL_SECONDS, | |
| ) | |
| # Surface whether per-user DB calls run AS THE USER (RLS-enforced) or | |
| # fall back to the service-role key. The anon key is public, so this | |
| # is just an operator nudge, not a secret. | |
| if SUPABASE_SERVICE_KEY and not SUPABASE_ANON_KEY: | |
| logger.warning( | |
| "SUPABASE_ANON_KEY is unset β per-user reads/writes will use the " | |
| "service-role key and Row-Level Security is NOT enforced at the " | |
| "row level (the user_id=eq. filters are the only guard). Set " | |
| "SUPABASE_ANON_KEY (the public anon key) to enable RLS-enforced " | |
| "per-user access." | |
| ) | |
| elif SUPABASE_ANON_KEY: | |
| logger.info("Per-user DB access runs as the user β RLS enforced in Postgres.") | |
| else: | |
| logger.warning( | |
| "AUTH IS OFF β set SUPABASE_JWT_SECRET + TURINGDNA_ANON_SECRET " | |
| "as env vars to enable. All requests will be treated as " | |
| "anonymous with unlimited quota." | |
| ) | |