shak3008 commited on
Commit
b5a1b3b
·
1 Parent(s): 3fd3b7e

Add DocWeave authentication integration, dynamic workspace selection, search, Dockerfile, and HF Spaces config

Browse files
.env.example ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # DiffWeave Configuration (.env)
3
+ # Semantic Version Control & PR Review Deck for DocWeave Knowledge
4
+ # =============================================================================
5
+
6
+ # -----------------------------------------------------------------------------
7
+ # 1. Connection Mode (Remote MCP Server vs. Local DocWeave)
8
+ # -----------------------------------------------------------------------------
9
+ # For deployed / cloud instances (e.g. Hugging Face Spaces or custom domain):
10
+ # DOCWEAVE_MCP_URL=https://your-docweave.hf.space
11
+ # DOCWEAVE_API_KEY=your-docweave-personal-access-token
12
+
13
+ # For local development with DocWeave on the same machine:
14
+ DOCWEAVE_BACKEND_DIR=../DocWeave/backend
15
+ DOCWEAVE_BACKEND_URL=http://localhost:8000
16
+
17
+ # -----------------------------------------------------------------------------
18
+ # 2. Database & Auth (Shared with DocWeave for Unified Identity)
19
+ # -----------------------------------------------------------------------------
20
+ # Shared Neon PostgreSQL database URL
21
+ DATABASE_URL=postgresql://neondb_owner:npg_LP1rgDY6JsTV@ep-morning-shadow-azegcix7-pooler.c-3.ap-southeast-1.aws.neon.tech/neondb?sslmode=require
22
+
23
+ # JWT Secret Key (Must match DocWeave SECRET_KEY to verify tokens)
24
+ SECRET_KEY=b1c482a537917a24274fa65e7c7fbe5dcce1e97a1e16c662dd470872e8f76fdf
25
+ ALGORITHM=HS256
26
+ ACCESS_TOKEN_EXPIRE_MINUTES=480
27
+
28
+ # -----------------------------------------------------------------------------
29
+ # 3. Workspace Defaults
30
+ # -----------------------------------------------------------------------------
31
+ # Default target workspace ID (e.g., Cardiology workspace)
32
+ DIFFWEAVE_WORKSPACE_ID=3e24fa72-b6c2-4427-9e44-b26a997fd205
33
+
34
+ # -----------------------------------------------------------------------------
35
+ # 4. DiffWeave Studio Server Bridge
36
+ # -----------------------------------------------------------------------------
37
+ PORT=7860
38
+ HOST=127.0.0.1
39
+
40
+ # -----------------------------------------------------------------------------
41
+ # 5. Email & Notification Delivery (Optional - for DocWeave OTP / email verification)
42
+ # -----------------------------------------------------------------------------
43
+ # RESEND_API_KEY=re_your_api_key_here
44
+ # SMTP_FROM=DocWeave <noreply@doc-weave.xyz>
45
+ # FRONTEND_URL=http://localhost:7860
Dockerfile CHANGED
@@ -22,15 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
22
  && rm -rf /var/lib/apt/lists/*
23
 
24
  # Install Python backend dependencies
25
- COPY pyproject.toml ./
26
- RUN pip install --no-cache-dir \
27
- fastapi==0.115.0 \
28
- uvicorn==0.30.6 \
29
- pydantic==2.8.2 \
30
- httpx==0.27.2 \
31
- typer==0.12.5 \
32
- rich==13.8.0 \
33
- python-multipart==0.0.9
34
 
35
  # Copy DiffWeave application package
36
  COPY diffweave ./diffweave
 
22
  && rm -rf /var/lib/apt/lists/*
23
 
24
  # Install Python backend dependencies
25
+ COPY requirements.txt ./
26
+ RUN pip install --no-cache-dir -r requirements.txt
 
 
 
 
 
 
 
27
 
28
  # Copy DiffWeave application package
29
  COPY diffweave ./diffweave
README.md CHANGED
@@ -63,10 +63,25 @@ DiffWeave is built on top of the **DocWeave** document intelligence engine via t
63
 
64
  ## Quickstart
65
 
66
- ### 1. Requirements
67
  - Python 3.10+
68
  - Node.js 18+ (for Studio UI development)
69
- - DocWeave engine backend (or run in embedded standalone mode)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  ### 2. CLI Setup
72
  Run diagnostic checks to verify connectivity to DocWeave:
 
63
 
64
  ## Quickstart
65
 
66
+ ### 1. Requirements & Environment Configuration
67
  - Python 3.10+
68
  - Node.js 18+ (for Studio UI development)
69
+ - DocWeave engine backend (or run in standalone / remote cloud mode)
70
+
71
+ Copy `.env.example` to `.env` to configure your environment:
72
+ ```bash
73
+ cp .env.example .env
74
+ ```
75
+
76
+ | Variable | Description | Default / Example |
77
+ | :--- | :--- | :--- |
78
+ | `DATABASE_URL` | Neon PostgreSQL connection string (shared with DocWeave) | `postgresql://...` |
79
+ | `SECRET_KEY` | JWT secret for token verification (shared with DocWeave) | `b1c482a...` |
80
+ | `DOCWEAVE_MCP_URL` | Remote DocWeave MCP server URL (for deployed cloud mode) | `https://your-docweave.hf.space` |
81
+ | `DOCWEAVE_API_KEY` | Personal Access Token (for CI/CD or automated pipelines) | `dw_pat_...` |
82
+ | `DOCWEAVE_BACKEND_DIR` | Path to local DocWeave backend (for in-process dev) | `../DocWeave/backend` |
83
+ | `DIFFWEAVE_WORKSPACE_ID`| Default workspace ID to bind | `3e24fa72-b6c2-4427-9e44-b26a997fd205` |
84
+ | `PORT` | DiffWeave Studio port | `7860` |
85
 
86
  ### 2. CLI Setup
87
  Run diagnostic checks to verify connectivity to DocWeave:
diffweave/__init__.py CHANGED
@@ -3,3 +3,9 @@ DiffWeave — A Git-Like Developer Platform for DocWeave.
3
  """
4
 
5
  __version__ = "0.1.0"
 
 
 
 
 
 
 
3
  """
4
 
5
  __version__ = "0.1.0"
6
+
7
+ try:
8
+ from dotenv import load_dotenv
9
+ load_dotenv()
10
+ except ImportError:
11
+ pass
diffweave/bridge/app.py CHANGED
@@ -1,4 +1,4 @@
1
- """
2
  FastAPI Server Bridge for DiffWeave Studio.
3
 
4
  Provides secure, authenticated, workspace-scoped REST APIs that
@@ -68,231 +68,241 @@ class CreateRuleRequest(BaseModel):
68
 
69
 
70
  # ---------------------------------------------------------------------------
71
- # Authentication & API Key Management
72
  # ---------------------------------------------------------------------------
 
 
 
 
 
73
 
74
- class SignupRequest(BaseModel):
75
- username: str
76
- email: str
77
- password: str
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- @app.post("/api/auth/signup")
81
- async def signup_endpoint(req: SignupRequest):
82
- user_email = req.email.strip().lower()
83
- username = req.username.strip()
84
- api_key = f"dw_live_{secrets.token_hex(16)}"
85
- mcp_url = os.environ.get("DOCWEAVE_MCP_URL", "http://127.0.0.1:7860")
86
-
87
  try:
88
- save_credentials(
89
- access_token=api_key,
90
- email=user_email,
91
- username=username,
92
- mcp_url=mcp_url,
93
  )
94
- cli_synced = True
95
- except Exception:
96
- cli_synced = False
97
-
98
- return {
99
- "access_token": api_key,
100
- "api_key": api_key,
101
- "username": username,
102
- "email": user_email,
103
- "role": "Architect",
104
- "provider": "docweave-identity",
105
- "cli_command": f"dw login --api-key {api_key}",
106
- "cli_synced": cli_synced,
107
- "credentials_path": str(get_credentials_file()),
108
- }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
- class LoginRequest(BaseModel):
112
- username: str
113
- password: Optional[str] = None
114
- email: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
 
 
116
 
117
- class GenerateKeyRequest(BaseModel):
118
- name: Optional[str] = "CLI Personal Access Token"
119
- expires_in_days: Optional[int] = 90
 
 
 
 
120
 
 
 
 
 
 
 
121
 
122
- class SyncCLIRequest(BaseModel):
123
- access_token: str
124
- email: Optional[str] = None
125
- username: Optional[str] = None
126
- mcp_url: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
 
 
 
128
 
129
- @app.post("/api/auth/demo-login")
130
- async def demo_login():
131
- api_key = f"dw_live_demo_{secrets.token_hex(16)}"
132
- username = "DocWeave Evaluator"
133
  email = "evaluator@docweave.io"
134
- mcp_url = os.environ.get("DOCWEAVE_MCP_URL", "http://127.0.0.1:7860")
135
-
 
 
 
 
 
 
 
 
136
  try:
137
  save_credentials(
138
- access_token=api_key,
139
  email=email,
140
  username=username,
141
- mcp_url=mcp_url,
142
  )
143
- cli_synced = True
144
  except Exception:
145
- cli_synced = False
146
-
147
- return {
148
- "access_token": api_key,
149
- "api_key": api_key,
150
- "username": username,
151
- "email": email,
152
- "role": "Evaluator / Architect",
153
- "provider": "docweave-sso",
154
- "cli_command": f"dw login --token {api_key}",
155
- "cli_synced": cli_synced,
156
- "credentials_path": str(get_credentials_file()),
157
- }
158
 
159
-
160
- @app.post("/api/auth/login")
161
- async def login_endpoint(req: LoginRequest):
162
- user_email = req.email or req.username
163
- username = req.username if "@" not in req.username else req.username.split("@")[0]
164
- api_key = f"dw_live_{secrets.token_hex(16)}"
165
- mcp_url = os.environ.get("DOCWEAVE_MCP_URL", "http://127.0.0.1:7860")
166
-
167
- try:
168
- save_credentials(
169
- access_token=api_key,
170
- email=user_email,
171
- username=username,
172
- mcp_url=mcp_url,
173
- )
174
- cli_synced = True
175
- except Exception:
176
- cli_synced = False
177
-
178
  return {
179
- "access_token": api_key,
180
- "api_key": api_key,
 
181
  "username": username,
182
- "email": user_email,
183
- "role": "Developer",
184
- "provider": "docweave-sso",
185
- "cli_command": f"dw login --token {api_key}",
186
- "cli_synced": cli_synced,
187
- "credentials_path": str(get_credentials_file()),
188
- }
189
-
190
-
191
- @app.post("/api/auth/generate-key")
192
- async def generate_key_endpoint(req: GenerateKeyRequest):
193
- api_key = f"dw_live_{secrets.token_hex(16)}"
194
- creds = load_credentials() or {}
195
- synced = False
196
- if creds:
197
- try:
198
- save_credentials(
199
- access_token=api_key,
200
- email=creds.get("email"),
201
- username=creds.get("username"),
202
- mcp_url=creds.get("mcp_url"),
203
- )
204
- synced = True
205
- except Exception:
206
- pass
207
-
208
- return {
209
- "name": req.name,
210
- "api_key": api_key,
211
- "created_at": datetime.utcnow().isoformat() + "Z",
212
- "expires_in_days": req.expires_in_days,
213
- "cli_command": f"dw login --token {api_key}",
214
- "cli_synced": synced,
215
- "credentials_path": str(get_credentials_file()),
216
  }
217
 
218
-
219
  @app.post("/api/auth/sync-cli")
220
- async def sync_cli_endpoint(req: SyncCLIRequest):
 
 
 
 
221
  try:
222
  save_credentials(
223
- access_token=req.access_token,
224
- email=req.email,
225
- username=req.username,
226
- mcp_url=req.mcp_url or "http://127.0.0.1:7860",
227
  )
228
- return {
229
- "status": "ok",
230
- "synced": True,
231
- "path": str(get_credentials_file()),
232
- "username": req.username,
233
- "token_preview": req.access_token[:12] + "...",
234
- }
235
  except Exception as e:
236
  raise HTTPException(status_code=500, detail=str(e))
237
-
238
-
239
- @app.get("/api/auth/whoami")
240
- async def get_whoami_endpoint():
241
- creds = load_credentials()
242
- if not creds:
243
- return {"authenticated": False, "user": None}
244
- return {
245
- "authenticated": True,
246
- "user": {
247
- "username": creds.get("username", "Authenticated User"),
248
- "email": creds.get("email", ""),
249
- "access_token": creds.get("access_token", ""),
250
- "mcp_url": creds.get("mcp_url", ""),
251
- },
252
- "credentials_path": str(get_credentials_file()),
253
- }
254
-
255
-
256
- @app.post("/api/auth/logout")
257
- async def logout_endpoint():
258
- clear_credentials()
259
- return {"status": "ok", "message": "Logged out and cleared credentials."}
260
-
261
-
262
- # ---------------------------------------------------------------------------
263
- # Health & MCP Meta
264
- # ---------------------------------------------------------------------------
265
-
266
- @app.get("/api/health")
267
- async def health_check():
268
- return {"status": "ok", "service": "diffweave-bridge", "mcp_connected": True}
269
-
270
-
271
- @app.get("/api/mcp/tools")
272
- async def list_mcp_tools():
273
- """Discover all MCP tools exposed by the underlying DocWeave server."""
274
- from mcp_server import list_tools
275
- tools = await list_tools()
276
- return [
277
- {
278
- "name": t.name,
279
- "description": t.description,
280
- "inputSchema": t.inputSchema,
281
- }
282
- for t in tools
283
- ]
284
-
285
 
286
  # ---------------------------------------------------------------------------
287
  # Workspaces
288
  # ---------------------------------------------------------------------------
289
 
290
  @app.get("/api/workspaces")
291
- async def get_workspaces():
292
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  return await client.list_workspaces()
294
  except Exception as e:
295
- raise HTTPException(status_code=500, detail=str(e))
296
 
297
 
298
  @app.post("/api/workspaces")
@@ -592,14 +602,13 @@ async def get_activity_feed_endpoint(
592
 
593
 
594
  # ---------------------------------------------------------------------------
595
- # Static Studio UI Hosting (if built)
596
  # ---------------------------------------------------------------------------
597
 
598
  @app.get("/healthz")
599
  async def healthz():
600
  return {"status": "ok", "service": "diffweave", "platform": "huggingface-space"}
601
 
602
-
603
  studio_dist_candidates = [
604
  Path("/app/studio/dist"),
605
  Path("studio/dist"),
@@ -607,12 +616,29 @@ studio_dist_candidates = [
607
  Path(__file__).parent.parent / "studio" / "dist",
608
  Path("C:/Users/Adhi/Desktop/DiffWeave/studio/dist"),
609
  ]
610
- for dist_path in studio_dist_candidates:
611
- if dist_path.exists() and (dist_path / "index.html").exists():
612
- from fastapi.staticfiles import StaticFiles
613
- app.mount("/", StaticFiles(directory=str(dist_path), html=True), name="studio_ui")
 
614
  break
615
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
 
617
  if __name__ == "__main__":
618
  import uvicorn
 
1
+ """
2
  FastAPI Server Bridge for DiffWeave Studio.
3
 
4
  Provides secure, authenticated, workspace-scoped REST APIs that
 
68
 
69
 
70
  # ---------------------------------------------------------------------------
71
+ # Authentication -- Complete DocWeave Authentication Engine
72
  # ---------------------------------------------------------------------------
73
+ import json as _json
74
+ from fastapi import Request as _Request, Header
75
+ from fastapi.responses import Response as _Response
76
+ from diffweave.bridge import docweave_auth
77
+ from diffweave.cli.credentials import save_credentials, clear_credentials
78
 
79
+ _DOCWEAVE_URL = os.environ.get("DOCWEAVE_BACKEND_URL", "http://localhost:8000")
 
 
 
80
 
81
+ @app.post("/api/auth/send-otp")
82
+ async def send_otp_endpoint(request: _Request):
83
+ body = await request.json()
84
+ try:
85
+ res = docweave_auth.send_otp(
86
+ email=body.get("email", ""),
87
+ username=body.get("username", ""),
88
+ password=body.get("password", ""),
89
+ )
90
+ return res
91
+ except ValueError as e:
92
+ raise HTTPException(status_code=400, detail=str(e))
93
+ except Exception as e:
94
+ raise HTTPException(status_code=500, detail=f"Auth error: {str(e)}")
95
 
96
+ @app.post("/api/auth/verify-otp")
97
+ async def verify_otp_endpoint(request: _Request):
98
+ body = await request.json()
 
 
 
 
99
  try:
100
+ res = docweave_auth.verify_otp(
101
+ email=body.get("email", ""),
102
+ code=body.get("code", ""),
 
 
103
  )
104
+ return res
105
+ except ValueError as e:
106
+ raise HTTPException(status_code=400, detail=str(e))
107
+ except Exception as e:
108
+ raise HTTPException(status_code=500, detail=f"Verification error: {str(e)}")
 
 
 
 
 
 
 
 
 
 
109
 
110
+ @app.post("/api/auth/signup")
111
+ async def signup_endpoint(request: _Request):
112
+ body = await request.json()
113
+ try:
114
+ res = docweave_auth.send_otp(
115
+ email=body.get("email", ""),
116
+ username=body.get("username", ""),
117
+ password=body.get("password", ""),
118
+ )
119
+ return res
120
+ except ValueError as e:
121
+ raise HTTPException(status_code=400, detail=str(e))
122
 
123
+ @app.post("/api/auth/login")
124
+ async def login_endpoint(request: _Request):
125
+ """
126
+ DocWeave login: accepts both OAuth2 form data (username, password)
127
+ and JSON bodies (email/username, password).
128
+ """
129
+ content_type = request.headers.get("content-type", "")
130
+ username = ""
131
+ password = ""
132
+ if "application/x-www-form-urlencoded" in content_type:
133
+ form = await request.form()
134
+ username = form.get("username", "")
135
+ password = form.get("password", "")
136
+ else:
137
+ try:
138
+ data = await request.json()
139
+ username = data.get("username") or data.get("email", "")
140
+ password = data.get("password", "")
141
+ except Exception:
142
+ pass
143
 
144
+ if not username or not password:
145
+ raise HTTPException(status_code=400, detail="Username/Email and password are required.")
146
 
147
+ try:
148
+ result = docweave_auth.login(username, password)
149
+ return result
150
+ except ValueError as e:
151
+ raise HTTPException(status_code=401, detail=str(e))
152
+ except Exception as e:
153
+ raise HTTPException(status_code=500, detail=f"Login failed: {str(e)}")
154
 
155
+ @app.post("/api/auth/demo-login")
156
+ async def demo_login_endpoint():
157
+ try:
158
+ return docweave_auth.demo_login()
159
+ except Exception as e:
160
+ raise HTTPException(status_code=500, detail=f"Demo login failed: {str(e)}")
161
 
162
+ @app.get("/api/auth/me")
163
+ @app.get("/api/auth/whoami")
164
+ async def auth_me_endpoint(authorization: str = Header(None)):
165
+ if not authorization or not authorization.startswith("Bearer "):
166
+ raise HTTPException(status_code=401, detail="Authentication token required.")
167
+ token = authorization.split(" ", 1)[1].strip()
168
+ user = docweave_auth.get_current_user_from_token(token)
169
+ if not user:
170
+ raise HTTPException(status_code=401, detail="Invalid or expired token.")
171
+ return user
172
+
173
+ @app.post("/api/auth/forgot-password")
174
+ async def forgot_password_endpoint(request: _Request):
175
+ body = await request.json()
176
+ return docweave_auth.forgot_password(body.get("email", ""))
177
+
178
+ @app.post("/api/auth/reset-password")
179
+ async def reset_password_endpoint(request: _Request):
180
+ body = await request.json()
181
+ try:
182
+ return docweave_auth.reset_password(body.get("token", ""), body.get("new_password", ""))
183
+ except ValueError as e:
184
+ raise HTTPException(status_code=400, detail=str(e))
185
 
186
+ @app.post("/api/auth/logout")
187
+ async def logout_endpoint():
188
+ return docweave_auth.logout()
189
 
190
+ @app.post("/api/auth/generate-key")
191
+ async def generate_key_endpoint(request: _Request, authorization: str = Header(None)):
192
+ token = ""
 
193
  email = "evaluator@docweave.io"
194
+ username = "DocWeave User"
195
+ if authorization and authorization.startswith("Bearer "):
196
+ token = authorization.split(" ", 1)[1].strip()
197
+ user = docweave_auth.get_current_user_from_token(token)
198
+ if user:
199
+ email = user.get("email", email)
200
+ username = user.get("username", username)
201
+ if not token:
202
+ token = f"dw_pat_{uuid.uuid4().hex}"
203
+
204
  try:
205
  save_credentials(
206
+ access_token=token,
207
  email=email,
208
  username=username,
209
+ api_key=token,
210
  )
 
211
  except Exception:
212
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  return {
215
+ "status": "success",
216
+ "api_key": token,
217
+ "token_type": "personal_access_token",
218
  "username": username,
219
+ "email": email,
220
+ "created_at": datetime.utcnow().isoformat(),
221
+ "instructions": "Set DIFFWEAVE_API_KEY in terminal or run 'diffweave auth login'.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  }
223
 
 
224
  @app.post("/api/auth/sync-cli")
225
+ async def sync_cli_endpoint(request: _Request):
226
+ body = await request.json()
227
+ token = body.get("access_token", "")
228
+ email = body.get("email", "evaluator@docweave.io")
229
+ username = body.get("username", "Evaluator")
230
  try:
231
  save_credentials(
232
+ access_token=token,
233
+ email=email,
234
+ username=username,
235
+ api_key=token,
236
  )
 
 
 
 
 
 
 
237
  except Exception as e:
238
  raise HTTPException(status_code=500, detail=str(e))
239
+ return {"status": "synced", "cli_config": "~/.diffweave/credentials.json"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
  # ---------------------------------------------------------------------------
242
  # Workspaces
243
  # ---------------------------------------------------------------------------
244
 
245
  @app.get("/api/workspaces")
246
+ async def get_workspaces(authorization: str = Header(None)):
247
  try:
248
+ from sqlalchemy import text
249
+ user = None
250
+ if authorization and authorization.startswith("Bearer "):
251
+ token = authorization.split(" ", 1)[1].strip()
252
+ user = docweave_auth.get_current_user_from_token(token)
253
+
254
+ eng = docweave_auth.get_engine()
255
+ with eng.connect() as conn:
256
+ # If user is authenticated, query their workspaces first
257
+ if user and user.get("id"):
258
+ rows = conn.execute(
259
+ text("""
260
+ SELECT w.id, w.name, w.description,
261
+ (SELECT count(*) FROM knowledge_items WHERE workspace_id = w.id) as k_count,
262
+ (SELECT count(*) FROM documents WHERE workspace_id = w.id) as d_count
263
+ FROM workspaces w
264
+ WHERE w.created_by = :uid
265
+ ORDER BY k_count DESC, w.created_at DESC
266
+ """),
267
+ {"uid": user["id"]}
268
+ ).fetchall()
269
+ if rows:
270
+ return [
271
+ {
272
+ "id": str(r[0]),
273
+ "name": r[1],
274
+ "description": r[2] or f"{r[4]} documents · {r[3]} verified facts",
275
+ "k_count": r[3],
276
+ "d_count": r[4]
277
+ }
278
+ for r in rows
279
+ ]
280
+
281
+ # If evaluator/demo or no workspaces for user, return all workspaces
282
+ rows = conn.execute(
283
+ text("""
284
+ SELECT w.id, w.name, w.description,
285
+ (SELECT count(*) FROM knowledge_items WHERE workspace_id = w.id) as k_count,
286
+ (SELECT count(*) FROM documents WHERE workspace_id = w.id) as d_count
287
+ FROM workspaces w
288
+ ORDER BY k_count DESC, w.created_at DESC
289
+ """)
290
+ ).fetchall()
291
+ if rows:
292
+ return [
293
+ {
294
+ "id": str(r[0]),
295
+ "name": r[1],
296
+ "description": r[2] or f"{r[4]} documents · {r[3]} verified facts",
297
+ "k_count": r[3],
298
+ "d_count": r[4]
299
+ }
300
+ for r in rows
301
+ ]
302
+
303
  return await client.list_workspaces()
304
  except Exception as e:
305
+ return await client.list_workspaces()
306
 
307
 
308
  @app.post("/api/workspaces")
 
602
 
603
 
604
  # ---------------------------------------------------------------------------
605
+ # Static Studio UI Hosting (SPA with catch-all routing)
606
  # ---------------------------------------------------------------------------
607
 
608
  @app.get("/healthz")
609
  async def healthz():
610
  return {"status": "ok", "service": "diffweave", "platform": "huggingface-space"}
611
 
 
612
  studio_dist_candidates = [
613
  Path("/app/studio/dist"),
614
  Path("studio/dist"),
 
616
  Path(__file__).parent.parent / "studio" / "dist",
617
  Path("C:/Users/Adhi/Desktop/DiffWeave/studio/dist"),
618
  ]
619
+
620
+ dist_path = None
621
+ for p in studio_dist_candidates:
622
+ if p.exists() and (p / "index.html").exists():
623
+ dist_path = p
624
  break
625
 
626
+ if dist_path:
627
+ from fastapi.staticfiles import StaticFiles
628
+ from fastapi.responses import FileResponse
629
+
630
+ assets_path = dist_path / "assets"
631
+ if assets_path.exists():
632
+ app.mount("/assets", StaticFiles(directory=str(assets_path)), name="studio_assets")
633
+
634
+ @app.get("/{full_path:path}")
635
+ async def serve_spa_page(full_path: str):
636
+ # Allow static files if they exist in dist
637
+ file_p = dist_path / full_path
638
+ if file_p.exists() and file_p.is_file():
639
+ return FileResponse(file_p)
640
+ # Otherwise fallback to index.html for React Router (e.g. /login, /signup)
641
+ return FileResponse(dist_path / "index.html")
642
 
643
  if __name__ == "__main__":
644
  import uvicorn
diffweave/bridge/docweave_auth.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DocWeave Complete Authentication Engine for DiffWeave
2
+ # Shares the same Neon PostgreSQL database, users table, bcrypt passwords,
3
+ # and JWT signatures with DocWeave.
4
+
5
+ import os
6
+ import uuid
7
+ import time
8
+ import random
9
+ import logging
10
+ from datetime import datetime, timedelta
11
+ from passlib.context import CryptContext
12
+ from jose import jwt, JWTError
13
+ from sqlalchemy import create_engine, text
14
+ import requests
15
+ from diffweave.cli.credentials import save_credentials, clear_credentials
16
+
17
+ logger = logging.getLogger("diffweave.auth")
18
+
19
+ SECRET_KEY = os.environ.get(
20
+ "SECRET_KEY",
21
+ "b1c482a537917a24274fa65e7c7fbe5dcce1e97a1e16c662dd470872e8f76fdf"
22
+ )
23
+ ALGORITHM = "HS256"
24
+ ACCESS_TOKEN_EXPIRE_MINUTES = 480
25
+ DATABASE_URL = os.environ.get(
26
+ "DATABASE_URL",
27
+ "postgresql://neondb_owner:npg_LP1rgDY6JsTV@ep-morning-shadow-azegcix7-pooler.c-3.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"
28
+ )
29
+ RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
30
+ FROM_EMAIL = os.environ.get("SMTP_FROM", "DocWeave <noreply@doc-weave.xyz>")
31
+
32
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
33
+
34
+ _engine = None
35
+ def get_engine():
36
+ global _engine
37
+ if _engine is None:
38
+ _engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=300)
39
+ return _engine
40
+
41
+ _otp_store: dict[str, dict] = {}
42
+ _reset_tokens: dict[str, dict] = {}
43
+
44
+ def hash_password(password: str) -> str:
45
+ return pwd_context.hash(password)
46
+
47
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
48
+ return pwd_context.verify(plain_password, hashed_password)
49
+
50
+ def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
51
+ to_encode = data.copy()
52
+ expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
53
+ to_encode.update({"exp": expire})
54
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
55
+
56
+ def decode_access_token(token: str) -> dict:
57
+ try:
58
+ return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
59
+ except JWTError:
60
+ return None
61
+
62
+ def send_email(to_email: str, subject: str, html: str) -> bool:
63
+ if not RESEND_API_KEY:
64
+ return False
65
+ try:
66
+ r = requests.post(
67
+ "https://api.resend.com/emails",
68
+ headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"},
69
+ json={"from": FROM_EMAIL, "to": [to_email], "subject": subject, "html": html},
70
+ timeout=10,
71
+ )
72
+ return r.status_code in (200, 201)
73
+ except Exception as e:
74
+ logger.error(f"Failed to send email: {e}")
75
+ return False
76
+
77
+ def send_otp(email: str, username: str, password: str) -> dict:
78
+ email = email.strip().lower()
79
+ username = username.strip()
80
+ if not email or not username or not password:
81
+ raise ValueError("Email, username, and password are required.")
82
+ if "@" not in email or "." not in email.split("@")[-1]:
83
+ raise ValueError("Invalid email address.")
84
+
85
+ eng = get_engine()
86
+ with eng.connect() as conn:
87
+ existing = conn.execute(text("SELECT id FROM users WHERE email = :e"), {"e": email}).first()
88
+ if existing:
89
+ raise ValueError("Email already registered in DocWeave.")
90
+ existing_u = conn.execute(text("SELECT id FROM users WHERE username = :u"), {"u": username}).first()
91
+ if existing_u:
92
+ raise ValueError("Username already taken in DocWeave.")
93
+
94
+ otp_code = f"{random.randint(100000, 999999)}"
95
+ _otp_store[email] = {
96
+ "code": otp_code,
97
+ "expires_at": time.time() + 600,
98
+ "username": username,
99
+ "password": password,
100
+ }
101
+
102
+ html = f"""<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
103
+ <h2 style="color: #10b981; margin-bottom: 8px;">DocWeave & DiffWeave</h2>
104
+ <p style="color: #666; margin-bottom: 24px;">Your email verification code is:</p>
105
+ <div style="background: #f3f4f6; border-radius: 8px; padding: 24px; text-align: center; margin-bottom: 24px;">
106
+ <span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #111;">{otp_code}</span>
107
+ </div>
108
+ <p style="color: #666; font-size: 14px;">This code expires in 10 minutes.</p>
109
+ </div>"""
110
+
111
+ sent = send_email(email, "DocWeave — Your verification code", html)
112
+ print(f"\n[DocWeave Auth OTP] ========================================\nVerification code for {email}: {otp_code}\n(Sent via Resend: {sent})\n========================================\n", flush=True)
113
+
114
+ return {"message": f"Verification code sent to {email}"}
115
+
116
+ def verify_otp(email: str, code: str) -> dict:
117
+ email = email.strip().lower()
118
+ code = code.strip()
119
+ stored = _otp_store.get(email)
120
+ if not stored:
121
+ raise ValueError("No verification request found for this email or code has expired.")
122
+ if time.time() > stored["expires_at"]:
123
+ del _otp_store[email]
124
+ raise ValueError("Verification code has expired. Please sign up again.")
125
+ if stored["code"] != code:
126
+ raise ValueError("Invalid verification code.")
127
+
128
+ username = stored["username"]
129
+ password = stored["password"]
130
+ hashed = hash_password(password)
131
+ user_id = str(uuid.uuid4())
132
+
133
+ eng = get_engine()
134
+ with eng.connect() as conn:
135
+ conn.execute(
136
+ text("INSERT INTO users (id, username, email, hashed_password, role) VALUES (:id, :username, :email, :hashed, :role)"),
137
+ {"id": user_id, "username": username, "email": email, "hashed": hashed, "role": "user"}
138
+ )
139
+ conn.commit()
140
+
141
+ del _otp_store[email]
142
+ return {"message": "Account created successfully. Please sign in.", "user_id": user_id, "email": email, "username": username}
143
+
144
+ def login(email_or_username: str, password: str) -> dict:
145
+ val = email_or_username.strip().lower()
146
+ eng = get_engine()
147
+ with eng.connect() as conn:
148
+ row = conn.execute(
149
+ text("SELECT id, username, email, hashed_password, role FROM users WHERE LOWER(email) = :val OR LOWER(username) = :val"),
150
+ {"val": val}
151
+ ).first()
152
+
153
+ if not row or not verify_password(password, row[3]):
154
+ raise ValueError("Invalid email or password.")
155
+
156
+ token = create_access_token({"sub": row[2]})
157
+
158
+ try:
159
+ save_credentials(
160
+ access_token=token,
161
+ email=row[2],
162
+ username=row[1],
163
+ api_key=token,
164
+ )
165
+ except Exception as e:
166
+ logger.warning(f"Could not save CLI credentials: {e}")
167
+
168
+ return {
169
+ "access_token": token,
170
+ "token_type": "bearer",
171
+ "user": {
172
+ "id": str(row[0]),
173
+ "username": row[1],
174
+ "email": row[2],
175
+ "role": row[4] or "user",
176
+ }
177
+ }
178
+
179
+ def demo_login() -> dict:
180
+ email = "evaluator@docweave.io"
181
+ username = "DocWeave Evaluator"
182
+ user_id = "00000000-0000-0000-0000-000000000001"
183
+
184
+ eng = get_engine()
185
+ try:
186
+ with eng.connect() as conn:
187
+ row = conn.execute(text("SELECT id, username, email, role FROM users WHERE email = :e"), {"e": email}).first()
188
+ if row:
189
+ user_id = str(row[0])
190
+ username = row[1]
191
+ except Exception:
192
+ pass
193
+
194
+ token = create_access_token({"sub": email})
195
+
196
+ try:
197
+ save_credentials(
198
+ access_token=token,
199
+ email=email,
200
+ username=username,
201
+ api_key=token,
202
+ )
203
+ except Exception:
204
+ pass
205
+
206
+ return {
207
+ "access_token": token,
208
+ "token_type": "bearer",
209
+ "user": {
210
+ "id": user_id,
211
+ "username": username,
212
+ "email": email,
213
+ "role": "evaluator",
214
+ }
215
+ }
216
+
217
+ def get_current_user_from_token(token: str) -> dict:
218
+ payload = decode_access_token(token)
219
+ if not payload or "sub" not in payload:
220
+ return None
221
+ email = payload["sub"]
222
+ eng = get_engine()
223
+ with eng.connect() as conn:
224
+ row = conn.execute(
225
+ text("SELECT id, username, email, role FROM users WHERE email = :e"),
226
+ {"e": email}
227
+ ).first()
228
+ if not row:
229
+ if email == "evaluator@docweave.io":
230
+ return {
231
+ "id": "00000000-0000-0000-0000-000000000001",
232
+ "username": "DocWeave Evaluator",
233
+ "email": "evaluator@docweave.io",
234
+ "role": "evaluator",
235
+ }
236
+ return None
237
+ return {
238
+ "id": str(row[0]),
239
+ "username": row[1],
240
+ "email": row[2],
241
+ "role": row[3] or "user",
242
+ }
243
+
244
+ def forgot_password(email: str) -> dict:
245
+ email = email.strip().lower()
246
+ eng = get_engine()
247
+ with eng.connect() as conn:
248
+ row = conn.execute(text("SELECT id FROM users WHERE email = :e"), {"e": email}).first()
249
+ if not row:
250
+ return {"message": "If that email exists, a password reset link has been sent."}
251
+
252
+ reset_token = f"{uuid.uuid4().hex}"
253
+ _reset_tokens[reset_token] = {
254
+ "email": email,
255
+ "expires_at": time.time() + 3600,
256
+ }
257
+
258
+ frontend_url = os.environ.get("FRONTEND_URL", "http://localhost:7860")
259
+ reset_link = f"{frontend_url}/reset-password?token={reset_token}"
260
+ html = f"""<div style="font-family: sans-serif; padding: 32px;">
261
+ <h2>DocWeave & DiffWeave Password Reset</h2>
262
+ <p>You requested a password reset. Click below to choose a new password:</p>
263
+ <p><a href="{reset_link}" style="background:#10b981;color:white;padding:10px 20px;text-decoration:none;border-radius:6px;">Reset Password</a></p>
264
+ <p>This link expires in 1 hour.</p>
265
+ </div>"""
266
+ send_email(email, "DocWeave — Reset your password", html)
267
+ print(f"\n[DocWeave Auth Reset] Reset link for {email}: {reset_link}\n", flush=True)
268
+ return {"message": "If that email exists, a password reset link has been sent."}
269
+
270
+ def reset_password(token: str, new_password: str) -> dict:
271
+ token = token.strip()
272
+ stored = _reset_tokens.get(token)
273
+ if not stored:
274
+ raise ValueError("Invalid or expired reset token.")
275
+ if time.time() > stored["expires_at"]:
276
+ del _reset_tokens[token]
277
+ raise ValueError("Reset token has expired.")
278
+
279
+ if len(new_password) < 8:
280
+ raise ValueError("Password must be at least 8 characters.")
281
+
282
+ email = stored["email"]
283
+ hashed = hash_password(new_password)
284
+ eng = get_engine()
285
+ with eng.connect() as conn:
286
+ conn.execute(text("UPDATE users SET hashed_password = :h WHERE email = :e"), {"h": hashed, "e": email})
287
+ conn.commit()
288
+
289
+ del _reset_tokens[token]
290
+ return {"message": "Password has been reset successfully. Please log in."}
291
+
292
+ def logout() -> dict:
293
+ try:
294
+ clear_credentials()
295
+ except Exception:
296
+ pass
297
+ return {"status": "ok"}
diffweave/cli/commands/auth_cmd.py CHANGED
@@ -30,8 +30,19 @@ def login_command(
30
 
31
  # 1. Direct Token Login
32
  if token:
33
- save_credentials(access_token=token, email="token-user", username="API Token User", mcp_url=server_url)
34
- print_success("Successfully authenticated via API token!")
 
 
 
 
 
 
 
 
 
 
 
35
  print_info(f" Stored in: ~/.diffweave/credentials.json")
36
  return
37
 
@@ -75,24 +86,49 @@ def login_command(
75
  print_info(f"Authenticating [bold white]{email}[/bold white] against [cyan]{server_url}[/cyan]...")
76
 
77
  try:
78
- with httpx.Client(timeout=15.0) as client:
79
- resp = client.post(
80
- f"{server_url}/auth/login",
81
- data={"username": email, "password": password},
82
- )
83
- if resp.status_code == 200:
84
- data = resp.json()
85
- tok = data.get("access_token")
86
- save_credentials(access_token=tok, email=email, username=email.split("@")[0], mcp_url=server_url)
87
- print_success(f"Successfully authenticated as [bold white]{email}[/bold white]!")
88
- print_info(" Session token stored in ~/.diffweave/credentials.json")
89
- else:
90
- print_error(f"Login failed: {resp.text}")
91
- raise typer.Exit(code=1)
92
- except httpx.ConnectError:
93
- print_error(f"Could not connect to DocWeave server at {server_url}.")
94
- print_info("Ensure the server is running, or pass --token / --url to specify a remote host.")
95
- raise typer.Exit(code=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  except Exception as e:
97
  print_error(f"Authentication error: {e}")
98
  raise typer.Exit(code=1)
 
30
 
31
  # 1. Direct Token Login
32
  if token:
33
+ email = "token-user"
34
+ username = "API Token User"
35
+ try:
36
+ from diffweave.bridge.docweave_auth import get_current_user_from_token
37
+ user_info = get_current_user_from_token(token)
38
+ if user_info:
39
+ email = user_info.get("email", email)
40
+ username = user_info.get("username", username)
41
+ except Exception:
42
+ pass
43
+
44
+ save_credentials(access_token=token, email=email, username=username, mcp_url=server_url)
45
+ print_success(f"Successfully authenticated as [bold white]{username}[/bold white] ({email})!")
46
  print_info(f" Stored in: ~/.diffweave/credentials.json")
47
  return
48
 
 
86
  print_info(f"Authenticating [bold white]{email}[/bold white] against [cyan]{server_url}[/cyan]...")
87
 
88
  try:
89
+ data = None
90
+ tok = None
91
+ user_info = None
92
+
93
+ # Try HTTP endpoint first
94
+ try:
95
+ with httpx.Client(timeout=10.0) as client:
96
+ for endpoint in [f"{server_url}/api/auth/login", f"{server_url}/auth/login"]:
97
+ try:
98
+ resp = client.post(endpoint, json={"username": email, "password": password})
99
+ if resp.status_code == 200:
100
+ data = resp.json()
101
+ break
102
+ # Try form data
103
+ resp = client.post(endpoint, data={"username": email, "password": password})
104
+ if resp.status_code == 200:
105
+ data = resp.json()
106
+ break
107
+ except Exception:
108
+ pass
109
+ except Exception:
110
+ pass
111
+
112
+ # Fallback to direct DocWeave database auth if local/unreachable
113
+ if not data:
114
+ try:
115
+ from diffweave.bridge import docweave_auth
116
+ data = docweave_auth.login(email, password)
117
+ except Exception as e:
118
+ pass
119
+
120
+ if data and data.get("access_token"):
121
+ tok = data.get("access_token")
122
+ user_data = data.get("user", {})
123
+ uname = user_data.get("username") or email.split("@")[0]
124
+ save_credentials(access_token=tok, email=email, username=uname, mcp_url=server_url)
125
+ print_success(f"Successfully authenticated as [bold white]{uname}[/bold white] ({email})!")
126
+ print_info(" Session token stored in ~/.diffweave/credentials.json")
127
+ else:
128
+ print_error(f"Login failed: Invalid DocWeave email or password.")
129
+ raise typer.Exit(code=1)
130
+ except typer.Exit:
131
+ raise
132
  except Exception as e:
133
  print_error(f"Authentication error: {e}")
134
  raise typer.Exit(code=1)
diffweave/cli/credentials.py CHANGED
@@ -28,9 +28,11 @@ def save_credentials(
28
  email: Optional[str] = None,
29
  username: Optional[str] = None,
30
  mcp_url: Optional[str] = None,
 
31
  ) -> None:
32
  data = {
33
  "access_token": access_token,
 
34
  "email": email or "",
35
  "username": username or "",
36
  "mcp_url": mcp_url or "",
 
28
  email: Optional[str] = None,
29
  username: Optional[str] = None,
30
  mcp_url: Optional[str] = None,
31
+ api_key: Optional[str] = None,
32
  ) -> None:
33
  data = {
34
  "access_token": access_token,
35
+ "api_key": api_key or access_token,
36
  "email": email or "",
37
  "username": username or "",
38
  "mcp_url": mcp_url or "",
diffweave/mcp/client.py CHANGED
@@ -50,7 +50,6 @@ class DiffWeaveMCPClient:
50
  if self.mcp_url:
51
  # Production remote cloud mode (e.g., https://your-docweave.hf.space)
52
  self.mode = "remote"
53
- return
54
 
55
  # Local development check
56
  if backend_dir:
@@ -134,7 +133,56 @@ class DiffWeaveMCPClient:
134
 
135
  db = get_db()
136
  try:
137
- user = _get_current_user(db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  result = await _dispatch(name, arguments, db, user)
139
  return result
140
  except ValueError as e:
 
50
  if self.mcp_url:
51
  # Production remote cloud mode (e.g., https://your-docweave.hf.space)
52
  self.mode = "remote"
 
53
 
54
  # Local development check
55
  if backend_dir:
 
133
 
134
  db = get_db()
135
  try:
136
+ user = None
137
+ ws_id = arguments.get("workspace_id")
138
+ if not ws_id and "document_id" in arguments:
139
+ try:
140
+ from mcp_server import Document
141
+ doc = db.query(Document).filter(Document.id == arguments["document_id"]).first()
142
+ if doc:
143
+ ws_id = str(doc.workspace_id)
144
+ except Exception:
145
+ pass
146
+ if not ws_id and "proposal_id" in arguments:
147
+ try:
148
+ from mcp_server import Proposal
149
+ p = db.query(Proposal).filter(Proposal.id == arguments["proposal_id"]).first()
150
+ if p:
151
+ ws_id = str(p.workspace_id)
152
+ except Exception:
153
+ pass
154
+ if not ws_id and "item_id" in arguments:
155
+ try:
156
+ from mcp_server import KnowledgeItem
157
+ ki = db.query(KnowledgeItem).filter(KnowledgeItem.id == arguments["item_id"]).first()
158
+ if ki:
159
+ ws_id = str(ki.workspace_id)
160
+ except Exception:
161
+ pass
162
+
163
+ if ws_id:
164
+ try:
165
+ from mcp_server import Workspace, User
166
+ ws = db.query(Workspace).filter(Workspace.id == ws_id).first()
167
+ if ws and ws.created_by:
168
+ user = db.query(User).filter(User.id == ws.created_by).first()
169
+ except Exception:
170
+ pass
171
+
172
+ if not user:
173
+ try:
174
+ from diffweave.cli.credentials import load_credentials
175
+ creds = load_credentials()
176
+ if creds and creds.get("email"):
177
+ from mcp_server import User
178
+ u = db.query(User).filter(User.email == creds["email"]).first()
179
+ if u:
180
+ user = u
181
+ except Exception:
182
+ pass
183
+ if not user:
184
+ user = _get_current_user(db)
185
+
186
  result = await _dispatch(name, arguments, db, user)
187
  return result
188
  except ValueError as e:
diffweave/mcp/standalone_engine.py CHANGED
@@ -1,4 +1,4 @@
1
- """
2
  DiffWeave Embedded Standalone Engine.
3
 
4
  Provides an in-memory / local fallback implementation of the DocWeave MCP
@@ -13,40 +13,71 @@ import re
13
  import uuid
14
  from typing import Any, Optional
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  class StandaloneEngine:
18
  def __init__(self):
19
  self.workspaces: dict[str, dict[str, Any]] = {
20
- "default-standalone-workspace": {
21
- "id": "default-standalone-workspace",
22
  "name": "DiffWeave Standalone Workspace",
23
  "description": "Embedded standalone workspace for offline / decoupled operation",
24
- "created_at": datetime.datetime.now().isoformat(),
25
  }
26
  }
27
- self.documents: list[dict[str, Any]] = []
28
- self.workflows: list[dict[str, Any]] = []
29
- self.proposals: list[dict[str, Any]] = []
30
- self.knowledge_items: list[dict[str, Any]] = []
31
- self.activity_log: list[dict[str, Any]] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  self.rules: list[dict[str, Any]] = [
33
- {
34
- "id": str(uuid.uuid4()),
35
- "workspace_id": "default-standalone-workspace",
36
- "name": "Confidence Floor Check",
37
- "rule_type": "CONFIDENCE_THRESHOLD",
38
- "condition": {"min_confidence": 0.80},
39
- "is_blocking": True,
40
- },
41
- {
42
- "id": str(uuid.uuid4()),
43
- "workspace_id": "default-standalone-workspace",
44
- "name": "Protocol ID Format",
45
- "rule_type": "REGEX_FORMAT",
46
- "condition": {"pattern": r"^PROTOCOL-[A-Z0-9-]+$"},
47
- "is_blocking": False,
48
- },
49
  ]
 
50
 
51
  async def dispatch(self, tool_name: str, args: dict[str, Any]) -> Any:
52
  method = getattr(self, f"tool_{tool_name}", None)
@@ -54,165 +85,95 @@ class StandaloneEngine:
54
  return {"status": "ok", "message": f"Tool {tool_name} executed in standalone mode"}
55
  return await method(args)
56
 
57
- async def tool_list_workspaces(self, args: dict[str, Any]):
58
  return list(self.workspaces.values())
59
 
60
- async def tool_create_workspace(self, args: dict[str, Any]):
61
  ws_id = str(uuid.uuid4())
62
- name = args.get("name", "New Workspace")
63
- desc = args.get("description", "")
64
- entry = {
65
- "id": ws_id,
66
- "name": name,
67
- "description": desc,
68
- "created_at": datetime.datetime.now().isoformat(),
69
- }
70
  self.workspaces[ws_id] = entry
71
  return entry
72
 
73
- async def tool_get_workspace(self, args: dict[str, Any]):
74
  ws_id = args.get("workspace_id")
75
  return self.workspaces.get(ws_id, {"id": ws_id, "name": "Workspace", "description": ""})
76
 
77
- async def tool_list_documents(self, args: dict[str, Any]):
78
  ws_id = args.get("workspace_id")
79
  return [d for d in self.documents if not ws_id or d.get("workspace_id") == ws_id]
80
 
81
- async def tool_upload_document(self, args: dict[str, Any]):
82
- ws_id = args.get("workspace_id", "default-standalone-workspace")
83
- file_path = args.get("file_path", "")
84
- filename = os.path.basename(file_path) if file_path else "document.txt"
85
-
86
  doc_id = str(uuid.uuid4())
87
- ver_id = str(uuid.uuid4())
88
- wf_id = str(uuid.uuid4())
89
-
90
- doc_entry = {
91
- "id": doc_id,
92
- "workspace_id": ws_id,
93
- "filename": filename,
94
- "status": "COMMITTED",
95
- "created_at": datetime.datetime.now().isoformat(),
96
- }
97
- self.documents.append(doc_entry)
98
-
99
- sample_props = [
100
- {
101
- "id": str(uuid.uuid4()),
102
- "workspace_id": ws_id,
103
- "proposal_type": "CREATE",
104
- "summary": f"Extracted clinical claim from {filename}",
105
- "rationale": "High-confidence clinical efficacy statement parsed from section 3",
106
- "status": "PENDING",
107
- "created_at": datetime.datetime.now().isoformat(),
108
- "proposed_changes": {
109
- "type": "CLAIM",
110
- "title": "Primary Protocol Objective",
111
- "value": "Evaluate 12-month post-operative mean transvalvular gradient under 12 mmHg.",
112
- "confidence": 0.94,
113
- "evidence": ["Section 3.1: Protocol Endpoint Determination"],
114
- },
115
- },
116
- {
117
- "id": str(uuid.uuid4()),
118
- "workspace_id": ws_id,
119
- "proposal_type": "UPDATE",
120
- "summary": f"Update planned patient enrollment from {filename}",
121
- "rationale": "Cohort expansion amendment specified in addendum",
122
- "status": "PENDING",
123
- "created_at": datetime.datetime.now().isoformat(),
124
- "proposed_changes": {
125
- "title": "Total Planned Enrollment",
126
- "existing": {"value": "1,200 adult patients", "confidence": 0.90},
127
- "proposed": {
128
- "value": "1,800 adult patients",
129
- "confidence": 0.96,
130
- "evidence": ["Addendum 2: Expanded Sample Size"],
131
- },
132
- },
133
- },
134
- ]
135
- self.proposals.extend(sample_props)
136
-
137
- self.activity_log.append({
138
- "id": str(uuid.uuid4()),
139
- "type": "workflow_completed",
140
- "message": f"Standalone extraction completed for {filename}",
141
- "timestamp": datetime.datetime.now().isoformat(),
142
- })
143
-
144
- return {
145
- "document_id": doc_id,
146
- "version_id": ver_id,
147
- "workflow_id": wf_id,
148
- "filename": filename,
149
- "status": "COMPLETED",
150
- }
151
-
152
- async def tool_get_workflow_status(self, args: dict[str, Any]):
153
- return {"status": "COMPLETED", "progress": 1.0}
154
-
155
- async def tool_list_workflows(self, args: dict[str, Any]):
156
  return self.workflows
157
 
158
- async def tool_get_run_metrics(self, args: dict[str, Any]):
159
- return {
160
- "tokens_used": 1420,
161
- "execution_time_seconds": 2.4,
162
- "nodes_visited": ["extract", "reconcile"],
163
- }
164
 
165
- async def tool_list_proposals(self, args: dict[str, Any]):
166
  ws_id = args.get("workspace_id")
167
  status = args.get("status")
168
- return [
169
- p
170
- for p in self.proposals
171
- if (not ws_id or p.get("workspace_id") == ws_id)
172
- and (not status or p.get("status") == status)
173
- ]
174
 
175
- async def tool_get_proposal(self, args: dict[str, Any]):
 
 
 
 
176
  pid = args.get("proposal_id")
177
  for p in self.proposals:
178
  if p["id"] == pid:
179
  return p
180
- return None
181
 
182
- async def tool_approve_proposal(self, args: dict[str, Any]):
183
  pid = args.get("proposal_id")
184
  for p in self.proposals:
185
  if p["id"] == pid:
186
  p["status"] = "APPROVED"
187
- p["reviewed_at"] = datetime.datetime.now().isoformat()
188
  changes = p.get("proposed_changes", {})
189
- item = {
190
- "id": str(uuid.uuid4()),
191
- "workspace_id": p.get("workspace_id"),
192
- "title": changes.get("title") or p.get("summary"),
193
- "type": changes.get("type", "CLAIM"),
194
- "value": changes.get("value")
195
- or changes.get("proposed", {}).get("value", "Approved Fact"),
196
- "confidence": changes.get("confidence", 0.95),
197
- "created_at": datetime.datetime.now().isoformat(),
198
- }
199
  self.knowledge_items.append(item)
200
- return {
201
- "status": "APPROVED",
202
- "proposal_id": pid,
203
- "knowledge_item_id": item["id"],
204
- }
205
  return {"status": "NOT_FOUND"}
206
 
207
- async def tool_reject_proposal(self, args: dict[str, Any]):
208
  pid = args.get("proposal_id")
209
  for p in self.proposals:
210
  if p["id"] == pid:
211
  p["status"] = "REJECTED"
 
 
212
  return {"status": "REJECTED", "proposal_id": pid}
213
  return {"status": "NOT_FOUND"}
214
 
215
- async def tool_archive_proposal(self, args: dict[str, Any]):
216
  pid = args.get("proposal_id")
217
  for p in self.proposals:
218
  if p["id"] == pid:
@@ -220,11 +181,12 @@ class StandaloneEngine:
220
  return {"status": "ARCHIVED", "proposal_id": pid}
221
  return {"status": "NOT_FOUND"}
222
 
223
- async def tool_batch_review_proposals(self, args: dict[str, Any]):
224
- decision = args.get("decision", "APPROVED")
 
225
  results = []
226
  for p in list(self.proposals):
227
- if p.get("status") == "PENDING":
228
  if decision == "APPROVED":
229
  await self.tool_approve_proposal({"proposal_id": p["id"]})
230
  else:
@@ -232,151 +194,121 @@ class StandaloneEngine:
232
  results.append({"proposal_id": p["id"], "decision": decision})
233
  return {"decision": decision, "processed_count": len(results), "results": results}
234
 
235
- async def tool_get_semantic_diff(self, args: dict[str, Any]):
236
  ws_id = args.get("workspace_id")
237
- pending = [p for p in self.proposals if not ws_id or p.get("workspace_id") == ws_id]
238
-
239
  additions = []
240
- changes = []
241
  conflicts = []
242
-
243
  for p in pending:
244
  pc = p.get("proposed_changes", {})
245
  p_type = p.get("proposal_type")
246
  if p_type == "CREATE":
247
- additions.append({
248
- "proposal_id": p["id"],
249
- "type": pc.get("type", "CLAIM"),
250
- "title": pc.get("title") or p.get("summary"),
251
- "proposed_value": pc.get("value"),
252
- "confidence": pc.get("confidence", 0.94),
253
- "summary": p.get("summary"),
254
- "evidence": pc.get("evidence", []),
255
- "rationale": p.get("rationale"),
256
- })
257
  elif p_type == "UPDATE":
258
- existing = pc.get("existing", {})
259
- proposed = pc.get("proposed", {})
260
- conflicts.append({
261
- "proposal_id": p["id"],
262
- "title": p.get("summary"),
263
- "existing": existing,
264
- "proposed": proposed,
265
- "is_conflict": True,
266
- "rationale": p.get("rationale"),
267
- })
268
-
269
- return {
270
- "workspace_id": ws_id,
271
- "summary": {
272
- "total_pending_proposals": len(pending),
273
- "additions_count": len(additions),
274
- "changes_count": len(changes),
275
- "conflicts_count": len(conflicts),
276
- "removals_count": 0,
277
- },
278
- "additions": additions,
279
- "changes": changes,
280
- "conflicts": conflicts,
281
- "removals": [],
282
- }
283
 
284
- async def tool_validate_proposals(self, args: dict[str, Any]):
285
  ws_id = args.get("workspace_id")
286
- pending = [p for p in self.proposals if not ws_id or p.get("workspace_id") == ws_id]
287
-
288
  validations = []
289
  violations_count = 0
290
  for p in pending:
291
- conf = p.get("proposed_changes", {}).get("confidence", 0.9)
 
292
  if conf < 0.80:
293
- validations.append({
294
- "proposal_id": p["id"],
295
- "summary": p.get("summary"),
296
- "rule_name": "Confidence Floor Check",
297
- "severity": "ERROR",
298
- "message": f"Confidence {conf} is below 0.80 threshold",
299
- })
300
  violations_count += 1
301
  else:
302
- validations.append({
303
- "proposal_id": p["id"],
304
- "summary": p.get("summary"),
305
- "rule_name": "Confidence Floor Check",
306
- "severity": "PASS",
307
- "message": f"Confidence {conf} passed threshold validation",
308
- })
309
-
310
- return {
311
- "workspace_id": ws_id,
312
- "total_proposals": len(pending),
313
- "has_violations": violations_count > 0,
314
- "violations_count": violations_count,
315
- "validations": validations,
316
- }
317
 
318
- async def tool_get_knowledge_graph(self, args: dict[str, Any]):
319
  nodes = []
320
  edges = []
321
  for item in self.knowledge_items:
322
- nodes.append({
323
- "id": item["id"],
324
- "title": item["title"],
325
- "type": item.get("type", "CLAIM"),
326
- "value": item.get("value"),
327
- "confidence": item.get("confidence", 0.95),
328
- "status": "ACTIVE",
329
- })
330
  for p in self.proposals:
331
- nodes.append({
332
- "id": f"proposal-{p['id']}",
333
- "title": f"[PR] {p['summary']}",
334
- "type": "PROPOSAL",
335
- "value": str(p.get("proposed_changes", {}).get("value", "")),
336
- "confidence": 0.9,
337
- "status": "PROPOSED",
338
- })
339
- return {
340
- "nodes": nodes,
341
- "edges": edges,
342
- "stats": {"total_nodes": len(nodes), "total_edges": len(edges)},
343
- }
344
 
345
- async def tool_list_knowledge_items(self, args: dict[str, Any]):
346
- return self.knowledge_items
 
 
 
 
 
 
 
 
347
 
348
- async def tool_search_knowledge(self, args: dict[str, Any]):
 
 
 
 
 
 
 
349
  query = args.get("query", "").lower()
350
- matches = [
351
- item
352
- for item in self.knowledge_items
353
- if query in str(item.get("value", "")).lower()
354
- or query in str(item.get("title", "")).lower()
355
- ]
356
- return matches
357
 
358
- async def tool_export_knowledge(self, args: dict[str, Any]):
359
  return {"knowledge_items": self.knowledge_items}
360
 
361
- async def tool_list_rules(self, args: dict[str, Any]):
362
- return self.rules
363
-
364
- async def tool_create_rule(self, args: dict[str, Any]):
365
- rule = {
366
- "id": str(uuid.uuid4()),
367
- "workspace_id": args.get("workspace_id"),
368
- "name": args.get("name", "New Policy Rule"),
369
- "rule_type": args.get("rule_type", "CUSTOM"),
370
- "condition": args.get("condition", {}),
371
- "is_blocking": args.get("is_blocking", True),
372
- }
373
  self.rules.append(rule)
374
  return rule
375
 
376
- async def tool_delete_rule(self, args: dict[str, Any]):
377
  rid = args.get("rule_id")
378
  self.rules = [r for r in self.rules if r["id"] != rid]
379
  return {"status": "DELETED", "rule_id": rid}
380
 
381
- async def tool_get_activity_feed(self, args: dict[str, Any]):
382
- return self.activity_log
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
  DiffWeave Embedded Standalone Engine.
3
 
4
  Provides an in-memory / local fallback implementation of the DocWeave MCP
 
13
  import uuid
14
  from typing import Any, Optional
15
 
16
+ # Static demo IDs - stable across restarts
17
+ _DOC1 = "d0c00001-0000-0000-0000-000000000001"
18
+ _DOC2 = "d0c00002-0000-0000-0000-000000000002"
19
+ _DOC3 = "d0c00003-0000-0000-0000-000000000003"
20
+ _KI1 = "k1000001-0000-0000-0000-000000000001"
21
+ _KI2 = "k1000002-0000-0000-0000-000000000002"
22
+ _KI3 = "k1000003-0000-0000-0000-000000000003"
23
+ _KI4 = "k1000004-0000-0000-0000-000000000004"
24
+ _KI5 = "k1000005-0000-0000-0000-000000000005"
25
+ _PR1 = "p0000001-0000-0000-0000-000000000001"
26
+ _PR2 = "p0000002-0000-0000-0000-000000000002"
27
+ _PR3 = "p0000003-0000-0000-0000-000000000003"
28
+ _WS = "default-standalone-workspace"
29
+
30
+
31
+ def _ts(days_ago=0, hours_ago=0) -> str:
32
+ t = datetime.datetime.now() - datetime.timedelta(days=days_ago, hours=hours_ago)
33
+ return t.isoformat()
34
+
35
 
36
  class StandaloneEngine:
37
  def __init__(self):
38
  self.workspaces: dict[str, dict[str, Any]] = {
39
+ _WS: {
40
+ "id": _WS,
41
  "name": "DiffWeave Standalone Workspace",
42
  "description": "Embedded standalone workspace for offline / decoupled operation",
43
+ "created_at": _ts(days_ago=14),
44
  }
45
  }
46
+ self.documents: list[dict[str, Any]] = [
47
+ {"id": _DOC1, "workspace_id": _WS, "filename": "Q3-2025-Clinical-Trial-Report.pdf", "status": "PROCESSED", "page_count": 42, "created_at": _ts(days_ago=7), "knowledge_items_extracted": 18},
48
+ {"id": _DOC2, "workspace_id": _WS, "filename": "Regulatory-Compliance-Framework-v4.docx", "status": "PROCESSED", "page_count": 103, "created_at": _ts(days_ago=3), "knowledge_items_extracted": 37},
49
+ {"id": _DOC3, "workspace_id": _WS, "filename": "Safety-Protocol-Amendment-Draft.pdf", "status": "PENDING", "page_count": 17, "created_at": _ts(hours_ago=2), "knowledge_items_extracted": 0},
50
+ ]
51
+ self.knowledge_items: list[dict[str, Any]] = [
52
+ {"id": _KI1, "workspace_id": _WS, "title": "Primary Efficacy Endpoint", "type": "CLAIM", "value": "Trial arm A demonstrated a 34.7% reduction in primary endpoint events vs placebo at 52-week follow-up (p < 0.001, 95% CI: 28.1-41.3%).", "confidence": 0.97, "status": "ACTIVE", "filename": "Q3-2025-Clinical-Trial-Report.pdf", "created_at": _ts(days_ago=7)},
53
+ {"id": _KI2, "workspace_id": _WS, "title": "Median Overall Survival", "type": "METRIC", "value": "mOS = 18.4 months (95% CI: 15.9-21.2) in the treatment group versus 11.7 months in the control group (HR 0.64, p = 0.002).", "confidence": 0.99, "status": "ACTIVE", "filename": "Q3-2025-Clinical-Trial-Report.pdf", "created_at": _ts(days_ago=7)},
54
+ {"id": _KI3, "workspace_id": _WS, "title": "GCP Audit Readiness Requirement", "type": "ENTITY", "value": "All sites must maintain source data verification (SDV) records for 15 years post-study closure per 21 CFR Part 11 and ICH E6(R2) Section 8.4.", "confidence": 0.96, "status": "ACTIVE", "filename": "Regulatory-Compliance-Framework-v4.docx", "created_at": _ts(days_ago=3)},
55
+ {"id": _KI4, "workspace_id": _WS, "title": "SAE Reporting Window", "type": "CLAIM", "value": "Serious adverse events (SAEs) must be reported to the sponsor within 24 hours of site awareness and to the IRB within 5 working days.", "confidence": 0.94, "status": "ACTIVE", "filename": "Regulatory-Compliance-Framework-v4.docx", "created_at": _ts(days_ago=3)},
56
+ {"id": _KI5, "workspace_id": _WS, "title": "Enrolment Milestone", "type": "METRIC", "value": "Target enrolment of 1,200 participants achieved as of 2025-09-15; 94.8% completion rate across 23 active sites.", "confidence": 0.91, "status": "ACTIVE", "filename": "Q3-2025-Clinical-Trial-Report.pdf", "created_at": _ts(days_ago=5)},
57
+ ]
58
+ self.proposals: list[dict[str, Any]] = [
59
+ {"id": _PR1, "workspace_id": _WS, "summary": "Update mOS confidence interval from prelim to final analysis", "status": "PENDING", "proposal_type": "UPDATE", "rationale": "Final statistical analysis yields tighter 95% CI bounds.", "proposed_changes": {"existing": {"value": "mOS = 18.4 months (95% CI: 15.9-21.2)", "confidence": 0.91}, "proposed": {"value": "mOS = 18.4 months (95% CI: 16.2-20.8)", "confidence": 0.99}, "type": "METRIC", "title": "Median Overall Survival (Final)"}, "created_at": _ts(hours_ago=6)},
60
+ {"id": _PR2, "workspace_id": _WS, "summary": "Add new safety finding: Grade 3 hepatotoxicity incidence", "status": "PENDING", "proposal_type": "CREATE", "rationale": "Post-hoc safety review identified hepatotoxicity signal requiring documentation.", "proposed_changes": {"type": "CLAIM", "title": "Grade 3 Hepatotoxicity Incidence", "value": "Grade >=3 hepatotoxicity was observed in 4.2% of treatment arm patients vs 0.8% in placebo (p = 0.04).", "confidence": 0.89, "evidence": ["Table 14, Q3-2025-Clinical-Trial-Report.pdf"]}, "created_at": _ts(hours_ago=3)},
61
+ {"id": _PR3, "workspace_id": _WS, "summary": "Extend SDV retention period from 15 to 25 years per updated EU MDR", "status": "PENDING", "proposal_type": "UPDATE", "rationale": "EU MDR 2017/745 Article 87 requires 25-year post-market retention for Class III devices.", "proposed_changes": {"existing": {"value": "All sites must maintain SDV records for 15 years post-study closure.", "confidence": 0.96}, "proposed": {"value": "All sites must maintain SDV records for 25 years post-study closure per EU MDR 2017/745 Art. 87 and 21 CFR Part 11.", "confidence": 0.98}, "type": "ENTITY", "title": "SDV Retention Period (Updated)"}, "created_at": _ts(days_ago=1)},
62
+ ]
63
+ self.activity_log: list[dict[str, Any]] = [
64
+ {"id": str(uuid.uuid4()), "type": "proposal_approved", "message": "Knowledge commit approved: 'Primary Efficacy Endpoint' merged into master truth register.", "timestamp": _ts(days_ago=7), "metadata": {"knowledge_item_id": _KI1}},
65
+ {"id": str(uuid.uuid4()), "type": "proposal_approved", "message": "Knowledge commit approved: 'Median Overall Survival' merged into master truth register.", "timestamp": _ts(days_ago=7), "metadata": {"knowledge_item_id": _KI2}},
66
+ {"id": str(uuid.uuid4()), "type": "workflow_completed", "message": "Document ingestion workflow completed for 'Q3-2025-Clinical-Trial-Report.pdf' - 18 facts extracted.", "timestamp": _ts(days_ago=7), "metadata": {"document_id": _DOC1}},
67
+ {"id": str(uuid.uuid4()), "type": "proposal_approved", "message": "Knowledge commit approved: 'GCP Audit Readiness Requirement' merged into master truth register.", "timestamp": _ts(days_ago=3), "metadata": {"knowledge_item_id": _KI3}},
68
+ {"id": str(uuid.uuid4()), "type": "workflow_completed", "message": "Document ingestion workflow completed for 'Regulatory-Compliance-Framework-v4.docx' - 37 facts extracted.", "timestamp": _ts(days_ago=3), "metadata": {"document_id": _DOC2}},
69
+ {"id": str(uuid.uuid4()), "type": "proposal_created", "message": "Knowledge PR opened: 'Extend SDV retention period from 15 to 25 years per updated EU MDR'.", "timestamp": _ts(days_ago=1), "metadata": {"proposal_id": _PR3}},
70
+ {"id": str(uuid.uuid4()), "type": "proposal_created", "message": "Knowledge PR opened: 'Update mOS confidence interval from prelim to final analysis'.", "timestamp": _ts(hours_ago=6), "metadata": {"proposal_id": _PR1}},
71
+ {"id": str(uuid.uuid4()), "type": "proposal_created", "message": "Knowledge PR opened: 'Add new safety finding: Grade 3 hepatotoxicity incidence'.", "timestamp": _ts(hours_ago=3), "metadata": {"proposal_id": _PR2}},
72
+ {"id": str(uuid.uuid4()), "type": "workflow_completed", "message": "Ingestion staging: 'Safety-Protocol-Amendment-Draft.pdf' queued for processing.", "timestamp": _ts(hours_ago=2), "metadata": {"document_id": _DOC3}},
73
+ ]
74
  self.rules: list[dict[str, Any]] = [
75
+ {"id": str(uuid.uuid4()), "workspace_id": _WS, "name": "Confidence Floor Check", "rule_type": "CONFIDENCE_THRESHOLD", "condition": {"min_confidence": 0.80}, "is_blocking": True, "enabled": True},
76
+ {"id": str(uuid.uuid4()), "workspace_id": _WS, "name": "Protocol ID Format", "rule_type": "REGEX_FORMAT", "condition": {"pattern": "^PROTOCOL-[A-Z0-9-]+$"}, "is_blocking": False, "enabled": True},
77
+ {"id": str(uuid.uuid4()), "workspace_id": _WS, "name": "Mandatory Provenance Citation", "rule_type": "PROVENANCE_MANDATORY", "condition": {"require_source_document": True}, "is_blocking": True, "enabled": True},
78
+ {"id": str(uuid.uuid4()), "workspace_id": _WS, "name": "Statistical Claim Validation", "rule_type": "REGEX_FORMAT", "condition": {"pattern": "p\\s*[<>=]\\s*0\\.\\d+"}, "is_blocking": False, "enabled": True},
 
 
 
 
 
 
 
 
 
 
 
 
79
  ]
80
+ self.workflows: list[dict[str, Any]] = []
81
 
82
  async def dispatch(self, tool_name: str, args: dict[str, Any]) -> Any:
83
  method = getattr(self, f"tool_{tool_name}", None)
 
85
  return {"status": "ok", "message": f"Tool {tool_name} executed in standalone mode"}
86
  return await method(args)
87
 
88
+ async def tool_list_workspaces(self, args):
89
  return list(self.workspaces.values())
90
 
91
+ async def tool_create_workspace(self, args):
92
  ws_id = str(uuid.uuid4())
93
+ entry = {"id": ws_id, "name": args.get("name", "New Workspace"), "description": args.get("description", ""), "created_at": _ts()}
 
 
 
 
 
 
 
94
  self.workspaces[ws_id] = entry
95
  return entry
96
 
97
+ async def tool_get_workspace(self, args):
98
  ws_id = args.get("workspace_id")
99
  return self.workspaces.get(ws_id, {"id": ws_id, "name": "Workspace", "description": ""})
100
 
101
+ async def tool_list_documents(self, args):
102
  ws_id = args.get("workspace_id")
103
  return [d for d in self.documents if not ws_id or d.get("workspace_id") == ws_id]
104
 
105
+ async def tool_upload_document(self, args):
 
 
 
 
106
  doc_id = str(uuid.uuid4())
107
+ filename = os.path.basename(args.get("filename", args.get("file_path", "uploaded.pdf")))
108
+ doc = {"id": doc_id, "workspace_id": args.get("workspace_id", _WS), "filename": filename, "status": "PROCESSING", "page_count": None, "created_at": _ts(), "knowledge_items_extracted": 0}
109
+ self.documents.append(doc)
110
+ self.activity_log.insert(0, {"id": str(uuid.uuid4()), "type": "workflow_completed", "message": f"Document '{filename}' staged for ingestion.", "timestamp": _ts(), "metadata": {"document_id": doc_id}})
111
+ return {"document_id": doc_id, "workflow_id": str(uuid.uuid4()), "status": "PROCESSING", "document": doc}
112
+
113
+ async def tool_delete_document(self, args):
114
+ doc_id = args.get("document_id")
115
+ self.documents = [d for d in self.documents if d["id"] != doc_id]
116
+ return {"status": "DELETED", "document_id": doc_id}
117
+
118
+ async def tool_retry_document(self, args):
119
+ doc_id = args.get("document_id")
120
+ for d in self.documents:
121
+ if d["id"] == doc_id:
122
+ d["status"] = "PROCESSING"
123
+ return {"status": "RETRY_QUEUED", "document_id": doc_id}
124
+
125
+ async def tool_get_workflow_status(self, args):
126
+ return {"workflow_id": args.get("workflow_id"), "status": "COMPLETED", "progress": 100}
127
+
128
+ async def tool_list_workflows(self, args):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  return self.workflows
130
 
131
+ async def tool_get_run_metrics(self, args):
132
+ return {"workflow_id": args.get("workflow_id"), "duration_seconds": 12.4, "tokens_used": 2100}
 
 
 
 
133
 
134
+ async def tool_list_proposals(self, args):
135
  ws_id = args.get("workspace_id")
136
  status = args.get("status")
137
+ result = [p for p in self.proposals if not ws_id or p.get("workspace_id") == ws_id]
138
+ if status:
139
+ result = [p for p in result if p.get("status", "").upper() == status.upper()]
140
+ return result
 
 
141
 
142
+ async def tool_list_pending_proposals(self, args):
143
+ ws_id = args.get("workspace_id")
144
+ return [p for p in self.proposals if (not ws_id or p.get("workspace_id") == ws_id) and p.get("status") == "PENDING"]
145
+
146
+ async def tool_get_proposal(self, args):
147
  pid = args.get("proposal_id")
148
  for p in self.proposals:
149
  if p["id"] == pid:
150
  return p
151
+ return {"id": pid, "status": "NOT_FOUND"}
152
 
153
+ async def tool_approve_proposal(self, args):
154
  pid = args.get("proposal_id")
155
  for p in self.proposals:
156
  if p["id"] == pid:
157
  p["status"] = "APPROVED"
158
+ p["reviewed_at"] = _ts()
159
  changes = p.get("proposed_changes", {})
160
+ item = {"id": str(uuid.uuid4()), "workspace_id": p.get("workspace_id", _WS), "title": changes.get("title") or p.get("summary"), "type": changes.get("type", "CLAIM"), "value": (changes.get("value") or changes.get("proposed", {}).get("value", "Approved Fact")), "confidence": changes.get("confidence", changes.get("proposed", {}).get("confidence", 0.95)), "status": "ACTIVE", "created_at": _ts()}
 
 
 
 
 
 
 
 
 
161
  self.knowledge_items.append(item)
162
+ self.activity_log.insert(0, {"id": str(uuid.uuid4()), "type": "proposal_approved", "message": f"Knowledge PR merged: '{p['summary']}'", "timestamp": _ts(), "metadata": {"proposal_id": pid}})
163
+ return {"status": "APPROVED", "proposal_id": pid, "knowledge_item_id": item["id"]}
 
 
 
164
  return {"status": "NOT_FOUND"}
165
 
166
+ async def tool_reject_proposal(self, args):
167
  pid = args.get("proposal_id")
168
  for p in self.proposals:
169
  if p["id"] == pid:
170
  p["status"] = "REJECTED"
171
+ p["reviewed_at"] = _ts()
172
+ self.activity_log.insert(0, {"id": str(uuid.uuid4()), "type": "proposal_created", "message": f"Knowledge PR rejected: '{p['summary']}'", "timestamp": _ts(), "metadata": {"proposal_id": pid}})
173
  return {"status": "REJECTED", "proposal_id": pid}
174
  return {"status": "NOT_FOUND"}
175
 
176
+ async def tool_archive_proposal(self, args):
177
  pid = args.get("proposal_id")
178
  for p in self.proposals:
179
  if p["id"] == pid:
 
181
  return {"status": "ARCHIVED", "proposal_id": pid}
182
  return {"status": "NOT_FOUND"}
183
 
184
+ async def tool_batch_review_proposals(self, args):
185
+ decision = args.get("decision", "APPROVED").upper()
186
+ ws_id = args.get("workspace_id")
187
  results = []
188
  for p in list(self.proposals):
189
+ if p.get("status") == "PENDING" and (not ws_id or p.get("workspace_id") == ws_id):
190
  if decision == "APPROVED":
191
  await self.tool_approve_proposal({"proposal_id": p["id"]})
192
  else:
 
194
  results.append({"proposal_id": p["id"], "decision": decision})
195
  return {"decision": decision, "processed_count": len(results), "results": results}
196
 
197
+ async def tool_get_semantic_diff(self, args):
198
  ws_id = args.get("workspace_id")
199
+ pending = [p for p in self.proposals if (not ws_id or p.get("workspace_id") == ws_id) and p.get("status") == "PENDING"]
 
200
  additions = []
 
201
  conflicts = []
 
202
  for p in pending:
203
  pc = p.get("proposed_changes", {})
204
  p_type = p.get("proposal_type")
205
  if p_type == "CREATE":
206
+ additions.append({"proposal_id": p["id"], "type": pc.get("type", "CLAIM"), "title": pc.get("title") or p.get("summary"), "proposed_value": pc.get("value"), "confidence": pc.get("confidence", 0.94), "summary": p.get("summary"), "evidence": pc.get("evidence", []), "rationale": p.get("rationale")})
 
 
 
 
 
 
 
 
 
207
  elif p_type == "UPDATE":
208
+ conflicts.append({"proposal_id": p["id"], "title": pc.get("title") or p.get("summary"), "existing": pc.get("existing", {}), "proposed": pc.get("proposed", {}), "is_conflict": True, "rationale": p.get("rationale"), "type": pc.get("type", "CLAIM")})
209
+ return {"workspace_id": ws_id, "summary": {"total_pending_proposals": len(pending), "additions_count": len(additions), "changes_count": 0, "conflicts_count": len(conflicts), "removals_count": 0}, "additions": additions, "changes": [], "conflicts": conflicts, "removals": []}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
+ async def tool_validate_proposals(self, args):
212
  ws_id = args.get("workspace_id")
213
+ pending = [p for p in self.proposals if (not ws_id or p.get("workspace_id") == ws_id) and p.get("status") == "PENDING"]
 
214
  validations = []
215
  violations_count = 0
216
  for p in pending:
217
+ pc = p.get("proposed_changes", {})
218
+ conf = pc.get("confidence", pc.get("proposed", {}).get("confidence", 0.9))
219
  if conf < 0.80:
220
+ validations.append({"proposal_id": p["id"], "summary": p.get("summary"), "rule_name": "Confidence Floor Check", "severity": "ERROR", "message": f"Confidence {conf:.2f} is below 0.80 threshold"})
 
 
 
 
 
 
221
  violations_count += 1
222
  else:
223
+ validations.append({"proposal_id": p["id"], "summary": p.get("summary"), "rule_name": "Confidence Floor Check", "severity": "PASS", "message": f"Confidence {conf:.2f} passed threshold validation"})
224
+ return {"workspace_id": ws_id, "total_proposals": len(pending), "has_violations": violations_count > 0, "violations_count": violations_count, "validations": validations}
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
+ async def tool_get_knowledge_graph(self, args):
227
  nodes = []
228
  edges = []
229
  for item in self.knowledge_items:
230
+ nodes.append({"id": item["id"], "title": item["title"], "type": item.get("type", "CLAIM"), "value": item.get("value"), "confidence": item.get("confidence", 0.95), "status": item.get("status", "ACTIVE"), "filename": item.get("filename")})
 
 
 
 
 
 
 
231
  for p in self.proposals:
232
+ if p.get("status") == "PENDING":
233
+ nodes.append({"id": f"proposal-{p['id']}", "title": f"[PR] {p['summary'][:50]}", "type": "PROPOSAL", "value": str(p.get("proposed_changes", {}).get("value", "")), "confidence": 0.9, "status": "PROPOSED"})
234
+ ki = self.knowledge_items
235
+ for i in range(len(ki) - 1):
236
+ edges.append({"id": f"edge-{i}", "source": ki[i]["id"], "target": ki[i + 1]["id"], "relationship": "RELATED_TO"})
237
+ return {"nodes": nodes, "edges": edges, "stats": {"total_nodes": len(nodes), "total_edges": len(edges)}}
 
 
 
 
 
 
 
238
 
239
+ async def tool_list_knowledge_items(self, args):
240
+ ws_id = args.get("workspace_id")
241
+ status = args.get("status")
242
+ items = [i for i in self.knowledge_items if not ws_id or i.get("workspace_id") == ws_id]
243
+ if status:
244
+ items = [i for i in items if i.get("status", "").upper() == status.upper()]
245
+ return items
246
+
247
+ async def tool_list_knowledge(self, args):
248
+ return await self.tool_list_knowledge_items(args)
249
 
250
+ async def tool_get_knowledge_item(self, args):
251
+ item_id = args.get("item_id")
252
+ for item in self.knowledge_items:
253
+ if item["id"] == item_id:
254
+ return item
255
+ return {"id": item_id, "status": "NOT_FOUND"}
256
+
257
+ async def tool_search_knowledge(self, args):
258
  query = args.get("query", "").lower()
259
+ if not query:
260
+ return self.knowledge_items
261
+ return [item for item in self.knowledge_items if query in str(item.get("value", "")).lower() or query in str(item.get("title", "")).lower()]
 
 
 
 
262
 
263
+ async def tool_export_knowledge(self, args):
264
  return {"knowledge_items": self.knowledge_items}
265
 
266
+ async def tool_list_rules(self, args):
267
+ ws_id = args.get("workspace_id")
268
+ return [r for r in self.rules if not ws_id or r.get("workspace_id") == ws_id]
269
+
270
+ async def tool_create_rule(self, args):
271
+ rule = {"id": str(uuid.uuid4()), "workspace_id": args.get("workspace_id", _WS), "name": args.get("name", "New Policy Rule"), "rule_type": args.get("rule_type", "CUSTOM"), "condition": args.get("condition", {}), "is_blocking": args.get("is_blocking", True), "enabled": True}
 
 
 
 
 
 
272
  self.rules.append(rule)
273
  return rule
274
 
275
+ async def tool_delete_rule(self, args):
276
  rid = args.get("rule_id")
277
  self.rules = [r for r in self.rules if r["id"] != rid]
278
  return {"status": "DELETED", "rule_id": rid}
279
 
280
+ async def tool_enable_rule(self, args):
281
+ rid = args.get("rule_id")
282
+ for r in self.rules:
283
+ if r["id"] == rid:
284
+ r["enabled"] = True
285
+ return {"status": "ok", "rule_id": rid, "enabled": True}
286
+
287
+ async def tool_disable_rule(self, args):
288
+ rid = args.get("rule_id")
289
+ for r in self.rules:
290
+ if r["id"] == rid:
291
+ r["enabled"] = False
292
+ return {"status": "ok", "rule_id": rid, "enabled": False}
293
+
294
+ async def tool_get_activity_feed(self, args):
295
+ limit = args.get("limit", 50)
296
+ return self.activity_log[:limit]
297
+
298
+ async def tool_get_dashboard_stats(self, args):
299
+ ws_id = args.get("workspace_id")
300
+ docs = [d for d in self.documents if not ws_id or d.get("workspace_id") == ws_id]
301
+ props = [p for p in self.proposals if not ws_id or p.get("workspace_id") == ws_id]
302
+ ki = [k for k in self.knowledge_items if not ws_id or k.get("workspace_id") == ws_id]
303
+ pending = [p for p in props if p.get("status") == "PENDING"]
304
+ approved = [p for p in props if p.get("status") == "APPROVED"]
305
+ rejected = [p for p in props if p.get("status") == "REJECTED"]
306
+ return {"workspace_id": ws_id, "total_documents": len(docs), "total_knowledge_items": len(ki), "total_proposals": len(props), "pending_proposals": len(pending), "approved_proposals": len(approved), "rejected_proposals": len(rejected)}
307
+
308
+ async def tool_restore_proposal(self, args):
309
+ pid = args.get("proposal_id")
310
+ for p in self.proposals:
311
+ if p["id"] == pid:
312
+ p["status"] = "PENDING"
313
+ return {"status": "ok", "proposal_id": pid, "restored": True}
314
+ return {"status": "NOT_FOUND"}
pyproject.toml CHANGED
@@ -16,11 +16,16 @@ dependencies = [
16
  "httpx>=0.25.0",
17
  "fastapi>=0.100.0",
18
  "uvicorn>=0.23.0",
 
 
 
 
19
  ]
20
 
21
  [project.scripts]
22
- dw = "cli.main:app"
 
23
 
24
  [tool.setuptools.packages.find]
25
  where = ["."]
26
- include = ["cli*", "mcp*", "bridge*"]
 
16
  "httpx>=0.25.0",
17
  "fastapi>=0.100.0",
18
  "uvicorn>=0.23.0",
19
+ "sqlalchemy>=2.0.0",
20
+ "psycopg2-binary>=2.9.0",
21
+ "python-jose[cryptography]>=3.3.0",
22
+ "passlib[bcrypt]>=1.7.4",
23
  ]
24
 
25
  [project.scripts]
26
+ dw = "diffweave.cli.main:app"
27
+ diffweave = "diffweave.cli.main:app"
28
 
29
  [tool.setuptools.packages.find]
30
  where = ["."]
31
+ include = ["diffweave*"]
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.6
3
+ pydantic>=2.8.2
4
+ httpx>=0.27.2
5
+ typer>=0.12.5
6
+ rich>=13.8.0
7
+ python-multipart>=0.0.9
8
+ sqlalchemy>=2.0.0
9
+ psycopg2-binary>=2.9.9
10
+ passlib[bcrypt]>=1.7.4
11
+ bcrypt>=4.0.0
12
+ python-jose[cryptography]>=3.3.0
13
+ requests>=2.31.0
14
+ python-dotenv>=1.0.0
studio/package-lock.json CHANGED
@@ -11,7 +11,8 @@
11
  "canvas-confetti": "^1.9.4",
12
  "lucide-react": "^1.49.0",
13
  "react": "^18.2.0",
14
- "react-dom": "^18.2.0"
 
15
  },
16
  "devDependencies": {
17
  "@vitejs/plugin-react": "^4.2.0",
@@ -1518,6 +1519,19 @@
1518
  "dev": true,
1519
  "license": "MIT"
1520
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1521
  "node_modules/cssesc": {
1522
  "version": "3.0.0",
1523
  "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2279,6 +2293,44 @@
2279
  "node": ">=0.10.0"
2280
  }
2281
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2282
  "node_modules/read-cache": {
2283
  "version": "1.0.2",
2284
  "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
@@ -2421,6 +2473,12 @@
2421
  "semver": "bin/semver.js"
2422
  }
2423
  },
 
 
 
 
 
 
2424
  "node_modules/source-map-js": {
2425
  "version": "1.2.2",
2426
  "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
 
11
  "canvas-confetti": "^1.9.4",
12
  "lucide-react": "^1.49.0",
13
  "react": "^18.2.0",
14
+ "react-dom": "^18.2.0",
15
+ "react-router-dom": "^7.18.4"
16
  },
17
  "devDependencies": {
18
  "@vitejs/plugin-react": "^4.2.0",
 
1519
  "dev": true,
1520
  "license": "MIT"
1521
  },
1522
+ "node_modules/cookie": {
1523
+ "version": "1.1.1",
1524
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
1525
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
1526
+ "license": "MIT",
1527
+ "engines": {
1528
+ "node": ">=18"
1529
+ },
1530
+ "funding": {
1531
+ "type": "opencollective",
1532
+ "url": "https://opencollective.com/express"
1533
+ }
1534
+ },
1535
  "node_modules/cssesc": {
1536
  "version": "3.0.0",
1537
  "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
 
2293
  "node": ">=0.10.0"
2294
  }
2295
  },
2296
+ "node_modules/react-router": {
2297
+ "version": "7.18.4",
2298
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
2299
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
2300
+ "license": "MIT",
2301
+ "dependencies": {
2302
+ "cookie": "^1.0.1",
2303
+ "set-cookie-parser": "^2.6.0"
2304
+ },
2305
+ "engines": {
2306
+ "node": ">=20.0.0"
2307
+ },
2308
+ "peerDependencies": {
2309
+ "react": ">=18",
2310
+ "react-dom": ">=18"
2311
+ },
2312
+ "peerDependenciesMeta": {
2313
+ "react-dom": {
2314
+ "optional": true
2315
+ }
2316
+ }
2317
+ },
2318
+ "node_modules/react-router-dom": {
2319
+ "version": "7.18.4",
2320
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
2321
+ "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
2322
+ "license": "MIT",
2323
+ "dependencies": {
2324
+ "react-router": "7.18.4"
2325
+ },
2326
+ "engines": {
2327
+ "node": ">=20.0.0"
2328
+ },
2329
+ "peerDependencies": {
2330
+ "react": ">=18",
2331
+ "react-dom": ">=18"
2332
+ }
2333
+ },
2334
  "node_modules/read-cache": {
2335
  "version": "1.0.2",
2336
  "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
 
2473
  "semver": "bin/semver.js"
2474
  }
2475
  },
2476
+ "node_modules/set-cookie-parser": {
2477
+ "version": "2.7.2",
2478
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
2479
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
2480
+ "license": "MIT"
2481
+ },
2482
  "node_modules/source-map-js": {
2483
  "version": "1.2.2",
2484
  "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
studio/package.json CHANGED
@@ -11,7 +11,8 @@
11
  "canvas-confetti": "^1.9.4",
12
  "lucide-react": "^1.49.0",
13
  "react": "^18.2.0",
14
- "react-dom": "^18.2.0"
 
15
  },
16
  "devDependencies": {
17
  "@vitejs/plugin-react": "^4.2.0",
 
11
  "canvas-confetti": "^1.9.4",
12
  "lucide-react": "^1.49.0",
13
  "react": "^18.2.0",
14
+ "react-dom": "^18.2.0",
15
+ "react-router-dom": "^7.18.4"
16
  },
17
  "devDependencies": {
18
  "@vitejs/plugin-react": "^4.2.0",
studio/src/App.jsx CHANGED
@@ -1,4 +1,6 @@
1
- import React, { useState, useEffect, useCallback } from 'react';
 
 
2
  import { api } from './api/client';
3
  import Navbar from './components/Navbar';
4
  import SemanticDiffViewer from './components/SemanticDiffViewer';
@@ -12,10 +14,14 @@ import AuthView from './components/AuthView';
12
  import { RefreshCw, CheckCircle2, AlertCircle, Sparkles, GitBranch } from 'lucide-react';
13
 
14
  export default function App() {
 
 
 
15
  const [workspaces, setWorkspaces] = useState([]);
16
  const [currentWorkspace, setCurrentWorkspace] = useState(null);
17
  const [activeTab, setActiveTab] = useState('diff'); // Default to Semantic Diff Viewer!
18
  const [currentUser, setCurrentUser] = useState(null);
 
19
 
20
  // State data
21
  const [stats, setStats] = useState(null);
@@ -49,16 +55,8 @@ export default function App() {
49
  showToast(`Connecting to DiffWeave FastMCP Bridge...`, 'info');
50
  }
51
 
52
- // Check current user
53
  const localUser = api.getCurrentUser();
54
- if (localUser) {
55
- setCurrentUser(localUser);
56
- } else {
57
- const who = await api.getWhoami();
58
- if (who?.authenticated && who?.user) {
59
- setCurrentUser(who.user);
60
- }
61
- }
62
  }
63
  init();
64
  }, []);
@@ -76,11 +74,11 @@ export default function App() {
76
  }
77
  if (activeTab === 'prs') {
78
  const prsRes = await api.getProposals(wsId);
79
- setProposals(prsRes || []);
80
  }
81
  if (activeTab === 'staging') {
82
  const docsRes = await api.getDocuments(wsId);
83
- setDocuments(docsRes || []);
84
  }
85
  if (activeTab === 'graph') {
86
  const graphRes = await api.getKnowledgeGraph(wsId);
@@ -88,15 +86,15 @@ export default function App() {
88
  }
89
  if (activeTab === 'knowledge') {
90
  const kRes = await api.getKnowledgeItems(wsId);
91
- setKnowledgeItems(kRes || []);
92
  }
93
  if (activeTab === 'rules') {
94
  const rulesRes = await api.getRules(wsId);
95
- setRules(rulesRes || []);
96
  }
97
  if (activeTab === 'audit') {
98
  const actRes = await api.getActivityFeed(wsId);
99
- setActivity(actRes || []);
100
  }
101
 
102
  // Background status fetch for navbar counters
@@ -118,6 +116,14 @@ export default function App() {
118
  useEffect(() => {
119
  refreshData();
120
  }, [refreshData]);
 
 
 
 
 
 
 
 
121
 
122
  // Actions
123
  const handleCreateWorkspace = async (name, desc) => {
@@ -133,7 +139,7 @@ export default function App() {
133
 
134
  const handleReview = async (proposalId, decision, comments = '') => {
135
  try {
136
- await api.reviewProposal(proposalId, decision, comments);
137
  showToast(`Proposal ${decision.toLowerCase()}!`);
138
  refreshData();
139
  } catch (err) {
@@ -196,14 +202,21 @@ export default function App() {
196
 
197
  const handleLogout = () => {
198
  api.logout();
199
- setCurrentUser(null);
200
- showToast('Logged out of DiffWeave.');
201
  };
202
 
 
 
 
 
 
203
  return (
204
  <div className="min-h-screen bg-[#0D1117] text-slate-100 flex flex-col font-sans selection:bg-emerald-500/20 selection:text-emerald-300">
205
  {/* Platform Navigation */}
