diff --git a/.do/app.yaml b/.do/app.yaml index 16a8bf4..3aaa310 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -7,6 +7,12 @@ alerts: ingress: rules: + # NextAuth OAuth routes → web (must be before /api catch-all) + - component: + name: web + match: + path: + prefix: /api/auth # API endpoints → backend - component: name: api @@ -130,3 +136,18 @@ services: scope: RUN_TIME type: SECRET value: REPLACE_WITH_API_KEY + - key: GOOGLE_CLIENT_ID + scope: RUN_TIME + type: SECRET + value: REPLACE_WITH_GOOGLE_CLIENT_ID + - key: GOOGLE_CLIENT_SECRET + scope: RUN_TIME + type: SECRET + value: REPLACE_WITH_GOOGLE_CLIENT_SECRET + - key: AUTH_SECRET + scope: RUN_TIME + type: SECRET + value: REPLACE_WITH_AUTH_SECRET + - key: AUTH_URL + scope: RUN_TIME + value: ${APP_URL} diff --git a/agent/db/connection.py b/agent/db/connection.py index aab3662..d04afe1 100644 --- a/agent/db/connection.py +++ b/agent/db/connection.py @@ -5,6 +5,7 @@ _pool: asyncpg.Pool | None = None _pool_lock: asyncio.Lock | None = None +_users_table_ensured: bool = False _DEFAULT_POOL_MIN_SIZE = 2 _DEFAULT_POOL_MAX_SIZE = 10 _DEFAULT_POOL_RETRIES = 5 @@ -31,8 +32,28 @@ def _float_env(name: str, default: float, minimum: float = 0.0) -> float: return default +async def ensure_users_table(pool: asyncpg.Pool) -> None: + """Create the users table if it does not already exist (idempotent).""" + async with pool.acquire() as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + email TEXT UNIQUE NOT NULL, + name TEXT, + image TEXT, + approved BOOLEAN NOT NULL DEFAULT FALSE, + domain TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + """ + ) + + async def get_pool() -> asyncpg.Pool: - global _pool, _pool_lock + global _pool, _pool_lock, _users_table_ensured if _pool_lock is None: _pool_lock = asyncio.Lock() if _pool is not None: @@ -65,6 +86,16 @@ async def get_pool() -> asyncpg.Pool: await asyncio.sleep(retry_delay * (attempt + 1)) if _pool is None and last_error is not None: raise last_error + if _pool is not None and not _users_table_ensured: + try: + await ensure_users_table(_pool) + _users_table_ensured = True + except Exception: + import logging + + logging.getLogger(__name__).warning( + "Could not create users table — DB may not be ready yet" + ) return _pool diff --git a/agent/nodes/deployer.py b/agent/nodes/deployer.py index 0eec788..db22ce5 100644 --- a/agent/nodes/deployer.py +++ b/agent/nodes/deployer.py @@ -77,7 +77,7 @@ async def deployer(state: VibeDeployState, config=None) -> dict: build_validation = state.get("build_validation") or {} deploy_gate_result = state.get("deploy_gate_result") or {} - app_name = _build_repo_name(idea) + app_name = str(blueprint.get("app_name") or "").strip() or _build_repo_name(idea) description = idea.get("tagline") or idea.get("problem") or "Generated by vibeDeploy" deploy_blocker = _get_deploy_blocker( frontend_code, @@ -414,12 +414,110 @@ async def _track_do_phase(phase_name: str) -> None: await _emit_step_start("verified", "Verifying live URL", config=config, live_url=live_url) url_verification = _verify_live_url(live_url) - if url_verification.get("root_ok"): - logger.info("[DEPLOYER] Live URL verified: %s → %s", live_url, url_verification) - await _emit_step_complete("verified", "Live URL verified", config=config, live_url=live_url) + v_score = url_verification.get("verification_score", 0) + v_verdict = url_verification.get("verification_verdict", "failed") + + if v_verdict == "verified": + logger.info( + "[DEPLOYER] Live URL verified (score=%d, verdict=%s): %s", + v_score, v_verdict, live_url, + ) + await _emit_step_complete( + "verified", + f"Live URL verified (score={v_score}/100)", + config=config, + live_url=live_url, + verification_score=v_score, + verification_verdict=v_verdict, + ) + elif v_verdict == "partial": + logger.warning( + "[DEPLOYER] Live URL partially verified (score=%d, verdict=%s): %s", + v_score, v_verdict, live_url, + ) + await _emit_step_complete( + "verified", + f"Live URL partially verified (score={v_score}/100) — basic functionality works, some issues detected", + config=config, + live_url=live_url, + verification_score=v_score, + verification_verdict=v_verdict, + ) else: - logger.warning("[DEPLOYER] Live URL verification failed: %s → %s", live_url, url_verification) - await _emit_step_error("verified", "Live URL verification failed", config=config, live_url=live_url) + # verdict == "failed" (score < 50) — attempt repair + logger.warning( + "[DEPLOYER] Live URL verification FAILED (score=%d, verdict=%s): %s — attempting repair", + v_score, v_verdict, live_url, + ) + await _emit_step_error( + "verified", + f"Live URL verification failed (score={v_score}/100) — attempting repair", + config=config, + live_url=live_url, + verification_score=v_score, + verification_verdict=v_verdict, + ) + + # Build a diagnostic summary from the verification result for the repair LLM + diag_lines = [f"Verification score: {v_score}/100 (verdict: {v_verdict})"] + if not url_verification.get("root_ok"): + diag_lines.append("CRITICAL: Root page did not return 200 or returned <100 bytes") + if not url_verification.get("health_ok"): + diag_lines.append("CRITICAL: /health endpoint did not return 200") + cs = url_verification.get("content_signals", {}) + if not cs.get("has_title"): + diag_lines.append("Missing: tag in HTML") + if not cs.get("has_semantic_html"): + diag_lines.append("Missing: semantic HTML elements (h1, nav, main)") + if not cs.get("has_interactive_elements"): + diag_lines.append(f"Missing: interactive elements (only {cs.get('interactive_count', 0)} found, need >=3)") + if cs.get("broken_images"): + diag_lines.append(f"Broken images: {cs['broken_images'][:3]}") + failed_eps = [ + k for k, v in url_verification.get("endpoint_results", {}).items() + if not v.get("ok") + ] + if failed_eps: + diag_lines.append(f"Failed API endpoints: {', '.join(failed_eps[:5])}") + failed_assets = [ + k for k, v in url_verification.get("asset_checks", {}).items() + if not v.get("ok") + ] + if failed_assets: + diag_lines.append(f"Failed assets: {', '.join(failed_assets[:5])}") + + diag_summary = "\n".join(diag_lines) + logger.info("[DEPLOYER] Verification diagnostics for repair:\n%s", diag_summary) + + # Attempt one repair cycle using the verification diagnostics + error_logs = await get_deploy_error_logs(app_id) if app_id else "" + combined_errors = f"=== Live URL Verification Failures ===\n{diag_summary}" + if error_logs: + combined_errors += f"\n\n=== Deploy Error Logs ===\n{error_logs}" + + fixed_files = await _repair_code_from_errors(all_files, combined_errors) + if fixed_files != all_files: + all_files = await _prepare_files_for_push(fixed_files) + push_result = await push_files( + {"full_name": github_full_name}, + all_files, + commit_message="fix: repair from live URL verification failures", + ) + if push_result.get("commit_sha") and app_id: + await asyncio.sleep(5) + redeploy_result = await redeploy_app(app_id) + if redeploy_result.get("status") != "error": + repair_url = await wait_for_deployment(app_id) + if repair_url: + live_url = repair_url + # Re-verify after repair + url_verification = _verify_live_url(live_url) + v_score = url_verification.get("verification_score", 0) + v_verdict = url_verification.get("verification_verdict", "failed") + logger.info( + "[DEPLOYER] Post-repair verification: score=%d, verdict=%s", + v_score, v_verdict, + ) homepage = live_url or github_repo_url if github_full_name: @@ -450,6 +548,8 @@ async def _track_do_phase(phase_name: str) -> None: "frontend_files": frontend_file_count, "backend_files": backend_file_count, "url_verification": url_verification, + "verification_score": v_score, + "verification_verdict": v_verdict, **ci_meta, }, "phase": "deployed", @@ -1712,9 +1812,30 @@ def _build_repo_name(idea: dict) -> str: def _verify_live_url(live_url: str) -> dict: + """E2E-level verification of a deployed URL. + + Returns a dict containing: + - root_ok, health_ok, root_bytes (legacy fields) + - content_signals: detailed frontend quality signals + - endpoint_results: per-endpoint probe results + - asset_checks: CSS/JS asset accessibility results + - verification_score: 0-100 composite quality score + - verification_verdict: "verified" | "partial" | "failed" + """ base = live_url.rstrip("/") - result = {"root_ok": False, "health_ok": False, "root_bytes": 0, "endpoint_results": {}} + result: dict = { + "root_ok": False, + "health_ok": False, + "root_bytes": 0, + "endpoint_results": {}, + "asset_checks": {}, + "verification_score": 0, + "verification_verdict": "failed", + } + score = 0 + root_html = "" + # ── Phase 0: Wait for root + health with retries ────────────────── for _ in range(6): for path, key in [("/", "root"), ("/health", "health"), ("/api/health", "health")]: try: @@ -1727,6 +1848,8 @@ def _verify_live_url(live_url: str) -> dict: if key == "root": result["root_ok"] = resp.status == 200 and len(body) > 100 result["root_bytes"] = max(result["root_bytes"], len(body)) + if resp.status == 200: + root_html = body.decode("utf-8", errors="replace") else: result["health_ok"] = resp.status == 200 except (error.HTTPError, error.URLError, OSError): @@ -1735,36 +1858,242 @@ def _verify_live_url(live_url: str) -> dict: break time.sleep(10) - # Content quality verification - content_signals = {"has_title": False, "has_components": False, "has_styles": False, "content_length_ok": False} - if result["root_ok"] and result["root_bytes"] > 0: - try: - req = request.Request( - f"{base}/", - headers={"User-Agent": "vibeDeploy-verifier/1.0"}, + # Score: root page loads (200, >100 bytes): +15 + if result["root_ok"]: + score += 15 + logger.info("[DEPLOYER][VERIFY] Root page OK (%d bytes) → +15", result["root_bytes"]) + else: + logger.warning("[DEPLOYER][VERIFY] Root page FAILED (bytes=%d)", result["root_bytes"]) + + # Score: health endpoint: +10 + if result["health_ok"]: + score += 10 + logger.info("[DEPLOYER][VERIFY] Health endpoint OK → +10") + else: + logger.warning("[DEPLOYER][VERIFY] Health endpoint FAILED") + + # ── Phase 1: Frontend rendering quality check ───────────────────── + content_signals: dict = { + "has_title": False, + "has_components": False, + "has_styles": False, + "content_length_ok": False, + "has_spa_framework": False, + "has_semantic_html": False, + "has_interactive_elements": False, + "has_meta_tags": False, + "broken_images": [], + "interactive_count": 0, + "semantic_elements": [], + } + + if root_html: + html_lower = root_html.lower() + + # Basic content signals + content_signals["content_length_ok"] = len(root_html) > 500 + content_signals["has_title"] = bool(re.search(r"<title>[^<]{3,}", root_html, re.IGNORECASE)) + content_signals["has_components"] = root_html.count("= 3 or root_html.count("= 2 + content_signals["has_styles"] = ( + "tailwind" in html_lower or "globals.css" in html_lower or "]+\.css', root_html, re.IGNORECASE)) + ) + + # Score: has title: +5 + if content_signals["has_title"]: + score += 5 + logger.info("[DEPLOYER][VERIFY] Title tag present → +5") + else: + logger.warning("[DEPLOYER][VERIFY] Title tag MISSING") + + # Score: has styles: +5 + if content_signals["has_styles"]: + score += 5 + logger.info("[DEPLOYER][VERIFY] Styles present → +5") + else: + logger.warning("[DEPLOYER][VERIFY] Styles MISSING") + + # SPA framework detection + content_signals["has_spa_framework"] = any( + marker in root_html + for marker in ("__NEXT_DATA__", "__NUXT__", "window.__remixContext", "id=\"__next\"", "id=\"app\"") + ) or bool(re.search(r']+src="[^"]*\.(bundle|chunk|app)\.[^"]*\.js"', root_html, re.IGNORECASE)) + + # Semantic HTML check: h1, nav/navigation, main/article + found_semantic = [] + if re.search(r"]", html_lower): + found_semantic.append("h1") + if re.search(r"]", html_lower): + found_semantic.append("nav") + if re.search(r"]", html_lower): + found_semantic.append("main") + if re.search(r"]", html_lower): + found_semantic.append("article") + if re.search(r"]", html_lower): + found_semantic.append("header") + if re.search(r"]", html_lower): + found_semantic.append("footer") + content_signals["semantic_elements"] = found_semantic + content_signals["has_semantic_html"] = len(found_semantic) >= 2 + + # Score: has semantic HTML (h1, nav, main): +10 + if content_signals["has_semantic_html"]: + score += 10 + logger.info("[DEPLOYER][VERIFY] Semantic HTML present (%s) → +10", ", ".join(found_semantic)) + else: + logger.warning("[DEPLOYER][VERIFY] Semantic HTML insufficient (found: %s)", found_semantic) + + # Interactive elements: buttons, forms, links + button_count = len(re.findall(r"]", html_lower)) + form_count = len(re.findall(r"]", html_lower)) + link_count = len(re.findall(r"]*href=", html_lower)) + input_count = len(re.findall(r"]", html_lower)) + interactive_total = button_count + form_count + link_count + input_count + content_signals["interactive_count"] = interactive_total + content_signals["has_interactive_elements"] = interactive_total >= 3 + + # Score: has interactive elements (buttons, forms, links >= 3): +10 + if content_signals["has_interactive_elements"]: + score += 10 + logger.info( + "[DEPLOYER][VERIFY] Interactive elements: %d (buttons=%d, forms=%d, links=%d, inputs=%d) → +10", + interactive_total, button_count, form_count, link_count, input_count, ) - with request.urlopen(req, timeout=15) as resp: - html = resp.read().decode("utf-8", errors="replace") - content_signals["content_length_ok"] = len(html) > 500 - content_signals["has_title"] = bool(re.search(r"[^<]{3,}", html, re.IGNORECASE)) - content_signals["has_components"] = html.count("= 3 or html.count("= 2 - content_signals["has_styles"] = "tailwind" in html.lower() or "globals.css" in html or "]+property=["\']og:title["\']', root_html, re.IGNORECASE)) + has_description = bool( + re.search(r']+name=["\']description["\']', root_html, re.IGNORECASE) + or re.search(r']+property=["\']og:description["\']', root_html, re.IGNORECASE) + ) + content_signals["has_meta_tags"] = has_og_title or has_description + + # Score: meta tags present: +5 + if content_signals["has_meta_tags"]: + score += 5 + logger.info("[DEPLOYER][VERIFY] Meta tags present (og:title=%s, description=%s) → +5", has_og_title, has_description) + else: + logger.warning("[DEPLOYER][VERIFY] Meta tags MISSING") + + # Broken images check + img_srcs = re.findall(r']+src=["\']([^"\']*)["\']', root_html, re.IGNORECASE) + broken_imgs = [src for src in img_srcs if not src or src.strip() in ("", "#", "about:blank")] + content_signals["broken_images"] = broken_imgs + + # Score: no broken images: +5 + if not broken_imgs: + score += 5 + logger.info("[DEPLOYER][VERIFY] No broken images → +5") + else: + logger.warning("[DEPLOYER][VERIFY] Broken images found: %s", broken_imgs[:5]) + result["content_signals"] = content_signals + # ── Phase 2: API response structure validation ──────────────────── openapi_endpoints = _discover_endpoints_from_openapi(base) + all_endpoints_ok = True + all_responses_valid = True + for ep in openapi_endpoints: - path = ep["path"] - method = ep["method"] - status_code = _probe_endpoint(base, path, method) - passed = status_code is not None and status_code < 500 - result["endpoint_results"][f"{method} {path}"] = { - "status": status_code, - "ok": passed, - } + ep_path = ep["path"] + ep_method = ep["method"] + probe = _probe_endpoint_deep(base, ep_path, ep_method) + passed = probe["status"] is not None and probe["status"] < 500 + result["endpoint_results"][f"{ep_method} {ep_path}"] = probe + if not passed: - logger.warning("[DEPLOYER] Endpoint probe failed: %s %s → %s", method, path, status_code) + all_endpoints_ok = False + logger.warning( + "[DEPLOYER][VERIFY] Endpoint probe FAILED: %s %s → status=%s", + ep_method, ep_path, probe["status"], + ) + if not probe.get("valid_response"): + all_responses_valid = False + + # Score: API endpoints all respond without 5xx: +15 + if openapi_endpoints: + if all_endpoints_ok: + score += 15 + logger.info("[DEPLOYER][VERIFY] All %d API endpoints respond without 5xx → +15", len(openapi_endpoints)) + else: + logger.warning("[DEPLOYER][VERIFY] Some API endpoints returned 5xx errors") + + # Score: API responses have valid JSON with content: +10 + if all_responses_valid: + score += 10 + logger.info("[DEPLOYER][VERIFY] All API responses have valid JSON content → +10") + else: + logger.warning("[DEPLOYER][VERIFY] Some API responses have empty/invalid JSON") + else: + # No OpenAPI endpoints discovered — award the API points if health is OK + # (backend-only apps without OpenAPI still get partial credit) + if result["health_ok"]: + score += 15 + logger.info("[DEPLOYER][VERIFY] No OpenAPI endpoints; health OK grants API credit → +15") + + # ── Phase 3: Asset integrity check ──────────────────────────────── + if root_html: + css_refs = re.findall(r']+href=["\']([^"\']+\.css[^"\']*)["\']', root_html, re.IGNORECASE) + js_refs = re.findall(r']+src=["\']([^"\']+\.js[^"\']*)["\']', root_html, re.IGNORECASE) + asset_refs = css_refs + js_refs + + assets_ok = True + checked = 0 + # Limit to 10 assets to keep within time budget + for asset_url in asset_refs[:10]: + full_url = asset_url + if asset_url.startswith("/"): + full_url = f"{base}{asset_url}" + elif not asset_url.startswith(("http://", "https://")): + full_url = f"{base}/{asset_url}" + + # Skip external CDN assets (tailwindcss, google fonts, etc.) — they are not our problem + if not full_url.startswith(base): + continue + + try: + asset_req = request.Request(full_url, method="HEAD", headers={"User-Agent": "vibeDeploy-verifier/1.0"}) + with request.urlopen(asset_req, timeout=10) as resp: + if resp.status != 200: + assets_ok = False + result["asset_checks"][asset_url] = {"status": resp.status, "ok": False} + logger.warning("[DEPLOYER][VERIFY] Asset NOT OK: %s → %d", asset_url, resp.status) + else: + result["asset_checks"][asset_url] = {"status": 200, "ok": True} + checked += 1 + except error.HTTPError as e: + assets_ok = False + result["asset_checks"][asset_url] = {"status": e.code, "ok": False} + logger.warning("[DEPLOYER][VERIFY] Asset error: %s → %d", asset_url, e.code) + except (error.URLError, OSError) as e: + assets_ok = False + result["asset_checks"][asset_url] = {"status": None, "ok": False, "error": str(e)[:100]} + logger.warning("[DEPLOYER][VERIFY] Asset unreachable: %s → %s", asset_url, e) + + # Score: assets load correctly: +10 + if assets_ok: + score += 10 + logger.info("[DEPLOYER][VERIFY] All %d checked assets load OK → +10", checked) + else: + logger.warning("[DEPLOYER][VERIFY] Some assets failed to load") + + # ── Phase 4: Compute final score and verdict ────────────────────── + score = min(score, 100) + result["verification_score"] = score + + if score >= 80: + result["verification_verdict"] = "verified" + elif score >= 50: + result["verification_verdict"] = "partial" + else: + result["verification_verdict"] = "failed" + + logger.info( + "[DEPLOYER][VERIFY] Final score: %d/100 → verdict=%s", + score, result["verification_verdict"], + ) return result @@ -1791,7 +2120,29 @@ def _discover_endpoints_from_openapi(base_url: str) -> list[dict]: def _probe_endpoint(base_url: str, path: str, method: str) -> int | None: + """Legacy probe — returns only status code. Kept for backward compatibility.""" + result = _probe_endpoint_deep(base_url, path, method) + return result["status"] + + +def _probe_endpoint_deep(base_url: str, path: str, method: str) -> dict: + """Enhanced endpoint probe returning status, content-type, and response validity. + + Returns a dict with: + - status: HTTP status code or None + - ok: True if status < 500 + - content_type: response Content-Type header + - valid_response: True if response is valid JSON with meaningful content + - response_preview: first 200 chars of response body (for debugging) + """ url = f"{base_url}{path}" + probe_result: dict = { + "status": None, + "ok": False, + "content_type": "", + "valid_response": False, + "response_preview": "", + } try: if method == "GET": req = request.Request(url, headers={"User-Agent": "vibeDeploy-verifier/1.0"}) @@ -1806,11 +2157,53 @@ def _probe_endpoint(base_url: str, path: str, method: str) -> int | None: }, ) with request.urlopen(req, timeout=15) as resp: - return resp.status + body = resp.read() + probe_result["status"] = resp.status + probe_result["ok"] = resp.status < 500 + probe_result["content_type"] = resp.headers.get("Content-Type", "") + + body_text = body.decode("utf-8", errors="replace") + probe_result["response_preview"] = body_text[:200] + + # Validate response has meaningful JSON content + if "application/json" in probe_result["content_type"]: + try: + parsed = json.loads(body_text) + # Check it is not an empty container or a generic error + if isinstance(parsed, dict): + # Empty dict or just a "detail" error is not valid content + if len(parsed) == 0: + probe_result["valid_response"] = False + elif list(parsed.keys()) == ["detail"] and "not found" in str(parsed.get("detail", "")).lower(): + probe_result["valid_response"] = False + else: + probe_result["valid_response"] = True + elif isinstance(parsed, list): + # Empty list is not necessarily invalid (could be no data yet) + # but a list with items is clearly valid + probe_result["valid_response"] = len(parsed) > 0 + else: + # Scalar JSON value — valid + probe_result["valid_response"] = True + except (json.JSONDecodeError, ValueError): + probe_result["valid_response"] = False + else: + # Non-JSON response — valid if status is OK and has content + probe_result["valid_response"] = resp.status < 400 and len(body) > 0 + except error.HTTPError as e: - return e.code + probe_result["status"] = e.code + probe_result["ok"] = e.code < 500 + try: + err_body = e.read().decode("utf-8", errors="replace")[:200] + probe_result["response_preview"] = err_body + except Exception: + pass except (error.URLError, OSError): - return None + probe_result["status"] = None + probe_result["ok"] = False + + return probe_result async def _check_deploy_health(live_url: str, timeout: int = 30) -> dict: diff --git a/agent/nodes/design_system_generator.py b/agent/nodes/design_system_generator.py index 4da0c8d..7b2348a 100644 --- a/agent/nodes/design_system_generator.py +++ b/agent/nodes/design_system_generator.py @@ -1,20 +1,219 @@ +"""Dynamic design system generator — creates a unique design system per app via LLM.""" + from __future__ import annotations +import json +import logging +import re from typing import Any +logger = logging.getLogger(__name__) + +_DESIGN_SYSTEM_PROMPT = """\ +You are a world-class UI/UX designer. Generate a complete, unique design system \ +for a web application based on the provided concept. + +Return a JSON object with these exact keys: + +{ + "color_palette": { + "primary": "oklch(L% C H)", + "accent": "oklch(L% C H)", + "base_hue": , + "surface": "oklch(L% C H)", + "surface_alt": "oklch(L% C H)", + "on_primary": "oklch(L% C H)", + "semantic_success": "oklch(L% C H)", + "semantic_warning": "oklch(L% C H)", + "semantic_error": "oklch(L% C H)" + }, + "typography": { + "display_font": "", + "body_font": "", + "mono_font": "JetBrains_Mono", + "scale_ratio": , + "base_size": "16px" + }, + "spacing": { + "unit": 4, + "radius_sm": "0.375rem", + "radius_md": "0.625rem", + "radius_lg": "1rem", + "radius_full": "9999px" + }, + "motion": { + "duration_fast": "150ms", + "duration_normal": "300ms", + "duration_slow": "500ms", + "easing": "cubic-bezier(0.16, 1, 0.3, 1)", + "stagger_delay": "0.08s", + "intensity": "subtle|moderate|expressive" + }, + "layout": { + "archetype": "storyboard|operations_console|studio|atlas|notebook|lab|creator_shell|marketplace", + "max_width": "1280px", + "sidebar_width": "240px", + "header_height": "56px", + "grid_columns": 12 + }, + "visual_identity": { + "mood": "<2-3 word mood>", + "contrast_level": "low|medium|high", + "border_style": "none|subtle|defined", + "shadow_style": "flat|soft|layered", + "icon_style": "outlined|filled|duotone", + "illustration_style": "" + } +} + +Rules: +- Use oklch() color format for all colors (wide-gamut, perceptually uniform) +- Choose primary/accent colors that evoke the app's domain and emotional tone +- Pick fonts from Google Fonts that match the personality (no fallbacks to system fonts) +- The design must feel UNIQUE to this specific app — not generic SaaS or admin +- Consider the target audience, app purpose, and emotional response +- A meal planner should feel warm and inviting (not cold/corporate) +- A finance app should feel trustworthy and precise (not playful) +- A travel app should feel adventurous and aspirational +- A developer tool should feel technical and efficient +- Light mode should be the primary concern; dark mode will be derived +""" + +_FALLBACK_DESIGN = { + "color_palette": { + "primary": "oklch(45% 0.2 250)", + "accent": "oklch(70% 0.18 160)", + "base_hue": 250, + "surface": "oklch(98% 0.005 250)", + "surface_alt": "oklch(95% 0.008 250)", + "on_primary": "oklch(98% 0.005 250)", + "semantic_success": "oklch(55% 0.18 142)", + "semantic_warning": "oklch(75% 0.18 75)", + "semantic_error": "oklch(55% 0.22 25)", + }, + "typography": { + "display_font": "Inter", + "body_font": "Inter", + "mono_font": "JetBrains_Mono", + "scale_ratio": 1.25, + "base_size": "16px", + }, + "spacing": { + "unit": 4, + "radius_sm": "0.375rem", + "radius_md": "0.625rem", + "radius_lg": "1rem", + "radius_full": "9999px", + }, + "motion": { + "duration_fast": "150ms", + "duration_normal": "300ms", + "duration_slow": "500ms", + "easing": "cubic-bezier(0.16, 1, 0.3, 1)", + "stagger_delay": "0.08s", + "intensity": "moderate", + }, + "layout": { + "archetype": "storyboard", + "max_width": "1280px", + "sidebar_width": "240px", + "header_height": "56px", + "grid_columns": 12, + }, + "visual_identity": { + "mood": "clean modern", + "contrast_level": "medium", + "border_style": "subtle", + "shadow_style": "soft", + "icon_style": "outlined", + "illustration_style": "none", + }, +} + + +def _parse_json_response(text: str) -> dict: + """Extract JSON from an LLM response that may contain markdown fences.""" + # Try direct parse + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + pass + # Try extracting from markdown code block + match = re.search(r"```(?:json)?\s*\n?(.*?)```", str(text), re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + pass + return {} + async def design_system_generator(state: dict[str, Any], config=None) -> dict: - """Extract and normalize design system context from the blueprint for downstream code generation.""" + """Generate a unique, app-specific design system via LLM based on idea + inspiration + experience.""" blueprint = state.get("blueprint") or {} - design_system = blueprint.get("design_system") or {} + idea = state.get("idea") or {} + inspiration_pack = state.get("inspiration_pack") or {} experience_contract = blueprint.get("experience_contract") or {} + design_system_from_blueprint = blueprint.get("design_system") or {} shared_constants = blueprint.get("shared_constants") or {} + + # Build context for the LLM + context = { + "app_name": blueprint.get("app_name") or idea.get("name") or "web app", + "tagline": idea.get("tagline") or idea.get("problem") or "", + "domain": inspiration_pack.get("domain") or design_system_from_blueprint.get("domain") or "", + "target_audience": idea.get("target_audience") or "", + "features": idea.get("features") or [], + "visual_motifs": inspiration_pack.get("visual_motifs") or [], + "interface_metaphor": inspiration_pack.get("interface_metaphor") or "", + "layout_archetype": inspiration_pack.get("layout_archetype") or "", + "design_direction": experience_contract.get("design_direction") or {}, + "must_have_surfaces": experience_contract.get("must_have_surfaces") or [], + "experience_non_negotiables": experience_contract.get("experience_non_negotiables") or [], + "anti_patterns": inspiration_pack.get("anti_patterns") or [], + } + + generated_design = None + try: + from ..llm import MODEL_CONFIG, ainvoke_with_retry, content_to_str, get_llm, get_rate_limit_fallback_models + + model = MODEL_CONFIG.get("brainstorm", "gpt-5.4") + llm = get_llm(model=model, temperature=0.6, max_tokens=2000) + response = await ainvoke_with_retry( + llm, + [ + {"role": "system", "content": _DESIGN_SYSTEM_PROMPT}, + { + "role": "user", + "content": f"Generate a unique design system for this app:\n\n{json.dumps(context, indent=2, ensure_ascii=False)}", + }, + ], + fallback_models=get_rate_limit_fallback_models(model), + ) + raw = content_to_str(response.content) + generated_design = _parse_json_response(raw) + if not generated_design.get("color_palette"): + logger.warning("[DESIGN] LLM response missing color_palette, using fallback") + generated_design = None + except Exception: + logger.warning("[DESIGN] LLM design generation failed, using fallback") + + design = generated_design or _FALLBACK_DESIGN + + # Merge LLM-generated design with any existing blueprint design context + merged_design_system = { + **design_system_from_blueprint, + "generated": design, + "domain": context["domain"], + "visual_direction": design.get("visual_identity", {}).get("mood", ""), + } + return { "design_system_context": { - "design_system": design_system, + "design_system": merged_design_system, "experience_contract": experience_contract, "shared_constants": shared_constants, }, - "design_preset": str(design_system.get("visual_direction") or ""), + "design_preset": json.dumps(design, ensure_ascii=False), "phase": "design_system_generated", } diff --git a/agent/nodes/design_tokens.py b/agent/nodes/design_tokens.py index 047e781..ad535e4 100644 --- a/agent/nodes/design_tokens.py +++ b/agent/nodes/design_tokens.py @@ -1,3 +1,8 @@ +"""Color token generator — uses LLM-generated palette or falls back to domain presets.""" + +import re + +# Legacy presets kept as fallback only DOMAIN_PRESETS = { "finance": {"primary": "oklch(25% 0.05 260)", "accent": "oklch(85% 0.15 85)", "base_hue": 260}, "health": {"primary": "oklch(85% 0.04 150)", "accent": "oklch(70% 0.18 25)", "base_hue": 150}, @@ -7,6 +12,12 @@ } +def _extract_hue(oklch_str: str) -> int: + """Extract hue value from an oklch() string.""" + match = re.search(r"oklch\([^)]*\s+([\d.]+)\s*\)", str(oklch_str)) + return int(float(match.group(1))) if match else 250 + + def _make_scale(hue: int, chroma_base: float = 0.15, steps: int = 12) -> list[str]: result = [] for i in range(1, steps + 1): @@ -16,29 +27,62 @@ def _make_scale(hue: int, chroma_base: float = 0.15, steps: int = 12) -> list[st return result -def generate_color_tokens(design_system: dict) -> str: - """Return a CSS string with @theme block, semantic tokens, 12-step primary scale, and dark mode.""" +def _get_palette(design_system: dict) -> dict: + """Extract color palette — prefer LLM-generated, fall back to domain preset.""" + generated = design_system.get("generated", {}) + palette = generated.get("color_palette") if generated else None + + if palette and palette.get("primary"): + return { + "primary": palette["primary"], + "accent": palette.get("accent", palette["primary"]), + "base_hue": palette.get("base_hue", _extract_hue(palette["primary"])), + "surface": palette.get("surface"), + "surface_alt": palette.get("surface_alt"), + "on_primary": palette.get("on_primary"), + "semantic_success": palette.get("semantic_success", "oklch(55% 0.18 142)"), + "semantic_warning": palette.get("semantic_warning", "oklch(75% 0.18 75)"), + "semantic_error": palette.get("semantic_error", "oklch(55% 0.22 25)"), + } + + # Legacy fallback domain = design_system.get("domain", "tech") preset = DOMAIN_PRESETS.get(domain, DOMAIN_PRESETS["tech"]) + return { + **preset, + "surface": None, + "surface_alt": None, + "on_primary": None, + "semantic_success": "oklch(55% 0.18 142)", + "semantic_warning": "oklch(75% 0.18 75)", + "semantic_error": "oklch(55% 0.22 25)", + } - primary = preset["primary"] - accent = preset["accent"] - base_hue = preset["base_hue"] + +def generate_color_tokens(design_system: dict) -> str: + """Return a CSS string with @theme block, semantic tokens, 12-step primary scale, and dark mode.""" + palette = _get_palette(design_system) + + primary = palette["primary"] + accent = palette["accent"] + base_hue = palette["base_hue"] + surface = palette.get("surface") or f"oklch(98% 0.005 {base_hue})" + surface_alt = palette.get("surface_alt") or f"oklch(95% 0.008 {base_hue})" scale = _make_scale(base_hue) lines: list[str] = ["@theme {"] - lines.append(f" --color-background: oklch(98% 0.005 {base_hue});") + lines.append(f" --color-background: {surface};") lines.append(f" --color-foreground: oklch(15% 0.01 {base_hue});") - lines.append(f" --color-card: oklch(97% 0.005 {base_hue});") + lines.append(f" --color-card: {surface_alt};") lines.append(f" --color-border: oklch(88% 0.01 {base_hue});") lines.append(f" --color-primary: {primary};") lines.append(f" --color-accent: {accent};") lines.append(f" --color-muted: oklch(92% 0.01 {base_hue});") - lines.append(" --color-success: oklch(55% 0.18 142);") - lines.append(" --color-warning: oklch(75% 0.18 75);") - lines.append(" --color-error: oklch(55% 0.22 25);") + lines.append(f" --color-success: {palette['semantic_success']};") + lines.append(f" --color-warning: {palette['semantic_warning']};") + lines.append(f" --color-error: {palette['semantic_error']};") for i, color in enumerate(scale, 1): lines.append(f" --color-primary-{i}: {color};") @@ -57,26 +101,34 @@ def generate_color_tokens(design_system: dict) -> str: return "\n".join(lines) -def to_css_variables(domain: str = "saas") -> str: - preset = DOMAIN_PRESETS.get(domain, DOMAIN_PRESETS["tech"]) +def to_css_variables(domain: str = "saas", design_system: dict | None = None) -> str: + """Generate :root CSS variables. Uses LLM palette when available.""" + if design_system: + palette = _get_palette(design_system) + else: + palette = {**DOMAIN_PRESETS.get(domain, DOMAIN_PRESETS["tech"]), + "surface": None, "surface_alt": None, "on_primary": None, + "semantic_success": "oklch(55% 0.18 142)", + "semantic_warning": "oklch(75% 0.18 75)", + "semantic_error": "oklch(55% 0.22 25)"} - primary = preset["primary"] - accent = preset["accent"] - base_hue = preset["base_hue"] + primary = palette["primary"] + accent = palette["accent"] + base_hue = palette["base_hue"] lines: list[str] = [":root {"] - lines.append(f" --background: oklch(98% 0.005 {base_hue});") + lines.append(f" --background: {palette.get('surface') or f'oklch(98% 0.005 {base_hue})'};") lines.append(f" --foreground: oklch(15% 0.01 {base_hue});") lines.append(" --card: oklch(100% 0 0);") lines.append(f" --border: oklch(90% 0.01 {base_hue});") lines.append(f" --primary: {primary};") - lines.append(f" --primary-foreground: oklch(98% 0.005 {base_hue});") + lines.append(f" --primary-foreground: {palette.get('on_primary') or f'oklch(98% 0.005 {base_hue})'};") lines.append(f" --accent: {accent};") lines.append(f" --muted: oklch(96% 0.008 {base_hue});") lines.append(f" --muted-foreground: oklch(45% 0.02 {base_hue});") - lines.append(" --success: oklch(65% 0.2 145);") - lines.append(" --warning: oklch(75% 0.15 85);") - lines.append(" --destructive: oklch(55% 0.2 25);") + lines.append(f" --success: {palette['semantic_success']};") + lines.append(f" --warning: {palette['semantic_warning']};") + lines.append(f" --destructive: {palette['semantic_error']};") lines.append(" --radius: 0.625rem;") lines.append("}") diff --git a/agent/nodes/motion_tokens.py b/agent/nodes/motion_tokens.py index b18405f..ff1364f 100644 --- a/agent/nodes/motion_tokens.py +++ b/agent/nodes/motion_tokens.py @@ -22,15 +22,29 @@ def generate_motion_tokens(design_system: dict) -> str: """Generate a valid TypeScript motion-tokens.ts string for a given design system. - Args: - design_system: dict with optional keys: - - visual_direction: one of "editorial", "dashboard", "creative", "default" - - Returns: - A TypeScript string suitable for writing to motion-tokens.ts. + Uses LLM-generated motion config when available, falls back to presets. """ - visual_dir = design_system.get("visual_direction", "dashboard") - intensity = MOTION_INTENSITY.get(visual_dir, MOTION_INTENSITY["default"]) + # Prefer LLM-generated motion settings + generated = design_system.get("generated", {}) + llm_motion = generated.get("motion") if generated else None + + if llm_motion and llm_motion.get("intensity"): + intensity_map = { + "subtle": MOTION_INTENSITY.get("dashboard", MOTION_INTENSITY["default"]), + "moderate": MOTION_INTENSITY["default"], + "expressive": MOTION_INTENSITY.get("creative", MOTION_INTENSITY["default"]), + } + intensity = intensity_map.get(llm_motion["intensity"], MOTION_INTENSITY["default"]) + # Override easing from LLM if provided + llm_easing = llm_motion.get("easing", "") + if llm_easing and "cubic-bezier" in llm_easing: + import re as _re + m = _re.search(r"cubic-bezier\(([^)]+)\)", llm_easing) + if m: + intensity = {**intensity, "ease": f"[{m.group(1)}]"} + else: + visual_dir = design_system.get("visual_direction", "dashboard") + intensity = MOTION_INTENSITY.get(visual_dir, MOTION_INTENSITY["default"]) scale = intensity["duration_scale"] stagger = intensity["stagger"] diff --git a/agent/nodes/typography.py b/agent/nodes/typography.py index b39ea16..2d46c8b 100644 --- a/agent/nodes/typography.py +++ b/agent/nodes/typography.py @@ -64,9 +64,22 @@ _DEFAULT_PAIRING_ID = "modern_saas" -def select_font_pairing(typography_hint: str) -> dict: +def select_font_pairing(typography_hint: str, design_system: dict | None = None) -> dict: + """Select font pairing — prefer LLM-generated fonts, fall back to keyword matching.""" import re + # Use LLM-generated typography if available + if design_system: + generated = design_system.get("generated", {}) + typo = generated.get("typography") if generated else None + if typo and typo.get("display_font"): + return { + "id": "llm_generated", + "display": typo["display_font"].replace(" ", "_"), + "body": (typo.get("body_font") or typo["display_font"]).replace(" ", "_"), + "keywords": [], + } + hint_lower = typography_hint.lower() for pairing in FONT_PAIRINGS: for keyword in pairing["keywords"]: diff --git a/agent/server.py b/agent/server.py index c8b8f0e..e41a404 100644 --- a/agent/server.py +++ b/agent/server.py @@ -607,7 +607,19 @@ async def lifespan(app: FastAPI): else: db_path = os.environ.get("DB_PATH", str(_AGENT_DIR / "vibedeploy.db")) _store = ResultStore(db_path=db_path) + + # Start TTL cleanup for deployed apps + _ttl_task = None + if os.environ.get("DIGITALOCEAN_API_TOKEN"): + from .tools.digitalocean import _ttl_cleanup_loop + + _ttl_task = asyncio.create_task(_ttl_cleanup_loop()) + logger.info("[TTL] App cleanup loop started (TTL=%sh)", os.environ.get("DEPLOY_APP_TTL_HOURS", "72")) + yield + + if _ttl_task: + _ttl_task.cancel() await _store.close() _store = None @@ -630,7 +642,7 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, allow_origins=_ALLOWED_ORIGINS, - allow_methods=["GET", "POST", "PUT", "OPTIONS"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "X-API-Key", "X-Vibedeploy-Ops-Token"], allow_credentials=False, max_age=600, @@ -1970,6 +1982,146 @@ async def zero_prompt_build_status(session_id: str, card_id: str): } +# ── Dashboard Auth ──────────────────────────────────────────────────── + + +class UpsertUserRequest(BaseModel): + email: str + name: str = "" + image: str = "" + + +class UpsertUserResponse(BaseModel): + id: str + email: str + name: str + approved: bool + domain: str + + +class CheckUserResponse(BaseModel): + approved: bool + domain: str + email: str + + +@app.post("/dashboard/auth/upsert-user") +async def dashboard_auth_upsert_user(body: UpsertUserRequest): + from .db.connection import get_pool + + if not body.email or "@" not in body.email: + raise HTTPException(status_code=400, detail="invalid_email") + + domain = body.email.rsplit("@", 1)[1].lower() + approved = domain == "2weeks.co" + + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO users (email, name, image, approved, domain) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (email) DO UPDATE + SET name = EXCLUDED.name, + image = EXCLUDED.image, + last_login_at = now() + RETURNING id, email, name, approved, domain + """, + body.email, + body.name, + body.image, + approved, + domain, + ) + + return UpsertUserResponse( + id=row["id"], + email=row["email"], + name=row["name"], + approved=row["approved"], + domain=row["domain"], + ) + + +@app.get("/dashboard/auth/check-user") +async def dashboard_auth_check_user(email: str): + from .db.connection import get_pool + + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="invalid_email") + + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT approved, domain, email FROM users WHERE email = $1", + email, + ) + + if row is None: + raise HTTPException(status_code=404, detail="user_not_found") + + return CheckUserResponse( + approved=row["approved"], + domain=row["domain"], + email=row["email"], + ) + + +@app.get("/dashboard/apps") +async def dashboard_list_apps(): + """List all live DigitalOcean apps with age and status.""" + from .tools.digitalocean import list_apps + + apps = await list_apps() + result = [] + for app in apps: + spec = app.get("spec", {}) + name = spec.get("name", "") + active = app.get("active_deployment", {}) + result.append({ + "id": app.get("id", ""), + "name": name, + "live_url": app.get("live_url", ""), + "region": app.get("region", {}).get("slug", ""), + "phase": active.get("phase", "UNKNOWN"), + "created_at": app.get("created_at", ""), + "updated_at": app.get("updated_at", ""), + "protected": name in {"vibedeploy"}, + }) + return result + + +@app.delete("/dashboard/apps/{app_id}") +async def dashboard_delete_app(app_id: str): + """Delete a specific generated app. Refuses to delete the production app.""" + from .tools.digitalocean import delete_app, list_apps + + apps = await list_apps() + target = None + for app in apps: + if app.get("id") == app_id: + target = app + break + if not target: + raise HTTPException(status_code=404, detail="app_not_found") + + name = target.get("spec", {}).get("name", "") + if name in {"vibedeploy"}: + raise HTTPException(status_code=403, detail="cannot_delete_production_app") + + result = await delete_app(app_id) + return result + + +@app.post("/dashboard/cleanup-apps") +async def dashboard_cleanup_apps(): + """Manually trigger TTL cleanup of expired generated apps.""" + from .tools.digitalocean import cleanup_expired_apps + + result = await cleanup_expired_apps() + return result + + if __name__ == "__main__": uvicorn.run( "agent.server:app", diff --git a/agent/tools/digitalocean.py b/agent/tools/digitalocean.py index b97d1e2..2004ca3 100644 --- a/agent/tools/digitalocean.py +++ b/agent/tools/digitalocean.py @@ -1,12 +1,20 @@ import asyncio +import logging import os from collections.abc import Awaitable, Callable +from datetime import datetime, timezone import httpx from gradient_adk.tracing import trace_tool +logger = logging.getLogger(__name__) + DO_API_BASE = "https://api.digitalocean.com/v2" +# TTL for generated apps (default 72 hours / 3 days). Override via DEPLOY_APP_TTL_HOURS env var. +_DEFAULT_APP_TTL_HOURS = 72 +_PROTECTED_APP_NAMES = frozenset({"vibedeploy"}) + def _headers() -> dict: token = os.getenv("DIGITALOCEAN_API_TOKEN") @@ -89,6 +97,55 @@ async def wait_for_deployment( return "" +async def cleanup_expired_apps() -> dict: + """Delete generated apps older than the configured TTL. Protects vibedeploy.""" + ttl_hours = max(1, int(os.getenv("DEPLOY_APP_TTL_HOURS", str(_DEFAULT_APP_TTL_HOURS)))) + try: + apps = await list_apps() + except Exception: + return {"status": "error", "error": "failed to list apps"} + + now = datetime.now(timezone.utc) + deleted = [] + skipped = [] + + for app in apps: + name = app.get("spec", {}).get("name", "") + app_id = app.get("id", "") + if not app_id or name in _PROTECTED_APP_NAMES: + continue + created_str = app.get("created_at", "") + if not created_str: + continue + try: + created = datetime.fromisoformat(created_str.replace("Z", "+00:00")) + except (ValueError, TypeError): + continue + age_hours = (now - created).total_seconds() / 3600 + if age_hours < ttl_hours: + skipped.append({"name": name, "age_hours": round(age_hours, 1)}) + continue + logger.info("[TTL] Deleting expired app %s (age=%.1fh, ttl=%dh)", name, age_hours, ttl_hours) + result = await delete_app(app_id) + deleted.append({"name": name, "app_id": app_id, "age_hours": round(age_hours, 1), "result": result.get("status")}) + + return {"deleted": deleted, "skipped": skipped, "ttl_hours": ttl_hours} + + +async def _ttl_cleanup_loop(interval_seconds: int = 3600) -> None: + """Background loop that periodically cleans expired apps.""" + while True: + await asyncio.sleep(interval_seconds) + try: + if not os.getenv("DIGITALOCEAN_API_TOKEN"): + continue + result = await cleanup_expired_apps() + if result.get("deleted"): + logger.info("[TTL] Cleanup result: %d deleted", len(result["deleted"])) + except Exception: + logger.exception("[TTL] Cleanup loop error") + + @trace_tool("get_app_platform_status") async def get_app_status(app_id: str) -> dict: try: diff --git a/web/next.config.ts b/web/next.config.ts index 68a6c64..6079bdf 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -2,6 +2,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + images: { + remotePatterns: [ + { protocol: "https", hostname: "lh3.googleusercontent.com" }, + ], + }, }; export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json index c49e278..3cbe4ab 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@auth/core": "^0.41.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -17,6 +18,7 @@ "framer-motion": "^12.37.0", "lucide-react": "^0.577.0", "next": "16.1.7", + "next-auth": "^5.0.0-beta.30", "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", @@ -127,6 +129,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2254,6 +2285,15 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -9745,7 +9785,6 @@ "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -11320,6 +11359,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -11444,6 +11510,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12043,6 +12118,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/web/package.json b/web/package.json index 91929ac..ce08f0d 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "@auth/core": "^0.41.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -19,6 +20,7 @@ "framer-motion": "^12.37.0", "lucide-react": "^0.577.0", "next": "16.1.7", + "next-auth": "^5.0.0-beta.30", "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/web/src/app/api/auth/[...nextauth]/route.ts b/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c55a45e --- /dev/null +++ b/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/web/src/app/auth/error/page.tsx b/web/src/app/auth/error/page.tsx new file mode 100644 index 0000000..1b46b09 --- /dev/null +++ b/web/src/app/auth/error/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { AlertTriangle, LogOut } from "lucide-react"; +import { signOut } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +const errorMessages: Record = { + Configuration: "There is a problem with the server configuration.", + AccessDenied: "You do not have permission to sign in.", + Verification: "The verification link has expired or has already been used.", + Default: "An unexpected authentication error occurred.", +}; + +function ErrorContent() { + const searchParams = useSearchParams(); + const errorType = searchParams.get("error") || "Default"; + const message = errorMessages[errorType] || errorMessages.Default; + + return ( +
+ + +
+ +
+ + Authentication Error + +
+ +

