diff --git a/.do/app.yaml b/.do/app.yaml index db6d1dc..16a8bf4 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -96,6 +96,13 @@ services: scope: RUN_TIME type: SECRET value: REPLACE_WITH_GOOGLE_API_KEY + - key: VIBEDEPLOY_API_KEY + scope: RUN_TIME + type: SECRET + value: REPLACE_WITH_API_KEY + - key: VIBEDEPLOY_CORS_ORIGINS + scope: RUN_TIME + value: ${APP_URL} - name: web environment_slug: node-js @@ -119,3 +126,7 @@ services: - key: HOSTNAME scope: RUN_TIME value: "0.0.0.0" + - key: VIBEDEPLOY_API_KEY + scope: RUN_TIME + type: SECRET + value: REPLACE_WITH_API_KEY diff --git a/agent/.env.example b/agent/.env.example index 1fc908e..31b64f8 100644 --- a/agent/.env.example +++ b/agent/.env.example @@ -29,5 +29,10 @@ BRAVE_API_KEY= GITHUB_TOKEN= GITHUB_ORG= +# === Auth & CORS === +VIBEDEPLOY_API_KEY= +VIBEDEPLOY_OPS_TOKEN= +VIBEDEPLOY_CORS_ORIGINS=http://localhost:3000 + # === Database === DATABASE_URL= diff --git a/agent/auth.py b/agent/auth.py new file mode 100644 index 0000000..f457cdd --- /dev/null +++ b/agent/auth.py @@ -0,0 +1,180 @@ +"""Authentication and rate-limiting middleware for vibeDeploy gateway.""" + +import hmac +import logging +import os +import time + +from fastapi import HTTPException, Request, Security +from fastapi.security import APIKeyHeader + +logger = logging.getLogger(__name__) + +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +_PUBLIC_PATHS: frozenset[str] = frozenset( + { + "/", + "/health", + "/cost-estimate", + "/api/cost-estimate", + "/models", + "/api/models", + } +) + +_PUBLIC_PREFIXES: tuple[str, ...] = ("/test/",) + + +def _get_api_key() -> str: + for key in ("VIBEDEPLOY_API_KEY", "VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN"): + value = os.getenv(key, "").strip() + if value: + return value + return "" + + +def _is_public_path(path: str) -> bool: + if path in _PUBLIC_PATHS: + return True + return any(path.startswith(prefix) for prefix in _PUBLIC_PREFIXES) + + +async def verify_api_key( + request: Request, + api_key: str | None = Security(_api_key_header), +) -> str | None: + path = request.url.path + + if _is_public_path(path): + return None + + expected = _get_api_key() + + # Auth disabled when no key is configured (dev mode) + if not expected: + return None + + # SSE endpoints: fall back to query param (EventSource can't send headers) + if not api_key and "/events" in path: + api_key = request.query_params.get("api_key") + + if not api_key: + raise HTTPException( + status_code=401, + detail="missing_api_key", + headers={"WWW-Authenticate": "ApiKey"}, + ) + + if not hmac.compare_digest(api_key, expected): + logger.warning("Invalid API key from %s for %s", request.client.host if request.client else "unknown", path) + raise HTTPException(status_code=403, detail="invalid_api_key") + + return api_key + + +class _RateLimitBucket: + __slots__ = ("_requests",) + + def __init__(self) -> None: + self._requests: list[float] = [] + + def hit(self, now: float, window_seconds: int, max_requests: int) -> bool: + cutoff = now - window_seconds + self._requests = [t for t in self._requests if t > cutoff] + if len(self._requests) >= max_requests: + return False + self._requests.append(now) + return True + + def is_empty(self) -> bool: + return len(self._requests) == 0 + + +_rate_buckets: dict[str, _RateLimitBucket] = {} +_BUCKET_CLEANUP_INTERVAL = 300 # seconds +_last_bucket_cleanup = 0.0 + +_RATE_LIMITS: dict[str, tuple[int, int]] = { + "write": (10, 60), + "read": (120, 60), + "sse": (20, 60), +} + +_WRITE_PATHS: frozenset[str] = frozenset( + { + "/run", + "/api/run", + "/resume", + "/api/resume", + "/brainstorm", + "/api/brainstorm", + "/zero-prompt/start", + "/api/zero-prompt/start", + "/zero-prompt/reset", + "/api/zero-prompt/reset", + } +) + +_SSE_FRAGMENTS: tuple[str, ...] = ("/events", "/build/") + + +def _classify_rate_tier(path: str, method: str) -> str: + if path in _WRITE_PATHS: + return "write" + for fragment in _SSE_FRAGMENTS: + if fragment in path: + return "sse" + if method == "POST" and "/actions" in path: + return "write" + return "read" + + +def _cleanup_stale_buckets(now: float) -> None: + global _last_bucket_cleanup + if now - _last_bucket_cleanup < _BUCKET_CLEANUP_INTERVAL: + return + _last_bucket_cleanup = now + max_window = max(w for _, w in _RATE_LIMITS.values()) + stale: list[str] = [] + for k, b in _rate_buckets.items(): + b._requests = [t for t in b._requests if t > now - max_window] + if b.is_empty(): + stale.append(k) + for k in stale: + _rate_buckets.pop(k, None) + + +async def rate_limit_check(request: Request) -> None: + path = request.url.path + + if _is_public_path(path): + return + + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + else: + client_ip = request.client.host if request.client else "unknown" + method = request.method + tier = _classify_rate_tier(path, method) + max_requests, window_seconds = _RATE_LIMITS[tier] + bucket_key = f"{client_ip}:{tier}" + now = time.monotonic() + + _cleanup_stale_buckets(now) + + if bucket_key not in _rate_buckets: + _rate_buckets[bucket_key] = _RateLimitBucket() + + if not _rate_buckets[bucket_key].hit(now, window_seconds, max_requests): + logger.warning("Rate limit exceeded: %s %s from %s (tier=%s)", method, path, client_ip, tier) + raise HTTPException( + status_code=429, + detail="rate_limit_exceeded", + headers={ + "Retry-After": str(window_seconds), + "X-RateLimit-Limit": str(max_requests), + "X-RateLimit-Window": str(window_seconds), + }, + ) diff --git a/agent/server.py b/agent/server.py index 270ef29..01332ce 100644 --- a/agent/server.py +++ b/agent/server.py @@ -27,7 +27,9 @@ import httpx import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI, Header, HTTPException, Request +from fastapi import Depends, FastAPI, Header, HTTPException, Request + +from .auth import rate_limit_check, verify_api_key from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from starlette.responses import JSONResponse, StreamingResponse @@ -346,7 +348,7 @@ def _meeting_store_payload(meeting: dict) -> dict: def _ops_token() -> str: - for key in ("VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN", "DIGITALOCEAN_INFERENCE_KEY"): + for key in ("VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN"): value = os.getenv(key, "").strip() if value: return value @@ -610,13 +612,28 @@ async def lifespan(app: FastAPI): _store = None -app = FastAPI(title="vibeDeploy Agent (local)", lifespan=lifespan) +app = FastAPI( + title="vibeDeploy Agent (local)", + lifespan=lifespan, + dependencies=[Depends(verify_api_key), Depends(rate_limit_check)], +) + +_ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.getenv( + "VIBEDEPLOY_CORS_ORIGINS", + "https://vibedeploy-7tgzk.ondigitalocean.app,http://localhost:3000,http://localhost:9001", + ).split(",") + if origin.strip() +] app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=_ALLOWED_ORIGINS, + allow_methods=["GET", "POST", "PUT", "OPTIONS"], + allow_headers=["Content-Type", "X-API-Key", "X-Vibedeploy-Ops-Token"], + allow_credentials=False, + max_age=600, ) _NODE_EVENTS = NODE_EVENTS diff --git a/agent/tests/conftest.py b/agent/tests/conftest.py index f07e317..dee6430 100644 --- a/agent/tests/conftest.py +++ b/agent/tests/conftest.py @@ -7,9 +7,18 @@ import pytest_asyncio from httpx import ASGITransport, AsyncClient +from agent.auth import _rate_buckets from agent.db.store import ResultStore +@pytest.fixture(autouse=True) +def _clear_rate_limits(): + """Clear rate limit buckets before each test to avoid cross-test pollution.""" + _rate_buckets.clear() + yield + _rate_buckets.clear() + + @pytest_asyncio.fixture async def store() -> AsyncIterator[ResultStore]: s = ResultStore(":memory:") diff --git a/agent/tests/test_auth.py b/agent/tests/test_auth.py new file mode 100644 index 0000000..e934a43 --- /dev/null +++ b/agent/tests/test_auth.py @@ -0,0 +1,201 @@ +"""Tests for the auth module (API key verification and rate limiting).""" + +import time +from unittest.mock import MagicMock + +import pytest + +from agent.auth import ( + _classify_rate_tier, + _get_api_key, + _is_public_path, + _rate_buckets, + _RateLimitBucket, + rate_limit_check, + verify_api_key, +) + + +class TestPublicPaths: + def test_health_is_public(self): + assert _is_public_path("/health") is True + + def test_root_is_public(self): + assert _is_public_path("/") is True + + def test_cost_estimate_is_public(self): + assert _is_public_path("/cost-estimate") is True + + def test_models_is_public(self): + assert _is_public_path("/models") is True + + def test_test_prefix_is_public(self): + assert _is_public_path("/test/result/abc") is True + + def test_run_is_not_public(self): + assert _is_public_path("/run") is False + + def test_zero_prompt_start_is_not_public(self): + assert _is_public_path("/zero-prompt/start") is False + + def test_dashboard_stats_is_not_public(self): + assert _is_public_path("/dashboard/stats") is False + + +class TestGetApiKey: + def test_returns_vibedeploy_api_key(self, monkeypatch): + monkeypatch.setenv("VIBEDEPLOY_API_KEY", "test-key-123") + monkeypatch.delenv("VIBEDEPLOY_OPS_TOKEN", raising=False) + monkeypatch.delenv("DASHBOARD_ADMIN_TOKEN", raising=False) + assert _get_api_key() == "test-key-123" + + def test_falls_back_to_ops_token(self, monkeypatch): + monkeypatch.delenv("VIBEDEPLOY_API_KEY", raising=False) + monkeypatch.setenv("VIBEDEPLOY_OPS_TOKEN", "ops-token") + monkeypatch.delenv("DASHBOARD_ADMIN_TOKEN", raising=False) + assert _get_api_key() == "ops-token" + + def test_returns_empty_when_none_set(self, monkeypatch): + monkeypatch.delenv("VIBEDEPLOY_API_KEY", raising=False) + monkeypatch.delenv("VIBEDEPLOY_OPS_TOKEN", raising=False) + monkeypatch.delenv("DASHBOARD_ADMIN_TOKEN", raising=False) + assert _get_api_key() == "" + + def test_does_not_fall_back_to_inference_key(self, monkeypatch): + monkeypatch.delenv("VIBEDEPLOY_API_KEY", raising=False) + monkeypatch.delenv("VIBEDEPLOY_OPS_TOKEN", raising=False) + monkeypatch.delenv("DASHBOARD_ADMIN_TOKEN", raising=False) + monkeypatch.setenv("DIGITALOCEAN_INFERENCE_KEY", "should-not-use") + assert _get_api_key() == "" + + +class TestRateTierClassification: + def test_run_is_write(self): + assert _classify_rate_tier("/run", "POST") == "write" + + def test_api_run_is_write(self): + assert _classify_rate_tier("/api/run", "POST") == "write" + + def test_zero_prompt_start_is_write(self): + assert _classify_rate_tier("/zero-prompt/start", "POST") == "write" + + def test_events_is_sse(self): + assert _classify_rate_tier("/dashboard/events", "GET") == "sse" + + def test_zero_prompt_events_is_sse(self): + assert _classify_rate_tier("/zero-prompt/events", "GET") == "sse" + + def test_build_events_is_sse(self): + assert _classify_rate_tier("/zero-prompt/s1/build/c1/events", "GET") == "sse" + + def test_actions_is_write(self): + assert _classify_rate_tier("/zero-prompt/s1/actions", "POST") == "write" + + def test_dashboard_stats_is_read(self): + assert _classify_rate_tier("/dashboard/stats", "GET") == "read" + + def test_result_is_read(self): + assert _classify_rate_tier("/result/abc", "GET") == "read" + + +class TestRateLimitBucket: + def test_allows_within_limit(self): + bucket = _RateLimitBucket() + now = time.monotonic() + for _ in range(5): + assert bucket.hit(now, 60, 5) is True + + def test_blocks_at_limit(self): + bucket = _RateLimitBucket() + now = time.monotonic() + for _ in range(5): + bucket.hit(now, 60, 5) + assert bucket.hit(now, 60, 5) is False + + def test_allows_after_window_expires(self): + bucket = _RateLimitBucket() + now = time.monotonic() + for _ in range(5): + bucket.hit(now, 60, 5) + # Simulate time passing beyond window + assert bucket.hit(now + 61, 60, 5) is True + + +@pytest.mark.asyncio +class TestVerifyApiKey: + async def test_public_path_skips_auth(self): + request = MagicMock() + request.url.path = "/health" + result = await verify_api_key(request, api_key=None) + assert result is None + + async def test_no_key_configured_passes(self, monkeypatch): + monkeypatch.delenv("VIBEDEPLOY_API_KEY", raising=False) + monkeypatch.delenv("VIBEDEPLOY_OPS_TOKEN", raising=False) + monkeypatch.delenv("DASHBOARD_ADMIN_TOKEN", raising=False) + request = MagicMock() + request.url.path = "/run" + result = await verify_api_key(request, api_key=None) + assert result is None + + async def test_valid_key_passes(self, monkeypatch): + monkeypatch.setenv("VIBEDEPLOY_API_KEY", "valid-key") + request = MagicMock() + request.url.path = "/run" + result = await verify_api_key(request, api_key="valid-key") + assert result == "valid-key" + + async def test_invalid_key_raises_403(self, monkeypatch): + from fastapi import HTTPException + + monkeypatch.setenv("VIBEDEPLOY_API_KEY", "valid-key") + request = MagicMock() + request.url.path = "/run" + request.client.host = "127.0.0.1" + with pytest.raises(HTTPException) as exc_info: + await verify_api_key(request, api_key="wrong-key") + assert exc_info.value.status_code == 403 + + async def test_missing_key_raises_401(self, monkeypatch): + from fastapi import HTTPException + + monkeypatch.setenv("VIBEDEPLOY_API_KEY", "valid-key") + request = MagicMock() + request.url.path = "/run" + request.query_params = {} + with pytest.raises(HTTPException) as exc_info: + await verify_api_key(request, api_key=None) + assert exc_info.value.status_code == 401 + + async def test_sse_path_accepts_query_param(self, monkeypatch): + monkeypatch.setenv("VIBEDEPLOY_API_KEY", "valid-key") + request = MagicMock() + request.url.path = "/zero-prompt/events" + request.query_params = {"api_key": "valid-key"} + result = await verify_api_key(request, api_key=None) + assert result == "valid-key" + + +@pytest.mark.asyncio +class TestRateLimitCheck: + async def test_public_path_not_limited(self): + request = MagicMock() + request.url.path = "/health" + # Should not raise + await rate_limit_check(request) + + async def test_blocks_after_limit(self): + from fastapi import HTTPException + + _rate_buckets.clear() + request = MagicMock() + request.url.path = "/run" + request.method = "POST" + request.client.host = "test-ip-block" + + for _ in range(10): + await rate_limit_check(request) + + with pytest.raises(HTTPException) as exc_info: + await rate_limit_check(request) + assert exc_info.value.status_code == 429 diff --git a/agent/tests/test_dashboard_snapshot.py b/agent/tests/test_dashboard_snapshot.py index b4537a4..b9f2343 100644 --- a/agent/tests/test_dashboard_snapshot.py +++ b/agent/tests/test_dashboard_snapshot.py @@ -314,6 +314,7 @@ async def test_dashboard_reconcile_endpoint_prunes_store_to_supplied_showcase_ap _reset_dashboard_caches(srv) monkeypatch.setenv("VIBEDEPLOY_OPS_TOKEN", "ops-secret") + monkeypatch.setenv("VIBEDEPLOY_API_KEY", "ops-secret") showcase_apps = [ srv._showcase_app_from_inventory( "demopilot-168642", @@ -452,7 +453,7 @@ async def _fake_showcase_live_apps(): reconcile = await app_client.post( "/api/ops/dashboard/reconcile", - headers={"x-vibedeploy-ops-token": "ops-secret"}, + headers={"x-vibedeploy-ops-token": "ops-secret", "X-API-Key": "ops-secret"}, json={ "showcase_apps": [ { @@ -467,8 +468,9 @@ async def _fake_showcase_live_apps(): assert reconcile.status_code == 200 assert reconcile.json()["stored"] == 5 - results = (await app_client.get("/dashboard/results")).json() - deployments = (await app_client.get("/dashboard/deployments")).json() + _auth = {"X-API-Key": "ops-secret"} + results = (await app_client.get("/dashboard/results", headers=_auth)).json() + deployments = (await app_client.get("/dashboard/deployments", headers=_auth)).json() assert len(results) == 5 assert len(deployments) == 5 diff --git a/web/src/app/zero-prompt/page.tsx b/web/src/app/zero-prompt/page.tsx index 5acf934..9434a3a 100644 --- a/web/src/app/zero-prompt/page.tsx +++ b/web/src/app/zero-prompt/page.tsx @@ -1,10 +1,11 @@ import { DASHBOARD_API_URL } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/fetch-with-auth"; import { ZeroPromptWorkspace } from "@/components/zero-prompt/zero-prompt-workspace"; import type { ZPSession } from "@/types/zero-prompt"; async function getInitialSession(): Promise { try { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/dashboard`, { cache: "no-store" }); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/dashboard`, { cache: "no-store" }); if (!response.ok) return null; const data = await response.json(); if (!data?.session_id) return null; diff --git a/web/src/hooks/use-pipeline-monitor.ts b/web/src/hooks/use-pipeline-monitor.ts index b6e38ea..63501c0 100644 --- a/web/src/hooks/use-pipeline-monitor.ts +++ b/web/src/hooks/use-pipeline-monitor.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { ActivePipeline, DashboardEvent, NodeMetadata, PipelineNodeStatus } from "@/types/dashboard"; import { DASHBOARD_API_URL } from "@/lib/api"; +import { appendApiKey } from "@/lib/fetch-with-auth"; import { createSSEClient } from "@/lib/sse-client"; const ACTIVE_POLL_MS = 30_000; @@ -97,7 +98,7 @@ export function usePipelineMonitor() { setConnected(true); const abort = createSSEClient({ - url: `${DASHBOARD_API_URL}/dashboard/events`, + url: appendApiKey(`${DASHBOARD_API_URL}/dashboard/events`), body: {}, onEvent: (sseEvent) => { try { @@ -250,7 +251,8 @@ export function usePipelineMonitor() { const fetchActive = useCallback(async () => { try { - const res = await fetch(`${DASHBOARD_API_URL}/dashboard/active`); + const { authenticatedFetch } = await import("@/lib/fetch-with-auth"); + const res = await authenticatedFetch(`${DASHBOARD_API_URL}/dashboard/active`); if (res.ok) setActivePipelines(await res.json()); } catch { } }, []); diff --git a/web/src/hooks/use-zero-prompt.ts b/web/src/hooks/use-zero-prompt.ts index 0e4e73c..67b257a 100644 --- a/web/src/hooks/use-zero-prompt.ts +++ b/web/src/hooks/use-zero-prompt.ts @@ -372,11 +372,14 @@ export function useZeroPrompt(initialSession: ZPSession | null = null) { const connect = async () => { try { - const eventsUrl = eventSessionId + const { appendApiKey, authHeaders } = await import("@/lib/fetch-with-auth"); + const baseUrl = eventSessionId ? `${DASHBOARD_API_URL}/zero-prompt/events?session_id=${encodeURIComponent(eventSessionId)}` : `${DASHBOARD_API_URL}/zero-prompt/events`; + const eventsUrl = appendApiKey(baseUrl); const res = await fetch(eventsUrl, { signal: controller.signal, + headers: authHeaders(), }); if (!res.ok || !res.body) throw new Error(`SSE failed: ${res.status}`); setIsConnected(true); diff --git a/web/src/lib/__tests__/api.test.ts b/web/src/lib/__tests__/api.test.ts index 4112737..0895147 100644 --- a/web/src/lib/__tests__/api.test.ts +++ b/web/src/lib/__tests__/api.test.ts @@ -39,7 +39,7 @@ describe("api.ts", () => { const result = await checkHealth(); expect(result).toBe(true); - expect(global.fetch).toHaveBeenCalledWith(`${DASHBOARD_API_URL}/health`); + expect(global.fetch).toHaveBeenCalledWith(`${DASHBOARD_API_URL}/health`, expect.any(Object)); }); it("returns false on network error", async () => { @@ -59,7 +59,7 @@ describe("api.ts", () => { const result = await getMeetingResult("test-id"); expect(result).toBeNull(); - expect(global.fetch).toHaveBeenCalledWith(`${DASHBOARD_API_URL}/result/test-id`); + expect(global.fetch).toHaveBeenCalledWith(`${DASHBOARD_API_URL}/result/test-id`, expect.any(Object)); }); it("returns data on success", async () => { diff --git a/web/src/lib/__tests__/fetch-with-auth.test.ts b/web/src/lib/__tests__/fetch-with-auth.test.ts new file mode 100644 index 0000000..6c965bc --- /dev/null +++ b/web/src/lib/__tests__/fetch-with-auth.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Must mock process.env before importing the module +const MOCK_API_KEY = "test-api-key-123"; + +describe("fetch-with-auth", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + describe("authHeaders", () => { + it("includes Content-Type by default", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", ""); + const { authHeaders } = await import("../fetch-with-auth"); + const headers = authHeaders(); + expect(headers["Content-Type"]).toBe("application/json"); + }); + + it("includes X-API-Key when env var is set", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", MOCK_API_KEY); + const { authHeaders } = await import("../fetch-with-auth"); + const headers = authHeaders(); + expect(headers["X-API-Key"]).toBe(MOCK_API_KEY); + }); + + it("omits X-API-Key when env var is empty", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", ""); + const { authHeaders } = await import("../fetch-with-auth"); + const headers = authHeaders(); + expect(headers["X-API-Key"]).toBeUndefined(); + }); + + it("merges extra headers", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", ""); + const { authHeaders } = await import("../fetch-with-auth"); + const headers = authHeaders({ Accept: "text/event-stream" }); + expect(headers["Accept"]).toBe("text/event-stream"); + expect(headers["Content-Type"]).toBe("application/json"); + }); + }); + + describe("appendApiKey", () => { + it("appends api_key to URL without query params", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", MOCK_API_KEY); + const { appendApiKey } = await import("../fetch-with-auth"); + const result = appendApiKey("http://example.com/events"); + expect(result).toContain("?api_key="); + expect(result).toContain(MOCK_API_KEY); + }); + + it("appends with & when URL already has query params", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", MOCK_API_KEY); + const { appendApiKey } = await import("../fetch-with-auth"); + const result = appendApiKey("http://example.com/events?session_id=abc"); + expect(result).toContain("&api_key="); + }); + + it("returns URL unchanged when no API key", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", ""); + const { appendApiKey } = await import("../fetch-with-auth"); + const url = "http://example.com/events"; + expect(appendApiKey(url)).toBe(url); + }); + }); + + describe("authenticatedFetch", () => { + it("calls fetch with auth headers", async () => { + vi.stubEnv("VIBEDEPLOY_API_KEY", MOCK_API_KEY); + const mockFetch = vi.fn().mockResolvedValue(new Response("ok")); + vi.stubGlobal("fetch", mockFetch); + + const { authenticatedFetch } = await import("../fetch-with-auth"); + await authenticatedFetch("http://example.com/api/run", { + method: "POST", + body: JSON.stringify({ prompt: "test" }), + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("http://example.com/api/run"); + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/json"); + }); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 926bf15..9ab0da8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import { authenticatedFetch } from "./fetch-with-auth"; + export const AGENT_URL = process.env.NEXT_PUBLIC_AGENT_URL ?? "http://localhost:8080"; @@ -11,9 +13,8 @@ export async function startMeeting(input: string): Promise<{ }> { const meetingId = crypto.randomUUID(); - const response = await fetch(`${DASHBOARD_API_URL}/run`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/run`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: input, config: { configurable: { thread_id: meetingId } }, @@ -34,9 +35,8 @@ export async function resumeMeeting( meetingId: string, action: string = "proceed", ): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/resume`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/resume`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thread_id: meetingId, action }), }); @@ -71,7 +71,7 @@ export async function getMeetingResult( meetingId: string, ): Promise { try { - const response = await fetch(`${DASHBOARD_API_URL}/result/${meetingId}`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/result/${meetingId}`); if (!response.ok) return null; return response.json(); } catch { @@ -84,9 +84,8 @@ export async function startBrainstorm(input: string): Promise<{ streamUrl: string; }> { const sessionId = crypto.randomUUID(); - const response = await fetch(`${DASHBOARD_API_URL}/brainstorm`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/brainstorm`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: input, config: { configurable: { thread_id: sessionId } }, @@ -123,7 +122,7 @@ export async function getBrainstormResult( sessionId: string, ): Promise { try { - const response = await fetch( + const response = await authenticatedFetch( `${DASHBOARD_API_URL}/brainstorm/result/${sessionId}`, ); if (!response.ok) return null; diff --git a/web/src/lib/dashboard-api.ts b/web/src/lib/dashboard-api.ts index b84f183..97f969e 100644 --- a/web/src/lib/dashboard-api.ts +++ b/web/src/lib/dashboard-api.ts @@ -1,9 +1,10 @@ import { DASHBOARD_API_URL } from "./api"; +import { authenticatedFetch } from "./fetch-with-auth"; import type { DeployedApp } from "@/types/dashboard"; export async function checkHealth(): Promise { try { - const response = await fetch(`${DASHBOARD_API_URL}/health`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/health`); return response.ok; } catch { return false; @@ -18,7 +19,7 @@ export async function getDashboardStats(): Promise<{ nogo_count: number; }> { try { - const response = await fetch(`${DASHBOARD_API_URL}/dashboard/stats`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/dashboard/stats`); if (!response.ok) throw new Error("Failed to fetch stats"); return response.json(); } catch { @@ -41,7 +42,7 @@ export async function getDashboardResults(): Promise< }> > { try { - const response = await fetch(`${DASHBOARD_API_URL}/dashboard/results`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/dashboard/results`); if (!response.ok) throw new Error("Failed to fetch results"); return response.json(); } catch { @@ -56,7 +57,7 @@ export async function getDashboardBrainstorms(): Promise< }> > { try { - const response = await fetch(`${DASHBOARD_API_URL}/dashboard/brainstorms`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/dashboard/brainstorms`); if (!response.ok) throw new Error("Failed to fetch brainstorms"); return response.json(); } catch { @@ -66,7 +67,7 @@ export async function getDashboardBrainstorms(): Promise< export async function getDashboardDeployments(): Promise { try { - const response = await fetch(`${DASHBOARD_API_URL}/dashboard/deployments`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/dashboard/deployments`); if (!response.ok) throw new Error("Failed to fetch deployments"); return response.json(); } catch { diff --git a/web/src/lib/fetch-with-auth.ts b/web/src/lib/fetch-with-auth.ts new file mode 100644 index 0000000..c13a024 --- /dev/null +++ b/web/src/lib/fetch-with-auth.ts @@ -0,0 +1,36 @@ +/** + * Authenticated fetch wrapper for API calls to the vibeDeploy backend. + * + * The API key is injected server-side via VIBEDEPLOY_API_KEY env var. + * This key must NEVER be exposed to the browser (no NEXT_PUBLIC_ prefix). + */ + +const API_KEY = process.env.VIBEDEPLOY_API_KEY ?? ""; + +export function authHeaders(extra?: Record): Record { + const headers: Record = { + "Content-Type": "application/json", + ...extra, + }; + if (API_KEY) { + headers["X-API-Key"] = API_KEY; + } + return headers; +} + +export async function authenticatedFetch(url: string, init?: RequestInit): Promise { + const merged: RequestInit = { + ...init, + headers: { + ...authHeaders(), + ...(init?.headers as Record | undefined), + }, + }; + return fetch(url, merged); +} + +export function appendApiKey(url: string): string { + if (!API_KEY) return url; + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}api_key=${encodeURIComponent(API_KEY)}`; +} diff --git a/web/src/lib/sse-client.ts b/web/src/lib/sse-client.ts index f3cd76d..459c1c0 100644 --- a/web/src/lib/sse-client.ts +++ b/web/src/lib/sse-client.ts @@ -38,9 +38,10 @@ export function createSSEClient(options: SSEClientOptions): () => void { async function connect() { try { + const { authHeaders } = await import("./fetch-with-auth"); const response = await fetch(options.url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: JSON.stringify(options.body), signal: controller.signal, }); diff --git a/web/src/lib/zero-prompt-api.ts b/web/src/lib/zero-prompt-api.ts index 33a1af2..9cf9dfa 100644 --- a/web/src/lib/zero-prompt-api.ts +++ b/web/src/lib/zero-prompt-api.ts @@ -1,4 +1,5 @@ import { DASHBOARD_API_URL } from "./api"; +import { authenticatedFetch, appendApiKey } from "./fetch-with-auth"; import type { ZPCard, ZPSession } from "@/types/zero-prompt"; const LATEST_SESSION_ID = "latest"; @@ -27,9 +28,9 @@ function parseStartSessionResponse(raw: string): ZPSession { } export async function startSession(goal?: number): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/start`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/start`, { method: "POST", - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ goal }), }); if (!response.ok) throw new Error("Failed to start session"); @@ -37,13 +38,13 @@ export async function startSession(goal?: number): Promise { } export async function getDashboard(): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/dashboard`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/dashboard`); if (!response.ok) throw new Error("Failed to get dashboard"); return response.json(); } export async function getSession(id: string): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/${id}`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/${id}`); if (!response.ok) throw new Error("Failed to get session"); return response.json(); } @@ -53,48 +54,48 @@ export async function getLatestSession(): Promise { } export async function getDeployedCards(limit = 50): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/deployed?limit=${limit}`); + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/deployed?limit=${limit}`); if (!response.ok) throw new Error("Failed to get deployed cards"); const data = await response.json(); return data.cards || []; } export async function queueBuild(sessionId: string, cardId: string): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { method: "POST", - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "queue_build", card_id: cardId }), }); if (!response.ok) throw new Error("Failed to queue build"); } export async function passCard(sessionId: string, cardId: string): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { method: "POST", - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "pass_card", card_id: cardId }), }); if (!response.ok) throw new Error("Failed to pass card"); } export async function deleteCard(sessionId: string, cardId: string): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { method: "POST", - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete_card", card_id: cardId }), }); if (!response.ok) throw new Error("Failed to delete card"); } export async function deleteRejectedCards(sessionId: string): Promise { - const response = await fetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { + const response = await authenticatedFetch(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/actions`, { method: "POST", - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete_rejected_cards" }), }); if (!response.ok) throw new Error("Failed to delete rejected cards"); } export function getBuildEventsUrl(sessionId: string, cardId: string): string { - return `${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/build/${cardId}/events`; + return appendApiKey(`${DASHBOARD_API_URL}/zero-prompt/${sessionId || LATEST_SESSION_ID}/build/${cardId}/events`); }