diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d24078a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# Root compose defaults (safe for local self-host / dev)
+# Copy to .env to override.
+
+# --- app ports (host) ---
+FRONTEND_PORT=3000
+BACKEND_PORT=8000
+
+# --- database ---
+POSTGRES_DB=openclaw_agency
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_PORT=5432
+
+# --- redis ---
+REDIS_PORT=6379
+
+# --- backend settings (see backend/.env.example for full list) ---
+CORS_ORIGINS=http://localhost:3000
+DB_AUTO_MIGRATE=true
+
+# --- frontend settings ---
+# Public URL used by the browser to reach the API
+NEXT_PUBLIC_API_URL=http://localhost:8000
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7401f9e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,55 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [master]
+ # Allow maintainers to manually kick CI when GitHub doesn't create a run for a new head SHA.
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install uv
+ run: python -m pip install --upgrade pip uv
+
+ - name: Cache uv
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cache/uv
+ backend/.venv
+ key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ run: make setup
+
+ - name: Run checks
+ env:
+ # Keep CI builds deterministic and secretless.
+ NEXT_TELEMETRY_DISABLED: "1"
+ run: make check
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..df5a587
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,44 @@
+# syntax=docker/dockerfile:1
+
+FROM python:3.12-slim AS base
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+# System deps (keep minimal)
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends curl ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install uv (https://github.com/astral-sh/uv)
+RUN curl -LsSf https://astral.sh/uv/install.sh | sh
+ENV PATH="/root/.local/bin:${PATH}"
+
+# --- deps layer ---
+FROM base AS deps
+
+# Copy only dependency metadata first for better build caching
+COPY pyproject.toml uv.lock ./
+
+# Create venv and sync deps (including runtime)
+RUN uv sync --frozen --no-dev
+
+# --- runtime ---
+FROM base AS runtime
+
+# Copy virtual environment from deps stage
+COPY --from=deps /app/.venv /app/.venv
+ENV PATH="/app/.venv/bin:${PATH}"
+
+# Copy app source
+COPY alembic ./alembic
+COPY alembic.ini ./alembic.ini
+COPY app ./app
+
+# Default API port
+EXPOSE 8000
+
+# Run the API
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 0000000..f6a43a9
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,67 @@
+name: openclaw-mission-control
+
+services:
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-openclaw_agency}
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "${POSTGRES_PORT:-5432}:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis_data:/data
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ backend:
+ build:
+ context: ./backend
+ env_file:
+ - ./backend/.env.example
+ environment:
+ # Override localhost defaults for container networking
+ DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-openclaw_agency}
+ REDIS_URL: redis://redis:6379/0
+ CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
+ DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ ports:
+ - "${BACKEND_PORT:-8000}:8000"
+
+ frontend:
+ build:
+ context: ./frontend
+ args:
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
+ env_file:
+ - ./frontend/.env.example
+ environment:
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
+ depends_on:
+ - backend
+ ports:
+ - "${FRONTEND_PORT:-3000}:3000"
+
+volumes:
+ postgres_data:
+ redis_data:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..4a87dcd
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,38 @@
+# syntax=docker/dockerfile:1
+
+FROM node:20-alpine AS deps
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM node:20-alpine AS builder
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . ./
+
+# Allows configuring the API URL at build time.
+ARG NEXT_PUBLIC_API_URL=http://localhost:8000
+ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
+
+RUN npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+
+# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
+# (but note some values may be baked at build time).
+ENV NEXT_PUBLIC_API_URL=http://localhost:8000
+
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/next.config.ts ./next.config.ts
+
+EXPOSE 3000
+
+CMD ["npm", "run", "start"]
diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx
index 377a775..408079c 100644
--- a/frontend/src/app/agents/[agentId]/edit/page.tsx
+++ b/frontend/src/app/agents/[agentId]/edit/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx
index 29706e3..76f624d 100644
--- a/frontend/src/app/agents/[agentId]/page.tsx
+++ b/frontend/src/app/agents/[agentId]/page.tsx
@@ -1,10 +1,12 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx
index 92fec06..3d08640 100644
--- a/frontend/src/app/agents/new/page.tsx
+++ b/frontend/src/app/agents/new/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx
index ea5cb45..3a09a95 100644
--- a/frontend/src/app/agents/page.tsx
+++ b/frontend/src/app/agents/page.tsx
@@ -1,10 +1,12 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
type ColumnDef,
type SortingState,
diff --git a/frontend/src/app/boards/[boardId]/approvals/page.tsx b/frontend/src/app/boards/[boardId]/approvals/page.tsx
index a362a64..531ca13 100644
--- a/frontend/src/app/boards/[boardId]/approvals/page.tsx
+++ b/frontend/src/app/boards/[boardId]/approvals/page.tsx
@@ -1,8 +1,10 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useParams } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut } from "@/auth/clerk";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx
index a3f9813..70b9b37 100644
--- a/frontend/src/app/boards/[boardId]/edit/page.tsx
+++ b/frontend/src/app/boards/[boardId]/edit/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { X } from "lucide-react";
import { ApiError } from "@/api/mutator";
diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx
index 1784834..1725560 100644
--- a/frontend/src/app/boards/[boardId]/page.tsx
+++ b/frontend/src/app/boards/[boardId]/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
Activity,
ArrowUpRight,
diff --git a/frontend/src/app/boards/new/page.tsx b/frontend/src/app/boards/new/page.tsx
index c9fb9ae..5b188ec 100644
--- a/frontend/src/app/boards/new/page.tsx
+++ b/frontend/src/app/boards/new/page.tsx
@@ -1,10 +1,12 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import { useCreateBoardApiV1BoardsPost } from "@/api/generated/boards/boards";
diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx
index 2234ef8..7e979e8 100644
--- a/frontend/src/app/boards/page.tsx
+++ b/frontend/src/app/boards/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo, useState } from "react";
import Link from "next/link";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
type ColumnDef,
flexRender,
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index be3ef59..b21b5f1 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -1,8 +1,10 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo } from "react";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
Area,
AreaChart,
diff --git a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
index 9cd78a8..8f1233b 100644
--- a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
import { ApiError } from "@/api/mutator";
diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx
index a8f4133..726d4a1 100644
--- a/frontend/src/app/gateways/[gatewayId]/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx
index f310fd7..1c99568 100644
--- a/frontend/src/app/gateways/new/page.tsx
+++ b/frontend/src/app/gateways/new/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
import { ApiError } from "@/api/mutator";
diff --git a/frontend/src/app/gateways/page.tsx b/frontend/src/app/gateways/page.tsx
index ca5ac18..5ede20a 100644
--- a/frontend/src/app/gateways/page.tsx
+++ b/frontend/src/app/gateways/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useMemo, useState } from "react";
import Link from "next/link";
-import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
type ColumnDef,
type SortingState,
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 146d8a1..905dfae 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -3,9 +3,9 @@ import "./globals.css";
import type { Metadata } from "next";
import type { ReactNode } from "react";
-import { ClerkProvider } from "@clerk/nextjs";
import { IBM_Plex_Sans, Sora } from "next/font/google";
+import { AuthProvider } from "@/components/providers/AuthProvider";
import { QueryProvider } from "@/components/providers/QueryProvider";
export const metadata: Metadata = {
@@ -29,14 +29,14 @@ const headingFont = Sora({
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
-
-
+
+
+
{children}
-
-
-
+
+
+
);
}
diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx
index 21bd936..66613ef 100644
--- a/frontend/src/app/onboarding/page.tsx
+++ b/frontend/src/app/onboarding/page.tsx
@@ -1,9 +1,11 @@
"use client";
+export const dynamic = "force-dynamic";
+
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
-import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut, useAuth, useUser } from "@/auth/clerk";
import { Globe, Info, RotateCcw, Save, User } from "lucide-react";
import { ApiError } from "@/api/mutator";
diff --git a/frontend/src/auth/clerk.tsx b/frontend/src/auth/clerk.tsx
new file mode 100644
index 0000000..691b3a0
--- /dev/null
+++ b/frontend/src/auth/clerk.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+// NOTE: We intentionally keep this file very small and dependency-free.
+// It provides CI/secretless-build safe fallbacks for Clerk hooks/components.
+
+import {
+ ClerkProvider,
+ SignedIn as ClerkSignedIn,
+ SignedOut as ClerkSignedOut,
+ SignInButton as ClerkSignInButton,
+ SignOutButton as ClerkSignOutButton,
+ useAuth as clerkUseAuth,
+ useUser as clerkUseUser,
+} from "@clerk/nextjs";
+
+import type { ComponentProps } from "react";
+
+export function isClerkEnabled(): boolean {
+ // Invariant: Clerk is disabled ONLY when the publishable key is absent.
+ // If a key is present, we assume Clerk is intended to be enabled and we let
+ // Clerk fail fast if the key is invalid/misconfigured.
+ return Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
+}
+
+export function SignedIn(props: { children: ReactNode }) {
+ if (!isClerkEnabled()) return null;
+ return {props.children};
+}
+
+export function SignedOut(props: { children: ReactNode }) {
+ if (!isClerkEnabled()) return <>{props.children}>;
+ return {props.children};
+}
+
+// Keep the same prop surface as Clerk components so call sites don't need edits.
+export function SignInButton(props: ComponentProps) {
+ if (!isClerkEnabled()) return null;
+ return ;
+}
+
+export function SignOutButton(props: ComponentProps) {
+ if (!isClerkEnabled()) return null;
+ return ;
+}
+
+export function useUser() {
+ if (!isClerkEnabled()) {
+ return { isLoaded: true, isSignedIn: false, user: null } as const;
+ }
+ return clerkUseUser();
+}
+
+export function useAuth() {
+ if (!isClerkEnabled()) {
+ return {
+ isLoaded: true,
+ isSignedIn: false,
+ userId: null,
+ sessionId: null,
+ getToken: async () => null,
+ } as const;
+ }
+ return clerkUseAuth();
+}
+
+// Re-export ClerkProvider for places that want to mount it, but strongly prefer
+// gating via isClerkEnabled() at call sites.
+export { ClerkProvider };
diff --git a/frontend/src/components/BoardApprovalsPanel.tsx b/frontend/src/components/BoardApprovalsPanel.tsx
index 0ed470d..264d73c 100644
--- a/frontend/src/components/BoardApprovalsPanel.tsx
+++ b/frontend/src/components/BoardApprovalsPanel.tsx
@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState } from "react";
-import { useAuth } from "@clerk/nextjs";
+import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { Clock } from "lucide-react";
diff --git a/frontend/src/components/organisms/LandingHero.tsx b/frontend/src/components/organisms/LandingHero.tsx
index 38db84f..349bd48 100644
--- a/frontend/src/components/organisms/LandingHero.tsx
+++ b/frontend/src/components/organisms/LandingHero.tsx
@@ -1,6 +1,6 @@
"use client";
-import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
+import { SignInButton, SignedIn, SignedOut } from "@/auth/clerk";
import { HeroCopy } from "@/components/molecules/HeroCopy";
import { Button } from "@/components/ui/button";
diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx
index 0918924..661fbea 100644
--- a/frontend/src/components/organisms/UserMenu.tsx
+++ b/frontend/src/components/organisms/UserMenu.tsx
@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
-import { SignOutButton, useUser } from "@clerk/nextjs";
+import { SignOutButton, useUser } from "@/auth/clerk";
import { LogOut } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx
new file mode 100644
index 0000000..4da743e
--- /dev/null
+++ b/frontend/src/components/providers/AuthProvider.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { ClerkProvider } from "@clerk/nextjs";
+import type { ReactNode } from "react";
+
+function isLikelyValidClerkPublishableKey(key: string | undefined): key is string {
+ if (!key) return false;
+ // Clerk publishable keys look like: pk_test_... or pk_live_...
+ // In CI we want builds to stay secretless; if the key isn't present/valid,
+ // we skip Clerk entirely so `next build` can prerender.
+ //
+ // Note: Clerk appears to validate key *contents*, not just shape. We therefore
+ // use a conservative heuristic to avoid treating obvious placeholders as valid.
+ const m = /^pk_(test|live)_([A-Za-z0-9]+)$/.exec(key);
+ if (!m) return false;
+ const body = m[2];
+ if (body.length < 16) return false;
+ if (/^0+$/.test(body)) return false;
+ return true;
+}
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
+
+ if (!isLikelyValidClerkPublishableKey(publishableKey)) {
+ return <>{children}>;
+ }
+
+ return {children};
+}
diff --git a/frontend/src/components/templates/DashboardShell.tsx b/frontend/src/components/templates/DashboardShell.tsx
index ace048a..04b516a 100644
--- a/frontend/src/components/templates/DashboardShell.tsx
+++ b/frontend/src/components/templates/DashboardShell.tsx
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
-import { SignedIn, useUser } from "@clerk/nextjs";
+import { SignedIn, useUser } from "@/auth/clerk";
import { BrandMark } from "@/components/atoms/BrandMark";
import { UserMenu } from "@/components/organisms/UserMenu";
diff --git a/frontend/src/components/templates/LandingShell.tsx b/frontend/src/components/templates/LandingShell.tsx
index 59760fc..32d6af7 100644
--- a/frontend/src/components/templates/LandingShell.tsx
+++ b/frontend/src/components/templates/LandingShell.tsx
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
-import { SignedIn } from "@clerk/nextjs";
+import { SignedIn } from "@/auth/clerk";
import { BrandMark } from "@/components/atoms/BrandMark";
import { UserMenu } from "@/components/organisms/UserMenu";
diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts
index 7543980..75b3086 100644
--- a/frontend/src/proxy.ts
+++ b/frontend/src/proxy.ts
@@ -1,6 +1,9 @@
+import { NextResponse } from "next/server";
import { clerkMiddleware } from "@clerk/nextjs/server";
-export default clerkMiddleware();
+const isClerkEnabled = () => Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
+
+export default isClerkEnabled() ? clerkMiddleware() : () => NextResponse.next();
export const config = {
matcher: [