+ {message} +

+
+ + + + +
+
+ ); +} + +export default function AuthErrorPage() { + return ( + +
Loading...
+ + } + > + +
+ ); +} diff --git a/web/src/app/auth/pending/page.tsx b/web/src/app/auth/pending/page.tsx new file mode 100644 index 0000000..18ee5c4 --- /dev/null +++ b/web/src/app/auth/pending/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useSession, signOut } from "next-auth/react"; +import { ShieldAlert, LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function PendingPage() { + const { data: session } = useSession(); + + return ( +
+ + +
+ +
+ + Access Restricted + + + Due to significant cost increases from LLM API and DigitalOcean + infrastructure usage, access to this platform is currently restricted + to authorized users only. + +
+ +
+

+ To request access, please contact +

+ + sejun@2weeks.co + +
+ {session?.user?.email && ( +

+ Signed in as{" "} + + {session.user.email} + +

+ )} +
+ + + +
+
+ ); +} diff --git a/web/src/app/auth/signin/page.tsx b/web/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..6f6fb36 --- /dev/null +++ b/web/src/app/auth/signin/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +function GoogleIcon() { + return ( + + ); +} + +function SignInContent() { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"; + + return ( +
+ + + + vibeDeploy + + Sign in to continue + + + + + +
+ ); +} + +export default function SignInPage() { + return ( + +
Loading...
+ + } + > + +
+ ); +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 9ce8efd..bb17676 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ErrorBoundary } from "@/components/shared"; +import { AuthSessionProvider } from "@/components/providers/session-provider"; import "./globals.css"; const geistSans = Geist({ @@ -42,9 +43,11 @@ export default function RootLayout({ Skip to main content -
- {children} -
+ +
+ {children} +
+
); diff --git a/web/src/components/providers/session-provider.tsx b/web/src/components/providers/session-provider.tsx new file mode 100644 index 0000000..0adc858 --- /dev/null +++ b/web/src/components/providers/session-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +export function AuthSessionProvider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/web/src/components/shared/user-menu.tsx b/web/src/components/shared/user-menu.tsx new file mode 100644 index 0000000..1623153 --- /dev/null +++ b/web/src/components/shared/user-menu.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useSession, signOut } from "next-auth/react"; +import { LogOut } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +export function UserMenu() { + const { data: session, status } = useSession(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + // Close menu on outside click + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + if (open) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + // Close on Escape key + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + if (open) { + document.addEventListener("keydown", handleKeyDown); + } + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open]); + + if (status === "loading") { + return ( +
+ ); + } + + if (status === "unauthenticated" || !session?.user) { + return null; + } + + const { user } = session; + const initials = (user.name ?? user.email ?? "?") + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + + return ( +
+ + + {open && ( +
+
+ {user.name && ( +

{user.name}

+ )} + {user.email && ( +

{user.email}

+ )} + {user.domain && ( + + {user.domain} + + )} +
+
+ +
+
+ )} +
+ ); +} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..1280447 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,78 @@ +import NextAuth from "next-auth"; +import Google from "next-auth/providers/google"; +import { authenticatedFetch } from "@/lib/fetch-with-auth"; +import { DASHBOARD_API_URL } from "@/lib/api"; + +const FIVE_MINUTES_MS = 5 * 60 * 1000; + +export const { handlers, signIn, signOut, auth } = NextAuth({ + providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }), + ], + session: { + strategy: "jwt", + }, + pages: { + signIn: "/auth/signin", + error: "/auth/error", + }, + callbacks: { + async signIn({ user }) { + try { + await authenticatedFetch( + `${DASHBOARD_API_URL}/dashboard/auth/upsert-user`, + { + method: "POST", + body: JSON.stringify({ + email: user.email, + name: user.name, + image: user.image, + }), + }, + ); + } catch (error) { + console.error("Failed to upsert user:", error); + } + return true; + }, + + async jwt({ token, user, trigger }) { + // On initial sign-in, extract domain and set approval + if (trigger === "signIn" && user?.email) { + const domain = user.email.split("@")[1] ?? ""; + token.domain = domain; + token.approved = domain === "2weeks.co"; + token.approvedCheckedAt = Date.now(); + } + + // Periodically refresh approval status (every 5 minutes) + const now = Date.now(); + const lastChecked = token.approvedCheckedAt ?? 0; + if (token.email && now - lastChecked > FIVE_MINUTES_MS) { + try { + const res = await authenticatedFetch( + `${DASHBOARD_API_URL}/dashboard/auth/check-user?email=${encodeURIComponent(token.email as string)}`, + ); + if (res.ok) { + const data = await res.json(); + token.approved = Boolean(data.approved); + } + } catch (error) { + console.error("Failed to check user approval:", error); + } + token.approvedCheckedAt = now; + } + + return token; + }, + + async session({ session, token }) { + session.user.approved = token.approved ?? false; + session.user.domain = token.domain ?? ""; + return session; + }, + }, +}); diff --git a/web/src/middleware.ts b/web/src/middleware.ts new file mode 100644 index 0000000..dd4e388 --- /dev/null +++ b/web/src/middleware.ts @@ -0,0 +1,43 @@ +import { auth } from "@/lib/auth"; + +const publicPaths = ["/", "/demo"]; + +function isPublicPath(pathname: string): boolean { + if (publicPaths.includes(pathname)) return true; + if (pathname.startsWith("/auth/")) return true; + if (pathname.startsWith("/api/auth/")) return true; + if (pathname.startsWith("/_next/")) return true; + if (pathname === "/favicon.ico") return true; + // Static assets (files with extensions like .js, .css, .png) + if (/\.\w+$/.test(pathname)) return true; + return false; +} + +export default auth((req) => { + const { pathname } = req.nextUrl; + + if (isPublicPath(pathname)) return; + + const session = req.auth; + + // Not logged in — redirect to sign-in + if (!session?.user) { + const signInUrl = new URL("/auth/signin", req.url); + signInUrl.searchParams.set("callbackUrl", pathname); + return Response.redirect(signInUrl); + } + + // Logged in but NOT approved — redirect to pending page + if (!session.user.approved && pathname !== "/auth/pending") { + return Response.redirect(new URL("/auth/pending", req.url)); + } + + // Approved user on /auth/pending — redirect to dashboard + if (session.user.approved && pathname === "/auth/pending") { + return Response.redirect(new URL("/dashboard", req.url)); + } +}); + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.).*)"], +}; diff --git a/web/src/types/next-auth.d.ts b/web/src/types/next-auth.d.ts new file mode 100644 index 0000000..8c0b7d8 --- /dev/null +++ b/web/src/types/next-auth.d.ts @@ -0,0 +1,23 @@ +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + user: { + id?: string; + name?: string | null; + email?: string | null; + image?: string | null; + approved: boolean; + domain: string; + }; + } +} + +declare module "next-auth/jwt" { + interface JWT { + approved?: boolean; + domain?: string; + approvedCheckedAt?: number; + } +}