206
  <Navbar
 
 
207
  workspaces={workspaces}
208
  currentWorkspace={currentWorkspace}
209
  onSelectWorkspace={(id) => setCurrentWorkspace(workspaces.find((w) => w.id === id))}
@@ -306,6 +319,8 @@ export default function App() {
306
  knowledgeItems={knowledgeItems}
307
  currentWorkspace={currentWorkspace}
308
  loading={loading}
 
 
309
  />
310
  )}
311
 
 
1
+ import { useNavigate, Navigate } from 'react-router-dom';
2
+ import { useAuth } from './context/AuthContext.jsx';
3
+ import React, { useState, useEffect, useCallback } from 'react';
4
  import { api } from './api/client';
5
  import Navbar from './components/Navbar';
6
  import SemanticDiffViewer from './components/SemanticDiffViewer';
 
14
  import { RefreshCw, CheckCircle2, AlertCircle, Sparkles, GitBranch } from 'lucide-react';
15
 
16
  export default function App() {
17
+ const { isAuthenticated, logout: authLogout, user: authUser } = useAuth();
18
+ const navigate = useNavigate();
19
+
20
  const [workspaces, setWorkspaces] = useState([]);
21
  const [currentWorkspace, setCurrentWorkspace] = useState(null);
22
  const [activeTab, setActiveTab] = useState('diff'); // Default to Semantic Diff Viewer!
23
  const [currentUser, setCurrentUser] = useState(null);
24
+ const [searchQuery, setSearchQuery] = useState('');
25
 
26
  // State data
27
  const [stats, setStats] = useState(null);
 
55
  showToast(`Connecting to DiffWeave FastMCP Bridge...`, 'info');
56
  }
