File size: 9,852 Bytes
3cdde15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4ce6eb
 
 
 
3cdde15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d4d3e9
3cdde15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4ce6eb
 
 
 
 
 
3cdde15
 
 
 
 
 
 
 
 
e4ce6eb
 
3cdde15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""
AgentIC Auth β€” Supabase JWT middleware + plan/build-count guard.

Env vars required:
    SUPABASE_URL          – e.g. https://xyz.supabase.co
    SUPABASE_SERVICE_KEY  – service-role key (server-side only, never expose)
    SUPABASE_JWT_SECRET   – JWT secret from Supabase dashboard β†’ Settings β†’ API
    ENCRYPTION_KEY        – symmetric key for encrypting BYOK API keys (32+ chars)
"""

import hashlib
import hmac
import json
import os
import time
from functools import lru_cache
from typing import Optional, Tuple

import httpx
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

# ─── Config ──────────────────────────────────────────────────────────
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "")
# ENCRYPTION_KEY must be set in production via env var β€” never rely on a default.
# If unset, BYOK key storage is disabled with a clear error rather than silently
# using a publicly-known default key that would let anyone decrypt stored keys.
ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "")

AUTH_ENABLED = bool(SUPABASE_URL and SUPABASE_SERVICE_KEY and SUPABASE_JWT_SECRET)

# Plan limits: max successful builds allowed (None = unlimited)
PLAN_LIMITS = {
    "free": 2,
    "starter": 25,
    "pro": None,   # unlimited
    "byok": None,  # unlimited, uses own key
}

_bearer = HTTPBearer(auto_error=False)


# ─── JWT Decode (no pyjwt dependency β€” use Supabase /auth/v1/user) ──
def _decode_supabase_jwt(token: str) -> dict:
    """Validate JWT by calling Supabase auth endpoint.

    We call GET /auth/v1/user with the user's access_token.
    Supabase verifies the JWT signature and returns the user object.
    """
    resp = httpx.get(
        f"{SUPABASE_URL}/auth/v1/user",
        headers={
            "Authorization": f"Bearer {token}",
            "apikey": SUPABASE_SERVICE_KEY,
        },
        timeout=10,
    )
    if resp.status_code != 200:
        raise HTTPException(status_code=401, detail="Invalid or expired token")
    return resp.json()


# ─── Supabase DB helpers (use service-role key) ─────────────────────
def _supabase_rpc(fn_name: str, params: dict) -> dict:
    """Call a Supabase RPC function."""
    resp = httpx.post(
        f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}",
        headers={
            "apikey": SUPABASE_SERVICE_KEY,
            "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
            "Content-Type": "application/json",
        },
        json=params,
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json() if resp.text else {}


def _supabase_query(table: str, select: str = "*", filters: str = "") -> list:
    """Simple REST query against Supabase PostgREST."""
    url = f"{SUPABASE_URL}/rest/v1/{table}?select={select}"
    if filters:
        url += f"&{filters}"
    resp = httpx.get(
        url,
        headers={
            "apikey": SUPABASE_SERVICE_KEY,
            "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
        },
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()


def _supabase_insert(table: str, data: dict) -> dict:
    resp = httpx.post(
        f"{SUPABASE_URL}/rest/v1/{table}",
        headers={
            "apikey": SUPABASE_SERVICE_KEY,
            "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
            "Content-Type": "application/json",
            "Prefer": "return=representation",
        },
        json=data,
        timeout=10,
    )
    resp.raise_for_status()
    rows = resp.json()
    return rows[0] if rows else {}


def _supabase_update(table: str, filters: str, data: dict) -> dict:
    resp = httpx.patch(
        f"{SUPABASE_URL}/rest/v1/{table}?{filters}",
        headers={
            "apikey": SUPABASE_SERVICE_KEY,
            "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
            "Content-Type": "application/json",
            "Prefer": "return=representation",
        },
        json=data,
        timeout=10,
    )
    resp.raise_for_status()
    rows = resp.json()
    return rows[0] if rows else {}


# ─── BYOK Encryption ────────────────────────────────────────────────
def encrypt_api_key(plaintext: str) -> str:
    """XOR-based encryption with HMAC integrity check."""
    if not ENCRYPTION_KEY:
        raise RuntimeError(
            "ENCRYPTION_KEY env var is not set. "
            "Set a secret 32+ character value in HuggingFace Spaces secrets before storing BYOK keys."
        )
    key_bytes = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()
    ct = bytes(a ^ b for a, b in zip(plaintext.encode(), (key_bytes * ((len(plaintext) // 32) + 1))))
    mac = hmac.new(key_bytes, ct, hashlib.sha256).hexdigest()
    import base64
    return base64.urlsafe_b64encode(ct).decode() + "." + mac


def decrypt_api_key(ciphertext: str) -> str:
    import base64
    if not ENCRYPTION_KEY:
        raise RuntimeError("ENCRYPTION_KEY env var is not set β€” cannot decrypt stored API key.")
    parts = ciphertext.split(".", 1)
    if len(parts) != 2:
        raise ValueError("Malformed encrypted key")
    ct = base64.urlsafe_b64decode(parts[0])
    mac = parts[1]
    key_bytes = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()
    expected_mac = hmac.new(key_bytes, ct, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(mac, expected_mac):
        raise ValueError("Integrity check failed β€” key may have been tampered with")
    pt = bytes(a ^ b for a, b in zip(ct, (key_bytes * ((len(ct) // 32) + 1))))
    return pt.decode()


# ─── FastAPI Dependency: get current user ────────────────────────────
async def get_current_user(
    request: Request,
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
) -> Optional[dict]:
    """Extract and validate the Supabase JWT from the Authorization header.

    Returns the user profile dict or None if auth is disabled.
    When auth is enabled but no valid token is provided, raises 401.
    """
    if not AUTH_ENABLED:
        return None  # Auth not configured β€” allow anonymous access

    if not credentials:
        raise HTTPException(status_code=401, detail="Missing Authorization header")

    token = credentials.credentials
    user = _decode_supabase_jwt(token)
    uid = user.get("id")
    if not uid:
        raise HTTPException(status_code=401, detail="Invalid user")

    # Fetch profile from DB
    profiles = _supabase_query("profiles", filters=f"id=eq.{uid}")
    if not profiles:
        raise HTTPException(status_code=404, detail="Profile not found. Sign up first.")

    return profiles[0]


# ─── Build Guard: check plan + build count ───────────────────────────
def check_build_allowed(profile: Optional[dict]) -> None:
    """Raise 402 if the user has exhausted their plan's build quota.

    Called before every /build request when auth is enabled.
    """
    if profile is None:
        return  # Auth disabled β€” no restrictions

    plan = profile.get("plan", "free")
    builds = profile.get("successful_builds", 0)
    limit = PLAN_LIMITS.get(plan)

    if limit is not None and builds >= limit:
        raise HTTPException(
            status_code=402,
            detail={
                "error": "build_limit_reached",
                "plan": plan,
                "used": builds,
                "limit": limit,
                "message": f"You've used all {limit} builds on the {plan} plan. Upgrade to continue building chips.",
                "upgrade_url": "/pricing",
            },
        )


def get_llm_key_for_user(profile: Optional[dict]) -> Optional[str]:
    """Return the user's own LLM API key if they're on the BYOK plan.

    Returns None for all other plans (server uses global NVIDIA_API_KEY).
    """
    if profile is None:
        return None

    if profile.get("plan") != "byok":
        return None

    encrypted_key = profile.get("llm_api_key")
    if not encrypted_key:
        raise HTTPException(
            status_code=400,
            detail="BYOK plan requires an API key. Set it in your profile settings.",
        )

    try:
        return decrypt_api_key(encrypted_key)
    except ValueError:
        raise HTTPException(status_code=500, detail="Failed to decrypt stored API key")


def record_build_start(profile: Optional[dict], job_id: str, design_name: str) -> None:
    """Insert a build record into the builds table."""
    if profile is None or not AUTH_ENABLED:
        return
    _supabase_insert("builds", {
        "user_id": profile["id"],
        "job_id": job_id,
        "design_name": design_name,
        "status": "queued",
    })


def record_build_success(profile: Optional[dict], job_id: str) -> None:
    """Mark build as done and increment the user's successful_builds count."""
    if profile is None or not AUTH_ENABLED:
        return
    uid = profile["id"]
    # Update build row
    _supabase_update("builds", f"job_id=eq.{job_id}", {
        "status": "done",
        "finished_at": "now()",
    })
    # Increment counter
    _supabase_rpc("increment_successful_builds", {"uid": uid})


def record_build_failure(job_id: str) -> None:
    """Mark build as failed."""
    if not AUTH_ENABLED:
        return
    _supabase_update("builds", f"job_id=eq.{job_id}", {
        "status": "failed",
        "finished_at": "now()",
    })