gaurv007 commited on
Commit
e3f2df1
·
verified ·
1 Parent(s): 94c4c90

v3: All missing features — auth callback, settings, PDF export, email (Resend), JWT auth, /api/history, SaulLM integration, extension icons, Supabase 0.10 breaking changes, Stripe v22

Browse files
api/auth.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ClauseGuard — JWT Authentication for FastAPI
3
+ Validates Supabase JWTs using JWKS (RS256).
4
+ """
5
+
6
+ import os
7
+ from functools import lru_cache
8
+ from typing import Optional
9
+
10
+ import httpx
11
+ from fastapi import Depends, HTTPException, Header
12
+ from jose import jwt, JWTError, jwk
13
+ from jose.utils import base64url_decode
14
+
15
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
16
+ SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "")
17
+
18
+ _jwks_cache: Optional[dict] = None
19
+
20
+
21
+ async def _get_jwks() -> dict:
22
+ """Fetch and cache JWKS from Supabase."""
23
+ global _jwks_cache
24
+ if _jwks_cache:
25
+ return _jwks_cache
26
+
27
+ if not SUPABASE_URL:
28
+ return {}
29
+
30
+ async with httpx.AsyncClient() as client:
31
+ resp = await client.get(f"{SUPABASE_URL}/auth/v1/jwks")
32
+ resp.raise_for_status()
33
+ _jwks_cache = resp.json()
34
+ return _jwks_cache
35
+
36
+
37
+ def _verify_with_secret(token: str) -> dict:
38
+ """Verify JWT using Supabase JWT secret (HS256 fallback)."""
39
+ if not SUPABASE_JWT_SECRET:
40
+ raise HTTPException(status_code=500, detail="JWT secret not configured")
41
+
42
+ try:
43
+ payload = jwt.decode(
44
+ token,
45
+ SUPABASE_JWT_SECRET,
46
+ algorithms=["HS256"],
47
+ audience="authenticated",
48
+ )
49
+ return payload
50
+ except JWTError as e:
51
+ raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
52
+
53
+
54
+ async def _verify_with_jwks(token: str) -> dict:
55
+ """Verify JWT using JWKS endpoint (RS256)."""
56
+ jwks = await _get_jwks()
57
+ if not jwks or "keys" not in jwks:
58
+ return _verify_with_secret(token)
59
+
60
+ try:
61
+ unverified_header = jwt.get_unverified_header(token)
62
+ kid = unverified_header.get("kid")
63
+
64
+ key = None
65
+ for k in jwks["keys"]:
66
+ if k.get("kid") == kid:
67
+ key = k
68
+ break
69
+
70
+ if not key:
71
+ raise HTTPException(status_code=401, detail="Token key ID not found in JWKS")
72
+
73
+ payload = jwt.decode(
74
+ token,
75
+ key,
76
+ algorithms=["RS256"],
77
+ audience="authenticated",
78
+ )
79
+ return payload
80
+ except JWTError as e:
81
+ raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
82
+
83
+
84
+ async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
85
+ """
86
+ Extract and validate user from Authorization header.
87
+ Returns None for unauthenticated requests (free tier).
88
+ Raises 401 for invalid tokens.
89
+ """
90
+ if not authorization:
91
+ return None
92
+
93
+ token = authorization.replace("Bearer ", "")
94
+ if not token:
95
+ return None
96
+
97
+ try:
98
+ payload = await _verify_with_jwks(token)
99
+ return {
100
+ "id": payload.get("sub"),
101
+ "email": payload.get("email"),
102
+ "role": payload.get("role", "authenticated"),
103
+ }
104
+ except HTTPException:
105
+ raise
106
+ except Exception:
107
+ raise HTTPException(status_code=401, detail="Authentication failed")
108
+
109
+
110
+ async def require_auth(user: Optional[dict] = Depends(get_current_user)) -> dict:
111
+ """Dependency that requires a valid authenticated user."""
112
+ if not user:
113
+ raise HTTPException(status_code=401, detail="Authentication required")
114
+ return user
api/main.py CHANGED
@@ -1,42 +1,35 @@
1
  """