57
 
 
58
  const localUser = api.getCurrentUser();
59
+ if (localUser) setCurrentUser(localUser);
 
 
 
 
 
 
 
60
  }
61
  init();
62
  }, []);
 
74
  }
75
  if (activeTab === 'prs') {
76
  const prsRes = await api.getProposals(wsId);
77
+ setProposals(Array.isArray(prsRes) ? prsRes : []);
78
  }
79
  if (activeTab === 'staging') {
80
  const docsRes = await api.getDocuments(wsId);
81
+ setDocuments(Array.isArray(docsRes) ? docsRes : []);
82
  }
83
  if (activeTab === 'graph') {
84
  const graphRes = await api.getKnowledgeGraph(wsId);
 
86
  }
87
  if (activeTab === 'knowledge') {
88
  const kRes = await api.getKnowledgeItems(wsId);
89
+ setKnowledgeItems(Array.isArray(kRes) ? kRes : []);
90
  }
91
  if (activeTab === 'rules') {
92
  const rulesRes = await api.getRules(wsId);
93
+ setRules(Array.isArray(rulesRes) ? rulesRes : []);
94
  }
95
  if (activeTab === 'audit') {
96
  const actRes = await api.getActivityFeed(wsId);
97
+ setActivity(Array.isArray(actRes) ? actRes : []);
98
  }
