From 4e905cec6d5603c08306716ceaae9b903d02a4ce Mon Sep 17 00:00:00 2001 From: ryzrr Date: Sat, 18 Jul 2026 22:32:47 +0530 Subject: [PATCH 1/5] chore(deploy): Docker build args, dockerignore, Next 16 proxy middleware - Pass NEXT_PUBLIC_* as build args so they're inlined into the frontend image - Add .dockerignore (keeps node_modules/.next/.env out of the build context) - Rename middleware.ts -> proxy.ts (Next.js 16 renamed the middleware file) - Wrap the sign-in form in (useSearchParams build requirement) --- .dockerignore | 34 ++++++++++++++++++++++++++++++++++ Dockerfile.frontend | 6 ++++++ app/auth/signin/page.tsx | 13 ++++++++++--- infra/docker-compose.yml | 4 ++++ middleware.ts => proxy.ts | 0 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 .dockerignore rename middleware.ts => proxy.ts (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b76921f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Node +node_modules +npm-debug.log* +.npm + +# Next.js build output (rebuilt inside Docker) +.next +out + +# Git +.git +.gitignore +.github + +# Environment files (secrets — never bake into image) +.env +.env.* +!.env.example + +# Dev tooling +.eslintcache +*.log + +# Python +apps/api/__pycache__ +apps/api/.ruff_cache +apps/**/__pycache__ +apps/**/*.pyc +apps/**/*.pyo + +# Misc +*.md +.claude +infra diff --git a/Dockerfile.frontend b/Dockerfile.frontend index ab356bf..01ecbbe 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -12,6 +12,12 @@ FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_WS_URL +ARG NEXT_PUBLIC_LIVEBOARD_API_KEY +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +ENV NEXT_PUBLIC_LIVEBOARD_API_KEY=$NEXT_PUBLIC_LIVEBOARD_API_KEY RUN npm run build # Production runtime diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 6dcec9a..7cf301e 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -2,12 +2,11 @@ import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; -import { useState } from "react"; +import { Suspense, useState } from "react"; import { LiveboardIcon } from "@/components/logo"; const hasGoogle = Boolean(process.env.NEXT_PUBLIC_GOOGLE_ENABLED) || - // fallback: let the server tell us via the error message false; const isDev = process.env.NODE_ENV !== "production"; @@ -20,7 +19,7 @@ const ERROR_MESSAGES: Record = { Configuration: "Auth is not fully configured yet.", }; -export default function SignInPage() { +function SignInForm() { const searchParams = useSearchParams(); const callbackUrl = searchParams.get("callbackUrl") ?? "/overview"; const error = searchParams.get("error"); @@ -139,6 +138,14 @@ export default function SignInPage() { ); } +export default function SignInPage() { + return ( + }> + + + ); +} + function GoogleIcon() { return ( diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 68ccacc..c62ea37 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -78,6 +78,10 @@ services: build: context: .. dockerfile: Dockerfile.frontend + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL} + NEXT_PUBLIC_LIVEBOARD_API_KEY: ${NEXT_PUBLIC_LIVEBOARD_API_KEY} ports: - "3000:3000" environment: diff --git a/middleware.ts b/proxy.ts similarity index 100% rename from middleware.ts rename to proxy.ts From ced6e2fc5fa715e376d628b5fc0b175bb544d458 Mon Sep 17 00:00:00 2001 From: ryzrr Date: Sat, 18 Jul 2026 22:35:17 +0530 Subject: [PATCH 2/5] =?UTF-8?q?feat(api):=20multi-tenant=20backend=20?= =?UTF-8?q?=E2=80=94=20orgs,=20RLS,=20BFF=20auth,=20realtime=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrations: organizations + memberships + projects.org_id (003); api_keys table with rotation/revocation (004); Row-Level Security via a restricted dashboard_reader role scoped by app.project_id GUC (005, fails closed) - Internal endpoints (BFF-only): provision user+org+project on login, list access, self-serve project/key create/rotate/delete, realtime-token mint - Auth planes: authenticate_internal + resolve_project_id (internal token + project id, or x-api-key); scoped_conn runs dashboard reads under RLS - Realtime: Socket.io + SSE accept short-lived project-scoped tokens - Per-project ingest rate limit; CORS locked to configured origins - Fix: get_timeseries interval param (timedelta); /v1/traces JSONB tags parse --- apps/api/api/deps.py | 122 +++++- apps/api/api/routes/alerts.py | 25 +- apps/api/api/routes/ingest.py | 22 +- apps/api/api/routes/internal.py | 349 ++++++++++++++++++ apps/api/api/routes/query.py | 37 +- apps/api/core/config.py | 21 ++ apps/api/main.py | 7 +- .../migrations/versions/003_multitenancy.py | 86 +++++ apps/api/migrations/versions/004_api_keys.py | 54 +++ apps/api/migrations/versions/005_rls.py | 64 ++++ apps/api/realtime/socket_server.py | 34 +- apps/api/realtime/sse.py | 27 +- apps/api/realtime/tokens.py | 29 ++ 13 files changed, 827 insertions(+), 50 deletions(-) create mode 100644 apps/api/api/routes/internal.py create mode 100644 apps/api/migrations/versions/003_multitenancy.py create mode 100644 apps/api/migrations/versions/004_api_keys.py create mode 100644 apps/api/migrations/versions/005_rls.py create mode 100644 apps/api/realtime/tokens.py diff --git a/apps/api/api/deps.py b/apps/api/api/deps.py index a6e8271..43bbe3c 100644 --- a/apps/api/api/deps.py +++ b/apps/api/api/deps.py @@ -1,9 +1,9 @@ import hashlib import hmac -from typing import AsyncGenerator +from typing import AsyncGenerator, Optional import asyncpg -from fastapi import Header, HTTPException +from fastapi import Depends, Header, HTTPException from core.database import get_pool from core.redis_client import get_redis @@ -19,23 +19,103 @@ async def get_redis_dep(): return await get_redis() -async def authenticate_project(x_api_key: str = Header(...)) -> str: +# ─── Credential helpers ────────────────────────────────────────────────────── + +async def _project_id_from_api_key(x_api_key: str) -> str: """ - Validate x-api-key header and return the project's UUID. - Key is stored as SHA-256 — the raw key never touches the DB. + Look up a project by its raw API key (stored as SHA-256). + Checks the `api_keys` table (non-revoked) first, then falls back to the + legacy `projects.api_key` column for keys minted before Phase 8.3. """ hashed = hashlib.sha256(x_api_key.encode()).hexdigest() pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( - "SELECT id FROM projects WHERE api_key = $1", + "SELECT project_id FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL", hashed, ) + if row is None: + row = await conn.fetchrow( + "SELECT id AS project_id FROM projects WHERE api_key = $1", + hashed, + ) if row is None: raise HTTPException(status_code=401, detail="Invalid API key") - return str(row["id"]) + return str(row["project_id"]) + + +def _valid_internal_token(token: str) -> bool: + """ + Constant-time check of a trusted server-to-server token. + + Accepts a dedicated INTERNAL_SERVICE_TOKEN when configured; otherwise + falls back to the master API_SECRET_KEY so local/dev works without an + extra secret. Both are server-only values, never shipped to the browser. + """ + from core.config import settings + + candidates = [] + internal = settings.internal_service_token.get_secret_value() + if internal: + candidates.append(internal) + candidates.append(settings.api_secret_key.get_secret_value()) + + token_b = token.encode() + return any(hmac.compare_digest(token_b, c.encode()) for c in candidates) + + +# ─── Write plane (SDK ingest) ──────────────────────────────────────────────── + +async def authenticate_project(x_api_key: str = Header(...)) -> str: + """ + Validate the x-api-key header and return the project's UUID. + Used by the write plane (ingest, span ingest) — the key maps 1:1 to a + project, so an SDK can only ever write into its own project. + """ + return await _project_id_from_api_key(x_api_key) + + +# ─── Internal plane (trusted Next.js BFF) ──────────────────────────────────── + +async def authenticate_internal(x_internal_token: str = Header(...)) -> None: + """Guard for /v1/internal/* — only the trusted dashboard server may call.""" + if not _valid_internal_token(x_internal_token): + raise HTTPException(status_code=403, detail="Forbidden") +# ─── Read plane (dashboard) ────────────────────────────────────────────────── + +async def resolve_project_id( + x_api_key: Optional[str] = Header(None), + x_internal_token: Optional[str] = Header(None), + x_project_id: Optional[str] = Header(None), +) -> str: + """ + Resolve the project a dashboard read is scoped to, via EITHER: + + • Trusted BFF call — a valid x-internal-token plus an explicit + x-project-id. The Next.js server has already verified the logged-in + user is a member of the org owning that project, so we trust it. + (Postgres RLS in Phase 8.4 will add a DB-level backstop.) + + • Direct API key — x-api-key mapped to its project (backward-compatible + with the pre-multi-tenant dashboard and any key-based tooling). + + Exactly one credential path must be satisfied. + """ + if x_internal_token is not None and x_project_id is not None: + if not _valid_internal_token(x_internal_token): + raise HTTPException(status_code=403, detail="Forbidden") + return x_project_id + + if x_api_key is not None: + return await _project_id_from_api_key(x_api_key) + + raise HTTPException(status_code=401, detail="Missing project credentials") + + +# ─── Admin (master key) ────────────────────────────────────────────────────── + async def authenticate_admin(x_master_key: str = Header(...)) -> None: """ Validate master key for admin-only endpoints. @@ -45,3 +125,31 @@ async def authenticate_admin(x_master_key: str = Header(...)) -> None: expected = settings.api_secret_key.get_secret_value() if not hmac.compare_digest(x_master_key.encode(), expected.encode()): raise HTTPException(status_code=403, detail="Forbidden") + + +# ─── RLS-scoped read connection (defense-in-depth) ─────────────────────────── + +async def scoped_conn( + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(get_db), +) -> AsyncGenerator[asyncpg.Connection, None]: + """ + A DB connection for dashboard READS with Row-Level Security enforced. + + Assumes the least-privilege `dashboard_reader` role and pins the request's + project via the `app.project_id` GUC — so Postgres itself refuses rows from + any other tenant, even if an app query forgets its `WHERE project_id = …`. + A read that never set the GUC sees zero rows (fail closed). + + Role + GUC are reset in `finally`; asyncpg's pool reset is a further backstop. + """ + await conn.execute("SELECT set_config('app.project_id', $1, false)", project_id) + await conn.execute("SET ROLE dashboard_reader") + try: + yield conn + finally: + try: + await conn.execute("RESET ROLE") + await conn.execute("SELECT set_config('app.project_id', '', false)") + except Exception: # pragma: no cover - best-effort cleanup before pool reset + pass diff --git a/apps/api/api/routes/alerts.py b/apps/api/api/routes/alerts.py index 6e96604..07b5bb8 100644 --- a/apps/api/api/routes/alerts.py +++ b/apps/api/api/routes/alerts.py @@ -1,6 +1,6 @@ """ Alert rules CRUD + alert history endpoints. -All routes require x-api-key auth via authenticate_project dependency. +All routes require x-api-key auth via resolve_project_id dependency. """ from __future__ import annotations @@ -8,9 +8,9 @@ from datetime import datetime import asyncpg -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response -from api.deps import authenticate_project, get_db +from api.deps import resolve_project_id, get_db, scoped_conn from api.schemas import ( AlertHistoryOut, AlertRuleOut, @@ -42,8 +42,8 @@ def _rule_row_to_out(row: asyncpg.Record) -> AlertRuleOut: @router.get("/alert-rules", response_model=list[AlertRuleOut]) async def list_alert_rules( - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[AlertRuleOut]: rows = await conn.fetch( """ @@ -63,7 +63,7 @@ async def list_alert_rules( @router.post("/alert-rules", response_model=AlertRuleOut, status_code=201) async def create_alert_rule( body: CreateAlertRuleRequest, - project_id: str = Depends(authenticate_project), + project_id: str = Depends(resolve_project_id), conn: asyncpg.Connection = Depends(get_db), ) -> AlertRuleOut: row = await conn.fetchrow( @@ -92,7 +92,7 @@ async def create_alert_rule( async def update_alert_rule( rule_id: str, body: UpdateAlertRuleRequest, - project_id: str = Depends(authenticate_project), + project_id: str = Depends(resolve_project_id), conn: asyncpg.Connection = Depends(get_db), ) -> AlertRuleOut: existing = await conn.fetchrow( @@ -138,12 +138,12 @@ async def update_alert_rule( # ─── Delete ────────────────────────────────────────────────────────────────── -@router.delete("/alert-rules/{rule_id}", status_code=204) +@router.delete("/alert-rules/{rule_id}", status_code=204, response_model=None, response_class=Response) async def delete_alert_rule( rule_id: str, - project_id: str = Depends(authenticate_project), + project_id: str = Depends(resolve_project_id), conn: asyncpg.Connection = Depends(get_db), -) -> None: +) -> Response: result = await conn.execute( "DELETE FROM alert_rules WHERE id = $1 AND project_id = $2", rule_id, @@ -151,6 +151,7 @@ async def delete_alert_rule( ) if result == "DELETE 0": raise HTTPException(status_code=404, detail="Alert rule not found") + return Response(status_code=204) # ─── History ───────────────────────────────────────────────────────────────── @@ -166,8 +167,8 @@ def _duration_str(fired_at: datetime, resolved_at: datetime | None) -> str: @router.get("/alert-history", response_model=list[AlertHistoryOut]) async def get_alert_history( - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[AlertHistoryOut]: rows = await conn.fetch( """ diff --git a/apps/api/api/routes/ingest.py b/apps/api/api/routes/ingest.py index a3afd18..059b633 100644 --- a/apps/api/api/routes/ingest.py +++ b/apps/api/api/routes/ingest.py @@ -1,15 +1,34 @@ import logging +import time -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from api.deps import authenticate_project, get_redis_dep from api.schemas import IngestRequest, IngestResponse +from core.config import settings from streams.producer import produce_events router = APIRouter(prefix="/v1", tags=["ingest"]) logger = logging.getLogger(__name__) +async def _check_rate_limit(redis, project_id: str, n: int) -> None: + """Fixed-window per-project ingest limiter (Phase 8.5). Raises 429 on overage.""" + limit = settings.ingest_rate_limit_per_min + if limit <= 0: + return + window = int(time.time() // 60) + key = f"ratelimit:ingest:{project_id}:{window}" + current = await redis.incrby(key, n) + if current == n: # first write in this window — set TTL just past the minute + await redis.expire(key, 65) + if current > limit: + raise HTTPException( + status_code=429, + detail=f"Ingest rate limit exceeded ({limit} events/min)", + ) + + @router.post("/ingest", status_code=202, response_model=IngestResponse) async def ingest_events( body: IngestRequest, @@ -20,6 +39,7 @@ async def ingest_events( Accept a batch of API events and push them onto the Redis stream. Returns 202 immediately — processing is done by the aggregation worker. """ + await _check_rate_limit(redis, project_id, len(body.events)) await produce_events(project_id, body.events, redis) logger.debug("Accepted %d events for project %s", len(body.events), project_id) return IngestResponse(accepted=len(body.events)) diff --git a/apps/api/api/routes/internal.py b/apps/api/api/routes/internal.py new file mode 100644 index 0000000..6afcaaa --- /dev/null +++ b/apps/api/api/routes/internal.py @@ -0,0 +1,349 @@ +""" +Internal server-to-server endpoints for the Next.js dashboard (BFF). + +Guarded by `authenticate_internal` (INTERNAL_SERVICE_TOKEN, or the master key +as a dev fallback). NEVER called from the browser. + +Phase 8.2 — provisioning + access scope. +Phase 8.3 — self-serve project & API-key management (create / list / rotate / +delete), all authorized by org membership. +""" +from __future__ import annotations + +import hashlib +import logging +import re +import secrets + +import asyncpg +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel + +from api.deps import authenticate_internal, get_db, get_redis_dep +from realtime.tokens import mint_realtime_token + +router = APIRouter(prefix="/v1/internal", tags=["internal"]) +logger = logging.getLogger(__name__) + + +# ─── Schemas ───────────────────────────────────────────────────────────────── + +class ProvisionRequest(BaseModel): + email: str + name: str | None = None + + +class CreateProjectRequest(BaseModel): + email: str + name: str + + +class EmailBody(BaseModel): + email: str + + +class ProjectBrief(BaseModel): + id: str + name: str + org_id: str + + +class ProjectDetail(BaseModel): + id: str + name: str + org_id: str + api_key_masked: str + created_at: str + + +class ProvisionResponse(BaseModel): + user_id: str + org_id: str + projects: list[ProjectBrief] + new_api_key: str | None = None + + +class AccessResponse(BaseModel): + user_id: str | None + projects: list[ProjectBrief] + + +class CreatedProjectResponse(BaseModel): + project: ProjectBrief + new_api_key: str + + +class RotatedKeyResponse(BaseModel): + project_id: str + new_api_key: str + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def _slugify(text: str) -> str: + base = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + return base or "workspace" + + +def _generate_api_key() -> tuple[str, str]: + raw = f"lb_live_{secrets.token_urlsafe(32)}" + hashed = hashlib.sha256(raw.encode()).hexdigest() + return raw, hashed + + +def _mask(prefix: str | None) -> str: + return f"{prefix}{'•' * 20}" if prefix else "lb_live_" + "•" * 24 + + +async def _ensure_user_and_org( + db: asyncpg.Connection, email: str, name: str | None +) -> tuple[str, str]: + """Upsert the user and guarantee they own a personal org. Returns (user_id, org_id).""" + email = email.strip().lower() + display = (name or email.split("@")[0]).strip() + + user = await db.fetchrow( + """ + INSERT INTO users (email, name) + VALUES ($1, $2) + ON CONFLICT (email) DO UPDATE + SET name = COALESCE(EXCLUDED.name, users.name) + RETURNING id + """, + email, + name, + ) + user_id = user["id"] + + org = await db.fetchrow( + """ + SELECT o.id + FROM organizations o + JOIN memberships m ON m.org_id = o.id + WHERE m.user_id = $1 AND m.role = 'owner' + ORDER BY o.created_at + LIMIT 1 + """, + user_id, + ) + if org is None: + slug = f"{_slugify(display)}-{secrets.token_hex(3)}" + org = await db.fetchrow( + "INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id", + f"{display}'s workspace", + slug, + ) + await db.execute( + "INSERT INTO memberships (org_id, user_id, role) VALUES ($1, $2, 'owner')", + org["id"], + user_id, + ) + return str(user_id), str(org["id"]) + + +async def _create_project( + db: asyncpg.Connection, org_id: str, owner_id: str, name: str +) -> tuple[asyncpg.Record, str]: + """Create a project + its first API key (stored in api_keys). Returns (row, raw_key).""" + raw, hashed = _generate_api_key() + proj = await db.fetchrow( + """ + INSERT INTO projects (name, owner_id, org_id) + VALUES ($1, $2::uuid, $3::uuid) + RETURNING id, name, org_id + """, + name, + owner_id, + org_id, + ) + await db.execute( + "INSERT INTO api_keys (project_id, key_hash, name, prefix) VALUES ($1, $2, 'default', $3)", + proj["id"], + hashed, + raw[:12], + ) + return proj, raw + + +async def _require_member(db: asyncpg.Connection, email: str, project_id: str) -> str: + """Ensure `email` belongs to the org owning `project_id`. Returns user_id or 403.""" + row = await db.fetchrow( + """ + SELECT m.user_id + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = $1::uuid AND u.email = $2 + """, + project_id, + email.strip().lower(), + ) + if row is None: + raise HTTPException(status_code=403, detail="Not a member of this project's organization") + return str(row["user_id"]) + + +# ─── Provisioning (Phase 8.2) ──────────────────────────────────────────────── + +@router.post("/provision", response_model=ProvisionResponse) +async def provision( + body: ProvisionRequest, + db: asyncpg.Connection = Depends(get_db), + _: None = Depends(authenticate_internal), +) -> ProvisionResponse: + """Idempotently ensure user + personal org + at least one project. Safe on every login.""" + async with db.transaction(): + user_id, org_id = await _ensure_user_and_org(db, body.email, body.name) + rows = await db.fetch( + "SELECT id, name, org_id FROM projects WHERE org_id = $1::uuid ORDER BY created_at", + org_id, + ) + new_key: str | None = None + if not rows: + proj, raw = await _create_project(db, org_id, user_id, "My API") + new_key = raw + rows = [proj] + + projects = [ProjectBrief(id=str(r["id"]), name=r["name"], org_id=str(r["org_id"])) for r in rows] + logger.info("Provisioned user=%s org=%s projects=%d", body.email, org_id, len(projects)) + return ProvisionResponse(user_id=user_id, org_id=org_id, projects=projects, new_api_key=new_key) + + +@router.get("/access", response_model=AccessResponse) +async def access( + email: str, + db: asyncpg.Connection = Depends(get_db), + _: None = Depends(authenticate_internal), +) -> AccessResponse: + """Every project the user may read (membership scope) — the BFF's authz source of truth.""" + email = email.strip().lower() + user = await db.fetchrow("SELECT id FROM users WHERE email = $1", email) + if user is None: + return AccessResponse(user_id=None, projects=[]) + rows = await db.fetch( + """ + SELECT p.id, p.name, p.org_id + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = $1 + ORDER BY p.created_at + """, + user["id"], + ) + return AccessResponse( + user_id=str(user["id"]), + projects=[ProjectBrief(id=str(r["id"]), name=r["name"], org_id=str(r["org_id"])) for r in rows], + ) + + +# ─── Project & key management (Phase 8.3) ──────────────────────────────────── + +@router.get("/projects", response_model=list[ProjectDetail]) +async def list_projects( + email: str, + db: asyncpg.Connection = Depends(get_db), + _: None = Depends(authenticate_internal), +) -> list[ProjectDetail]: + """List the user's projects (across their orgs) with a masked key hint.""" + rows = await db.fetch( + """ + SELECT p.id, p.name, p.org_id, p.created_at, + (SELECT k.prefix FROM api_keys k + WHERE k.project_id = p.id AND k.revoked_at IS NULL + ORDER BY k.created_at DESC LIMIT 1) AS prefix + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE u.email = $1 + ORDER BY p.created_at + """, + email.strip().lower(), + ) + return [ + ProjectDetail( + id=str(r["id"]), + name=r["name"], + org_id=str(r["org_id"]), + api_key_masked=_mask(r["prefix"]), + created_at=r["created_at"].isoformat() if r["created_at"] else "", + ) + for r in rows + ] + + +@router.post("/projects", response_model=CreatedProjectResponse, status_code=201) +async def create_project( + body: CreateProjectRequest, + db: asyncpg.Connection = Depends(get_db), + _: None = Depends(authenticate_internal), +) -> CreatedProjectResponse: + """Create a project in the user's personal org and mint its first key (shown once).""" + name = body.name.strip() + if len(name) < 2: + raise HTTPException(status_code=422, detail="Project name must be at least 2 characters") + async with db.transaction(): + user_id, org_id = await _ensure_user_and_org(db, body.email, None) + proj, raw = await _create_project(db, org_id, user_id, name) + return CreatedProjectResponse( + project=ProjectBrief(id=str(proj["id"]), name=proj["name"], org_id=str(proj["org_id"])), + new_api_key=raw, + ) + + +@router.post("/projects/{project_id}/rotate", response_model=RotatedKeyResponse) +async def rotate_key( + project_id: str, + body: EmailBody, + db: asyncpg.Connection = Depends(get_db), + _: None = Depends(authenticate_internal), +) -> RotatedKeyResponse: + """Revoke the project's active keys and mint a fresh one (shown once).""" + await _require_member(db, body.email, project_id) + raw, hashed = _generate_api_key() + async with db.transaction(): + await db.execute( + "UPDATE api_keys SET revoked_at = now() WHERE project_id = $1::uuid AND revoked_at IS NULL", + project_id, + ) + await db.execute( + "INSERT INTO api_keys (project_id, key_hash, name, prefix) VALUES ($1::uuid, $2, 'default', $3)", + project_id, + hashed, + raw[:12], + ) + # keep the legacy column in sync (nullable) so old fallback never wins with a stale key + await db.execute("UPDATE projects SET api_key = NULL WHERE id = $1::uuid", project_id) + return RotatedKeyResponse(project_id=project_id, new_api_key=raw) + + +class RealtimeTokenRequest(BaseModel): + project_id: str + + +class RealtimeTokenResponse(BaseModel): + token: str + expires_in: int + + +@router.post("/realtime-token", response_model=RealtimeTokenResponse) +async def realtime_token( + body: RealtimeTokenRequest, + redis=Depends(get_redis_dep), + _: None = Depends(authenticate_internal), +) -> RealtimeTokenResponse: + """Mint a short-lived, project-scoped token for the browser's realtime connections.""" + token, ttl = await mint_realtime_token(redis, body.project_id) + return RealtimeTokenResponse(token=token, expires_in=ttl) + + +@router.delete("/projects/{project_id}", status_code=204, response_model=None, response_class=Response) +async def delete_project( + project_id: str, + email: str, + db: asyncpg.Connection = Depends(get_db), + _: None = Depends(authenticate_internal), +) -> Response: + """Delete a project (cascades its api_keys). Caller must be an org member.""" + await _require_member(db, email, project_id) + await db.execute("DELETE FROM projects WHERE id = $1::uuid", project_id) + return Response(status_code=204) diff --git a/apps/api/api/routes/query.py b/apps/api/api/routes/query.py index c4b06d9..f1b8a5b 100644 --- a/apps/api/api/routes/query.py +++ b/apps/api/api/routes/query.py @@ -1,16 +1,17 @@ """ Read-only analytics query endpoints. -All use x-api-key header auth via authenticate_project dependency. +All use x-api-key header auth via resolve_project_id dependency. """ from __future__ import annotations import datetime +import json import logging import asyncpg from fastapi import APIRouter, Depends, Query -from api.deps import authenticate_project, get_db +from api.deps import resolve_project_id, scoped_conn from api.schemas import ( EndpointStat, IncidentOut, @@ -29,17 +30,22 @@ @router.get("/metrics/timeseries", response_model=list[TimeseriesPoint]) async def get_timeseries( hours: int = Query(24, ge=1, le=168), - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[TimeseriesPoint]: """Request volume and status-code breakdown bucketed over time.""" - bucket = "5 minutes" if hours <= 6 else "15 minutes" if hours <= 24 else "1 hour" + # asyncpg encodes interval params from datetime.timedelta (not str). + bucket = ( + datetime.timedelta(minutes=5) if hours <= 6 + else datetime.timedelta(minutes=15) if hours <= 24 + else datetime.timedelta(hours=1) + ) fmt = "%H:%M" if hours <= 24 else "%b %d" rows = await conn.fetch( """ SELECT - time_bucket($1::interval, time) AS t, + time_bucket($1, time) AS t, COUNT(*)::int AS requests, SUM(CASE WHEN status_code < 400 THEN 1 ELSE 0 END)::int AS req_2xx, SUM(CASE WHEN status_code BETWEEN 400 AND 499 THEN 1 ELSE 0 END)::int AS req_4xx, @@ -76,8 +82,8 @@ async def get_timeseries( @router.get("/endpoints", response_model=list[EndpointStat]) async def get_endpoints( hours: int = Query(24, ge=1, le=168), - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[EndpointStat]: """Per-route aggregate stats with p50/p95/p99 latency percentiles.""" rows = await conn.fetch( @@ -132,8 +138,8 @@ async def get_endpoints( @router.get("/incidents", response_model=list[IncidentOut]) async def get_incidents( - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[IncidentOut]: """Recent incidents from the AI anomaly detection worker (Phase 5).""" rows = await conn.fetch( @@ -164,8 +170,8 @@ async def get_incidents( @router.get("/traces", response_model=list[TraceOut]) async def get_traces( - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[TraceOut]: """ Recent distributed traces assembled from the spans table. @@ -227,7 +233,8 @@ async def get_traces( start_time=max(0, span_start_ms - trace_start_ms), duration=int(row["duration_ms"] or 0), status="error" if (row["status_code"] and row["status_code"] >= 500) else "ok", - tags=dict(row["tags"] or {}), + # asyncpg returns JSONB as a string — parse it back to a dict. + tags=(json.loads(row["tags"]) if isinstance(row["tags"], str) else (row["tags"] or {})), ) ) @@ -276,8 +283,8 @@ def _service_name(prefix: str) -> str: @router.get("/services", response_model=list[ServiceStatusOut]) async def get_services( - project_id: str = Depends(authenticate_project), - conn: asyncpg.Connection = Depends(get_db), + project_id: str = Depends(resolve_project_id), + conn: asyncpg.Connection = Depends(scoped_conn), ) -> list[ServiceStatusOut]: """ Service health derived from route-prefix groups in event data. diff --git a/apps/api/core/config.py b/apps/api/core/config.py index f2da6b0..f44e66d 100644 --- a/apps/api/core/config.py +++ b/apps/api/core/config.py @@ -16,14 +16,35 @@ class Settings(BaseSettings): api_secret_key: SecretStr ingest_api_port: int = 8000 + # ── Internal service auth (BFF ↔ backend, Phase 8.2) ────────────────────── + # Trusted token the Next.js dashboard server uses to call /v1/internal/* + # and to make session-scoped reads. Empty in dev → the master key is + # accepted as a fallback (see api.deps.authenticate_internal). Set a + # dedicated strong value in production. + internal_service_token: SecretStr = SecretStr("") + # ── AI (Phase 5 — Cerebras) ─────────────────────────────────────────────── cerebras_api_key: SecretStr = SecretStr("") + # ── CORS / realtime origins (Phase 8.5) ────────────────────────────────── + # Comma-separated list of allowed browser origins (dashboard + status page). + # Only realtime (Socket.io / SSE) hits the API cross-origin — REST goes + # through the same-origin BFF. + cors_origins: str = "http://localhost:3000" + + # ── Per-project ingest rate limit (Phase 8.5) ───────────────────────────── + # Max events accepted per project per minute (fixed window). 0 = unlimited. + ingest_rate_limit_per_min: int = 120_000 + # ── Environment ─────────────────────────────────────────────────────────── environment: str = "development" model_config = SettingsConfigDict(env_file=".env", extra="ignore") + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + @field_validator("api_secret_key", mode="after") @classmethod def _strong_master_key(cls, v: SecretStr) -> SecretStr: diff --git a/apps/api/main.py b/apps/api/main.py index 40f9aff..a8242a4 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -8,7 +8,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import alerts, ingest, projects, query, spans +from api.routes import alerts, ingest, internal, projects, query, spans from core.database import close_pool, get_pool from core.redis_client import close_redis from realtime.pubsub import listen_pubsub @@ -56,15 +56,18 @@ async def lifespan(app: FastAPI): redoc_url=None, ) +from core.config import settings as _settings + _fastapi.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_settings.cors_origin_list, allow_methods=["POST", "GET", "OPTIONS"], allow_headers=["*"], ) _fastapi.include_router(ingest.router) _fastapi.include_router(projects.router) +_fastapi.include_router(internal.router) _fastapi.include_router(query.router) _fastapi.include_router(alerts.router) _fastapi.include_router(spans.router) diff --git a/apps/api/migrations/versions/003_multitenancy.py b/apps/api/migrations/versions/003_multitenancy.py new file mode 100644 index 0000000..601a2f2 --- /dev/null +++ b/apps/api/migrations/versions/003_multitenancy.py @@ -0,0 +1,86 @@ +"""Multi-tenancy: organizations, memberships, projects.org_id + +Revision ID: 003 +Revises: 002 +Create Date: 2026-07-18 + +Introduces the tenancy identity layer: + organizations — a tenant (a company / team / solo dev's workspace) + memberships — which users belong to which org, and their role + projects.org_id — every project now belongs to exactly one org + +Telemetry tables already carry project_id, so an org owns its data +transitively (org -> projects -> events/spans/incidents). This migration +only adds the *ownership* layer; read-time authorization is enforced in +the app (Phase 8.2) and, later, Postgres RLS (Phase 8.4). +""" + +from alembic import op + +revision = "003" +down_revision = "002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── Organizations (tenants) ────────────────────────────────────────────── + op.execute(""" + CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() + ); + """) + + # ── Memberships (user ↔ org, with role) ────────────────────────────────── + op.execute(""" + CREATE TABLE IF NOT EXISTS memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('owner', 'admin', 'member')), + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (org_id, user_id) + ); + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_memberships_user ON memberships(user_id);") + op.execute("CREATE INDEX IF NOT EXISTS idx_memberships_org ON memberships(org_id);") + + # ── projects now belong to an org ──────────────────────────────────────── + op.execute(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS org_id UUID + REFERENCES organizations(id) ON DELETE CASCADE; + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id);") + + # ── Backfill: adopt any pre-existing projects into a 'Default' org ──────── + # Keeps a single-tenant install working after the upgrade. + op.execute(""" + INSERT INTO organizations (name, slug) + SELECT 'Default', 'default' + WHERE NOT EXISTS (SELECT 1 FROM organizations WHERE slug = 'default'); + """) + op.execute(""" + UPDATE projects + SET org_id = (SELECT id FROM organizations WHERE slug = 'default') + WHERE org_id IS NULL; + """) + # If a project already has an owner_id, seat that user as org owner. + op.execute(""" + INSERT INTO memberships (org_id, user_id, role) + SELECT DISTINCT p.org_id, p.owner_id, 'owner' + FROM projects p + WHERE p.owner_id IS NOT NULL + AND p.org_id IS NOT NULL + ON CONFLICT (org_id, user_id) DO NOTHING; + """) + + +def downgrade() -> None: + op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS org_id;") + op.execute("DROP TABLE IF EXISTS memberships;") + op.execute("DROP TABLE IF EXISTS organizations;") diff --git a/apps/api/migrations/versions/004_api_keys.py b/apps/api/migrations/versions/004_api_keys.py new file mode 100644 index 0000000..6defb75 --- /dev/null +++ b/apps/api/migrations/versions/004_api_keys.py @@ -0,0 +1,54 @@ +"""API keys table — multiple keys per project, rotation + revocation + +Revision ID: 004 +Revises: 003 +Create Date: 2026-07-18 + +Moves API-key auth from the single `projects.api_key` column to a dedicated +`api_keys` table so a project can have multiple keys, rotate, and revoke. +`projects.api_key` is kept (now nullable) as a legacy fallback for auth. +""" + +from alembic import op + +revision = "004" +down_revision = "003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + key_hash TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT 'default', + prefix TEXT, -- display hint, e.g. 'lb_live_eyA2' + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() + ); + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_project ON api_keys(project_id);") + op.execute( + "CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash) " + "WHERE revoked_at IS NULL;" + ) + + # Backfill existing project keys into the new table. + op.execute(""" + INSERT INTO api_keys (project_id, key_hash, name) + SELECT id, api_key, 'default' + FROM projects + WHERE api_key IS NOT NULL + ON CONFLICT (key_hash) DO NOTHING; + """) + + # New projects store their keys in api_keys; the column is now optional. + op.execute("ALTER TABLE projects ALTER COLUMN api_key DROP NOT NULL;") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS api_keys;") + # Leaves projects.api_key nullable — safe (existing rows keep their value). diff --git a/apps/api/migrations/versions/005_rls.py b/apps/api/migrations/versions/005_rls.py new file mode 100644 index 0000000..b153919 --- /dev/null +++ b/apps/api/migrations/versions/005_rls.py @@ -0,0 +1,64 @@ +"""Row-Level Security backstop for tenant isolation + +Revision ID: 005 +Revises: 004 +Create Date: 2026-07-18 + +Defense-in-depth: even if an app query forgets its `WHERE project_id = …`, +the database refuses to return another tenant's rows. + +Design (single DB owner + a multi-project worker): + • A restricted, NOLOGIN role `dashboard_reader` with SELECT only. + • Dashboard READ connections `SET ROLE dashboard_reader` + set the + `app.project_id` GUC (see api.deps.scoped_conn). Non-owner ⇒ RLS is + enforced ⇒ only rows for the active project are visible. + • The owner (ingest worker, alert writes) is NOT forced under RLS, so + cross-project writes/aggregation keep working. + +A read that forgets to set `app.project_id` sees ZERO rows (fail closed). +""" + +from alembic import op + +revision = "005" +down_revision = "004" +branch_labels = None +depends_on = None + +_TABLES = ["events", "spans", "incidents", "alert_rules", "alert_history"] + + +def upgrade() -> None: + # Restricted read role (no login — reached only via SET ROLE by the owner). + op.execute(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_reader') THEN + CREATE ROLE dashboard_reader NOLOGIN; + END IF; + END $$; + """) + # Let the app's owner role assume dashboard_reader. + op.execute("DO $$ BEGIN EXECUTE 'GRANT dashboard_reader TO ' || current_user; END $$;") + op.execute("GRANT USAGE ON SCHEMA public TO dashboard_reader;") + + for t in _TABLES: + op.execute(f"GRANT SELECT ON {t} TO dashboard_reader;") + op.execute(f"ALTER TABLE {t} ENABLE ROW LEVEL SECURITY;") + # Scoped SELECT policy — applies only to dashboard_reader; the owner + # (worker/ingest/alert writes) is unaffected. + op.execute(f""" + CREATE POLICY {t}_tenant_isolation ON {t} + FOR SELECT + TO dashboard_reader + USING (project_id = NULLIF(current_setting('app.project_id', true), '')::uuid); + """) + + +def downgrade() -> None: + for t in _TABLES: + op.execute(f"DROP POLICY IF EXISTS {t}_tenant_isolation ON {t};") + op.execute(f"ALTER TABLE {t} DISABLE ROW LEVEL SECURITY;") + op.execute(f"REVOKE SELECT ON {t} FROM dashboard_reader;") + op.execute("REVOKE USAGE ON SCHEMA public FROM dashboard_reader;") + # Role left in place (may be referenced elsewhere); drop manually if needed. diff --git a/apps/api/realtime/socket_server.py b/apps/api/realtime/socket_server.py index 6e01b92..38bc67b 100644 --- a/apps/api/realtime/socket_server.py +++ b/apps/api/realtime/socket_server.py @@ -16,39 +16,55 @@ import socketio +from core.config import settings + logger = logging.getLogger(__name__) # Async Socket.io server — wraps FastAPI via socketio.ASGIApp in main.py sio = socketio.AsyncServer( async_mode="asgi", - cors_allowed_origins="*", + cors_allowed_origins=settings.cors_origin_list, logger=False, engineio_logger=False, ) async def _resolve_project(api_key: str) -> str | None: - """SHA-256 key lookup — same logic as api/deps.py authenticate_project.""" + """SHA-256 key lookup — api_keys (non-revoked) first, legacy column fallback.""" from core.database import get_pool hashed = hashlib.sha256(api_key.encode()).hexdigest() pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( - "SELECT id FROM projects WHERE api_key = $1", hashed + "SELECT project_id FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL", + hashed, ) - return str(row["id"]) if row else None + if row is None: + row = await conn.fetchrow( + "SELECT id AS project_id FROM projects WHERE api_key = $1", hashed + ) + return str(row["project_id"]) if row else None @sio.event async def connect(sid: str, environ: dict, auth: dict | None) -> None: - api_key: str = (auth or {}).get("api_key", "") - if not api_key: - raise ConnectionRefusedError("api_key required") + auth = auth or {} + token = auth.get("token", "") + api_key = auth.get("api_key", "") + + project_id: str | None = None + if token: + # Browser path: short-lived, project-scoped realtime token (Phase 8.4). + from core.redis_client import get_redis + from realtime.tokens import resolve_realtime_token + + project_id = await resolve_realtime_token(await get_redis(), token) + elif api_key: + project_id = await _resolve_project(api_key) - project_id = await _resolve_project(api_key) if not project_id: - raise ConnectionRefusedError("invalid api_key") + raise ConnectionRefusedError("valid token or api_key required") await sio.enter_room(sid, f"project:{project_id}") await sio.save_session(sid, {"project_id": project_id}) diff --git a/apps/api/realtime/sse.py b/apps/api/realtime/sse.py index cf01f57..73055fd 100644 --- a/apps/api/realtime/sse.py +++ b/apps/api/realtime/sse.py @@ -41,23 +41,42 @@ async def _authenticate_sse( x_api_key: str | None = Header(default=None), api_key: str | None = Query(default=None), + token: str | None = Query(default=None), + redis: aioredis.Redis = Depends(get_redis), ) -> str: - """Resolve project_id from header or query-param API key.""" + """ + Resolve project_id from, in order: + • a short-lived realtime token (browser, Phase 8.4), or + • an x-api-key header / ?api_key= query param (SDK/programmatic). + """ import hashlib + from realtime.tokens import resolve_realtime_token + + if token: + project_id = await resolve_realtime_token(redis, token) + if project_id: + return project_id + raise HTTPException(status_code=401, detail="Invalid or expired token") + raw_key = x_api_key or api_key if not raw_key: - raise HTTPException(status_code=401, detail="api_key required") + raise HTTPException(status_code=401, detail="token or api_key required") hashed = hashlib.sha256(raw_key.encode()).hexdigest() pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( - "SELECT id FROM projects WHERE api_key = $1", hashed + "SELECT project_id FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL", + hashed, ) + if row is None: + row = await conn.fetchrow( + "SELECT id AS project_id FROM projects WHERE api_key = $1", hashed + ) if not row: raise HTTPException(status_code=401, detail="Invalid API key") - return str(row["id"]) + return str(row["project_id"]) # ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/apps/api/realtime/tokens.py b/apps/api/realtime/tokens.py new file mode 100644 index 0000000..3bbe38d --- /dev/null +++ b/apps/api/realtime/tokens.py @@ -0,0 +1,29 @@ +""" +Short-lived, project-scoped realtime tokens (Phase 8.4). + +The dashboard BFF (which knows the signed-in user's projects) asks the backend +to mint a token for a specific project; the browser then uses that opaque token +for the Socket.io handshake and the SSE stream. No API key ever reaches the +browser. Tokens live in Redis with a TTL and can be revoked by deletion. +""" +from __future__ import annotations + +import secrets + +RT_PREFIX = "rt:" +RT_TTL_SECONDS = 1800 # 30 minutes + + +async def mint_realtime_token(redis, project_id: str, ttl: int = RT_TTL_SECONDS) -> tuple[str, int]: + token = secrets.token_urlsafe(24) + await redis.set(f"{RT_PREFIX}{token}", project_id, ex=ttl) + return token, ttl + + +async def resolve_realtime_token(redis, token: str) -> str | None: + if not token: + return None + val = await redis.get(f"{RT_PREFIX}{token}") + if val is None: + return None + return val.decode() if isinstance(val, (bytes, bytearray)) else str(val) From 52a52f3eb7bb6204c68d504e127aa3d05b597b7e Mon Sep 17 00:00:00 2001 From: ryzrr Date: Sat, 18 Jul 2026 22:37:28 +0530 Subject: [PATCH 3/5] feat(web): session-scoped BFF, real projects, honest data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BFF routes: /api/projects (list/create/delete/rotate), /api/lb/* read proxy (session -> membership check -> backend with internal token), /api/realtime-token - Provision user+org on login (auth.ts); session carries allowed project ids - ProjectProvider now backend-backed (real projects, no localStorage mock); create dialog + switcher use the real key lifecycle - Dashboard reads go through the BFF scoped to the active project (no browser key) - Realtime (metrics/incidents/live-log) use short-lived project-scoped tokens - Honesty: a real project never shows synthetic/mock data — real values or an empty state (stat cards, traces, and all useApiQuery-backed panels) Co-Authored-By: Claude Opus 4.8 --- app/(dashboard)/overview/page.tsx | 9 +- app/(dashboard)/traces/page.tsx | 10 +- app/api/lb/[...path]/route.ts | 62 ++++++ app/api/projects/[id]/rotate/route.ts | 29 +++ app/api/projects/[id]/route.ts | 23 +++ app/api/projects/route.ts | 44 ++++ app/api/realtime-token/route.ts | 32 +++ auth.ts | 69 ++++++- components/projects/create-project-dialog.tsx | 22 +- components/providers/project-provider.tsx | 188 +++++++++--------- hooks/use-api-query.ts | 29 ++- hooks/use-data.ts | 20 +- hooks/use-live-metrics.ts | 33 ++- hooks/use-websocket.ts | 101 ++++++---- lib/api-client.ts | 45 ++--- lib/realtime.ts | 17 ++ lib/socket.ts | 20 +- types/next-auth.d.ts | 12 ++ 18 files changed, 563 insertions(+), 202 deletions(-) create mode 100644 app/api/lb/[...path]/route.ts create mode 100644 app/api/projects/[id]/rotate/route.ts create mode 100644 app/api/projects/[id]/route.ts create mode 100644 app/api/projects/route.ts create mode 100644 app/api/realtime-token/route.ts create mode 100644 lib/realtime.ts diff --git a/app/(dashboard)/overview/page.tsx b/app/(dashboard)/overview/page.tsx index 552ae3a..cb62e8c 100644 --- a/app/(dashboard)/overview/page.tsx +++ b/app/(dashboard)/overview/page.tsx @@ -23,10 +23,11 @@ export default function OverviewPage() { const { data: chartData } = useChartData(hours); const { data: incidents } = useIncidents(); - // Live stat cards + incidents via Socket.io — falls back to placeholders while connecting - const apiKey = process.env.NEXT_PUBLIC_LIVEBOARD_API_KEY ?? null; - const cards = useMetrics(apiKey); - const liveIncidents = useIncidentSocket(apiKey); + // Live stat cards + incidents via Socket.io — project-scoped realtime token + // fetched from the BFF; falls back to demo data while connecting / keyless. + const projectId = activeProject?.id ?? null; + const cards = useMetrics(projectId); + const liveIncidents = useIncidentSocket(projectId); return (
diff --git a/app/(dashboard)/traces/page.tsx b/app/(dashboard)/traces/page.tsx index bae0d6d..cbf9507 100644 --- a/app/(dashboard)/traces/page.tsx +++ b/app/(dashboard)/traces/page.tsx @@ -37,9 +37,15 @@ export default function TracesPage() { const { data: traces } = useTraces(); const [selectedTrace, setSelectedTrace] = useState(null); - // Auto-select first trace when data first arrives + // Keep the selection in sync with the current trace list: clear it when the + // list is empty (e.g. a real project with no spans, or after switching + // projects), and pick the first trace when the current one is gone. useEffect(() => { - if (traces.length > 0 && !selectedTrace) setSelectedTrace(traces[0]); + if (traces.length === 0) { + setSelectedTrace(null); + } else if (!selectedTrace || !traces.some((t) => t.id === selectedTrace.id)) { + setSelectedTrace(traces[0]); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [traces]); const [selectedSpan, setSelectedSpan] = useState(null); diff --git a/app/api/lb/[...path]/route.ts b/app/api/lb/[...path]/route.ts new file mode 100644 index 0000000..2eab36d --- /dev/null +++ b/app/api/lb/[...path]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +/** + * Session-scoped read/write proxy for dashboard telemetry. + * + * browser (no key) → here (NextAuth session) → backend (internal token + project) + * + * Enforces that the requested `?project=` belongs to the signed-in user + * (via `session.projectIds`), then forwards to the FastAPI backend with the + * server-only internal token and an explicit `x-project-id`. The browser never + * holds a project API key or the internal token. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +async function proxy(req: Request, ctx: { params: Promise<{ path: string[] }> }) { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const reqUrl = new URL(req.url); + const project = reqUrl.searchParams.get("project"); + if (!project) return NextResponse.json({ error: "missing_project" }, { status: 400 }); + if (!session.projectIds?.includes(project)) { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + + const { path } = await ctx.params; + reqUrl.searchParams.delete("project"); + const qs = reqUrl.searchParams.toString(); + const target = `${API_URL}/${path.join("/")}${qs ? `?${qs}` : ""}`; + + const init: RequestInit = { + method: req.method, + headers: { + "x-internal-token": internalToken(), + "x-project-id": project, + "content-type": "application/json", + }, + cache: "no-store", + }; + if (req.method !== "GET" && req.method !== "HEAD") { + const body = await req.text(); + if (body) init.body = body; + } + + const res = await fetch(target, init); + const text = await res.text(); + return new NextResponse(text || null, { + status: res.status, + headers: { "content-type": res.headers.get("content-type") ?? "application/json" }, + }); +} + +export const GET = proxy; +export const POST = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/app/api/projects/[id]/rotate/route.ts b/app/api/projects/[id]/rotate/route.ts new file mode 100644 index 0000000..812c602 --- /dev/null +++ b/app/api/projects/[id]/rotate/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +export async function POST( + _req: Request, + ctx: { params: Promise<{ id: string }> } +) { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const { id } = await ctx.params; + const res = await fetch( + `${API_URL}/v1/internal/projects/${encodeURIComponent(id)}/rotate`, + { + method: "POST", + headers: { "content-type": "application/json", "x-internal-token": internalToken() }, + body: JSON.stringify({ email }), + cache: "no-store", + } + ); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..5d99df4 --- /dev/null +++ b/app/api/projects/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +export async function DELETE( + _req: Request, + ctx: { params: Promise<{ id: string }> } +) { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const { id } = await ctx.params; + const res = await fetch( + `${API_URL}/v1/internal/projects/${encodeURIComponent(id)}?email=${encodeURIComponent(email)}`, + { method: "DELETE", headers: { "x-internal-token": internalToken() }, cache: "no-store" } + ); + return new NextResponse(null, { status: res.ok ? 204 : res.status }); +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..6a36a51 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +// BFF: dashboard → here (session-authed) → backend internal endpoints (token-authed). +// The browser never sees the internal token or a project API key. + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +export async function GET() { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const res = await fetch( + `${API_URL}/v1/internal/projects?email=${encodeURIComponent(email)}`, + { headers: { "x-internal-token": internalToken() }, cache: "no-store" } + ); + if (!res.ok) return NextResponse.json({ error: "backend_error" }, { status: 502 }); + return NextResponse.json(await res.json()); +} + +export async function POST(req: Request) { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const body = await req.json().catch(() => ({})); + const name = String(body?.name ?? "").trim(); + if (name.length < 2) { + return NextResponse.json({ error: "invalid_name" }, { status: 422 }); + } + + const res = await fetch(`${API_URL}/v1/internal/projects`, { + method: "POST", + headers: { "content-type": "application/json", "x-internal-token": internalToken() }, + body: JSON.stringify({ email, name }), + cache: "no-store", + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/app/api/realtime-token/route.ts b/app/api/realtime-token/route.ts new file mode 100644 index 0000000..805275f --- /dev/null +++ b/app/api/realtime-token/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +// Mints a short-lived, project-scoped realtime token for the browser's +// Socket.io / SSE connections — only after verifying the signed-in user is a +// member of the requested project. The browser never holds an API key. + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +export async function GET(req: Request) { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const project = new URL(req.url).searchParams.get("project"); + if (!project) return NextResponse.json({ error: "missing_project" }, { status: 400 }); + if (!session.projectIds?.includes(project)) { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + + const res = await fetch(`${API_URL}/v1/internal/realtime-token`, { + method: "POST", + headers: { "content-type": "application/json", "x-internal-token": internalToken() }, + body: JSON.stringify({ project_id: project }), + cache: "no-store", + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/auth.ts b/auth.ts index fd2b89e..05a37a4 100644 --- a/auth.ts +++ b/auth.ts @@ -2,6 +2,45 @@ import NextAuth from "next-auth"; import Google from "next-auth/providers/google"; import Credentials from "next-auth/providers/credentials"; +interface ProvisionResult { + user_id: string; + org_id: string; + projects: { id: string; name: string; org_id: string }[]; + new_api_key?: string | null; +} + +/** + * Server-only: ensure the user has a backend user + org + project. + * Uses a trusted internal token (falls back to the master key in dev). + * Never call from the browser — this runs only in NextAuth callbacks. + */ +async function provisionUser(email: string, name?: string): Promise { + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + const token = process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY; + if (!token) { + console.warn("[auth] No INTERNAL_SERVICE_TOKEN / API_SECRET_KEY set — skipping provisioning"); + return null; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch(`${apiUrl}/v1/internal/provision`, { + method: "POST", + headers: { "content-type": "application/json", "x-internal-token": token }, + body: JSON.stringify({ email, name }), + signal: controller.signal, + }); + if (!res.ok) { + console.error("[auth] provision returned", res.status); + return null; + } + return (await res.json()) as ProvisionResult; + } finally { + clearTimeout(timeout); + } +} + export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET @@ -41,6 +80,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ session: { strategy: "jwt" }, callbacks: { + // Provision a real user + org + project in the backend on sign-in, and + // record the projects this user may read. Never blocks login on failure. + async jwt({ token, user }) { + if (user?.email) { + try { + const result = await provisionUser(user.email, user.name ?? undefined); + if (result) { + token.userId = result.user_id; + token.projectIds = result.projects.map((p) => p.id); + token.defaultProjectId = result.projects[0]?.id; + } + } catch (err) { + console.error("[auth] provisionUser failed (non-fatal):", err); + } + } + return token; + }, + async signIn({ user }) { const allowedEmails = (process.env.ALLOWED_EMAILS ?? "") .split(",") @@ -59,9 +116,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, async session({ session, token }) { - if (session.user && token.sub) { - session.user.id = token.sub; + const t = token as { + userId?: string; + projectIds?: string[]; + defaultProjectId?: string; + sub?: string; + }; + if (session.user) { + session.user.id = t.userId ?? t.sub ?? session.user.id; } + session.projectIds = t.projectIds; + session.defaultProjectId = t.defaultProjectId; return session; }, }, diff --git a/components/projects/create-project-dialog.tsx b/components/projects/create-project-dialog.tsx index 9361319..8c43862 100644 --- a/components/projects/create-project-dialog.tsx +++ b/components/projects/create-project-dialog.tsx @@ -18,6 +18,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { const [step, setStep] = useState("form"); const [name, setName] = useState(""); const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); const inputRef = useRef(null); @@ -31,10 +32,11 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { setError(""); setResult(null); setCopied(false); + setSubmitting(false); }, 200); } - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const trimmed = name.trim(); if (!trimmed) { @@ -49,9 +51,17 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { setError("Name must be 60 characters or less."); return; } - const created = createProject(trimmed); - setResult(created); - setStep("apikey"); + setSubmitting(true); + setError(""); + try { + const created = await createProject(trimmed); + setResult(created); + setStep("apikey"); + } catch { + setError("Couldn't create project. Please try again."); + } finally { + setSubmitting(false); + } } const copyKey = useCallback(() => { @@ -113,8 +123,8 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { -
diff --git a/components/providers/project-provider.tsx b/components/providers/project-provider.tsx index 68e6577..5180cc7 100644 --- a/components/providers/project-provider.tsx +++ b/components/providers/project-provider.tsx @@ -14,7 +14,7 @@ import { useSession } from "next-auth/react"; export interface Project { id: string; name: string; - /** Masked display-only key: lb_live_abcd1234...ef12 */ + /** Masked display-only key hint: lb_live_abcd•••• */ apiKeyMasked: string; createdAt: string; } @@ -28,144 +28,148 @@ export interface CreateProjectResult { interface ProjectContextValue { projects: Project[]; activeProject: Project | null; + loading: boolean; setActiveProject: (project: Project) => void; - createProject: (name: string) => CreateProjectResult; - deleteProject: (id: string) => void; + createProject: (name: string) => Promise; + deleteProject: (id: string) => Promise; + rotateKey: (id: string) => Promise; + refresh: () => Promise; } -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function randomHex(bytes: number): string { - const buf = new Uint8Array(bytes); - crypto.getRandomValues(buf); - return Array.from(buf) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -function generateApiKey(): { raw: string; masked: string } { - const hex = randomHex(32); - return { - raw: `lb_live_${hex}`, - masked: `lb_live_${hex.slice(0, 8)}...${hex.slice(-4)}`, - }; -} - -function generateId(): string { - return randomHex(16); +// Backend `ProjectDetail` shape (snake_case) returned by the BFF. +interface ProjectDetailDTO { + id: string; + name: string; + org_id: string; + api_key_masked: string; + created_at: string; } -function storageKey(userId: string) { - return `lb_projects_${userId}`; +function fromDTO(d: ProjectDetailDTO): Project { + return { id: d.id, name: d.name, apiKeyMasked: d.api_key_masked, createdAt: d.created_at }; } -function activeKey(userId: string) { - return `lb_active_${userId}`; +function maskRaw(raw: string): string { + return `${raw.slice(0, 12)}${"•".repeat(20)}`; } // ─── Context ───────────────────────────────────────────────────────────────── const ProjectContext = createContext(null); +function activeStorageKey(userId: string) { + return `lb_active_${userId}`; +} + export function ProjectProvider({ children }: { children: React.ReactNode }) { - const { data: session } = useSession(); + const { data: session, status } = useSession(); const userId = session?.user?.id ?? session?.user?.email ?? null; const [projects, setProjects] = useState([]); const [activeProjectId, setActiveProjectId] = useState(null); + const [loading, setLoading] = useState(true); - // Load from localStorage when userId becomes available - useEffect(() => { + const refresh = useCallback(async () => { if (!userId) return; - - const raw = localStorage.getItem(storageKey(userId)); - const stored: Project[] = raw ? (JSON.parse(raw) as Project[]) : []; - - if (stored.length === 0) { - // Seed a default project on first sign-in - const { raw: rawKey, masked } = generateApiKey(); - const defaultProject: Project = { - id: generateId(), - name: "My API", - apiKeyMasked: masked, - createdAt: new Date().toISOString(), - }; - // We discard rawKey here — user would use "Create project" flow for new ones - void rawKey; - const seeded = [defaultProject]; - localStorage.setItem(storageKey(userId), JSON.stringify(seeded)); - setProjects(seeded); - setActiveProjectId(defaultProject.id); - localStorage.setItem(activeKey(userId), defaultProject.id); - return; + setLoading(true); + try { + const res = await fetch("/api/projects", { cache: "no-store" }); + if (!res.ok) return; + const data = (await res.json()) as ProjectDetailDTO[]; + const mapped = data.map(fromDTO); + setProjects(mapped); + setActiveProjectId((prev) => { + if (prev && mapped.some((p) => p.id === prev)) return prev; + const saved = localStorage.getItem(activeStorageKey(userId)); + if (saved && mapped.some((p) => p.id === saved)) return saved; + return mapped[0]?.id ?? null; + }); + } catch { + // network/backend down — leave projects as-is + } finally { + setLoading(false); } - - setProjects(stored); - - const savedActive = localStorage.getItem(activeKey(userId)); - const validActive = stored.find((p) => p.id === savedActive); - setActiveProjectId(validActive ? validActive.id : stored[0].id); }, [userId]); - const persist = useCallback( - (updated: Project[], newActiveId?: string) => { - if (!userId) return; - localStorage.setItem(storageKey(userId), JSON.stringify(updated)); - if (newActiveId !== undefined) { - localStorage.setItem(activeKey(userId), newActiveId); - } - }, - [userId] - ); + // Load once the session is known. + useEffect(() => { + if (status === "loading") return; + if (userId) { + void refresh(); + } else { + setProjects([]); + setActiveProjectId(null); + setLoading(false); + } + }, [status, userId, refresh]); const setActiveProject = useCallback( (project: Project) => { setActiveProjectId(project.id); - if (userId) localStorage.setItem(activeKey(userId), project.id); + if (userId) localStorage.setItem(activeStorageKey(userId), project.id); }, [userId] ); const createProject = useCallback( - (name: string): CreateProjectResult => { - const { raw, masked } = generateApiKey(); + async (name: string): Promise => { + const res = await fetch("/api/projects", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (!res.ok) throw new Error("Failed to create project"); + const data = (await res.json()) as { + project: { id: string; name: string; org_id: string }; + new_api_key: string; + }; + await refresh(); + setActiveProjectId(data.project.id); + if (userId) localStorage.setItem(activeStorageKey(userId), data.project.id); const project: Project = { - id: generateId(), - name: name.trim(), - apiKeyMasked: masked, + id: data.project.id, + name: data.project.name, + apiKeyMasked: maskRaw(data.new_api_key), createdAt: new Date().toISOString(), }; - setProjects((prev) => { - const updated = [...prev, project]; - persist(updated, project.id); - return updated; - }); - setActiveProjectId(project.id); - return { project, rawApiKey: raw }; + return { project, rawApiKey: data.new_api_key }; }, - [persist] + [refresh, userId] ); const deleteProject = useCallback( - (id: string) => { - setProjects((prev) => { - const updated = prev.filter((p) => p.id !== id); - const newActive = - activeProjectId === id ? (updated[0]?.id ?? null) : activeProjectId; - persist(updated, newActive ?? undefined); - if (newActive !== activeProjectId) setActiveProjectId(newActive); - return updated; - }); + async (id: string): Promise => { + const res = await fetch(`/api/projects/${id}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) throw new Error("Failed to delete project"); + setActiveProjectId((prev) => (prev === id ? null : prev)); + await refresh(); }, - [activeProjectId, persist] + [refresh] ); + const rotateKey = useCallback(async (id: string): Promise => { + const res = await fetch(`/api/projects/${id}/rotate`, { method: "POST" }); + if (!res.ok) throw new Error("Failed to rotate key"); + const data = (await res.json()) as { new_api_key: string }; + await refresh(); + return data.new_api_key; + }, [refresh]); + const activeProject = projects.find((p) => p.id === activeProjectId) ?? projects[0] ?? null; return ( {children} diff --git a/hooks/use-api-query.ts b/hooks/use-api-query.ts index 5435fd0..103b5b9 100644 --- a/hooks/use-api-query.ts +++ b/hooks/use-api-query.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { apiFetch } from "@/lib/api-client"; +import { useProjects } from "@/components/providers/project-provider"; /** * Generic hook for REST API data. @@ -17,12 +18,18 @@ export function useApiQuery( transform?: (raw: unknown) => T, pollMs?: number, ): { data: T; loading: boolean; refetch: () => void } { + const { activeProject } = useProjects(); + const projectId = activeProject?.id ?? null; + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [tick, setTick] = useState(0); + // Scope every request to the active project (the BFF verifies membership). + const effectiveParams = projectId ? { ...params, project: projectId } : params; + // Stable string key — avoids object-reference churn in the dependency array - const paramKey = Object.entries(params) + const paramKey = Object.entries(effectiveParams) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}=${v}`) .join("&"); @@ -38,7 +45,15 @@ export function useApiQuery( setLoading(true); } - apiFetch(path, params) + // No active project yet → show fallback (demo) instead of a scopeless call. + if (!projectId) { + setLoading(false); + return () => { + active = false; + }; + } + + apiFetch(path, effectiveParams) .then((raw) => { if (!active) return; setData(transform ? transform(raw) : (raw as T)); @@ -53,7 +68,7 @@ export function useApiQuery( active = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [path, paramKey, tick]); + }, [path, paramKey, tick, projectId]); // Reset tick (and clear stale data) whenever the query params change const prevParamKey = useRef(paramKey); @@ -72,5 +87,11 @@ export function useApiQuery( return () => clearInterval(id); }, [pollMs]); - return { data: data ?? fallback, loading, refetch }; + // Honesty rule: a REAL project never shows the demo `fallback`. While loading + // or on error it shows an empty result (empty state), not fabricated data. + // The demo `fallback` is only used when there's no project (landing / loading). + const emptyFallback = (Array.isArray(fallback) ? ([] as unknown as T) : fallback); + const effectiveFallback = projectId ? emptyFallback : fallback; + + return { data: data ?? effectiveFallback, loading, refetch }; } diff --git a/hooks/use-data.ts b/hooks/use-data.ts index 32c6b30..420fd2c 100644 --- a/hooks/use-data.ts +++ b/hooks/use-data.ts @@ -11,6 +11,7 @@ import { useMemo } from "react"; import { useApiQuery } from "@/hooks/use-api-query"; import { apiMutate } from "@/lib/api-client"; +import { useProjects } from "@/components/providers/project-provider"; import { generateChartData, getAlertRules, @@ -188,14 +189,9 @@ export function useEndpoints(hours = 24) { */ export function useTraces() { const mockTraces = useMemo(() => getTraces(), []); - const { data, loading } = useApiQuery( - "/v1/traces", - {}, - mockTraces, - toTraces, - ); - // Empty spans table → real data is [] → keep mock so UI is never blank - return { data: data.length > 0 ? data : mockTraces, loading }; + // Real project → shows real spans (empty when none). Only the no-project + // (landing) case falls back to the demo traces, handled inside useApiQuery. + return useApiQuery("/v1/traces", {}, mockTraces, toTraces); } /** Service health derived from route-prefix groups — used by the status page. */ @@ -284,20 +280,22 @@ function toAlertHistory(raw: unknown): AlertHistoryEntry[] { /** Alert rules for the current project — with create/toggle helpers. */ export function useAlertRules() { + const { activeProject } = useProjects(); + const project = activeProject?.id; const fallback = useMemo(() => getAlertRules(), []); const query = useApiQuery("/v1/alert-rules", {}, fallback, toAlertRules); async function createRule(payload: CreateAlertRulePayload): Promise { - const raw = await apiMutate("POST", "/v1/alert-rules", payload); + const raw = await apiMutate("POST", "/v1/alert-rules", payload, project); return toAlertRule(raw); } async function toggleRule(id: string, enabled: boolean): Promise { - await apiMutate("PATCH", `/v1/alert-rules/${id}`, { enabled }); + await apiMutate("PATCH", `/v1/alert-rules/${id}`, { enabled }, project); } async function deleteRule(id: string): Promise { - await apiMutate("DELETE", `/v1/alert-rules/${id}`); + await apiMutate("DELETE", `/v1/alert-rules/${id}`, undefined, project); } return { ...query, createRule, toggleRule, deleteRule }; diff --git a/hooks/use-live-metrics.ts b/hooks/use-live-metrics.ts index 0a83d48..e122094 100644 --- a/hooks/use-live-metrics.ts +++ b/hooks/use-live-metrics.ts @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import { useProjects } from "@/components/providers/project-provider"; +import { fetchRealtimeToken } from "@/lib/realtime"; import type { LogEntry } from "@/lib/types"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; @@ -56,12 +58,16 @@ export function useLiveLog(maxEntries = LOG_BUFFER) { const timerRef = useRef | null>(null); const mockRef = useRef | null>(null); + const { activeProject } = useProjects(); + const projectId = activeProject?.id ?? null; + useEffect(() => { pausedRef.current = paused; }, [paused]); useEffect(() => { - const apiKey = process.env.NEXT_PUBLIC_LIVEBOARD_API_KEY ?? ""; + let cancelled = false; + let token: string | null = null; function startMock() { if (mockRef.current) return; @@ -73,13 +79,22 @@ export function useLiveLog(maxEntries = LOG_BUFFER) { }, 900); } - function connect() { - if (!apiKey) { + async function connect() { + if (cancelled) return; + if (!projectId) { + startMock(); + return; + } + // Short-lived, project-scoped realtime token (Phase 8.4) — refetched on + // each (re)connect so an expired token self-heals. + token = await fetchRealtimeToken(projectId); + if (cancelled) return; + if (!token) { startMock(); return; } - const url = `${API_URL}/v1/stream/logs?api_key=${encodeURIComponent(apiKey)}`; + const url = `${API_URL}/v1/stream/logs?token=${encodeURIComponent(token)}`; const es = new EventSource(url); esRef.current = es; @@ -126,16 +141,20 @@ export function useLiveLog(maxEntries = LOG_BUFFER) { }; } - connect(); + void connect(); return () => { + cancelled = true; esRef.current?.close(); esRef.current = null; if (timerRef.current) clearTimeout(timerRef.current); - if (mockRef.current) clearInterval(mockRef.current); + if (mockRef.current) { + clearInterval(mockRef.current); + mockRef.current = null; + } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [maxEntries]); + }, [maxEntries, projectId]); return { logs, paused, setPaused, connected }; } diff --git a/hooks/use-websocket.ts b/hooks/use-websocket.ts index d9fcb58..35f9e67 100644 --- a/hooks/use-websocket.ts +++ b/hooks/use-websocket.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { getSocket, type MetricUpdate } from "@/lib/socket"; +import { fetchRealtimeToken } from "@/lib/realtime"; import type { Incident, StatCard } from "@/lib/types"; const SPARKLINE_LENGTH = 20; @@ -27,11 +28,12 @@ function mockMetric(): MetricUpdate { } /** - * Connects to the Socket.io server and returns live stat-card data. - * When no API key / live backend is available, falls back to a self-updating - * demo stream so the dashboard always feels alive instead of stuck "connecting…". + * Streams live stat-card data for a project. Fetches a short-lived, project- + * scoped realtime token from the BFF (no API key in the browser). When there's + * no project / backend, falls back to a self-updating demo stream so the + * dashboard always feels alive instead of stuck "connecting…". */ -export function useMetrics(apiKey: string | null) { +export function useMetrics(projectId: string | null) { const [cards, setCards] = useState(emptyCards); const history = useRef<{ @@ -100,7 +102,6 @@ export function useMetrics(apiKey: string | null) { } let mockTimer: ReturnType | null = null; - let gotReal = false; function startMock() { if (mockTimer) return; @@ -109,35 +110,41 @@ export function useMetrics(apiKey: string | null) { mockTimer = setInterval(() => applyUpdate(mockMetric()), 2000); } - if (!apiKey) { + // No project context (logged-out / loading) → synthetic demo so the UI + // isn't dead. A REAL project never shows synthetic numbers. + if (!projectId) { startMock(); return () => { if (mockTimer) clearInterval(mockTimer); }; } - const socket = getSocket(apiKey); - function onMetric(update: MetricUpdate) { - gotReal = true; - if (mockTimer) { - clearInterval(mockTimer); - mockTimer = null; + let cancelled = false; + let socket: ReturnType | null = null; + let onMetric: ((update: MetricUpdate) => void) | null = null; + + void (async () => { + const token = await fetchRealtimeToken(projectId); + if (cancelled) return; + if (!token) { + // Realtime unavailable entirely (backend down) — demo fallback for dev. + startMock(); + return; } - applyUpdate(update); - } - socket.on("metric", onMetric); - - // If no real metric arrives shortly, switch to demo mode. - const grace = setTimeout(() => { - if (!gotReal) startMock(); - }, 1500); + // Real project + realtime available → show ONLY real metrics. When there's + // no traffic in the last minute the worker publishes nothing, so the cards + // stay at their empty ("—") state — which is the honest answer. + socket = getSocket(token); + onMetric = (update: MetricUpdate) => applyUpdate(update); + socket.on("metric", onMetric); + })(); return () => { - socket.off("metric", onMetric); - clearTimeout(grace); + cancelled = true; + if (socket && onMetric) socket.off("metric", onMetric); if (mockTimer) clearInterval(mockTimer); }; - }, [apiKey]); + }, [projectId]); return cards; } @@ -156,33 +163,41 @@ interface RawIncidentPush { * Listens for real-time `incident` Socket.io events pushed by the anomaly worker. * Returns newly pushed incidents so the caller can prepend them to the REST list. */ -export function useIncidentSocket(apiKey: string | null): Incident[] { +export function useIncidentSocket(projectId: string | null): Incident[] { const [live, setLive] = useState([]); useEffect(() => { - if (!apiKey) return; - - const socket = getSocket(apiKey); - - function onIncident(raw: RawIncidentPush) { - const incident: Incident = { - id: raw.id, - severity: raw.severity as Incident["severity"], - title: raw.title, - summary: raw.summary, - endpoint: raw.endpoint, - timestamp: new Date(raw.timestamp), - resolved: raw.resolved, + if (!projectId) return; + + let cancelled = false; + let socket: ReturnType | null = null; + let onIncident: ((raw: RawIncidentPush) => void) | null = null; + + void (async () => { + const token = await fetchRealtimeToken(projectId); + if (cancelled || !token) return; + socket = getSocket(token); + onIncident = (raw: RawIncidentPush) => { + const incident: Incident = { + id: raw.id, + severity: raw.severity as Incident["severity"], + title: raw.title, + summary: raw.summary, + endpoint: raw.endpoint, + timestamp: new Date(raw.timestamp), + resolved: raw.resolved, + }; + // Prepend; deduplicate by id; keep at most 20 live-pushed incidents + setLive((prev) => [incident, ...prev.filter((i) => i.id !== raw.id)].slice(0, 20)); }; - // Prepend; deduplicate by id; keep at most 20 live-pushed incidents - setLive((prev) => [incident, ...prev.filter((i) => i.id !== raw.id)].slice(0, 20)); - } + socket.on("incident", onIncident); + })(); - socket.on("incident", onIncident); return () => { - socket.off("incident", onIncident); + cancelled = true; + if (socket && onIncident) socket.off("incident", onIncident); }; - }, [apiKey]); + }, [projectId]); return live; } diff --git a/lib/api-client.ts b/lib/api-client.ts index fdb9f4b..7a81163 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -1,50 +1,41 @@ -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; -const API_KEY = process.env.NEXT_PUBLIC_LIVEBOARD_API_KEY ?? ""; - /** - * Fetch JSON from the LiveBoard API with x-api-key auth. - * Throws if the key is missing or the response is not ok. + * Dashboard reads/writes go through the same-origin BFF (`/api/lb/*`), which + * authenticates via the NextAuth session and scopes to the caller's project. + * No API key or internal token ever ships to the browser. + * + * Callers pass the active project id as `params.project` (reads) or the + * `project` argument (mutations). */ + +function bffUrl(path: string, params: Record = {}): string { + const qs = new URLSearchParams(params).toString(); + return `/api/lb${path}${qs ? `?${qs}` : ""}`; +} + +/** Fetch JSON from a backend query endpoint via the BFF. */ export async function apiFetch( path: string, params: Record = {}, ): Promise { - if (!API_KEY) throw new Error("NEXT_PUBLIC_LIVEBOARD_API_KEY is not configured"); - - const url = new URL(`${API_URL}${path}`); - Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); - - const res = await fetch(url.toString(), { - headers: { "x-api-key": API_KEY }, - }); - + const res = await fetch(bffUrl(path, params), { cache: "no-store" }); if (!res.ok) throw new Error(`API ${res.status}: ${path}`); - return res.json() as Promise; } -/** - * POST / PATCH / DELETE to the LiveBoard API. - * Returns parsed JSON for 2xx responses with a body; undefined for 204. - */ +/** POST / PATCH / DELETE to a backend endpoint via the BFF, scoped to `project`. */ export async function apiMutate( method: "POST" | "PATCH" | "DELETE", path: string, body?: unknown, + project?: string, ): Promise { - if (!API_KEY) throw new Error("NEXT_PUBLIC_LIVEBOARD_API_KEY is not configured"); - - const res = await fetch(`${API_URL}${path}`, { + const res = await fetch(bffUrl(path, project ? { project } : {}), { method, - headers: { - "x-api-key": API_KEY, - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`API ${res.status}: ${method} ${path}`); - if (res.status === 204) return undefined as T; return res.json() as Promise; } diff --git a/lib/realtime.ts b/lib/realtime.ts new file mode 100644 index 0000000..bc7ec3c --- /dev/null +++ b/lib/realtime.ts @@ -0,0 +1,17 @@ +/** + * Fetch a short-lived, project-scoped realtime token from the BFF. + * Returns null if unauthenticated / not a member / backend unavailable — + * callers fall back to demo mode. + */ +export async function fetchRealtimeToken(projectId: string): Promise { + try { + const res = await fetch(`/api/realtime-token?project=${encodeURIComponent(projectId)}`, { + cache: "no-store", + }); + if (!res.ok) return null; + const data = (await res.json()) as { token?: string }; + return data.token ?? null; + } catch { + return null; + } +} diff --git a/lib/socket.ts b/lib/socket.ts index c1e13ba..137dad1 100644 --- a/lib/socket.ts +++ b/lib/socket.ts @@ -13,14 +13,25 @@ export interface MetricUpdate { // ─── Singleton ─────────────────────────────────────────────────────────────── let _socket: Socket | null = null; - -export function getSocket(apiKey: string): Socket { - if (_socket) return _socket; +let _token: string | null = null; + +/** + * Connect (or reuse) the Socket.io singleton using a short-lived, project-scoped + * realtime token. When the token changes (e.g. project switch), the old socket + * is torn down and a fresh one is created for the new project room. + */ +export function getSocket(token: string): Socket { + if (_socket && _token === token) return _socket; + if (_socket) { + _socket.disconnect(); + _socket = null; + } + _token = token; const url = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; _socket = io(url, { - auth: { api_key: apiKey }, + auth: { token }, // Exponential back-off: 1 s → 2 s → 4 s → 8 s (capped) reconnectionDelay: 1_000, reconnectionDelayMax: 8_000, @@ -47,4 +58,5 @@ export function getSocket(apiKey: string): Socket { export function disconnectSocket(): void { _socket?.disconnect(); _socket = null; + _token = null; } diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index 5832ba0..cdea171 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -5,5 +5,17 @@ declare module "next-auth" { user: { id: string; } & DefaultSession["user"]; + /** Project IDs the signed-in user is authorized to read (org membership scope). */ + projectIds?: string[]; + /** The project shown by default in the dashboard. */ + defaultProjectId?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + userId?: string; + projectIds?: string[]; + defaultProjectId?: string; } } From a505ce283ab3910fe3fa5ccf8624cda1a9b467ee Mon Sep 17 00:00:00 2001 From: ryzrr Date: Sat, 18 Jul 2026 22:37:28 +0530 Subject: [PATCH 4/5] feat(sdk): emit spans per request for distributed tracing - SpanBuffer + sendSpans() POST to /v1/spans (batched, fire-and-forget) - Express middleware emits a root span per request (service/operation/ duration/status/tags) and propagates x-trace-id + x-parent-span-id so downstream instrumented services can attach child spans - New config: `service` name and `tracing` toggle (default on) --- packages/sdk-js/src/buffer.ts | 49 ++++++++++++++++++++++++++++++++-- packages/sdk-js/src/client.ts | 39 +++++++++++++-------------- packages/sdk-js/src/config.ts | 4 +++ packages/sdk-js/src/express.ts | 32 ++++++++++++++++++++-- packages/sdk-js/src/types.ts | 30 +++++++++++++++++++++ 5 files changed, 129 insertions(+), 25 deletions(-) diff --git a/packages/sdk-js/src/buffer.ts b/packages/sdk-js/src/buffer.ts index d2c0b2b..6277baa 100644 --- a/packages/sdk-js/src/buffer.ts +++ b/packages/sdk-js/src/buffer.ts @@ -1,5 +1,5 @@ -import type { EventPayload, IngestBatch } from "./types"; -import { sendBatch } from "./client"; +import type { EventPayload, IngestBatch, SpanPayload, SpanBatch } from "./types"; +import { sendBatch, sendSpans } from "./client"; export class EventBuffer { private queue: EventPayload[] = []; @@ -51,3 +51,48 @@ export class EventBuffer { this.flush(); } } + +/** Batches spans and flushes them to /v1/spans on an interval or when full. */ +export class SpanBuffer { + private queue: SpanPayload[] = []; + private timer: ReturnType | null = null; + private flushing = false; + + constructor( + private readonly ingestUrl: string, + private readonly apiKey: string, + private readonly batchSize: number, + flushIntervalMs: number + ) { + this.timer = setInterval(() => this.flush(), flushIntervalMs); + if (this.timer.unref) this.timer.unref(); + + const shutdown = () => this.flush(); + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + } + + add(span: SpanPayload): void { + this.queue.push(span); + // Backend accepts up to 500 spans per batch. + if (this.queue.length >= Math.min(this.batchSize, 500)) { + this.flush(); + } + } + + flush(): void { + if (this.flushing || this.queue.length === 0) return; + this.flushing = true; + const batch: SpanBatch = { spans: this.queue.splice(0) }; + sendSpans(this.ingestUrl, this.apiKey, batch); + this.flushing = false; + } + + destroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.flush(); + } +} diff --git a/packages/sdk-js/src/client.ts b/packages/sdk-js/src/client.ts index b13c727..3afc6b7 100644 --- a/packages/sdk-js/src/client.ts +++ b/packages/sdk-js/src/client.ts @@ -1,26 +1,18 @@ -import type { IngestBatch } from "./types"; +import type { IngestBatch, SpanBatch } from "./types"; import * as https from "https"; import * as http from "http"; const REQUEST_TIMEOUT_MS = 5_000; /** - * Fire-and-forget POST to /v1/ingest. - * Silently drops errors — observability must never crash the host app. + * Fire-and-forget JSON POST. Silently drops errors — observability must never + * crash the host app. */ -export function sendBatch( - ingestUrl: string, - apiKey: string, - batch: IngestBatch -): void { - const body = Buffer.from(JSON.stringify(batch), "utf-8"); - const url = new URL(`${ingestUrl}/v1/ingest`); +function post(ingestUrl: string, path: string, apiKey: string, payload: unknown): void { + const body = Buffer.from(JSON.stringify(payload), "utf-8"); + const url = new URL(`${ingestUrl}${path}`); const isHttps = url.protocol === "https:"; - const port = url.port - ? parseInt(url.port, 10) - : isHttps - ? 443 - : 80; + const port = url.port ? parseInt(url.port, 10) : isHttps ? 443 : 80; const options: http.RequestOptions = { hostname: url.hostname, @@ -41,18 +33,23 @@ export function sendBatch( // Drain the response body so the socket is released res.resume(); }); - - req.setTimeout(REQUEST_TIMEOUT_MS, () => { - req.destroy(); - }); - + req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy()); req.on("error", () => { // Silently drop — network failures are not SDK failures }); - req.write(body); req.end(); } catch { // Silently drop } } + +/** POST a batch of request events to /v1/ingest. */ +export function sendBatch(ingestUrl: string, apiKey: string, batch: IngestBatch): void { + post(ingestUrl, "/v1/ingest", apiKey, batch); +} + +/** POST a batch of spans to /v1/spans. */ +export function sendSpans(ingestUrl: string, apiKey: string, batch: SpanBatch): void { + post(ingestUrl, "/v1/spans", apiKey, batch); +} diff --git a/packages/sdk-js/src/config.ts b/packages/sdk-js/src/config.ts index 33cc653..5f45374 100644 --- a/packages/sdk-js/src/config.ts +++ b/packages/sdk-js/src/config.ts @@ -9,6 +9,8 @@ const DEFAULTS = { redactHeaders: ["authorization", "cookie"], flushInterval: 500, batchSize: 100, + service: "api", + tracing: true, } as const; export function resolveConfig(config: LiveBoardConfig): ResolvedConfig { @@ -27,6 +29,8 @@ export function resolveConfig(config: LiveBoardConfig): ResolvedConfig { getUserId: config.getUserId ?? extractUserId, flushInterval: config.flushInterval ?? DEFAULTS.flushInterval, batchSize: config.batchSize ?? DEFAULTS.batchSize, + service: config.service ?? DEFAULTS.service, + tracing: config.tracing ?? DEFAULTS.tracing, }; } diff --git a/packages/sdk-js/src/express.ts b/packages/sdk-js/src/express.ts index f707779..5a0378b 100644 --- a/packages/sdk-js/src/express.ts +++ b/packages/sdk-js/src/express.ts @@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "http"; import type { LiveBoardConfig } from "./types"; import { resolveConfig, shouldIgnore, shouldSample } from "./config"; import { getRoute } from "./normalise"; -import { EventBuffer } from "./buffer"; +import { EventBuffer, SpanBuffer } from "./buffer"; import { SDK_VERSION } from "./version"; // Minimal Express-compatible types — avoids a hard @types/express dependency @@ -25,6 +25,9 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware { resolved.batchSize, resolved.flushInterval ); + const spanBuffer = resolved.tracing + ? new SpanBuffer(resolved.ingestUrl, resolved.apiKey, resolved.batchSize, resolved.flushInterval) + : null; return function liveboardMiddleware( req: IncomingMessage, @@ -36,7 +39,16 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware { const incoming = req.headers["x-trace-id"]; const traceId = (typeof incoming === "string" && incoming) ? incoming : randomUUID(); + // If an upstream instrumented service passed its span id, parent to it. + const incomingParent = req.headers["x-parent-span-id"]; + const parentId = typeof incomingParent === "string" && incomingParent ? incomingParent : undefined; + + // This request's root span id — propagated to downstream services so they + // can attach their spans under it (enables cross-service traces). + const spanId = randomUUID(); + res.setHeader("x-trace-id", traceId); + res.setHeader("x-parent-span-id", spanId); res.on("finish", () => { const route = getRoute(req); @@ -46,9 +58,10 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware { const durationMs = Date.now() - startMs; const statusCode = res.statusCode; + const method = (req.method ?? "GET").toUpperCase(); buffer.add({ - method: (req.method ?? "GET").toUpperCase(), + method, route, status_code: statusCode, duration_ms: durationMs, @@ -61,6 +74,21 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware { : undefined, sdk_version: SDK_VERSION, }); + + // Emit a root span for this request → powers the trace / flame-graph view. + if (spanBuffer) { + spanBuffer.add({ + span_id: spanId, + trace_id: traceId, + parent_id: parentId, + service_name: resolved.service, + operation: `${method} ${route}`, + start_time: new Date(startMs).toISOString(), + duration_ms: durationMs, + status_code: statusCode, + tags: { method, route }, + }); + } }); next(); diff --git a/packages/sdk-js/src/types.ts b/packages/sdk-js/src/types.ts index caee61f..8b20925 100644 --- a/packages/sdk-js/src/types.ts +++ b/packages/sdk-js/src/types.ts @@ -27,6 +27,16 @@ export interface LiveBoardConfig { flushInterval?: number; /** Flush early when the buffer reaches this many events. Default: 100 */ batchSize?: number; + /** + * Service name attached to emitted spans (shown in the trace/flame view). + * Default: 'api' + */ + service?: string; + /** + * Emit a distributed-tracing span per request. Default: true. + * Set false to send only request events (no spans). + */ + tracing?: boolean; } // ─── Internal resolved config (all fields required + defaults applied) ──────── @@ -39,6 +49,8 @@ export interface ResolvedConfig { getUserId: (req: IncomingMessage) => string | undefined; flushInterval: number; batchSize: number; + service: string; + tracing: boolean; } // ─── Wire format — must match apps/api/api/schemas.py EventPayload exactly ─── @@ -58,3 +70,21 @@ export interface EventPayload { export interface IngestBatch { events: EventPayload[]; } + +// ─── Span wire format — must match apps/api/api/schemas.py SpanIn exactly ───── + +export interface SpanPayload { + span_id: string; + trace_id: string; + parent_id?: string; + service_name: string; + operation: string; + start_time: string; // ISO 8601 UTC + duration_ms: number; + status_code: number; + tags: Record; +} + +export interface SpanBatch { + spans: SpanPayload[]; +} From 55cabac86346a5aa684d400a24a26b4cc2bbb0d8 Mon Sep 17 00:00:00 2001 From: ryzrr Date: Sat, 18 Jul 2026 23:28:20 +0530 Subject: [PATCH 5/5] fix(api): move settings import to module top (ruff E402) --- apps/api/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/main.py b/apps/api/main.py index a8242a4..44faa4e 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.routes import alerts, ingest, internal, projects, query, spans +from core.config import settings from core.database import close_pool, get_pool from core.redis_client import close_redis from realtime.pubsub import listen_pubsub @@ -56,11 +57,9 @@ async def lifespan(app: FastAPI): redoc_url=None, ) -from core.config import settings as _settings - _fastapi.add_middleware( CORSMiddleware, - allow_origins=_settings.cors_origin_list, + allow_origins=settings.cors_origin_list, allow_methods=["POST", "GET", "OPTIONS"], allow_headers=["*"], )