2
- ClauseGuard — FastAPI Backend
3
- Serves clause classification via Legal-BERT ONNX model + SaulLM explanations.
4
- Production-ready: CORS, rate limiting, JWT auth, usage tracking.
5
-
6
- Compatible with: FastAPI 0.115+, Pydantic 2.x, Python 3.11+ (April 2026)
7
  """
8
 
9
  import os
10
  import time
11
- import json
12
  import re
13
  from contextlib import asynccontextmanager
14
  from typing import Optional
15
 
 
16
  import numpy as np
17
- from fastapi import FastAPI, HTTPException, Depends, Header, Request
18
  from fastapi.middleware.cors import CORSMiddleware
19
- from fastapi.responses import JSONResponse
20
  from pydantic import BaseModel, Field
21
 
 
 
22
  # ─── Config ───
23
  MODEL_PATH = os.environ.get("MODEL_PATH", "./clauseguard-model/final")
24
  ONNX_MODEL_PATH = os.environ.get("ONNX_MODEL_PATH", "./clauseguard-model-onnx")
25
  USE_ONNX = os.environ.get("USE_ONNX", "true").lower() == "true"
26
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
27
- SUPABASE_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
28
- RATE_LIMIT_FREE = int(os.environ.get("RATE_LIMIT_FREE", "10")) # per day
29
- RATE_LIMIT_PRO = int(os.environ.get("RATE_LIMIT_PRO", "1000")) # per day
30
 
31
  LABEL_NAMES = [
32
- "Limitation of liability",
33
- "Unilateral termination",
34
- "Unilateral change",
35
- "Content removal",
36
- "Contract by using",
37
- "Choice of law",
38
- "Jurisdiction",
39
- "Arbitration",
40
  ]
41
 
42
  LABEL_DESCRIPTIONS = {
@@ -44,29 +37,33 @@ LABEL_DESCRIPTIONS = {
44
  "Unilateral termination": "Company can terminate your account at any time without reason.",
45
  "Unilateral change": "Company can change terms at any time without your consent.",
46
  "Content removal": "Company can delete your content without notice or justification.",
47
- "Contract by using": "You're bound to the contract simply by using the service — a dark pattern.",
48
- "Choice of law": "Governing law may differ from your country reducing your legal protections.",
49
  "Jurisdiction": "Disputes must be resolved in a jurisdiction that may disadvantage you.",
50
- "Arbitration": "Forces disputes to arbitration instead of court you waive your right to sue.",
51
  }
52
 
53
  SEVERITY_MAP = {
54
- "Limitation of liability": "HIGH",
55
- "Unilateral termination": "HIGH",
56
- "Arbitration": "HIGH",
57
- "Unilateral change": "MEDIUM",
58
- "Content removal": "MEDIUM",
59
- "Choice of law": "MEDIUM",
60
- "Jurisdiction": "MEDIUM",
61
- "Contract by using": "LOW",
62
  }
63
 
64
- # ─── Model Loading ───
65
- classifier = None
 
 
 
 
 
 
 
 
66
 
 
 
67
 
68
  def load_model():
69
- """Load the ML model (ONNX preferred, PyTorch fallback)."""
70
  global classifier
71
  try:
72
  if USE_ONNX and os.path.exists(ONNX_MODEL_PATH):
@@ -75,103 +72,90 @@ def load_model():
75
  model = ORTModelForSequenceClassification.from_pretrained(ONNX_MODEL_PATH)
76
  tokenizer = AutoTokenizer.from_pretrained(ONNX_MODEL_PATH)
77
  classifier = pipeline("text-classification", model=model, tokenizer=tokenizer, top_k=None)
78
- print(f"✅ Loaded ONNX model from {ONNX_MODEL_PATH}")
79
  elif os.path.exists(MODEL_PATH):
80
  from transformers import pipeline
81
  classifier = pipeline("text-classification", model=MODEL_PATH, top_k=None, device=-1)
82
- print(f"✅ Loaded PyTorch model from {MODEL_PATH}")
83
- else:
84
- print("⚠️ No model found — using regex fallback")
85
  except Exception as e:
86
- print(f"⚠️ Model load failed: {e} — using regex fallback")
87
-
88
 
89
- # ─── Regex Fallback ───
90
- CLAUSE_PATTERNS = {
91
- 0: [r"not liable", r"shall not be (liable|responsible)", r"in no event.*liable",
92
- r"limitation of liability", r"without warranty", r"disclaim"],
93
- 1: [r"terminat.*at any time", r"suspend.*account.*without",
94
- r"we may (terminat|suspend|discontinu)", r"right to (terminat|suspend)"],
95
- 2: [r"sole discretion", r"reserves? the right to (modify|change|update|amend)",
96
- r"at any time.*without (prior )?notice", r"we may (modify|change|update)"],
97
  3: [r"remove.*content.*without", r"right to remove", r"we may.*remove"],
98
  4: [r"by (using|accessing).*you agree", r"continued use.*constitutes? acceptance"],
99
  5: [r"governed by.*laws? of", r"shall be governed", r"laws of the state of"],
100
- 6: [r"exclusive jurisdiction", r"courts? of.*(california|delaware|new york|ireland|england)",
101
- r"submit to.*jurisdiction"],
102
  7: [r"arbitrat", r"binding arbitration", r"waive.*right.*court", r"class action waiver"],
103
  }
104
 
105
-
106
- def classify_regex(text: str) -> list[dict]:
107
- """Fallback: classify using regex patterns."""
 
 
 
 
 
 
 
 
 
 
 
108
  results = []
109
  text_lower = text.lower()
110
- for label_id, patterns in CLAUSE_PATTERNS.items():
111
- for pattern in patterns:
112
- if re.search(pattern, text_lower):
113
- name = LABEL_NAMES[label_id]
114
- results.append({
115
- "id": label_id,
116
- "name": name,
117
- "severity": SEVERITY_MAP[name],
118
- "description": LABEL_DESCRIPTIONS[name],
119
- "confidence": 0.7,
120
- })
121
  break
122
  return results
123
 
124
-
125
- def classify_ml(text: str) -> list[dict]:
126
- """Classify using the ML model."""
127
- if classifier is None:
128
- return classify_regex(text)
129
-
130
- try:
131
- preds = classifier(text, truncation=True, max_length=512)
132
- results = []
133
- for p in preds[0] if isinstance(preds[0], list) else preds:
134
- label = p["label"]
135
- score = p["score"]
136
- if score > 0.5 and label in LABEL_DESCRIPTIONS:
137
- results.append({
138
- "id": LABEL_NAMES.index(label) if label in LABEL_NAMES else -1,
139
- "name": label,
140
- "severity": SEVERITY_MAP.get(label, "MEDIUM"),
141
- "description": LABEL_DESCRIPTIONS[label],
142
- "confidence": round(score, 3),
143
- })
144
- return results
145
- except Exception:
146
- return classify_regex(text)
147
-
148
-
149
- # ─── Request/Response Models ───
150
  class AnalyzeRequest(BaseModel):
151
  clauses: list[str] = Field(..., min_length=1, max_length=500)
152
  source_url: Optional[str] = None
153
 
154
-
155
- class ClauseResult(BaseModel):
156
- text: str
157
- categories: list[dict]
158
-
159
-
160
  class AnalyzeResponse(BaseModel):
161
  risk_score: int
162
  grade: str
163
  total_clauses: int
164
  flagged_count: int
165
- results: list[ClauseResult]
166
- model: str # "ml" or "regex"
167
  latency_ms: int
168
 
169
-
170
  class ExplainRequest(BaseModel):
171
  clause: str = Field(..., min_length=10, max_length=2000)
172
  category: str
173
 
174
-
175
  class ExplainResponse(BaseModel):
176
  clause: str
177
  category: str
@@ -179,109 +163,101 @@ class ExplainResponse(BaseModel):
179
  legal_basis: str
180
  recommendation: str
181
 
182
-
183
  # ─── App ───
184
  @asynccontextmanager
185
  async def lifespan(app: FastAPI):
186
  load_model()
187
  yield
188
 
189
- app = FastAPI(
190
- title="ClauseGuard API",
191
- description="AI-powered unfair clause detection in Terms of Service, contracts, and legal documents.",
192
- version="1.0.0",
193
- lifespan=lifespan,
194
- )
195
 
196
  app.add_middleware(
197
  CORSMiddleware,
198
- allow_origins=[
199
- "https://clauseguard.com",
200
- "https://app.clauseguard.com",
201
- "chrome-extension://*",
202
- "http://localhost:3000", # dev
203
- ],
204
- allow_credentials=True,
205
- allow_methods=["GET", "POST", "OPTIONS"],
206
- allow_headers=["*"],
207
  )
208
 
209
-
210
- # ─── Routes ───
211
  @app.get("/health")
212
  async def health():
213
- return {"status": "ok", "model_loaded": classifier is not None}
214
-
215
 
216
  @app.post("/api/analyze", response_model=AnalyzeResponse)
217
- async def analyze(req: AnalyzeRequest):
218
  start = time.time()
219
 
220
- results = []
221
- for clause in req.clauses:
222
- categories = classify_ml(clause) if classifier else classify_regex(clause)
223
- results.append(ClauseResult(text=clause, categories=categories))
224
 
225
- flagged = [r for r in results if len(r.categories) > 0]
226
- sev_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
227
  for r in flagged:
228
- for c in r.categories:
229
- sev_counts[c.get("severity", "LOW")] += 1
230
 
231
  total = len(req.clauses)
232
- risk_score = min(100, int(
233
- (sev_counts["HIGH"] * 20 + sev_counts["MEDIUM"] * 10 + sev_counts["LOW"] * 5)
234
- / max(1, total) * 100
235
- ))
236
-
237
- if risk_score >= 60:
238
- grade = "F"
239
- elif risk_score >= 40:
240
- grade = "D"
241
- elif risk_score >= 20:
242
- grade = "C"
243
- elif risk_score >= 10:
244
- grade = "B"
245
- else:
246
- grade = "A"
247
-
248
  latency = int((time.time() - start) * 1000)
249
 
250
- return AnalyzeResponse(
251
- risk_score=risk_score,
252
- grade=grade,
253
- total_clauses=total,
254
- flagged_count=len(flagged),
255
- results=results,
256
- model="ml" if classifier else "regex",
257
- latency_ms=latency,
258
- )
259
 
 
 
 
260
 
261
  @app.post("/api/explain", response_model=ExplainResponse)
262
- async def explain(req: ExplainRequest):
263
- """Explain why a clause is unfair (premium feature — placeholder for SaulLM integration)."""
264
  desc = LABEL_DESCRIPTIONS.get(req.category, "Unknown category.")
265
-
266
- legal_basis_map = {
267
- "Arbitration": "EU Directive 93/13/EEC, Art. 3 — unfair terms in consumer contracts; CFPB arbitration rule (US).",
268
- "Unilateral change": "EU Directive 93/13/EEC, Annex 1(j) — terms enabling unilateral alteration.",
269
- "Content removal": "EU Digital Services Act (DSA), Art. 17 — statement of reasons required for content moderation.",
270
- "Jurisdiction": "EU Regulation 1215/2012 (Brussels I), Art. 18 — consumer's domicile prevails.",
271
- "Choice of law": "EU Regulation 593/2008 (Rome I), Art. 6 consumer protection of habitual residence.",
272
- "Limitation of liability": "EU Directive 93/13/EEC, Annex 1(a) — excluding statutory rights is unfair.",
273
- "Unilateral termination": "EU Directive 93/13/EEC, Annex 1(f)(g) — termination without reasonable notice.",
274
- "Contract by using": "EU Directive 2011/83/EU, Art. 8 — active consent required, not passive acceptance.",
275
- }
276
-
277
- return ExplainResponse(
278
- clause=req.clause,
279
- category=req.category,
280
- explanation=desc,
281
- legal_basis=legal_basis_map.get(req.category, "Consult local consumer protection laws."),
282
- recommendation="Consider negotiating this clause or seeking legal advice before agreeing.",
283
- )
284
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
  if __name__ == "__main__":
287
  import uvicorn
 
1
  """