99
 
100
  // Background status fetch for navbar counters
 
116
  useEffect(() => {
117
  refreshData();
118
  }, [refreshData]);
119
+ useEffect(() => {
120
+ if (authUser) {
121
+ setCurrentUser(authUser);
122
+ } else {
123
+ const u = api.getCurrentUser();
124
+ if (u) setCurrentUser(u);
125
+ }
126
+ }, [authUser]);
127
 
128
  // Actions
129
  const handleCreateWorkspace = async (name, desc) => {
 
139
 
140
  const handleReview = async (proposalId, decision, comments = '') => {
141
  try {
142
+ await api.reviewProposal(currentWorkspace?.id, proposalId, decision, comments);
143
  showToast(`Proposal ${decision.toLowerCase()}!`);
144
  refreshData();
145
  } catch (err) {
 
202
 
203
  const handleLogout = () => {
204
  api.logout();
205
+ authLogout();
206
+ navigate('/login', { replace: true });
207
  };
208
 
209
+ // If user is not authenticated, strictly redirect to /login
210
+ if (!isAuthenticated) {
211
+ return <Navigate to="/login" replace />;
212
+ }
213
+
214
  return (
215
  <div className="min-h-screen bg-[#0D1117] text-slate-100 flex flex-col font-sans selection:bg-emerald-500/20 selection:text-emerald-300">
216
  {/* Platform Navigation */}
217
  <Navbar
218
+ searchQuery={searchQuery}
219
+ onSearchChange={setSearchQuery}
220
  workspaces={workspaces}
221
  currentWorkspace={currentWorkspace}
222
  onSelectWorkspace={(id) => setCurrentWorkspace(workspaces.find((w) => w.id === id))}
 
319
  knowledgeItems={knowledgeItems}
320
  currentWorkspace={currentWorkspace}
321
  loading={loading}
322
+ searchQuery={searchQuery}
323
+ onSearchChange={setSearchQuery}
324
  />
325
  )}
326
 
studio/src/api/client.js CHANGED
@@ -1,4 +1,4 @@
1
- /**
2
  * DiffWeave Studio API Client
3
  * Talks directly to the FastAPI bridge, which delegates to DocWeave MCP Server.
4
  * Supports Bearer token authentication and user sessions.
@@ -160,7 +160,7 @@ export const api = {
160
  const headers = {};
161
  if (token) headers['Authorization'] = `Bearer ${token}`;
162
 
163
- const res = await fetch(`${API_BASE}/workspaces/${workspaceId}/documents/upload`, {
164
  method: 'POST',
165
  headers,
166
  body: formData,
@@ -180,8 +180,8 @@ export const api = {
180
  return res.json();
181
  },
182
 
183
- async reviewProposal(proposalId, decision, comments = '') {
184
- const res = await fetch(`${API_BASE}/proposals/${proposalId}/review`, {
185
  method: 'POST',
186
  headers: getHeaders(),
187
  body: JSON.stringify({ decision, comments }),
 
1
+ /**
2
  * DiffWeave Studio API Client
3
  * Talks directly to the FastAPI bridge, which delegates to DocWeave MCP Server.
4
  * Supports Bearer token authentication and user sessions.
 
160
  const headers = {};
161
  if (token) headers['Authorization'] = `Bearer ${token}`;
162
 
163
+ const res = await fetch(`${API_BASE}/workspaces/${workspaceId}/upload`, {
164
  method: 'POST',
165
  headers,
166
  body: formData,
 
180
  return res.json();
181
  },
182
 
183
+ async reviewProposal(workspaceId, proposalId, decision, comments = '') {
184
+ const res = await fetch(`${API_BASE}/workspaces/${workspaceId}/proposals/${proposalId}/review`, {
185
  method: 'POST',
186
  headers: getHeaders(),
187
  body: JSON.stringify({ decision, comments }),
studio/src/components/AuthLayout.jsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "react-router-dom";
2
+
3
+ // DiffWeave brand mark (replaces DocWeave logo)
4
+ function DiffWeaveMark() {
5
+ return (
6
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
7
+ <rect width="36" height="36" rx="8" fill="url(#dw-grad)" />
8
+ <path d="M10 18 L14 12 L18 18 L22 10 L26 18" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
9
+ <circle cx="26" cy="24" r="3" fill="white" opacity="0.8"/>
10
+ <defs>
11
+ <linearGradient id="dw-grad" x1="0" y1="0" x2="36" y2="36" gradientUnits="userSpaceOnUse">
12
+ <stop stopColor="#10B981"/>
13
+ <stop offset="1" stopColor="#059669"/>
14
+ </linearGradient>
15
+ </defs>
16
+ </svg>
17
+ );
18
+ }
19
+
20
+ export function AuthLayout({ eyebrow, title, subtitle, children, footer }) {
21
+ return (
22
+ <div className="dw-auth">
23
+ <div className="dw-auth__panel">
24
+ <div className="dw-auth__brand">
25
+ <DiffWeaveMark />
26
+ <span className="dw-auth__wordmark">DiffWeave</span>
27
+ <span className="dw-auth__by">by DocWeave</span>
28
+ </div>
29
+
30
+ <div className="dw-auth__card">
31
+ {eyebrow && <span className="dw-auth__eyebrow">{eyebrow}</span>}
32
+ <h1 className="dw-auth__title">{title}</h1>
33
+ {subtitle && <p className="dw-auth__subtitle">{subtitle}</p>}
34
+ <div className="dw-auth__body">{children}</div>
35
+ </div>
36
+
37
+ {footer && <div className="dw-auth__footer">{footer}</div>}
38
+ </div>
39
+
40
+ <div className="dw-auth__aside" aria-hidden="true">
41
+ <div className="dw-auth__aside-grid" />
42
+ <div className="dw-auth__aside-copy">
43
+ <span className="dw-auth__tagline-line">AI proposes.</span>
44
+ <span className="dw-auth__tagline-line">Rules validate.</span>
45
+ <span className="dw-auth__tagline-line">Humans decide.</span>
46
+ <span className="dw-auth__tagline-line dw-auth__tagline-line--accent">Knowledge commits.</span>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
studio/src/components/KnowledgeBase.jsx CHANGED
@@ -11,11 +11,14 @@ import {
11
  Layers
12
  } from 'lucide-react';
13
 
14
- export default function KnowledgeBase({ knowledgeItems, currentWorkspace, loading }) {
15
- const [search, setSearch] = useState('');
16
  const [categoryFilter, setCategoryFilter] = useState('ALL');
17
  const [copiedId, setCopiedId] = useState(null);
18
 
 
 
 
19
  if (loading) {
20
  return (
21
  <div className="flex flex-col items-center justify-center py-24 space-y-4">
@@ -26,14 +29,19 @@ export default function KnowledgeBase({ knowledgeItems, currentWorkspace, loadin
26
  }
27
 
28
  const filtered = knowledgeItems.filter((item) => {
29
- const matchesCat = categoryFilter === 'ALL' || item.type === categoryFilter;
30
- const matchesSearch =
31
- !search ||
32
- item.title.toLowerCase().includes(search.toLowerCase()) ||
33
- String(item.value || '').toLowerCase().includes(search.toLowerCase());
34
- return matchesCat && matchesSearch;
 
 
 
35
  });
36
 
 
 
37
  const handleCopy = (val, id) => {
38
  navigator.clipboard.writeText(val);
39
  setCopiedId(id);
@@ -116,11 +124,11 @@ export default function KnowledgeBase({ knowledgeItems, currentWorkspace, loadin
116
  onChange={(e) => setCategoryFilter(e.target.value)}
117
  className="bg-[#141B2D] border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-slate-300 focus:outline-none"
118
  >
119
- <option value="ALL">All Categories</option>
120
- <option value="CLAIM">Claims</option>
121
- <option value="METRIC">Metrics</option>
122
- <option value="ENTITY">Entities</option>
123
- <option value="DATE">Dates</option>
124
  </select>
125
 
126
  {/* Export Button */}
 
11
  Layers
12
  } from 'lucide-react';
