Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 18 additions & 15 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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=<cookie> STARBOARD_SESSION_COOKIE=<cookie> pnpm run
smoke:consumer-auth -- --require-authenticated`, then re-run `proof:s`.
Expand Down
1 change: 1 addition & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ node_modules/
dist/
cloudflare-env.d.ts
.wrangler/
.dev.vars*
*.tsbuildinfo
108 changes: 108 additions & 0 deletions app/functions/_lib/access.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRemoteJWKSet>
>();

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<OperatorIdentity> {
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 },
);
}
107 changes: 107 additions & 0 deletions app/functions/api/[[path]].ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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),
});
}
9 changes: 9 additions & 0 deletions app/functions/api/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import catalog from "../../public/api-ai.json";

export async function onRequest(): Promise<Response> {
return Response.json(catalog, {
headers: {
"Cache-Control": "public, max-age=300",
},
});
}
21 changes: 21 additions & 0 deletions app/functions/api/session.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
try {
const operator = await verifyOperator(context.request, context.env);
return Response.json({ operator });
} catch (error) {
return accessErrorResponse(error);
}
}
3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions app/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion app/public/_redirects
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@
/index.md /index.md 200
/robots.txt /robots.txt 200
/sitemap.xml /sitemap.xml 200
/* /index.html 200
Loading
Loading