2
+ ClauseGuard — FastAPI Backend (Production)
3
+ Clause classification + explanations + history + JWT auth.
4
+ FastAPI 0.136, Pydantic 2.13, Python 3.12 (April 2026)
 
 
5
  """
6
 
7
  import os
8
  import time
 
9
  import re
10
  from contextlib import asynccontextmanager
11
  from typing import Optional
12
 
13
+ import httpx
14
  import numpy as np
15
+ from fastapi import FastAPI, HTTPException, Depends
16
  from fastapi.middleware.cors import CORSMiddleware
 
17
  from pydantic import BaseModel, Field
18
 
19
+ from auth import get_current_user, require_auth
20
+
21
  # ─── Config ───
22
  MODEL_PATH = os.environ.get("MODEL_PATH", "./clauseguard-model/final")
23
  ONNX_MODEL_PATH = os.environ.get("ONNX_MODEL_PATH", "./clauseguard-model-onnx")
24
  USE_ONNX = os.environ.get("USE_ONNX", "true").lower() == "true"
25
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
26
+ SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
27
+ HF_API_TOKEN = os.environ.get("HF_API_TOKEN", "")
28
+ SAULLM_ENDPOINT = os.environ.get("SAULLM_ENDPOINT", "")
29
 
30
  LABEL_NAMES = [
31
+ "Limitation of liability", "Unilateral termination", "Unilateral change",
32
+ "Content removal", "Contract by using", "Choice of law", "Jurisdiction", "Arbitration",
 
 
 
 
 
 
33
  ]
34
 
35
  LABEL_DESCRIPTIONS = {
 
37
  "Unilateral termination": "Company can terminate your account at any time without reason.",
38
  "Unilateral change": "Company can change terms at any time without your consent.",
39
  "Content removal": "Company can delete your content without notice or justification.",
40
+ "Contract by using": "You are bound to the contract simply by using the service.",
41
+ "Choice of law": "Governing law may differ from your country, reducing your legal protections.",
42
  "Jurisdiction": "Disputes must be resolved in a jurisdiction that may disadvantage you.",
43
+ "Arbitration": "Forces disputes to arbitration instead of court. You waive your right to sue.",
44
  }
45
 
46
  SEVERITY_MAP = {
47
+ "Limitation of liability": "HIGH", "Unilateral termination": "HIGH", "Arbitration": "HIGH",
48
+ "Unilateral change": "MEDIUM", "Content removal": "MEDIUM", "Choice of law": "MEDIUM",
49
+ "Jurisdiction": "MEDIUM", "Contract by using": "LOW",
 
 
 
 
 
50
  }
51
 
52
+ LEGAL_BASIS = {
53
+ "Arbitration": "EU Directive 93/13/EEC Art. 3; CFPB arbitration rule (US).",
54
+ "Unilateral change": "EU Directive 93/13/EEC Annex 1(j) — unilateral alteration.",
55
+ "Content removal": "EU Digital Services Act Art. 17 — statement of reasons required.",
56
+ "Jurisdiction": "EU Regulation 1215/2012 Art. 18 — consumer domicile prevails.",
57
+ "Choice of law": "EU Regulation 593/2008 Art. 6 — consumer protection of habitual residence.",
58
+ "Limitation of liability": "EU Directive 93/13/EEC Annex 1(a) — excluding statutory rights.",
59
+ "Unilateral termination": "EU Directive 93/13/EEC Annex 1(f)(g) — termination without notice.",
60
+ "Contract by using": "EU Directive 2011/83/EU Art. 8 — active consent required.",
61
+ }
62
 
63
+ # ─── Model ───
64
+ classifier = None
65
 
66
  def load_model():
 
67
  global classifier
68
  try:
69
  if USE_ONNX and os.path.exists(ONNX_MODEL_PATH):
 
72
  model = ORTModelForSequenceClassification.from_pretrained(ONNX_MODEL_PATH)
73
  tokenizer = AutoTokenizer.from_pretrained(ONNX_MODEL_PATH)
74
  classifier = pipeline("text-classification", model=model, tokenizer=tokenizer, top_k=None)
 
75
  elif os.path.exists(MODEL_PATH):
76
  from transformers import pipeline
77
  classifier = pipeline("text-classification", model=MODEL_PATH, top_k=None, device=-1)
 
 
 
78
  except Exception as e:
79
+ print(f"Model load failed: {e}")
 
80
 
81
+ # ─── Regex fallback ───
82
+ PATTERNS = {
83
+ 0: [r"not liable", r"shall not be (liable|responsible)", r"in no event.*liable", r"limitation of liability", r"without warranty", r"disclaim"],
84
+ 1: [r"terminat.*at any time", r"suspend.*account.*without", r"we may (terminat|suspend|discontinu)", r"right to (terminat|suspend)"],
85
+ 2: [r"sole discretion", r"reserves? the right to (modify|change|update|amend)", r"at any time.*without (prior )?notice", r"we may (modify|change|update)"],
 
 
 
86
  3: [r"remove.*content.*without", r"right to remove", r"we may.*remove"],
87
  4: [r"by (using|accessing).*you agree", r"continued use.*constitutes? acceptance"],
88
  5: [r"governed by.*laws? of", r"shall be governed", r"laws of the state of"],
89
+ 6: [r"exclusive jurisdiction", r"courts? of.*(california|delaware|new york|ireland|england)", r"submit to.*jurisdiction"],
 
90
  7: [r"arbitrat", r"binding arbitration", r"waive.*right.*court", r"class action waiver"],
91
  }
92
 
93
+ def classify_clause(text: str) -> list[dict]:
94
+ if classifier:
95
+ try:
96
+ preds = classifier(text, truncation=True, max_length=512)
97
+ items = preds[0] if isinstance(preds[0], list) else preds
98
+ return [
99
+ {"name": p["label"], "severity": SEVERITY_MAP.get(p["label"], "MEDIUM"),
100
+ "description": LABEL_DESCRIPTIONS.get(p["label"], ""), "confidence": round(p["score"], 3)}
101
+ for p in items if p["score"] > 0.5 and p["label"] in LABEL_DESCRIPTIONS
102
+ ]
103
+ except Exception:
104
+ pass
105
+
106
+ # Regex fallback
107
  results = []
108
  text_lower = text.lower()
109
+ for lid, pats in PATTERNS.items():
110
+ for p in pats:
111
+ if re.search(p, text_lower):
112
+ name = LABEL_NAMES[lid]
113
+ results.append({"name": name, "severity": SEVERITY_MAP[name],
114
+ "description": LABEL_DESCRIPTIONS[name], "confidence": 0.7})
 
 
 
 
 
115
  break
116
  return results
117
 
118
+ # ─── Supabase helper ───
119
+ async def supabase_insert(table: str, data: dict):
120
+ if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
121
+ return
122
+ async with httpx.AsyncClient() as client:
123
+ await client.post(
124
+ f"{SUPABASE_URL}/rest/v1/{table}",
125
+ json=data,
126
+ headers={"apikey": SUPABASE_SERVICE_KEY, "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
127
+ "Content-Type": "application/json", "Prefer": "return=minimal"},
128
+ )
129
+
130
+ async def supabase_query(table: str, params: dict, headers_extra: dict = {}):
131
+ if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
132
+ return []
133
+ async with httpx.AsyncClient() as client:
134
+ resp = await client.get(
135
+ f"{SUPABASE_URL}/rest/v1/{table}",
136
+ params=params,
137
+ headers={"apikey": SUPABASE_SERVICE_KEY, "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", **headers_extra},
138
+ )
139
+ return resp.json() if resp.status_code == 200 else []
140
+
141
+ # ─── Models ───
 
 
142
  class AnalyzeRequest(BaseModel):
143
  clauses: list[str] = Field(..., min_length=1, max_length=500)
144
  source_url: Optional[str] = None
145
 
 
 
 
 
 
 
146
  class AnalyzeResponse(BaseModel):
147
  risk_score: int
148
  grade: str
149
  total_clauses: int
150
  flagged_count: int
151
+ results: list[dict]
152
+ model: str
153
  latency_ms: int
154
 
 
155
  class ExplainRequest(BaseModel):
156
  clause: str = Field(..., min_length=10, max_length=2000)
157
  category: str
158
 
 
159
  class ExplainResponse(BaseModel):
160
  clause: str
161
  category: str
 
163
  legal_basis: str
164
  recommendation: str
165
 
 
166
  # ─── App ───
167
  @asynccontextmanager
168
  async def lifespan(app: FastAPI):
169
  load_model()
170
  yield
171
 
172
+ app = FastAPI(title="ClauseGuard API", version="1.0.0", lifespan=lifespan)
 
 
 
 
 
173
 
174
  app.add_middleware(
175
  CORSMiddleware,
176
+ allow_origins=["https://clauseguard.com", "https://app.clauseguard.com", "chrome-extension://*", "http://localhost:3000"],
177
+ allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
 
 
 
 
 
 
 
178
  )
179
 
 
 
180
  @app.get("/health")
181
  async def health():
182
+ return {"status": "ok", "model": "ml" if classifier else "regex"}
 
183
 
184
  @app.post("/api/analyze", response_model=AnalyzeResponse)
185
+ async def analyze(req: AnalyzeRequest, user: Optional[dict] = Depends(get_current_user)):
186
  start = time.time()
187
 
188
+ results = [{"text": c, "categories": classify_clause(c)} for c in req.clauses]
189
+ flagged = [r for r in results if r["categories"]]
 
 
190
 
191
+ sev = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
 
192
  for r in flagged:
193
+ for c in r["categories"]:
194
+ sev[c.get("severity", "LOW")] += 1
195
 
196
  total = len(req.clauses)
197
+ risk = min(100, round((sev["HIGH"] * 20 + sev["MEDIUM"] * 10 + sev["LOW"] * 5) / max(1, total) * 100))
198
+ grade = "F" if risk >= 60 else "D" if risk >= 40 else "C" if risk >= 20 else "B" if risk >= 10 else "A"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  latency = int((time.time() - start) * 1000)
200
 
201
+ # Save to DB if authenticated
202
+ if user:
203
+ await supabase_insert("analyses", {
204
+ "user_id": user["id"], "source_url": req.source_url, "total_clauses": total,
205
+ "flagged_count": len(flagged), "risk_score": risk, "grade": grade, "clauses": results,
206
+ })
 
 
 
207
 
208
+ return AnalyzeResponse(risk_score=risk, grade=grade, total_clauses=total,
209
+ flagged_count=len(flagged), results=results,
210
+ model="ml" if classifier else "regex", latency_ms=latency)
211
 
212
  @app.post("/api/explain", response_model=ExplainResponse)
213
+ async def explain(req: ExplainRequest, user: dict = Depends(require_auth)):
 
214
  desc = LABEL_DESCRIPTIONS.get(req.category, "Unknown category.")
215
+ legal = LEGAL_BASIS.get(req.category, "Consult local consumer protection laws.")
216
+ recommendation = "Review this clause carefully. Consider negotiating or seeking legal advice before agreeing."
217
+
218
+ # Try SaulLM-7B if endpoint configured
219
+ if SAULLM_ENDPOINT and HF_API_TOKEN:
220
+ try:
221
+ prompt = f"""You are a consumer protection legal analyst. Analyze this clause and explain why it may be unfair.
222
+
223
+ Clause: "{req.clause}"
224
+ Category: {req.category}
225
+
226
+ Provide:
227
+ 1. A plain-English explanation of why this is problematic
228
+ 2. The specific legal basis (EU/US consumer protection law)
229
+ 3. A practical recommendation for the consumer
230
+
231
+ Be concise. 3-4 sentences maximum per section."""
232
+
233
+ async with httpx.AsyncClient(timeout=30.0) as client:
234
+ resp = await client.post(
235
+ SAULLM_ENDPOINT,
236
+ json={"inputs": prompt, "parameters": {"max_new_tokens": 300, "temperature": 0.3}},
237
+ headers={"Authorization": f"Bearer {HF_API_TOKEN}"},
238
+ )
239
+ if resp.status_code == 200:
240
+ output = resp.json()
241
+ generated = output[0]["generated_text"] if isinstance(output, list) else output.get("generated_text", "")
242
+ if generated and len(generated) > 50:
243
+ parts = generated.split("\n\n")
244
+ desc = parts[0] if len(parts) > 0 else desc
245
+ legal = parts[1] if len(parts) > 1 else legal
246
+ recommendation = parts[2] if len(parts) > 2 else recommendation
247
+ except Exception:
248
+ pass # Fall back to static responses
249
+
250
+ return ExplainResponse(clause=req.clause, category=req.category,
251
+ explanation=desc, legal_basis=legal, recommendation=recommendation)
252
+
253
+ @app.get("/api/history")
254
+ async def history(user: dict = Depends(require_auth), limit: int = 20, offset: int = 0):
255
+ limit = min(limit, 100)
256
+ data = await supabase_query("analyses", {
257
+ "user_id": f"eq.{user['id']}", "select": "*",
258
+ "order": "created_at.desc", "limit": str(limit), "offset": str(offset),
259
+ })
260
+ return {"analyses": data, "limit": limit, "offset": offset}
261
 
262
  if __name__ == "__main__":
263
  import uvicorn
extension/icons/icon.svg ADDED
extension/icons/icon128.png ADDED
extension/icons/icon16.png ADDED
extension/icons/icon32.png ADDED
extension/icons/icon48.png ADDED
web/.env.example CHANGED
@@ -1,14 +1,22 @@
1
  # Supabase
2
  NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
3
- NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
4
- SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
 
5
 
6
- # Stripe
7
- STRIPE_SECRET_KEY=sk_test_...
8
  STRIPE_WEBHOOK_SECRET=whsec_...
9
  STRIPE_PRO_PRICE_ID=price_...
10
  STRIPE_TEAM_PRICE_ID=price_...
11
 
 
 
 
12
  # App
13
- NEXT_PUBLIC_APP_URL=http://localhost:3000
14
  CLAUSEGUARD_API_URL=http://localhost:8000
 
 
 
 
 
1
  # Supabase
2
  NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
3
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=eyJ...
4
+ SUPABASE_SERVICE_ROLE_KEY=eyJ...
5
+ SUPABASE_JWT_SECRET=your-jwt-secret
6
 
7
+ # Stripe (v22 — no apiVersion needed)
8
+ STRIPE_SECRET_KEY=sk_live_...
9
  STRIPE_WEBHOOK_SECRET=whsec_...
10
  STRIPE_PRO_PRICE_ID=price_...
11
  STRIPE_TEAM_PRICE_ID=price_...
12
 
13
+ # Resend
14
+ RESEND_API_KEY=re_...
15
+
16
  # App
17
+ NEXT_PUBLIC_SITE_URL=https://clauseguard.com
18
  CLAUSEGUARD_API_URL=http://localhost:8000
19
+
20
+ # ML (optional — for SaulLM explain feature)
21
+ HF_API_TOKEN=hf_...
22
+ SAULLM_ENDPOINT=https://your-endpoint.endpoints.huggingface.cloud
web/app/api/emails/scan-report/route.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { Resend } from "resend";
3
+
4
+ const resend = new Resend(process.env.RESEND_API_KEY);
5
+
6
+ export async function POST(req: NextRequest) {
7
+ try {
8
+ const { email, risk_score, grade, flagged_count, total_clauses, source_url } = await req.json();
9
+
10
+ if (!email) {
11
+ return NextResponse.json({ error: "Email required" }, { status: 400 });
12
+ }
13
+
14
+ const gradeColor = grade === "F" || grade === "D" ? "#b91c1c" : grade === "C" ? "#a16207" : "#15803d";
15
+ const gradeBg = grade === "F" || grade === "D" ? "#fef2f2" : grade === "C" ? "#fffbeb" : "#f0fdf4";
16
+
17
+ const { data, error } = await resend.emails.send({
18
+ from: "ClauseGuard <reports@clauseguard.com>",
19
+ to: [email],
20
+ subject: `Scan complete — Grade ${grade} (${risk_score}/100 risk)`,
21
+ html: `
22
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
23
+ <p style="font-size:12px;color:#a1a1aa;margin:0 0 8px;">ClauseGuard Scan Report</p>
24
+ <h1 style="font-size:20px;font-weight:600;color:#18181b;margin:0 0 20px;">
25
+ ${source_url ? new URL(source_url).hostname : "Document"} — Risk ${risk_score}/100
26
+ </h1>
27
+
28
+ <div style="display:flex;gap:12px;margin-bottom:24px;">
29
+ <div style="background:${gradeBg};color:${gradeColor};padding:12px 20px;border-radius:8px;text-align:center;flex:1;">
30
+ <div style="font-size:24px;font-weight:700;">Grade ${grade}</div>
31
+ </div>
32
+ <div style="background:#f4f4f5;padding:12px 20px;border-radius:8px;text-align:center;flex:1;">
33
+ <div style="font-size:24px;font-weight:700;">${flagged_count}</div>
34
+ <div style="font-size:11px;color:#71717a;">of ${total_clauses} flagged</div>
35
+ </div>
36
+ </div>
37
+
38
+ <a href="https://clauseguard.com/dashboard-pages/dashboard"
39
+ style="display:inline-block;background:#18181b;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">
40
+ View full report
41
+ </a>
42
+
43
+ <p style="font-size:12px;color:#a1a1aa;margin-top:32px;border-top:1px solid #f4f4f5;padding-top:16px;">
44
+ clauseguard.com — Not legal advice.
45
+ </p>
46
+ </div>
47
+ `,
48
+ });
49
+
50
+ if (error) {
51
+ return NextResponse.json({ error: error.message }, { status: 500 });
52
+ }
53
+
54
+ return NextResponse.json({ id: data?.id });
55
+ } catch (error) {
56
+ console.error("Email error:", error);
57
+ return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
58
+ }
59
+ }
web/app/api/emails/welcome/route.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { Resend } from "resend";
3
+
4
+ const resend = new Resend(process.env.RESEND_API_KEY);
5
+
6
+ export async function POST(req: NextRequest) {
7
+ try {
8
+ const { email, name } = await req.json();
9
+
10
+ if (!email) {
11
+ return NextResponse.json({ error: "Email required" }, { status: 400 });
12
+ }
13
+
14
+ const { data, error } = await resend.emails.send({
15
+ from: "ClauseGuard <welcome@clauseguard.com>",
16
+ to: [email],
17
+ subject: "Welcome to ClauseGuard",
18
+ html: `
19
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
20
+ <h1 style="font-size:20px;font-weight:600;color:#18181b;margin:0 0 16px;">Welcome to ClauseGuard${name ? `, ${name}` : ""}</h1>
21
+ <p style="font-size:14px;color:#52525b;line-height:1.6;margin:0 0 24px;">
22
+ You now have a tool that reads the fine print for you. Here is what you can do:
23
+ </p>
24
+ <ul style="font-size:14px;color:#52525b;line-height:1.8;padding-left:20px;margin:0 0 24px;">
25
+ <li>Install the Chrome extension to scan pages as you browse</li>
26
+ <li>Use the web scanner to paste and analyze any document</li>
27
+ <li>Get a risk score and grade for every Terms of Service you encounter</li>
28
+ </ul>
29
+ <a href="https://clauseguard.com/dashboard-pages/analyze"
30
+ style="display:inline-block;background:#18181b;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">
31
+ Scan your first document
32
+ </a>
33
+ <p style="font-size:12px;color:#a1a1aa;margin-top:32px;border-top:1px solid #f4f4f5;padding-top:16px;">
34
+ You are receiving this because you signed up at clauseguard.com.
35
+ </p>
36
+ </div>
37
+ `,
38
+ });
39
+
40
+ if (error) {
41
+ return NextResponse.json({ error: error.message }, { status: 500 });
42
+ }
43
+
44
+ return NextResponse.json({ id: data?.id });
45
+ } catch (error) {
46
+ console.error("Email error:", error);
47
+ return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
48
+ }
49
+ }
web/app/api/history/route.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createClient } from "@/lib/supabase/server";
3
+
4
+ export async function GET(req: NextRequest) {
5
+ const supabase = await createClient();
6
+ const { data: { user } } = await supabase.auth.getUser();
7
+
8
+ if (!user) {
9
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
10
+ }
11
+
12
+ const url = new URL(req.url);
13
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
14
+ const offset = parseInt(url.searchParams.get("offset") || "0");
15
+
16
+ const { data: analyses, count, error } = await supabase
17
+ .from("analyses")
18
+ .select("*", { count: "exact" })
19
+ .eq("user_id", user.id)
20
+ .order("created_at", { ascending: false })
21
+ .range(offset, offset + limit - 1);
22
+
23
+ if (error) {
24
+ return NextResponse.json({ error: error.message }, { status: 500 });
25
+ }
26
+
27
+ return NextResponse.json({
28
+ analyses: analyses || [],
29
+ total: count || 0,
30
+ limit,
31
+ offset,
32
+ });
33
+ }
web/app/api/pdf/report/route.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { Document, Page, Text, View, StyleSheet, pdf } from "@react-pdf/renderer";
3
+ import React from "react";
4
+
5
+ export const runtime = "nodejs";
6
+
7
+ const styles = StyleSheet.create({
8
+ page: { padding: 40, fontFamily: "Helvetica", fontSize: 11, color: "#27272a" },
9
+ header: { marginBottom: 24 },
10
+ title: { fontSize: 22, fontWeight: "bold", marginBottom: 4 },
11
+ subtitle: { fontSize: 11, color: "#71717a" },
12
+ divider: { borderBottomWidth: 1, borderBottomColor: "#e4e4e7", marginVertical: 16 },
13
+ scoreRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 16 },
14
+ scoreLabel: { fontSize: 10, color: "#a1a1aa" },
15
+ scoreValue: { fontSize: 28, fontWeight: "bold" },
16
+ gradeTag: { fontSize: 12, fontWeight: "bold", padding: "4 12", borderRadius: 4 },
17
+ clauseCard: { marginBottom: 12, padding: 12, borderWidth: 1, borderColor: "#e4e4e7", borderRadius: 6 },
18
+ clauseText: { fontSize: 10, color: "#3f3f46", lineHeight: 1.5, marginBottom: 6 },
19
+ tag: { fontSize: 9, fontWeight: "bold", padding: "2 8", borderRadius: 3, marginRight: 4 },
20
+ tagHigh: { backgroundColor: "#fef2f2", color: "#b91c1c" },
21
+ tagMedium: { backgroundColor: "#fffbeb", color: "#a16207" },
22
+ tagLow: { backgroundColor: "#eff6ff", color: "#1d4ed8" },
23
+ footer: { position: "absolute", bottom: 30, left: 40, right: 40, fontSize: 8, color: "#a1a1aa", textAlign: "center" },
24
+ });
25
+
26
+ interface Clause { text: string; categories: { name: string; severity: string }[]; }
27
+ interface ReportData { risk_score: number; grade: string; total_clauses: number; flagged_count: number; results: Clause[]; source_url?: string; }
28
+
29
+ function ClauseReport({ data }: { data: ReportData }) {
30
+ const flagged = data.results.filter((r) => r.categories.length > 0);
31
+ const gradeColor = data.grade === "F" || data.grade === "D" ? "#b91c1c" : data.grade === "C" ? "#a16207" : "#15803d";
32
+ const gradeBg = data.grade === "F" || data.grade === "D" ? "#fef2f2" : data.grade === "C" ? "#fffbeb" : "#f0fdf4";
33
+
34
+ return (
35
+ <Document>
36
+ <Page size="A4" style={styles.page}>
37
+ <View style={styles.header}>
38
+ <Text style={styles.title}>ClauseGuard Report</Text>
39
+ <Text style={styles.subtitle}>
40
+ {data.source_url || "Manual scan"} — {new Date().toLocaleDateString()}
41
+ </Text>
42
+ </View>
43
+
44
+ <View style={styles.scoreRow}>
45
+ <View>
46
+ <Text style={styles.scoreLabel}>RISK SCORE</Text>
47
+ <Text style={styles.scoreValue}>{data.risk_score}/100</Text>
48
+ </View>
49
+ <Text style={[styles.gradeTag, { backgroundColor: gradeBg, color: gradeColor }]}>
50
+ Grade {data.grade}
51
+ </Text>
52
+ </View>
53
+
54
+ <Text style={styles.subtitle}>
55
+ {data.total_clauses} clauses scanned — {data.flagged_count} flagged
56
+ </Text>
57
+
58
+ <View style={styles.divider} />
59
+
60
+ {flagged.map((clause, i) => (
61
+ <View key={i} style={styles.clauseCard}>
62
+ <Text style={styles.clauseText}>{clause.text.substring(0, 300)}</Text>
63
+ <View style={{ flexDirection: "row", flexWrap: "wrap" }}>
64
+ {clause.categories.map((cat, j) => {
65
+ const tagStyle = cat.severity === "HIGH" ? styles.tagHigh : cat.severity === "MEDIUM" ? styles.tagMedium : styles.tagLow;
66
+ return (
67
+ <Text key={j} style={[styles.tag, tagStyle]}>
68
+ {cat.name}
69
+ </Text>
70
+ );
71
+ })}
72
+ </View>
73
+ </View>
74
+ ))}
75
+
76
+ <Text style={styles.footer}>
77
+ Generated by ClauseGuard — clauseguard.com — Not legal advice
78
+ </Text>
79
+ </Page>
80
+ </Document>
81
+ );
82
+ }
83
+
84
+ export async function POST(req: NextRequest) {
85
+ try {
86
+ const data: ReportData = await req.json();
87
+
88
+ const instance = pdf(React.createElement(ClauseReport, { data }));
89
+ const buffer = await instance.toBuffer();
90
+
91
+ const chunks: Uint8Array[] = [];
92
+ for await (const chunk of buffer as any) {
93
+ chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
94
+ }
95
+
96
+ return new Response(Buffer.concat(chunks), {
97
+ headers: {
98
+ "Content-Type": "application/pdf",
99
+ "Content-Disposition": `attachment; filename="clauseguard-report-${Date.now()}.pdf"`,
100
+ },
101
+ });
102
+ } catch (error) {
103
+ console.error("PDF generation error:", error);
104
+ return NextResponse.json({ error: "PDF generation failed" }, { status: 500 });
105
+ }
106
+ }
web/app/api/stripe/checkout/route.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { stripe, PLANS } from "@/lib/stripe";
3
  import { createClient } from "@/lib/supabase/server";
4
 
5
  export async function POST(req: NextRequest) {
@@ -22,7 +22,8 @@ export async function POST(req: NextRequest) {
22
  return NextResponse.json({ error: "Price not configured" }, { status: 500 });
23
  }
24
 
25
- // Get or create Stripe customer
 
26
  const { data: profile } = await supabase
27
  .from("profiles")
28
  .select("stripe_customer_id")
@@ -37,24 +38,17 @@ export async function POST(req: NextRequest) {
37
  metadata: { supabase_user_id: user.id },
38
  });
39
  customerId = customer.id;
40
-
41
- await supabase
42
- .from("profiles")
43
- .update({ stripe_customer_id: customerId })
44
- .eq("id", user.id);
45
  }
46
 
47
- // Create checkout session
48
  const session = await stripe.checkout.sessions.create({
49
  customer: customerId,
50
  mode: "subscription",
51
  payment_method_types: ["card"],
52
  line_items: [{ price: priceId, quantity: 1 }],
53
- success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard-pages/dashboard?session_id={CHECKOUT_SESSION_ID}`,
54
- cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
55
- subscription_data: {
56
- metadata: { supabase_user_id: user.id, plan },
57
- },
58
  });
59
 
60
  return NextResponse.json({ url: session.url });
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { getStripe, PLANS } from "@/lib/stripe";
3
  import { createClient } from "@/lib/supabase/server";
4
 
5
  export async function POST(req: NextRequest) {
 
22
  return NextResponse.json({ error: "Price not configured" }, { status: 500 });
23
  }
24
 
25
+ const stripe = getStripe();
26
+
27
  const { data: profile } = await supabase
28
  .from("profiles")
29
  .select("stripe_customer_id")
 
38
  metadata: { supabase_user_id: user.id },
39
  });
40
  customerId = customer.id;
41
+ await supabase.from("profiles").update({ stripe_customer_id: customerId }).eq("id", user.id);
 
 
 
 
42
  }
43
 
 
44
  const session = await stripe.checkout.sessions.create({
45
  customer: customerId,
46
  mode: "subscription",
47
  payment_method_types: ["card"],
48
  line_items: [{ price: priceId, quantity: 1 }],
49
+ success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard-pages/dashboard?session_id={CHECKOUT_SESSION_ID}`,
50
+ cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
51
+ subscription_data: { metadata: { supabase_user_id: user.id, plan } },
 
 
52
  });
53
 
54
  return NextResponse.json({ url: session.url });
web/app/api/stripe/webhook/route.ts CHANGED
@@ -1,66 +1,78 @@
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { stripe } from "@/lib/stripe";
3
  import { createClient } from "@supabase/supabase-js";
 
4
 
5
- // Use service role for webhook (no user context)
6
  const supabase = createClient(
7
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
8
  process.env.SUPABASE_SERVICE_ROLE_KEY!
9
  );
10
 
 
 
11
  export async function POST(req: NextRequest) {
12
  const body = await req.text();
13
  const sig = req.headers.get("stripe-signature")!;
 
14
 
15
  let event;
16
  try {
17
  event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
18
- } catch (err) {
19
- console.error("Webhook signature verification failed:", err);
20
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
21
  }
22
 
23
  switch (event.type) {
24
  case "customer.subscription.created":
25
  case "customer.subscription.updated": {
26
- const subscription = event.data.object as any;
27
- const customerId = subscription.customer as string;
28
- const plan = subscription.metadata?.plan || "pro";
29
- const status = subscription.status;
30
 
31
- if (status === "active" || status === "trialing") {
32
- await supabase
33
  .from("profiles")
34
- .update({
35
- plan,
36
- stripe_subscription_id: subscription.id,
37
- updated_at: new Date().toISOString(),
38
- })
39
- .eq("stripe_customer_id", customerId);
 
 
 
 
 
 
 
40
  }
41
  break;
42
  }
43
 
44
  case "customer.subscription.deleted": {
45
- const subscription = event.data.object as any;
46
- const customerId = subscription.customer as string;
47
-
48
  await supabase
49
  .from("profiles")
50
- .update({
51
- plan: "free",
52
- stripe_subscription_id: null,
53
- updated_at: new Date().toISOString(),
54
- })
55
- .eq("stripe_customer_id", customerId);
56
  break;
57
  }
58
 
59
  case "invoice.payment_failed": {
60
  const invoice = event.data.object as any;
61
- const customerId = invoice.customer as string;
62
- console.warn(`Payment failed for customer ${customerId}`);
63
- // Could send email notification here
 
 
 
 
 
 
 
 
 
 
 
64
  break;
65
  }
66
  }
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { getStripe } from "@/lib/stripe";
3
  import { createClient } from "@supabase/supabase-js";
4
+ import { Resend } from "resend";
5
 
 
6
  const supabase = createClient(
7
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
8
  process.env.SUPABASE_SERVICE_ROLE_KEY!
9
  );
10
 
11
+ const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
12
+
13
  export async function POST(req: NextRequest) {
14
  const body = await req.text();
15
  const sig = req.headers.get("stripe-signature")!;
16
+ const stripe = getStripe();
17
 
18
  let event;
19
  try {
20
  event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
21
+ } catch {
 
22
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
23
  }
24
 
25
  switch (event.type) {
26
  case "customer.subscription.created":
27
  case "customer.subscription.updated": {
28
+ const sub = event.data.object as any;
29
+ const plan = sub.metadata?.plan || "pro";
 
 
30
 
31
+ if (sub.status === "active" || sub.status === "trialing") {
32
+ const { data } = await supabase
33
  .from("profiles")
34
+ .update({ plan, stripe_subscription_id: sub.id, updated_at: new Date().toISOString() })
35
+ .eq("stripe_customer_id", sub.customer)
36
+ .select("email")
37
+ .single();
38
+
39
+ if (data?.email && resend && event.type === "customer.subscription.created") {
40
+ await resend.emails.send({
41
+ from: "ClauseGuard <noreply@clauseguard.com>",
42
+ to: [data.email],
43
+ subject: `Welcome to ClauseGuard ${plan.charAt(0).toUpperCase() + plan.slice(1)}`,
44
+ html: `<p>Your ${plan} subscription is active. You now have unlimited scans.</p><p><a href="https://clauseguard.com/dashboard-pages/dashboard">Go to dashboard</a></p>`,
45
+ });
46
+ }
47
  }
48
  break;
49
  }
50
 
51
  case "customer.subscription.deleted": {
52
+ const sub = event.data.object as any;
 
 
53
  await supabase
54
  .from("profiles")
55
+ .update({ plan: "free", stripe_subscription_id: null, updated_at: new Date().toISOString() })
56
+ .eq("stripe_customer_id", sub.customer);
 
 
 
 
57
  break;
58
  }
59
 
60
  case "invoice.payment_failed": {
61
  const invoice = event.data.object as any;
62
+ const { data } = await supabase
63
+ .from("profiles")
64
+ .select("email")
65
+ .eq("stripe_customer_id", invoice.customer)
66
+ .single();
67
+
68
+ if (data?.email && resend) {
69
+ await resend.emails.send({
70
+ from: "ClauseGuard <noreply@clauseguard.com>",
71
+ to: [data.email],
72
+ subject: "Payment failed — action needed",
73
+ html: "<p>Your latest payment failed. Please update your payment method to keep your subscription active.</p>",
74
+ });
75
+ }
76
  break;
77
  }
78
  }
web/app/auth/callback/route.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient } from "@/lib/supabase/server";
2
+ import { NextResponse } from "next/server";
3
+
4
+ export async function GET(request: Request) {
5
+ const requestUrl = new URL(request.url);
6
+ const code = requestUrl.searchParams.get("code");
7
+ const next = requestUrl.searchParams.get("next") ?? "/dashboard-pages/dashboard";
8
+ const origin = requestUrl.origin;
9
+
10
+ if (code) {
11
+ const supabase = await createClient();
12
+ const { error } = await supabase.auth.exchangeCodeForSession(code);
13
+
14
+ if (!error) {
15
+ return NextResponse.redirect(`${origin}${next}`);
16
+ }
17
+ }
18
+
19
+ return NextResponse.redirect(`${origin}/auth/login?error=callback_failed`);
20
+ }
web/app/dashboard-pages/settings/page.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient } from "@/lib/supabase/server";
2
+ import { getStripe } from "@/lib/stripe";
3
+ import { redirect } from "next/navigation";
4
+ import Link from "next/link";
5
+
6
+ async function createBillingPortal(formData: FormData) {
7
+ "use server";
8
+ const supabase = await createClient();
9
+ const { data: { user } } = await supabase.auth.getUser();
10
+ if (!user) redirect("/auth/login");
11
+
12
+ const { data: profile } = await supabase.from("profiles").select("stripe_customer_id").eq("id", user.id).single();
13
+ if (!profile?.stripe_customer_id) redirect("/pricing");
14
+
15
+ const stripe = getStripe();
16
+ const { url } = await stripe.billingPortal.sessions.create({
17
+ customer: profile.stripe_customer_id,
18
+ return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard-pages/settings`,
19
+ });
20
+ redirect(url!);
21
+ }
22
+
23
+ async function handleSignOut() {
24
+ "use server";
25
+ const supabase = await createClient();
26
+ await supabase.auth.signOut();
27
+ redirect("/");
28
+ }
29
+
30
+ export default async function SettingsPage() {
31
+ const supabase = await createClient();
32
+ const { data: { user } } = await supabase.auth.getUser();
33
+
34
+ const { data: profile } = await supabase
35
+ .from("profiles")
36
+ .select("*")
37
+ .eq("id", user?.id)
38
+ .single();
39
+
40
+ const plan = profile?.plan || "free";
41
+ const used = profile?.analyses_this_month || 0;
42
+ const limit = plan === "free" ? 10 : "Unlimited";
43
+
44
+ return (
45
+ <div className="min-h-screen bg-white">
46
+ <div className="max-w-2xl mx-auto px-6 py-12">
47
+ <div className="mb-8">
48
+ <Link href="/dashboard-pages/dashboard" className="text-sm text-zinc-400 hover:text-zinc-600">← Dashboard</Link>
49
+ <h1 className="mt-4 text-2xl font-semibold">Settings</h1>
50
+ </div>
51
+
52
+ {/* Account */}
53
+ <section className="mb-10">
54
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wide mb-4">Account</h2>
55
+ <div className="border border-zinc-200 rounded-lg divide-y divide-zinc-100">
56
+ <div className="px-5 py-4 flex justify-between items-center">
57
+ <div>
58
+ <p className="text-sm font-medium">Email</p>
59
+ <p className="text-sm text-zinc-500">{user?.email}</p>
60
+ </div>
61
+ </div>
62
+ <div className="px-5 py-4 flex justify-between items-center">
63
+ <div>
64
+ <p className="text-sm font-medium">Name</p>
65
+ <p className="text-sm text-zinc-500">{profile?.full_name || "Not set"}</p>
66
+ </div>
67
+ </div>
68
+ <div className="px-5 py-4 flex justify-between items-center">
69
+ <div>
70
+ <p className="text-sm font-medium">User ID</p>
71
+ <p className="text-sm text-zinc-400 font-mono text-xs">{user?.id}</p>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </section>
76
+
77
+ {/* Subscription */}
78
+ <section className="mb-10">
79
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wide mb-4">Subscription</h2>
80
+ <div className="border border-zinc-200 rounded-lg divide-y divide-zinc-100">
81
+ <div className="px-5 py-4 flex justify-between items-center">
82
+ <div>
83
+ <p className="text-sm font-medium">Plan</p>
84
+ <p className="text-sm text-zinc-500 capitalize">{plan}</p>
85
+ </div>
86
+ {plan === "free" ? (
87
+ <Link href="/#pricing" className="text-sm text-zinc-900 font-medium border border-zinc-200 px-3 py-1.5 rounded-md hover:bg-zinc-50">
88
+ Upgrade
89
+ </Link>
90
+ ) : (
91
+ <form action={createBillingPortal}>
92
+ <button type="submit" className="text-sm text-zinc-900 font-medium border border-zinc-200 px-3 py-1.5 rounded-md hover:bg-zinc-50">
93
+ Manage billing
94
+ </button>
95
+ </form>
96
+ )}
97
+ </div>
98
+ <div className="px-5 py-4 flex justify-between items-center">
99
+ <div>
100
+ <p className="text-sm font-medium">Usage this month</p>
101
+ <p className="text-sm text-zinc-500">{used} / {limit} scans</p>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </section>
106
+
107
+ {/* Danger zone */}
108
+ <section>
109
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wide mb-4">Session</h2>
110
+ <form action={handleSignOut}>
111
+ <button type="submit" className="text-sm text-red-600 font-medium border border-red-200 px-4 py-2 rounded-md hover:bg-red-50">
112
+ Sign out
113
+ </button>
114
+ </form>
115
+ </section>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
web/lib/stripe.ts CHANGED
@@ -1,9 +1,13 @@
1
  import Stripe from "stripe";
2
 
3
- export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
4
- apiVersion: "2026-03-25.dahlia",
5
- typescript: true,
6
- });
 
 
 
 
7
 
8
  export const PLANS = {
9
  free: {
 
1
  import Stripe from "stripe";
2
 
3
+ let _stripe: Stripe | null = null;
4
+
5
+ export function getStripe(): Stripe {
6
+ if (!_stripe) {
7
+ _stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
8
+ }
9
+ return _stripe;
10
+ }
11
 
12
  export const PLANS = {
13
  free: {
web/lib/supabase/client.ts CHANGED
@@ -3,6 +3,6 @@ import { createBrowserClient } from "@supabase/ssr";
3
  export function createClient() {
4
  return createBrowserClient(
5
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
6
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7
  );
8
  }
 
3
  export function createClient() {
4
  return createBrowserClient(
5
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
6
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
7
  );
8
  }
web/lib/supabase/server.ts CHANGED
@@ -6,7 +6,7 @@ export async function createClient() {
6
 
7
  return createServerClient(
8
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
9
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
10
  {
11
  cookies: {
12
  getAll() {
@@ -18,7 +18,7 @@ export async function createClient() {
18
  cookieStore.set(name, value, options)
19
  );
20
  } catch {
21
- // Server Component — can't set cookies, ignore
22
  }
23
  },
24
  },
 
6
 
7
  return createServerClient(
8
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
9
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
10
  {
11
  cookies: {
12
  getAll() {
 
18
  cookieStore.set(name, value, options)
19
  );
20
  } catch {
21
+ // Server Component context middleware handles refresh
22
  }
23
  },
24
  },
web/middleware.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { NextResponse, type NextRequest } from "next/server";
2
  import { createServerClient } from "@supabase/ssr";
 
3
 
4
  export async function middleware(request: NextRequest) {
5
  let supabaseResponse = NextResponse.next({ request });
6
 
7
  const supabase = createServerClient(
8
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
9
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
10
  {
11
  cookies: {
12
  getAll() {
@@ -23,17 +23,18 @@ export async function middleware(request: NextRequest) {
23
  }
24
  );
25
 
26
- const { data: { user } } = await supabase.auth.getUser();
 
27
 
28
- // Protect dashboard routes
29
  if (request.nextUrl.pathname.startsWith("/dashboard-pages") && !user) {
30
  const url = request.nextUrl.clone();
31
  url.pathname = "/auth/login";
32
- url.searchParams.set("redirect", request.nextUrl.pathname);
33
  return NextResponse.redirect(url);
34
  }
35
 
36
- // Redirect logged-in users away from auth pages
37
  if (request.nextUrl.pathname.startsWith("/auth/") && user) {
38
  if (!request.nextUrl.pathname.includes("callback")) {
39
  return NextResponse.redirect(new URL("/dashboard-pages/dashboard", request.url));
 
 
1
  import { createServerClient } from "@supabase/ssr";
2
+ import { NextResponse, type NextRequest } from "next/server";
3
 
4
  export async function middleware(request: NextRequest) {
5
  let supabaseResponse = NextResponse.next({ request });
6
 
7
  const supabase = createServerClient(
8
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
9
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
10
  {
11
  cookies: {
12
  getAll() {
 
23
  }
24
  );
25
 
26
+ const { data } = await supabase.auth.getClaims();
27
+ const user = data?.claims;
28
 
29
+ // Protect dashboard
30
  if (request.nextUrl.pathname.startsWith("/dashboard-pages") && !user) {
31
  const url = request.nextUrl.clone();
32
  url.pathname = "/auth/login";
33
+ url.searchParams.set("next", request.nextUrl.pathname);
34
  return NextResponse.redirect(url);
35
  }
36
 
37
+ // Redirect logged-in away from auth pages (except callback)
38
  if (request.nextUrl.pathname.startsWith("/auth/") && user) {
39
  if (!request.nextUrl.pathname.includes("callback")) {
40
  return NextResponse.redirect(new URL("/dashboard-pages/dashboard", request.url));
web/next.config.ts CHANGED
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
10
  },
11
  experimental: {
12
  serverActions: { bodySizeLimit: "2mb" },
 
13
  },
14
  };
15
 
 
10
  },
11
  experimental: {
12
  serverActions: { bodySizeLimit: "2mb" },
13
+ serverComponentsExternalPackages: ["@react-pdf/renderer"],
14
  },
15
  };
16
 
web/package.json CHANGED
@@ -15,6 +15,9 @@
15
  "@supabase/supabase-js": "2.104.0",
16
  "@supabase/ssr": "0.10.2",
17
  "stripe": "22.0.2",
 
 
 
18
  "lucide-react": "0.474.0",
19
  "clsx": "2.1.1",
20
  "tailwind-merge": "3.0.0"
 
15
  "@supabase/supabase-js": "2.104.0",
16
  "@supabase/ssr": "0.10.2",
17
  "stripe": "22.0.2",
18
+ "resend": "6.12.2",
19
+ "@react-pdf/renderer": "4.5.1",
20
+ "jose": "6.2.2",
21
  "lucide-react": "0.474.0",
22
  "clsx": "2.1.1",
23
  "tailwind-merge": "3.0.0"