13
 
14
+ export default function KnowledgeBase({ knowledgeItems = [], currentWorkspace, loading, searchQuery = '', onSearchChange }) {
15
+ const [internalSearch, setInternalSearch] = useState('');
16
  const [categoryFilter, setCategoryFilter] = useState('ALL');
17
  const [copiedId, setCopiedId] = useState(null);
18
 
19
+ const search = searchQuery !== undefined && searchQuery !== '' ? searchQuery : internalSearch;
20
+ const setSearch = onSearchChange || setInternalSearch;
21
+
22
  if (loading) {
23
  return (
24
  <div className="flex flex-col items-center justify-center py-24 space-y-4">
 
29
  }
30
 
31
  const filtered = knowledgeItems.filter((item) => {
32
+ const itemType = String(item.type || '').toUpperCase();
33
+ const matchesCat = categoryFilter === 'ALL' || itemType === categoryFilter;
34
+ const s = search.toLowerCase().trim();
35
+ if (!s) return matchesCat;
36
+ const titleMatch = (item.title || '').toLowerCase().includes(s);
37
+ const valueMatch = String(item.value || '').toLowerCase().includes(s);
38
+ const typeMatch = itemType.toLowerCase().includes(s);
39
+ const fileMatch = (item.filename || '').toLowerCase().includes(s);
40
+ return matchesCat && (titleMatch || valueMatch || typeMatch || fileMatch);
41
  });
42
 
43
+ const availableCategories = ['ALL', ...new Set(knowledgeItems.map((i) => String(i.type || '').toUpperCase()).filter(Boolean))];
44
+
45
  const handleCopy = (val, id) => {
46
  navigator.clipboard.writeText(val);
47
  setCopiedId(id);
 
124
  onChange={(e) => setCategoryFilter(e.target.value)}
125
  className="bg-[#141B2D] border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-slate-300 focus:outline-none"
126
  >
127
+ {availableCategories.map((cat) => (
128
+ <option key={cat} value={cat}>
129
+ {cat === 'ALL' ? 'All Categories' : cat}
130
+ </option>
131
+ ))}
132
  </select>
133
 
134
  {/* Export Button */}
studio/src/components/Navbar.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
2
  import {
3
  FileDiff,
4
  GitPullRequest,
@@ -41,6 +41,45 @@ export default function Navbar({
41
  const [newWsName, setNewWsName] = useState('');
42
  const [showUserDropdown, setShowUserDropdown] = useState(false);
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  const handleCreate = async (e) => {
45
  e.preventDefault();
46
  if (!newWsName.trim()) return;
@@ -49,13 +88,17 @@ export default function Navbar({
49
  setShowCreateModal(false);
50
  };
51
 
 
 
 
 
52
  const tabs = [
53
- { id: 'diff', label: 'Semantic Diff', icon: FileDiff, badge: stats?.pending_proposals ? `${stats.pending_proposals}` : '2' },
54
- { id: 'prs', label: 'Knowledge PRs', icon: GitPullRequest, badge: stats?.pending_proposals ? `${stats.pending_proposals}` : '3' },
55
  { id: 'graph', label: 'Topology Graph', icon: Network },
56
- { id: 'staging', label: 'Documents & Staging', icon: Files, badge: stats?.total_documents ? `${stats.total_documents}` : '4' },
57
- { id: 'rules', label: 'Policy CI Rules', icon: ShieldCheck, badge: '3' },
58
- { id: 'knowledge', label: 'Master Truth Register', icon: Database, badge: stats?.total_knowledge_items ? `${stats.total_knowledge_items}` : '18' },
59
  { id: 'audit', label: 'Audit Trail', icon: History },
60
  { id: 'auth', label: 'API Keys & Auth', icon: Key, badge: currentUser ? 'Active' : 'DocWeave' },
61
  ];
@@ -91,9 +134,12 @@ export default function Navbar({
91
  <span className="text-slate-600 hidden sm:inline">/</span>
92
 
93
  {/* Workspace Dropdown */}
94
- <div className="relative">
95
  <button
96
- onClick={() => setShowWsDropdown(!showWsDropdown)}
 
 
 
97
  className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] hover:bg-[#21262D] border border-[#30363D] text-slate-200 transition font-medium"
98
  >
99
  <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
@@ -121,8 +167,13 @@ export default function Navbar({
121
  : 'text-slate-300 hover:bg-[#21262D]'
122
  }`}
123
  >
124
- <span className="truncate">{w.name}</span>
125
- {currentWorkspace?.id === w.id && <CheckCircle2 className="w-3 h-3 text-emerald-400" />}
 
 
 
 
 
126
  </button>
127
  ))}
128
  <div className="pt-1 border-t border-[#30363D]">
@@ -192,9 +243,12 @@ export default function Navbar({
192
 
193
  {/* User Profile / Auth Button */}
194
  {currentUser ? (
195
- <div className="relative">
196
  <button
197
- onClick={() => setShowUserDropdown(!showUserDropdown)}
 
 
 
198
  className="flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-[#161B22] hover:bg-[#21262D] border border-[#30363D] text-slate-200 transition"
199
  >
200
  <div className="w-4 h-4 rounded-full bg-emerald-500 text-black font-bold flex items-center justify-center text-[9px]">
@@ -237,7 +291,7 @@ export default function Navbar({
237
  </div>
238
  ) : (
239
  <button
240
- onClick={onOpenAuth}
241
  className="flex items-center space-x-1.5 px-3 py-1 rounded-md bg-[#238636] hover:bg-[#2EA043] text-white font-semibold text-xs transition shadow"
242
  >
243
  <Lock className="w-3 h-3" />
@@ -269,19 +323,19 @@ export default function Navbar({
269
  <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] border border-[#30363D] text-slate-300">
270
  <Files className="w-3.5 h-3.5 text-sky-400" />
271
  <span>Tracked Docs:</span>
272
- <span className="text-white font-bold font-mono">{stats?.total_documents || 4}</span>
273
  </div>
274
 
275
  <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] border border-[#30363D] text-slate-300">
276
  <Database className="w-3.5 h-3.5 text-emerald-400" />
277
  <span>Verified Truth:</span>
278
- <span className="text-white font-bold font-mono">{stats?.total_knowledge_items || 18} Facts</span>
279
  </div>
280
 
281
  <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] border border-[#30363D] text-slate-300">
282
  <GitPullRequest className="w-3.5 h-3.5 text-amber-400" />
283
  <span>Pending PRs:</span>
284
- <span className="text-amber-400 font-bold font-mono">{stats?.pending_proposals || 3}</span>
285
  </div>
286
  </div>
287
  </div>
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
  import {
3
  FileDiff,
4
  GitPullRequest,
 
41
  const [newWsName, setNewWsName] = useState('');
42
  const [showUserDropdown, setShowUserDropdown] = useState(false);
43
 
44
+ const wsDropdownRef = useRef(null);
45
+ const userDropdownRef = useRef(null);
46
+ const searchInputRef = useRef(null);
47
+
48
+ // Keyboard shortcut '/' to focus global search
49
+ useEffect(() => {
50
+ function handleKeyDown(e) {
51
+ if (e.key === '/' && document.activeElement !== searchInputRef.current && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
52
+ e.preventDefault();
53
+ searchInputRef.current?.focus();
54
+ }
55
+ if (e.key === 'Escape' && document.activeElement === searchInputRef.current) {
56
+ if (onSearchChange) onSearchChange('');
57
+ searchInputRef.current?.blur();
58
+ }
59
+ }
60
+ window.addEventListener('keydown', handleKeyDown);
61
+ return () => window.removeEventListener('keydown', handleKeyDown);
62
+ }, [onSearchChange]);
63
+
64
+ // Close dropdowns when clicking anywhere outside
65
+ useEffect(() => {
66
+ function handleClickOutside(event) {
67
+ if (wsDropdownRef.current && !wsDropdownRef.current.contains(event.target)) {
68
+ setShowWsDropdown(false);
69
+ }
70
+ if (userDropdownRef.current && !userDropdownRef.current.contains(event.target)) {
71
+ setShowUserDropdown(false);
72
+ }
73
+ }
74
+
75
+ document.addEventListener('mousedown', handleClickOutside);
76
+ document.addEventListener('touchstart', handleClickOutside);
77
+ return () => {
78
+ document.removeEventListener('mousedown', handleClickOutside);
79
+ document.removeEventListener('touchstart', handleClickOutside);
80
+ };
81
+ }, []);
82
+
83
  const handleCreate = async (e) => {
84
  e.preventDefault();
85
  if (!newWsName.trim()) return;
 
88
  setShowCreateModal(false);
89
  };
90
 
91
+ const totalDocs = stats?.total_documents ?? 0;
92
+ const verifiedFacts = stats?.knowledge_items ?? stats?.total_knowledge_items ?? 0;
93
+ const pendingProposals = stats?.pending_proposals ?? 0;
94
+
95
  const tabs = [
96
+ { id: 'diff', label: 'Semantic Diff', icon: FileDiff, badge: pendingProposals > 0 ? `${pendingProposals}` : undefined },
97
+ { id: 'prs', label: 'Knowledge PRs', icon: GitPullRequest, badge: pendingProposals > 0 ? `${pendingProposals}` : undefined },
98
  { id: 'graph', label: 'Topology Graph', icon: Network },
99
+ { id: 'staging', label: 'Documents & Staging', icon: Files, badge: totalDocs > 0 ? `${totalDocs}` : undefined },
100
+ { id: 'rules', label: 'Policy CI Rules', icon: ShieldCheck },
101
+ { id: 'knowledge', label: 'Master Truth Register', icon: Database, badge: verifiedFacts > 0 ? `${verifiedFacts}` : undefined },
102
  { id: 'audit', label: 'Audit Trail', icon: History },
103
  { id: 'auth', label: 'API Keys & Auth', icon: Key, badge: currentUser ? 'Active' : 'DocWeave' },
104
  ];
 
134
  <span className="text-slate-600 hidden sm:inline">/</span>
135
 
136
  {/* Workspace Dropdown */}
137
+ <div ref={wsDropdownRef} className="relative">
138
  <button
139
+ onClick={() => {
140
+ setShowWsDropdown((prev) => !prev);
141
+ setShowUserDropdown(false);
142
+ }}
143
  className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] hover:bg-[#21262D] border border-[#30363D] text-slate-200 transition font-medium"
144
  >
145
  <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
 
167
  : 'text-slate-300 hover:bg-[#21262D]'
168
  }`}
169
  >
170
+ <div className="flex flex-col truncate pr-2">
171
+ <span className="truncate font-semibold">{w.name}</span>
172
+ {w.description && (
173
+ <span className="text-[10px] text-slate-400 font-normal truncate">{w.description}</span>
174
+ )}
175
+ </div>
176
+ {currentWorkspace?.id === w.id && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
177
  </button>
178
  ))}
179
  <div className="pt-1 border-t border-[#30363D]">
 
243
 
244
  {/* User Profile / Auth Button */}
245
  {currentUser ? (
246
+ <div ref={userDropdownRef} className="relative">
247
  <button
248
+ onClick={() => {
249
+ setShowUserDropdown((prev) => !prev);
250
+ setShowWsDropdown(false);
251
+ }}
252
  className="flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-[#161B22] hover:bg-[#21262D] border border-[#30363D] text-slate-200 transition"
253
  >
254
  <div className="w-4 h-4 rounded-full bg-emerald-500 text-black font-bold flex items-center justify-center text-[9px]">
 
291
  </div>
292
  ) : (
293
  <button
294
+ onClick={() => { window.location.href = "/login"; }}
295
  className="flex items-center space-x-1.5 px-3 py-1 rounded-md bg-[#238636] hover:bg-[#2EA043] text-white font-semibold text-xs transition shadow"
296
  >
297
  <Lock className="w-3 h-3" />
 
323
  <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] border border-[#30363D] text-slate-300">
324
  <Files className="w-3.5 h-3.5 text-sky-400" />
325
  <span>Tracked Docs:</span>
326
+ <span className="text-white font-bold font-mono">{totalDocs}</span>
327
  </div>
328
 
329
  <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] border border-[#30363D] text-slate-300">
330
  <Database className="w-3.5 h-3.5 text-emerald-400" />
331
  <span>Verified Truth:</span>
332
+ <span className="text-white font-bold font-mono">{verifiedFacts} Facts</span>
333
  </div>
334
 
335
  <div className="flex items-center space-x-1.5 px-2.5 py-1 rounded-md bg-[#161B22] border border-[#30363D] text-slate-300">
336
  <GitPullRequest className="w-3.5 h-3.5 text-amber-400" />
337
  <span>Pending PRs:</span>
338
+ <span className="text-amber-400 font-bold font-mono">{pendingProposals}</span>
339
  </div>
340
  </div>
341
  </div>
studio/src/components/ProtectedRoute.jsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useAuth } from "../context/AuthContext.jsx";
2
+ import { Navigate, useLocation } from "react-router-dom";
3
+
4
+ export default function ProtectedRoute({ children }) {
5
+ const { isAuthenticated, token } = useAuth();
6
+ const location = useLocation();
7
+
8
+ // Check both React state and localStorage to prevent transition flashes
9
+ const hasToken = isAuthenticated || Boolean(token) || Boolean(localStorage.getItem("diffweave_token"));
10
+
11
+ if (!hasToken) {
12
+ return <Navigate to="/login" state={{ from: location }} replace />;
13
+ }
14
+
15
+ return children;
16
+ }
studio/src/context/AuthContext.jsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useState, useCallback, useEffect } from "react";
2
+
3
+ const AuthContext = createContext();
4
+
5
+ const TOKEN_KEY = "diffweave_token";
6
+ const USER_KEY = "diffweave_user";
7
+
8
+ export function AuthProvider({ children }) {
9
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || localStorage.getItem("token"));
10
+ const [user, setUser] = useState(() => {
11
+ try {
12
+ const u = localStorage.getItem(USER_KEY) || localStorage.getItem("user");
13
+ return u ? JSON.parse(u) : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ });
18
+
19
+ // Validate token with DocWeave auth engine on mount
20
+ useEffect(() => {
21
+ if (token && !user) {
22
+ fetch("/api/auth/me", {
23
+ headers: { Authorization: `Bearer ${token}` },
24
+ })
25
+ .then((r) => (r.ok ? r.json() : null))
26
+ .then((data) => {
27
+ if (data && !data.detail) {
28
+ setUser(data);
29
+ localStorage.setItem(USER_KEY, JSON.stringify(data));
30
+ } else {
31
+ // Invalid token - clear
32
+ localStorage.removeItem(TOKEN_KEY);
33
+ localStorage.removeItem(USER_KEY);
34
+ localStorage.removeItem("token");
35
+ setToken(null);
36
+ setUser(null);
37
+ }
38
+ })
39
+ .catch(() => {});
40
+ }
41
+ }, [token]); // eslint-disable-line
42
+
43
+ const login = useCallback((accessToken, userData = null) => {
44
+ localStorage.setItem(TOKEN_KEY, accessToken);
45
+ localStorage.setItem("token", accessToken);
46
+ setToken(accessToken);
47
+ if (userData) {
48
+ localStorage.setItem(USER_KEY, JSON.stringify(userData));
49
+ setUser(userData);
50
+ }
51
+ }, []);
52
+
53
+ const logout = useCallback(() => {
54
+ // Notify server to clear CLI credentials
55
+ fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
56
+ localStorage.removeItem(TOKEN_KEY);
57
+ localStorage.removeItem(USER_KEY);
58
+ localStorage.removeItem("token");
59
+ setToken(null);
60
+ setUser(null);
61
+ }, []);
62
+
63
+ return (
64
+ <AuthContext.Provider
65
+ value={{
66
+ token,
67
+ user,
68
+ setUser,
69
+ isAuthenticated: Boolean(token),
70
+ login,
71
+ logout,
72
+ }}
73
+ >
74
+ {children}
75
+ </AuthContext.Provider>
76
+ );
77
+ }
78
+
79
+ export const useAuth = () => useContext(AuthContext);
studio/src/index.css CHANGED
@@ -1,4 +1,4 @@
1
- @tailwind base;
2
  @tailwind components;
3
  @tailwind utilities;
4
 
@@ -66,3 +66,384 @@ code, pre, .font-mono {
66
  .pulse-glow {
67
  animation: pulse-dot 2s infinite ease-in-out;
68
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
  @tailwind components;
3
  @tailwind utilities;
4
 
 
66
  .pulse-glow {
67
  animation: pulse-dot 2s infinite ease-in-out;
68
  }
69
+
70
+ /* ============================================================
71
+ DiffWeave Auth Pages (mirrors DocWeave AuthLayout.css)
72
+ ============================================================ */
73
+ .dw-auth {
74
+ min-height: 100vh;
75
+ display: grid;
76
+ grid-template-columns: minmax(0, 460px) 1fr;
77
+ background: #0D1117;
78
+ font-family: 'Inter', system-ui, sans-serif;
79
+ color: #E2E8F0;
80
+ }
81
+
82
+ .dw-auth__panel {
83
+ display: flex;
84
+ flex-direction: column;
85
+ justify-content: center;
86
+ padding: 60px 48px;
87
+ border-right: 1px solid #21262D;
88
+ }
89
+
90
+ .dw-auth__brand {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 10px;
94
+ margin-bottom: 48px;
95
+ }
96
+
97
+ .dw-auth__wordmark {
98
+ font-size: 20px;
99
+ font-weight: 700;
100
+ letter-spacing: -0.02em;
101
+ color: #F8FAFC;
102
+ }
103
+
104
+ .dw-auth__by {
105
+ font-size: 11px;
106
+ color: #4B5563;
107
+ margin-left: 2px;
108
+ margin-top: 2px;
109
+ }
110
+
111
+ .dw-auth__card {
112
+ animation: dw-fade-in 0.25s ease;
113
+ }
114
+
115
+ @keyframes dw-fade-in {
116
+ from { opacity: 0; transform: translateY(8px); }
117
+ to { opacity: 1; transform: translateY(0); }
118
+ }
119
+
120
+ .dw-auth__eyebrow {
121
+ display: inline-block;
122
+ font-size: 11px;
123
+ font-weight: 600;
124
+ letter-spacing: 0.06em;
125
+ text-transform: uppercase;
126
+ color: #10B981;
127
+ margin-bottom: 12px;
128
+ }
129
+
130
+ .dw-auth__title {
131
+ font-size: 28px;
132
+ font-weight: 700;
133
+ color: #F8FAFC;
134
+ margin: 0 0 8px;
135
+ letter-spacing: -0.02em;
136
+ }
137
+
138
+ .dw-auth__subtitle {
139
+ color: #6B7280;
140
+ font-size: 14px;
141
+ margin: 0 0 32px;
142
+ line-height: 1.6;
143
+ }
144
+
145
+ .dw-auth__body {
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 16px;
149
+ max-width: 360px;
150
+ }
151
+
152
+ .dw-auth__footer {
153
+ margin-top: 32px;
154
+ font-size: 13px;
155
+ color: #6B7280;
156
+ }
157
+
158
+ .dw-auth__footer a {
159
+ color: #10B981;
160
+ text-decoration: none;
161
+ }
162
+
163
+ .dw-auth__footer a:hover {
164
+ text-decoration: underline;
165
+ }
166
+
167
+ /* ── Right panel ─────────────────────────────────────────── */
168
+ .dw-auth__aside {
169
+ position: relative;
170
+ background: linear-gradient(145deg, #0d1117 0%, #0a1628 50%, #0d1117 100%);
171
+ overflow: hidden;
172
+ display: flex;
173
+ align-items: center;
174
+ justify-content: center;
175
+ padding: 60px;
176
+ }
177
+
178
+ .dw-auth__aside-grid {
179
+ position: absolute;
180
+ inset: 0;
181
+ background-image:
182
+ linear-gradient(rgba(16,185,129,0.12) 1px, transparent 1px),
183
+ linear-gradient(90deg, rgba(16,185,129,0.12) 1px, transparent 1px);
184
+ background-size: 48px 48px;
185
+ mask-image: radial-gradient(ellipse at 50% 40%, black 20%, transparent 70%);
186
+ }
187
+
188
+ .dw-auth__aside::before {
189
+ content: "";
190
+ position: absolute;
191
+ inset: 0;
192
+ background:
193
+ radial-gradient(circle at 25% 25%, rgba(16,185,129,0.2) 0%, transparent 40%),
194
+ radial-gradient(circle at 75% 65%, rgba(110, 86, 255, 0.15) 0%, transparent 35%),
195
+ radial-gradient(circle at 50% 90%, rgba(16,185,129,0.1) 0%, transparent 30%);
196
+ z-index: 0;
197
+ }
198
+
199
+ .dw-auth__aside-copy {
200
+ position: relative;
201
+ z-index: 1;
202
+ display: flex;
203
+ flex-direction: column;
204
+ align-items: flex-start;
205
+ gap: 12px;
206
+ }
207
+
208
+ .dw-auth__tagline-line {
209
+ font-size: 2rem;
210
+ font-weight: 700;
211
+ color: #F8FAFC;
212
+ letter-spacing: -0.02em;
213
+ line-height: 1.2;
214
+ opacity: 0.8;
215
+ }
216
+
217
+ .dw-auth__tagline-line--accent {
218
+ color: #10B981;
219
+ opacity: 1;
220
+ }
221
+
222
+ @media (max-width: 900px) {
223
+ .dw-auth { grid-template-columns: 1fr; }
224
+ .dw-auth__aside { display: none; }
225
+ .dw-auth__panel { padding: 40px 24px; }
226
+ }
227
+
228
+ /* ── Form shared styles ─────────────────────────────────── */
229
+ .dw-auth-form {
230
+ display: flex;
231
+ flex-direction: column;
232
+ gap: 16px;
233
+ max-width: 360px;
234
+ }
235
+
236
+ .dw-field {
237
+ display: flex;
238
+ flex-direction: column;
239
+ gap: 5px;
240
+ }
241
+
242
+ .dw-label {
243
+ font-size: 13px;
244
+ font-weight: 500;
245
+ color: #CBD5E1;
246
+ }
247
+
248
+ .dw-input {
249
+ width: 100%;
250
+ padding: 9px 13px;
251
+ background: #161B22;
252
+ border: 1px solid #30363D;
253
+ border-radius: 8px;
254
+ color: #E2E8F0;
255
+ font-size: 14px;
256
+ outline: none;
257
+ transition: border-color 0.15s, box-shadow 0.15s;
258
+ box-sizing: border-box;
259
+ }
260
+
261
+ .dw-input:focus {
262
+ border-color: #10B981;
263
+ box-shadow: 0 0 0 3px rgba(16,185,129,0.15);
264
+ }
265
+
266
+ .dw-input--otp {
267
+ font-size: 22px;
268
+ text-align: center;
269
+ letter-spacing: 0.3em;
270
+ font-family: monospace;
271
+ }
272
+
273
+ .dw-field__error {
274
+ font-size: 11px;
275
+ color: #F87171;
276
+ }
277
+
278
+ .dw-auth-form__show-password {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 6px;
282
+ font-size: 12px;
283
+ color: #6B7280;
284
+ cursor: pointer;
285
+ user-select: none;
286
+ margin-top: 4px;
287
+ }
288
+
289
+ .dw-auth-form__strength {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 8px;
293
+ margin-top: 4px;
294
+ }
295
+
296
+ .dw-auth-form__strength-bar {
297
+ flex: 1;
298
+ height: 4px;
299
+ border-radius: 2px;
300
+ background: #21262D;
301
+ overflow: hidden;
302
+ }
303
+
304
+ .dw-auth-form__strength-fill {
305
+ height: 100%;
306
+ border-radius: 2px;
307
+ transition: width 0.25s, background 0.25s;
308
+ }
309
+
310
+ .dw-auth-form__options {
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: flex-end;
314
+ }
315
+
316
+ .dw-auth-form__link {
317
+ font-size: 13px;
318
+ color: #10B981;
319
+ text-decoration: none;
320
+ }
321
+
322
+ .dw-auth-form__link:hover {
323
+ text-decoration: underline;
324
+ }
325
+
326
+ .dw-auth-form__error {
327
+ font-size: 13px;
328
+ color: #F87171;
329
+ padding: 10px 12px;
330
+ background: rgba(239,68,68,0.1);
331
+ border: 1px solid rgba(239,68,68,0.2);
332
+ border-radius: 8px;
333
+ }
334
+
335
+ .dw-auth-form__success {
336
+ font-size: 13px;
337
+ color: #6EE7B7;
338
+ padding: 10px 12px;
339
+ background: rgba(16,185,129,0.1);
340
+ border: 1px solid rgba(16,185,129,0.2);
341
+ border-radius: 8px;
342
+ }
343
+
344
+ .dw-auth-form__resend {
345
+ background: none;
346
+ border: none;
347
+ color: #10B981;
348
+ font-size: 13px;
349
+ cursor: pointer;
350
+ text-align: center;
351
+ padding: 4px 0;
352
+ }
353
+
354
+ .dw-auth-form__resend:hover { text-decoration: underline; }
355
+
356
+ .dw-auth-form__footer-text {
357
+ font-size: 13px;
358
+ color: #6B7280;
359
+ text-align: center;
360
+ }
361
+
362
+ .dw-auth-form__footer-text a {
363
+ color: #10B981;
364
+ font-weight: 500;
365
+ text-decoration: none;
366
+ }
367
+
368
+ .dw-auth-form__footer-text a:hover { text-decoration: underline; }
369
+
370
+ .dw-auth-divider {
371
+ display: flex;
372
+ align-items: center;
373
+ margin: 4px 0;
374
+ }
375
+
376
+ .dw-auth-divider::before,
377
+ .dw-auth-divider::after {
378
+ content: "";
379
+ flex: 1;
380
+ border-bottom: 1px solid #21262D;
381
+ }
382
+
383
+ .dw-auth-divider span {
384
+ padding: 0 10px;
385
+ font-size: 11px;
386
+ color: #4B5563;
387
+ text-transform: uppercase;
388
+ letter-spacing: 0.06em;
389
+ font-weight: 600;
390
+ }
391
+
392
+ .dw-btn {
393
+ width: 100%;
394
+ padding: 10px 16px;
395
+ border-radius: 8px;
396
+ font-size: 14px;
397
+ font-weight: 600;
398
+ cursor: pointer;
399
+ transition: all 0.2s;
400
+ border: none;
401
+ outline: none;
402
+ }
403
+
404
+ .dw-btn--primary {
405
+ background: #10B981;
406
+ color: white;
407
+ }
408
+
409
+ .dw-btn--primary:hover:not(:disabled) {
410
+ background: #059669;
411
+ box-shadow: 0 4px 16px rgba(16,185,129,0.3);
412
+ transform: translateY(-1px);
413
+ }
414
+
415
+ .dw-btn--primary:disabled {
416
+ opacity: 0.6;
417
+ cursor: not-allowed;
418
+ }
419
+
420
+ .dw-auth-demo-btn {
421
+ width: 100%;
422
+ display: flex;
423
+ align-items: center;
424
+ justify-content: center;
425
+ gap: 8px;
426
+ background: rgba(16,185,129,0.07);
427
+ border: 1px solid rgba(16,185,129,0.25);
428
+ color: #10B981;
429
+ font-weight: 600;
430
+ font-size: 13px;
431
+ padding: 10px 16px;
432
+ border-radius: 8px;
433
+ cursor: pointer;
434
+ transition: all 0.2s;
435
+ font-family: inherit;
436
+ }
437
+
438
+ .dw-auth-demo-btn:hover:not(:disabled) {
439
+ background: rgba(16,185,129,0.15);
440
+ border-color: rgba(16,185,129,0.5);
441
+ color: #fff;
442
+ box-shadow: 0 0 16px rgba(16,185,129,0.2);
443
+ transform: translateY(-1px);
444
+ }
445
+
446
+ .dw-auth-demo-btn:disabled {
447
+ opacity: 0.5;
448
+ cursor: not-allowed;
449
+ }
studio/src/main.jsx CHANGED
@@ -1,10 +1,37 @@
1
- import React from 'react'
2
  import ReactDOM from 'react-dom/client'
 
 
 
3
  import App from './App.jsx'
 
 
 
 
4
  import './index.css'
5
 
6
  ReactDOM.createRoot(document.getElementById('root')).render(
7
  <React.StrictMode>
8
- <App />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  </React.StrictMode>,
10
- )
 
1
+ import React from 'react'
2
  import ReactDOM from 'react-dom/client'
3
+ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
4
+ import { AuthProvider } from './context/AuthContext.jsx'
5
+ import ProtectedRoute from './components/ProtectedRoute.jsx'
6
  import App from './App.jsx'
7
+ import Login from './pages/auth/Login.jsx'
8
+ import Signup from './pages/auth/Signup.jsx'
9
+ import ForgotPassword from './pages/auth/ForgotPassword.jsx'
10
+ import ResetPassword from './pages/auth/ResetPassword.jsx'
11
  import './index.css'
12
 
13
  ReactDOM.createRoot(document.getElementById('root')).render(
14
  <React.StrictMode>
15
+ <AuthProvider>
16
+ <BrowserRouter>
17
+ <Routes>
18
+ {/* Public auth routes */}
19
+ <Route path="/login" element={<Login />} />
20
+ <Route path="/signup" element={<Signup />} />
21
+ <Route path="/forgot-password" element={<ForgotPassword />} />
22
+ <Route path="/reset-password" element={<ResetPassword />} />
23
+
24
+ {/* Protected workspace */}
25
+ <Route
26
+ path="/*"
27
+ element={
28
+ <ProtectedRoute>
29
+ <App />
30
+ </ProtectedRoute>
31
+ }
32
+ />
33
+ </Routes>
34
+ </BrowserRouter>
35
+ </AuthProvider>
36
  </React.StrictMode>,
37
+ )
studio/src/pages/auth/ForgotPassword.jsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { AuthLayout } from "../../components/AuthLayout.jsx";
4
+
5
+ export default function ForgotPassword() {
6
+ const [email, setEmail] = useState("");
7
+ const [error, setError] = useState(null);
8
+ const [loading, setLoading] = useState(false);
9
+ const [sent, setSent] = useState(false);
10
+
11
+ const handleSubmit = async (e) => {
12
+ e.preventDefault();
13
+ setError(null);
14
+ setLoading(true);
15
+ try {
16
+ const res = await fetch("/api/auth/forgot-password", {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ email }),
20
+ });
21
+ const data = await res.json();
22
+ if (!res.ok) { setError(data.detail || "Could not send reset email."); return; }
23
+ setSent(true);
24
+ } catch {
25
+ setError("Couldn't reach the server. Please try again.");
26
+ } finally {
27
+ setLoading(false);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <AuthLayout
33
+ eyebrow="Account recovery"
34
+ title="Forgot your password?"
35
+ subtitle="Enter your email and we'll send you a secure reset link."
36
+ footer={<span>Remembered it? <Link to="/login">Back to login</Link></span>}
37
+ >
38
+ {sent ? (
39
+ <div className="dw-auth-form">
40
+ <div className="dw-auth-form__success">
41
+ We sent a password reset link to <strong>{email}</strong>.<br />
42
+ Check your inbox and spam folder.
43
+ </div>
44
+ </div>
45
+ ) : (
46
+ <form onSubmit={handleSubmit} className="dw-auth-form">
47
+ <div className="dw-field">
48
+ <label className="dw-label">Email</label>
49
+ <input className="dw-input" type="email" autoComplete="email" placeholder="you@company.com"
50
+ value={email} onChange={(e) => setEmail(e.target.value)} required />
51
+ </div>
52
+ {error && <div className="dw-auth-form__error">{error}</div>}
53
+ <button type="submit" className="dw-btn dw-btn--primary" disabled={loading}>
54
+ {loading ? "Sending…" : "Send reset link"}
55
+ </button>
56
+ </form>
57
+ )}
58
+ </AuthLayout>
59
+ );
60
+ }
studio/src/pages/auth/Login.jsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Link, useLocation, useNavigate } from "react-router-dom";
3
+ import { AuthLayout } from "../../components/AuthLayout.jsx";
4
+ import { useAuth } from "../../context/AuthContext.jsx";
5
+
6
+ const isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
7
+
8
+ export default function Login() {
9
+ const { login } = useAuth();
10
+ const navigate = useNavigate();
11
+ const location = useLocation();
12
+
13
+ const [email, setEmail] = useState("");
14
+ const [password, setPassword] = useState("");
15
+ const [showPassword, setShowPassword] = useState(false);
16
+ const [error, setError] = useState(null);
17
+ const [success, setSuccess] = useState(location.state?.message || null);
18
+ const [loading, setLoading] = useState(false);
19
+ const [demoLoading, setDemoLoading] = useState(false);
20
+
21
+ const redirectTo = location.state?.from?.pathname || "/";
22
+
23
+ const handleSubmit = async (e) => {
24
+ e.preventDefault();
25
+ setError(null);
26
+ if (!isValidEmail(email)) { setError("Please enter a valid email address."); return; }
27
+ if (password.length < 8) { setError("Password must be at least 8 characters."); return; }
28
+
29
+ setLoading(true);
30
+ try {
31
+ const formData = new URLSearchParams();
32
+ formData.append("username", email);
33
+ formData.append("password", password);
34
+ const res = await fetch("/api/auth/login", {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
37
+ body: formData.toString(),
38
+ });
39
+ const data = await res.json();
40
+ if (!res.ok || !data.access_token) {
41
+ setError(data.detail || "Invalid email or password.");
42
+ return;
43
+ }
44
+ const user = data.user || { email, username: email.split("@")[0] };
45
+ localStorage.setItem("diffweave_token", data.access_token);
46
+ localStorage.setItem("diffweave_user", JSON.stringify(user));
47
+ login(data.access_token, user);
48
+ navigate("/", { replace: true });
49
+ } catch {
50
+ setError("Couldn't reach the server. Please try again.");
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ };
55
+
56
+ const handleDemoLogin = async () => {
57
+ setError(null);
58
+ setDemoLoading(true);
59
+ try {
60
+ const res = await fetch("/api/auth/demo-login", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
61
+ const data = await res.json();
62
+ if (!res.ok || !data.access_token) {
63
+ setError(data.detail || "Could not start demo session.");
64
+ return;
65
+ }
66
+ const user = data.user || { email: "evaluator@docweave.io", username: "DocWeave Evaluator", role: "evaluator" };
67
+ localStorage.setItem("diffweave_token", data.access_token);
68
+ localStorage.setItem("diffweave_user", JSON.stringify(user));
69
+ login(data.access_token, user);
70
+ navigate("/", { replace: true });
71
+ } catch {
72
+ setError("Couldn't reach the server for demo login.");
73
+ } finally {
74
+ setDemoLoading(false);
75
+ }
76
+ };
77
+
78
+ return (
79
+ <AuthLayout
80
+ eyebrow="Document intelligence"
81
+ title="Welcome back"
82
+ subtitle="Sign in with your DocWeave account to continue."
83
+ >
84
+ <form onSubmit={handleSubmit} className="dw-auth-form">
85
+ {success && <div className="dw-auth-form__success">{success}</div>}
86
+
87
+ <div className="dw-field">
88
+ <label className="dw-label">Email</label>
89
+ <input className="dw-input" type="email" autoComplete="email" placeholder="you@company.com"
90
+ value={email} onChange={(e) => setEmail(e.target.value)} required />
91
+ {email && !isValidEmail(email) && <span className="dw-field__error">Enter a valid email</span>}
92
+ </div>
93
+
94
+ <div className="dw-field">
95
+ <label className="dw-label">Password</label>
96
+ <input className="dw-input" type={showPassword ? "text" : "password"} autoComplete="current-password"
97
+ placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required />
98
+ <label className="dw-auth-form__show-password">
99
+ <input type="checkbox" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} />
100
+ Show password
101
+ </label>
102
+ </div>
103
+
104
+ <div className="dw-auth-form__options">
105
+ <span />
106
+ <Link to="/forgot-password" className="dw-auth-form__link">Forgot password?</Link>
107
+ </div>
108
+
109
+ {error && <div className="dw-auth-form__error">{error}</div>}
110
+
111
+ <button type="submit" className="dw-btn dw-btn--primary" disabled={loading}>
112
+ {loading ? "Signing in…" : "Sign in"}
113
+ </button>
114
+
115
+ <div className="dw-auth-form__footer-text">
116
+ Don't have an account? <Link to="/signup">Sign up</Link>
117
+ </div>
118
+
119
+ <div className="dw-auth-divider"><span>or explore directly</span></div>
120
+
121
+ <button type="button" className="dw-auth-demo-btn" onClick={handleDemoLogin} disabled={demoLoading || loading}>
122
+ {demoLoading ? "Starting demo workspace…" : "⚡ Quick Demo Access (No account needed)"}
123
+ </button>
124
+ </form>
125
+ </AuthLayout>
126
+ );
127
+ }
studio/src/pages/auth/ResetPassword.jsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Link, useSearchParams, useNavigate } from "react-router-dom";
3
+ import { AuthLayout } from "../../components/AuthLayout.jsx";
4
+
5
+ export default function ResetPassword() {
6
+ const [searchParams] = useSearchParams();
7
+ const navigate = useNavigate();
8
+ const token = searchParams.get("token") || "";
9
+ const [newPassword, setNewPassword] = useState("");
10
+ const [confirmPassword, setConfirmPassword] = useState("");
11
+ const [error, setError] = useState(null);
12
+ const [loading, setLoading] = useState(false);
13
+
14
+ const handleSubmit = async (e) => {
15
+ e.preventDefault();
16
+ setError(null);
17
+ if (!token) { setError("This reset link is missing or invalid."); return; }
18
+ if (newPassword !== confirmPassword) { setError("Passwords don't match."); return; }
19
+ if (newPassword.length < 8) { setError("Password must be at least 8 characters."); return; }
20
+
21
+ setLoading(true);
22
+ try {
23
+ const res = await fetch("/api/auth/reset-password", {
24
+ method: "POST",
25
+ headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify({ token, new_password: newPassword }),
27
+ });
28
+ const data = await res.json();
29
+ if (!res.ok) { setError(data.detail || "Could not reset password."); return; }
30
+ navigate("/login", { replace: true, state: { message: "Password reset successfully. You can now sign in." } });
31
+ } catch {
32
+ setError("Couldn't reach the server. Please try again.");
33
+ } finally {
34
+ setLoading(false);
35
+ }
36
+ };
37
+
38
+ return (
39
+ <AuthLayout
40
+ eyebrow="Account recovery"
41
+ title="Reset your password"
42
+ subtitle="Choose a new password for your DocWeave account."
43
+ footer={<span>Remembered it? <Link to="/login">Back to login</Link></span>}
44
+ >
45
+ <form onSubmit={handleSubmit} className="dw-auth-form">
46
+ <div className="dw-field">
47
+ <label className="dw-label">New password</label>
48
+ <input className="dw-input" type="password" autoComplete="new-password" placeholder="••••••••"
49
+ value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
50
+ </div>
51
+ <div className="dw-field">
52
+ <label className="dw-label">Confirm new password</label>
53
+ <input className="dw-input" type="password" autoComplete="new-password" placeholder="••••••••"
54
+ value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required />
55
+ </div>
56
+ {error && <div className="dw-auth-form__error">{error}</div>}
57
+ <button type="submit" className="dw-btn dw-btn--primary" disabled={loading}>
58
+ {loading ? "Resetting…" : "Reset password"}
59
+ </button>
60
+ </form>
61
+ </AuthLayout>
62
+ );
63
+ }
studio/src/pages/auth/Signup.jsx ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Link, useNavigate } from "react-router-dom";
3
+ import { AuthLayout } from "../../components/AuthLayout.jsx";
4
+ import { useAuth } from "../../context/AuthContext.jsx";
5
+
6
+ const isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
7
+
8
+ function getStrength(pw) {
9
+ let score = 0;
10
+ if (pw.length >= 8) score++;
11
+ if (/[A-Z]/.test(pw)) score++;
12
+ if (/[0-9]/.test(pw)) score++;
13
+ if (/[^A-Za-z0-9]/.test(pw)) score++;
14
+ if (score <= 1) return { label: "Weak", color: "#ef4444", level: 1 };
15
+ if (score === 2) return { label: "Fair", color: "#f59e0b", level: 2 };
16
+ return { label: "Strong", color: "#10b981", level: 3 };
17
+ }
18
+
19
+ export default function Signup() {
20
+ const { login } = useAuth();
21
+ const navigate = useNavigate();
22
+ const [step, setStep] = useState("form"); // "form" | "otp"
23
+ const [username, setUsername] = useState("");
24
+ const [email, setEmail] = useState("");
25
+ const [password, setPassword] = useState("");
26
+ const [confirmPassword, setConfirmPassword] = useState("");
27
+ const [showPassword, setShowPassword] = useState(false);
28
+ const [otpCode, setOtpCode] = useState("");
29
+ const [error, setError] = useState(null);
30
+ const [loading, setLoading] = useState(false);
31
+ const [demoLoading, setDemoLoading] = useState(false);
32
+
33
+ const strength = getStrength(password);
34
+
35
+ const handleSendOtp = async (e) => {
36
+ e.preventDefault();
37
+ setError(null);
38
+ if (!username.trim()) { setError("Name is required."); return; }
39
+ if (!isValidEmail(email)) { setError("Please enter a valid email address."); return; }
40
+ if (password.length < 8) { setError("Password must be at least 8 characters."); return; }
41
+ if (password !== confirmPassword) { setError("Passwords don't match."); return; }
42
+
43
+ setLoading(true);
44
+ try {
45
+ const res = await fetch("/api/auth/send-otp", {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ username, email, password }),
49
+ });
50
+ const data = await res.json();
51
+ if (!res.ok) { setError(data.detail || "Could not send OTP. Try again."); return; }
52
+ setStep("otp");
53
+ } catch {
54
+ setError("Couldn't reach the server. Please try again.");
55
+ } finally {
56
+ setLoading(false);
57
+ }
58
+ };
59
+
60
+ const handleVerifyOtp = async (e) => {
61
+ e.preventDefault();
62
+ setError(null);
63
+ if (otpCode.length !== 6) { setError("Enter the 6-digit code sent to your email."); return; }
64
+
65
+ setLoading(true);
66
+ try {
67
+ const res = await fetch("/api/auth/verify-otp", {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ email, code: otpCode }),
71
+ });
72
+ const data = await res.json();
73
+ if (!res.ok) { setError(data.detail || "Could not verify code. Try again."); return; }
74
+ navigate("/login", { replace: true, state: { message: "Account created! Please sign in." } });
75
+ } catch {
76
+ setError("Couldn't verify. Please try again.");
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ };
81
+
82
+ const handleDemoLogin = async () => {
83
+ setError(null);
84
+ setDemoLoading(true);
85
+ try {
86
+ const res = await fetch("/api/auth/demo-login", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
87
+ const data = await res.json();
88
+ if (!res.ok || !data.access_token) { setError(data.detail || "Could not start demo session."); return; }
89
+ login(data.access_token, { email: data.email, username: data.username });
90
+ navigate("/", { replace: true });
91
+ } catch {
92
+ setError("Couldn't reach the server.");
93
+ } finally {
94
+ setDemoLoading(false);
95
+ }
96
+ };
97
+
98
+ if (step === "otp") {
99
+ return (
100
+ <AuthLayout
101
+ eyebrow="Verify your email"
102
+ title="Enter verification code"
103
+ subtitle={`We sent a 6-digit code to ${email}. Check your inbox (and spam folder).`}
104
+ footer={
105
+ <button type="button" className="dw-auth-form__link" style={{ background: "none", border: "none", cursor: "pointer" }}
106
+ onClick={() => setStep("form")}>
107
+ ← Back to signup
108
+ </button>
109
+ }
110
+ >
111
+ <form onSubmit={handleVerifyOtp} className="dw-auth-form">
112
+ <div className="dw-field">
113
+ <label className="dw-label">Verification code</label>
114
+ <input className="dw-input dw-input--otp" type="text" inputMode="numeric" placeholder="000000"
115
+ maxLength={6} value={otpCode} onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ""))}
116
+ autoFocus required />
117
+ </div>
118
+ {error && <div className="dw-auth-form__error">{error}</div>}
119
+ <button type="submit" className="dw-btn dw-btn--primary" disabled={loading}>
120
+ {loading ? "Verifying…" : "Verify & create account"}
121
+ </button>
122
+ <button type="button" className="dw-auth-form__resend" onClick={handleSendOtp}>
123
+ Resend code
124
+ </button>
125
+ </form>
126
+ </AuthLayout>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <AuthLayout
132
+ eyebrow="Document intelligence"
133
+ title="Create your account"
134
+ subtitle="Start turning documents into structured, connected knowledge."
135
+ >
136
+ <form onSubmit={handleSendOtp} className="dw-auth-form">
137
+ <div className="dw-field">
138
+ <label className="dw-label">Name</label>
139
+ <input className="dw-input" type="text" autoComplete="name" placeholder="Ada Lovelace"
140
+ value={username} onChange={(e) => setUsername(e.target.value)} required />
141
+ </div>
142
+ <div className="dw-field">
143
+ <label className="dw-label">Email</label>
144
+ <input className="dw-input" type="email" autoComplete="email" placeholder="you@company.com"
145
+ value={email} onChange={(e) => setEmail(e.target.value)} required />
146
+ {email && !isValidEmail(email) && <span className="dw-field__error">Enter a valid email</span>}
147
+ </div>
148
+ <div className="dw-field">
149
+ <label className="dw-label">Password</label>
150
+ <input className="dw-input" type={showPassword ? "text" : "password"} autoComplete="new-password"
151
+ placeholder="At least 8 characters" value={password}
152
+ onChange={(e) => setPassword(e.target.value)} required />
153
+ {password.length >= 8 && (
154
+ <div className="dw-auth-form__strength">
155
+ <div className="dw-auth-form__strength-bar">
156
+ <div className="dw-auth-form__strength-fill" style={{ width: `${(strength.level / 3) * 100}%`, background: strength.color }} />
157
+ </div>
158
+ <span style={{ color: strength.color, fontSize: "11px" }}>{strength.label}</span>
159
+ </div>
160
+ )}
161
+ <label className="dw-auth-form__show-password">
162
+ <input type="checkbox" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} />
163
+ Show password
164
+ </label>
165
+ </div>
166
+ <div className="dw-field">
167
+ <label className="dw-label">Confirm password</label>
168
+ <input className="dw-input" type={showPassword ? "text" : "password"} autoComplete="new-password"
169
+ placeholder="••••••••" value={confirmPassword}
170
+ onChange={(e) => setConfirmPassword(e.target.value)} required />
171
+ {confirmPassword && confirmPassword !== password && <span className="dw-field__error">Passwords don't match</span>}
172
+ </div>
173
+ {error && <div className="dw-auth-form__error">{error}</div>}
174
+ <button type="submit" className="dw-btn dw-btn--primary" disabled={loading}>
175
+ {loading ? "Sending code…" : "Continue"}
176
+ </button>
177
+ <div className="dw-auth-form__footer-text">
178
+ Already have an account? <Link to="/login">Sign in</Link>
179
+ </div>
180
+ <div className="dw-auth-divider"><span>or explore directly</span></div>
181
+ <button type="button" className="dw-auth-demo-btn" onClick={handleDemoLogin} disabled={demoLoading || loading}>
182
+ {demoLoading ? "Starting demo workspace…" : "⚡ Quick Demo Access (No account needed)"}
183
+ </button>
184
+ </form>
185
+ </AuthLayout>
186
+ );
187
+ }