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/(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/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/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/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/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/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[];
+}
diff --git a/middleware.ts b/proxy.ts
similarity index 100%
rename from middleware.ts
rename to proxy.ts
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;
}
}