diff --git a/AGENTS.md b/AGENTS.md index 5e4a3fb..5e0befa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,14 +23,14 @@ repo is retired; do not recreate it. ## Repo shape -Monorepo, **three independently-built packages**, no root `package.json`, no -root workspace. Each package has its own `pnpm-lock.yaml` — install per package. +Monorepo with **two independently-built product packages** and separate root +docs tooling. There is no root workspace. Each package has its own +`pnpm-lock.yaml` — install per package. | Package | Path | Stack | | --- | --- | --- | | RAG Worker (the product) | `cloudflare/worker/` | Hono, Workers AI, Vectorize, D1, R2, Queues, Workflows | | Dashboard app | `app/` | Vite + React (static) → Cloudflare Pages | -| Landing page | `landing-astro/` | Astro (static) → Cloudflare Pages | Retired reference: `src/kb/` (Python), root `migrations/` (legacy Postgres), `data/` (local corpus, gitignored). Active D1 migrations: @@ -49,7 +49,6 @@ pnpm run predeploy:local # the full local pre-deploy gate (asks for sibling repo pnpm deploy # wrangler deploy — ASK before touching prod # App (from app/) pnpm install / pnpm dev / pnpm build / pnpm typecheck -# Landing (from landing-astro/) pnpm install / pnpm build / pnpm preview # Docs (from repo root) pnpm install --frozen-lockfile diff --git a/README.md b/README.md index 7122fdf..b5af8b4 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ have: **Operator-flavored** - [`docs/operations/runbook.md`](docs/operations/runbook.md) — operator runbook +- [`docs/operations/dashboard-access.md`](docs/operations/dashboard-access.md) — internal dashboard identity and proxy setup - [`docs/product/demo-walkthrough.md`](docs/product/demo-walkthrough.md) — guided demo - [`docs/product/onboard-new-domain.md`](docs/product/onboard-new-domain.md) — adding a third domain in ~30 min - [`docs/product/agent-search-direction.md`](docs/product/agent-search-direction.md) — product direction + gap map diff --git a/STATUS.md b/STATUS.md index f767a78..f0b889a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -33,12 +33,14 @@ the non-negotiable product invariant. overall A+ (readiness, scoped query eval, lexical `kb-search`, semantic `kb-query`, ingestion, observability, hosted UI). Final benchmark p95s: lexical 99.46 ms, semantic 550.73 ms; query eval hit/citation rates 1.0. -- **Frontend surfaces:** Astro landing on Cloudflare Pages; Vite + React - dashboard deployed on Cloudflare Pages at `search.sassmaker.com`; Worker - `/ui` operator testing surface. The former OpenNext Worker remains available - on its `workers.dev` hostname as a rollback target, but no longer owns the - production custom domain. Home, operator configuration, navigation, and - direct `/domains` deep-link smoke passed after the 2026-07-25 cutover. +- **Frontend surfaces:** Vite + React dashboard deployed on Cloudflare Pages at + `search.sassmaker.com`; Worker `/ui` operator testing surface. The former + OpenNext Worker remains available on its `workers.dev` hostname as a rollback + target, but no longer owns the production custom domain. Home, operator + configuration, navigation, and direct `/domains` deep-link smoke passed after + the 2026-07-25 cutover. The deployed dashboard still uses the prior + browser-provided service-key flow until the internal-operator change is + configured and released. - **Deployed corpus is opt-in.** The cutover shipped code + infra parity, not a full demo-corpus backfill. Demo `legal`/`sec` query corpora need an explicit ingestion run before they answer production questions. @@ -48,9 +50,17 @@ the non-negotiable product invariant. - Docs consolidation (this branch): unify scattered root-level and `docs/` markdown into one canonical knowledge system with Blume as the presentation layer only. See `docs/index.md`. +- Internal operator dashboard (local, not deployed): Cloudflare Access identity, + server-side Pages proxy, tenant-scoped Data inspection, and cross-domain Query + History. See archived OpenSpec change + `2026-07-25-internal-operator-dashboard`. ## Blockers +- **Internal dashboard release:** Pages still needs the Access application, + `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`, `RAG_SERVICE_URL`, and the + server-side `RAG_SERVICE_KEY` secret. The Worker chunk route must deploy + before the dashboard. See `docs/operations/dashboard-access.md`. - **S-grade proof:** missing `KARTE_SESSION_COOKIE` and `STARBOARD_SESSION_COOKIE` for authenticated product-session smokes. This is not a Cloudflare runtime gap. Without them `smoke:consumer-auth` reports @@ -68,18 +78,11 @@ the non-negotiable product invariant. `docs/operations/highsignal-integration.md`) - Should signal publication store knowledgebase trace IDs alongside generated claims? (same) -- The dashboard `app/public/llms.txt` and `llms-full.txt` advertise - `https://search.sassmaker.com/api/ai` as an agent catalog, but the actual - file served is `app/public/api-ai.json` (a static asset). There is no - `/api/ai` route in the static dashboard. Either add a static `/api/ai` - asset/redirect that serves - `api-ai.json`, or fix `llms.txt`/`llms-full.txt`/`robots.txt` to point at - `/api-ai.json`. (Discovered during the docs audit.) ## Next steps -1. Close the `/api/ai` vs `/api-ai.json` mismatch in `app/public/` (see - Unresolved questions). +1. Configure and release the internal operator dashboard in Worker-then-Pages + order, then verify identity, Data, Query History, and one cited query. 2. Complete live S-grade consumer proof once session cookies are available: `KARTE_SESSION_COOKIE= STARBOARD_SESSION_COOKIE= pnpm run smoke:consumer-auth -- --require-authenticated`, then re-run `proof:s`. diff --git a/app/.gitignore b/app/.gitignore index 4d057bb..5f03de6 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -4,4 +4,5 @@ node_modules/ dist/ cloudflare-env.d.ts .wrangler/ +.dev.vars* *.tsbuildinfo diff --git a/app/functions/_lib/access.ts b/app/functions/_lib/access.ts new file mode 100644 index 0000000..e926920 --- /dev/null +++ b/app/functions/_lib/access.ts @@ -0,0 +1,108 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; + +export interface AccessEnv { + CF_ACCESS_TEAM_DOMAIN?: string; + CF_ACCESS_AUD?: string; + CF_ACCESS_DEV_EMAIL?: string; +} + +export interface OperatorIdentity { + email: string; + subject: string; + expiresAt: number | null; +} + +export class AccessError extends Error { + constructor( + message: string, + readonly status: 401 | 503, + ) { + super(message); + } +} + +const jwksByTeam = new Map< + string, + ReturnType +>(); + +function normalizedTeamDomain(raw: string): string { + const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; + return withProtocol.replace(/\/+$/, ""); +} + +function isLocalPreview(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1"; +} + +function identityFromPayload(payload: JWTPayload): OperatorIdentity { + const email = typeof payload.email === "string" ? payload.email.trim() : ""; + const subject = typeof payload.sub === "string" ? payload.sub : ""; + if (!email || !subject) { + throw new AccessError("Access token is missing operator identity", 401); + } + return { + email, + subject, + expiresAt: typeof payload.exp === "number" ? payload.exp : null, + }; +} + +export async function verifyOperator( + request: Request, + env: AccessEnv, +): Promise { + const devEmail = env.CF_ACCESS_DEV_EMAIL?.trim(); + if (devEmail && isLocalPreview(request)) { + return { + email: devEmail, + subject: `local:${devEmail}`, + expiresAt: null, + }; + } + + const teamDomainRaw = env.CF_ACCESS_TEAM_DOMAIN?.trim(); + const audience = env.CF_ACCESS_AUD?.trim(); + if (!teamDomainRaw || !audience) { + throw new AccessError("Cloudflare Access is not configured", 503); + } + + const token = request.headers.get("Cf-Access-Jwt-Assertion")?.trim(); + if (!token) { + throw new AccessError("Cloudflare Access session required", 401); + } + + const teamDomain = normalizedTeamDomain(teamDomainRaw); + let jwks = jwksByTeam.get(teamDomain); + if (!jwks) { + jwks = createRemoteJWKSet( + new URL("/cdn-cgi/access/certs", `${teamDomain}/`), + ); + jwksByTeam.set(teamDomain, jwks); + } + + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: teamDomain, + audience, + }); + return identityFromPayload(payload); + } catch (error) { + if (error instanceof AccessError) throw error; + throw new AccessError("Cloudflare Access session is invalid or expired", 401); + } +} + +export function accessErrorResponse(error: unknown): Response { + if (error instanceof AccessError) { + return Response.json( + { error: error.message }, + { status: error.status }, + ); + } + return Response.json( + { error: "Operator authentication failed" }, + { status: 401 }, + ); +} diff --git a/app/functions/api/[[path]].ts b/app/functions/api/[[path]].ts new file mode 100644 index 0000000..bf9bcd3 --- /dev/null +++ b/app/functions/api/[[path]].ts @@ -0,0 +1,107 @@ +import { + accessErrorResponse, + verifyOperator, + type AccessEnv, +} from "../_lib/access"; + +interface ProxyEnv extends AccessEnv { + RAG_SERVICE_URL?: string; + RAG_SERVICE_KEY?: string; +} + +interface ProxyContext { + request: Request; + env: ProxyEnv; + params: { + path?: string | string[]; + }; +} + +const REQUEST_HEADERS = ["accept", "content-type", "if-match"]; +const RESPONSE_HEADERS = [ + "cache-control", + "content-disposition", + "content-type", + "retry-after", + "x-rag-timing", +]; + +function proxyPath(value: string | string[] | undefined): string { + return Array.isArray(value) ? value.join("/") : (value ?? ""); +} + +function upstreamHeaders(request: Request, serviceKey: string): Headers { + const headers = new Headers({ + Authorization: `Bearer ${serviceKey}`, + }); + for (const name of REQUEST_HEADERS) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + return headers; +} + +function downstreamHeaders(upstream: Response): Headers { + const headers = new Headers(); + for (const name of RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + return headers; +} + +export async function onRequest( + context: ProxyContext, +): Promise { + try { + await verifyOperator(context.request, context.env); + } catch (error) { + return accessErrorResponse(error); + } + + const serviceUrl = context.env.RAG_SERVICE_URL?.trim(); + const serviceKey = context.env.RAG_SERVICE_KEY?.trim(); + if (!serviceUrl || !serviceKey) { + return Response.json( + { error: "RAG service proxy is not configured" }, + { status: 503 }, + ); + } + + const path = proxyPath(context.params.path).replace(/^\/+/, ""); + if (path !== "v1" && !path.startsWith("v1/")) { + return Response.json({ error: "Unsupported proxy path" }, { status: 404 }); + } + + let upstreamUrl: URL; + try { + upstreamUrl = new URL(`/${path}`, `${serviceUrl.replace(/\/+$/, "")}/`); + } catch { + return Response.json( + { error: "RAG service URL is invalid" }, + { status: 503 }, + ); + } + if ( + upstreamUrl.pathname !== "/v1" + && !upstreamUrl.pathname.startsWith("/v1/") + ) { + return Response.json({ error: "Unsupported proxy path" }, { status: 404 }); + } + upstreamUrl.search = new URL(context.request.url).search; + + const method = context.request.method.toUpperCase(); + const hasBody = method !== "GET" && method !== "HEAD"; + const upstream = await fetch(upstreamUrl, { + method, + headers: upstreamHeaders(context.request, serviceKey), + body: hasBody ? context.request.body : undefined, + redirect: "manual", + }); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: downstreamHeaders(upstream), + }); +} diff --git a/app/functions/api/ai.ts b/app/functions/api/ai.ts new file mode 100644 index 0000000..7deb18a --- /dev/null +++ b/app/functions/api/ai.ts @@ -0,0 +1,9 @@ +import catalog from "../../public/api-ai.json"; + +export async function onRequest(): Promise { + return Response.json(catalog, { + headers: { + "Cache-Control": "public, max-age=300", + }, + }); +} diff --git a/app/functions/api/session.ts b/app/functions/api/session.ts new file mode 100644 index 0000000..18064b7 --- /dev/null +++ b/app/functions/api/session.ts @@ -0,0 +1,21 @@ +import { + accessErrorResponse, + verifyOperator, + type AccessEnv, +} from "../_lib/access"; + +interface SessionContext { + request: Request; + env: AccessEnv; +} + +export async function onRequest( + context: SessionContext, +): Promise { + try { + const operator = await verifyOperator(context.request, context.env); + return Response.json({ operator }); + } catch (error) { + return accessErrorResponse(error); + } +} diff --git a/app/package.json b/app/package.json index 703c448..f78e5b2 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,7 @@ "name": "knowledgebase-app", "version": "0.1.0", "private": true, - "description": "Knowledgebase dashboard — static Vite + React app talking directly to the RAG Worker API.", + "description": "Knowledgebase internal operator dashboard with Cloudflare Access and a server-side Worker proxy.", "type": "module", "scripts": { "dev": "vite", @@ -18,6 +18,7 @@ "dependencies": { "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "jose": "6.2.4", "lucide-react": "1.11.0", "react": "19.2.5", "react-dom": "19.2.5", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 5049ddd..b5a8a8a 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: clsx: specifier: 2.1.1 version: 2.1.1 + jose: + specifier: 6.2.4 + version: 6.2.4 lucide-react: specifier: 1.11.0 version: 1.11.0(react@19.2.5) @@ -1078,6 +1081,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -2311,6 +2317,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.4: {} + js-yaml@4.2.0: dependencies: argparse: 2.0.1 diff --git a/app/public/_redirects b/app/public/_redirects index 2525155..452390e 100644 --- a/app/public/_redirects +++ b/app/public/_redirects @@ -4,4 +4,3 @@ /index.md /index.md 200 /robots.txt /robots.txt 200 /sitemap.xml /sitemap.xml 200 -/* /index.html 200 diff --git a/app/src/App.tsx b/app/src/App.tsx index 7b8cd9e..e1a7b96 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,6 +1,7 @@ -import { ConfigGuard } from "@/components/config-guard"; +import { AccessGuard } from "@/components/access-guard"; import { Sidebar } from "@/components/sidebar"; import { isAppPath, usePathname } from "@/lib/router"; +import DataPage from "@/pages/data"; import DomainsPage from "@/pages/domains"; import EvalsPage from "@/pages/evals"; import IngestPage from "@/pages/ingest"; @@ -11,10 +12,12 @@ import TracesPage from "@/pages/traces"; const PAGES = { "/": OverviewPage, + "/data": DataPage, "/domains": DomainsPage, "/query": QueryPage, "/ingest": IngestPage, "/evals": EvalsPage, + "/history": TracesPage, "/traces": TracesPage, "/settings": SettingsPage, } as const; @@ -36,15 +39,15 @@ export default function App() { const Page = isAppPath(pathname) ? PAGES[pathname] : NotFoundPage; return ( -
- -
-
- + +
+ +
+
- +
-
+ ); } diff --git a/app/src/components/access-guard.tsx b/app/src/components/access-guard.tsx new file mode 100644 index 0000000..e2ee5c3 --- /dev/null +++ b/app/src/components/access-guard.tsx @@ -0,0 +1,104 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import { api, ApiError, type OperatorSession } from "@/lib/api"; +import { Button } from "@/components/button"; +import { AlertCircle, Loader2, RefreshCw, ShieldCheck } from "lucide-react"; + +const OperatorContext = createContext(null); + +export function useOperator() { + const operator = useContext(OperatorContext); + if (!operator) { + throw new Error("useOperator must be used inside AccessGuard"); + } + return operator; +} + +export function AccessGuard({ children }: { children: React.ReactNode }) { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + + async function loadSession() { + setLoading(true); + setError(null); + setStatus(null); + try { + setSession(await api.getSession()); + } catch (cause) { + setSession(null); + setStatus(cause instanceof ApiError ? cause.status : null); + if (cause instanceof ApiError && cause.body && typeof cause.body === "object") { + const message = (cause.body as { error?: unknown }).error; + setError(typeof message === "string" ? message : cause.message); + } else { + setError(cause instanceof Error ? cause.message : "Unable to verify operator access"); + } + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadSession(); + }, []); + + if (loading) { + return ( +
+
+ + Verifying internal access… +
+
+ ); + } + + if (!session) { + const configurationError = status === 503; + return ( +
+
+
+ + + +

+ {configurationError ? "Internal access needs setup" : "Internal access required"} +

+

+ {configurationError + ? "Configure the Cloudflare Access audience and team domain for this Pages project." + : "Sign in through Cloudflare Access to inspect private corpus data and query history."} +

+
+ +
+ +
+

Access check failed

+

{error}

+
+
+ + +
+
+ ); + } + + return ( + + {children} + + ); +} diff --git a/app/src/components/config-guard.tsx b/app/src/components/config-guard.tsx deleted file mode 100644 index 7691920..0000000 --- a/app/src/components/config-guard.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useEffect, useState } from "react"; -import { - isConfigured, - getServiceUrl, - getServiceKey, - setServiceUrl, - setServiceKey, - api, - ApiError, -} from "@/lib/api"; -import { Button } from "@/components/button"; -import { Loader2, AlertCircle, ArrowRight } from "lucide-react"; - -export function ConfigGuard({ children }: { children: React.ReactNode }) { - const [configured, setConfigured] = useState(false); - const [mounted, setMounted] = useState(false); - - // Inline connection form state - const [url, setUrl] = useState(""); - const [key, setKey] = useState(""); - const [connecting, setConnecting] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - setMounted(true); - const configuredNow = isConfigured(); - setConfigured(configuredNow); - if (!configuredNow) { - // Pre-fill URL from env default if available - setUrl(getServiceUrl()); - setKey(getServiceKey()); - } - }, []); - - // Listen for storage changes (e.g. user saves in settings page) - useEffect(() => { - function onStorage() { - setConfigured(isConfigured()); - } - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - async function handleConnect() { - if (!url.trim() || !key.trim()) return; - setConnecting(true); - setError(null); - // Save first so the API client can use them - setServiceUrl(url.trim()); - setServiceKey(key.trim()); - try { - await api.getStatus(); - setConfigured(true); - } catch (e) { - setError( - e instanceof ApiError - ? `API error ${e.status} — check URL and key` - : e instanceof Error - ? e.message - : "Connection failed", - ); - } finally { - setConnecting(false); - } - } - - if (!mounted) { - return ( -
-
Loading…
-
- ); - } - - if (!configured) { - return ( -
-
- {/* Header */} -
- - KB - -

- Connect your Knowledgebase -

-

- Enter your RAG service URL and service key to start - managing domains, ingesting files, and running queries. -

-
- - {/* Connection form */} -
- - - - {error && ( -
- - {error} -
- )} - - -
- - {/* Help text */} -

- Don't have a service key? Run the Worker locally with{" "} - - pnpm dev - {" "} - and check the Worker logs, or set{" "} - - RAG_SERVICE_KEY - {" "} - in your Worker secrets. -

-
-
- ); - } - - return <>{children}; -} diff --git a/app/src/components/sidebar.tsx b/app/src/components/sidebar.tsx index a1e0114..b6e3970 100644 --- a/app/src/components/sidebar.tsx +++ b/app/src/components/sidebar.tsx @@ -1,40 +1,44 @@ import { AppLink as Link } from "@/components/app-link"; import { usePathname } from "@/lib/router"; import { cn } from "@/lib/utils"; +import { useOperator } from "@/components/access-guard"; import { LayoutDashboard, Database, Search, FileUp, FlaskConical, - Activity, + History, Settings, + TableProperties, } from "lucide-react"; const NAV = [ { href: "/", label: "Overview", icon: LayoutDashboard }, + { href: "/data", label: "Data", icon: TableProperties }, + { href: "/history", label: "Query history", icon: History }, { href: "/domains", label: "Domains", icon: Database }, { href: "/query", label: "Query", icon: Search }, { href: "/ingest", label: "Ingest", icon: FileUp }, { href: "/evals", label: "Evals", icon: FlaskConical }, - { href: "/traces", label: "Traces", icon: Activity }, { href: "/settings", label: "Settings", icon: Settings }, ]; export function Sidebar() { const pathname = usePathname(); + const operator = useOperator(); return ( -