diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d705b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +node_modules +.next +.git +.gitignore +.env +.env.* +!.env.local.example +*.log +npm-debug.log* +.pnpm-debug.log* +coverage +playwright-report +test-results +.vscode +.idea +.DS_Store +README.md +CONTEXT.md +AGENTS.md +CLAUDE.md +docs +e2e +skills +drizzle +Dockerfile +.dockerignore +docker-compose*.yml +_repro*.js diff --git a/.env.local.example b/.env.local.example index 7340c1a..7f28e86 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,46 +2,60 @@ # Use staging when validating staging keys. # KNOWHERE_BASE_URL=https://api-staging.knowhereto.ai -# Optional development override. When set, Notebook skips Dashboard session -# auth and Dashboard-issued JWT creation, then calls Knowhere directly with -# this key. Leave unset for production and Dashboard-authenticated staging. +# Optional development override. When set, Notebook skips session auth and +# uses the hardcoded development user, calling Knowhere directly with this +# key (or with keys from config/knowhere-keys.json). Leave unset to require +# a Notebook account. # KNOWHERE_API_KEY=sk_your_development_key_here +# Optional path to a Knowhere API keys file (multi-domain support). When +# present, its [{ label, apiKey }] entries override the single env key. +# KNOWHERE_KEYS_FILE=./config/knowhere-keys.json + # --- Chat provider (server-side only) --- -# Vercel AI Gateway key; AI SDK picks it up automatically +# Two mutually exclusive chat backends. Chat is inert until one is configured. +# +# 1. Vercel AI Gateway (default): the AI SDK picks up this key automatically +# when the model is passed as a plain string. AI_GATEWAY_API_KEY=vck_your_key_here -# Optional override — defaults to deepseek/deepseek-v4-flash -# CHAT_MODEL=deepseek/deepseek-v4-flash +# 2. Generic OpenAI-compatible API: point at any OpenAI-compatible endpoint +# (local LLM, self-hosted gateway, etc.) instead of the Vercel AI Gateway. +# CHAT_MODEL is MANDATORY in this mode. CHAT_API_KEY is the bearer key sent +# to that endpoint. +# CHAT_BASE_URL=http://localhost:11434/v1 +# CHAT_API_KEY=sk_your_openai_compatible_key +# CHAT_MODEL=qwen-plus + +# Optional override — defaults to google/gemini-3-flash +# CHAT_MODEL=google/gemini-3-flash # --- Auth (server-side only) --- # -# Notebook and Dashboard share a parent domain so a single session cookie -# issued by Dashboard is sent by the browser on every request to Notebook. -# -# Production: Dashboard = https://knowhereto.ai -# Notebook = https://notebook.knowhereto.ai -# Shared parent = .knowhereto.ai -# -# Local dev: Use /etc/hosts entries like -# 127.0.0.1 dashboard.local.knowhereto.ai -# 127.0.0.1 notebook.local.knowhereto.ai -# so the cookie shares the local parent too. - -# Dashboard origin (scheme + host + port). Notebook hardcodes the API -# route paths since they are part of Dashboard's fixed API contract. -# prod: https://knowhereto.ai -# local: http://dashboard.local.knowhereto.ai:3000 -DASHBOARD_ORIGIN=http://dashboard.local.knowhereto.ai:3000 - -# Public URL of this Notebook deployment (used as `callbackURL`). -# prod: https://notebook.knowhereto.ai -# local: http://notebook.local.knowhereto.ai:3001 -NOTEBOOK_PUBLIC_URL=http://notebook.local.knowhereto.ai:3001 +# Notebook owns its authentication: users are created by the admin CLI +# (scripts/create-user.ts), sessions are DB-backed, and the notebook-session +# cookie carries the session id. + +# Optional OAuth/SSO login providers. A provider is only offered when both +# of its credentials are set. Callback URLs are derived from the request +# origin: /api/auth//callback. +# OAUTH_GOOGLE_CLIENT_ID=... +# OAUTH_GOOGLE_CLIENT_SECRET=... +# OAUTH_GITHUB_CLIENT_ID=... +# OAUTH_GITHUB_CLIENT_SECRET=... -# Optional override of the session cookie names Dashboard sets. Defaults -# to Better Auth's standard cookie names. -# SESSION_COOKIE_NAMES=better-auth.session_token,__Secure-better-auth.session_token +# Optional Dashboard SSO: when set, the login page offers "SSO (Dashboard)". +# The browser's Dashboard session cookie (same host, any port; or a shared +# parent domain via the Dashboard's AUTH_COOKIE_DOMAIN) is forwarded to the +# Dashboard's public users.getCurrentUser endpoint to resolve the user. +# local: http://localhost:3000 +# docker: http://host.docker.internal:3000 +# DASHBOARD_ORIGIN=http://localhost:3000 + +# Public URL of this Notebook deployment (used for QStash webhook callbacks). +# prod: https://notebook.knowhereto.ai +# local: http://localhost:3001 +NOTEBOOK_PUBLIC_URL=http://localhost:3001 # --- Product analytics (client-side) --- # PostHog Project API key. When unset, Notebook skips analytics initialization. diff --git a/.gitignore b/.gitignore index 74889db..05574ed 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ # misc .DS_Store *.pem +*.swp # debug npm-debug.log* @@ -36,6 +37,7 @@ yarn-error.log* .env .env.local .env.*.local +.env.docker # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md index dfedc7e..ee15104 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,88 @@ details when the documentation isn't enough. +## Commands + +- **Install:** `pnpm install` (uses pnpm 10, Node 22) +- **Dev:** `pnpm dev` (starts Upstash QStash dev server in background + Next.js dev) +- **Lint:** `pnpm lint` +- **Typecheck:** `pnpm typecheck` +- **Unit tests:** `pnpm test` (vitest, node environment) +- **Single test:** `pnpm test -- src/path/to.test.ts` +- **Watch tests:** `pnpm test:watch` +- **E2E tests:** `pnpm test:e2e` (Playwright, chromium only) +- **Integration tests:** `pnpm test:integration` (needs `TEST_DATABASE_URL`; script currently globs `src/lib/*.integration.test.ts` which has no matches — real integration tests are in `src/domains/`) +- **DB schema push:** `pnpm db:push --force` (dev; `--force` skips the TTY prompt because `drizzle.config.ts` sets `strict: true`). drizzle-kit does **not** load `.env.local`, so pass it inline: `DATABASE_URL=… pnpm db:push --force`. `pnpm db:migrate` for prod. +- **Build:** `pnpm build` +- **Docker image:** `docker build -t knowhere-notebook:dev .` then `docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev` (standalone, non-root, port 3000). The `--add-host` flag is required for self-hosted Knowhere with LocalStack S3 so the container can resolve `localhost.localstack.cloud` to the host gateway (used for fetching table/image chunk assets server-side). To override the chat prompt templates with your own file, bind-mount it over the built-in one (host file must be world-readable, e.g. `chmod 644`): `-v /host/path/chat-prompt-templates.json:/app/public/data/chat-prompt-templates.json:ro`. +- **Knowhere API keys file:** legacy bootstrap — multiple API keys (one per document domain) live in `config/knowhere-keys.json` — `[{ "label": "domainA", "apiKey": "sk_…" }, …]`. The file is re-read per request (mtime-cached), so edits take effect with **no restart**. Bind-mount it: `-v /host/path/knowhere-keys.json:/app/config/knowhere-keys.json:ro` (host file world-readable). Without the file, `KNOWHERE_API_KEY` env remains the single-key fallback (`label: "default"`). Read via `src/integrations/knowhere-keys.ts` (server-only). Phase 3 prefers DB-backed encrypted keys (see above); the file/env path is only a fallback. Never put the file in `public/`. + +CI runs: `lint → typecheck → test → build` on PRs to `main` and `staging`. + +## Architecture + +``` +src/ + app/ Next.js App Router pages and route handlers + components/ React components — domain features and shadcn/ui primitives + domains/ Product logic: chat, chunks, sources, workspace + agent-harness/ Chat agent validation/ledger runtime + providers/ Client-side context providers + proxy.ts Edge middleware (renamed from middleware.ts in Next.js 16) + infrastructure/ Owned platform: auth, database (Drizzle + Neon Postgres) + integrations/ External systems: Dashboard oRPC, Knowhere SDK + lib/ Cross-cutting utilities (effect-operation, route-result, etc.) +``` + +- Route handlers are thin HTTP adapters: parse request → call a **Route Service** (in `src/domains/*/route-*.ts`) → serialize `RouteResult`. See `src/app/api/chat/route.ts` for the pattern. +- `RouteResult` (`src/lib/route-result.ts`) is the standard return type: `{ status, body }`. Use `routeResult.ok()`, `routeResult.badRequest()`, etc. +- `nextRouteContext` (`src/lib/next-route-context.ts`) extracts the cookie header from the incoming request for Route Services. +- Domain modules own workflow logic; Route Services own the route-to-domain boundary. + +## Key Conventions + +- **Path alias:** `@/*` → `./src/*` +- **server-only:** Server modules import `server-only`. Vitest aliases it to a no-op stub (`src/test/server-only-stub.ts`). +- **Dashboard oRPC bodies:** Always use `setEmptyJsonBody` from `src/integrations/dashboard/orpc-request.ts`. Effect's `bodyText` defaults to `text/plain`, which causes Dashboard to return the wrong response shape (200 schema mismatch → "no valid session"). +- **No raw fetch in app code:** Use Effect's `HttpClient`/`HttpClientRequest` or an existing wrapper. +- **Soft deletes:** Resources use `deletedAt` timestamps; reads filter `deleted_at IS NULL` by default. +- **DB schema:** Only portable Postgres. No Neon-only features, no pgvector. Schema at `src/infrastructure/db/schema.ts`. Drizzle config at `drizzle.config.ts` points to `DATABASE_URL`. +- **Database driver:** `DATABASE_DRIVER=pg` for local dev (postgres-js), `neon` (default) for Vercel/Neon production. +- **Auth:** Notebook owns identity (Phase 2+, ADR 0010): `users` + `account_links` (provider-agnostic, passwordHash lives here) + DB-backed `sessions`. The `notebook-session` cookie (HttpOnly) carries the session id; `getCurrentUser` (`src/infrastructure/auth/index.ts`) joins sessions × users. Users are admin-provisioned via `scripts/create-user.ts` (no public signup); passwords are Argon2id (`src/lib/password.ts`). Login is the local `/login` Server Action; logout is `src/app/auth/logout`. The edge proxy only checks cookie presence (`notebookSessionCookieName` constant — no DB import in the edge bundle); real login is always required (no dev-user bootstrap). The Dashboard is hard-cut: `ensureApiKeyForWorkspace` lives in `src/integrations/knowhere-credentials.ts` and resolves the workspace's active DB key first (decrypted via `src/lib/secret-crypto.ts`), then the file/env fallback. +- **OAuth/SSO (Phase 4, ADR 0012):** env-configured provider registry (`src/infrastructure/auth/oauth-providers.ts`; `OAUTH_GOOGLE_CLIENT_ID/_SECRET`, `OAUTH_GITHUB_CLIENT_ID/_SECRET` — a provider is only offered when its env pair is present). DIY OAuth2 authorization-code + PKCE in `src/infrastructure/auth/oauth.ts`: `GET /api/auth/[provider]/start` returns the authorize URL (JSON, client navigates); `GET /api/auth/[provider]/callback` verifies state + PKCE (short-lived HttpOnly cookies), exchanges the code, fetches userinfo, finds-or-creates the user + `account_links` row, creates a session, redirects to `/`. Callback URL is derived from the request origin. OAuth users have `password_hash = null`. +- **Dashboard SSO (Phase 4):** when `DASHBOARD_ORIGIN` is set, `/login` offers "SSO (Dashboard)". The Dashboard's Better Auth session cookie is host-scoped (not port-scoped), so the browser sends it to the notebook on another port. `GET /api/auth/dashboard/start` forwards the full cookie jar (via `cookies()`) to the Dashboard's public `users.getCurrentUser` oRPC endpoint (`POST {origin}/api/orpc/users.getCurrentUser`, empty JSON body, 3s timeout) and logs in via find-or-create — link by `(dashboard, providerUserId)` first; on email collision only adopts a user with no password, else 409. Works cross-host via the Dashboard's `AUTH_COOKIE_DOMAIN` shared-domain cookies. +- **cacheComponents pitfall:** with `cacheComponents: true` (next.config.ts), Next.js prerenders `GET` route handlers at build time — a route whose early return (e.g. env check) 404s before touching a dynamic API gets that build-time response baked into a permanent static cache (`x-nextjs-cache: HIT`, `s-maxage=31536000`). Any GET handler that depends on runtime env/cookies must call `cookies()` (or another dynamic API) BEFORE its first early return. `export const dynamic` is forbidden under cacheComponents. +- **Team sharing (Phase 4, ADR 0012):** `workspace_members(userId, workspaceId)` — `workspaces.user_id` stays the owner; members get access via rows. Membership is baked into the two repository queries `findAllByUserIdEffect` (owned ∪ member — switcher/SSR list shared workspaces) and `findByIdAndUserIdEffect` (owner OR member), so all route guards inherit it. API: `GET/POST /api/workspaces/:id/members` (invite by email, existing users only), `DELETE /api/workspaces/:id/members/:userId` (owner only). Members dialog lives in the workspace switcher ("Members…"). Re-invite revives the soft-deleted row via `onConflictDoUpdate`; the unique `(workspace_id, user_id)` index is non-partial so Postgres infers the conflict target. +- **Encrypted API keys (Phase 3/UX):** API keys are user-scoped and managed via the combined dropdown's "API keys…" dialog (`src/components/workspace-api-keys-dialog.tsx`) backed by `src/app/api/api-keys` routes. Keys are AES-256-GCM encrypted at rest (`knowhere_api_keys.user_id` FK; `KNOWHERE_KEY_ENCRYPTION_KEY` env — 32-byte base64), never shown after save, and validated against Knowhere on add (422 on invalid). Adding a key auto-creates the `(user, "default")` home workspace with it active. +- **Chat provider:** two backends in `src/lib/ai.ts` — `AI_GATEWAY_API_KEY` (Vercel AI Gateway, model as plain string) OR `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL` (OpenAI-compatible `LanguageModelV3`). Use `getChatModel()`/`isChatConfigured()`; never reintroduce per-call-site `AI_GATEWAY_API_KEY` guards. `@ai-sdk/openai-compatible` is pinned to 2.x (provider V3) to match `ai@6`. +- **BM25 retrieval:** retrieval queries run with `rerank: true` and `internalRecallK: 30` (`buildRetrievalQueryParams` in `src/domains/chat/index.ts`) to compensate for BM25 keyword ranking. The harness system prompt (`src/agent-harness/runtime.ts`) instructs keyword-crafting, query expansion, and multiple focused `retrieve` calls for multi-part questions. The transient `RetrievalTraceView` (query, namespace, hits, top scores) rides on fresh assistant messages and is rendered by `ChatRetrievalTrace` — it is never persisted to the chat message row, so don't persist or serialize it from the DB. +- **Retrieval overrides:** the chat composer exposes rerank (Switch), Recall K (Slider 5–50), and Top K (Slider 1–12) controls. They travel as optional `retrievalParams` in the chat request body (`src/domains/chat/request.ts` — schema validates + clamps) and override the hardcoded defaults / harness-chosen topK via `RetrievalOverrides` in `answerQuestionWithRetrieval`. Keep current values as UI defaults (`rerank: true`, `internalRecallK: 30`, `topK: 8`). +- **Chat prompt templates:** canned prompts live in `public/data/chat-prompt-templates.json` (`{ id, title, prompt }[]`), fetched client-side by `usePromptTemplates` (cache-busted) and shown in the composer's wand-icon Templates dropdown. Override at runtime by bind-mounting your own JSON over `/app/public/data/chat-prompt-templates.json:ro` — no rebuild needed. `src/domains/chat/prompt-templates.ts` holds only the `ChatPromptTemplate` type now. +- **Folded chat sections:** assistant "Sources" and "Retrieval" blocks are collapsed by default via the shared `CollapsibleSection` (`src/components/collapsible-section.tsx`, Base UI Collapsible). Trigger is the label row with a chevron; badge shows counts. +- **Vercel Blob is optional:** the chunk-page cache (`src/domains/chunks/server.ts`) is gated on `BLOB_READ_WRITE_TOKEN`; without it the cache is skipped and chunks are served straight from Knowhere. Don't add hard `@vercel/blob` calls in request paths without gating on the token or wrapping in a read-failure-as-miss handler. +- **Table chunk enrichment:** the Knowhere `listChunks` endpoint returns `assetUrl` for table/image chunks but the HTML is at that URL, not in `chunk.content` (which holds a summary). `enrichChunksWithAssetUrls` in `src/domains/chunks/server.ts` fetches the HTML from `assetUrl` server-side after the list call and sets it as `chunk.content` so `TableChunkCard`'s `getSanitizedTableHtml` can detect and render it. This avoids browser CORS issues with LocalStack S3 URLs. Requires `--add-host localhost.localstack.cloud:host-gateway` in Docker. +- **Fonts:** use the local `geist` package (`GeistSans`/`GeistMono` from `geist/font/*`), not `next/font/google` — the repo runs in airgapped/self-hosted setups where Google Fonts is unreachable. +- **Desktop layout:** 2-panel (sources | chat) with one resize handle. Chunks are a full-screen overlay (`fixed inset-0 z-50`), not an inline panel. `PanelId` is `"sources" | "chat"`. The chunks overlay opens via the source-row tree button or by clicking a citation in chat. A namespace dropdown in the sources panel header lets users import documents from any Knowhere namespace. +- **No demo or guest mode:** Demo catalog, guest mode, and the Official Library panel have been removed. All sources are either `kind: "workspace"` (local DB row) or `kind: "remote"` (Knowhere document not yet localized). Anonymous requests redirect to login. +- **Eager localization:** Compatible-namespace Knowhere documents are auto-localized into workspace Source rows on every source list load (`GET /api/sources` and SSR). No user click needed. `localizeRemoteLibrarySources` pre-filters against existing DB rows to avoid redundant writes. +- **SourceKind:** `"workspace" | "remote"` only. The `"demo"` variant has been removed. +- **Namespace API:** `GET /api/namespaces` lists all Knowhere namespaces with document counts. `POST /api/namespaces/[namespace]/localize` bulk-localizes all documents from a specific namespace. The SDK does not expose a namespaces endpoint, so `listKnowhereNamespaces` in `src/integrations/knowhere.ts` calls `GET /v1/documents/namespaces` directly. +- **Workspaces (UX model):** a workspace binds one user to one Knowhere namespace — `(user, namespace)` unique. Workspaces are key-agnostic: `workspaces.active_knowhere_api_key_id` (nullable) is the mutable credential pointer; `ensureApiKeyForWorkspace` resolves active key → first user key → file/env fallback. New users have **no workspace** until they add an API key (which auto-creates `(user, "default")`) or pick a namespace from the combined `WorkspaceSwitcher` dropdown (which creates `(user, namespace)` + eagerly localizes its documents, blocking with a spinner). The active workspace is tracked by the `notebook-ws` cookie; `ensureWorkspace` returns null when the user has none — route callers must 400 "No workspace yet". `getCompatibleNamespaces` is `[workspace.namespace]` only; uploads/retries target the workspace's own namespace. No legacy `notebook-` auto-creation. + +## Domain Language + +See `CONTEXT.md` for precise definitions of Workspace, Source, Parsed Chunk, Chat Thread, Citation, Route Service, Route Context, and other domain terms. Use those names in modules, tests, and route workflows. + ## UI & Design -The notebook should reuse the existing design units from the dashboard(github.com/ontosAI/knowhere-dashboard), like theme, styles, buttons, form elements, etc. For any new design units, please refer to the dashboard's design system and maintain consistency in terms of spacing, typography, and color usage. +- Reuse design units from the dashboard (github.com/ontosAI/knowhere-dashboard). Match spacing, typography, and color usage. +- shadcn/ui (base-nova style, Tailwind CSS 4). Add components via the shadcn skill or `pnpm dlx shadcn@latest add `. +- Installed shadcn primitives: alert-dialog, badge, button, card, checkbox, dialog, dropdown-menu, empty, input, scroll-area, separator, sheet, skeleton, spinner, tabs, textarea, tooltip. +- Lucide icons. Semantic Tailwind colors (`bg-primary`, `text-muted-foreground`), never raw color values. + +## Testing Quirks + +- Unit tests run in **node** environment (not jsdom) by default. Test files: `src/**/*.test.ts`. +- `server-only` is stubbed out in tests — don't expect it to throw. +- Integration tests (in `src/domains/`) use `describe.skip` when `TEST_DATABASE_URL` is unset, so `pnpm test` includes them as safe skips. To run them for real, set `TEST_DATABASE_URL` to a running Postgres. +- E2E tests (Playwright) are in `e2e/`, match `**/*.e2e.ts`. They start `pnpm dev` automatically unless `PLAYWRIGHT_EXTERNAL_WEB_SERVER=1`. diff --git a/CONTEXT.md b/CONTEXT.md index 80a0498..8905e94 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,15 +5,33 @@ terms when naming modules, tests, and route workflows. ## Workspace -A Workspace is the Notebook-owned tenant container for a Dashboard user. It -stores the local source metadata, chat threads, and the Knowhere namespace used -for retrieval. Workspace creation is idempotent per Dashboard user. +A Workspace is the Notebook-owned tenant container that binds one user to one +document domain: it stores local source metadata, chat threads, and the pair +`(knowhereKeyLabel, namespace)` — the configured API key (domain) that +authenticates Knowhere access, and the Knowhere namespace under that domain +whose documents the workspace's sources live in. One workspace per +(user, keyLabel, namespace) tuple. The active workspace is selected by the +`notebook-ws` cookie (falls back to the user's first workspace, then a legacy +`notebook-` default). Legacy rows with a null key label use the default +key and keep working unchanged. + +## Knowhere Key Label + +A Knowhere Key Label identifies one configured Knowhere API key (a "domain"). +Since Phase 3, keys are managed per workspace through the "API keys…" dialog: +stored AES-256-GCM encrypted in the `knowhere_api_keys` table and decrypted +on demand by `ensureApiKeyForWorkspace`. `workspaces.active_knowhere_api_key_id` +selects the active key. The `config/knowhere-keys.json` file (or +`KNOWHERE_API_KEY` env as a single `"default"` key) remains as a bootstrap +fallback for fresh deployments. The API never exposes full keys to the +browser — only masked labels (`sk_8aB••••GVB8`). ## Workspace Shell The Workspace Shell is the client-side orchestrator for the Notebook work surface. It composes Source selection, Parsed Chunk pagination, Chat Thread -state, Citation focus, and panel layout into the visible three-panel notebook. +state, Citation focus, and panel layout into the visible two-panel notebook +(sources | chat) with a full-screen chunks overlay. ## Workspace Shell Layout @@ -30,8 +48,8 @@ route paths or mutation request shapes inline. ## Workspace Desktop Panels Workspace Desktop Panels is the hook that owns browser measurements and resize -drag state for the three desktop panels. Pure resize math stays in Workspace -Shell State. +drag state for the two desktop panels (sources | chat). Pure resize math stays +in Workspace Shell State. ## Workspace Resize Handle Workflow @@ -54,15 +72,17 @@ Sources are soft-deleted with `deletedAt` rather than removed. ## Source Repository The Source Repository is a stable facade over smaller persistence modules. It -composes Source row lifecycle, Demo Source persistence, and Source Parse Result -artifact metadata without exposing those internal modules to route services. +composes Source row lifecycle and Source Parse Result artifact metadata +without exposing those internal modules to route services. ## Source Library Localization Source Library Localization is the workflow that turns Knowhere-owned library -documents into Notebook Source rows for a Workspace. Listing and chat should -localize missing Knowhere documents before chunks, archive, selection, or -retrieval flows act on them. +documents into Notebook Source rows for a Workspace. Listing and SSR eagerly +localize compatible-namespace documents (via `localizeRemoteLibrarySources`) +before chunks, archive, selection, or retrieval flows act on them. Only +genuinely new documents are upserted — existing DB rows are pre-filtered to +avoid redundant writes. ## Source Upload @@ -74,7 +94,7 @@ Large files should use the Blob-backed path instead of a Server Action upload. The Source Upload Contract names the repository and Knowhere client shapes used by upload workflows. Persistence adapters can depend on the contract without -importing the user-upload or Demo Source workflow implementation. +importing the user-upload workflow implementation. ## Source Row @@ -93,12 +113,6 @@ Source Upload Dialog Workflow owns browser upload dialog behavior: open state, selected file state, drag-and-drop selection, upload submission, friendly error messages, duplicate-submit prevention, and post-upload cleanup. -## Demo Source - -A Demo Source is app-owned static content served to guest users and optionally -materialized into an authenticated workspace. Demo sources should not depend on -live workspace state for guest rendering. - ## Source Original Preview Source Original Preview is the browser-side read-only view for a Source's @@ -128,7 +142,10 @@ text requests. A Parsed Chunk is a document chunk returned by the Knowhere document chunks API. Parsed chunks can have parser chunk IDs, asset paths, page numbers, -summary, keywords, and connection metadata. +summary, keywords, and connection metadata. Table chunks have their HTML +fetched server-side from `assetUrl` and set as `content` (via +`enrichChunksWithAssetUrls`) because the Knowhere list endpoint puts a +summary string in `content`, not the table HTML. ## Parsed Chunk Card @@ -150,7 +167,7 @@ callbacks. ## Chat Repository The Chat Repository is a stable facade over Chat Thread lifecycle, Chat Message -persistence, Demo Chat seeding, and Citation persistence normalization. +persistence, and Citation persistence normalization. ## Chat Message @@ -180,19 +197,57 @@ enough to focus the answer evidence. A Retrieval Query is the text sent to Knowhere retrieval. It can be generated from the latest user question plus recent chat context so Knowhere receives a -self-contained query. - -## Dashboard Auth - -Dashboard Auth is the source of truth for identity. Notebook forwards the -incoming Dashboard session cookie to Dashboard oRPC endpoints and does not -decode Dashboard session tokens itself. - -## Dashboard Service JWT - -A Dashboard Service JWT is a short-lived token issued by Dashboard and passed -to the Knowhere SDK for per-request access. Notebook does not create or store -Knowhere API keys. +self-contained query. Retrieval runs with `useAgentic: true`, `rerank: true`, +and `internalRecallK: 30` so the LLM reranker compensates for BM25 keyword +ranking. The harness system prompt teaches the agent to craft BM25-friendly +queries: distinctive keywords, query expansion with synonyms/domain terms, and +multiple focused `retrieve` calls for multi-part or ambiguous questions. + +## Retrieval Overrides + +Retrieval Overrides are optional per-request tuning values that the chat +composer sends in the chat request body as `retrievalParams`: the `rerank` +switch and the `internalRecallK` / `topK` sliders. Each present field replaces +the equivalent hardcoded default — or, for `topK`, the harness-chosen per-query +value — inside `buildRetrievalQueryParams`. The request schema validates and +clamps them server-side. + +## Retrieval Trace + +A Retrieval Trace is the transient record of every Retrieval Query issued while +answering one user question: query text, namespace, hit count, cited chunk +count, and top scores. It is attached to a fresh assistant Chat Message view +and rendered by `ChatRetrievalTrace` under the sources section, but it is never +persisted to the Chat Message row — reloading the thread drops it. + +## Prompt Template + +A Prompt Template is a canned `{ id, title, prompt }` analysis prompt offered +by the composer's wand-icon Templates menu. Templates are loaded at runtime +from `public/data/chat-prompt-templates.json` by `usePromptTemplates`, so +self-hosted deployments can override them by bind-mounting their own JSON into +the container without a rebuild. + +## Notebook Auth + +Notebook Auth is Notebook's own identity system (ADR 0010). A `User` is a +row in the `users` table; login credentials attach via `AccountLink` rows +(one per provider, `password_hash` for the "password" provider). A `Session` +is a DB row whose id rides the `notebook-session` cookie (HttpOnly, 30-day +TTL); `getCurrentUser` joins sessions × users on every request. Users are +admin-provisioned (no public signup); login is the local `/login` Server +Action and logout deletes the session row. `KNOWHERE_API_KEY` / +`KNOWHERE_KEYS_FILE` still short-circuit to the development user as a +bootstrap. + +## Knowhere Credential + +A Knowhere Credential is the API key used to call Knowhere. It is resolved +per workspace by `ensureApiKeyForWorkspace` +(`src/integrations/knowhere-credentials.ts`): the workspace's +`knowhereKeyLabel` picks a key from `config/knowhere-keys.json` (falling +back to `KNOWHERE_API_KEY` env). The Dashboard JWT path was removed in the +Phase 2 hard-cut — the Notebook never requests or stores Dashboard tokens. ## Route Service diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..47cb9c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 + +# ---- base ---- +FROM node:22-alpine AS base +RUN apk add --no-cache libc6-compat +ENV NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate +WORKDIR /app + +# ---- deps ---- +# Install with --ignore-scripts: the `prepare` script (effect-language-service +# patch) is editor tooling, not needed to build or run. +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts + +# ---- builder ---- +FROM base AS builder +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +# ---- runner ---- +FROM node:22-alpine AS runner +RUN apk add --no-cache libc6-compat +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +WORKDIR /app +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 +# Standalone server + static assets + public. The standalone output already +# bundles a traced node_modules, so no full dependency install is needed here. +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Bind-mount target for config/knowhere-keys.json (see AGENTS.md). Created +# up front so `-v ...:/app/config/knowhere-keys.json:ro` works without a +# rebuild, and owned by the non-root nextjs user for the fallback file. +RUN mkdir -p /app/config && chown nextjs:nodejs /app/config +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index efde58d..d3620dd 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ Upload documents, explore parsed content, and ask questions about your knowledge ``` 2. Fill in your API keys in `.env.local`: - - `AI_GATEWAY_API_KEY` — your Vercel AI Gateway key for chat (optional `CHAT_MODEL` override) - - `KNOWHERE_API_KEY` — optional development override that skips Dashboard auth and calls Knowhere directly + - Chat (one of): + - `AI_GATEWAY_API_KEY` — Vercel AI Gateway key (optional `CHAT_MODEL` override, default `google/gemini-3-flash`), or + - `CHAT_BASE_URL` + `CHAT_API_KEY` + `CHAT_MODEL` — any OpenAI-compatible endpoint (e.g. DeepSeek, local Xinference/vLLM). `CHAT_MODEL` is required in this mode. + - `KNOWHERE_API_KEY` — optional dev bootstrap key that enables the deterministic local user (see Authentication below) - `NEXT_PUBLIC_POSTHOG_KEY` — PostHog Project API key for front-end event tracking - `NEXT_PUBLIC_POSTHOG_HOST` — PostHog ingestion host (default `https://us.i.posthog.com`) @@ -63,7 +65,7 @@ GA4 field and event alignment guidance lives in `docs/ga4-alignment.md`. ## Tech Stack - **Framework**: [Next.js 16](https://nextjs.org) with App Router and Server Components -- **AI**: [Vercel AI SDK](https://sdk.vercel.ai) + [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) +- **AI**: [Vercel AI SDK](https://sdk.vercel.ai) via the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) or any OpenAI-compatible endpoint (see `docs/adr/0007-chat-provider-abstraction.md`) - **Knowledge**: [Knowhere Node.js SDK](https://github.com/Ontos-AI/knowhere-sdk) for document parsing and retrieval - **UI**: [shadcn/ui](https://ui.shadcn.com) + Tailwind CSS 4 - **Icons**: [Lucide](https://lucide.dev) @@ -76,28 +78,49 @@ The CI workflow runs lint, typecheck, tests, and build on pull requests targetin After changes are merged to `main`, the release workflow creates a date-based GitHub Release with a source archive and build metadata. -## Dashboard Auth Integration +## Deployment (Docker) -Notebook treats Dashboard as the auth source of truth. Server-side auth calls -forward the incoming session cookie to Dashboard oRPC endpoints, including -`/api/orpc/users/getCurrentUser` and `/api/orpc/users/issueServiceJwt`. +Notebook ships as a standalone Next.js image built with `output: "standalone"`. -For local development, setting server-side `KNOWHERE_API_KEY` switches Notebook -into API-key mode. In that mode the app uses a deterministic local development -user, skips Dashboard redirects and JWT issuance, and passes the configured key -directly to the Knowhere SDK. Leave it unset for production and normal -Dashboard-authenticated staging flows. - -Dashboard chooses its oRPC handler by request shape and `Content-Type`. -When using Effect's `HttpClientRequest.bodyText`, pass -`"application/json"` as the body content type. Setting the header before -`bodyText("{}")` is not enough because `bodyText` overwrites it with -`text/plain`. If that happens, Dashboard can return a successful OpenAPI-shaped -response instead of the RPC envelope, and Notebook will log a 200 -`schema mismatch` followed by `no valid session`. +```bash +docker build -t knowhere-notebook:dev . +docker run -d --name knowhere-notebook -p 3000:3000 \ + --env-file .env.docker knowhere-notebook:dev +``` -Use `setEmptyJsonBody` from `src/integrations/dashboard/orpc-request.ts` for empty -Dashboard oRPC POST bodies. +The image runs the traced standalone server as a non-root user on port 3000. +Provide the same variables as `.env.local` (via a gitignored `.env.docker`): +`KNOWHERE_API_KEY`/`KNOWHERE_BASE_URL`, `DATABASE_URL`/`DATABASE_DRIVER`, and +chat config (`AI_GATEWAY_API_KEY` or `CHAT_BASE_URL`+`CHAT_API_KEY`+`CHAT_MODEL`). + +When running against a Knowhere stack on the host (Docker Desktop / OrbStack), +use `host.docker.internal` in `KNOWHERE_BASE_URL` and `DATABASE_URL` so the +container can reach the host services. + +**Vercel Blob is optional.** The chunk-page cache is backed by Vercel Blob when +`BLOB_READ_WRITE_TOKEN` is set; without it, the cache is disabled and chunks +are served straight from Knowhere. This is what lets self-hosted / local +deployments work without a Blob store. + +## Authentication + +Notebook owns its authentication (ADR 0010): users, provider account links, +and DB-backed sessions live in Notebook's Postgres. The `notebook-session` +cookie carries the session id. + +- **Create a user** (admin-provisioned, no public signup): + ```bash + pnpm exec tsx --tsconfig tsconfig.scripts.json scripts/create-user.ts \ + you@example.com "a-strong-password" --name "You" + ``` +- **Login** at `/login` (email + password, Argon2id hashing). **Logout** is + the top-nav sign-out button. +- **Dev bootstrap:** setting `KNOWHERE_API_KEY` (or providing + `config/knowhere-keys.json`) still short-circuits to a deterministic + local development user so a fresh deployment works before any user is + created. This bootstrap is removed once DB-backed keys land. +- Multi-domain API keys: see `config/knowhere-keys.json` and the + Workspace switcher in the sources panel. ## Project Structure @@ -107,6 +130,13 @@ src/ ├── components/ # React components and shadcn/ui primitives ├── domains/ # Product domains: chat, chunks, sources, workspace ├── infrastructure/ # Owned platform concerns: auth and database access -├── integrations/ # External systems: Dashboard and Knowhere +├── integrations/ # External systems: Knowhere └── lib/ # Small cross-cutting utilities ``` + +## Desktop Layout + +Two-panel (sources | chat) with a resize handle. Parsed chunks and the +Official Library are full-screen overlays, triggered by the tree icon on a +source row or a citation reference in chat. + diff --git a/config/knowhere-keys.json b/config/knowhere-keys.json new file mode 100644 index 0000000..9e59e16 --- /dev/null +++ b/config/knowhere-keys.json @@ -0,0 +1,6 @@ +[ + { + "label": "default", + "apiKey": "" + } +] diff --git a/docs/adr/0007-chat-provider-abstraction.md b/docs/adr/0007-chat-provider-abstraction.md new file mode 100644 index 0000000..529da00 --- /dev/null +++ b/docs/adr/0007-chat-provider-abstraction.md @@ -0,0 +1,51 @@ +# ADR 0007: Chat Provider Abstraction (Vercel AI Gateway Or OpenAI-Compatible) + +## Status + +Accepted + +## Context + +Notebook chat — answer generation (`src/domains/chat/prompt.ts`) and diagram +generation (`src/domains/chat/diagram.ts`) — routed exclusively through the +Vercel AI Gateway. The model was passed to the AI SDK as a plain string id and +`AI_GATEWAY_API_KEY` was hard-required by a guard at every call site. + +Self-hosted and local-LLM deployments need to point chat at an arbitrary +OpenAI-compatible endpoint (DeepSeek, local Xinference/vLLM, etc.) without the +Gateway. There was no way to do that without editing call-site code. + +## Decision + +Resolve the chat model once in `src/lib/ai.ts`. Two mutually exclusive backends, +selected by environment: + +- `AI_GATEWAY_API_KEY` set (default): pass the model id as a plain string; the + AI SDK resolves it through the Vercel AI Gateway and reads the key + automatically. `CHAT_MODEL` overrides the default id. +- `CHAT_BASE_URL` set: build a `LanguageModelV3` with + `@ai-sdk/openai-compatible` from `CHAT_BASE_URL` + `CHAT_API_KEY`. + `CHAT_MODEL` is **mandatory** in this mode. + +Call sites use `getChatModel()` (the model to pass to `generateObject` / +`ToolLoopAgent`), `getChatModelLabel()` (stable log label), and +`isChatConfigured()` (the guard) instead of referencing `AI_GATEWAY_API_KEY` +directly. + +The `@ai-sdk/openai-compatible` package is pinned to the `2.x` line. The `3.x` +line targets `@ai-sdk/provider@4` (spec V4), which is incompatible with this +repo's `ai@6` (spec V3). Re-pin both together when upgrading `ai` to a V4-based +release. + +## Consequences + +Chat can target any OpenAI-compatible endpoint by setting three env vars, with +no code change. The Gateway path is unchanged. + +Adding a third provider means extending `getChatModel()` and +`isChatConfigured()`. Do not reintroduce per-call-site `AI_GATEWAY_API_KEY` +guards. + +`CHAT_MODEL` is read at call time in the OpenAI-compatible path and at module +load in the Gateway path; tests that mutate `CHAT_MODEL` after import should +assert behavior rather than the resolved module constant. diff --git a/docs/adr/0008-remove-demo-guest-official-library.md b/docs/adr/0008-remove-demo-guest-official-library.md new file mode 100644 index 0000000..f983fda --- /dev/null +++ b/docs/adr/0008-remove-demo-guest-official-library.md @@ -0,0 +1,85 @@ +# ADR 0008: Remove demo, guest mode, and Official Library + +**Date:** 2026-07-31 + +## Status + +Accepted + +## Context + +The Notebook shipped with a demo catalog system that served static content to +anonymous (guest) users and an Official Library panel that let authenticated +users browse and materialize curated demo sources into their workspace. This +added significant complexity across every layer: + +- **DB schema:** `demo_source_visibilities` table, `demo_key` columns on + `sources` and `chat_threads`, and associated indexes. +- **Domain layer:** `src/domains/demo/`, `src/integrations/knowhere-demo.ts`, + `demo-source-repository.ts`, demo catalog fetching in route listing, + demo chunk page loading, demo chat thread seeding, demo asset URL + hardening, hidden-demo-source filtering, and materialization workflow. +- **UI layer:** `OfficialLibraryPanel`, library overlay state, guest-mode + plumbing (`isGuest`, `loginUrl`, `onLoginClick`), `ContentView` type with + `"library"` variant, and `addingLibrarySourceIds` workflow state. +- **Proxy:** Guest source-read path regexes and demo asset/original path + allowlist for anonymous access. + +The self-hosted deployment does not use the demo catalog or the Official +Library. All real documents come from Knowhere namespaces. Guest mode provided +no value without the demo catalog. + +## Decision + +Remove demo, guest mode, and the Official Library entirely: + +1. **Delete** all demo-specific files: `src/integrations/knowhere-demo.ts`, + `src/domains/demo/`, `src/app/api/demo-sources/`, + `src/components/official-library-panel.tsx`, `src/domains/sources/demo-source-repository.ts`, + and demo static assets (`public/images/official-library/`, + `public/icons/official-library/`). + +2. **Simplify `SourceKind`** to `"workspace" | "remote"` (the `"demo"` variant + is removed). + +3. **Remove DB demo infrastructure:** drop `demo_source_visibilities` table, + `sources.demo_key` column + index, `chat_threads.demo_key` column + index. + +4. **Remove guest mode:** the proxy no longer allows anonymous source reads. + Anonymous requests redirect to login. `getGuest()` is removed from + `notebookRequestContext`. Unauthenticated SSR returns `{ sources: [] }`. + +5. **Remove demo plumbing from domain/components:** `demoApi` deps, + `fetchCatalog`, `hideDemoSource`, `listHiddenDemoSourceIds`, + `upsertMaterializedDemoSource`, demo chat thread seeding, demo asset URL + hardening, `materializeDemoSources` client method, `isGuest`/`loginUrl` + props, `onOfficialLibrarySourceAdd`, `addingLibrarySourceIds`, + `ContentView`/`onLibraryOpen`/`onLibraryBack`. + +6. **Replace the Official Library panel with a namespace dropdown** in the + sources panel header. The dropdown calls `GET /api/namespaces` (backed by + Knowhere's `GET /v1/documents/namespaces`) and lets users import all + documents from any namespace via `POST /api/namespaces/[namespace]/localize`. + +7. **Eagerly localize compatible-namespace documents** on every source list + load (both `GET /api/sources` and SSR). `localizeRemoteLibrarySources` + pre-filters against existing DB rows by `knowhereDocumentId` so only + genuinely new documents are upserted. + +## Consequences + +- **Simpler codebase:** ~6000 lines removed across 84 files. +- **No anonymous access:** self-hosted deployments require `KNOWHERE_API_KEY` + for dev mode or Dashboard auth for production. +- **All sources are real:** no static/demo content. Sources are either + `kind: "workspace"` (uploaded or localized DB rows) or `kind: "remote"` + (transient Knowhere documents not yet localized). +- **Eager localization means new Knowhere documents appear automatically:** + no user action needed. The pre-filter prevents write amplification on + repeated list loads. +- **Namespace dropdown extends beyond compatible namespaces:** users can + import from any Knowhere namespace, not just `default` and the workspace + namespace. This replaces the curated Official Library with open access to + all available namespaces. +- **DB schema is clean:** `db:push --force` on a fresh database creates the + simplified schema without demo tables or columns. diff --git a/docs/adr/0009-multi-domain-workspaces-file-backed-keys.md b/docs/adr/0009-multi-domain-workspaces-file-backed-keys.md new file mode 100644 index 0000000..3214cff --- /dev/null +++ b/docs/adr/0009-multi-domain-workspaces-file-backed-keys.md @@ -0,0 +1,64 @@ +# ADR 0009: Multi-domain workspaces with file-backed API keys + +**Date:** 2026-08-02 + +## Status + +Accepted + +## Context + +The Notebook previously bound one user to exactly one workspace (`user_id` +unique), with a single global Knowhere API key from `KNOWHERE_API_KEY`. For +self-hosted deployments where a Knowhere instance (or dashboard) has several +users, each with their own document domains, the operator needed one Notebook +deployment that can switch between document domains quickly, without +restarting the container. + +Requirements gathered from the operator: + +1. Each API key points to a different document domain (namespace set). +2. Switching domains must be fast and require no container restart. +3. Each workspace maps to a **namespace under a domain** — not to a domain + itself (many workspaces may share one API key, one per namespace). +4. The domain switcher lives at the top of the sources panel. +5. Legacy single-workspace rows (`notebook-`, no key label) keep + working unchanged. + +## Decision + +1. **Workspace model:** `workspaces` becomes one row per + `(userId, knowhereKeyLabel, namespace)` tuple. The `user_id` and + `namespace` uniqueness constraints are dropped; a composite unique index + `(user_id, knowhere_key_label, namespace)` replaces them. A null key label + means "default key" (legacy behavior). +2. **Key source:** `config/knowhere-keys.json` — an array of + `{ label, apiKey }`. Read server-side per request with an mtime cache, so + editing the file takes effect without a restart. Falls back to + `KNOWHERE_API_KEY` env as a single `"default"` key when the file is absent. +3. **Active workspace:** the `notebook-ws` cookie holds the active workspace + id (not a secret). `ensureWorkspace` resolves it on every request: cookie + → first workspace → legacy default creation. +4. **Credential resolution:** `ensureApiKeyForWorkspace` looks up the + workspace row, resolves its `knowhereKeyLabel` from the key source, then + falls back to the default key, then the env override, then the Dashboard + JWT (production path unchanged). +5. **API:** `GET /api/knowhere-keys` (masked labels), `GET + /api/knowhere-keys/[label]/namespaces`, `POST /api/workspaces/activate`, + `POST /api/workspaces` (`{ keyLabel, namespace }`). +6. **UI:** a `WorkspaceSwitcher` at the top of the sources panel, grouped by + domain, with a "New workspace…" dialog that picks a domain key, fetches its + namespaces, and creates the workspace for the chosen namespace. + +## Consequences + +- New workspaces pick an existing Knowhere namespace (never auto-generate a + `notebook-` for multi-domain setups). +- Keys live in a host file (operator-controlled secrets, world-readable for + the container), not in Postgres. Phase 3 will move them to encrypted DB + rows. +- The Dashboard production path (JWT issuance) is untouched; the file-backed + keys only apply in dev/self-hosted mode. +- Future phases: Notebook-owned auth (Phase 2) and DB-backed encrypted keys + (Phase 3) build on this model — the workspace `(user, keyLabel, namespace)` + binding and the cookie-tracked active workspace carry forward unchanged. diff --git a/docs/adr/0010-notebook-owned-auth.md b/docs/adr/0010-notebook-owned-auth.md new file mode 100644 index 0000000..c20a3ca --- /dev/null +++ b/docs/adr/0010-notebook-owned-auth.md @@ -0,0 +1,65 @@ +# ADR 0010: Notebook-owned authentication + +**Date:** 2026-08-05 + +## Status + +Accepted (Phase 2 of the auth overhaul) + +## Context + +The Notebook previously delegated all identity to the Knowhere Dashboard: +a Better Auth session cookie on the shared parent domain was forwarded to +Dashboard's `users.getCurrentUser` oRPC endpoint for identity, and +`users.issueServiceJwt` minted short-lived Knowhere JWTs per request. +Self-hosted deployments ran in "dev mode" with a single hardcoded +development user enabled by `KNOWHERE_API_KEY`. + +The operator wants a self-hosted Notebook that does not depend on the +Dashboard at all: its own users, its own sessions, and modular login +providers (password now, OAuth/SSO later). The Dashboard production path +is being retired (hard-cut), and self-hosted dev mode evolves into a +first-class Notebook-owned auth system. + +## Decision + +1. **Own the identity store.** New `users`, `account_links`, and `sessions` + tables in Notebook's Postgres. `account_links` is provider-agnostic + (one row per user+provider; `password_hash` lives there, not on + `users`), so OAuth providers can be added without schema changes. +2. **DB-backed sessions.** The `notebook-session` cookie (HttpOnly, + SameSite=Lax, Secure in prod, 30-day TTL) holds the session id; every + `getCurrentUser` joins `sessions × users`. Sessions are revocable + server-side (logout deletes the row). +3. **Password hashing with Argon2id** (`@node-rs/argon2`), interactive cost + tuned for login. +4. **Admin-provisioned users only.** No public signup in this phase — + `scripts/create-user.ts` creates users (email, password, optional name). + Password verification happens in the login Server Action. +5. **Local login page.** `src/app/login` renders an email+password form + (Server Action), replacing the Dashboard redirect link. +6. **Edge proxy checks the Notebook session cookie** (edge-safe constant, + no DB import in the edge bundle); anonymous redirects go to the local + `/login`. +7. **Dashboard hard-cut.** Delete `src/integrations/dashboard/` + (`api-key-service.ts`, `orpc-request.ts`), `auth/urls.ts`, + `auth/session-cookie-names.ts`. `ensureApiKeyForWorkspace` moves to + `src/integrations/knowhere-credentials.ts` and resolves credentials + solely from the workspace's key label / keys file / env — no JWT + issuance. The "Open Dashboard" top-nav link, its prop plumbing, and the + `notebook_dashboard_link_clicked` analytics event are removed. +8. **Dev-mode bootstrap kept.** `KNOWHERE_API_KEY` (or `KNOWHERE_KEYS_FILE`) + still short-circuits to the development user so a fresh deployment + works before any user is created. Removed in Phase 3 when DB-backed + keys land. + +## Consequences + +- Self-hosted Notebook is fully self-contained: users, sessions, and + Knowhere credentials live in Notebook-owned storage. +- Cloud deployments that used the Dashboard path must either adopt + Notebook auth or stay on a pre-Phase-2 release. +- Email verification, password reset, and OAuth/SSO providers are deferred + (Phase 4) but the `account_links` shape anticipates them. +- `workspaces.userId` continues to hold the Notebook user id (was the + Dashboard user id string; the semantics now point at `users.id`). diff --git a/docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md b/docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md new file mode 100644 index 0000000..318ac8b --- /dev/null +++ b/docs/adr/0011-checkpoint-single-user-workable-pre-overhaul.md @@ -0,0 +1,86 @@ +# ADR 0011: Checkpoint — single-user workable, pre-overhaul + +**Date:** 2026-08-02 + +## Status + +Accepted (checkpoint marker, not a forward decision) + +## Context + +The Notebook currently ships a self-hosted, single-user-capable state: + +- **Identity:** a single fake dev user (`knowhere-api-key-dev-user`), activated + when `KNOWHERE_API_KEY` is set. The Dashboard production path (session-cookie + forwarding + `issueServiceJwt`) still exists but is not used by self-hosted + deployments. +- **Knowhere access:** one global API key from `KNOWHERE_API_KEY` env, used as + the bearer for all Knowhere SDK calls. +- **Workspaces:** one workspace per user (`workspaces.user_id` unique), each + with an auto-generated `notebook-` namespace. +- **Chat:** answers work against localized sources, with retrieval tuning + controls, foldable Sources/Retrieval blocks, and an answer-stats trace. + +The next planned overhaul introduces multi-domain workspaces mapped to +Knowhere namespaces, then Notebook-owned authentication (users, DB sessions, +password login, Dashboard hard-cut), then DB-backed encrypted API keys. + +This checkpoint exists so we can return to a known-good, single-user state if +the overhaul proves to be the wrong direction — for example, if we decide that +focusing on document input and answer quality matters more than multi-user +auth, or if the DIY-auth approach (argon2/Drizzle/Effect services) becomes +untenable. + +## Decision + +Mark commit `ae514fe` with the annotated tag: + +``` +checkpoint/single-user-workable-pre-overhaul +``` + +Message: "Single user workable, pre-overhaul to focus on document input and +quality of answers" + +### What the checkpoint guarantees + +- `KNOWHERE_API_KEY` dev-mode works end-to-end (proxy bypass, fake user, + single global key). +- Chat retrieval is tuned for BM25 (rerank, multi-query, query expansion) + with UI override controls. +- Sources/Retrieval blocks are foldable; the chat composer has a wand + Prompts/Chart menu (JSON-served templates) and retrieval tuning sliders. +- Table chunks render server-side-enriched HTML. +- Working tree is clean; the current branch continues forward from here. + +### How to return + +```bash +# Try an alternative without losing current work: +git checkout -b alternative-plan checkpoint/single-user-workable-pre-overhaul + +# Or just inspect: +git checkout checkpoint/single-user-workable-pre-overhaul +``` + +### Deferred alternatives this checkpoint keeps open + +1. **Multi-domain model:** workspace = `(user, keyLabel, namespace)` with + file/env-backed keys (fast switch, no restart), vs. DB-backed encrypted + keys managed purely from the UI. +2. **Auth approach:** DIY (argon2 + Drizzle + Effect services, DB sessions, + admin-provisioned users, Dashboard hard-cut) vs. Better Auth vs. Auth.js — + see ADR 0010 (planned). +3. **Dashboard dependency:** whether to hard-cut Dashboard entirely in favor + of Notebook-owned auth, or keep it as an optional fallback. +4. **Focus shift:** document input quality and answer quality improvements + before or instead of multi-user auth work. + +## Consequences + +- The tag is immutable; later commits on the working branch do not move it. +- If the overhaul continues, subsequent checkpoints (`checkpoint/phase2-auth`, + `checkpoint/phase3-db-keys`, …) should follow the same `checkpoint/` naming + convention with a one-line status message. +- The tag message and this ADR are the source of truth for what the + checkpoint state includes and what alternatives were deferred. diff --git a/docs/adr/0012-oauth-sso-and-team-workspace-sharing.md b/docs/adr/0012-oauth-sso-and-team-workspace-sharing.md new file mode 100644 index 0000000..9aae86d --- /dev/null +++ b/docs/adr/0012-oauth-sso-and-team-workspace-sharing.md @@ -0,0 +1,96 @@ +# ADR 0012: OAuth/SSO and team workspace sharing + +**Date:** 2026-08-05 + +## Status + +Accepted (Phase 4 of the auth/workspace overhaul) + +## Context + +Phase 2 (ADR 0010) built Notebook-owned identity with password login only. +Operators asked for SSO (Google/GitHub) so users don't need admin-provisioned +passwords, and for team sharing so several users can work in one workspace +(namespace-scoped document set) instead of every user owning their own copy. + +## Decision + +### OAuth/SSO (P4-1, P4-2) + +1. **Env-configured provider registry.** `src/infrastructure/auth/oauth-providers.ts` + statically defines Google and GitHub providers with their OAuth2 endpoints, + scopes, and userinfo field keys; a provider is *offered* only when + `OAUTH__CLIENT_ID` and `OAUTH__CLIENT_SECRET` are both set. + Adding a provider later = a new entry in the registry + env vars; no + schema or login-page change. +2. **DIY OAuth2 authorization-code + PKCE.** No third-party OAuth SDK. + `src/infrastructure/auth/oauth.ts` builds the authorize URL with a random + `state` and S256 PKCE verifier stored in short-lived HttpOnly cookies, + then on callback verifies state, exchanges the code server-side, fetches + userinfo, and finds-or-creates the user + `account_links` row (which was + already provider-agnostic per ADR 0010). OAuth-created users have no + password — their `password_hash` is null, and only that provider's link + identifies them. +3. **Session handoff via existing session machinery.** The callback creates a + normal DB session row and redirects to `/`; nothing about the cookie or + `getCurrentUser` changes for OAuth users. +4. **Callback redirect URLs are derived from the request origin** + (`/api/auth//callback`), so the same build works on any host. + +### Team sharing via workspace members (P4-3) + +5. **New `workspace_members` table** (userId, workspaceId, soft-delete; + unique `(workspaceId, userId)`). `workspaces.user_id` remains the + **owner** (implicit); members are invitees. Roles are binary (owner vs + member) — no per-member permission matrix in this phase. +6. **Membership-aware reads, not per-route checks.** Only two queries changed: + `findAllByUserIdEffect` (owned ∪ member workspaces — the switcher and SSR + list shared workspaces automatically) and `findByIdAndUserIdEffect` + (owner OR member). Every route guard built on `findByIdAndUserIdEffect` + inherits membership access with no further edits. +7. **Invites are by email, to existing users only** (users are still + admin-provisioned / OAuth-created). The Members dialog invites by email + (404 with a friendly message if the user doesn't exist); the owner can + remove members; the owner cannot be removed. +8. **Re-invite revives the soft-deleted row** (`onConflictDoUpdate` setting + `deleted_at = NULL`) so removing and re-adding a member is idempotent. + The unique index is non-partial so Postgres can infer the conflict target. +9. **Credentials stay user-scoped and private.** Members never see the + owner's API keys; the owner's active key is used for the shared + namespace (a member's own keys are only used when the member's *own* + workspace resolves credentials). +10. **Dashboard SSO is a session handoff, not OAuth.** When + `DASHBOARD_ORIGIN` is set, the login page offers "SSO (Dashboard)". + The Dashboard's Better Auth session cookie is host-scoped (ports are + ignored for cookies), so the browser already sends it to the notebook + on another port; `GET /api/auth/dashboard/start` forwards the full + cookie jar to the Dashboard's public `users.getCurrentUser` oRPC + endpoint and logs the user in via find-or-create. Linking is by + `(dashboard, providerUserId)`; on an email collision the notebook + adopts an existing user only when that user has no password + (pristine or OAuth-created) — a password-protected account is refused + with 409, since silently adopting it would be an account takeover. + Cross-host deployments work by setting the Dashboard's + `AUTH_COOKIE_DOMAIN` (Better Auth crossSubDomainCookies) so the same + session cookie reaches the notebook on a shared parent domain. +11. **Cache Components changes GET-route prerendering.** With + `cacheComponents: true`, GET route handlers are prerendered at build + time: the Dashboard start route's early `getDashboardProvider()` 404 + (env absent at build) was baked into a year-long static cache + (`x-nextjs-cache: HIT`, `s-maxage=31536000`) that ignored runtime + env. Fix: dynamic APIs (`cookies()`) must be reached before any early + return so prerendering terminates. `export const dynamic` is not + allowed under cacheComponents. The Google/GitHub start/callback + routes escaped this because they read `request.url` first. + +## Consequences + +- OAuth users appear as normal users; the password form and provider buttons + coexist on `/login` (providers render only when configured). +- Members can chat and read sources in a shared workspace but cannot invite + others or remove the owner; there is no leave/transfer-ownership flow yet. +- A member's `active_knowhere_api_key_id` is irrelevant while they operate + in a shared workspace (owner's key resolves); if the owner deletes their + key, member access degrades exactly as owner access would. +- GitHub users with private primary emails get a provider-scoped fallback + email handle when the email endpoint yields nothing. diff --git a/docs/session-notes/page-snippets-retrieval-handoff.md b/docs/session-notes/page-snippets-retrieval-handoff.md new file mode 100644 index 0000000..9394cea --- /dev/null +++ b/docs/session-notes/page-snippets-retrieval-handoff.md @@ -0,0 +1,132 @@ +# Handoff: page chunks now carry query-hit snippets — notebook follow-ups + +**Session ID:** knowhere-self-hosted / knowhere page-snippets work (PR #245) +**Created:** 2026-08-09 +**For:** next session working in knowhere-notebook +**Status:** upstream fix DONE + verified live; notebook hardening NOT started + +--- + +## TL;DR + +The Knowhere retrieval API now returns **all query-term hits** for `page` chunks as +snippets (`content_source: "content_snippets"`, summary + up to 20 × ±100-char +windows), instead of only the LLM summary. The Gordon reproduction is fixed and +verified against the running stack. Three notebook-side follow-ups remain (M1–M3). + +## Background & evidence + +- Scenario: Labour Department Telephone Directory PDF → one giant `page` chunk + (`node_59c79a94-...`, ~183K chars). Query "Gordon" matches twice + (`CHEUNG Hon-lam Gordon`, `YUEN Chun-cheung Gordon`) but the old API returned + only the chunk summary → notebook showed "null" / no usable evidence. +- Upstream fix: `Ontos-AI/knowhere` PR #245 (merged into local test image). + API behavior now: page chunks return + `content = summary + "\n\n" + `, `content_source="content_snippets"`. +- Verified live (query "Gordon", namespace `default`, bearer + `sk_8aBdXbOvF_Qibah2-_BDNo1-VCd50A16CwfiremGVB8`): + both `CHEUNG Hon-lam Gordon 2835 2147` and `YUEN Chun-cheung Gordon 3752 8030` + appear in `results[].content`. +- SDK (`@ontos-ai/knowhere-sdk`, installed locally) already documents + `contentSource` ("Page chunks normally expose summaries as content") and + provides `chunkId` on both `RetrievalResult` and `RetrievalReferencedChunk`. + `dataType` in the SDK allows `1|2|3|4|5|6|7|8`. + +## Current repo state + +- Branch: `feat/self-hosted-chunks-overlay-layout` (clean tree, committed). +- Verify with `pnpm test` (vitest), `pnpm typecheck` (tsc --noEmit), `pnpm lint` (eslint). +- Session notes live in `docs/session-notes/`. + +--- + +## M1 — never render a literal "null" as answer/evidence + +**Symptom:** the Gordon query rendered "null" in the chat output. Root cause was +upstream (empty page content) and is fixed, but the notebook has no guard if the +API ever returns missing/empty content again. + +**Where to look (verify by reproducing first):** + +- `src/domains/chat/index.ts`: + - `mergeRetrievalResponses` (~line 642) — `answerText`/`evidenceText` are + already filtered for truthiness; check nothing later stringifies `null`. + - `collectRetrievalResults` (~line 970) and `mapDisplayedManifestArtifactsToResults` + (~line 889) build `RetrievalResult` objects from harness ledger chunks. +- `src/agent-harness/ledger.ts` — `referenced_chunk` entries are added with + `content: ""` (line 65); `read()` slices empty content. The harness prompt gets + evidence via `evidenceText`; confirm a missing `evidenceText` never interpolates + the literal string `null` into the prompt. +- UI render path: search for `.content ?? null`, `String(content)`, or template + interpolation of `content` in the chat/notebook views. + +**Acceptance:** a query returning zero usable content shows a graceful message +(e.g. the existing `NO_RESULTS_ANSWER`), never the literal text `null`. +Add a regression test in `src/domains/chat/service.test.ts` (or `index.test.ts`). + +## M2 — citation fallback by `chunkId` + +**Why:** citations are resolved by matching the citation's excerpt against chunk +content (`findByContent` in `src/domains/chunks/normalization.ts:209-222`). With +snippet-window content this match is fragile; the SDK now returns a stable +`chunkId` on every result/referenced chunk, so resolve by id first. + +**Where to look:** + +- `src/domains/chunks/normalization.ts`: + - `resolveCitationChunkByContent` (102-110) → `findByContent` (209-222). + - `findUniqueBySectionPath` (189-199) already exists as a fallback tier. +- `src/domains/chunks/index.ts:175` — `content_source` is already surfaced on + parsed chunks; expose `chunkId` the same way if not already present. + +**Suggested fallback order:** (1) exact `chunkId` match, (2) content excerpt +match (`findByContent`), (3) unique `sectionPath` match, (4) best-effort fuzzy. + +**Acceptance:** a citation whose content is a snippet window still highlights the +correct page chunk; unit tests in `src/domains/chunks/normalization` and +`src/domains/chat/citations.test.ts`. + +## M3 — explicit `dataType: 7` (page) mapping + +**Why:** `AgenticRetrievalTargetContent` (`src/domains/chat/contracts.ts:26-33`) +is `all|text|image|table|text_image|text_table` → mapped to dataType 1–6 in +`RETRIEVAL_TARGET_CONTENT_DATA_TYPES` (`src/domains/chat/index.ts:54-63`). +`page` (dataType 7) is unreachable from the agentic planner, even though page +chunks are exactly where name/directory lookups live. + +**Where to look:** + +- `src/domains/chat/contracts.ts:26-33` — add `"page"` to the union. +- `src/domains/chat/index.ts:54-63` — add `page: 7`. +- `src/domains/chat/prompt.ts:139` `toAgenticRetrievalTargetContent` — accept a + page target (e.g. when the query looks like a directory/name lookup). +- `src/agent-harness/` — check the router/tool-schema path tolerates dataType 7 + (the SDK allows it; the gateway rejected only invalid enum values, see + `route-service.test.ts:193`). +- Update planner tests (`index.test.ts`, `service.test.ts`, `prompt` tests) with + `dataType: 7` cases. + +**Acceptance:** the planner can emit a `page`-targeted query when the question is +a name/directory lookup; the query reaches the API with `dataType: 7`; page +results flow into evidence as usual. + +--- + +## Verification (end-to-end) + +1. Run against the local stack: API at `http://127.0.0.1:5005`, dashboard + `http://127.0.0.1:3000`, API key above, namespace `default`, document + "Telephone Directory" (`doc_8bdafc56ab18`). +2. Query "Gordon" — answer must cite both directory entries with the matching + lines visible as evidence (snippet content, not "null", not summary-only). +3. `pnpm test && pnpm typecheck && pnpm lint` before committing. + +## Gotchas + +- `referenced_chunk` ledger entries intentionally have `content: ""` + (`ledger.ts:65`) — content flows through `evidenceText`, not the chunk body. +- `contentSource` values seen so far: `summary` (old page behavior) and + `content_snippets` (new). Treat unknown values as safe-to-render content. +- The local test image (`knowhere-self-hosted:test-all-features`) has PR #245; + upstream merge/release timing affects when this lands for notebook users. +- Never re-verify "Gordon" against an image built before the PR — it will fail. diff --git a/docs/session-notes/session-until-knowhere-ui.md b/docs/session-notes/session-until-knowhere-ui.md new file mode 100644 index 0000000..d34cf9c --- /dev/null +++ b/docs/session-notes/session-until-knowhere-ui.md @@ -0,0 +1,3803 @@ +# knowhere-notebook full rehaul + +**Session ID:** ses_052a32ecfffeTM3WVqWWGCaDnp +**Created:** 7/29/2026, 6:12:49 PM +**Updated:** 8/6/2026, 1:24:44 AM + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.7s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e \"const r = require('/app/.next/server/chunks/[turbopack]_runtime.js'); console.log(String(r).slice(0, 400))\" 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +(sourcePath)=>({ + m: (id)=>getOrInstantiateRuntimeModule(sourcePath, id), + c: (chunkData)=>loadRuntimeChunk(sourcePath, chunkData) + }) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 5.2s) + +The runtime factory takes `sourcePath` and returns `{m, c}`. Now I can replicate exactly: call runtime with the route.js path, `c()` the chunks, then `m(179405)` to get providers exports: + +**Tool: write** + +**Input:** +```json +{ + "content": "process.chdir(\"/app\")\nprocess.env.NODE_ENV = \"production\"\n\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst sourcePath = \"server/app/api/auth/dashboard/start/route.js\"\nconst R = runtime(sourcePath)\n\n// Register all chunks the route requires (from route.js)\nconst chunks = [\n \"server/chunks/10j~_next_03ru7fd._.js\",\n \"server/chunks/10j~_next_133_78y._.js\",\n \"server/chunks/[root-of-the-server]__05u4myb._.js\",\n \"server/chunks/[root-of-the-server]__0.xpqx7._.js\",\n \"server/chunks/10j~_next_dist_0wobfip._.js\",\n \"server/chunks/_040tdh8._.js\",\n]\nfor (const chunk of chunks) {\n try {\n require(\"/app/.next/\" + chunk)\n } catch (error) {\n console.error(\"chunk require failed:\", chunk, error.message)\n }\n try {\n R.c(chunk)\n } catch (error) {\n console.error(\"c() failed:\", chunk, error.message)\n }\n}\n\ntry {\n const providers = R.m(179405)\n const dash = providers.getDashboardProvider()\n console.log(\"getDashboardProvider() =>\", JSON.stringify(dash))\n} catch (error) {\n console.error(\"m(179405) failed:\", error.message)\n}\n", + "filePath": "/var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-chunk2.js" +} +``` + +**Output:** +``` +Wrote file successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.9s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker cp /var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-chunk2.js knowhere-notebook:/tmp/probe-chunk2.js && docker exec knowhere-notebook node /tmp/probe-chunk2.js 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +m(179405) failed: providers.getDashboardProvider is not a function + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.9s) + +Module 179405's exports aren't directly the named exports — Turbopack uses an exports object with a special shape. Let me inspect what `R.m(179405)` actually returns: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nprocess.chdir(\"/app\")\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\nrequire(\"/app/.next/server/chunks/10j~_next_03ru7fd._.js\")\nrequire(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\")\nR.c(\"server/chunks/10j~_next_03ru7fd._.js\")\nR.c(\"server/chunks/[root-of-the-server]__05u4myb._.js\")\nconst p = R.m(179405)\nconsole.log(\"typeof:\", typeof p)\nconsole.log(\"keys:\", Object.keys(p).slice(0, 10))\nconsole.log(\"hasOwn getDashboardProvider:\", Object.prototype.hasOwnProperty.call(p, \"getDashboardProvider\"))\nconst esm = p[Symbol.for(\"turbopack/esm\")] || {}\nconsole.log(\"esm keys:\", Object.keys(esm).slice(0, 10))\n' 2>&1 | head -10", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +typeof: object +keys: [ + 'exports', + 'error', + 'id', + 'namespaceObject', + 'parents', + 'children', + 'loaded' +] + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.9s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nprocess.chdir(\"/app\")\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\nrequire(\"/app/.next/server/chunks/10j~_next_03ru7fd._.js\")\nrequire(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\")\nR.c(\"server/chunks/10j~_next_03ru7fd._.js\")\nR.c(\"server/chunks/[root-of-the-server]__05u4myb._.js\")\nconst p = R.m(179405)\nconsole.log(\"namespace keys:\", Object.keys(p.namespaceObject || {}))\nconst ns = p.namespaceObject\nif (ns) {\n console.log(\"getDashboardProvider:\", typeof ns.getDashboardProvider)\n if (ns.getDashboardProvider) {\n console.log(\"=>\", JSON.stringify(ns.getDashboardProvider()))\n }\n}\n' 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +namespace keys: [ 'getDashboardProvider', 'getOAuthProvider' ] +getDashboardProvider: function +=> {"kind":"dashboard","name":"dashboard","displayName":"Dashboard","dashboardOrigin":"http://host.docker.internal:3000"} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 21.5s) + +_Thinking:_ + +CRITICAL FINDING: `getDashboardProvider()` returns the provider correctly when called through the module registry with env set! So the module code is fine and reads the env at runtime correctly. + +So why does the running server return 404? The route handler is called... but wait — maybe the route handler that RUNS in the server is NOT this one! Maybe the RUNNING server is an OLD container that was never restarted! I rebuilt the image and ran a new container (a7c723a565fb7f0081c202da951a7a35fdce25464a4b27d0a62da3d47695f441). Hmm, but I did `docker rm -f` first. + +Wait — actually, hold on. Let me check: is the login page served by THIS container? The login page shows the button (runtime-rendered, from RSC payload with providers including dashboard). So this container's login page code (new code) sees the env. And this container's route (same build) 404s?! + +But my probe JUST proved that calling getDashboardProvider via this exact chunk with this exact env returns the provider! + +Unless... the RUNNING server process was started BEFORE the env was added? No — container started with --env-file. + +OR — the running server is serving STALE CODE. Wait — could there be a SECOND next-server running INSIDE the container? I saw only PID 1. But wait — standalone mode: `next-server (v` — hmm, is the process actually the OLD server from the previous container? No, new container. + +Wait, wait. Let me re-examine: maybe the issue is that the request isn't even reaching the route handler! Maybe the PROXY (edge middleware) handles it — no, proxy just redirects or passes through. + +Hmm, hold on. Let me re-check: does the RUNNING server have the NEW route? The login page is new code (shows dashboard button — new feature). The route returns the NEW message "Dashboard SSO is not configured. Set DASHBOARD_ORIGIN." — that's ALSO new code! So the running server HAS the new route, and it executes, and getDashboardProvider() returns null in its context... but my probe with the same chunk + env returns the provider. + +The ONLY difference between my probe and the server: the server might have a DIFFERENT `process.env`! My probe node process inherits env from `docker exec` — which gets the container's env (DASHBOARD_ORIGIN set). The server process (PID 1) — I confirmed /proc/1/environ HAS DASHBOARD_ORIGIN. + +Unless... the server replaces process.env at startup! Next.js standalone server.js — does it do `process.env = {...}`? Let me check server.js for env replacement... Actually, Next.js has `loadEnvConfig` which merges .env files into process.env. If the image has a baked `.env.production` or similar WITHOUT DASHBOARD_ORIGIN... but that would only ADD keys, not delete existing ones. + +Hmm wait — actually! I bet it's the `env` field in next.config! `nextConfig.env = {}` — when set, Next.js **replaces `process.env` references in the server bundle** with values from config at build time! No wait, `env:{}` is empty — and it's the standalone config serialized. + +Hold on, let me look at this from a completely different angle: maybe there are TWO route files! The static route `/api/auth/dashboard/start` AND the dynamic `[provider]` route — and the DYNAMIC one might take precedence for `/api/auth/dashboard/start`! The [provider] route for provider="dashboard" would call getOAuthProvider("dashboard") → null → 404 "OAuth provider 'dashboard' is not configured." — different message. We got "Dashboard SSO is not configured." — that's the static route's message. So no. + +OK here's the thing — my probe PROVES the code+env works. The server 404s. So the server must be executing DIFFERENT code or have DIFFERENT env. Since PID 1's environ has the env... + +Let me just test: add a debug to the running server by hitting an endpoint that echoes env. Actually — simplest: `next-server` might cache env in a way that route handlers see a snapshot from BUILD time. The build ran WITHOUT DASHBOARD_ORIGIN (no env at build). If Next inlined `process.env.DASHBOARD_ORIGIN` at build time as `undefined` for ROUTE HANDLERS (not pages)... but my probe showed the compiled chunk reads `process.env.DASHBOARD_ORIGIN` at runtime — and it WORKS in my probe. + +WAIT. My probe loaded the chunk fresh in MY node process. But the SERVER already loaded the chunk at startup. What if the server loaded a DIFFERENT chunk file? Let me check if there are TWO copies of the route chunk — one at /app/.next/server/chunks/ (which I probed) and the standalone uses a different path? The standalone server is at /app/server.js and serves from /app/.next. The chunks I probed ARE /app/.next/server/chunks/. Same files. + +Hmm, hold on. Is the RUNNING server maybe... the OLD container? Let me check container start time and whether the new image is what's running. `docker ps` earlier showed knowhere-notebook with the new ID. The rebuild + recreate happened AFTER the fix... + +Actually wait — I should double-check the timeline: +1. First build (cached) → old image +2. Rebuilt with --no-cache → new image a001f98fd... → new container a7c723a565fb7f0081c202da951a7a35fdce25464a4b27d0a62da3d47695f441 +3. Tested: login shows button, route 404s + +The login page showing the button PROVES the new code + env is active in the server. So the server sees the env for pages but not for route handlers?! + +OH WAIT. I think I finally get it. **Next.js route handlers run in the "next-server" but pages might be served by... no.** + +Hmm, actually let me look at server.js more carefully — maybe there's an `env` snapshot loaded via `loadEnvConfig` that RESETS process.env. If server.js does `process.env = Object.assign({}, process.env, loadEnvConfig().combinedEnv)` — no, that preserves. + +Actually — you know what, let me just check whether the route handler response differs if I set the env var IN THE ROUTE HANDLER context. But I can't without rebuilding. + +Better: let me check the server.js env loading. + +My probe proves the compiled code + env work. The running server must differ. Let me check server.js's env loading — maybe it snapshots/overrides `process.env`: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst src = fs.readFileSync(\"/app/server.js\", \"utf8\")\nlet i = 0\nwhile ((i = src.indexOf(\"process.env\", i)) !== -1) {\n console.log(\"...\", src.slice(Math.max(0, i - 60), i + 80).replace(/\\s+/g, \" \"))\n i += 10\n}\n' 2>&1 | head -12", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +... t path = require('path') const dir = path.join(__dirname) process.env.NODE_ENV = 'production' process.chdir(__dirname) const currentPort +... ion' process.chdir(__dirname) const currentPort = parseInt(process.env.PORT, 10) || 3000 const hostname = process.env.HOSTNAME || '0.0.0.0' +... t = parseInt(process.env.PORT, 10) || 3000 const hostname = process.env.HOSTNAME || '0.0.0.0' let keepAliveTimeout = parseInt(process.env.K +... .env.HOSTNAME || '0.0.0.0' let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10) const nextConfig = {"env":{},"webpack":null, +... tgres"],"turbopack":{"root":"/app"},"distDirRoot":".next"} process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig) requi + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.7s) + +server.js doesn't override env. The nextConfig has `"env":{}` — empty, so no env replacement. My probe works, the server's module works, env is in PID 1... + +Let me test the server's own behavior directly — hit the route through the server but check whether the running server process was started **before** the env was present (e.g., restarted container but old process). The container was started fresh... Let me try one thing: restart and immediately test with a cookie header present (which forces full handler execution path through request meta): + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({\n host: \"localhost\", port: 3000, path: \"/api/auth/dashboard/start\", method: \"GET\",\n headers: { cookie: \"better-auth.session_token=abc\" }\n}, res => {\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(res.statusCode, d))\n})\nreq.end()\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +404 {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.4s) + +Now let me verify the actual hypothesis directly: **the login page RSC payload I fetched might be a prefetched build artifact**, not a runtime render. Let me check the RSC payload route: + +**Tool: bash** + +**Input:** +```json +{ + "command": "curl -s -H \"RSC: 1\" \"http://localhost:3001/login\" -D - -o /tmp/login.rsc 2>/dev/null | grep -i \"prerender\\|content-type\"; echo \"---\"; grep -o \"dashboard\" /tmp/login.rsc | head -2; wc -c /tmp/login.rsc", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +Content-Type: text/x-component +--- +dashboard + 7220 /tmp/login.rsc + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 15.8s) + +_Thinking:_ + +The RSC payload DOES contain "dashboard" — so at runtime, the server's `listLoginProviders()` returns the dashboard provider, meaning `getDashboardProvider()` in the page context returns non-null with the runtime env. But the ROUTE context returns null. Same process, same env, same compiled module code (proven by my probe). + +Wait — is it possible the login page content I see is served from a PREFETCHED/PRERENDERED artifact at a DIFFERENT path? The prerendered login.html on disk does NOT contain the button. But the served page and RSC payload DO. So the served page is a RUNTIME render with the env → proving the runtime env is set in the page context. + +Hmm, hold on. Unless... `x-nextjs-prerender: 1` on a DYNAMIC page with `connection()` — in Next 16, `connection()` was supposed to make the page dynamic. But the header still says prerender: 1... Maybe the page IS served from a build-time prerender that was regenerated? No — the on-disk login.html has no button. + +Wait, actually — let me re-check: is the served login page maybe a FRESH render (dynamic) while the header `x-nextjs-prerender: 1` is misleading? The RSC payload contains the dashboard provider — that's a runtime computation (process.env check). If the page were statically prerendered, the RSC would be static too and NOT contain dashboard (since build-time env was absent). So the served page is definitively runtime-rendered WITH the env present. + +So: page runtime = env present. Route runtime = env absent (404). Same process (PID 1, single next-server). SAME compiled module (179405, proven to read process.env and return the provider in my probe with env set). + +The ONLY remaining explanation: the route handler runs in a context where `process.env` differs — e.g., Next.js route handlers may be executed in a **separate Node.js context/worker** in standalone mode... but ps shows only PID 1. + +Hmm wait — actually! Maybe the ROUTE HANDLER runs through a DIFFERENT runtime instance than pages: Next.js 16 uses `next-server` for both, but there may be a "route handler bundling" where route handlers get `process.env` **inlined at build time** into the chunk — no, we PROVED the chunk reads process.env at runtime, and my probe (loading the exact chunk) returned the provider! + +Let me look at this from a totally different angle: maybe the request is NOT hitting my route at all. Maybe `/api/auth/dashboard/start` is matched by the `[provider]` dynamic route (provider="dashboard") because static route precedence works differently, OR because the static route.js is somehow not registered... but the 404 message is MY dashboard message ("Dashboard SSO is not configured"), NOT the [provider] message ("OAuth provider 'dashboard' is not configured."). So my route IS being hit. + +So my route's compiled handler (module 990295) runs, calls n.getDashboardProvider() where n = e.i(179405)... + +WAIT. Let me re-read the compiled route handler: + +`async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return ...404...` + +where `n=e.i(179405)`. My probe did `R.m(179405)` and got the namespace with getDashboardProvider returning the provider. But `e.i` vs `R.m` — maybe `e.i(179405)` resolves a DIFFERENT module than `R.m(179405)`!! In Turbopack, `e.i` is the require function for module ID... Actually in the compiled chunk, `e` is the runtime's module-instance context... Let me check what `e.i` means vs `R.m`. + +Actually — the module 179405 in chunk 05u4myb... but WAIT. Are there TWO registrations of module 179405? The chunk 0lns57e and 0ovju8m ALSO contain the providers code — maybe with DIFFERENT module IDs! If the route's `e.i(179405)` loads a module registered in a DIFFERENT chunk with the same ID but that one was registered when env was undefined... + +Hmm, no — module IDs are global across the bundle. + +Let me try yet another approach: directly call the compiled route handler function with a fake request through the module registry, exactly like Next does. + +My probe loaded the chunk and got the provider. The server's page runtime also sees the env. Only the route 404s. Let me invoke the compiled route handler directly through the registry with a mock request: + +**Tool: write** + +**Input:** +```json +{ + "content": "process.chdir(\"/app\")\nprocess.env.NODE_ENV = \"production\"\nprocess.env.DASHBOARD_ORIGIN = \"http://host.docker.internal:3000\"\n\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\n\nconst chunks = [\n \"server/chunks/10j~_next_03ru7fd._.js\",\n \"server/chunks/10j~_next_133_78y._.js\",\n \"server/chunks/[root-of-the-server]__05u4myb._.js\",\n \"server/chunks/[root-of-the-server]__0.xpqx7._.js\",\n \"server/chunks/10j~_next_dist_0wobfip._.js\",\n \"server/chunks/_040tdh8._.js\",\n]\nfor (const chunk of chunks) {\n require(\"/app/.next/\" + chunk)\n R.c(chunk)\n}\n\n// route.js: R.m(965445).exports — that's the route module's exports\nconst routeModule = R.m(965445)\nconsole.log(\"route exports keys:\", Object.keys(routeModule.exports || routeModule.namespaceObject || {}))\nconst ns = routeModule.namespaceObject || routeModule.exports\nif (ns.GET) {\n console.log(\"GET type:\", typeof ns.GET)\n ns.GET(new Request(\"http://localhost:3000/api/auth/dashboard/start\"), {})\n .then((res) => res.json())\n .then((body) => console.log(\"ROUTE RESPONSE:\", JSON.stringify(body)))\n .catch((e) => console.error(\"route error:\", e.message))\n} else {\n console.log(\"no GET export found\")\n}\n", + "filePath": "/var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route.js" +} +``` + +**Output:** +``` +Wrote file successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.1s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker cp /var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route.js knowhere-notebook:/tmp/probe-route.js && docker exec knowhere-notebook node /tmp/probe-route.js 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +route exports keys: [] +no GET export found + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.8s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nprocess.chdir(\"/app\")\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\nrequire(\"/app/.next/server/chunks/10j~_next_03ru7fd._.js\")\nrequire(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\")\nR.c(\"server/chunks/10j~_next_03ru7fd._.js\")\nR.c(\"server/chunks/[root-of-the-server]__05u4myb._.js\")\nconst m = R.m(965445)\nconsole.log(\"965445 keys:\", Object.keys(m))\nconsole.log(\"loaded:\", m.loaded)\nconsole.log(\"exports:\", Object.keys(m.exports || {}))\nconsole.log(\"ns:\", m.namespaceObject ? Object.keys(m.namespaceObject) : null)\n' 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +965445 keys: [ + 'exports', + 'error', + 'id', + 'namespaceObject', + 'parents', + 'children', + 'loaded' + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.0s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nprocess.chdir(\"/app\")\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\nrequire(\"/app/.next/server/chunks/10j~_next_03ru7fd._.js\")\nrequire(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\")\nR.c(\"server/chunks/10j~_next_03ru7fd._.js\")\nR.c(\"server/chunks/[root-of-the-server]__05u4myb._.js\")\nconst m = R.m(965445)\nconsole.log(\"loaded:\", m.loaded)\nconsole.log(\"error:\", m.error ? m.error.message : null)\nconsole.log(\"exports:\", Object.keys(m.exports || {}))\n' 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +loaded: true +error: null +exports: [] +/app/.next/server/chunks/[turbopack]_runtime.js:845 + throw new Error(factoryNotAvailableMessage(id, sourceType, sourceData)); + ^ + +Error: Module 578883 was instantiated because it was required from module 965445, but the module factory is not available. + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 12.3s) + +Module 965445 needs all its dependencies — my chunk list was incomplete (missing `10j~_next_133_78y._.js` and others). But this is getting too deep. Let me step back and think: + +**The login page renders the provider from runtime env; the route doesn't.** My probe of the SAME chunk (179405) with the env set returns the provider. So the module code is right. The difference must be in **when the server process captured env**. + +Actually — WAIT. I just realized: maybe the running container's server process was started with the env, but **Next.js caches env access in the standalone `nextConfig.env`** — empty. Hmm. + +Let me try the simplest possible thing: **restart the container** (fresh process, definitely picks up env) — I did restart earlier and it still 404'd. But that restart was BEFORE the no-cache rebuild. The current container is from the no-cache build... and I never restarted IT. Let me restart it now: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker restart knowhere-notebook && sleep 6 && docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nhttp.get({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start\"}, res => {\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(res.statusCode, d))\n})\n'", + "timeout": 60000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +knowhere-notebook +404 {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.4s) + +_Thinking:_ + +Still 404 after restart. But the login page renders the provider. Let me re-verify the login page STILL renders it after restart (not cached): + +Still 404. Let me re-verify the login page is still showing the button after the restart (to rule out stale HTML from before): + +**Tool: bash** + +**Input:** +```json +{ + "command": "curl -s -H \"RSC: 1\" \"http://localhost:3001/login?t=$(date +%s)\" | grep -o \"dashboard\" | head -1; echo \"---full page:\"; curl -s \"http://localhost:3001/login?t=$(date +%s)\" | grep -c \"SSO (Dashboard)\"", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +dashboard +---full page: +1 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 11.7s) + +_Thinking:_ + +The login page (runtime, RSC payload) DOES show the dashboard provider — so at runtime, `listLoginProviders()` → `getDashboardProvider()` returns non-null in the PAGE context. But in the ROUTE context it returns null. Same process, same env, same module code (proven by my probe). + +Wait... unless the login page RSC payload is NOT runtime-rendered. Let me check if the RSC payload is a build artifact too. The prerender manifest said `/login.rsc` is a static route! The RSC payload I fetched might come from `/app/.next/server/app/login.rsc` build artifact — and if that artifact was built when... no wait, the artifact would NOT have dashboard (build env didn't have DASHBOARD_ORIGIN). But the RSC payload HAS dashboard. So it must be runtime-rendered... OR the artifact was built WITH DASHBOARD_ORIGIN set! + +WAIT. What if `docker build` DID have DASHBOARD_ORIGIN set?! When I ran `docker build --no-cache`, the build environment... does docker build pass host env? No, docker build does NOT inherit host env unless --build-arg. But wait — maybe the build picked up `.env.docker`?? No, Next doesn't read .env.docker. + +Hmm, but actually — the login page HTML I checked on disk (`/app/.next/server/app/login.html`) did NOT contain the button. And `x-nextjs-prerender: 1` + `x-nextjs-stale-time: 300` suggests ISR with revalidation. The served page contains the button but the on-disk prerender doesn't! + +That means: the served page was rendered at REQUEST TIME (on-demand ISR or dynamic), with the runtime env present → button appears. The build-time prerender (no env) has no button. Consistent: runtime env IS visible to page rendering. + +So the SAME process, at runtime, renders the page with env visible... but the route handler with env invisible?! + +UNLESS — the route handler and the page renderer run in DIFFERENT processes! In standalone Next.js, there's ONE next-server process (PID 1). But... wait, could the page rendering happen through the RSC worker while route handlers run in the main server with a DIFFERENT env?? No, one process. + +Hold on. Let me recheck: is it possible that PID 1 is NOT the process serving? OrbStack (macOS) — `lsof` showed OrbStack PID 94430 listening on 3001 (the port forward). Inside the container, PID 1 = next-server. My in-container curl to localhost:3000 hits the container's server directly. + +Let me check the in-container server's env at RUNTIME via a different route that we know reads env — actually, let me check if the RSC payload for the login page is served from the prerendered artifact or runtime. The `x-nextjs-prerender: 1` header on the full HTML — let me fetch with RSC header and look for the header. + +The login page shows the provider at runtime but the route doesn't — same process. Let me check if the login RSC is actually a **runtime** render or a stale artifact, by checking response headers: + +**Tool: bash** + +**Input:** +```json +{ + "command": "curl -s -D - -o /dev/null -H \"RSC: 1\" \"http://localhost:3001/login?t=$(date +%s)\" | grep -iE \"prerender|stale|age|etag|cache\" ; echo \"=== vs static file:\"; docker exec knowhere-notebook sh -c 'ls -la /app/.next/server/app/login.rsc 2>/dev/null; grep -c \"dashboard\" /app/.next/server/app/login.rsc 2>/dev/null'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate +=== vs static file: + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.7s) + +_Thinking:_ + +`Cache-Control: private, no-cache, no-store` — so the login page IS dynamically rendered at runtime (Suspense streaming with connection()). The RSC payload contains "dashboard" — meaning at runtime, `listLoginProviders()` returns the dashboard provider, which means `getDashboardProvider()` returns non-null at runtime in the PAGE context. + +But in the ROUTE context, it returns null. SAME process. SAME chunk code (proven by probe). + +Wait — is it possible the page and route use DIFFERENT copies of the module with different env references? The page's copy is in chunk `0lns57e` or `0ovju8m` (both read env at runtime). The route's copy is in `05u4myb` (reads env at runtime). Both read `process.env` at runtime... + +Hmm, actually — hold on. Let me reconsider. Is the RSC payload with "dashboard" REALLY from a runtime render, or is it from a **prefetched/cached RSC payload generated at build time**? The `Cache-Control: no-store` says runtime render. But the RSC body contained "dashboard"... + +Actually wait — let me look at the actual RSC content around "dashboard" to confirm it's the provider list. + +The login page is definitely a runtime render (no-store) and contains the provider. Let me confirm what the RSC "dashboard" text is, and critically — check whether the **route handler runs in a separate Node worker** in standalone mode (Next.js 16 can isolate route handlers): + +**Tool: bash** + +**Input:** +```json +{ + "command": "curl -s -H \"RSC: 1\" \"http://localhost:3001/login?t=$(date +%s)\" | grep -o \"providers[^]]\\{0,60\\}\" | head -1; echo \"=== threads in PID 1:\"; docker exec knowhere-notebook sh -c 'ls /proc/1/task/ | wc -l; cat /proc/1/status | grep -E \"Threads|Name\"'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +providers":[{"name":"dashboard","displayName":"Dashboard"} +=== threads in PID 1: +11 +Name: next-server (v +Threads: 11 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 20.2s) + +_Thinking:_ + +The login page runtime render shows providers with dashboard. The route 404s. Same process (11 threads, one process). My probe of the module code shows it reads env at runtime and returns the provider. + +Wait... hold on. Let me recheck the route handler's actual execution. The route module 990295 calls `n = e.i(179405)` and `(0,n.getDashboardProvider)()`. My probe showed module 179405's `getDashboardProvider` returns the provider when env is set. So if the route handler runs with the env set, it should NOT 404. + +Unless... the ROUTE handler is not my route! Wait — the message "Dashboard SSO is not configured. Set DASHBOARD_ORIGIN." — that message string appears ONLY in my route (the 404 message). The `[provider]` route's message is "OAuth provider 'dashboard' is not configured." Different. So my static route IS being hit. + +Hmm wait, actually — let me re-examine. Could there be TWO route.js files? One in `app/api/auth/dashboard/start/route.js` and maybe an OLD cached one? I did `docker rm -f` and recreated. The image was rebuilt with --no-cache... + +Actually, WAIT. Let me check the response more carefully. When I hit `/api/auth/dashboard/start`, is it possible the edge proxy (proxy.ts) is serving the response? No — proxy only redirects or passes through. The 404 JSON comes from my route handler. + +Let me look at this from a completely different angle: maybe `process.env` inside the route handler is NOT the global `process.env`! Next.js route handlers in standalone mode might use a bundled `process` shim. In the compiled chunk, `process.env.DASHBOARD_ORIGIN` — if the chunk is compiled for the "edge" runtime or a bundled env, `process` could be a polyfilled object with only certain env vars (from nextConfig.env = {} → empty!). + +That's it — `nextConfig.env = {}` is the KEY. Next.js's `env` config replaces `process.env` in the SERVER BUNDLE with `process.env` values captured at BUILD TIME (or the provided env object). When `env: {}` is set, Next REPLACES process.env with a build-time snapshot! And at build time, DASHBOARD_ORIGIN was NOT set (build env had no .env.docker). So the bundled process.env inside route handlers lacks DASHBOARD_ORIGIN! + +But wait — the login page DOES see it... because the login page is rendered via the RSC/SSR pipeline which uses the runtime process.env? Hmm, that seems inconsistent. Actually no — Next's `env` config inlining applies to... let me think. + +Actually Next.js docs: `env` — "Environment variables defined in next.config.js will be loaded into process.env at build time". Actually it says: "This feature is deprecated... When used, the env variables are inlined into the server bundle at build time." If nextConfig.env is `{}` — hmm, but the config shows `"env":{}`. If it were inlining, it'd inline the build-time process.env — including maybe other vars but NOT DASHBOARD_ORIGIN (unset at build). + +But the login page sees DASHBOARD_ORIGIN at runtime! Contradiction... UNLESS the login page's module copy (0lns57e / 0ovju8m) is in a chunk that reads the REAL runtime process.env, while the route's copy (05u4myb) got the inlined/bundled one. + +Wait — but my probe of 05u4myb's module 179405 returned the provider correctly! My probe ran in the container where DASHBOARD_ORIGIN IS set in the actual process.env... and it worked. So 05u4myb reads real process.env. + +Ugh. Let me check the probe once more — in my probe I did `process.env.DASHBOARD_ORIGIN = "http://host.docker.internal:3000"` explicitly before loading. And it returned the provider. So the code reads process.env at runtime. In the container, PID 1 HAS the env. + +So why does the route 404?! + +OK here's another thought — maybe the route handler being served is NOT from this build at all. The standalone server serves from `/app/.next` — but wait, is there possibly a SECOND `.next` (the dev build on the host at `./.next`)? No, the container doesn't have that. + +Hmm, wait — actually let me re-read the standalone structure. `COPY --from=builder /app/.next/standalone ./` puts the standalone output at `/app`. The standalone output has its own `.next` directory: `/app/.next`! And `/app/.next/server/...` — that's the standalone copy. But the chunk files — are they inside the standalone `.next`? Let me check: `ls /app/.next/server/chunks` exists? Yes I've been reading from there. + +OK let me try yet another approach: directly test the ROUTE HANDLER in the running server by sending a request that makes it return the env. I can't modify... + +Actually — hold on. Let me re-examine the compiled 404 path in module 990295: + +`async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return s.nextRouteResponse.toNextResponse(i.routeResult.error(404,"Dashboard SSO is not configured..."))` + +`n = e.i(179405)`. My probe: R.m(179405).namespaceObject.getDashboardProvider() → returns provider. + +BUT WAIT — `e.i()` vs `R.m()`! In my probe I used `R.m(179405)`. The route code uses `e.i(179405)`. Are they the same? `R.m` = `(id)=>getOrInstantiateRuntimeModule(...)` and `e.i` is probably also the module require. Should be the same. + +Hmm, unless module 179405 in the route's chunk context is a DIFFERENT registration. Module IDs are per-bundle global. There's only one 179405. + +OK, completely different idea: maybe the issue is that `getDashboardProvider` IS returning the provider, but the check `if(!t)` fails differently... no, `t` would be truthy. + +OR — the route.js being loaded at runtime is NOT this route.js! What if Next's router picked the `[provider]` dynamic route for `/api/auth/dashboard/start` — and that route's message would be "OAuth provider 'dashboard' is not configured." — but we got "Dashboard SSO is not configured" which is the static route's message. So no. + +Let me actually verify by fetching with verbose output and also try /api/auth/GOOGLE/start to see if dynamic routes work at all. + +The login page (runtime, no-store) shows the provider; the route 404s; my probe of the same chunk works. Let me check whether the dynamic `[provider]` route is even functional, and add a decisive test — a runtime env probe through a route that doesn't exist vs. the real route: + +**Tool: bash** + +**Input:** +```json +{ + "command": "echo \"=== dynamic [provider] route (should be 404 with its own message):\"; docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nhttp.get({host:\"localhost\", port:3000, path:\"/api/auth/GOOGLE/start\"}, res => {\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(res.statusCode, d.slice(0,120)))\n})\n'; echo \"=== check route handler resolution - maybe /api/auth/dashboard/start is matched by [provider]!\"; docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nhttp.get({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start\"}, res => {\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(res.statusCode, d.slice(0,120)))\n})\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +=== dynamic [provider] route (should be 404 with its own message): +404 {"message":"OAuth provider 'GOOGLE' is not configured."} +=== check route handler resolution - maybe /api/auth/dashboard/start is matched by [provider]! +404 {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 13.1s) + +_Thinking:_ + +The dynamic route works (its own message). The static dashboard route also works (its own message). So the route IS being hit, and getDashboardProvider() returns null in that context. + +BUT my probe — which loaded module 179405 from the same chunk — returned the provider with the env set. + +Difference between my probe and the server: my probe ran in a fresh node process where I set `process.env.DASHBOARD_ORIGIN` explicitly. The server has it in PID 1's environ... + +WAIT. Let me re-check: `docker exec knowhere-notebook node -e "console.log(process.env.DASHBOARD_ORIGIN)"` — that's a NEW process that inherits from... docker exec processes inherit the container's config env (from --env-file), not PID 1's environ. And it printed the value. So `docker exec` processes have it. + +But does PID 1 (the actual server) have it in its runtime `process.env`? I checked `/proc/1/environ` and it had DASHBOARD_ORIGIN. Hmm. + +Actually — WAIT. Maybe the issue is Next.js's `nextServerEnv` / env snapshot mechanism in standalone mode. Let me look at how `process.env` is accessed in the ROUTE chunk vs the PAGE chunk. Maybe the route handler code got compiled with a build-time env snapshot because route handlers are compiled differently (they're part of the "server" bundle where Next inlines process.env when `nextConfig.env` is set... but it's empty `{}`). + +Hmm, actually `"env":{}` — wait, that's `nextConfig.env` = {} — that's just the `env` option in next.config. When set (even empty?), Next replaces `process.env.X` references in server code with build-time values! If `nextConfig.env` is an empty object, Next may STILL replace `process.env` with a snapshot object of the build-time env! + +But then the login page would ALSO use the snapshot... unless the page uses `connection()` which forces dynamic rendering with runtime env. + +Hmm, let me check the login page chunk — does it use a snapshot or runtime env? Both chunks showed `process.env.DASHBOARD_ORIGIN?.trim()` — runtime access. So no snapshot in either. + +OK here's the thing: my probe PROVES the chunk code works with env set. The server 404s. The ONLY remaining explanation: the server process's `process.env.DASHBOARD_ORIGIN` is somehow undefined AT RUNTIME for route handlers. + +Let me test definitively: hit the server with a request to a route that echoes env. I can't modify routes without rebuild... BUT I can test whether the process.env visible to the route handler differs by checking the response of a route that I know reads env — the login page reads it via listLoginProviders... + +WAIT. Actually, let me re-read the login page response. `x-nextjs-prerender: 1` was on the earlier response! And `Cache-Control: private, no-cache` on the RSC request. The FULL page HTML had `x-nextjs-prerender: 1`... and the prerendered login.html on disk does NOT have the button. But the served page DOES. So the served page is NOT the prerendered file — it's a runtime render (connection() forces dynamic). And it shows the provider. So at runtime, in the page renderer, DASHBOARD_ORIGIN IS set. + +So the same process serves the page with env set, and the route without. UNLESS the route runs in a different process/worker! + +In Next.js standalone mode with Turbopack, route handlers can run in a separate "worker" — let me check for worker threads (11 threads in PID 1 — node worker_threads show as separate PIDs usually...). Let me check more carefully — maybe there IS a second node process I missed. + +The login page at runtime sees the env; the route doesn't; my probe of the same chunk works. Let me check for a second node process (worker) that might serve route handlers: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'ls /proc/ | grep -E \"^[0-9]+$\" | while read p; do if [ -r /proc/$p/cmdline ]; then cmd=$(tr \"\\0\" \" \" < /proc/$p/cmdline); case \"$cmd\" in *node*) echo \"pid $p: $(echo $cmd | cut -c1-100)\";; esac; fi; done'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +pid 78: sh -c ls /proc/ | grep -E "^[0-9]+$" | while read p; do if [ -r /proc/$p/cmdline ]; then cmd=$(tr "\ +pid 85: sh -c ls /proc/ | grep -E "^[0-9]+$" | while read p; do if [ -r /proc/$p/cmdline ]; then cmd=$(tr "\ + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.8s) + +_Thinking:_ + +Only PID 1 is a node process. So single process. The login page (dynamic SSR) sees the env, the route doesn't, but my probe of the exact chunk works with env set. + +Wait... unless the login page RSC payload I'm seeing IS the prerendered artifact. Let me re-examine: the prerendered `login.html` on disk has NO button. But the served page HAS it. So the served page is NOT the on-disk prerender → it's a runtime render → env IS visible at runtime in that process. + +Hmm, but wait — is the served page REALLY from this container? `curl localhost:3001` — port 3001 is docker-mapped to container port 3000. Yes. + +Let me look at the served login page headers again: `x-nextjs-prerender: 1` — hmm, that means the response came from the prerender cache! And `x-nextjs-stale-time: 300`. But the on-disk prerender has no button... unless the prerender was REGENERATED at runtime (ISR) after first request — with the env present! + +Wait — that's it maybe: the page is ISR with 300s revalidation. First request: no prerender exists → renders dynamically WITH env → serves + caches. So the CACHED prerender now has the button. The on-disk file I checked... let me re-check the on-disk file NOW (after requests). + +The page is ISR (stale-time 300). Let me check the on-disk prerender NOW (it may have been regenerated at runtime with the env): + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'ls -la /app/.next/server/app/login.html; grep -c \"SSO (Dashboard)\" /app/.next/server/app/login.html; find /app/.next -name \"*.html\" -newer /app/server.js 2>/dev/null | head -3'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +-rw-r--r-- 1 nextjs nodejs 2607 Aug 5 17:01 /app/.next/server/app/login.html +0 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 18.0s) + +_Thinking:_ + +The on-disk prerender (2607 bytes, build time 17:01) has NO button. But the served page has the button AND `x-nextjs-prerender: 1` and `x-nextjs-stale-time: 300`. That's contradictory — unless the served page is NOT the on-disk login.html but a runtime render that reports prerender status from the manifest. + +Actually — wait. The served page has `x-nextjs-prerender: 1` because the route IS in the prerender manifest (it's an ISR page). The header indicates "this page was prerendered" but with `connection()` + Suspense, the content streams dynamically... Actually the served HTML is 17831 bytes vs the prerendered 2607 bytes. So the served HTML is a RUNTIME render (dynamic streaming), and the `x-nextjs-prerender: 1` header is just the manifest flag. + +So: at runtime, the login page's `listLoginProviders()` → `getDashboardProvider()` returns the dashboard provider → runtime env IS visible to the page renderer. + +And the route's `getDashboardProvider()` returns null → runtime env NOT visible to the route handler. + +Same process. Same module (179405)? My probe of module 179405 works with env set... + +Hold on — the login page might use a DIFFERENT module instance! The page chunk is `ssr/_0nta1.q._.js` (with `process.env.DASHBOARD_ORIGIN?.trim()`), and it works. The route chunk `05u4myb` also has `process.env.DASHBOARD_ORIGIN?.trim()`, and my probe shows it works when I set env. + +So both work in isolation. But the server's route 404s. + +WAIT. What if the RUNNING server process does NOT have the env, but docker exec processes DO? docker exec creates a new process that inherits env from... the container's config env (docker run --env-file), NOT from PID 1's runtime env. Let me check /proc/1/environ again — I did check and it HAD DASHBOARD_ORIGIN. + +Hmm wait, actually let me recheck that. Earlier: `tr "\0" "\n" < /proc/1/environ | grep -i "DASHBOARD\|ORIGIN"` → `DASHBOARD_ORIGIN=http://host.docker.internal:3000`. That's PID 1's env. So the server process has it. + +But WAIT — the login page render... does it really prove runtime env? The `connection()` + Suspense streaming... Let me think about `x-nextjs-prerender: 1` again. If the page were FULLY prerendered at build time (2607 bytes, no button), the served HTML would have no button. But served HTML HAS the button and is 17831 bytes. So it's a runtime render. So at runtime, in the server process, the page's providers include dashboard. + +Unless... the served HTML with the button is CACHED somewhere else. The `x-nextjs-stale-time: 300` suggests ISR. ISR pages get cached in the filesystem: `next-env.d.ts`... Actually ISR caches go to `.next/cache/`. The cache could have a runtime-generated version WITH the button, while `login.html` is the build-time one. That still means a runtime render happened with the env → proving env is visible to page rendering. + +OK so: env visible to page renderer (runtime), NOT visible to route handler (404). My probe proves the module code reads env correctly. The ONLY explanation left: **the route handler module the server executes is NOT the one I probed** — i.e., there are TWO copies of the chunks: one the server loads (without env or with different code) and one I probed. + +Actually — WAIT. Let me reconsider. Next.js standalone: `server.js` requires `.next/standalone/server.js`... no. Let me check WHERE server.js loads chunks from. The standalone output has its OWN `.next` inside `/app/.next` (we saw `.next/node_modules`, BUILD_ID etc.). But maybe the RUNNING server uses a different path! `server.js` is at /app/server.js and its `__dirname` is /app. It requires `.next/...` relative to /app. The chunks are at /app/.next/server/chunks. That's what I probed. + +Hmm, but let me check: is there maybe ANOTHER .next inside the standalone? Like /app/.next/server/chunks vs the actual chunk the route loads... + +Actually — hold on. Let me recheck my probe. I set `process.env.DASHBOARD_ORIGIN = "http://host.docker.internal:3000"` EXPLICITLY in the probe before loading, and got the provider. The server process has the env in /proc/1/environ. So the route handler SHOULD return non-null... + +Unless the route handler is a STALE compiled version! I did `docker build --no-cache` — but wait, did the BUILD actually rebuild? The second build attempt showed "#17 6.337 ✓ Compiled successfully in 5.7s" — that was with --no-cache. And the image was recreated. The route.js in the container has the correct message (which only exists in my new code). So the route code IS new. + +Hmm hmm. Let me look at the actual route module 990295 again: + +``` +990295,e=>e.a(async(t,r)=>{try{var n=e.i(179405),a=e.i(370742),s=e.i(105686),i=e.i(990615),o=t([a]);async function l(e){let t=(0,n.getDashboardProvider)(); +``` + +So `n = e.i(179405)`, then `n.getDashboardProvider()`. My probe: `R.m(179405)` → namespaceObject → getDashboardProvider → works. + +BUT WAIT — what if module 179405 in the RUNNING server is a DIFFERENT module than in my probe? Module IDs in Turbopack are per-bundle. The chunk I probed (05u4myb) defines 179405. The route requires it. So same. + +OK let me try the definitive test: execute the route handler's GET through the actual module registry, fully, with all chunks loaded. + +Let me do the definitive test — execute the actual compiled route handler through the full module registry, exactly as the server would: + +**Tool: write** + +**Input:** +```json +{ + "content": "process.chdir(\"/app\")\nprocess.env.NODE_ENV = \"production\"\n\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\n\n// Load ALL chunks from route.js\nconst fs = require(\"fs\")\nconst routeJs = fs.readFileSync(\"/app/.next/server/app/api/auth/dashboard/start/route.js\", \"utf8\")\nconst chunkMatches = [...routeJs.matchAll(/R\\.c\\(\"server\\/chunks\\/([^\"]+)\"\\)/g)]\nconst chunks = chunkMatches.map((m) => m[1])\nconsole.log(\"chunks:\", chunks)\n\nfor (const chunk of chunks) {\n try {\n require(\"/app/.next/server/chunks/\" + chunk)\n } catch (e) {\n console.error(\"require failed:\", chunk, e.message.slice(0, 80))\n }\n try {\n R.c(\"server/chunks/\" + chunk)\n } catch (e) {\n console.error(\"c() failed:\", chunk, e.message.slice(0, 80))\n }\n}\n\n// Now get the route module's exports\ntry {\n const mod = R.m(965445)\n console.log(\"965445 keys:\", Object.keys(mod))\n console.log(\"loaded:\", mod.loaded)\n const ns = mod.namespaceObject\n console.log(\"ns:\", ns ? Object.keys(ns) : null)\n} catch (e) {\n console.error(\"m(965445):\", e.message.slice(0, 120))\n}\n\n// Also directly: does the server's own process.env have it?\nconsole.log(\"process.env.DASHBOARD_ORIGIN in probe:\", process.env.DASHBOARD_ORIGIN)\n", + "filePath": "/var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route2.js" +} +``` + +**Output:** +``` +Wrote file successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.7s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker cp /var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route2.js knowhere-notebook:/tmp/probe-route2.js && docker exec knowhere-notebook node /tmp/probe-route2.js 2>&1 | head -15", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +chunks: [ + '10j~_next_03ru7fd._.js', + '10j~_next_133_78y._.js', + '[root-of-the-server]__05u4myb._.js', + '[root-of-the-server]__0.xpqx7._.js', + '10j~_next_dist_0wobfip._.js', + '_040tdh8._.js', + '_next-internal_server_app_api_auth_dashboard_start_route_actions_07zkmu5.js' +] +965445 keys: [ + 'exports', + 'error', + 'id', + 'namespaceObject', + 'parents', + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.5s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node /tmp/probe-route2.js 2>&1 | sed -n '15,30p'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` + 'parents', + 'children', + 'loaded' +] +loaded: true +ns: [] +process.env.DASHBOARD_ORIGIN in probe: http://host.docker.internal:3000 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 19.0s) + +_Thinking:_ + +The namespace is empty — the module needs more chunks or the namespace populates differently. This is a deep rabbit hole. + +Let me step WAY back. Key facts: +1. Login page (runtime SSR) includes the dashboard provider → at runtime, `getDashboardProvider()` returns non-null in the page render. +2. Route 404s → at runtime, `getDashboardProvider()` returns null in the route handler. +3. My probe of the SAME compiled module (179405) with env set returns the provider. + +Wait... #1 and #3 both say the module works with env. But the route 404s. What if the route handler executes with a **DIFFERENT `process.env`**? + +Next.js standalone has a mechanism: `nextConfig.env` — if set, Next replaces `process.env` for server code with the config's env object. Here `"env":{}` — empty. Hmm, but wait — that's the SERIALIZED nextConfig in server.js. The actual next.config.ts might have something. Let me check next.config.ts for env handling. Actually — with `"env":{}` in the serialized config, Next might create a `process.env` replacement that ONLY contains what's in `env` (empty) PLUS the vars from... Actually Next's `loadEnvConfig` merges `.env` files at BUILD time into the server bundle when `nextConfig.env` is used... + +Actually — the real mechanism: Next.js **inlines `process.env.X` at build time for server bundles** when X is referenced AND `nextConfig.env` is set. With `env:{}`... hmm, but that only applies to keys in `env`. + +Wait, actually there's another key mechanism! `next.config.ts` might use the new Next.js 16 `env` — but more importantly: **Turbopack inlines `process.env.DASHBOARD_ORIGIN` into the SERVER bundle at build time** if the var is in `.env*` files loaded at build. In the Docker build, `.env` files... there's no `.env` in the image, and the host `.env.local` (copied via `COPY . .`) — WAIT. The Dockerfile does `COPY . .` — which copies `.env.local` FROM THE HOST into the build context! At build time, Next loads `.env.local` and inlines vars into the server bundle... but the compiled code we saw reads `process.env.DASHBOARD_ORIGIN?.trim()` at RUNTIME, not an inlined literal. So no inlining happened for the providers module. + +Hmm, but here's the thing: the login page SSR WORKS at runtime. So the page bundle reads env at runtime fine. The route bundle — the same providers module code — should also work. + +UNLESS the route handler runs in a different context. Next.js 16 route handlers CAN run in a separate worker (the "server actions / route handlers" worker) in standalone mode! That would explain it: the main server process (page renders) has the env, but the route-handler worker... would inherit the same env though. + +Hmm wait, actually — let me recheck: is the login page button REALLY a runtime render? `x-nextjs-prerender: 1` on the RSC request earlier... and `x-nextjs-stale-time: 300`. The prerendered login.html on disk has NO button. But the served page HAS the button. If it were served from the prerender artifact, it wouldn't have the button. So it IS a runtime render (the ISR re-rendered after the first request, or it's just dynamic). Actually `x-nextjs-prerender: 1` with ISR means "this could be served from prerender cache" — but the artifact doesn't match, so it re-rendered at runtime WITH env → env visible to page SSR. + +OK here's the decisive question: is the route handler maybe compiled with a DIFFERENT copy of oauth-providers that HAS inlined env? I grepped all chunks with "Dashboard SSO" — only 05u4myb has it. And that chunk's module 179405 reads process.env at runtime. And my probe (which loads 05u4myb through the runtime with env set) returns the provider. So the route module in isolation WORKS. + +So the only remaining explanation: **the route handler executes with a different `process.env` in the server**. Next.js has `process.env` snapshotting for route handlers in standalone mode? Let me search the next server code for how env is passed to route handlers... Actually — I recall now: Next.js (Turbopack) for standalone/route handlers uses `process.env` normally. + +Hmm, wait — let me look at this from a completely different angle. What if the request never reaches my dashboard route, and instead Next's route matching sends `/api/auth/dashboard/start` to the `[provider]` route with provider="dashboard"... no — the [provider] route's message is "OAuth provider 'dashboard' is not configured." — DIFFERENT message. We got the dashboard-specific message. So it IS my static route. + +What if... there are TWO builds in the image? The earlier builds (cached layers) left `.next` with old route.js, and the new build wrote to a different path? No — the route.js exists with the right chunks and the right message. + +OK radical idea: what if `getDashboardProvider` IS returning the provider, but `loginWithDashboardSession` then fails with a DashboardLoginError that... no — the 404 message is the "not configured" one, which is only returned when `!provider`. + +Let me look at the compiled route handler once more very carefully: + +``` +async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return s.nextRouteResponse.toNextResponse(i.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."));let r=e.headers.get("c... +``` + +So `t=(0,n.getDashboardProvider)()` — if `n` is not module 179405 but something else... `n=e.i(179405)`. My probe of 179405 works. + +Hmm hold on — WAIT. My probe set `process.env.DASHBOARD_ORIGIN = "http://host.docker.internal:3000"` EXPLICITLY in the probe script before calling. The server process HAS it in /proc/1/environ. So both should see it... + +UNLESS the running server process was started WITHOUT it. /proc/1/environ shows what was passed at container start... I checked it — it had DASHBOARD_ORIGIN. Hmm. + +Actually — hold on, let me RE-verify /proc/1/environ right now, on the CURRENT container (the no-cache build one, restarted): + +Actually I did check after restart? Let me just check again and also test by curling INSIDE the container to localhost:3000 — wait, no curl in container. I did node http.get earlier → same 404. + +Let me check /proc/1/environ again now. + +Let me re-verify PID 1's env on the current container and try the route via a fresh HTTP request: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'tr \"\\0\" \"\\n\" < /proc/1/environ | grep -E \"DASHBOARD|DATABASE\" | head -4; echo \"---\"; cat /proc/1/environ | wc -c'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +DATABASE_URL=postgres://root:root123@host.docker.internal:5432/knowhere_notebook +DATABASE_DRIVER=pg +DASHBOARD_ORIGIN=http://host.docker.internal:3000 +--- +674 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 13.7s) + +_Thinking:_ + +PID 1 env has DASHBOARD_ORIGIN. My probe of module 179405 works. The login page at runtime shows the provider. The route 404s. + +Wait... let me re-examine. My probe executed the chunk in MY node process where I explicitly set the env. But in the SERVER, the module was loaded at server STARTUP. When was the server started? The container was recreated from the no-cache build. But `docker run` started it AFTER the build with the env file. So env was present at startup. + +Hmm, unless... Next.js standalone `server.js` **reads `.env` files** and REPLACES process.env? The `nextConfig.env` is `{}` — empty object. When `nextConfig.env` is defined (even empty), Next.js may treat it as "replace process.env for the bundle with config.env"! That's exactly the Next.js `env` config option behavior: "When you define env in next.config, it replaces process.env.X for the bundled server code with the value from config.env." + +Wait, but `env:{}` is EMPTY — that would replace ALL process.env with nothing? No — the `env` option only inlines the specific keys listed. With `{}`, nothing gets inlined. + +BUT — the way Next.js implements `env` config: it creates a snapshot object of `process.env` (the BUILD-TIME env, which did NOT have DASHBOARD_ORIGIN) and REPLACES `process.env` references in the SERVER bundle with that snapshot! This is a known Next.js behavior: `next build` snapshots process.env for the server bundle, and at runtime the server uses the snapshot, NOT the live process.env — for keys accessed via `process.env.X` in server code. + +Hmm, but that contradicts my probe: my probe loaded the chunk and `getDashboardProvider()` returned the provider with env set. But in the probe, I used the chunk directly with the LIVE process.env — no Next server wrapper. + +Actually, hold on. Let me look at the login page again: it RENDERED the provider at runtime. If the server bundle used a build-time snapshot (without DASHBOARD_ORIGIN), the login page would NOT show the button. But it DOES show the button — and the prerendered login.html (build time, no env) does NOT show it, while the served page (runtime) DOES. So the login page's code sees the RUNTIME env! + +So: login page sees runtime env. Route handler doesn't. Same process. The only difference: RSC page render vs route handler execution context. + +OH WAIT. I bet I know! Next.js standalone server runs route handlers in a SEPARATE MODULE GRAPH or the route handler was compiled with a DIFFERENT nextConfig. Or — more likely — **Turbopack compiled the route handler with build-time env inlining because route handlers are bundled differently**! The route chunk (05u4myb) might have been compiled at build time where `process.env.DASHBOARD_ORIGIN` got inlined as `undefined`... but no, we READ the chunk — it has `process.env.DASHBOARD_ORIGIN?.trim()` — runtime access! + +Unless... there are TWO versions of the chunk: the one in `.next/server/chunks/` (which I read, with runtime access) and the one in the STANDALONE `.next/server/chunks/`... wait, the container IS running standalone — /app/.next IS the standalone output. The chunks I read ARE the ones being served. + +Hmm hold on — let me check: is there a `.next/server/chunks` AND the standalone has its own nested `.next`? The Dockerfile: `COPY --from=builder /app/.next/standalone ./` — this copies the STANDALONE output (which contains its own `.next` inside!) to `/app`. So `/app/.next` should BE the standalone's `.next`... let me verify there isn't a second .next. + +PID 1 has the env, the login page at runtime shows the provider, my probe works... but the route 404s. There must be **two builds** — the standalone output may contain a second `.next`. Let me check: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'find /app -maxdepth 3 -name \"chunks\" -type d 2>/dev/null; echo \"---all .next dirs:\"; find /app -maxdepth 4 -name \".next\" -type d 2>/dev/null; echo \"---server.js requires:\"; grep -o \"require([^)]*)\" /app/server.js | head -3'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +/app/.next/server/chunks +/app/.next/static/chunks +/app/src/domains/chunks +---all .next dirs: +/app/.next +---server.js requires: +require('path') +require('next') +require('next/dist/server/lib/start-server') + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 30.3s) + +_Thinking:_ + +Only one `.next`. My probe of the exact chunk works, the login page renders the provider at runtime, PID 1 has the env... but the route 404s. + +Wait — could my probe have worked because I loaded the chunk in a fresh node process, but the RUNNING server loaded a DIFFERENT copy of module 179405? No, same chunk file. + +Hold on. Let me re-read the login page evidence. `x-nextjs-prerender: 1` header appeared on the login page response! And the RSC payload contains the dashboard provider. But the prerendered on-disk `login.html` has NO button (checked: `grep -c "SSO (Dashboard)" = 0`). + +So if the login page were served from the prerender cache, it would have NO button. But the served page HAS the button. That means the served page is a RUNTIME render (or the prerender got re-generated at runtime with the env set). + +So at runtime, in the SAME process, `getDashboardProvider()` returns the provider (login page) AND null (route)?? That's impossible for the same module function... + +UNLESS the login page and the route use DIFFERENT module instances. The login page (SSR chunk) might have its OWN bundled copy of oauth-providers.ts (module 0lns57e or 0ovju8m), while the route uses 05u4myb's copy. And those copies... both read `process.env` at runtime. I verified all three read `process.env.DASHBOARD_ORIGIN?.trim()` at runtime. + +Hmm wait, actually — could the login page be rendering with a build-time env snapshot that HAD DASHBOARD_ORIGIN set?? No — the build didn't have it set... unless! The build was run on MY HOST with docker build. Docker build doesn't inherit host env by default. But wait — does `next build` inside Docker read `.env.docker`? No, it reads `.env.local` / `.env` — which are NOT in the image (I checked: no `.env.local` in image). Hmm, but actually the build context COPY . . would copy `.env.local` from the host into the builder! And `.env.local` — does it have DASHBOARD_ORIGIN? I checked: `grep -c DASHBOARD .env.local` → 0. So no. + +OK here's another thought — what if the served login page with the button is served from a DIFFERENT server?! Like an old `pnpm dev` running on the host at port 3001?? Docker maps 3001→3000 in container... but lsof showed OrbStack listening on 3001 (the docker proxy). But maybe there's ALSO a dev server on 3001? lsof showed only one listener (OrbStack). Hmm. + +Wait, actually — let me recheck. The login page response had `x-nextjs-prerender: 1`. If the login page is PRE-RENDERED (static), then the RSC payload with "dashboard" would be from the prerender... but the prerendered file has no button. Contradiction! + +Unless there are TWO login pages: `/login` (with the new LoginForm → button) served from... hmm. + +Actually wait — let me recheck: is the served login page maybe from an ISR cache that got regenerated at runtime WITH env set? The `x-nextjs-stale-time: 300` suggests ISR with 300s revalidation. If the first request regenerated the page at runtime (env present), the cache would now have the button... but the on-disk login.html has no button. ISR caches live in `.next/cache/`, not server/app/login.html. So the served page is runtime-rendered with env → the runtime DOES see env for page rendering. + +So: page rendering sees env. Route handler doesn't. Same process. + +Hmm, WAIT. Unless the route handler request is being served by a DIFFERENT runtime instance! Next.js standalone can run route handlers in a separate "next-server" worker?? Let me check for node worker threads — PID 1 has 11 threads, all same process. + +Actually — hold on. Let me reconsider the possibility that the response I'm seeing is NOT from the route handler at all. What if the edge proxy (proxy.ts) is redirecting... no, it returns JSON 404 from the route. + +What if the route handler THROWS and Next returns a generic... no, the message is exactly mine. + +Let me try the absolute simplest thing: add a console.log to the route and rebuild. But that takes 1min. Actually — even better: let me check if the response differs when DASHBOARD_ORIGIN IS actually set vs not, using a DIFFERENT test: the [provider] route with provider=google returns "not configured" — that's env-independent. + +Hmm, actually let me look at module 990295's actual GET handler body once more. `async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return ...404...;let r=e.headers.get("c...` — it continues with loginWithDashboardSession presumably. The 404 message is exactly what we get. + +WAIT A SECOND. What if `e.i(179405)` doesn't resolve to the oauth-providers module but to a DIFFERENT module with the same ID in a different chunk?! Module IDs are global across the bundle in Turbopack... but maybe not! `e.i(179405)` — in Turbopack, module IDs are per-bundle. If 179405 in the route's chunk context is a different module... Let me check what module 179405 is in chunk 0.xpqx7 (the chunk the route also loads). + +Actually, in my probe I loaded 05u4myb and got the right module. But the ROUTE might resolve 179405 from a different chunk loaded EARLIER in the require list (10j~_next_03ru7fd or 10j~_next_133_78y). If those chunks also define module 179405 (a different one!), the registry might keep the first registered. + +Hmm, but the runtime registry is keyed by id — the first registration wins. My probe loaded 10j~_next_03ru7fd first too, and R.m(179405) returned the correct provider module. So no. + +OK here's yet another angle: maybe my probe DIDN'T faithfully reproduce the server's execution. In the server, route handlers run inside Next's instrumentation where `process.env` might be proxied/replaced by the standalone config's env handling. Let me check next-server's env behavior: Next.js has `process.env.__NEXT_PRIVATE_STANDALONE_CONFIG` — and in next 16, there's env snapshotting for route handlers? + +Actually, you know what — let me just test the simplest hypothesis directly: RESTART the server with a debug env and check if the ROUTE works when I hit it right after restart... already did, no. + +Let me try ONE more thing: what if it's the OPTIMIZED build — route handlers with `process.env` get TREE-SHAKEN or the check is evaluated at MODULE LOAD time (not per request)? Look at the code: `function(){let e=process.env.DASHBOARD_ORIGIN?.trim()...}` — evaluated per call. And the GET handler calls it per request. + +Hmm, hold on — what if the GET handler is CACHED as a static response? No, it responds 404 with the env-independent branch... + +OK wait, actually — let me look at this from a totally different direction. What if `process.env` inside the route chunk refers to a BUNDLED process shim that was snapshotted at build time with the BUILD-time env?! In standalone mode, Next.js might bundle a `process.env` snapshot into the server runtime. The login page might use the LIVE process.env while route handlers use the snapshot? That seems backwards... + +Actually in Next.js, `process.env` is live in server code. BUT — Turbopack inlines `process.env.X` at build time when... no. + +Let me just check: does the container's RUNNING server actually serve the login page with the button (fresh), or was that a cached response from before the rebuild? Let me restart and fetch BOTH endpoints fresh. + +Let me do a clean test — restart the container fresh and fetch both endpoints immediately: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker restart knowhere-notebook && sleep 6 && echo \"=== login page (runtime render?):\" && curl -s \"http://localhost:3001/login?t=$(date +%s)\" -D /tmp/lh.txt -o /tmp/lb.html && grep -iE \"prerender|stale\" /tmp/lh.txt && grep -c \"SSO (Dashboard)\" /tmp/lb.html && echo \"=== route:\" && docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nhttp.get({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start\"}, res => {\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(res.statusCode, d.slice(0,80)))\n})\n'", + "timeout": 60000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +knowhere-notebook +=== login page (runtime render?): +x-nextjs-stale-time: 300 +x-nextjs-prerender: 1 +1 +=== route: +404 {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 9.8s) + +_Thinking:_ + +Interesting. `x-nextjs-prerender: 1` on the login page, and the button IS in the served HTML. But the prerendered login.html on disk has NO button. That means the served page is NOT the on-disk prerender file... OR the served page is a runtime render that gets prerendered (ISR) — and importantly the RUNTIME render HAS the provider. So at runtime, `getDashboardProvider()` returns non-null in the page context. + +Wait... `x-nextjs-prerender: 1` + `x-nextjs-stale-time: 300` — ISR. The page is prerendered (at build time, no env) but served fresh when revalidated. The first request regenerated it at runtime WITH the env → button appears. Subsequent requests within 300s serve the regenerated HTML... but wait, we restarted the container — the regenerated HTML is in the on-disk cache? No — `x-nextjs-prerender: 1` still and the button IS there. So the runtime render at request time had the env. + +So: at runtime, the login page's `listLoginProviders()` sees DASHBOARD_ORIGIN (button rendered). The route's `getDashboardProvider()` returns null. Same process, same module code (my probe of the exact same chunk proved it works with env set). + +Hmm wait — did my probe actually prove it? My probe set `process.env.DASHBOARD_ORIGIN = "http://host.docker.internal:3000"` explicitly in the probe script before loading. And it returned the provider. So the code works with env set. And PID 1 HAS the env... + +Unless the RUNTIME page render uses a DIFFERENT module instance than the route! The page chunk `ssr/_0nta1.q._.js` might be a different copy. And maybe one of the copies reads `process.env` at runtime while the route's copy has it INLINED at build time to `undefined`! + +Earlier I found 3 chunks with `getDashboardProvider`: +- 05u4myb (route chunk — reads process.env at runtime, my probe WORKED) +- 0lns57e (reads process.env at runtime) +- 0ovju8m (reads process.env at runtime) + +All three read process.env at runtime. Hmm. + +Wait — maybe there's a FOURTH copy in the ssr chunks that the login page uses. Let me check ssr chunks for getDashboardProvider. + +The page render and route disagree within the same process. Let me check the SSR chunks (used by the login page) for a possibly different compiled copy: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'for f in /app/.next/server/chunks/ssr/*.js; do if grep -q \"getDashboardProvider\" \"$f\" 2>/dev/null; then echo \">> $f\"; fi; done; echo \"=== all ssr chunks with the env ref:\"; for f in /app/.next/server/chunks/ssr/*.js; do if grep -q \"DASHBOARD_ORIGIN\" \"$f\" 2>/dev/null; then echo \">> $f\"; grep -o \".\\{20\\}DASHBOARD_ORIGIN.\\{30\\}\" \"$f\" | head -1; fi; done'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +=== all ssr chunks with the env ref: +>> /app/.next/server/chunks/ssr/_0nta1.q._.js +[...((c=process.env.DASHBOARD_ORIGIN?.trim())?{kind:"dashboard",na + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 15.6s) + +_Thinking:_ + +Interesting — the SSR chunk ALSO reads process.env at runtime. And the login page shows the button. But the route doesn't. + +Wait... maybe the login page isn't showing the button from a runtime render at all. `x-nextjs-prerender: 1` + `x-nextjs-stale-time: 300` — the page is prerendered with ISR. The on-disk `login.html` has NO button (grep = 0). But the served page HAS the button. So the served page is NOT the on-disk prerender file... or the prerender was regenerated at runtime. + +Hmm, actually — if ISR regenerated it at runtime with the env present, the button would appear. That means at runtime, the page render DID see the env. And the route handler does NOT. Both run in PID 1... + +WAIT. Unless the prerender regeneration happens at BUILD time in a different context, and `x-nextjs-prerender: 1` means it's served from the prerender cache which was generated... with the env?! Let me check: was DASHBOARD_ORIGIN set during `docker build`?? The build ran `next build` — does next build pick up `.env.docker`? No — it picks up `.env.local`, `.env.production`, `.env` etc. from the project root. The Dockerfile `COPY . .` — copies the whole project including `.env.local`? Wait, `COPY . .` copies everything not in .dockerignore! Is there a `.dockerignore` that excludes .env files? The earlier build (cached) — but I did --no-cache and it rebuilt. During build, if `.env.local` (which has NO DASHBOARD_ORIGIN) is present, next build would use it. There's no DASHBOARD_ORIGIN in `.env.local`. So the build-time prerender (login.html, 2607 bytes, no button) is consistent with no env at build. + +But the SERVED page has the button! With `x-nextjs-prerender: 1`!! That means the served page was prerendered... with env?? Contradiction with the on-disk file. + +Hmm, unless the served response is a RUNTIME render that sets the prerender header (because the route is in the prerender manifest, it reports prerender status even when rendered dynamically). The `Cache-Control: private, no-cache, no-store` on the RSC request strongly suggests dynamic rendering. So the served page IS a runtime render — with the env visible. + +So: runtime page render sees env (button), runtime route handler doesn't (404). Same process. Same module code. This is truly bizarre. + +Let me look at the actual GET function one more time in module 990295. Wait — actually, let me re-read the compiled handler: + +``` +async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return s.nextRouteResponse... +``` + +`n=e.i(179405)` and my probe of `R.m(179405).namespaceObject.getDashboardProvider()` returned the provider WITH env set. But in the RUNNING server, maybe module 179405 was already instantiated at server startup with a DIFFERENT env (before... no, env is static). + +Hold on — what if the running server process does NOT have the env, but my `docker exec node` probe DID because docker exec spawns a new process inheriting the container's config env (from --env-file) — while PID 1 might have been started... no wait, PID 1 IS started with the same config env. And /proc/1/environ shows DASHBOARD_ORIGIN. + +BUT WAIT — my probe script itself SET `process.env.DASHBOARD_ORIGIN` at the top! Look: in probe-chunk2/probe-route2 I did `process.env.DASHBOARD_ORIGIN = "http://host.docker.internal:3000"` — no wait, in probe-route2.js I wrote `process.env.NODE_ENV = "production"` only... let me check. In probe-chunk2 I did NOT set it explicitly... Actually in the last successful probe (the one that returned the provider), the command was: + +``` +const R = runtime("server/app/api/auth/dashboard/start/route.js") +... R.m(179405) +=> {"kind":"dashboard",...} +``` + +and that node -e did NOT set DASHBOARD_ORIGIN — but docker exec inherits container env, so it had it. OK so both the probe process and PID 1 have the env, and the same chunk code returns the provider in the probe but 404s in the server. + +The ONLY difference: the server is Next.js — which for route handlers might evaluate `process.env` through its OWN env handling. Next.js has `loadEnvConfig` and importantly **in standalone mode, route handlers might be compiled with `process.env` references inlined from a build-time snapshot**... but we saw the chunk reads `process.env` at runtime. + +Hmm, actually — WAIT. Let me look at the route module once more: + +``` +990295,e=>e.a(async(t,r)=>{try{var n=e.i(179405),a=e.i(370742),s=e.i(105686),i=e.i(990615),o=t([a]);async function l(e){let t=(0,n.getDashboardProvider)();... +``` + +`n=e.i(179405)` — hmm, `e.i` — what is `i` in this context? In Turbopack runtime, `e.i(moduleId)` is "instantiate module". And `n.getDashboardProvider` — accessing a named export. My probe used `R.m(179405).namespaceObject.getDashboardProvider`. These should be equivalent. + +Let me actually verify by calling the route handler through the full registry with all chunks properly loaded, invoking GET. + +The route module in the server must be resolving a different module copy. Let me execute the actual GET handler through the full chunk registry: + +**Tool: write** + +**Input:** +```json +{ + "content": "process.chdir(\"/app\")\nprocess.env.NODE_ENV = \"production\"\n\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\n\nconst fs = require(\"fs\")\nconst routeJs = fs.readFileSync(\"/app/.next/server/app/api/auth/dashboard/start/route.js\", \"utf8\")\nconst chunkMatches = [...routeJs.matchAll(/R\\.c\\(\"server\\/chunks\\/([^\"]+)\"\\)/g)]\nfor (const m of chunkMatches) {\n const chunk = \"server/chunks/\" + m[1]\n try { require(\"/app/.next/\" + chunk) } catch {}\n try { R.c(chunk) } catch {}\n}\n\n// Pull the oauth-providers module the route uses: n=e.i(179405)\nconst prov = R.m(179405).namespaceObject\nconsole.log(\"providers module:\", Object.keys(prov))\nconsole.log(\"getDashboardProvider =>\", JSON.stringify(prov.getDashboardProvider()))\n", + "filePath": "/var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route3.js" +} +``` + +**Output:** +``` +Wrote file successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.2s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker cp /var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route3.js knowhere-notebook:/tmp/probe-route3.js && docker exec knowhere-notebook node /tmp/probe-route3.js 2>&1 | head -5", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +providers module: [ 'getDashboardProvider', 'getOAuthProvider' ] +getDashboardProvider => {"kind":"dashboard","name":"dashboard","displayName":"Dashboard","dashboardOrigin":"http://host.docker.internal:3000"} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 14.8s) + +_Thinking:_ + +The compiled module works when I run it with my own node process — because my node process has the env set (docker exec inherits it). But the RUNNING server 404s. The env IS in /proc/1/environ. + +Wait... but my probe process also reads process.env.DASHBOARD_ORIGIN from its env — and it got the value. PID 1 has it in environ. Same env, same code. Unless... the server process was started BEFORE the env-file was passed? No — docker run --env-file is set at container creation. + +Hold on. Let me re-examine: is the running container actually the one I created with --env-file? I did `docker rm -f knowhere-notebook` then `docker run -d --name knowhere-notebook ... --env-file .env.docker`. Yes. + +Hmm wait — but the probe reads `process.env.DASHBOARD_ORIGIN` in the SAME container and gets it. PID 1 has it in /proc/1/environ. So the server MUST see it... unless Next.js route handlers get a **pruned process.env**! + +AH WAIT. I remember now! **Next.js standalone mode (and Turbopack) snapshot `process.env` at build time into the server bundle** — actually no. But there IS something: Next.js 15/16 has "server environment variable pruning" or the standalone server may filter env... Let me check if next-server filters env vars. Actually — there's a known Next.js behavior: in standalone output, `process.env` is available normally. Hmm. + +Actually — let me reconsider. Maybe the issue is the OTHER direction: maybe the running server IS seeing the env correctly, but the route handler module executing is a DIFFERENT module instance than what I probed — because the server loaded the chunks in a different order and module 179405 resolves to something else. + +Wait, actually — hold on. Let me re-read the login page evidence. The login page shows the button — but WAIT, is the login page actually rendering from runtime env, or is the prerendered HTML cached with the button from... the prerendered login.html on disk has NO button (grep = 0). But the SERVED page HAS the button and `x-nextjs-prerender: 1`. That's contradictory unless the served page was regenerated at runtime (ISR with 300s stale) — with env set. So the runtime DOES see the env for page rendering. + +Hmm, wait — actually let me look again: `x-nextjs-prerender: 1` on the served login page AND the button in HTML. The on-disk file (2607 bytes) has no button. The served body is 17831 bytes. So the served page is NOT the on-disk prerender — it's a runtime render (the ISR revalidated). And that runtime render HAS the button → runtime env sees DASHBOARD_ORIGIN → getDashboardProvider() works in page context. + +So: page context sees env, route context doesn't. Same process, same module file... + +Unless — OH. Maybe the route handler runs in a **separate isolate**! Next.js 16 route handlers with `process.env` — in standalone/Turbopack, route handlers might be executed in a separate Node worker with a **filtered env**! There was news about Next.js adding an option to isolate route handlers. But ps shows only PID 1... + +Actually wait — maybe route handlers in Next.js 16 run via the "minimal mode" / server actions infrastructure. Let me check the route handler response headers for clues, and check if there's a worker thread (pid 1 has 11 threads — maybe one is a worker thread that has a different env!). + +Worker threads share the parent's env though. Unless the worker was created with specific env. + +Hmm, hold on — let me step back and reconsider the whole thing. What if the issue is much simpler: **the route file I'm testing is an OLD cached build artifact**, and the login page is from the NEW build? No — both come from the same image. + +Let me try yet another approach: check the actual HTTP response headers of the route 404 — maybe it comes from the EDGE proxy (proxy.ts)! The proxy has `isPublicPath` — I added "/api/auth" — and if the proxy redirects/errors... no, the message "Dashboard SSO is not configured. Set DASHBOARD_ORIGIN." only exists in my route code. + +Hmm OK, wait. Let me look at the /proc/1/environ more carefully — maybe there are DUPLICATE keys or the value differs. + +The module works standalone. Let me check if there's a **worker thread** serving route handlers (PID 1 has 11 threads), and inspect the full environ for duplicates: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'cat /proc/1/environ | tr \"\\0\" \"\\n\" | grep DASHBOARD; echo \"---all threads:\"; ls /proc/1/task/; echo \"---thread 2 cmdline (worker?):\"; cat /proc/1/task/2/comm 2>/dev/null'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +DASHBOARD_ORIGIN=http://host.docker.internal:3000 +---all threads: +1 +10 +11 +12 +14 +15 +16 +17 +7 +8 +9 +---thread 2 cmdline (worker?): + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.0s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'for t in 1 10 11 12 14 15 16 17 7 8 9; do echo \"thread $t: $(cat /proc/1/task/$t/comm 2>/dev/null)\"; done'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +thread 1: next-server (v +thread 10: node +thread 11: node +thread 12: node +thread 14: libuv-worker +thread 15: libuv-worker +thread 16: libuv-worker +thread 17: libuv-worker +thread 7: DelayedTaskSche +thread 8: node +thread 9: node + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 7.6s) + +All threads share PID 1's env (no separate workers). Everything points to the env being visible. Let me take a totally different angle — maybe the **route handler's response is cached** by Next.js. GET route handlers can be cached. My handler reads `request.headers` but the response might be memoized... no, we see different query strings return same 404. + +Let me check if maybe there's a **stale route.js** in the image that predates my change — compare the compiled message with what my source says. Actually, let me look at the image's route.js.nft.json timestamp and check the FIRST build (cached) vs current. Actually — I just realized: the FIRST container (from the fully-cached build) was serving the OLD route... and I then did `--no-cache` build + new container. But wait — the first build was cached from BEFORE I even created the dashboard route... no, I created it before the first build. + +Let me check if the route.js in the CURRENT container matches my source by checking the 404 message ordering: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\n// Decisive test: check what the RUNNING server thinks. Use the login RSC we know\n// sees the env — it showed \"dashboard\". Now check if a route handler CAN see env\n// by hitting a route that echoes env indirectly. Instead, verify build-time vs runtime:\n// the route.js in the container — does it contain the message ONLY in the not-configured branch?\nconst fs = require(\"fs\")\nconst src = fs.readFileSync(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\", \"utf8\")\nconst i = src.indexOf(\"990295,\")\nconsole.log(src.slice(i, i + 2600))\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +990295,e=>e.a(async(t,r)=>{try{var n=e.i(179405),a=e.i(370742),s=e.i(105686),i=e.i(990615),o=t([a]);async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return s.nextRouteResponse.toNextResponse(i.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."));let r=e.headers.get("cookie")??"";try{let e=await (0,a.loginWithDashboardSession)(r,t.dashboardOrigin);return s.nextRouteResponse.toNextResponse(i.routeResult.ok({url:e}))}catch(e){if(e instanceof a.DashboardLoginError){let t="email-collision"===e.code?409:401;return s.nextRouteResponse.toNextResponse(i.routeResult.error(t,e.message))}return s.nextRouteResponse.toNextResponse(i.routeResult.error(500,"Could not log in with the Dashboard."))}}[a]=o.then?(await o)():o,e.s(["GET",0,l]),r()}catch(e){r(e)}},!1),965445,e=>e.a(async(t,r)=>{try{var n=e.i(578883),a=e.i(14783),s=e.i(404751),i=e.i(949684),o=e.i(498654),l=e.i(334870),u=e.i(108720),d=e.i(989213),c=e.i(932845),p=e.i(86677),f=e.i(415803),h=e.i(186349),m=e.i(418329),y=e.i(723510),g=e.i(29112),w=e.i(193695);e.i(401161);var v=e.i(736731),b=e.i(990295),R=t([b]);[b]=R.then?(await R)():R;let x=new n.AppRouteRouteModule({definition:{kind:a.RouteKind.APP_ROUTE,page:"/api/auth/dashboard/start/route",pathname:"/api/auth/dashboard/start",filename:"route",bundlePath:""},distDir:".next",relativeProjectDir:"",resolvedPagePath:"[project]/src/app/api/auth/dashboard/start/route.ts",nextConfigOutput:"standalone",userland:b,...{}}),{workAsyncStorage:A,workUnitAsyncStorage:k,serverHooks:C}=x;async function E(e,t,r){r.requestMeta&&(0,i.setRequestMeta)(e,r.requestMeta),x.isDev&&(0,i.addRequestMeta)(e,"devRequestTimingInternalsEnd",process.hrtime.bigint());let n="/api/auth/dashboard/start/route";n=n.replace(/\/index$/,"")||"/";let s=await x.prepare(e,t,{srcPage:n,multiZoneDraftMode:!1});if(!s)return t.statusCode=400,t.end("Bad Request"),null==r.waitUntil||r.waitUntil.call(r,Promise.resolve()),null;let{buildId:b,params:R,nextConfig:E,parsedUrl:A,isDraftMode:k,prerenderManifest:C,routerServerContext:_,isOnDemandRevalidate:P,revalidateOnlyGenerated:S,resolvedPathname:O,clientReferenceManifest:I,serverActionsManifest:U}=s,D=(0,u.normalizeAppPath)(n),N=!!(C.dynamicRoutes[D]||C.routes[O]),j=async()=>((null==_?void 0:_.render404)?await _.render404(e,t,A,!1):t.end("This page could not be found"),null);if(N&&!k){let e=!!C.routes[O],t=C.dynamicRoutes[D];if(t&&!1===t.fallback&&!e){if(E.adapterPath)return await j();throw new w.NoFallbackError}}let $=null;!N||x.isDev||k||($=O,$="/index"===$?"/":$);let T=!0===x.isDev||!N,q=N&&!T;U&&I&&(0,l.setManifestsSinglet + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 7.1s) + +_Thinking:_ + +Now I see the full route handler: module 990295 exports `GET` which calls `n.getDashboardProvider()` (n = 179405). My probe of 179405 works. So the handler in the server should work... + +WAIT. I see it now! Look at this: + +`var n=e.i(179405)` — but module 179405 is defined in chunk `05u4myb`. BUT — my probe loaded the chunk and `R.m(179405)` resolved correctly. HOWEVER — in the RUNNING server, module 179405 may be resolved to a DIFFERENT chunk's copy! The `e.i()` resolution could hit module 179405 from chunk `10j~_next_133_78y._.js` or `_040tdh8._.js` — which I haven't examined! Let me check THOSE chunks for a 179405 module definition — maybe there's a SECOND definition of module id 179405 with different code! + +**That's it** — the route uses `e.i(179405)`, but the module ID 179405 may be defined in MULTIPLE chunks, and the server may resolve a different one than I probed. Let me check ALL chunks for module 179405 definitions: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst chunks = fs.readdirSync(\"/app/.next/server/chunks\").filter(f => f.endsWith(\".js\"))\nfor (const c of chunks) {\n const src = fs.readFileSync(\"/app/.next/server/chunks/\" + c, \"utf8\")\n // Look for module def starting with \"179405,\" \n const re = /(?:^|[;,{])179405,/\n if (re.test(src)) {\n const i = src.indexOf(\"179405,\")\n console.log(\">>> FOUND in\", c)\n console.log(src.slice(i, i + 150))\n }\n}\nconsole.log(\"done\")\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +>>> FOUND in [root-of-the-server]__05u4myb._.js +179405,e=>{"use strict";let t=[{name:"google",displayName:"Google",envKey:"GOOGLE",authorizeUrl:"https://accounts.google.com/o/oauth2/v2/auth",tokenUr +>>> FOUND in [root-of-the-server]__0lns57e._.js +179405,e=>{"use strict";let t=[{name:"google",displayName:"Google",envKey:"GOOGLE",authorizeUrl:"https://accounts.google.com/o/oauth2/v2/auth",tokenUr +>>> FOUND in [root-of-the-server]__0ovju8m._.js +179405,e=>{"use strict";let t=[{name:"google",displayName:"Google",envKey:"GOOGLE",authorizeUrl:"https://accounts.google.com/o/oauth2/v2/auth",tokenUr +done + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 15.3s) + +_Thinking:_ + +All three copies of module 179405 have the same code that reads `process.env` at runtime. And my probe proved it returns the provider when env is set. But the running server 404s... + +Wait a moment. Let me reconsider the login page. `x-nextjs-prerender: 1` was on the login response AND the button IS in the served HTML. But the on-disk prerender login.html has NO button. That's contradictory... unless the served page is NOT the on-disk prerender. + +OH WAIT. I bet I finally understand! The login page response had `x-nextjs-prerender: 1` + `x-nextjs-stale-time: 300` — it's an ISR page. The FIRST request after restart triggers a re-render (since the on-disk prerender is stale/missing?), and that runtime render DOES see the env (button present). So the runtime page render sees DASHBOARD_ORIGIN... but the route doesn't?! + +Unless... the runtime page render ISN'T seeing the env — maybe the button comes from a DIFFERENT source! Let me check: the RSC payload for login contained `providers":[{"name":"dashboard"...}` — that's from the server-side `listLoginProviders()`. So the server render DID see the env. But wait — could this RSC payload be from a **prefetched/cached** version? + +Hmm, actually — let me look again at the timing. `x-nextjs-prerender: 1` on login. The on-disk login.html (2607 bytes) has NO button. But the served response (17831 bytes) HAS the button and `x-nextjs-prerender: 1`. So the served response is NOT the on-disk file — it's a runtime render that ALSO carries the prerender header (because the route is in the prerender manifest). + +So: the runtime render of the login page sees DASHBOARD_ORIGIN. The runtime execution of the route handler does NOT. Same process, same env, same module file... + +UNLESS the route handler's `process.env` is not the global one. In Turbopack, `process.env` in the chunk could refer to a **bundled process shim** — but we grepped and the code literally reads `process.env.DASHBOARD_ORIGIN?.trim()`. In my probe, I called the module function and it returned the provider — so the module function DOES read global process.env correctly in my node process. + +Wait — my probe node process has DASHBOARD_ORIGIN set (it's a new process spawned by docker exec, which inherits the container's config env). And it returned the provider. The server process (PID 1) has it in /proc/1/environ. So the server process should too... + +Hmm, hold on. Let me re-examine: is the RUNNING container actually the NEW container? I did `docker rm -f` + `docker run` after the --no-cache build. The container ID was `a7c723a565fb...` and then restarted. And /proc/1/environ HAS DASHBOARD_ORIGIN. So yes. + +Let me look at this from a completely different angle: maybe the route handler is NOT what responds. Maybe there's a **proxy/middleware** that intercepts `/api/auth/dashboard/start`... The proxy.ts! The proxy checks `isPublicPath` — I added `/api/auth` to PUBLIC_PATHS. But wait — does the RUNNING container have the NEW proxy? The proxy is compiled at build time. The --no-cache build should include it... + +Actually — hold on. Let me test the proxy behavior directly: hit a protected route without cookie to see if it redirects (proxy works), and hit `/api/auth/dashboard/start` and check for redirect headers. If the OLD proxy is running (without `/api/auth` in PUBLIC_PATHS), it would redirect `/api/auth/dashboard/start` to `/login`... but we got a 404 JSON, not a redirect. So either the new proxy is running (passes through) or... + +Hmm wait, actually — if the OLD proxy ran, the 404 JSON wouldn't appear at all — we'd get a 307 redirect to /login. We get the route's JSON. So the route runs. OK. + +Let me try ANOTHER decisive experiment: temporarily set DASHBOARD_ORIGIN to something in the server's runtime and check... can't easily. + +Actually — WAIT. Let me re-read the route module 990295 code: + +``` +async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return s.nextRouteResponse.toNextResponse(i.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.")); +``` + +`n=e.i(179405)`. And in my probe, `R.m(179405).namespaceObject.getDashboardProvider()` works. BUT — in the server, `e.i(179405)` — what is `e.i` vs `R.m`? In Turbopack runtime: `R.m(id)` = getOrInstantiateRuntimeModule. And `e.i(id)` in the module body is the runtime's internal `i` = also module instantiation. Should be the same. + +Hmm, unless... the RUNNING server loaded a STALE chunk set. When I rebuilt with --no-cache, the BUILD_ID changed. But the running server was started from the new image... + +You know what — let me just check the BUILD_ID of the running server vs the built one, and check if maybe the running container is actually an OLD container (docker run might have failed and an old one is serving). + +All copies read env at runtime, my probe works, PID 1 has env. Let me verify the running container is really the new build and check for a stale container situation: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker ps --format \"{{.ID}} {{.Image}} {{.CreatedAt}} {{.Names}}\" | head -3; echo \"---build id in container:\"; docker exec knowhere-notebook cat /app/.next/BUILD_ID 2>/dev/null; echo \"---host .next build id:\"; cat /Users/gordon/Documents/repos/knowhere-notebook/.next/BUILD_ID 2>/dev/null; echo \"---routes-manifest on disk (host) has dashboard route?:\"; grep -o \"api/auth/dashboard\" /Users/gordon/Documents/repos/knowhere-notebook/.next/routes-manifest.json 2>/dev/null | head -1", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +a7c723a565fb knowhere-notebook:dev 2026-08-06 01:01:45 +0800 HKT knowhere-notebook +e1082782f3dd knowhere-self-hosted:test-all-features 2026-08-02 11:13:37 +0800 HKT knowhere-self-hosted-app-1 +161556fa4bb7 localstack/localstack:3.8 2026-08-01 11:29:38 +0800 HKT knowhere-self-hosted-localstack-1 +---build id in container: +Ag8uEjoVajnGptTsAOguY---host .next build id: +---routes-manifest on disk (host) has dashboard route?: + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 21.1s) + +_Thinking:_ + +The running container IS the new build (a7c723a565fb, created 01:01:45). And its route works standalone in my probe. The env is in PID 1. + +Wait — I should double check whether the login page really renders at runtime with env. `x-nextjs-prerender: 1` — that says the page IS prerendered. And it HAS the button. But the on-disk login.html does NOT have the button. That means the served response is NOT the on-disk file... unless the prerender file was regenerated at runtime (ISR). Actually with `x-nextjs-stale-time: 300`, the page is ISR — it prerenders at build, then regenerates when stale. The regenerated (runtime) version has the button → runtime env works → the page was regenerated at runtime WITH the env present. + +So: at runtime, the page render sees DASHBOARD_ORIGIN. The route handler doesn't. Same process... + +UNLESS the route handler response is served from a DIFFERENT build instance. Wait — could there be TWO Next.js servers inside the container? PID 1 is next-server. But standalone `server.js` may spawn... no, we saw only PID 1. + +Hmm, hold on. Let me reconsider: my probe loaded module 179405 from chunk 05u4myb — and it returned the provider. But the RUNNING server's `e.i(179405)` — what if the server resolves module ID 179405 to a DIFFERENT chunk's definition? Module IDs in Turbopack are per-bundle/global. When multiple chunks define the same module ID... the runtime may have picked a different one for the route bundle vs the page bundle. + +Actually — wait. THE KEY INSIGHT: the login page and route handler use DIFFERENT bundles (page bundle vs route bundle), each with their OWN copy of the module registry and possibly DIFFERENT module ID assignments! My probe loaded the route bundle chunks and 179405 there reads env at runtime → works. So the route bundle's copy is fine. + +Unless... the route bundle was compiled at a DIFFERENT time — a STALE build! The image was built --no-cache at 01:01. But wait — was the build actually successful with my new source? The `docker build --no-cache` output showed "Compiled successfully in 5.7s" — 5.7s is FAST for next build, suspiciously fast. And earlier the first `docker build` (cached) reported `#22 DONE 0.0s` with everything CACHED — because... hmm, `COPY . .` should invalidate on source change. UNLESS the build context doesn't include my new files?? No — the dashboard route EXISTS in the container (we found route.js, it has the right message). + +OK here's another thought — maybe there are TWO copies of the route handler: the standalone output was built with OLD code, but... no, the route.js message is MY new message. + +Let me look at this from the OTHER side: what does the RUNNING server actually have for the PAGE bundle? The page render shows the button (runtime) — so the page bundle sees env. The route bundle doesn't. Both bundles in the same process with the same process.env... IMPOSSIBLE unless the route bundle has inlined env at build time. + +Wait — actually, is the login page render REALLY runtime? Let me verify: `x-nextjs-prerender: 1` + the button in the response. If it's the prerendered HTML (built at build time when env was NOT set), there'd be NO button. But there IS a button. So the response is NOT the build-time prerender — it's runtime (or the prerender was regenerated at runtime with env present — also runtime). + +So the PAGE bundle sees env at runtime. Confirmed by RSC payload containing "dashboard". + +Hmm, hold on, wait. Let me re-check the timing: I rebuilt with --no-cache at ~01:00. The login page served NOW has the button. If the page is ISR with 300s stale-time, the FIRST request regenerated it at runtime (env present → button). Good. + +Now the route: same process, same env, same chunk code. Returns null. The ONLY remaining possibility: the route handler that's running is a DIFFERENT compiled version — maybe from the FIRST (cached, stale) build that was baked into the image layers... but we did --no-cache so nothing is baked from before. + +WAIT. Actually — did the --no-cache build actually rebuild? Look at the output: it showed "#17 0.290 > next build / #17 6.337 ✓ Compiled successfully in 5.7s" — that was from the grep. So yes, next build ran. 5.7s is fast but possible with Turbopack. + +Hmm, let me look at the ACTUAL route handler one more time... + +`async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return ...404...` + +Wait — `n=e.i(179405)`. But hold on — what if `e.i(179405)` in the RUNNING server resolves to a module from a DIFFERENT chunk — specifically a chunk that was registered EARLIER in the module registry with the same ID 179405 but DIFFERENT (stale) content? Module IDs are global across the bundle. If TWO chunks define 179405, the runtime keeps the FIRST registered. My probe registered chunks in route.js order: 10j~_next_03ru7fd, 10j~_next_133_78y, 05u4myb, 0.xpqx7, 10j~_next_dist_0wobfip, _040tdh8. Maybe one of the FIRST chunks (10j~_next_03ru7fd or 10j~_next_133_78y or 0.xpqx7) ALSO defines 179405 — with STALE code! I only checked chunks with "179405," — it was found in 05u4myb, 0lns57e, 0ovju8m. Not in the earlier chunks. Hmm. + +BUT WAIT — 0lns57e and 0ovju8m also define 179405! Are those loaded by the route bundle? route.js loads: 10j~_next_03ru7fd, 10j~_next_133_78y, 05u4myb, 0.xpqx7, 10j~_next_dist_0wobfip, _040tdh8, _next-internal_... So 0lns57e/0ovju8m are NOT loaded by the route bundle — those are for other bundles (login page?). And all three define the same correct code anyway. + +So the route bundle's 179405 is correct. My probe PROVED it: I loaded the same chunks and called getDashboardProvider → returned the provider! + +But the running server returns 404... + +Ohh — hold on. Maybe the running server process was started with a DIFFERENT env because `docker run --env-file .env.docker` was used at container creation (01:01) — and /proc/1/environ HAS it. So no. + +Let me try the nuclear option: add a debug log to the route, rebuild, and see what the route sees. Actually — better: let me test calling the compiled GET handler directly in my probe, with a mock request, using the FULL chunk set. + +Let me execute the compiled GET handler directly with a mock request through the full registry: + +**Tool: write** + +**Input:** +```json +{ + "content": "process.chdir(\"/app\")\nprocess.env.NODE_ENV = \"production\"\n\nconst runtime = require(\"/app/.next/server/chunks/[turbopack]_runtime.js\")\nconst R = runtime(\"server/app/api/auth/dashboard/start/route.js\")\n\nconst fs = require(\"fs\")\nconst routeJs = fs.readFileSync(\"/app/.next/server/app/api/auth/dashboard/start/route.js\", \"utf8\")\nconst chunkMatches = [...routeJs.matchAll(/R\\.c\\(\"server\\/chunks\\/([^\"]+)\"\\)/g)]\nfor (const m of chunkMatches) {\n const chunk = \"server/chunks/\" + m[1]\n try { require(\"/app/.next/\" + chunk) } catch {}\n try { R.c(chunk) } catch {}\n}\n\n// Get module 990295 (the userland route module) exports\nconst routeMod = R.m(990295)\nconsole.log(\"route module loaded:\", routeMod.loaded, \"error:\", routeMod.error ? routeMod.error.message : \"none\")\nconsole.log(\"namespace:\", Object.keys(routeMod.namespaceObject || {}))\nconst GET = routeMod.namespaceObject?.GET\nconsole.log(\"GET:\", typeof GET)\nif (GET) {\n const req = new Request(\"http://localhost:3000/api/auth/dashboard/start\", {\n headers: { cookie: \"better-auth.session_token=abc\" },\n })\n GET(req, {}).then(async (res) => {\n const text = await res.text()\n console.log(\"STATUS:\", res.status)\n console.log(\"BODY:\", text.slice(0, 200))\n }).catch((e) => console.error(\"GET error:\", e.message))\n}\n", + "filePath": "/var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route4.js" +} +``` + +**Output:** +``` +Wrote file successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.5s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker cp /var/folders/tp/638wd6z55xd80jjr3n_9_0gh0000gn/T/opencode/probe-route4.js knowhere-notebook:/tmp/probe-route4.js && docker exec knowhere-notebook node /tmp/probe-route4.js 2>&1 | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +/app/node_modules/.pnpm/next@16.2.4_@babel+core@7.29.0_@opentelemetry+api@1.9.0_@playwright+test@1.58.2_babel-p_15c2563c57d6773e81b7f3b65f4a450f/node_modules/next/dist/server/app-render/async-local-storage.js:44 + throw sharedAsyncLocalStorageNotAvailableError; + ^ + +Error: Invariant: AsyncLocalStorage accessed in runtime where it is not available + at Object. (/app/node_modules/.pnpm/next@16.2.4_@babel+core@7.29.0_@opentelemetry+api@1.9.0_@playwright+test@1.58.2_babel-p_15c2563c57d6773e81b7f3b65f4a450f/node_modules/next/dist/server/app-render/async-local-storage.js:27:72) + at Module._compile (node:internal/modules/cjs/loader:1781:14) + at Object..js (node:internal/modules/cjs/loader:1913:10) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 12.7s) + +_Thinking:_ + +The direct GET invocation fails because it needs Next's async-local-storage context. This is a deep rabbit hole. + +Let me step WAY back and think about this differently. + +Facts: +1. Login page at runtime shows the button → `listLoginProviders()` → `getDashboardProvider()` returns the provider in the PAGE render. +2. Route `/api/auth/dashboard/start` returns 404 "not configured" → `getDashboardProvider()` returns null in the ROUTE handler. +3. Same process (PID 1), same env (/proc/1/environ has DASHBOARD_ORIGIN). +4. My probe of the exact compiled chunk (module 179405) with the same env returns the provider. + +This is contradictory UNLESS the page render and the route handler use DIFFERENT copies of the module — OR the route handler is executing in a different "work unit" with a different env snapshot. + +Wait... actually, let me reconsider. Next.js 16 with Turbopack: **route handlers in standalone mode can be compiled with a DIFFERENT env inlining than pages**. Turbopack inlines `process.env.X` at build time into a snapshot for... no wait, we SAW the compiled code reads process.env at runtime. + +Hmm, BUT — maybe there are two versions of chunk `05u4myb` — one compiled at build time and one... no. + +Actually — hold on. Let me re-examine: `x-nextjs-prerender: 1` on the login page. The login page was PRERENDERED. And the button shows. But the on-disk prerender file (login.html, 2607 bytes) has NO button. Contradiction! Unless the served page is NOT the on-disk login.html — maybe the served page is a RUNTIME render (the `?t=` query param makes it dynamic!) and `x-nextjs-prerender: 1` means "this page supports prerendering" not "this response was prerendered". + +Actually — `x-nextjs-prerender: 1` with `x-nextjs-stale-time: 300` strongly suggests the response WAS served from the prerender cache... but the prerender file has no button while the response has the button. So the served page must be a RUNTIME render (ISR regenerated or dynamic). If it's a runtime render, then at RUNTIME, `listLoginProviders()` returned the dashboard provider → `getDashboardProvider()` saw the env at runtime. + +So the page render sees the env. The route handler doesn't. Same process. Same env. Same compiled code (proven by probe). + +UNLESS the route handler and the page renderer use DIFFERENT runtime module registries — and one of them has a DIFFERENT (inlined/older) copy of module 179405! + +Wait — I found 179405 defined in THREE chunks: 05u4myb, 0lns57e, 0ovju8m. Maybe the ROUTE uses a copy where... no, all three read process.env at runtime (I checked 05u4myb and 0lns57e and 0ovju8m — all `process.env.DASHBOARD_ORIGIN?.trim()`). + +Hmm, hold on. Let me check the response more carefully. Maybe the 404 is NOT from my route! Maybe `/api/auth/dashboard/start` is being matched by the DYNAMIC `[provider]` route with provider="dashboard"! The [provider] route's message is "OAuth provider 'dashboard' is not configured." — but we got "Dashboard SSO is not configured. Set DASHBOARD_ORIGIN." — that's MY static route's message. So no. + +OK completely different theory: **route handlers in Next.js 16 standalone get their OWN process.env snapshot** — the standalone server might filter env vars... no, /proc/1/environ has it and the login page sees it. + +Actually — WAIT. What if the login page response with the button is CACHED from a PREVIOUS container (before the restart)? No — `x-nextjs-stale-time: 300`, ISR caches in `.next/cache`. The cache dir would persist across container restarts if it's a volume... it's not a volume. But `docker restart` keeps the container filesystem! The FIRST request after restart could have regenerated from cache written by the PREVIOUS process... but the previous process was the same image with the same env. + +Hmm, actually — let me check the ISR cache in the container: does `.next/cache` exist and have login.html with button? + +This is a deep rabbit hole. Let me check the ISR cache — the login page's "button" may come from a cached runtime render from before, masking the true behavior: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'find /app/.next/cache -name \"*login*\" 2>/dev/null | head -3; ls /app/.next/cache 2>/dev/null; echo \"---fetch login with RSC and query (bypass cache):\"; '; curl -s -H \"RSC: 1\" -H \"Cache-Control: no-cache\" \"http://localhost:3001/login?q=$(date +%s%N)\" | grep -o \"dashboard\" | head -1", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +---fetch login with RSC and query (bypass cache): +dashboard + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 15.7s) + +_Thinking:_ + +No ISR cache dir. The login page DOES show "dashboard" at runtime with a unique query — so at runtime, in the same process, the login page's `getDashboardProvider()` returns the provider. + +But the route's `getDashboardProvider()` returns null. My probe of the exact module (179405) returns the provider. + +Wait — is it possible that the login page's RSC payload is a BUILD-TIME artifact (prefetched RSC)? The `x-nextjs-prerender: 1` header + `x-nextjs-stale-time: 300` suggests ISR. Let me check: maybe the login page RSC was prerendered at build time WITH the button... but I checked the on-disk prerender (`/app/.next/server/app/login.html`) has NO button. Yet the served page HAS it. So the served page is NOT the on-disk prerender file — it's a runtime render. And it has the button → runtime sees the env. + +So we have: +- login page (runtime render, same process): `getDashboardProvider()` → provider ✓ +- route handler (runtime, same process): `getDashboardProvider()` → null ✗ +- my probe (same env, same chunk): `getDashboardProvider()` → provider ✓ + +The ONLY way both can be true: the route handler executes a DIFFERENT copy of the module than the login page AND than my probe. But my probe loaded module 179405 from chunk 05u4myb — the exact chunk the route uses (990295 calls e.i(179405) and the chunk list includes 05u4myb). + +Hmm wait — unless the route module (990295) is itself from a DIFFERENT chunk! route.js requires: 10j~_next_03ru7fd, 10j~_next_133_78y, 05u4myb, 0.xpqx7, 10j~_next_dist_0wobfip, _040tdh8, _next-internal_server_app_api_auth_dashboard_start_route_actions. And module 965445 (the AppRoute wrapper) is in... where? The route.js calls R.m(965445). Module 965445's body I read is in 05u4myb (it references `b=e.i(990295)`). And 990295 is ALSO in 05u4myb (I saw its definition with the message). + +Wait, actually — I should verify which chunk contains 990295 and whether there are DUPLICATE 990295/965445 definitions across chunks. If there are two 965445 definitions and the server registered a different one first (module IDs are registered once, first-wins in the runtime registry), the route.js's R.m(965445) could resolve to a different copy — an OLD one with a different message... but the message we get IS the new one. + +OK here's another thought: maybe there are TWO copies of module 179405 — one in 05u4myb (with process.env runtime read — my probe) and the actual registered one... but the module registry in the RUNNING server could have registered 179405 from a DIFFERENT chunk FIRST (chunks load lazily; whoever registers first wins). The chunk list for the route includes 10j~_next_133_78y and _040tdh8 — maybe THOSE define 179405 with inlined env! + +I checked: `179405,` found in 05u4myb, 0lns57e, 0ovju8m only. Not in 10j~_next_133_78y or _040tdh8. Hmm. + +But wait — what about 0lns57e and 0ovju8m? Those are loaded by OTHER bundles (the login page bundle!). The login page's copy (in one of those chunks) reads process.env at runtime — fine, that's why the page works. + +So the route's copy (05u4myb → 179405) reads process.env at runtime, my probe of it works... but the running server's route returns null. + +Let me actually check: is my probe REALLY exercising the same code path? In probe-route4, I called GET → but it failed with AsyncLocalStorage error (need Next context). In probe-route3, I only called getDashboardProvider() directly and it returned the provider. So the code works. + +The mystery: the running server returns 404. Same code. Same env. Same process. + +UNLESS the running server's process.env is NOT what /proc/1/environ says! Maybe Next.js standalone REPLACES process.env with a filtered/snapshot version at startup! Let me check the running server's process.env AT RUNTIME — I can't easily inspect it... but wait, the login page sees the env at runtime (RSC shows dashboard). So process.env IS intact in the running server. + +Hmm hold on, actually — let me reconsider. Is the login page RSC payload really showing the DASHBOARD provider, or "dashboard" matching something else? Let me look at the actual RSC content. + +Let me verify precisely what the login RSC contains — maybe "dashboard" matches something else: + +**Tool: bash** + +**Input:** +```json +{ + "command": "curl -s -H \"RSC: 1\" -H \"Cache-Control: no-cache\" \"http://localhost:3001/login?q=$(date +%s%N)\" -o /tmp/login-rsc.txt; grep -o \"dashboard[^\\\"]*\" /tmp/login-rsc.txt | head -5; echo \"---full providers context:\"; grep -o \"providers.\\{0,120\\}\" /tmp/login-rsc.txt | head -2", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +dashboard +---full providers context: +providers":[{"name":"dashboard","displayName":"Dashboard"}]}]}]]}]}]}] + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 12.8s) + +_Thinking:_ + +The login page's RSC payload definitely contains `providers":[{"name":"dashboard","displayName":"Dashboard"}]`. This is the runtime render. So `listLoginProviders()` at runtime returns the dashboard provider — meaning `getDashboardProvider()` returns non-null at runtime in the page context. + +But the route returns null for the same function, in the same process. + +WAIT. Hmm. Unless the page render I'm seeing is NOT from this process... the container restarted, so no. And the RSC request had `Cache-Control: no-cache` + unique query — it's a fresh runtime render. + +So the SAME process: +- page render: `getDashboardProvider()` → non-null +- route handler: `getDashboardProvider()` → null + +Same module code (proven by probe). Same env (PID 1). This is bizarre. + +UNLESS the route handler and page render run in DIFFERENT processes! In standalone mode... wait, PID 1 is next-server, single process, 11 threads. But maybe route handlers execute in a worker thread with a different env? We checked threads — all belong to PID 1, same env. + +Hmm, hold on. Let me re-examine: maybe the page RSC payload is served from a cached/prefetched artifact and NOT from runtime. The `x-nextjs-prerender: 1` header on the login page indicates it IS prerendered! And `x-nextjs-stale-time: 300` means ISR. The login page is prerendered at BUILD time (when DASHBOARD_ORIGIN was NOT set!)... but the served content HAS the provider. That contradicts the prerendered file (which has no button). + +Wait — unless the prerender happens at REQUEST time. First request → prerenders dynamically with env → serves → caches. The on-disk `/app/.next/server/app/login.html` was checked at 2607 bytes with no button — but that was checked BEFORE... hmm, actually I checked it after requests and it was still 2607 bytes with 0 matches. + +Actually wait — the login page RSC I fetched has the provider. The on-disk login.html (2607 bytes) has no button. So the served response is NOT the on-disk file. It's either a dynamic render (with env visible → provider) or a cached ISR payload stored elsewhere. + +OK so the login page is dynamically rendered and sees the env. The route handler... doesn't. SAME PROCESS. + +Let me look at this from a totally different angle: maybe `process.env` in the ROUTE HANDLER context is not the global `process.env`. Next.js standalone/Turbopack might give route handlers a SANITIZED env. Actually — wait. I remember now! Next.js has a feature where route handlers... no. + +Let me just test the route handler env directly by making the route echo the env. I can't modify the route without rebuilding, but I CAN test via a different route that reads env — actually, there's a simpler test: the login PAGE uses `listLoginProviders` which reads env. It works. Is there ANY route handler that reads process.env successfully? The api-keys route reads KNOWHERE_KEY_ENCRYPTION_KEY — let me test an api-key operation to see if route handlers see env at all. + +Actually — even simpler and more decisive: let me check `docker exec` with the REAL server process env. Actually I already confirmed /proc/1/environ has it. + +Hmm wait, hold on. Let me re-read the earlier finding: the login page shows `x-nextjs-prerender: 1`. But when I curl with `?q=` and `RSC: 1` I get the provider in the payload. Let me check THAT response's headers — is it prerendered too? + +The page render definitely sees the env at runtime. Let me check if that RSC response is also marked prerendered, and test whether ANY route handler sees env — e.g., the api-keys route reading the encryption key: + +**Tool: bash** + +**Input:** +```json +{ + "command": "curl -s -D - -o /dev/null -H \"RSC: 1\" -H \"Cache-Control: no-cache\" \"http://localhost:3001/login?q=$(date +%s%N)\" | grep -iE \"prerender|stale|cache\"; echo \"=== api-keys route (reads KNOWHERE_KEY_ENCRYPTION_KEY):\"; curl -s -o /dev/null -w \"%{http_code}\\n\" \"http://localhost:3001/api/api-keys?t=$(date +%s)\"", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate +=== api-keys route (reads KNOWHERE_KEY_ENCRYPTION_KEY): +307 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 5.0s) + +The RSC render is `no-store` (true runtime render) and it sees the env. So the runtime DOES see the env in the page renderer. The route 404s. Same process... + +Let me look at this from the other side — **maybe the route handler in the RUNNING server is the OLD one from the FIRST (cached) build**. The first build (before `--no-cache`) produced the image that was running when I first tested. I then rebuilt `--no-cache` and recreated the container. The new container's route.js should be new... Let me verify the route.js in the CURRENT container vs. what the server actually loaded by checking the `routes-manifest` — and critically, checking whether the server was **restarted with the new image**: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker inspect knowhere-notebook --format \"Image: {{.Image}}\"; docker images --format \"{{.ID}} {{.Repository}}:{{.Tag}}\" | grep knowhere-notebook; echo \"---new image id:\"; docker inspect knowhere-notebook:dev --format \"{{.Id}}\" | cut -c1-20", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +Image: sha256:a775396f6932e657478a9920dd3433b02214d5ab4f3a02bc03f895205fe0dd6f +a775396f6932 knowhere-notebook:dev +---new image id: +sha256:a775396f6932e + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.3s) + +Container runs the latest image. The route code is new (its specific message exists). The env is in PID 1. The page renderer sees env. My probe of the exact chunk works... + +One last theory: **Next.js route handlers in standalone mode may execute with `process.env` replaced by a filtered snapshot** via `next-server`'s env handling. The login page uses `next/dynamic` streaming (Suspense + connection()) which may read a different env context than route handlers. Let me test decisively — hit the route and compare with a route I know reads env (e.g., check what happens with the OAuth google start — it reads OAUTH_GOOGLE_CLIENT_ID which is UNSET, so it 404s correctly). Both read env the same way, so the env-reading mechanism works... Let me just test the route with `NODE_ENV=development` style debug — no. + +**Time to stop diagnosing and check whether the problem is actually the server process started BEFORE the env-file was applied.** The container was created with `--env-file .env.docker` — but I rebuilt the image with `--no-cache` and then ran. Actually — wait, let me look at the container's start history: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker inspect knowhere-notebook --format \"Started: {{.State.StartedAt}} / {{.State.FinishedAt}}\"; echo \"---env-file actually applied:\"; docker inspect knowhere-notebook --format \"{{range .Config.Env}}{{println .}}{{end}}\" | grep -i dashboard", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +Started: 2026-08-05T17:11:32.951769186Z / 2026-08-05T17:11:32.797495231Z +---env-file actually applied: +DASHBOARD_ORIGIN=http://host.docker.internal:3000 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 13.9s) + +_Thinking:_ + +Everything checks out: container config has the env, PID 1 has it, code is new, page renderer sees it, probe works. The route returns 404. + +Wait... let me re-read the compiled handler one more time: + +`async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return s.nextRouteResponse.toNextResponse(i.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."));` + +Hmm — `n=e.i(179405)`. But hold on, in my probe I got module 179405's namespaceObject and called getDashboardProvider() → returned the provider. So the function IS correct. + +Unless... `e.i(179405)` in the running server resolves to a DIFFERENT module! Module IDs are per-chunk-registry. The route.js loads chunks in a specific order; when the server booted, maybe module 179405 was ALREADY registered from a DIFFERENT chunk (e.g., 0lns57e or 0ovju8m loaded earlier for the login page) and `e.i()` returns the FIRST registered instance. All three have identical code though... + +Wait — unless there's a FOURTH copy somewhere in the server bundle (next-server internals) with a STALE version! The route.js requires `[root-of-the-server]__0.xpqx7._.js` too — I only checked for "179405," pattern in chunks with that exact syntax. Let me check 0.xpqx7 for 179405 or the dashboard message. + +Actually earlier I grepped ALL chunks for "getDashboardProvider" and found only 3 files (05u4myb, 0lns57e, 0ovju8m). All identical code. + +OK here's another thought — maybe the RUNNING server process doesn't have the updated code at all because `docker run` used the OLD image... no, verified. + +Hmm, let me look at the route module 990295 more carefully: + +``` +990295,e=>e.a(async(t,r)=>{try{var n=e.i(179405),a=e.i(370742),s=e.i(105686),i=e.i(990615),o=t([a]);async function l(e){let t=(0,n.getDashboardProvider)();... +``` + +`n=e.i(179405)` — the oauth-providers module. +`a=e.i(370742)` — the oauth module (loginWithDashboardSession). +`s=e.i(105686)` — nextRouteResponse. +`i=e.i(990615)` — routeResult. +`o=t([a])` — lazy chunk for the oauth module. + +Then `if(!t)return ...404...`. So `t = getDashboardProvider()`. + +If `n` (179405) is correct, t should be non-null... + +UNLESS `e.i` here refers to a DIFFERENT loader than my probe's `R.m`. In Turbopack runtime, `e.i(id)` is "getOrInstantiateRuntimeModule". `R.m(id)` — same thing. Both should give the same module. + +Hmm hold on, wait. Let me look at this from a completely different angle. What if the running server ISN'T serving my route at all — what if `/api/auth/dashboard/start` is matched by the DYNAMIC `[provider]` route with provider="dashboard"?? The [provider] route's message is "OAuth provider 'dashboard' is not configured." — different message. We got "Dashboard SSO is not configured." which is the STATIC route's message. So it IS the static route. + +OK — completely different idea. What if `process.env.DASHBOARD_ORIGIN` IS set, `getDashboardProvider()` returns the provider, but... the response I'm seeing is CACHED?! GET route handlers CAN be cached in Next.js 16 (route cache with `connection()`/dynamic APIs disabling it). My handler doesn't use dynamic APIs at the top level... but it reads `request.headers.get("cookie")` which forces dynamic. But the FIRST request (before I added the env... no, env was there from the start). + +Wait — actually! When did I FIRST hit this route? The very first hit was after the cached build (which had OLD route code with the message "Dashboard SSO is not configured" — no wait, that message is from MY new code). + +Hmm, let me check: was there an EARLIER version of the dashboard route? No — I created the route this session. + +Let me test the caching theory: the route response could be cached by the router (ISR/route cache). If the first-ever request happened when env was somehow not visible... but env is in PID 1 since container start. + +Actually, you know what — let me check if Next.js 16 route handlers in standalone mode get their env from a SNAPSHOT taken at build time. The nextConfig embedded in server.js has `"env":{}` — but there might be an env snapshot in the standalone config. Let me look at the full nextConfig in server.js. + +Let me check the full nextConfig in server.js for env snapshotting: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst src = fs.readFileSync(\"/app/server.js\", \"utf8\")\nconst i = src.indexOf(\"const nextConfig\")\nconsole.log(src.slice(i, i + 700))\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +const nextConfig = {"env":{},"webpack":null,"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":14400,"formats":["image/webp"],"maximumRedirects":3,"maximumResponseBody":50000000,"dangerouslyAllowLocalIP":false,"dangerou + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 11.4s) + +_Thinking:_ + +The nextConfig env is `{}` — empty. So no env replacement. The route code reads process.env at runtime, the env is in PID 1, my probe works... + +WAIT. Let me re-examine my probe. My probe ran in a NEW node process where I could see `process.env.DASHBOARD_ORIGIN` — because the probe process inherited the container config env (docker exec gets the container's env, which is set from --env-file). That's the same env PID 1 has. And the probe returned the provider! + +So the module + env combo works in the container. But the RUNNING server returns 404. + +Unless... the server is NOT PID 1! Wait — `ps aux` showed PID 1 as next-server. And socket holder is PID 1. Hmm. + +Hold on — let me re-examine. Maybe the request is being served by a DIFFERENT listener. The container port 3000 → host 3001. My probe earlier hit `localhost:3000` INSIDE the container and got 404. So it's the container's own server. + +OK here's another thought: maybe the RUNNING server was started with a stale standalone output — i.e., `server.js` loads from `/app/.next/` — but WAIT. The standalone output copies `.next/standalone` content to `/app`, and that standalone `.next` contains `server/app/...`. But ALSO the Dockerfile does `COPY --from=builder /app/.next/static ./.next/static`. So `/app/.next` in the container = standalone's `.next` (from `COPY .next/standalone ./`) + static. That's the .next I probed. Same files. + +Hmm, hmm. Let me actually test the exact compiled handler in the container with the SAME process env, but through the Next server itself. Actually — I wonder... could `process.env` in the Turbopack chunk be evaluated at MODULE LOAD time vs CALL time? No — it's a function body, called per-request. + +Actually — WAIT A SECOND. Let me re-read the handler: + +``` +async function l(e){let t=(0,n.getDashboardProvider)();if(!t)return ...404...} +``` + +`n=e.i(179405)`. My probe: `R.m(179405).namespaceObject.getDashboardProvider()` → works. + +But in the RUNNING server, `e.i(179405)` — is 179405 the same module? Module IDs are per-bundle. In the route bundle, `e.i(179405)` resolves within the route's module graph. My probe used the same chunk files and the same runtime... and got the provider! + +So why does the server 404? + +Unless... the server is executing DIFFERENT chunk files than what I probed. The standalone output vs. what I probed: I probed `/app/.next/server/chunks/...` — same files the server loads (route.js requires them relative to `.next/server`). + +OK completely different theory: **Next.js caches the route handler response**. Route handlers CAN be cached with `export const dynamic = "force-static"` or by default in some configs. The `route.js` has no dynamic export... but reading `request.headers` makes it dynamic by default. Unless Next is caching it as static because it doesn't detect the dynamic usage... and the cached response was generated at... build time? No — route handlers aren't prerendered at build in app router unless forced. + +Wait — actually! Let me check if the response has caching headers: + +Let me check the response headers of the route 404 for caching markers: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start?t=\" + Date.now(), method:\"GET\"}, res => {\n console.log(\"status:\", res.statusCode)\n console.log(\"headers:\", JSON.stringify(res.headers, null, 1))\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d))\n})\nreq.end()\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +status: 404 +headers: { + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "x-nextjs-cache": "HIT", + "cache-control": "s-maxage=31536000", + "content-type": "application/json", + "date": "Wed, 05 Aug 2026 17:14:40 GMT", + "connection": "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked" +} +body: {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 15.1s) + +_Thinking:_ + +**FOUND IT!** `x-nextjs-cache: HIT` with `cache-control: s-maxage=31536000` (1 year!). The GET route handler is **being cached as a static route** by Next.js! The 404 was generated ONCE (probably during a build-time or first-request prerender when... wait, but env is set now). + +Actually — GET route handlers in Next.js 16 are **cached by default** (Route Handlers are static by default!). The route handler was evaluated at build time (when `DASHBOARD_ORIGIN` was NOT set in the build environment → 404 "not configured") and the static response was baked in with `s-maxage=31536000`. + +That's the classic Next.js 15/16 behavior change: **GET route handlers are statically optimized at build time** unless they use dynamic APIs (`cookies()`, `headers()`, `request.url` in a dynamic way, etc.). My handler reads `request.headers.get("cookie")` — but Next.js may still treat it as static if it doesn't recognize... actually reading request headers should make it dynamic. BUT — the app-path-routes-manifest / routes-manifest would show if this route is static. And `x-nextjs-cache: HIT` proves it. + +Also `Vary: rsc...` — no cookie in vary. + +The fix: force the route to be dynamic. Options: +1. `export const dynamic = "force-dynamic"` in the route. +2. Use `cookies()` from next/headers (dynamic API) instead of `request.headers`. +3. Add `export const revalidate = 0`. + +This also means the OAuth `/api/auth/[provider]/start` and `/callback` routes might be affected too! The callback uses `NextResponse.redirect` — if cached... redirects with 307? Let me check the OAuth start route caching too. Actually the `[provider]/start` route also reads `request.url` — hmm. + +But wait — my dashboard route reads `request.headers.get("cookie")` which SHOULD be dynamic... In Next.js, reading `req.headers` in a route handler does NOT automatically opt out of static optimization. Only `cookies()`, `headers()`, `connection()` from `next/headers` do (in RSC pages). For route handlers, Next.js 15+ checks for dynamic APIs; `request.headers` access is not recognized as dynamic (the bundler can't statically detect `req.headers.get("cookie")` as dynamic since `req` is passed in). Actually Next.js does detect... hmm, apparently not in this version. + +So the fix: +- `export const dynamic = "force-dynamic"` — forces runtime evaluation. +- Or use `cookies()` from `next/headers`. + +This also explains the LOGIN PAGE mystery! The login page was ALSO cached (x-nextjs-prerender: 1, stale-time 300) — but its prerender was REGENERATED at runtime (ISR, stale-while-revalidate) when the first request came in with the env set — that's why it shows the button now (the ISR cache was populated at runtime). The route handler, however, has `s-maxage=31536000` (1 year) — cached at build time, never regenerated. + +The cleanest fix: use `cookies()` from `next/headers` in the route (standard dynamic API), or `export const dynamic = "force-dynamic"`. I'll do both properly: + +1. `src/app/api/auth/dashboard/start/route.ts` — add `export const dynamic = "force-dynamic"` and/or use `cookies()`. +2. Also check the OAuth `[provider]/start` and `[provider]/callback` routes — they likely have the same caching issue! The callback does redirect — but the `start` route reads `request.url` (dynamic). Let me check both with headers. + +Actually for callback: it does `new URL(request.url)` and `NextResponse.redirect` — but if the build-time prerender ran with no providers, the redirect to /login?error=provider is baked with 1yr cache → OAuth would be broken. That's CRITICAL — the earlier P4-1 OAuth was likely ALSO cached-broken (we never browser-tested it, only unit tests!). Let me verify. + +**FOUND IT!** `x-nextjs-cache: HIT` with `s-maxage=31536000` (1 year)! The GET route handler is **statically optimized at build time** — the 404 was baked in during `next build` (when `DASHBOARD_ORIGIN` was unset) and cached for a year. The login page is ISR (revalidated at runtime → shows the button), but the route handler is hard-cached. + +This also means the P4-1 OAuth routes are likely broken the same way. Let me check: + +**Tool: bash** + +**Input:** +```json +{ + "command": "echo \"=== google start (was 404 'not configured' — is it cached?):\"; docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/google/start\", method:\"GET\"}, res => {\n console.log(\"status:\", res.statusCode, \"cache:\", res.headers[\"x-nextjs-cache\"], res.headers[\"cache-control\"])\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d.slice(0,100)))\n})\nreq.end()\n'; echo \"=== callback:\"; docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/google/callback?code=x\", method:\"GET\"}, res => {\n console.log(\"status:\", res.statusCode, \"cache:\", res.headers[\"x-nextjs-cache\"], \"location:\", res.headers.location)\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d.slice(0,60)))\n})\nreq.end()\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +=== google start (was 404 'not configured' — is it cached?): +status: 404 cache: undefined undefined +body: {"message":"OAuth provider 'google' is not configured."} +=== callback: +status: 307 cache: undefined location: http://0.0.0.0:3000/login?error=provider +body: + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 8.3s) + +_Thinking:_ + +Interesting — the google start route is NOT cached (no x-nextjs-cache header). But the dashboard route IS cached (x-nextjs-cache: HIT). Why the difference? + +The google start route reads `request.url` (via `new URL(request.url)`) which makes it dynamic. My dashboard route only reads `request.headers.get("cookie")` — which apparently Next.js's static optimization doesn't detect as dynamic! + +Also the callback is 307 (not cached) — it reads request.url too. + +So the fix: the dashboard start route must be marked dynamic. Reading `request.headers` alone doesn't opt out of static optimization in this Next version. I should add `export const dynamic = "force-dynamic"` to the dashboard start route. + +Wait, but the dashboard route reads `request.headers.get("cookie")`... Next.js static detection: route handlers that use `request.headers` or `request.url` should be dynamic. Actually in Next.js 15/16, GET route handlers are static by default and Next detects dynamic usage. Apparently `request.headers.get("cookie")` was NOT detected as dynamic. Let me just add `export const dynamic = "force-dynamic"`. + +Also — the callback returned location `http://0.0.0.0:3000/login?error=provider` — hmm, that's because request.url host is 0.0.0.0:3000 inside the container. That's fine for real usage (browser hits localhost:3001, so origin will be localhost:3001). + +Let me fix the route. + +**Root cause found**: the dashboard start route is being **statically optimized at build time** (`x-nextjs-cache: HIT`, `s-maxage=31536000` = 1 year) because reading `request.headers.get("cookie")` doesn't opt out of static optimization in this Next version. The 404 was baked into the build (when `DASHBOARD_ORIGIN` was unset at build time) and cached for a year. The login page is ISR (revalidated → works); the Google OAuth routes read `request.url` (dynamic → not cached). Fix: mark the route dynamic: + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.ts", + "newString": "import type { NextRequest, NextResponse } from \"next/server\"\n\nimport { getDashboardProvider } from \"@/infrastructure/auth/oauth-providers\"\nimport {\n DashboardLoginError,\n loginWithDashboardSession,\n} from \"@/infrastructure/auth/oauth\"\nimport { nextRouteResponse } from \"@/lib/next-route-response\"\nimport { routeResult } from \"@/lib/route-result\"\n\n// This handler forwards the browser's Dashboard session cookie, so it must\n// never be statically optimized (Next.js otherwise bakes a build-time\n// response — with whatever DASHBOARD_ORIGIN was at build — into the cache).\nexport const dynamic = \"force-dynamic\"\n\nexport async function GET(request: NextRequest): Promise {", + "oldString": "import type { NextRequest, NextResponse } from \"next/server\"\n\nimport { getDashboardProvider } from \"@/infrastructure/auth/oauth-providers\"\nimport {\n DashboardLoginError,\n loginWithDashboardSession,\n} from \"@/infrastructure/auth/oauth\"\nimport { nextRouteResponse } from \"@/lib/next-route-response\"\nimport { routeResult } from \"@/lib/route-result\"\n\nexport async function GET(request: NextRequest): Promise {" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 36.3s) + +_Thinking:_ + +Wait — but the Google OAuth start route also reads `request.url` and returns JSON with `{url}` — it showed no cache header (dynamic), fine. But the callback route — it redirects; the location was `http://0.0.0.0:3000/login?error=provider` — that's because the provider isn't configured at runtime; fine. + +But hold on — why was the dashboard route cached but the google routes not? The google start route uses `new URL(request.url)` — that's a dynamic access (request.url), which opts out of static optimization. The dashboard route only reads `request.headers` — hmm, actually reading request.headers should also be dynamic... but apparently in this Next version, `request.headers.get()` isn't detected as a dynamic API, or maybe the whole thing got treated as cacheable since it doesn't use `cookies()` from next/headers. + +Anyway, `force-dynamic` fixes it. Also — I should double check the login page: it shows the button because it's ISR (stale-time 300) and got revalidated at runtime with the env set. Good. + +But wait — there's a subtlety: at BUILD time, DASHBOARD_ORIGIN was set?? No — at build time it wasn't set (build ran without .env.docker env). So the baked response was 404. The login page revalidated at runtime and now shows the button — which is correct. + +Also — should I double check that at build time, the route's 404 with `s-maxage=31536000` — this also explains why my very first container (cached build) showed the same 404: it was baked in at build. + +Now rebuild with force-dynamic and retest. Also, I should add a proxy check — wait, no. Let me also think: is there a Next.js way to avoid this for route handlers generally? The OAuth start/callback routes read request.url (dynamic) so they're fine. Only my dashboard route was affected. + +Let me rebuild and verify. + +Now rebuild and verify the fix: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -E \"Compiled|ERROR|error TS|CACHED\" | head -4 && docker rm -f knowhere-notebook && docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev && sleep 8 && docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start\", method:\"GET\"}, res => {\n console.log(\"status:\", res.statusCode, \"cache:\", res.headers[\"x-nextjs-cache\"], \"cc:\", res.headers[\"cache-control\"])\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d.slice(0,120)))\n})\nreq.end()\n'", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#3 CACHED +#8 CACHED +#9 CACHED +#10 CACHED +knowhere-notebook +96d3d7c32ec5b8baf963fd86f0e5441ce63045c083e645026c1d370cf671cee6 +status: 404 cache: HIT cc: s-maxage=31536000 +body: {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.3s) + +_Thinking:_ + +Still cached! The build layers were CACHED — my route.ts change didn't invalidate the builder layer? Wait, `#10 CACHED` — let me check which layer. Actually the issue: the `COPY . .` should invalidate. But maybe the build output shows CACHED for some layers because... let me force no-cache again, or check if the build actually picked up the change. + +Actually wait — the `#3 CACHED #8 CACHED #9 CACHED #10 CACHED` — but the `pnpm build` step should NOT be cached since COPY . . changed. Unless the build output was truncated by my grep. Let me check whether `force-dynamic` actually made it into the build — check the compiled route for "force-dynamic" or dynamic markers. + +Actually, hmm — maybe the issue is different: with `export const dynamic = "force-dynamic"` the route should not be statically optimized. Let me verify the build picked up my change first. + +Build layers cached again — let me verify my change actually got compiled in, then force rebuild: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook grep -c \"force-dynamic\" /app/.next/server/chunks/[root-of-the-server]__05u4myb._.js 2>/dev/null; docker exec knowhere-notebook grep -o \".\\{20\\}force-dynamic.\\{20\\}\" /app/.next/server/app/api/auth/dashboard/start/route.js 2>/dev/null | head -1; echo \"---check build id:\"; docker exec knowhere-notebook cat /app/.next/BUILD_ID", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +zsh:1: no matches found: /app/.next/server/chunks/[root-of-the-server]__05u4myb._.js +---check build id: +Ag8uEjoVajnGptTsAOguY +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 19.5s) + +Same BUILD_ID as before — the change wasn't compiled. The Docker build used cached layers because `COPY . .` didn't invalidate... wait, that can't be. Let me check the actual build steps: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -E \"^#[0-9]+ \\[\" | head -20", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#1 [internal] load build definition from Dockerfile +#4 [internal] load metadata for docker.io/library/node:22-alpine +#5 [internal] load .dockerignore +#6 [runner 1/8] FROM docker.io/library/node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 +#7 [internal] load build context +#8 [builder 1/4] COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +#9 [deps 2/2] RUN pnpm install --frozen-lockfile --ignore-scripts +#10 [builder 2/4] COPY --from=deps /app/node_modules ./node_modules +#11 [runner 2/8] RUN apk add --no-cache libc6-compat +#12 [base 3/4] RUN corepack enable && corepack prepare pnpm@10.30.3 --activate +#13 [base 4/4] WORKDIR /app +#14 [builder 3/4] COPY . . +#15 [builder 4/4] RUN pnpm build + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 16.8s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -E \"CACHED|#15|#14|#17|Compiled\" | head -8", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#3 CACHED +#8 CACHED +#9 CACHED +#10 CACHED +#11 CACHED +#12 CACHED +#13 CACHED +#14 [builder 3/4] COPY . . + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 21.7s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | tail -12", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +6.223 Import trace: +6.223 App Route: +6.223 ./src/app/api/auth/dashboard/start/route.ts +6.223 ./src/integrations/knowhere-keys.ts +6.223 ./src/infrastructure/auth/knowhere-api-keys-repository.ts +6.223 ./src/app/api/workspaces/route.ts +6.223 +6.223 +6.223 at (./src/app/api/auth/dashboard/start/route.ts:14:14) +6.261  ELIFECYCLE  Command failed with exit code 1. +------ +ERROR: failed to build: failed to solve: process "/bin/sh -c pnpm build" did not complete successfully: exit code: 1 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 15.8s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -B5 \"Import trace\" | head -20", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.157 - remove them if possible, or +#15 6.157 - only use them in development, or +#15 6.157 - make sure they are statically scoped to some subfolder: path.join(process.cwd(), 'data', bar), or +#15 6.157 - add ignore comments: path.join(/*turbopackIgnore: true*/ process.cwd(), bar) +#15 6.157 +#15 6.157 Import trace: +-- +#15 6.208 16 | export async function GET(request: NextRequest): Promise<NextResponse> { +#15 6.208 17 | const provider = getDashboardProvider() +#15 6.208 +#15 6.208 Ecmascript file had an error +#15 6.208 +#15 6.208 Import trace: +-- +#15 6.208 at (./src/app/api/auth/dashboard/start/route.ts:14:14) +#15 6.242  ELIFECYCLE  Command failed with exit code 1. +#15 ERROR: process "/bin/sh -c pnpm build" did not complete successfully: exit code: 1 +------ + > [builder 4/4] RUN pnpm build: +6.208 Import trace: + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 16.5s) + +The build fails — a lint error in my route file. Let me see the actual error: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -B3 -A6 \"Ecmascript file had an error\" | head -14", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.357 16 | export async function GET(request: NextRequest): Promise<NextResponse> { +#15 6.357 17 | const provider = getDashboardProvider() +#15 6.357 +#15 6.357 Ecmascript file had an error +#15 6.357 +#15 6.357 Import trace: +#15 6.357 App Route: +#15 6.357 ./src/app/api/auth/dashboard/start/route.ts +#15 6.357 ./src/integrations/knowhere-keys.ts +#15 6.357 ./src/infrastructure/auth/knowhere-api-keys-repository.ts + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 35.1s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -A8 \"App Route:\" | head -12; echo \"===\"; docker build -t knowhere-notebook:dev . 2>&1 | grep -iE \"error\\[|Error:|TS[0-9]|TypeError\" | head -6", + "timeout": 300000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.204 App Route: +#15 6.204 ./next.config.ts +#15 6.204 ./src/integrations/knowhere-keys.ts +#15 6.204 ./src/infrastructure/auth/knowhere-api-keys-repository.ts +#15 6.204 ./src/app/api/workspaces/route.ts +#15 6.204 +#15 6.204 +#15 6.253 +#15 6.253 > Build error occurred +-- +#15 6.255 App Route: +#15 6.255 ./src/app/api/auth/dashboard/start/route.ts +=== +#15 6.304 Error: Turbopack build failed with 1 errors: +#15 ERROR: process "/bin/sh -c pnpm build" did not complete successfully: exit code: 1 +ERROR: failed to build: failed to solve: process "/bin/sh -c pnpm build" did not complete successfully: exit code: 1 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 30.5s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -B2 \"Export const is not allowed\\|const dynamic\\|dynamic = \" | head -8; docker build -t knowhere-notebook:dev . 2>&1 | grep -iE \"dynamic|route segment config\" | head -6", + "timeout": 600000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.317 12 | // never be statically optimized (Next.js otherwise bakes a build-time +#15 6.317 13 | // response — with whatever DASHBOARD_ORIGIN was at build — into the cache). +#15 6.317 > 14 | export const dynamic = "force-dynamic" +#15 6.406 - very dynamic requires (like require('./' + foo)). +#15 6.456 Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it. +#15 6.456 > 14 | export const dynamic = "force-dynamic" + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.3s) + +**`cacheComponents` is enabled in next.config.ts** — it forbids `export const dynamic`. Let me check the config: + +**Tool: bash** + +**Input:** +```json +{ + "command": "cat /Users/gordon/Documents/repos/knowhere-notebook/next.config.ts", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + cacheComponents: true, + reactCompiler: true, + serverExternalPackages: [ + "pg", + "@neondatabase/serverless", + "postgres", + ], + allowedDevOrigins: [ + "127.0.0.1", + "notebook.local.knowhereto.ai", + "notebook.127.0.0.1.nip.io", + "dashboard.127.0.0.1.nip.io", + ], + turbopack: { + root: process.cwd(), + }, +}; + +export default nextConfig; + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 16.3s) + +_Thinking:_ + +`cacheComponents: true` (Next 16 cacheComponents = the "use cache" system). With cacheComponents enabled, `export const dynamic` is not allowed. The equivalent for route handlers under cacheComponents is `export const maxDuration`... no. For route handlers with cacheComponents, the way to opt out of static optimization is to use dynamic APIs like `cookies()`, `headers()`, `connection()`, or `request.url`. Actually the Next.js docs say: with cacheComponents, route handlers that use `request.headers`/`request.url`/`cookies()` are automatically dynamic — but apparently reading `request.headers.get("cookie")` did NOT make it dynamic (it got cached as static). + +Hmm wait — actually let me reconsider. The `x-nextjs-cache: HIT` + `s-maxage=31536000` means the GET route was statically optimized at build time. For route handlers in Next 16 with cacheComponents, they're cached by default and the cache key... The docs: "Route handlers are cached by default when using GET and no dynamic APIs." The dynamic APIs for route handlers are: `cookies()`, `headers()`, `connection()` from next/headers, or reading `request.url`... Reading `request.headers` — hmm, in Next 15+ `request.headers` in route handlers IS supposed to opt out of caching. + +Wait, but the Google start route reads `new URL(request.url)` and is NOT cached (we saw no cache header on it). And the callback reads `request.url` too — not cached. So `request.url` opts out but `request.headers.get("cookie")` does NOT in this version?! Interesting — that seems to be the Next 16 behavior difference. Actually in Next 16 with cacheComponents, the route handler caching uses a "cacheComponents"-style approach where `request.url` is detected but `request.headers` access is... apparently not detected. + +The fix options: +1. Use `cookies()` from `next/headers` instead of `request.headers.get("cookie")` — cookies() is a dynamic API. +2. Read `request.url` somewhere (e.g., use `new URL(request.url)` for the callback... not needed here). +3. Use `connection()` from next/headers. +4. Add `export const maxDuration`... no. + +Actually wait — but can I even use `cookies()`? In Next 16, `cookies()` is `await`-able. Let me use `await cookies()` to get the cookie header. That's the canonical dynamic API. + +Actually the cleanest: in the route, use `const jar = await cookies()` and read the session... but I need the RAW cookie header to forward to the dashboard (the dashboard needs `better-auth.session_token`, not the notebook-session). The incoming request's full Cookie header contains BOTH. With `cookies()`, I only get individual notebook cookies? No — `cookies()` returns the request cookies for this app, which includes ALL cookies sent by the browser to this host (including the dashboard's `better-auth.session_token` since it's the same host, port-agnostic!). So I can rebuild the cookie header from `cookies().getAll()`. + +Actually simpler and most faithful: use `connection()`? That just forces dynamic without giving me cookies. + +Best approach: `await cookies()` from next/headers and forward `jar.getAll().map(c => `${c.name}=${c.value}`).join("; ")`. This is a dynamic API (opts out of static optimization) AND correctly forwards all browser cookies. + +Hmm, but wait — is `cookies()` in a route handler really treated as dynamic with cacheComponents? Yes — `cookies()` is a dynamic API, route handlers using it are never statically optimized. + +Alternatively keep `request.headers.get("cookie")` but ALSO read `request.url` (e.g., `const url = new URL(request.url)` just to force dynamic)? That's hacky. The `cookies()` approach is cleaner and actually more robust (it documents intent). + +Let me rewrite the route to use `cookies()`. + +`cacheComponents: true` forbids `export const dynamic`. The clean fix: use `cookies()` from `next/headers` — a dynamic API that both opts out of static optimization and lets me forward the full browser cookie header (which includes the dashboard's session cookie, since cookies are host-scoped, not port-scoped): + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.ts", + "newString": "import type { NextRequest, NextResponse } from \"next/server\"\nimport { cookies } from \"next/headers\"\n\nimport { getDashboardProvider } from \"@/infrastructure/auth/oauth-providers\"\nimport {\n DashboardLoginError,\n loginWithDashboardSession,\n} from \"@/infrastructure/auth/oauth\"\nimport { nextRouteResponse } from \"@/lib/next-route-response\"\nimport { routeResult } from \"@/lib/route-result\"\n\n// `cookies()` is a dynamic API: it keeps this route out of static\n// optimization (Next.js would otherwise bake a build-time response with\n// whatever DASHBOARD_ORIGIN was at build into the cache for a year).\nexport async function GET(request: NextRequest): Promise {\n const provider = getDashboardProvider()\n if (!provider) {\n return nextRouteResponse.toNextResponse(\n routeResult.error(\n 404,\n \"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.\",\n ),\n )\n }\n\n // Forward the browser's full cookie jar: cookies are host-scoped (not\n // port-scoped), so this includes the Dashboard's Better Auth session\n // cookie when the Dashboard runs on the same host on another port.\n const jar = await cookies()\n const cookieHeader = jar\n .getAll()\n .map((cookie) => `${cookie.name}=${cookie.value}`)\n .join(\"; \")\n\n try {\n const url = await loginWithDashboardSession(\n cookieHeader,\n provider.dashboardOrigin,\n )\n return nextRouteResponse.toNextResponse(routeResult.ok({ url }))\n } catch (error) {\n if (error instanceof DashboardLoginError) {\n const status = error.code === \"email-collision\" ? 409 : 401\n return nextRouteResponse.toNextResponse(\n routeResult.error(status, error.message),\n )\n }\n return nextRouteResponse.toNextResponse(\n routeResult.error(500, \"Could not log in with the Dashboard.\"),\n )\n }\n}", + "oldString": "import type { NextRequest, NextResponse } from \"next/server\"\n\nimport { getDashboardProvider } from \"@/infrastructure/auth/oauth-providers\"\nimport {\n DashboardLoginError,\n loginWithDashboardSession,\n} from \"@/infrastructure/auth/oauth\"\nimport { nextRouteResponse } from \"@/lib/next-route-response\"\nimport { routeResult } from \"@/lib/route-result\"\n\n// This handler forwards the browser's Dashboard session cookie, so it must\n// never be statically optimized (Next.js otherwise bakes a build-time\n// response — with whatever DASHBOARD_ORIGIN was at build — into the cache).\nexport const dynamic = \"force-dynamic\"\n\nexport async function GET(request: NextRequest): Promise {\n const provider = getDashboardProvider()\n if (!provider) {\n return nextRouteResponse.toNextResponse(\n routeResult.error(\n 404,\n \"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.\",\n ),\n )\n }\n\n const cookieHeader = request.headers.get(\"cookie\") ?? \"\"\n try {\n const url = await loginWithDashboardSession(\n cookieHeader,\n provider.dashboardOrigin,\n )\n return nextRouteResponse.toNextResponse(routeResult.ok({ url }))\n } catch (error) {\n if (error instanceof DashboardLoginError) {\n const status = error.code === \"email-collision\" ? 409 : 401\n return nextRouteResponse.toNextResponse(\n routeResult.error(status, error.message),\n )\n }\n return nextRouteResponse.toNextResponse(\n routeResult.error(500, \"Could not log in with the Dashboard.\"),\n )\n }\n}" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 28.5s) + +_Thinking:_ + +Wait — there's a subtlety. `cookies()` in route handlers only returns cookies that... no, `cookies()` from `next/headers` returns ALL request cookies in a route handler context. Good. + +But there's an issue: the dashboard's session cookie `better-auth.session_token` on localhost — when the browser requests `localhost:3001`, it sends cookies scoped to `localhost` host (ports ignored), which includes `better-auth.session_token` set by the dashboard on port 3000. So `jar.getAll()` includes it. + +Now also — importantly — I should check the OTHER auth routes for the same caching issue! The Google OAuth `start` route reads `request.url` (dynamic — `new URL(request.url)`) so it wasn't cached (we verified: no cache header). The `callback` route also reads `request.url` and redirects (307 — redirects aren't cached the same way). But to be safe, should I check them? Earlier test showed: +- `/api/auth/google/start` → 404, no cache header → dynamic ✓ +- `/api/auth/google/callback` → 307, no cache header → dynamic ✓ + +Good, those are fine because they read `request.url`. + +Now rebuild and test. + +Also verify the other auth routes aren't affected (they read `request.url` which is dynamic — already confirmed no cache header on them). Rebuild: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -E \"Compiled|ERROR|error TS|failed\" | head -4", + "timeout": 600000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.239 ✓ Compiled successfully in 5.6s + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 11.7s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker rm -f knowhere-notebook && docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev && sleep 8 && docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start\", method:\"GET\"}, res => {\n console.log(\"status:\", res.statusCode, \"cache:\", res.headers[\"x-nextjs-cache\"], \"cc:\", res.headers[\"cache-control\"])\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d.slice(0,150)))\n})\nreq.end()\n'", + "timeout": 120000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +knowhere-notebook +28c2ca6d10a26086d4381cf8fd010f9eac5bb419cd93d1d366fd85c8a8f523a6 +status: 404 cache: HIT cc: s-maxage=31536000 +body: {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 5.4s) + +_Thinking:_ + +STILL cached! Even with `cookies()` — the dynamic API. Hmm. But wait — the build compiled successfully, but did the container get the NEW image? Let me check BUILD_ID changed. + +Actually — wait. Maybe the issue is that `cookies()` in Next 16 with cacheComponents doesn't opt out? Or maybe the route is still being statically optimized at BUILD time because... the `getDashboardProvider()` is evaluated at build time?! No — route handlers execute at runtime. + +Hold on. Let me check: did the build actually include my changes? The build said "Compiled successfully" — but maybe the container is still the old image because docker run used the image tag but the build... let me verify BUILD_ID. + +Still cached! Let me verify the new image is actually running and the compiled route changed: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook cat /app/.next/BUILD_ID; docker images knowhere-notebook:dev --format \"{{.ID}} {{.CreatedAt}}\"; echo \"---compiled route check:\"; docker exec knowhere-notebook grep -c \"cookies()\" /app/.next/server/chunks/*.js 2>/dev/null | grep -v \":0\" | head -3", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +P87HlCE0Pv0KyDUma8wK7fec49ad7d677 2026-08-06 01:19:12 +0800 HKT +---compiled route check: +zsh:1: no matches found: /app/.next/server/chunks/*.js + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.4s) + +BUILD_ID changed (new build). Let me check the compiled route module for how it now reads cookies: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'ls /app/.next/server/chunks/ | grep \"root-of-the-server\" | head -8; echo \"---find the route chunk:\"; grep -rl \"Dashboard SSO\" /app/.next/server/chunks/ 2>/dev/null'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +[root-of-the-server]__0.xpqx7._.js +[root-of-the-server]__00~6gjr._.js +[root-of-the-server]__01_~08c._.js +[root-of-the-server]__03ceqts._.js +[root-of-the-server]__05kd_nm._.js +[root-of-the-server]__05q2dvc._.js +[root-of-the-server]__05qiw4r._.js +[root-of-the-server]__05u4myb._.js +---find the route chunk: +/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.0s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst src = fs.readFileSync(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\", \"utf8\")\nconst i = src.indexOf(\"Dashboard SSO\")\nconsole.log(src.slice(i-500, i+100))\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +o.accountLinksRepository.insertEffect({userId:a.id,provider:"dashboard",providerUserId:e.id,passwordHash:null})),a}e.s(["DashboardLoginError",0,E,"buildOAuthAuthorizeUrl",0,d,"completeOAuthLogin",0,c,"loginWithDashboardSession",0,g]),r()}catch(e){r(e)}},!1),990295,e=>e.a(async(t,r)=>{try{var n=e.i(315609),a=e.i(179405),s=e.i(370742),i=e.i(105686),o=e.i(990615),l=t([s]);async function u(e){let t=(0,a.getDashboardProvider)();if(!t)return i.nextRouteResponse.toNextResponse(o.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."));let r=(await (0,n.cookies)()).getAll().map + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.8s) + +_Thinking:_ + +The route now uses `cookies()`. But the response is STILL cached (`x-nextjs-cache: HIT`, `s-maxage=31536000`). + +Wait — `s-maxage=31536000` is the static optimization header. With `cacheComponents: true`, Next.js 16 caches route handlers by default and this route has no `cacheComponents`-compatible dynamic marker... Actually in Next 16 with cacheComponents, route handlers ARE cached by default (like the old `revalidate` for GET handlers) and you opt out with... hmm. `cookies()` should be a dynamic API though. + +Wait, maybe the problem: `x-nextjs-cache: HIT` means the response was served from the incremental cache. Maybe it was cached by the FIRST request AFTER the rebuild — when... no, the first request should have been dynamic. + +Hold on — actually, let me reconsider. Maybe `cookies()` IS being detected as dynamic, but Next.js still caches because... Actually wait, let me check the response headers more carefully. Earlier: `x-nextjs-cache: HIT`, `cache-control: s-maxage=31536000`. That cache-control of 1 year is the default static route handler cache. + +Let me check the route's compiled metadata — is there a `dynamic` flag in the manifest? + +Still cached. `cookies()` should be dynamic. Let me check the route manifest for the static/dynamic decision: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'cat /app/.next/server/app/api/auth/dashboard/start/route/app-paths-manifest.json; echo; echo \"---server-reference/route metadata:\"; ls /app/.next/server/app/api/auth/dashboard/start/route/; echo \"---build-manifest for the route:\"; cat /app/.next/server/app/api/auth/dashboard/start/route/build-manifest.json 2>/dev/null | head -c 300'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +{ + "/api/auth/dashboard/start/route": "app/api/auth/dashboard/start/route.js" +} +---server-reference/route metadata: +app-paths-manifest.json +build-manifest.json +server-reference-manifest.json +---build-manifest for the route: +{ + "devFiles": [], + "ampDevFiles": [], + "polyfillFiles": [], + "lowPriorityFiles": [], + "rootMainFiles": [], + "pages": {}, + "ampFirstPages": [] +} +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.8s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'grep -o \"appPathRoutesManifest\" -r /app/.next/*.json 2>/dev/null | head -1; python3 -c \"\nimport json\nd = json.load(open(\\\"/app/.next/app-path-routes-manifest.json\\\"))\nprint([k for k in d if \\\"auth\\\" in k])\n\" 2>/dev/null; echo \"---routes-manifest appRoutes for the route:\"; docker exec knowhere-notebook python3 -c \"\nimport json\nd = json.load(open(\\\"/app/.next/routes-manifest.json\\\"))\nfor k, v in d.get(\\\"appRoutes\\\", {}).items():\n if \\\"auth\\\" in k: print(k, v)\n\" 2>/dev/null | head -6'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +---routes-manifest appRoutes for the route: + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.7s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook sh -c 'cat /app/.next/app-path-routes-manifest.json | head -c 600'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +{ + "/_global-error/page": "/_global-error", + "/_not-found/page": "/_not-found", + "/api/api-keys/[apiKeyId]/namespaces/route": "/api/api-keys/[apiKeyId]/namespaces", + "/api/api-keys/[apiKeyId]/route": "/api/api-keys/[apiKeyId]", + "/api/api-keys/route": "/api/api-keys", + "/api/auth/[provider]/callback/route": "/api/auth/[provider]/callback", + "/api/auth/[provider]/start/route": "/api/auth/[provider]/start", + "/api/auth/dashboard/start/route": "/api/auth/dashboard/start", + "/api/chat/diagram/route": "/api/chat/diagram", + "/api/chat/route": "/api/chat", + "/api/chat/threads/[threadId]/ro +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.9s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst src = fs.readFileSync(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\", \"utf8\")\nconst i = src.indexOf(\"990295,\")\nconsole.log(src.slice(i, i + 1800))\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +990295,e=>e.a(async(t,r)=>{try{var n=e.i(315609),a=e.i(179405),s=e.i(370742),i=e.i(105686),o=e.i(990615),l=t([s]);async function u(e){let t=(0,a.getDashboardProvider)();if(!t)return i.nextRouteResponse.toNextResponse(o.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."));let r=(await (0,n.cookies)()).getAll().map(e=>`${e.name}=${e.value}`).join("; ");try{let e=await (0,s.loginWithDashboardSession)(r,t.dashboardOrigin);return i.nextRouteResponse.toNextResponse(o.routeResult.ok({url:e}))}catch(e){if(e instanceof s.DashboardLoginError){let t="email-collision"===e.code?409:401;return i.nextRouteResponse.toNextResponse(o.routeResult.error(t,e.message))}return i.nextRouteResponse.toNextResponse(o.routeResult.error(500,"Could not log in with the Dashboard."))}}[s]=l.then?(await l)():l,e.s(["GET",0,u]),r()}catch(e){r(e)}},!1),965445,e=>e.a(async(t,r)=>{try{var n=e.i(578883),a=e.i(14783),s=e.i(404751),i=e.i(949684),o=e.i(498654),l=e.i(334870),u=e.i(108720),d=e.i(989213),c=e.i(932845),p=e.i(86677),f=e.i(415803),h=e.i(186349),m=e.i(418329),y=e.i(723510),g=e.i(29112),w=e.i(193695);e.i(401161);var v=e.i(736731),b=e.i(990295),R=t([b]);[b]=R.then?(await R)():R;let x=new n.AppRouteRouteModule({definition:{kind:a.RouteKind.APP_ROUTE,page:"/api/auth/dashboard/start/route",pathname:"/api/auth/dashboard/start",filename:"route",bundlePath:""},distDir:".next",relativeProjectDir:"",resolvedPagePath:"[project]/src/app/api/auth/dashboard/start/route.ts",nextConfigOutput:"standalone",userland:b,...{}}),{workAsyncStorage:A,workUnitAsyncStorage:k,serverHooks:C}=x;async function E(e,t,r){r.requestMeta&&(0,i.setRequestMeta)(e,r.requestMeta),x.isDev&&(0,i.addRequestMeta)(e,"devRequestTimingInternalsEnd",process.hrtime.bigint());let n="/api/auth/dashboard/start/route";n=n.r + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.9s) + +_Thinking:_ + +The compiled route now uses `cookies()` (`(0,n.cookies)()`), and `n=e.i(315609)` which should be `next/headers`. But the response is STILL `x-nextjs-cache: HIT` with `s-maxage=31536000`. + +Wait — maybe `x-nextjs-cache: HIT` comes from the **client cache** (the incremental cache / fetch cache at the HTTP layer). With `cacheComponents: true`, Next.js 16 caches **all** route handlers by default unless they opt out! Route handlers in Next 16 with cacheComponents... hmm, actually route handlers are static by default in Next 15+, and `cacheComponents: true` makes components cacheable, but route handlers should use dynamic APIs like `cookies()`, `headers()`, `connection()` to opt out. + +Wait, actually — the `s-maxage=31536000` with `x-nextjs-cache: HIT` means the response came from the **full route cache** (static optimization). The route was statically optimized at build time. But we now use `cookies()` which should make it dynamic... + +Unless... the `cookies()` call inside a route handler with cacheComponents... Hmm. Let me check: in Next 16, is `cookies()` still a dynamic API for route handlers? The Next.js 16 docs mention `connection()` as the replacement for dynamic APIs. Maybe in Next 16, `cookies()` in a route handler doesn't opt out of static optimization anymore — you need `connection()`! + +Actually from Next docs: "In Next.js 15, `cookies()`, `headers()`, and `draftMode()` were async and opted routes into dynamic rendering. In Next 16, they're still dynamic APIs..." — I'm not sure. The AGENTS.md says this Next version has breaking changes and to read `node_modules/next/dist/docs/`. + +Let me check the docs for route handlers and dynamic rendering in this Next version. + +The route now uses `cookies()` but is still cached. This Next version has breaking changes — let me read the bundled docs: + +**Tool: bash** + +**Input:** +```json +{ + "command": "ls /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/ 2>/dev/null | head -20", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +01-app +02-pages +03-architecture +04-community +index.md + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.1s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "grep -rln \"force-dynamic\\|cacheComponents\" /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/ 2>/dev/null | head -8", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/08-caching.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/09-revalidating.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/02-guides/caching-without-cache-components.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/02-guides/preserving-ui-state.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/02-guides/memory-usage.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md +/Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/02-guides/instant-navigation.md + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.1s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "grep -n \"route handler\" /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/08-caching.md | head -10", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +(no output) +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.3s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "grep -n \"cached by default\\|GET Route\\|cacheComponents.*route\\|Route Handlers\" /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/08-caching.md | head -10", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +42:> **Good to know:** When Cache Components is enabled, `GET` Route Handlers follow the same prerendering model as pages. See [Route Handlers with Cache Components](/docs/app/getting-started/route-handlers#with-cache-components) for details. + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.1s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "grep -n \"with-cache-components\" /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/route-handlers.md 2>/dev/null | head -2; ls /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/ | grep -i route", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +15-route-handlers.md + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.8s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "grep -n \"Cache Components\\|cached\\|dynamic\\|cookies\" /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md | head -20", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +51:Route Handlers are not cached by default. You can, however, opt into caching for `GET` methods. Other supported HTTP methods are **not** cached. To cache a `GET` method, use a [route config option](/docs/app/guides/caching-without-cache-components#dynamic) such as `export const dynamic = 'force-static'` in your Route Handler file. +54:export const dynamic = 'force-static' +70:export const dynamic = 'force-static' +85:> **Good to know**: Other supported HTTP methods are **not** cached, even if they are placed alongside a `GET` method that is cached, in the same file. +87:#### With Cache Components +89:When [Cache Components](/docs/app/getting-started/caching) is enabled, `GET` Route Handlers follow the same model as normal UI routes in your application. They run at request time by default, can be prerendered when they don't access uncached or runtime data, and you can use `use cache` to include uncached data in the static response. +91:**Static example** - doesn't access uncached or runtime data, so it will be prerendered at build time: +124:> **Good to know**: Prerendering stops if the `GET` handler accesses network requests, database queries, async file system operations, request object properties (like `req.url`, `request.headers`, `request.cookies`, `request.body`), runtime APIs like [`cookies()`](/docs/app/api-reference/functions/cookies), [`headers()`](/docs/app/api-reference/functions/headers), [`connection()`](/docs/app/api-reference/functions/connection), or non-deterministic operations. +126:**Cached example** - accesses uncached data (database query) but caches it with `use cache`, allowing it to be included in the prerendered response: +148:Special Route Handlers like [`sitemap.ts`](/docs/app/api-reference/file-conventions/metadata/sitemap), [`opengraph-image.tsx`](/docs/app/api-reference/file-conventions/metadata/opengraph-image), and [`icon.tsx`](/docs/app/api-reference/file-conventions/metadata/app-icons), and other [metadata files](/docs/app/api-reference/file-conventions/metadata) remain static by default unless they use Request-time APIs or dynamic config options. + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.8s) + +_Thinking:_ + +Key insight: "Prerendering stops if the GET handler accesses ... request object properties (like req.url, request.headers...), runtime APIs like cookies()..." + +So with `cookies()` it should NOT be prerendered. But the response is still `x-nextjs-cache: HIT` with `s-maxage=31536000`. + +Wait — but the previous response test was BEFORE the rebuild with cookies()! Let me recheck the timeline: +1. First build: `request.headers.get("cookie")` → cached (HIT) — this is what we tested repeatedly. +2. Then I added `force-dynamic` → build failed (cacheComponents). +3. Then I switched to `cookies()` → rebuilt successfully. +4. Tested again → STILL `cache: HIT`? + +Wait no — the last test WAS after the cookies() rebuild. Let me re-verify. The last test output: `status: 404 cache: HIT cc: s-maxage=31536000`. Hmm. + +But hold on — maybe the cached response is served from the **client-side/incremental cache on the host proxy**? Or maybe `s-maxage` header is baked and `x-nextjs-cache: HIT` comes from the **incremental cache** which persists... The container is fresh though. + +Actually wait — `x-nextjs-cache: HIT` with `s-maxage=31536000` — maybe the response was prerendered AT BUILD TIME (prerender happens because... the handler accesses `cookies()` which should stop prerendering). Unless the prerender-manifest cached the OLD 404 from the FIRST build? No — fresh container, fresh image. + +Hmm, actually — let me look more carefully. Maybe the cookies() import path resolves differently and the runtime API detection failed. Let me check what module 315609 is — is it really `next/headers`? + +The docs say `cookies()` stops prerendering. But it's still HIT. Let me check what module 315609 (the `cookies` import) actually is: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst src = fs.readFileSync(\"/app/.next/server/chunks/[root-of-the-server]__05u4myb._.js\", \"utf8\")\nconst i = src.indexOf(\"315609,\")\nconsole.log(\"module 315609:\", src.slice(i, i + 200))\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +module 315609: + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.9s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nfor (const f of fs.readdirSync(\"/app/.next/server/chunks\")) {\n if (!f.endsWith(\".js\")) continue\n const src = fs.readFileSync(\"/app/.next/server/chunks/\" + f, \"utf8\")\n if (src.includes(\"315609,\")) {\n const i = src.indexOf(\"315609,\")\n console.log(\"in\", f, \":\", src.slice(i, i + 150).replace(/\\n/g, \" \"))\n break\n }\n}\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +in 10j~_next_03ru7fd._.js : 315609,(e,r,t)=>{r.exports.cookies=e.r(851039).cookies,r.exports.headers=e.r(489983).headers,r.exports.draftMode=e.r(12868).draftMode}]; //# sourceMa + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.0s) + +The `cookies` import is real (`next/headers`). Per docs it should stop prerendering... but the response still shows `cache: HIT`. Maybe the cached response is coming from **the host-side browser cache** (curl caching) or the response was cached before my rebuild. Let me test with cache-busting and check the raw headers precisely: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start?v=\" + Date.now(), method:\"GET\", headers: {\"Cache-Control\": \"no-cache\"}}, res => {\n console.log(\"status:\", res.statusCode)\n console.log(\"x-nextjs-cache:\", res.headers[\"x-nextjs-cache\"])\n console.log(\"cache-control:\", res.headers[\"cache-control\"])\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d.slice(0,100)))\n})\nreq.end()\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +status: 404 +x-nextjs-cache: HIT +cache-control: s-maxage=31536000 +body: {"message":"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.4s) + +Still HIT with a cache-busted URL. The response comes from Next's incremental cache at the server. But wait — the route now uses `cookies()` which should make it dynamic... unless the **prerender happened at build time** (during `next build`, the prerender step runs the handler with no env → 404 baked into the route's prerender cache). With cacheComponents, build-time prerender bakes it. Let me check the prerender-manifest: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst d = JSON.parse(fs.readFileSync(\"/app/.next/prerender-manifest.json\", \"utf8\"))\nconsole.log(\"dynamicRoutes keys:\")\nfor (const k of Object.keys(d.dynamicRoutes || {})) if (k.includes(\"auth\")) console.log(\" \", k)\nconsole.log(\"routes keys:\")\nfor (const k of Object.keys(d.routes || {})) if (k.includes(\"auth\")) console.log(\" \", k)\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +dynamicRoutes keys: +routes keys: + /api/auth/dashboard/start + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.3s) + +**The route IS in the prerender-manifest** — Next.js prerendered it at build time (with `cacheComponents`, GET handlers are prerendered unless they access runtime APIs). The docs say `cookies()` stops prerendering... but the compiled code DOES call it. Let me check if the prerender entry has a fallback, and look at the prerender manifest entry: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst d = JSON.parse(fs.readFileSync(\"/app/.next/prerender-manifest.json\", \"utf8\"))\nconsole.log(JSON.stringify(d.routes[\"/api/auth/dashboard/start\"], null, 1).slice(0, 600))\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +{ + "initialStatus": 404, + "initialHeaders": { + "content-type": "application/json", + "x-next-cache-tags": "_N_T_/layout,_N_T_/api/layout,_N_T_/api/auth/layout,_N_T_/api/auth/dashboard/layout,_N_T_/api/auth/dashboard/start/layout,_N_T_/api/auth/dashboard/start/route,_N_T_/api/auth/dashboard/start" + }, + "renderingMode": "STATIC", + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "initialRevalidateSeconds": false, + "srcRoute": "/api/auth/dashboard/start", + "dataRoute": + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 11.5s) + +_Thinking:_ + +Confirmed: `renderingMode: STATIC` with `initialStatus: 404` baked at build time. The docs said prerendering stops when accessing runtime APIs like `cookies()`... but the compiled code DOES access cookies() and it was still prerendered as STATIC. + +Hmm — wait. Maybe the issue is that the doc says "Prerendering stops if the GET handler accesses ... runtime APIs like cookies()". But the compiled code has `let r=(await (0,n.cookies)()).getAll()` — this IS runtime. Unless... the static analysis didn't detect it because it's accessed inside a nested function or via re-export? + +Actually — wait. Let me re-check. The prerender manifest was generated at BUILD time. Was the build WITH my cookies() change? The BUILD_ID is P87HlCE0Pv0KyDUma8wK7fec49ad7d677 — that's the new one (changed from Ag8uEjoVajnGptTsAOguY). And the compiled route does call cookies(). Yet renderingMode is STATIC. + +Hmm, actually let me re-read the docs line: "Prerendering stops if the GET handler accesses network requests, database queries, async file system operations, request object properties (like req.url, request.headers, request.cookies, request.body), runtime APIs like cookies(), headers(), connection(), or non-deterministic operations." + +So with `cookies()` it should NOT be prerendered. But it IS. Maybe there's a detection bug or the version behaves differently. OR — maybe the prerender-manifest.json in the image is from an EARLIER build layer (cached)? No — the image was fully rebuilt, BUILD_ID changed, and the route code is new. + +Wait — could there be a `use cache` wrapper? Let me check if maybe the route is being wrapped. Actually, let me look at the doc for what to do when cacheComponents is on. The docs for "Cached example" use `use cache`. To force runtime: the docs mention `connection()`. Maybe with cacheComponents, the route needs explicit `connection()`? + +Actually, let me reconsider: maybe the prerendering is fine and the issue is that at RUNTIME, Next serves the prerendered 404 because `experimentalBypassFor` doesn't match (only next-action / multipart form bypass). So even though the handler would run dynamically, the static route match serves the baked 404. + +Hmm wait — but then how did the GOOGLE start route avoid this? It reads `request.url` — the docs say request object properties stop prerendering. And google start was NOT in the prerender manifest (only dashboard start was). So the detection works for `req.url` but not for `cookies()`?? + +Let me test: does the google start route show up in prerender-manifest? It wasn't listed under /api/auth. Right — only dashboard/start was listed. So `req.url` access stopped prerendering for google, but `cookies()` did NOT stop it for dashboard. That contradicts the docs... + +Unless — the build with `cookies()` change... let me double check the compiled chunk really has cookies() — yes I saw `(0,n.cookies)()` in module 990295. Hmm. + +Wait, maybe there's a subtlety: my route file imports `cookies` but the module-level call is inside the GET function. The static analysis might need it at the top? No... + +Let me actually check what the docs say about cacheComponents + route handlers more carefully — maybe the answer is `use cache` / `connection()`. + +`renderingMode: STATIC` with a baked 404 — even though the code calls `cookies()`. The docs mention `connection()` — let me read the full route-handlers docs section on cache components: + +**Tool: bash** + +**Input:** +```json +{ + "command": "sed -n '85,150p' /Users/gordon/Documents/repos/knowhere-notebook/node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +> **Good to know**: Other supported HTTP methods are **not** cached, even if they are placed alongside a `GET` method that is cached, in the same file. + +#### With Cache Components + +When [Cache Components](/docs/app/getting-started/caching) is enabled, `GET` Route Handlers follow the same model as normal UI routes in your application. They run at request time by default, can be prerendered when they don't access uncached or runtime data, and you can use `use cache` to include uncached data in the static response. + +**Static example** - doesn't access uncached or runtime data, so it will be prerendered at build time: + +```tsx filename="app/api/project-info/route.ts" +export async function GET() { + return Response.json({ + projectName: 'Next.js', + }) +} +``` + +**Dynamic example** - accesses non-deterministic operations. During the build, prerendering stops when `Math.random()` is called, deferring to request-time rendering: + +```tsx filename="app/api/random-number/route.ts" +export async function GET() { + return Response.json({ + randomNumber: Math.random(), + }) +} +``` + +**Runtime data example** - accesses request-specific data. Prerendering terminates when runtime APIs like `headers()` are called: + +```tsx filename="app/api/user-agent/route.ts" +import { headers } from 'next/headers' + +export async function GET() { + const headersList = await headers() + const userAgent = headersList.get('user-agent') + + return Response.json({ userAgent }) +} +``` + +> **Good to know**: Prerendering stops if the `GET` handler accesses network requests, database queries, async file system operations, request object properties (like `req.url`, `request.headers`, `request.cookies`, `request.body`), runtime APIs like [`cookies()`](/docs/app/api-reference/functions/cookies), [`headers()`](/docs/app/api-reference/functions/headers), [`connection()`](/docs/app/api-reference/functions/connection), or non-deterministic operations. + +**Cached example** - accesses uncached data (database query) but caches it with `use cache`, allowing it to be included in the prerendered response: + +```tsx filename="app/api/products/route.ts" +import { cacheLife } from 'next/cache' + +export async function GET() { + const products = await getProducts() + return Response.json(products) +} + +async function getProducts() { + 'use cache' + cacheLife('hours') + + return await db.query('SELECT * FROM products') +} +``` + +> **Good to know**: `use cache` cannot be used directly inside a Route Handler body; extract it to a helper function. Cached responses revalidate according to `cacheLife` when a new request arrives. + +### Special Route Handlers + +Special Route Handlers like [`sitemap.ts`](/docs/app/api-reference/file-conventions/metadata/sitemap), [`opengraph-image.tsx`](/docs/app/api-reference/file-conventions/metadata/opengraph-image), and [`icon.tsx`](/docs/app/api-reference/file-conventions/metadata/app-icons), and other [metadata files](/docs/app/api-reference/file-conventions/metadata) remain static by default unless they use Request-time APIs or dynamic config options. + +### Route Resolution + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 12.8s) + +_Thinking:_ + +The docs say `cookies()` should stop prerendering. But the compiled route calls `(0,n.cookies)()` — and it was still prerendered as STATIC. Hmm. Wait — maybe the static analysis happens at the SOURCE level, and there's a subtle issue: my code calls `cookies()` but the return value... Let me re-read my route code: + +```ts +const jar = await cookies() +``` + +That should be detected. Unless... the build I'm testing isn't the one with cookies()! Let me check the BUILD_ID — the previous cached build had `Ag8uEjoVajnGptTsAOguY`, the new one `P87HlCE0Pv0KyDUma8wK7fec49ad7d677`. And I did verify the compiled route module calls `(0,n.cookies)()`. + +Wait — but does prerender-manifest get REBUILT with the new build? It shows `renderingMode: STATIC` with `initialStatus: 404`. If cookies() were detected, it would be dynamic. Unless... the prerender-manifest.json is from a STALE layer in the image? The image was rebuilt — the `.next` output is new. + +Hmm, wait. Actually — maybe the issue is that `cookies()` IS being detected as dynamic at runtime (the handler would run dynamically), but the prerender-manifest baked `initialStatus: 404` and `renderingMode: STATIC` means the server serves the BAKED response instead of running the handler?? + +Actually no — "renderingMode: STATIC" + "initialRevalidateSeconds: false" means: this route was statically rendered at build, cached forever. If the handler accesses cookies(), Next should mark it dynamic. The fact that it's STATIC means the prerender step did NOT encounter a runtime API during its build-time evaluation... + +OH WAIT. I see it now! Look at the compiled route: + +```js +async function u(e){let t=(0,a.getDashboardProvider)();if(!t)return i.nextRouteResponse.toNextResponse(o.routeResult.error(404,"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.")); +``` + +At BUILD TIME, the prerender step RUNS the handler (statically, without a request). `getDashboardProvider()` returns null (no env at build) → **the handler returns the 404 BEFORE ever reaching `cookies()`!** So the prerender never sees the runtime API call — it bails early with the 404 response, which gets baked as the static response! + +That's exactly it. The early return `if (!provider) return 404` short-circuits before `cookies()` is called, so Next's prerender sees a pure deterministic 404 and caches it. + +The fix: call `cookies()` (or `connection()`) BEFORE the provider check, so the prerender encounters the runtime API and defers to request-time rendering. Or use `connection()` at the top. Let me restructure: read cookies first, then check provider. + +Actually the cleanest: `await connection()` at the very top, or move the cookies() read before the provider check. But reading cookies before the check is fine — cookies() just reads the jar. + +Let me move the cookies() call above the provider check. + +**Root cause found**: at build time, Next prerenders the handler — `getDashboardProvider()` returns null (no env at build), so the handler returns the 404 **before ever reaching `cookies()`**. The prerender never encounters a runtime API, so the 404 gets baked as a permanent static response. Fix: call `cookies()` **before** the provider check so prerendering terminates at request time: + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.ts", + "newString": "// `cookies()` is a dynamic API and must be reached BEFORE any early return:\n// with cacheComponents, Next.js prerenders GET handlers at build time, and\n// an early `getDashboardProvider()` 404 would be baked in as a permanent\n// static response (build-time env ≠ runtime env). Reading the cookie jar\n// first terminates prerendering and defers to request-time rendering.\nexport async function GET(_request: NextRequest): Promise {\n const jar = await cookies()\n const provider = getDashboardProvider()\n if (!provider) {\n return nextRouteResponse.toNextResponse(\n routeResult.error(\n 404,\n \"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.\",\n ),\n )\n }\n\n // Forward the browser's full cookie jar: cookies are host-scoped (not\n // port-scoped), so this includes the Dashboard's Better Auth session\n // cookie when the Dashboard runs on the same host on another port.\n const cookieHeader = jar\n .getAll()\n .map((cookie) => `${cookie.name}=${cookie.value}`)\n .join(\"; \")", + "oldString": "// `cookies()` is a dynamic API: it keeps this route out of static\n// optimization (Next.js would otherwise bake a build-time response with\n// whatever DASHBOARD_ORIGIN was at build into the cache for a year).\nexport async function GET(request: NextRequest): Promise {\n const provider = getDashboardProvider()\n if (!provider) {\n return nextRouteResponse.toNextResponse(\n routeResult.error(\n 404,\n \"Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.\",\n ),\n )\n }\n\n // Forward the browser's full cookie jar: cookies are host-scoped (not\n // port-scoped), so this includes the Dashboard's Better Auth session\n // cookie when the Dashboard runs on the same host on another port.\n const jar = await cookies()\n const cookieHeader = jar\n .getAll()\n .map((cookie) => `${cookie.name}=${cookie.value}`)\n .join(\"; \")" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 38.5s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -E \"Compiled|ERROR|failed\" | head -3 && docker rm -f knowhere-notebook && docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev && sleep 8 && docker exec knowhere-notebook node -e '\nconst http = require(\"http\")\nconst req = http.request({host:\"localhost\", port:3000, path:\"/api/auth/dashboard/start\", method:\"GET\"}, res => {\n console.log(\"status:\", res.statusCode, \"| cache:\", res.headers[\"x-nextjs-cache\"])\n let d = \"\"; res.on(\"data\", c => d += c); res.on(\"end\", () => console.log(\"body:\", d.slice(0,120)))\n})\nreq.end()\n'", + "timeout": 600000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.135 ✓ Compiled successfully in 5.5s +knowhere-notebook +0f78f45d775118a10c15d2fee8afc2ee6fb6471674fa0fe50cd68103fbdb519f +status: 401 | cache: undefined +body: {"message":"Dashboard session check failed (status=404)."} + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 5.0s) + +**Fixed.** The route is now dynamic (`cache: undefined`), and it correctly attempts the Dashboard session check — returning 401 with "session check failed (status=404)" because `host.docker.internal:3000` is the self-hosted Knowhere app, not the dashboard. The plumbing works end-to-end. Let me verify the prerender-manifest no longer has it static and confirm login page still shows the button: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst d = JSON.parse(fs.readFileSync(\"/app/.next/prerender-manifest.json\", \"utf8\"))\nconsole.log(\"dashboard in prerender routes:\", \"/api/auth/dashboard/start\" in (d.routes || {}))\nconsole.log(\"google in prerender routes:\", \"/api/auth/[provider]/start\" in (d.dynamicRoutes || {}))\n'; echo \"=== login button:\"; curl -s \"http://localhost:3001/login?t=$(date +%s)\" | grep -c \"SSO (Dashboard)\"; echo \"=== dashboard start with a fake dashboard session cookie:\"; curl -s -w \"\\n%{http_code}\\n\" \"http://localhost:3001/api/auth/dashboard/start\" -H \"Cookie: better-auth.session_token=fake\"", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +dashboard in prerender routes: false +google in prerender routes: false +=== login button: +1 +=== dashboard start with a fake dashboard session cookie: +{"message":"Dashboard session check failed (status=404)."} +401 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.2s) + +Route is now dynamic, not in the prerender manifest, login button shows, and the full flow works (fails cleanly because port 3000 isn't the dashboard here). Now I need to check whether **other GET routes have the same prerender-baking problem** — this affects chat thread reads, sources, etc. Let me check which routes are in the prerender manifest: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker exec knowhere-notebook node -e '\nconst fs = require(\"fs\")\nconst d = JSON.parse(fs.readFileSync(\"/app/.next/prerender-manifest.json\", \"utf8\"))\nconst routes = Object.keys(d.routes || {})\nconsole.log(\"STATIC-prerendered routes (\", routes.length, \"):\")\nfor (const r of routes) console.log(\" \", r)\n'", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +STATIC-prerendered routes ( 7 ): + / + /_global-error + /_not-found + /e2e/citation-dedupe + /e2e/source-polling + /favicon.ico + /login + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 21.6s) + +_Thinking:_ + +Only `/login` (ISR, fine — it revalidates at runtime with `x-nextjs-stale-time: 300`) and the shell pages. No API GET routes are statically baked. So only the dashboard route had the problem (its early return short-circuited before the dynamic API). The google start route reads `request.url` before the early return, so it was never baked. + +Wait — actually, why was google start not baked but dashboard was? Google start reads `request.url` (via `new URL(request.url)`) at the top → dynamic. The dashboard route previously read `request.headers.get("cookie")` AFTER the provider check → the early 404 return happened first → baked. Makes sense. + +Now — the login page IS in the prerender list but with revalidation (stale-time 300), so it re-renders at runtime with real env — that's why the button appeared. But that means the login page prerender was generated at build time WITHOUT the env, then... wait, but the button showed on the FIRST request after restart? The ISR cache had been populated at build with no button... Actually `x-nextjs-prerender: 1` + `x-nextjs-stale-time: 300` — the served HTML DID contain the button. So the login page was re-rendered at runtime (ISR on-demand) with the env → button appears. Good — the login page is fine because ISR revalidates. + +Hmm, but wait — why did the login page show the button but the dashboard route didn't revalidate? Because the dashboard route had `initialRevalidateSeconds: false` (permanent, no revalidation), while the login page has `stale-time: 300` (ISR). The route handler prerender with `initialRevalidateSeconds: false` never revalidates. That's why. + +OK so the fix is complete for this route. Let me also double-check the OAuth start/callback routes weren't affected — they read `request.url` first thing, so they're dynamic. Confirmed: no `/api/auth/[provider]/*` in the prerender list. + +Now let me also verify the login page's `connection()` + Suspense pattern still works and run the full test suite, then commit. Also update the proxy test (already added). Let me run tests + lint + typecheck. + +Only the dashboard route was affected (the early 404 short-circuited before the dynamic API; login page ISR-revalidates fine, OAuth routes read `request.url` first so they were never baked). The fix is complete. Run the full suite: + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && pnpm test 2>&1 | grep -E \"FAIL|Test Files|Tests \" | head -4 && pnpm lint 2>&1 | grep -cE \" error| warning\"; pnpm typecheck 2>&1 | grep -c \"error TS\"; echo DONE", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ + FAIL src/app/api/auth/dashboard/start/route.test.ts > GET /api/auth/dashboard/start > 404s when the dashboard provider is not configured + FAIL src/app/api/auth/dashboard/start/route.test.ts > GET /api/auth/dashboard/start > returns the app URL after a successful login + FAIL src/app/api/auth/dashboard/start/route.test.ts > GET /api/auth/dashboard/start > surfaces the no-dashboard-session error as 401 +2 +0 +DONE + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.8s) + +The route now calls `cookies()` (from next/headers) which isn't mocked in the route test. Fix the test to mock `next/headers`: + +**Tool: read** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts", + "limit": 45 +} +``` + +**Output:** +``` +/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts +file + +1: import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +2: import { NextRequest } from "next/server" +3: +4: const mocks = vi.hoisted(() => ({ +5: getDashboardProvider: vi.fn(), +6: loginWithDashboardSession: vi.fn(), +7: DashboardLoginError: class extends Error { +8: readonly code: string +9: constructor(code: string, message: string) { +10: super(message) +11: this.code = code +12: } +13: }, +14: })) +15: +16: vi.mock("@/infrastructure/auth/oauth-providers", () => ({ +17: getDashboardProvider: mocks.getDashboardProvider, +18: })) +19: +20: vi.mock("@/infrastructure/auth/oauth", () => ({ +21: loginWithDashboardSession: mocks.loginWithDashboardSession, +22: DashboardLoginError: mocks.DashboardLoginError, +23: })) +24: +25: import { GET } from "./route" +26: +27: describe("GET /api/auth/dashboard/start", () => { +28: beforeEach(() => { +29: vi.clearAllMocks() +30: }) +31: +32: afterEach(() => { +33: vi.unstubAllEnvs() +34: }) +35: +36: it("404s when the dashboard provider is not configured", async () => { +37: mocks.getDashboardProvider.mockReturnValue(null) +38: +39: const response = await GET(new NextRequest("http://localhost:3001/api/auth/dashboard/start")) +40: const body = (await response.json()) as { message?: string } +41: +42: expect(response.status).toBe(404) +43: expect(body.message).toMatch(/DASHBOARD_ORIGIN/i) +44: }) +45: + +(Showing lines 1-45 of 121. Use offset=46 to continue.) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.2s) + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts", + "newString": "const mocks = vi.hoisted(() => ({\n getDashboardProvider: vi.fn(),\n loginWithDashboardSession: vi.fn(),\n DashboardLoginError: class extends Error {\n readonly code: string\n constructor(code: string, message: string) {\n super(message)\n this.code = code\n }\n },\n cookieJar: {\n getAll: vi.fn(() => []),\n },\n}))\n\nvi.mock(\"@/infrastructure/auth/oauth-providers\", () => ({\n getDashboardProvider: mocks.getDashboardProvider,\n}))\n\nvi.mock(\"@/infrastructure/auth/oauth\", () => ({\n loginWithDashboardSession: mocks.loginWithDashboardSession,\n DashboardLoginError: mocks.DashboardLoginError,\n}))\n\nvi.mock(\"next/headers\", () => ({\n cookies: async () => mocks.cookieJar,\n}))\n\nimport { GET } from \"./route\"", + "oldString": "const mocks = vi.hoisted(() => ({\n getDashboardProvider: vi.fn(),\n loginWithDashboardSession: vi.fn(),\n DashboardLoginError: class extends Error {\n readonly code: string\n constructor(code: string, message: string) {\n super(message)\n this.code = code\n }\n },\n}))\n\nvi.mock(\"@/infrastructure/auth/oauth-providers\", () => ({\n getDashboardProvider: mocks.getDashboardProvider,\n}))\n\nvi.mock(\"@/infrastructure/auth/oauth\", () => ({\n loginWithDashboardSession: mocks.loginWithDashboardSession,\n DashboardLoginError: mocks.DashboardLoginError,\n}))\n\nimport { GET } from \"./route\"" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 2.9s) + +Now the "returns the app URL" test should assert the cookie jar is forwarded. Let me update it: + +**Tool: read** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts", + "limit": 30, + "offset": 52 +} +``` + +**Output:** +``` +/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts +file + +52: +53: it("returns the app URL after a successful login", async () => { +54: mocks.getDashboardProvider.mockReturnValue({ +55: kind: "dashboard", +56: name: "dashboard", +57: displayName: "Dashboard", +58: dashboardOrigin: "http://localhost:3000", +59: }) +60: mocks.loginWithDashboardSession.mockResolvedValue("/") +61: +62: const request = new NextRequest("http://localhost:3001/api/auth/dashboard/start", { +63: headers: { cookie: "better-auth.session_token=abc" }, +64: }) +65: const response = await GET(request) +66: const body = (await response.json()) as { url?: string } +67: +68: expect(response.status).toBe(200) +69: expect(body.url).toBe("/") +70: expect(mocks.loginWithDashboardSession).toHaveBeenCalledWith( +71: "better-auth.session_token=abc", +72: "http://localhost:3000", +73: ) +74: }) +75: +76: it("surfaces the no-dashboard-session error as 401", async () => { +77: mocks.getDashboardProvider.mockReturnValue({ +78: kind: "dashboard", +79: name: "dashboard", +80: displayName: "Dashboard", +81: dashboardOrigin: "http://localhost:3000", + +(Showing lines 52-81 of 128. Use offset=82 to continue.) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 5.1s) + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts", + "newString": " it(\"returns the app URL after a successful login\", async () => {\n mocks.getDashboardProvider.mockReturnValue({\n kind: \"dashboard\",\n name: \"dashboard\",\n displayName: \"Dashboard\",\n dashboardOrigin: \"http://localhost:3000\",\n })\n mocks.loginWithDashboardSession.mockResolvedValue(\"/\")\n mocks.cookieJar.getAll.mockReturnValue([\n { name: \"better-auth.session_token\", value: \"abc\" },\n { name: \"notebook-session\", value: \"xyz\" },\n ])\n\n const request = new NextRequest(\"http://localhost:3001/api/auth/dashboard/start\")\n const response = await GET(request)\n const body = (await response.json()) as { url?: string }\n\n expect(response.status).toBe(200)\n expect(body.url).toBe(\"/\")\n expect(mocks.loginWithDashboardSession).toHaveBeenCalledWith(\n \"better-auth.session_token=abc; notebook-session=xyz\",\n \"http://localhost:3000\",\n )\n })", + "oldString": " it(\"returns the app URL after a successful login\", async () => {\n mocks.getDashboardProvider.mockReturnValue({\n kind: \"dashboard\",\n name: \"dashboard\",\n displayName: \"Dashboard\",\n dashboardOrigin: \"http://localhost:3000\",\n })\n mocks.loginWithDashboardSession.mockResolvedValue(\"/\")\n\n const request = new NextRequest(\"http://localhost:3001/api/auth/dashboard/start\", {\n headers: { cookie: \"better-auth.session_token=abc\" },\n })\n const response = await GET(request)\n const body = (await response.json()) as { url?: string }\n\n expect(response.status).toBe(200)\n expect(body.url).toBe(\"/\")\n expect(mocks.loginWithDashboardSession).toHaveBeenCalledWith(\n \"better-auth.session_token=abc\",\n \"http://localhost:3000\",\n )\n })" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.0s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && pnpm test src/app/api/auth/dashboard/start/route.test.ts 2>&1 | grep -E \"FAIL|×|Test Files|Tests \" | head -6", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` + Test Files 1 passed (1) + Tests 5 passed (5) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 14.4s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && pnpm test 2>&1 | grep -E \"FAIL|Test Files|Tests \" | head -4 && pnpm lint 2>&1 | grep -cE \" error| warning\"; pnpm typecheck 2>&1 | grep -c \"error TS\"; echo DONE", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` + Test Files 117 passed | 1 skipped (118) + Tests 586 passed | 13 skipped (599) +2 +2 +DONE + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 8.1s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && pnpm lint 2>&1 | grep -E \" error| warning\" | head -4; pnpm typecheck 2>&1 | grep \"error TS\" | head -4", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` + 17:27 warning '_request' is defined but never used @typescript-eslint/no-unused-vars +✖ 1 problem (0 errors, 1 warning) +src/app/api/auth/dashboard/start/route.test.ts(62,7): error TS2322: Type '{ name: string; value: string; }' is not assignable to type 'never'. +src/app/api/auth/dashboard/start/route.test.ts(63,7): error TS2322: Type '{ name: string; value: string; }' is not assignable to type 'never'. + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.6s) + +The cookieJar.getAll mock returns `never[]` — type it properly: + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.test.ts", + "newString": " cookieJar: {\n getAll: vi.fn<() => { name: string; value: string }[]>(() => []),\n },", + "oldString": " cookieJar: {\n getAll: vi.fn(() => []),\n }," +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 4.2s) + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/src/app/api/auth/dashboard/start/route.ts", + "newString": "export async function GET(_request: NextRequest): Promise {\n // `cookies()` is a dynamic API and must be reached BEFORE any early\n // return: with cacheComponents, Next.js prerenders GET handlers at build\n // time, and an early `getDashboardProvider()` 404 would be baked in as a\n // permanent static response (build-time env ≠ runtime env). Reading the\n // cookie jar first terminates prerendering and defers to request-time\n // rendering. (`_request` is unused; `cookies()` reads the request.)\n void _request\n const jar = await cookies()", + "oldString": "// `cookies()` is a dynamic API and must be reached BEFORE any early return:\n// with cacheComponents, Next.js prerenders GET handlers at build time, and\n// an early `getDashboardProvider()` 404 would be baked in as a permanent\n// static response (build-time env ≠ runtime env). Reading the cookie jar\n// first terminates prerendering and defers to request-time rendering.\nexport async function GET(_request: NextRequest): Promise {\n const jar = await cookies()" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 14.4s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && pnpm lint 2>&1 | grep -cE \" error| warning\"; pnpm typecheck 2>&1 | grep -c \"error TS\"; pnpm test 2>&1 | grep -E \"Test Files|Tests \" | tail -1", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +0 +0 + Tests 586 passed | 13 skipped (599) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 40.6s) + +All green. Rebuild the container one final time and verify: + +**Tool: bash** + +**Input:** +```json +{ + "command": "docker build -t knowhere-notebook:dev . 2>&1 | grep -E \"Compiled|ERROR\" | head -2 && docker rm -f knowhere-notebook && docker run -d --name knowhere-notebook -p 3001:3000 --add-host localhost.localstack.cloud:host-gateway --env-file .env.docker knowhere-notebook:dev && sleep 8 && echo \"=== route (should be dynamic, 401 session-check):\" && curl -s -w \" | %{http_code}\\n\" http://localhost:3001/api/auth/dashboard/start -H \"Cookie: better-auth.session_token=fake\" && echo \"=== login button:\" && curl -s http://localhost:3001/login | grep -c \"SSO (Dashboard)\" && echo \"=== google oauth route (still dynamic):\" && curl -s -o /dev/null -w \"%{http_code}\\n\" http://localhost:3001/api/auth/google/start", + "timeout": 600000, + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +#15 6.617 ✓ Compiled successfully in 6.0s +knowhere-notebook +43259fc65242da1b56551c7db0b3d5f28dc6288dffe73f6b3cd8246ee35a4c5a +=== route (should be dynamic, 401 session-check): +{"message":"Dashboard session check failed (status=404)."} | 401 +=== login button: +1 +=== google oauth route (still dynamic): +404 + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.4s) + +Everything works. Now commit — this was a significant catch. Also verify git status and clean up temp files: + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && rm -f /tmp/login-headers.txt /tmp/login-body.html /tmp/login-body2.html /tmp/login-rsc.txt /tmp/lh.txt /tmp/lb.html && docker exec knowhere-notebook rm -f /tmp/probe-*.js 2>/dev/null; git status --short | head -20", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +zsh:1: no matches found: /tmp/probe-*.js + M .env.local.example + M src/app/login/login-form.tsx + M src/app/login/page.tsx + M src/infrastructure/auth/oauth-providers.test.ts + M src/infrastructure/auth/oauth-providers.ts + M src/infrastructure/auth/oauth.test.ts + M src/infrastructure/auth/oauth.ts + M src/proxy.test.ts + M src/proxy.ts +?? src/app/api/auth/dashboard/ + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.6s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && git diff --stat | tail -3 && git add .env.local.example src/app/login/ src/infrastructure/auth/oauth-providers.ts src/infrastructure/auth/oauth-providers.test.ts src/infrastructure/auth/oauth.ts src/infrastructure/auth/oauth.test.ts src/proxy.ts src/proxy.test.ts src/app/api/auth/dashboard/ && git commit -m \"feat(auth): Dashboard SSO login via session handoff\n\nPhase 4 addendum. When DASHBOARD_ORIGIN is set, the login page offers\n'SSO (Dashboard)': the browser's Dashboard Better Auth session cookie is\nhost-scoped (not port-scoped), so it reaches the notebook on another port.\nGET /api/auth/dashboard/start forwards the full cookie jar to the\nDashboard's public users.getCurrentUser oRPC endpoint (empty JSON body,\n3s timeout) and logs the user in via find-or-create:\n\n- links by (dashboard, providerUserId) first\n- email collision: adopts an existing user only when they have no\n password (pristine or OAuth-created); password-protected accounts are\n refused with a 409 so nobody can take over an admin account\n- no-dashboard-session surfaces as 401 with an inline error on /login\n\nFixes a Next.js 16 cacheComponents pitfall: GET route handlers are\nprerendered at build time, and the route's early provider-404 was baked\ninto a year-long static cache. cookies() (a dynamic API) is now read\nbefore any early return, deferring to request-time rendering. Same\npattern keeps the Google/GitHub start/callback routes dynamic (they read\nrequest.url first). Proxy now whitelists /api/auth so anonymous login\nflows are not redirected to /login.\"", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` + src/proxy.test.ts | 16 ++ + src/proxy.ts | 1 + + 9 files changed, 534 insertions(+), 12 deletions(-) +[feat/self-hosted-chunks-overlay-layout cf0dd58] feat(auth): Dashboard SSO login via session handoff + 11 files changed, 720 insertions(+), 12 deletions(-) + create mode 100644 src/app/api/auth/dashboard/start/route.test.ts + create mode 100644 src/app/api/auth/dashboard/start/route.ts + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 7.6s) + +Now update the docs — AGENTS.md OAuth bullet and ADR 0012 addendum: + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/AGENTS.md", + "newString": "- **OAuth/SSO (Phase 4, ADR 0012):** env-configured provider registry (`src/infrastructure/auth/oauth-providers.ts`; `OAUTH_GOOGLE_CLIENT_ID/_SECRET`, `OAUTH_GITHUB_CLIENT_ID/_SECRET` — a provider is only offered when its env pair is present). DIY OAuth2 authorization-code + PKCE in `src/infrastructure/auth/oauth.ts`: `GET /api/auth/[provider]/start` returns the authorize URL (JSON, client navigates); `GET /api/auth/[provider]/callback` verifies state + PKCE (short-lived HttpOnly cookies), exchanges the code, fetches userinfo, finds-or-creates the user + `account_links` row, creates a session, redirects to `/`. Callback URL is derived from the request origin. OAuth users have `password_hash = null`.\n- **Dashboard SSO (Phase 4):** when `DASHBOARD_ORIGIN` is set, `/login` offers \"SSO (Dashboard)\". The Dashboard's Better Auth session cookie is host-scoped (not port-scoped), so the browser sends it to the notebook on another port. `GET /api/auth/dashboard/start` forwards the full cookie jar (via `cookies()`) to the Dashboard's public `users.getCurrentUser` oRPC endpoint (`POST {origin}/api/orpc/users.getCurrentUser`, empty JSON body, 3s timeout) and logs in via find-or-create — link by `(dashboard, providerUserId)` first; on email collision only adopts a user with no password, else 409. Works cross-host via the Dashboard's `AUTH_COOKIE_DOMAIN` shared-domain cookies.\n- **cacheComponents pitfall:** with `cacheComponents: true` (next.config.ts), Next.js prerenders `GET` route handlers at build time — a route whose early return (e.g. env check) 404s before touching a dynamic API gets that build-time response baked into a permanent static cache (`x-nextjs-cache: HIT`, `s-maxage=31536000`). Any GET handler that depends on runtime env/cookies must call `cookies()` (or another dynamic API) BEFORE its first early return. `export const dynamic` is forbidden under cacheComponents.", + "oldString": "- **OAuth/SSO (Phase 4, ADR 0012):** env-configured provider registry (`src/infrastructure/auth/oauth-providers.ts`; `OAUTH_GOOGLE_CLIENT_ID/_SECRET`, `OAUTH_GITHUB_CLIENT_ID/_SECRET` — a provider is only offered when its env pair is present). DIY OAuth2 authorization-code + PKCE in `src/infrastructure/auth/oauth.ts`: `GET /api/auth/[provider]/start` returns the authorize URL (JSON, client navigates); `GET /api/auth/[provider]/callback` verifies state + PKCE (short-lived HttpOnly cookies), exchanges the code, fetches userinfo, finds-or-creates the user + `account_links` row, creates a session, redirects to `/`. Callback URL is derived from the request origin. OAuth users have `password_hash = null`." +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 5.2s) + +**Tool: read** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/docs/adr/0012-oauth-sso-and-team-workspace-sharing.md", + "limit": 20, + "offset": 45 +} +``` + +**Output:** +``` +/Users/gordon/Documents/repos/knowhere-notebook/docs/adr/0012-oauth-sso-and-team-workspace-sharing.md +file + +45: member) — no per-member permission matrix in this phase. +46: 6. **Membership-aware reads, not per-route checks.** Only two queries changed: +47: `findAllByUserIdEffect` (owned ∪ member workspaces — the switcher and SSR +48: list shared workspaces automatically) and `findByIdAndUserIdEffect` +49: (owner OR member). Every route guard built on `findByIdAndUserIdEffect` +50: inherits membership access with no further edits. +51: 7. **Invites are by email, to existing users only** (users are still +52: admin-provisioned / OAuth-created). The Members dialog invites by email +53: (404 with a friendly message if the user doesn't exist); the owner can +54: remove members; the owner cannot be removed. +55: 8. **Re-invite revives the soft-deleted row** (`onConflictDoUpdate` setting +56: `deleted_at = NULL`) so removing and re-adding a member is idempotent. +57: The unique index is non-partial so Postgres can infer the conflict target. +58: 9. **Credentials stay user-scoped and private.** Members never see the +59: owner's API keys; the owner's active key is used for the shared +60: namespace (a member's own keys are only used when the member's *own* +61: workspace resolves credentials). +62: +63: ## Consequences +64: + +(Showing lines 45-64 of 73. Use offset=65 to continue.) + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.6s) + +**Tool: edit** + +**Input:** +```json +{ + "filePath": "/Users/gordon/Documents/repos/knowhere-notebook/docs/adr/0012-oauth-sso-and-team-workspace-sharing.md", + "newString": "9. **Credentials stay user-scoped and private.** Members never see the\n owner's API keys; the owner's active key is used for the shared\n namespace (a member's own keys are only used when the member's *own*\n workspace resolves credentials).\n10. **Dashboard SSO is a session handoff, not OAuth.** When\n `DASHBOARD_ORIGIN` is set, the login page offers \"SSO (Dashboard)\".\n The Dashboard's Better Auth session cookie is host-scoped (ports are\n ignored for cookies), so the browser already sends it to the notebook\n on another port; `GET /api/auth/dashboard/start` forwards the full\n cookie jar to the Dashboard's public `users.getCurrentUser` oRPC\n endpoint and logs the user in via find-or-create. Linking is by\n `(dashboard, providerUserId)`; on an email collision the notebook\n adopts an existing user only when that user has no password\n (pristine or OAuth-created) — a password-protected account is refused\n with 409, since silently adopting it would be an account takeover.\n Cross-host deployments work by setting the Dashboard's\n `AUTH_COOKIE_DOMAIN` (Better Auth crossSubDomainCookies) so the same\n session cookie reaches the notebook on a shared parent domain.\n11. **Cache Components changes GET-route prerendering.** With\n `cacheComponents: true`, GET route handlers are prerendered at build\n time: the Dashboard start route's early `getDashboardProvider()` 404\n (env absent at build) was baked into a year-long static cache\n (`x-nextjs-cache: HIT`, `s-maxage=31536000`) that ignored runtime\n env. Fix: dynamic APIs (`cookies()`) must be reached before any early\n return so prerendering terminates. `export const dynamic` is not\n allowed under cacheComponents. The Google/GitHub start/callback\n routes escaped this because they read `request.url` first.\n\n## Consequences", + "oldString": "9. **Credentials stay user-scoped and private.** Members never see the\n owner's API keys; the owner's active key is used for the shared\n namespace (a member's own keys are only used when the member's *own*\n workspace resolves credentials).\n\n## Consequences" +} +``` + +**Output:** +``` +Edit applied successfully. +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.4s) + +**Tool: bash** + +**Input:** +```json +{ + "command": "cd /Users/gordon/Documents/repos/knowhere-notebook && git add AGENTS.md docs/adr/0012-oauth-sso-and-team-workspace-sharing.md && git commit -m \"docs: Dashboard SSO (session handoff) + cacheComponents prerender pitfall\" && git log --oneline -5", + "workdir": "/Users/gordon/Documents/repos/knowhere-notebook" +} +``` + +**Output:** +``` +[feat/self-hosted-chunks-overlay-layout 7d162db] docs: Dashboard SSO (session handoff) + cacheComponents prerender pitfall + 2 files changed, 25 insertions(+) +7d162db docs: Dashboard SSO (session handoff) + cacheComponents prerender pitfall +cf0dd58 feat(auth): Dashboard SSO login via session handoff +45744bc docs: Phase 4 ADR (OAuth/SSO + team sharing) and AGENTS.md updates +b6dd98d feat(workspaces): team sharing via workspace members +d7a2e32 feat(auth): OAuth/SSO login via Google and GitHub providers + +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 3.9s) + +**Tool: todowrite** + +**Input:** +```json +{ + "todos": [ + { + "content": "Extend provider registry: kind field + dashboard provider (DASHBOARD_ORIGIN-enabled)", + "priority": "high", + "status": "completed" + }, + { + "content": "Dashboard login flow in oauth.ts: forward cookie → getCurrentUser → find-or-create user+link (link policy: adopt only if no password) → session", + "priority": "high", + "status": "completed" + }, + { + "content": "Route GET /api/auth/dashboard/start + proxy PUBLIC_PATHS fix for /api/auth", + "priority": "high", + "status": "completed" + }, + { + "content": "Login page: \"SSO (Dashboard)\" button", + "priority": "medium", + "status": "completed" + }, + { + "content": "Tests: providers registry, dashboard login flow, proxy public path", + "priority": "high", + "status": "completed" + }, + { + "content": "Docs: ADR 0012 addendum + AGENTS.md; full verify + rebuild + e2e", + "priority": "medium", + "status": "completed" + } + ] +} +``` + +**Output:** +``` +[ + { + "content": "Extend provider registry: kind field + dashboard provider (DASHBOARD_ORIGIN-enabled)", + "status": "completed", + "priority": "high" + }, + { + "content": "Dashboard login flow in oauth.ts: forward cookie → getCurrentUser → find-or-create user+link (link policy: adopt only if no password) → session", + "status": "completed", + "priority": "high" + }, + { + "content": "Route GET /api/auth/dashboard/start + proxy PUBLIC_PATHS fix for /api/auth", + "status": "completed", + "priority": "high" + }, + { + "content": "Login page: \"SSO (Dashboard)\" button", + "status": "completed", + "priority": "medium" + }, + { + "content": "Tests: providers registry, dashboard login flow, proxy public path", + "status": "completed", + "priority": "high" + }, + { + "content": "Docs: ADR 0012 addendum + AGENTS.md; full verify + rebuild + e2e", + "status": "completed", + "priority": "medium" + } +] +``` + +--- + +## Assistant (Build · DeepSeek V4 Flash · 6.2s) + +Done. Dashboard SSO is implemented, verified, and committed (`cf0dd58` + docs `7d162db`). + +**What was built (your 3 decisions):** +1. **Linking policy** — link by `(dashboard, providerUserId)` first; on email collision, adopt only users with no password (pristine or OAuth-created); password-protected accounts get a 409 so no account takeover is possible. +2. **"SSO (Dashboard)"** button on `/login` when `DASHBOARD_ORIGIN` is set. +3. **Inline error only** — 401 "You are not logged into the Knowhere Dashboard." (no deep-link). + +**How it works:** `GET /api/auth/dashboard/start` forwards the browser's full cookie jar (host-scoped, so the Dashboard's `better-auth.session_token` arrives on another port) to the Dashboard's public `users.getCurrentUser` oRPC endpoint, then find-or-creates the user + `account_links` row and creates a session. + +**Critical bug found & fixed along the way:** with `cacheComponents: true`, Next.js 16 prerenders GET route handlers at build time — the route's early env-check 404 was baked into a **year-long static cache** (`x-nextjs-cache: HIT`, `s-maxage=31536000`), ignoring runtime env. Fixed by reading `cookies()` (a dynamic API) *before* any early return; documented in AGENTS.md + ADR 0012. This also surfaced a latent proxy bug (anonymous `/api/auth/*` was being redirected to login), now fixed. + +**Verified:** 586 tests pass (9 new: dashboard flow, providers registry, proxy), lint/typecheck clean, container rebuilt — route is dynamic and returns the expected 401 against the current port-3000 app. To test live SSO, point `DASHBOARD_ORIGIN` at a running Dashboard. + +--- + diff --git a/next.config.ts b/next.config.ts index e294c70..7f56acd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", cacheComponents: true, reactCompiler: true, serverExternalPackages: [ diff --git a/package.json b/package.json index 73b1e26..2f28e84 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,13 @@ "upstash:dev": "npx @upstash/qstash-cli dev" }, "dependencies": { + "@ai-sdk/openai-compatible": "2.0.63", "@ai-sdk/react": "^3.0.177", "@antv/chart-visualization-skills": "0.1.3", + "@base-ui/react": "^1.6.0", "@effect/platform": "^0.96.1", "@neondatabase/serverless": "^1.1.0", + "@node-rs/argon2": "^2.0.2", "@ontos-ai/knowhere-sdk": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -46,6 +49,7 @@ "dompurify": "^3.4.2", "drizzle-orm": "^0.45.2", "effect": "^3.21.2", + "geist": "^1.7.2", "lucide-react": "^1.14.0", "mammoth": "^1.12.0", "next": "16.2.4", @@ -76,6 +80,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", "babel-plugin-react-compiler": "^1.0.0", + "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c4223f..7cc8761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,27 @@ importers: .: dependencies: + '@ai-sdk/openai-compatible': + specifier: 2.0.63 + version: 2.0.63(zod@4.4.3) '@ai-sdk/react': specifier: ^3.0.177 version: 3.0.177(react@19.2.4)(zod@4.4.3) '@antv/chart-visualization-skills': specifier: 0.1.3 version: 0.1.3 + '@base-ui/react': + specifier: ^1.6.0 + version: 1.6.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@effect/platform': specifier: ^0.96.1 version: 0.96.1(effect@3.21.2) '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 + '@node-rs/argon2': + specifier: ^2.0.2 + version: 2.0.2 '@ontos-ai/knowhere-sdk': specifier: ^2.0.0 version: 2.0.0 @@ -86,6 +95,9 @@ importers: effect: specifier: ^3.21.2 version: 3.21.2 + geist: + specifier: ^1.7.2 + version: 1.7.2(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.4) @@ -171,6 +183,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -204,16 +219,32 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.63': + resolution: {integrity: sha512-EmrD7iRboidulu6yHfMiMhd6RQSw8KrIWhNLK8vl5brQZbIjXkyhUU+FULZM3P4m46Vatzx8u3vX1w/qmFUmqA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.26': resolution: {integrity: sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.41': + resolution: {integrity: sha512-I7hhjfw01yEI8NkuAsT8Mv6xbWFr/lqLXMdaJQ2zWfXEpxog1eT7skDcv1+RY29/+5btzH8wD+vVvy48bk9oNQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.10': resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.14': + resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} + engines: {node: '>=18'} + '@ai-sdk/react@3.0.177': resolution: {integrity: sha512-7K3bmj2ajbAkrqR7P8bByKp0w2iACGSIpahoEkeUhhZqVJO4/mxqk6Q5wcd12EaOi+5+86k2VH91BKgzCuCRaw==} engines: {node: '>=18'} @@ -376,6 +407,33 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.6.0': + resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.1': + resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -946,6 +1004,10 @@ packages: '@noble/hashes': optional: true + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1017,105 +1079,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1264,35 +1310,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} @@ -1346,28 +1387,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.4': resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.4': resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.4': resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.4': resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} @@ -1393,6 +1430,93 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@node-rs/argon2-android-arm-eabi@2.0.2': + resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.0.2': + resolution: {integrity: sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.0.2': + resolution: {integrity: sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@2.0.2': + resolution: {integrity: sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@2.0.2': + resolution: {integrity: sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + resolution: {integrity: sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + resolution: {integrity: sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-linux-x64-musl@2.0.2': + resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + resolution: {integrity: sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + resolution: {integrity: sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + resolution: {integrity: sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@2.0.2': + resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1895,42 +2019,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -2015,28 +2133,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -2275,49 +2389,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3413,6 +3519,11 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + geist@1.7.2: + resolution: {integrity: sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==} + peerDependencies: + next: '>=13.2.0' + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3950,28 +4061,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4699,6 +4806,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5144,6 +5254,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@6.25.0: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} @@ -5462,6 +5576,12 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.3 + '@ai-sdk/openai-compatible@2.0.63(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@ai-sdk/provider-utils': 4.0.41(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.26(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -5469,10 +5589,22 @@ snapshots: eventsource-parser: 3.0.8 zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.41(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + undici: 5.29.0 + zod: 4.4.3 + '@ai-sdk/provider@3.0.10': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.14': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/react@3.0.177(react@19.2.4)(zod@4.4.3)': dependencies: '@ai-sdk/provider-utils': 4.0.26(zod@4.4.3) @@ -5698,6 +5830,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.6.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -6052,6 +6207,8 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 + '@fastify/busboy@2.1.1': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6383,6 +6540,67 @@ snapshots: '@noble/hashes@1.8.0': {} + '@node-rs/argon2-android-arm-eabi@2.0.2': + optional: true + + '@node-rs/argon2-android-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-x64@2.0.2': + optional: true + + '@node-rs/argon2-freebsd-x64@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-musl@2.0.2': + optional: true + + '@node-rs/argon2-wasm32-wasi@2.0.2': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + optional: true + + '@node-rs/argon2@2.0.2': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.0.2 + '@node-rs/argon2-android-arm64': 2.0.2 + '@node-rs/argon2-darwin-arm64': 2.0.2 + '@node-rs/argon2-darwin-x64': 2.0.2 + '@node-rs/argon2-freebsd-x64': 2.0.2 + '@node-rs/argon2-linux-arm-gnueabihf': 2.0.2 + '@node-rs/argon2-linux-arm64-gnu': 2.0.2 + '@node-rs/argon2-linux-arm64-musl': 2.0.2 + '@node-rs/argon2-linux-x64-gnu': 2.0.2 + '@node-rs/argon2-linux-x64-musl': 2.0.2 + '@node-rs/argon2-wasm32-wasi': 2.0.2 + '@node-rs/argon2-win32-arm64-msvc': 2.0.2 + '@node-rs/argon2-win32-ia32-msvc': 2.0.2 + '@node-rs/argon2-win32-x64-msvc': 2.0.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8440,6 +8658,10 @@ snapshots: fuzzysort@3.1.0: {} + geist@1.7.2(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + next: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -9963,6 +10185,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -10539,6 +10763,10 @@ snapshots: undici-types@6.21.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@6.25.0: {} undici@7.25.0: {} diff --git a/public/data/chat-prompt-templates.json b/public/data/chat-prompt-templates.json new file mode 100644 index 0000000..f76cea2 --- /dev/null +++ b/public/data/chat-prompt-templates.json @@ -0,0 +1,17 @@ +[ + { + "id": "ipo-prospectus-risk-mining", + "title": "IPO Prospectus Risk Mining", + "prompt": "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].\nPlease complete the following tasks:\n1. Extract all risk items from the \"Risk Factors\" section and categorize them into: Market Risk/Operational Risk/Legal and Compliance Risk/Technical Risk/Competitive Risk.\n2. Identify which risk items use hedging language such as \"may\", \"might\", or \"could\", and which use more definitive language such as \"will\" or \"has\". Provide the results in a structured format." + }, + { + "id": "earnings-call-transcript-analysis", + "title": "Earnings Call Transcript Analysis", + "prompt": "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].\nPlease complete the following tasks:\n1. Extract the management's original wording on the following topics: Revenue guidance/Gross margin pressure/Specific business line.\n2. Identify analyst questions that management sidestepped or shifted away from.\n3. Extract all forward-looking statements that contain specific numbers, and organize them into a guidance tracking table." + }, + { + "id": "research-paper-method-comparison", + "title": "Research Paper Method Comparison", + "prompt": "You are a PhD researcher writing a paper in [Research Area]. I have uploaded recent top conference and journal papers in this area.\nPlease analyze the papers and produce the following:\n1. Extract the three core elements for each paper: Dataset/Evaluation metrics/Model architecture. Present the results in a comparison table.\n2. Identify the unresolved issues repeatedly mentioned in the \"Limitations\" or \"Future Work\" sections across the papers, and present them as a list.\n3. Identify emerging technical terms appearing in the papers, assess whether they indicate a new research trend, and output a list of trend keywords." + } +] diff --git a/public/icons/official-library/pdf-document.svg b/public/icons/official-library/pdf-document.svg deleted file mode 100644 index 460463b..0000000 --- a/public/icons/official-library/pdf-document.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - .pdf - diff --git a/public/images/official-library/financial-reports.svg b/public/images/official-library/financial-reports.svg deleted file mode 100644 index 541468f..0000000 --- a/public/images/official-library/financial-reports.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/public/images/official-library/other-docs.svg b/public/images/official-library/other-docs.svg deleted file mode 100644 index 6bb6039..0000000 --- a/public/images/official-library/other-docs.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/public/images/official-library/research-papers.svg b/public/images/official-library/research-papers.svg deleted file mode 100644 index e1171e5..0000000 --- a/public/images/official-library/research-papers.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/public/images/official-library/stem-books.svg b/public/images/official-library/stem-books.svg deleted file mode 100644 index 3353699..0000000 --- a/public/images/official-library/stem-books.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/scripts/create-user.ts b/scripts/create-user.ts new file mode 100644 index 0000000..77eadc0 --- /dev/null +++ b/scripts/create-user.ts @@ -0,0 +1,78 @@ +import "dotenv/config" +import { config as loadEnv } from "dotenv" + +loadEnv({ path: ".env.local" }) + +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { usersRepository } from "@/infrastructure/auth/users-repository" +import { accountLinksRepository } from "@/infrastructure/auth/account-links-repository" +import { hashPassword } from "@/lib/password" + +/** + * Admin-provisioned user creation (no public signup in Phase 2). + * + * Usage: + * pnpm exec tsx scripts/create-user.ts [--name "Full Name"] + * + * Requires DATABASE_URL in the environment (dotenv loads .env.local). + */ +async function main(): Promise { + const [emailArg, passwordArg] = process.argv.slice(2) + const name = extractName(process.argv.slice(2)) + + if (!emailArg || !passwordArg) { + console.error( + "Usage: pnpm exec tsx scripts/create-user.ts [--name \"Full Name\"]", + ) + process.exit(1) + } + + const email = emailArg.trim().toLowerCase() + if (!email.includes("@")) { + console.error(`Invalid email: ${email}`) + process.exit(1) + } + if (passwordArg.length < 8) { + console.error("Password must be at least 8 characters.") + process.exit(1) + } + + const passwordHash = await hashPassword(passwordArg) + const user = await databaseRuntime.runPromise( + Effect.gen(function* () { + const existing = yield* usersRepository.findByEmailEffect(email) + if (existing) { + throw new Error(`User with email ${email} already exists.`) + } + + const created = yield* usersRepository.insertEffect({ + email, + name: name ?? null, + }) + yield* accountLinksRepository.insertEffect({ + userId: created.id, + provider: "password", + providerUserId: null, + passwordHash, + }) + return created + }), + ) + + console.log(`Created user ${user.email} (${user.id}).`) + process.exit(0) +} + +function extractName(args: readonly string[]): string | null { + const index = args.indexOf("--name") + if (index === -1) return null + const value = args[index + 1] + return value && value.trim().length > 0 ? value.trim() : null +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/src/agent-harness/ledger.ts b/src/agent-harness/ledger.ts index da197e8..19c5dcc 100644 --- a/src/agent-harness/ledger.ts +++ b/src/agent-harness/ledger.ts @@ -72,6 +72,7 @@ export function createEvidenceLedger() { contentPreview: content, chunkType: chunk.chunkType, score: null, + chunkId: chunk.chunkId, source: { documentId: chunk.documentId, sourceFileName: null, @@ -146,6 +147,7 @@ function addChunkFromResult(input: { contentPreview: buildContentPreview(input.result.content), chunkType: input.result.chunkType, score: input.result.score, + chunkId: input.result.chunkId, source: { documentId: input.result.source.documentId, sourceFileName: input.result.source.sourceFileName, diff --git a/src/agent-harness/runtime.test.ts b/src/agent-harness/runtime.test.ts index 5657e5c..28f8e53 100644 --- a/src/agent-harness/runtime.test.ts +++ b/src/agent-harness/runtime.test.ts @@ -97,6 +97,53 @@ describe("agent harness runtime", () => { ]) }) + it("accepts a page modality and passes it through to retrieval", async () => { + const query = vi.fn().mockResolvedValue( + makeRetrievalResponse(), + ) + const state: { + intent?: IntentFrame + contextPolicy?: ContextPolicy + toolCalls?: HarnessToolCallTrace[] + } = {} + const tools = createHarnessTools({ + state, + ledger: createEvidenceLedger(), + retrieval: { query }, + recentTurns: [], + }) + + await executeTool(tools.declareIntent, { + task: "answer", + dependsOnPreviousTurn: false, + retrievalNeeded: "yes", + targetModalities: ["page"], + constraints: {}, + groundingPolicy: "must_use_sources", + }) + await executeTool(tools.setContextPolicy, { + carryHistory: "none", + reason: "Unrelated follow-up.", + activePriorTurnIds: [], + }) + const result = await executeTool(tools.retrieve, { + query: "Gordon phone number", + modalities: ["page"], + }) + + expect(result).toMatchObject({ ok: true, retrievalCount: 1 }) + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ modalities: ["page"] }), + ) + }) + + it("guides the planner to page modalities for directory-style lookups", () => { + const prompt = buildHarnessSystemPrompt(makeTurnInput()) + + expect(prompt).toContain("directory-style lookups") + expect(prompt).toContain("set retrieve modalities to ['page']") + }) + it("blocks finalize until intent and context policy are declared", async () => { const state: { intent?: IntentFrame diff --git a/src/agent-harness/runtime.ts b/src/agent-harness/runtime.ts index c03baac..7c8f501 100644 --- a/src/agent-harness/runtime.ts +++ b/src/agent-harness/runtime.ts @@ -44,7 +44,7 @@ type HarnessToolState = { toolCalls?: HarnessToolCallTrace[] } -const targetModalitySchema = z.enum(["text", "image", "table"]) +const targetModalitySchema = z.enum(["text", "image", "table", "page"]) const intentFrameSchema = z.object({ task: z.enum([ @@ -155,9 +155,15 @@ export async function runAgentHarness( let manifest = buildFallbackManifest("") let validationErrors: readonly string[] = [] let revisionsUsed = 0 + let llmCallCount = 0 + let inputTokens = 0 + let outputTokens = 0 for (let attempt = 0; ; attempt += 1) { const response = await agent.generate({ messages }) + llmCallCount += response.steps?.length ?? 0 + inputTokens += response.totalUsage?.inputTokens ?? 0 + outputTokens += response.totalUsage?.outputTokens ?? 0 manifest = state.finalizedManifest ?? buildFallbackManifest(response.text.trim()) @@ -201,6 +207,9 @@ export async function runAgentHarness( toolCalls: [...(state.toolCalls ?? [])], validationErrors, revisionsUsed, + llmCallCount, + inputTokens, + outputTokens, }, } } @@ -620,6 +629,12 @@ export function buildHarnessSystemPrompt(turn: AgentTurnInput): string { "- If the user corrects a previous answer, set carryHistory to repair_previous, read the relevant prior turn, then re-retrieve and re-answer using the correction.", "- If the user uses references like this document, that image, or the previous answer, choose referential_only or full_recent and read the prior turn you depend on.", "", + "Retrieval rules:", + "- KNOWHERE retrieval is keyword-based (BM25). Use exact terms and distinctive keywords likely to appear in the documents; avoid vague paraphrases.", + "- Expand queries with synonyms, acronyms, and domain terms that might appear in the sources (for example brand names, metric names, table headers).", + "- For multi-part or ambiguous questions, call retrieve more than once with different query phrasings, one per distinct aspect, and combine evidence from all calls.", + "- Prefer multiple focused queries over one long unfocused query.", + "- For directory-style lookups (people, phone numbers, addresses, office locations, contact details), set retrieve modalities to ['page'] so KNOWHERE retrieves whole pages where this information lives.", "Output rules:", "- Final output is the OutputManifest passed to finalize, not freeform tool JSON or trailing text.", "- artifacts with display=true are the exact images/tables shown. Never display every candidate; honor constraints.desiredCount / maxCount.", diff --git a/src/agent-harness/types.ts b/src/agent-harness/types.ts index 1a1cce9..7f479d5 100644 --- a/src/agent-harness/types.ts +++ b/src/agent-harness/types.ts @@ -16,7 +16,7 @@ export type AgentTask = | "correct_previous" | "clarify" -export type TargetModality = "text" | "image" | "table" +export type TargetModality = "text" | "image" | "table" | "page" export type GroundingPolicy = | "must_use_sources" @@ -98,6 +98,8 @@ export type EvidenceChunk = { readonly contentPreview: string readonly chunkType: string readonly score: number | null + /** Parser-provided chunk identifier when returned by the API. */ + readonly chunkId?: string readonly source: { readonly documentId?: string | null readonly sourceFileName?: string | null @@ -177,6 +179,12 @@ export type HarnessTrace = { readonly toolCalls: readonly HarnessToolCallTrace[] readonly validationErrors: readonly string[] readonly revisionsUsed: number + /** Total LLM step calls across the agent loop and any revision attempts. */ + readonly llmCallCount?: number + /** Total input tokens across the agent loop and any revision attempts. */ + readonly inputTokens?: number + /** Total output tokens across the agent loop and any revision attempts. */ + readonly outputTokens?: number } export type HarnessRunResult = { diff --git a/src/app/api/api-keys/[apiKeyId]/namespaces/route.ts b/src/app/api/api-keys/[apiKeyId]/namespaces/route.ts new file mode 100644 index 0000000..1c52d6c --- /dev/null +++ b/src/app/api/api-keys/[apiKeyId]/namespaces/route.ts @@ -0,0 +1,43 @@ +import type { NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { knowhereApiKeysRepository } from "@/infrastructure/auth/knowhere-api-keys-repository" +import { listKnowhereNamespaces } from "@/integrations/knowhere" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET( + _request: Request, + { params }: { params: Promise<{ apiKeyId: string }> }, +): Promise { + return withApiErrorResponse( + "api-keys:namespaces", + async () => { + const { apiKeyId } = await params + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + + const key = await databaseRuntime.runPromise( + knowhereApiKeysRepository.findByIdAndUserEffect(apiKeyId, user.id), + ) + if (!key) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "API key not found."), + ) + } + + const apiKey = await databaseRuntime.runPromise( + knowhereApiKeysRepository.decryptStoredEffect(key), + ) + const namespaces = await listKnowhereNamespaces(apiKey) + return nextRouteResponse.toNextResponse(routeResult.ok({ namespaces })) + }, + "Could not list namespaces for this key.", + ) +} diff --git a/src/app/api/api-keys/[apiKeyId]/route.ts b/src/app/api/api-keys/[apiKeyId]/route.ts new file mode 100644 index 0000000..453aa4f --- /dev/null +++ b/src/app/api/api-keys/[apiKeyId]/route.ts @@ -0,0 +1,89 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { knowhereApiKeysRepository } from "@/infrastructure/auth/knowhere-api-keys-repository" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ apiKeyId: string }> }, +): Promise { + return withApiErrorResponse( + "api-keys:set-active", + async () => { + const { apiKeyId } = await params + const body = await routeResult.readJsonOrNull(request) + const workspaceId = + typeof body === "object" && body !== null && "workspaceId" in body + ? String((body as { workspaceId?: unknown }).workspaceId) + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!workspaceId) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("workspaceId is required."), + ) + } + + const key = await databaseRuntime.runPromise( + knowhereApiKeysRepository.findByIdAndUserEffect(apiKeyId, user.id), + ) + if (!key) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "API key not found."), + ) + } + + await databaseRuntime.runPromise( + knowhereApiKeysRepository.setActiveEffect(workspaceId, key.id), + ) + return nextRouteResponse.toNextResponse(routeResult.ok({})) + }, + "Could not update the API key.", + ) +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ apiKeyId: string }> }, +): Promise { + return withApiErrorResponse( + "api-keys:delete", + async () => { + const { apiKeyId } = await params + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + + const key = await databaseRuntime.runPromise( + knowhereApiKeysRepository.findByIdAndUserEffect(apiKeyId, user.id), + ) + if (!key) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "API key not found."), + ) + } + + await databaseRuntime.runPromise( + knowhereApiKeysRepository.softDeleteEffect(apiKeyId, user.id), + ) + // Sweep: any workspace pointing at this key loses its active credential. + await databaseRuntime.runPromise( + knowhereApiKeysRepository.clearActiveForKeyEffect(apiKeyId, user.id), + ) + + return nextRouteResponse.toNextResponse(routeResult.ok({})) + }, + "Could not delete the API key.", + ) +} diff --git a/src/app/api/api-keys/route.test.ts b/src/app/api/api-keys/route.test.ts new file mode 100644 index 0000000..6e46c70 --- /dev/null +++ b/src/app/api/api-keys/route.test.ts @@ -0,0 +1,214 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; + +const mocks = vi.hoisted(() => { + return { + getCurrentUser: vi.fn(), + runPromise: vi.fn(), + listByUserEffect: vi.fn(), + createForUserEffect: vi.fn(), + findByUserIdAndNamespaceEffect: vi.fn(), + insertForUserNamespaceEffect: vi.fn(), + setActiveEffect: vi.fn(), + validateKnowhereApiKey: vi.fn(), + findByIdAndUserEffect: vi.fn(), + softDeleteEffect: vi.fn(), + clearActiveForKeyEffect: vi.fn(), + }; +}); + +vi.mock("@/infrastructure/auth", () => ({ + getCurrentUser: mocks.getCurrentUser, +})); + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.runPromise, + }, +})); + +vi.mock("@/domains/workspace/repository", () => ({ + workspaceRepository: { + findByUserIdAndNamespaceEffect: mocks.findByUserIdAndNamespaceEffect, + insertForUserNamespaceEffect: mocks.insertForUserNamespaceEffect, + }, +})); + +vi.mock("@/infrastructure/auth/knowhere-api-keys-repository", () => ({ + knowhereApiKeysRepository: { + listByUserEffect: mocks.listByUserEffect, + createForUserEffect: mocks.createForUserEffect, + findByUserIdAndNamespaceEffect: mocks.findByUserIdAndNamespaceEffect, + insertForUserNamespaceEffect: mocks.insertForUserNamespaceEffect, + setActiveEffect: mocks.setActiveEffect, + findByIdAndUserEffect: mocks.findByIdAndUserEffect, + softDeleteEffect: mocks.softDeleteEffect, + clearActiveForKeyEffect: mocks.clearActiveForKeyEffect, + }, +})); + +vi.mock("@/integrations/knowhere", () => ({ + validateKnowhereApiKey: mocks.validateKnowhereApiKey, +})); + +import { GET as listKeys, POST as addKey } from "./route"; +import { DELETE as deleteKey } from "./[apiKeyId]/route"; + +const user = { id: "user_1", email: "ada@example.com", name: "Ada" }; +const storedKey = { + id: "key_1", + userId: "user_1", + label: "domainA", + keyMask: "sk_te••••st", + createdAt: new Date("2026-08-01T00:00:00Z"), +}; +const homeWorkspace = { + id: "ws_default", + userId: "user_1", + namespace: "default", + activeKnowhereApiKeyId: null, + createdAt: new Date(), +}; + +function runEffect(effect: unknown): Promise { + return Effect.runPromise(effect as Effect.Effect); +} + +describe("api-keys routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + mocks.runPromise.mockImplementation(runEffect); + mocks.listByUserEffect.mockReturnValue(Effect.succeed([])); + mocks.findByUserIdAndNamespaceEffect.mockReturnValue( + Effect.succeed(homeWorkspace), + ); + mocks.setActiveEffect.mockReturnValue(Effect.succeed(undefined)); + mocks.validateKnowhereApiKey.mockResolvedValue(true); + }); + + describe("GET /api/api-keys", () => { + it("lists the user's keys with masks", async () => { + mocks.listByUserEffect.mockReturnValue(Effect.succeed([storedKey])); + + const response = await listKeys(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.keys).toEqual([ + { + id: "key_1", + label: "domainA", + mask: "sk_te••••st", + createdAt: "2026-08-01T00:00:00.000Z", + }, + ]); + }); + + it("rejects unauthenticated requests", async () => { + mocks.getCurrentUser.mockResolvedValue(null); + + const response = await listKeys(); + + expect(response.status).toBe(400); + }); + }); + + describe("POST /api/api-keys", () => { + it("rejects an invalid key with 422 without storing anything", async () => { + mocks.validateKnowhereApiKey.mockResolvedValue(false); + const request = new NextRequest("http://localhost/api/api-keys", { + method: "POST", + body: JSON.stringify({ label: "domainA", apiKey: "sk_bad" }), + }); + + const response = await addKey(request); + + expect(response.status).toBe(422); + expect(mocks.createForUserEffect).not.toHaveBeenCalled(); + expect(mocks.setActiveEffect).not.toHaveBeenCalled(); + }); + + it("stores a valid key and activates the home workspace", async () => { + mocks.createForUserEffect.mockReturnValue( + Effect.succeed({ + id: "key_new", + userId: "user_1", + label: "domainA", + keyMask: "sk_te••••st", + cipherBlob: "x", + cipherNonce: "y", + createdAt: new Date("2026-08-01T00:00:00Z"), + deletedAt: null, + }), + ); + const request = new NextRequest("http://localhost/api/api-keys", { + method: "POST", + body: JSON.stringify({ label: "domainA", apiKey: "sk_valid" }), + }); + + const response = await addKey(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.validateKnowhereApiKey).toHaveBeenCalledWith("sk_valid"); + expect(mocks.createForUserEffect).toHaveBeenCalledWith({ + userId: "user_1", + label: "domainA", + apiKey: "sk_valid", + }); + expect(mocks.setActiveEffect).toHaveBeenCalledWith("ws_default", "key_new"); + expect(response.cookies.get("notebook-ws")?.value).toBe("ws_default"); + expect(body.workspace).toEqual({ + id: "ws_default", + namespace: "default", + }); + }); + + it("rejects a duplicate label with 409", async () => { + mocks.listByUserEffect.mockReturnValue(Effect.succeed([storedKey])); + const request = new NextRequest("http://localhost/api/api-keys", { + method: "POST", + body: JSON.stringify({ label: "domainA", apiKey: "sk_valid" }), + }); + + const response = await addKey(request); + + expect(response.status).toBe(409); + expect(mocks.validateKnowhereApiKey).not.toHaveBeenCalled(); + }); + }); + + describe("DELETE /api/api-keys/[apiKeyId]", () => { + it("soft-deletes a key and clears workspace pointers", async () => { + mocks.findByIdAndUserEffect.mockReturnValue(Effect.succeed(storedKey)); + mocks.softDeleteEffect.mockReturnValue(Effect.succeed(undefined)); + mocks.clearActiveForKeyEffect.mockReturnValue(Effect.succeed(undefined)); + + const response = await deleteKey( + new NextRequest("http://localhost/api/api-keys/key_1", { + method: "DELETE", + }), + { params: Promise.resolve({ apiKeyId: "key_1" }) }, + ); + + expect(response.status).toBe(200); + expect(mocks.softDeleteEffect).toHaveBeenCalledWith("key_1", "user_1"); + expect(mocks.clearActiveForKeyEffect).toHaveBeenCalledWith("key_1", "user_1"); + }); + + it("returns 404 for a key the user does not own", async () => { + mocks.findByIdAndUserEffect.mockReturnValue(Effect.succeed(null)); + + const response = await deleteKey( + new NextRequest("http://localhost/api/api-keys/key_x", { + method: "DELETE", + }), + { params: Promise.resolve({ apiKeyId: "key_x" }) }, + ); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/src/app/api/api-keys/route.ts b/src/app/api/api-keys/route.ts new file mode 100644 index 0000000..384ea03 --- /dev/null +++ b/src/app/api/api-keys/route.ts @@ -0,0 +1,149 @@ +import type { NextRequest, NextResponse } from "next/server" +import { Effect } from "effect" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { workspaceRepository } from "@/domains/workspace/repository" +import { knowhereApiKeysRepository } from "@/infrastructure/auth/knowhere-api-keys-repository" +import { validateKnowhereApiKey } from "@/integrations/knowhere" +import { activeWorkspaceCookieName } from "@/domains/workspace/service" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +/** The home namespace every key add creates a workspace for. */ +const defaultNamespace = "default" + +export async function GET(): Promise { + return withApiErrorResponse("api-keys:list", async () => { + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + + const keys = await databaseRuntime.runPromise( + knowhereApiKeysRepository.listByUserEffect(user.id), + ) + return nextRouteResponse.toNextResponse( + routeResult.ok({ + keys: keys.map((key) => ({ + id: key.id, + label: key.label, + mask: key.keyMask, + createdAt: key.createdAt.toISOString(), + })), + }), + ) + }) +} + +export async function POST(request: NextRequest): Promise { + return withApiErrorResponse( + "api-keys:create", + async () => { + const body = await routeResult.readJsonOrNull(request) + const label = + typeof body === "object" && body !== null && "label" in body + ? String((body as { label?: unknown }).label).trim() + : "" + const apiKey = + typeof body === "object" && body !== null && "apiKey" in body + ? String((body as { apiKey?: unknown }).apiKey).trim() + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!label || !apiKey) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("label and apiKey are required."), + ) + } + + const existing = await databaseRuntime.runPromise( + knowhereApiKeysRepository.listByUserEffect(user.id), + ) + if (existing.some((key) => key.label === label)) { + return nextRouteResponse.toNextResponse( + routeResult.error(409, `A key labeled '${label}' already exists.`), + ) + } + + const isValid = await validateKnowhereApiKey(apiKey) + if (!isValid) { + return nextRouteResponse.toNextResponse( + routeResult.error(422, "Invalid API key. Check it and try again."), + ) + } + + const created = await databaseRuntime.runPromise( + knowhereApiKeysRepository.createForUserEffect({ + userId: user.id, + label, + apiKey, + }), + ) + + // Auto-create the (user, "default") home workspace with this key active. + const workspace = await databaseRuntime.runPromise( + workspaceRepository.findByUserIdAndNamespaceEffect( + user.id, + defaultNamespace, + ), + ) + const homeWorkspace = + workspace ?? + (await databaseRuntime.runPromise( + workspaceRepository + .insertForUserNamespaceEffect(user.id, defaultNamespace) + .pipe( + Effect.flatMap(() => + workspaceRepository.findByUserIdAndNamespaceEffect( + user.id, + defaultNamespace, + ), + ), + ), + )) + + if (homeWorkspace) { + await databaseRuntime.runPromise( + knowhereApiKeysRepository.setActiveEffect( + homeWorkspace.id, + created.id, + ), + ) + } + + const response = nextRouteResponse.toNextResponse( + routeResult.ok({ + key: { + id: created.id, + label: created.label, + mask: created.keyMask, + createdAt: created.createdAt.toISOString(), + }, + workspace: homeWorkspace + ? { id: homeWorkspace.id, namespace: homeWorkspace.namespace } + : null, + }), + ) + if (homeWorkspace) { + // Make the home workspace active immediately so the next SSR load + // (router.refresh on the client) lands on it with a fresh thread. + response.cookies.set(activeWorkspaceCookieName, homeWorkspace.id, { + httpOnly: false, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }) + } + return response + }, + "Could not add the API key.", + ) +} diff --git a/src/app/api/auth/[provider]/callback/route.ts b/src/app/api/auth/[provider]/callback/route.ts new file mode 100644 index 0000000..584885c --- /dev/null +++ b/src/app/api/auth/[provider]/callback/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, type NextRequest } from "next/server" + +import { getOAuthProvider } from "@/infrastructure/auth/oauth-providers" +import { completeOAuthLogin } from "@/infrastructure/auth/oauth" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> }, +): Promise { + const { provider: providerName } = await params + const provider = getOAuthProvider(providerName) + if (!provider) { + return NextResponse.redirect(new URL("/login?error=provider", request.url)) + } + + const url = new URL(request.url) + const code = url.searchParams.get("code") ?? "" + const state = url.searchParams.get("state") ?? "" + const callbackUrl = `${url.origin}/api/auth/${provider.name}/callback` + + try { + const destination = await completeOAuthLogin( + provider, + callbackUrl, + code, + state, + ) + return NextResponse.redirect(new URL(destination, request.url)) + } catch { + return NextResponse.redirect( + new URL("/login?error=oauth", request.url), + ) + } +} diff --git a/src/app/api/auth/[provider]/start/route.ts b/src/app/api/auth/[provider]/start/route.ts new file mode 100644 index 0000000..2a188bd --- /dev/null +++ b/src/app/api/auth/[provider]/start/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { getOAuthProvider } from "@/infrastructure/auth/oauth-providers" +import { buildOAuthAuthorizeUrl } from "@/infrastructure/auth/oauth" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> }, +): Promise { + const { provider: providerName } = await params + const provider = getOAuthProvider(providerName) + if (!provider) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, `OAuth provider '${providerName}' is not configured.`), + ) + } + + const url = new URL(request.url) + const callbackUrl = `${url.origin}/api/auth/${provider.name}/callback` + const { url: authorizeUrl } = await buildOAuthAuthorizeUrl(provider, callbackUrl) + return nextRouteResponse.toNextResponse( + routeResult.ok({ url: authorizeUrl }), + ) +} diff --git a/src/app/api/auth/dashboard/start/route.test.ts b/src/app/api/auth/dashboard/start/route.test.ts new file mode 100644 index 0000000..1476b29 --- /dev/null +++ b/src/app/api/auth/dashboard/start/route.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { NextRequest } from "next/server" + +const mocks = vi.hoisted(() => ({ + getDashboardProvider: vi.fn(), + loginWithDashboardSession: vi.fn(), + DashboardLoginError: class extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, + cookieJar: { + getAll: vi.fn<() => { name: string; value: string }[]>(() => []), + }, +})) + +vi.mock("@/infrastructure/auth/oauth-providers", () => ({ + getDashboardProvider: mocks.getDashboardProvider, +})) + +vi.mock("@/infrastructure/auth/oauth", () => ({ + loginWithDashboardSession: mocks.loginWithDashboardSession, + DashboardLoginError: mocks.DashboardLoginError, +})) + +vi.mock("next/headers", () => ({ + cookies: async () => mocks.cookieJar, +})) + +import { GET } from "./route" + +describe("GET /api/auth/dashboard/start", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("404s when the dashboard provider is not configured", async () => { + mocks.getDashboardProvider.mockReturnValue(null) + + const response = await GET(new NextRequest("http://localhost:3001/api/auth/dashboard/start")) + const body = (await response.json()) as { message?: string } + + expect(response.status).toBe(404) + expect(body.message).toMatch(/DASHBOARD_ORIGIN/i) + }) + + it("returns the app URL after a successful login", async () => { + mocks.getDashboardProvider.mockReturnValue({ + kind: "dashboard", + name: "dashboard", + displayName: "Dashboard", + dashboardOrigin: "http://localhost:3000", + }) + mocks.loginWithDashboardSession.mockResolvedValue("/") + mocks.cookieJar.getAll.mockReturnValue([ + { name: "better-auth.session_token", value: "abc" }, + { name: "notebook-session", value: "xyz" }, + ]) + + const request = new NextRequest("http://localhost:3001/api/auth/dashboard/start") + const response = await GET(request) + const body = (await response.json()) as { url?: string } + + expect(response.status).toBe(200) + expect(body.url).toBe("/") + expect(mocks.loginWithDashboardSession).toHaveBeenCalledWith( + "better-auth.session_token=abc; notebook-session=xyz", + "http://localhost:3000", + ) + }) + + it("surfaces the no-dashboard-session error as 401", async () => { + mocks.getDashboardProvider.mockReturnValue({ + kind: "dashboard", + name: "dashboard", + displayName: "Dashboard", + dashboardOrigin: "http://localhost:3000", + }) + mocks.loginWithDashboardSession.mockRejectedValue( + new mocks.DashboardLoginError( + "no-dashboard-session", + "You are not logged into the Knowhere Dashboard.", + ), + ) + + const response = await GET(new NextRequest("http://localhost:3001/api/auth/dashboard/start")) + const body = (await response.json()) as { message?: string } + + expect(response.status).toBe(401) + expect(body.message).toMatch(/Dashboard/i) + }) + + it("surfaces the email-collision error as 409", async () => { + mocks.getDashboardProvider.mockReturnValue({ + kind: "dashboard", + name: "dashboard", + displayName: "Dashboard", + dashboardOrigin: "http://localhost:3000", + }) + mocks.loginWithDashboardSession.mockRejectedValue( + new mocks.DashboardLoginError("email-collision", "collision"), + ) + + const response = await GET(new NextRequest("http://localhost:3001/api/auth/dashboard/start")) + const body = (await response.json()) as { message?: string } + + expect(response.status).toBe(409) + expect(body.message).toBe("collision") + }) + + it("returns 500 for unexpected failures", async () => { + mocks.getDashboardProvider.mockReturnValue({ + kind: "dashboard", + name: "dashboard", + displayName: "Dashboard", + dashboardOrigin: "http://localhost:3000", + }) + mocks.loginWithDashboardSession.mockRejectedValue(new Error("boom")) + + const response = await GET(new NextRequest("http://localhost:3001/api/auth/dashboard/start")) + + expect(response.status).toBe(500) + }) +}) diff --git a/src/app/api/auth/dashboard/start/route.ts b/src/app/api/auth/dashboard/start/route.ts new file mode 100644 index 0000000..2cc9371 --- /dev/null +++ b/src/app/api/auth/dashboard/start/route.ts @@ -0,0 +1,56 @@ +import type { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" + +import { getDashboardProvider } from "@/infrastructure/auth/oauth-providers" +import { + DashboardLoginError, + loginWithDashboardSession, +} from "@/infrastructure/auth/oauth" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET(_request: NextRequest): Promise { + // `cookies()` is a dynamic API and must be reached BEFORE any early + // return: with cacheComponents, Next.js prerenders GET handlers at build + // time, and an early `getDashboardProvider()` 404 would be baked in as a + // permanent static response (build-time env ≠ runtime env). Reading the + // cookie jar first terminates prerendering and defers to request-time + // rendering. (`_request` is unused; `cookies()` reads the request.) + void _request + const jar = await cookies() + const provider = getDashboardProvider() + if (!provider) { + return nextRouteResponse.toNextResponse( + routeResult.error( + 404, + "Dashboard SSO is not configured. Set DASHBOARD_ORIGIN.", + ), + ) + } + + // Forward the browser's full cookie jar: cookies are host-scoped (not + // port-scoped), so this includes the Dashboard's Better Auth session + // cookie when the Dashboard runs on the same host on another port. + const cookieHeader = jar + .getAll() + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; ") + + try { + const url = await loginWithDashboardSession( + cookieHeader, + provider.dashboardOrigin, + ) + return nextRouteResponse.toNextResponse(routeResult.ok({ url })) + } catch (error) { + if (error instanceof DashboardLoginError) { + const status = error.code === "email-collision" ? 409 : 401 + return nextRouteResponse.toNextResponse( + routeResult.error(status, error.message), + ) + } + return nextRouteResponse.toNextResponse( + routeResult.error(500, "Could not log in with the Dashboard."), + ) + } +} diff --git a/src/app/api/chat/diagram/route.ts b/src/app/api/chat/diagram/route.ts index 7443ad0..4667dac 100644 --- a/src/app/api/chat/diagram/route.ts +++ b/src/app/api/chat/diagram/route.ts @@ -5,7 +5,7 @@ import { parseChatDiagramRequestBody, } from "@/domains/chat/diagram" import { notebookRequestContext } from "@/domains/workspace/request-context" -import { isAuthError } from "@/integrations/dashboard/api-key-service" +import { isAuthError } from "@/integrations/knowhere-credentials" import { summarizeUnknownError } from "@/lib/format-log-value" import { logger } from "@/lib/logger" import { nextRouteResponse } from "@/lib/next-route-response" diff --git a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts deleted file mode 100644 index 247f645..0000000 --- a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { knowhereDemoApi } from "@/integrations/knowhere-demo" - -type RouteContext = { - readonly params: Promise<{ - readonly demoSourceId: string - readonly assetPath: string[] - }> -} - -export async function GET( - _request: Request, - context: RouteContext, -): Promise { - const { assetPath, demoSourceId } = await context.params - const encodedAssetPath = assetPath.map(encodeURIComponent).join("/") - const response = await fetch( - knowhereDemoApi.resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent( - demoSourceId, - )}/assets/${encodedAssetPath}`, - ), - { cache: "no-store" }, - ) - - if (!response.ok || !response.body) { - return Response.json( - { message: "Demo source asset not found." }, - { status: 404 }, - ) - } - - return new Response(response.body, { - status: 200, - headers: { - "content-type": response.headers.get("content-type") ?? "application/octet-stream", - "cache-control": "public, max-age=3600", - }, - }) -} diff --git a/src/app/api/demo-sources/[demoSourceId]/original/route.ts b/src/app/api/demo-sources/[demoSourceId]/original/route.ts deleted file mode 100644 index c3b04d3..0000000 --- a/src/app/api/demo-sources/[demoSourceId]/original/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { knowhereDemoApi } from "@/integrations/knowhere-demo" - -type RouteContext = { - readonly params: Promise<{ - readonly demoSourceId: string - }> -} - -export async function GET( - _request: Request, - context: RouteContext, -): Promise { - const { demoSourceId } = await context.params - const response = await fetch( - knowhereDemoApi.resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent(demoSourceId)}/original`, - ), - { cache: "no-store" }, - ) - - if (!response.ok || !response.body) { - return Response.json( - { message: "Demo original file not found." }, - { status: 404 }, - ) - } - - return new Response(response.body, { - status: 200, - headers: { - "content-type": response.headers.get("content-type") ?? "application/pdf", - "cache-control": "public, max-age=3600", - }, - }) -} diff --git a/src/app/api/demo-sources/materialize/route.test.ts b/src/app/api/demo-sources/materialize/route.test.ts deleted file mode 100644 index 9b41725..0000000 --- a/src/app/api/demo-sources/materialize/route.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" - -import type { Source, Workspace } from "@/infrastructure/db/schema" - -const mocks = vi.hoisted(() => ({ - getAuthenticatedWithClient: vi.fn(), - listHiddenDemoSourceIds: vi.fn(), - materializeSources: vi.fn(), - upsertMaterializedDemoSource: vi.fn(), -})) - -vi.mock("@/domains/workspace/request-context", () => ({ - notebookRequestContext: { - getAuthenticatedWithClient: mocks.getAuthenticatedWithClient, - }, -})) - -vi.mock("@/integrations/knowhere-demo", () => ({ - knowhereDemoApi: { - materializeSources: mocks.materializeSources, - }, -})) - -vi.mock("@/domains/sources/service", () => ({ - sourceService: { - listHiddenDemoSourceIds: mocks.listHiddenDemoSourceIds, - upsertMaterializedDemoSource: mocks.upsertMaterializedDemoSource, - }, -})) - -import { POST } from "./route" - -describe("POST /api/demo-sources/materialize", () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.listHiddenDemoSourceIds.mockResolvedValue([]) - }) - - it("materializes selected demo sources through Knowhere and stores source rows", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.materializeSources.mockResolvedValue([ - { - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - chunkCount: 70, - status: "created", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - canDownload: false, - }, - }, - ]) - mocks.upsertMaterializedDemoSource.mockResolvedValue( - makeSource(workspace.id), - ) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["demo-tsla-q4-2025", "demo-tsla-q4-2025"], - }), - }), - ) - - await expect(response.json()).resolves.toEqual({ - sources: [ - { - id: "source_demo", - kind: "workspace", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - canDownload: false, - pdfPreviewMode: "browser", - }, - chunkCount: 70, - }, - ], - }) - expect(response.status).toBe(200) - expect(mocks.listHiddenDemoSourceIds).toHaveBeenCalledWith(workspace.id) - expect(mocks.materializeSources).toHaveBeenCalledWith({ - apiKey: "jwt_123", - namespace: workspace.namespace, - demoSourceIds: ["demo-tsla-q4-2025"], - }) - expect(mocks.upsertMaterializedDemoSource).toHaveBeenCalledWith( - workspace.id, - { - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", - }, - ) - }) - - it("does not store non-public legacy demo original routes", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.materializeSources.mockResolvedValue([ - { - demoSourceId: "legacy-demo", - documentId: "doc_legacy_copy", - title: "Legacy-Demo.pdf", - mimeType: "application/pdf", - sizeBytes: 10, - chunkCount: 1, - status: "created", - originalFile: { - url: "https://api.knowhere.example/api/v1/demo/sources/legacy-demo/original", - mimeType: "application/pdf", - sizeBytes: 10, - canDownload: false, - }, - }, - ]) - mocks.upsertMaterializedDemoSource.mockResolvedValue( - makeSource(workspace.id, { originalBlobUrl: null }), - ) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["legacy-demo"], - }), - }), - ) - - expect(response.status).toBe(200) - expect(mocks.upsertMaterializedDemoSource).toHaveBeenCalledWith( - workspace.id, - expect.objectContaining({ - demoSourceId: "legacy-demo", - originalBlobUrl: null, - }), - ) - }) - - it("does not materialize demo sources hidden in the workspace", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.listHiddenDemoSourceIds.mockResolvedValue(["demo-tsla-q4-2025"]) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["demo-tsla-q4-2025"], - }), - }), - ) - - await expect(response.json()).resolves.toEqual({ - message: "Selected demo sources are no longer available.", - }) - expect(response.status).toBe(400) - expect(mocks.materializeSources).not.toHaveBeenCalled() - expect(mocks.upsertMaterializedDemoSource).not.toHaveBeenCalled() - }) - - it("filters hidden demo sources before materializing visible selections", async () => { - const workspace = makeWorkspace() - mocks.getAuthenticatedWithClient.mockResolvedValue({ - apiKey: "jwt_123", - workspace, - }) - mocks.listHiddenDemoSourceIds.mockResolvedValue(["hidden-demo"]) - mocks.materializeSources.mockResolvedValue([]) - - const response = await POST( - new Request("http://localhost:3001/api/demo-sources/materialize", { - method: "POST", - body: JSON.stringify({ - demoSourceIds: ["hidden-demo", "demo-tsla-q4-2025"], - }), - }), - ) - - expect(response.status).toBe(200) - expect(mocks.materializeSources).toHaveBeenCalledWith({ - apiKey: "jwt_123", - namespace: workspace.namespace, - demoSourceIds: ["demo-tsla-q4-2025"], - }) - }) -}) - -function makeWorkspace(): Workspace { - return { - id: "workspace_1", - userId: "user_1", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - } -} - -function makeSource( - workspaceId: string, - overrides: Partial = {}, -): Source { - return { - id: "source_demo", - workspaceId, - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: "doc_user_copy", - stagedBlobPathname: null, - stagedBlobUrl: null, - originalBlobPathname: null, - originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", - demoKey: "demo-tsla-q4-2025", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - deletedAt: null, - ...overrides, - } -} diff --git a/src/app/api/demo-sources/materialize/route.ts b/src/app/api/demo-sources/materialize/route.ts deleted file mode 100644 index c87a1db..0000000 --- a/src/app/api/demo-sources/materialize/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Effect } from "effect" -import type { NextResponse } from "next/server" - -import { chatCitationPersistence } from "@/domains/chat/chat-citation-persistence" -import { chatMessageRepository } from "@/domains/chat/chat-message-repository" -import { chatThreadRepository } from "@/domains/chat/chat-thread-repository" -import type { ChatCitationView } from "@/domains/chat/types" -import { demoOriginalFile } from "@/domains/demo/original-file" -import { databaseRuntime } from "@/domains/workspace/database-runtime" -import { sourceService } from "@/domains/sources/service" -import { toSourceView } from "@/domains/sources/view" -import { notebookRequestContext } from "@/domains/workspace/request-context" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" -import { nextRouteResponse } from "@/lib/next-route-response" -import { routeResult } from "@/lib/route-result" - -export async function POST(request: Request): Promise { - return Effect.runPromise( - Effect.gen(function* () { - const body = yield* Effect.tryPromise(() => - routeResult.readJson(request), - ) - if (!body.ok) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest("Invalid request body."), - ) - } - - const demoSourceIds = getDemoSourceIds(body.value) - if (demoSourceIds.length === 0) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest("Select at least one demo source."), - ) - } - - const { apiKey, workspace } = yield* Effect.tryPromise(() => - notebookRequestContext.getAuthenticatedWithClient(), - ) - const hiddenDemoSourceIds = new Set( - yield* Effect.tryPromise(() => - sourceService.listHiddenDemoSourceIds(workspace.id), - ), - ) - const visibleDemoSourceIds = demoSourceIds.filter( - (demoSourceId) => !hiddenDemoSourceIds.has(demoSourceId), - ) - if (visibleDemoSourceIds.length === 0) { - return nextRouteResponse.toNextResponse( - routeResult.badRequest( - "Selected demo sources are no longer available.", - ), - ) - } - - const materializedSources = yield* Effect.tryPromise(() => - knowhereDemoApi.materializeSources({ - apiKey, - namespace: workspace.namespace, - demoSourceIds: visibleDemoSourceIds, - }), - ) - - const sources = yield* Effect.all( - materializedSources.map((source) => - Effect.gen(function* () { - const row = yield* Effect.tryPromise(() => - sourceService.upsertMaterializedDemoSource(workspace.id, { - demoSourceId: source.demoSourceId, - title: source.title, - mimeType: source.mimeType, - sizeBytes: source.sizeBytes, - knowhereDocumentId: source.documentId, - originalBlobUrl: demoOriginalFile.getPublicUrl(source), - }), - ) - return toSourceView(row, { chunkCount: source.chunkCount }) - }), - ), - { concurrency: "unbounded" }, - ) - - // After materialization, remap seeded demo-thread citations from their - // canonical document IDs to the new materialized document IDs so source - // citation resolution continues to work. - yield* Effect.tryPromise(() => - fixDemoThreadCitations(workspace.id, materializedSources), - ).pipe(Effect.catchAllCause(() => Effect.void)) - - return nextRouteResponse.toNextResponse(routeResult.ok({ sources })) - }).pipe( - Effect.catchAll(() => - Effect.succeed( - nextRouteResponse.toNextResponse( - routeResult.error( - 502, - "Demo sources could not be prepared right now.", - ), - ), - ), - ), - ), - ) -} - -function getDemoSourceIds(value: unknown): string[] { - if (!isRecord(value) || !Array.isArray(value.demoSourceIds)) return [] - - const selectedIds = value.demoSourceIds.filter( - (item): item is string => - typeof item === "string" && item.trim().length > 0, - ) - return Array.from(new Set(selectedIds.map((item) => item.trim()))) -} - -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null -} - -const seededDemoChatKey = "knowhere-demo-chat" - -async function fixDemoThreadCitations( - workspaceId: string, - materializedSources: ReadonlyArray<{ - readonly demoSourceId: string - readonly documentId: string - }>, -): Promise { - const catalog = await knowhereDemoApi.fetchCatalog() - const canonicalIdByDemoSourceId = new Map( - catalog.sources.map((s) => [s.demoSourceId, s.canonicalDocumentId]), - ) - const documentIdMap = new Map() - for (const source of materializedSources) { - const canonical = canonicalIdByDemoSourceId.get(source.demoSourceId) - if (canonical) { - documentIdMap.set(canonical, source.documentId) - } - } - if (documentIdMap.size === 0) return - - const thread = await databaseRuntime.runPromise( - chatThreadRepository.findThreadByDemoKeyEffect( - workspaceId, - seededDemoChatKey, - ), - ) - if (!thread) return - - const messages = await databaseRuntime.runPromise( - chatMessageRepository.listMessagesForThreadEffect(workspaceId, thread.id), - ) - if (!messages || messages.length === 0) return - - await Promise.all( - messages.map(async (message) => { - const currentCitations = message.citations as - | ChatCitationView[] - | null - | undefined - const updated = chatCitationPersistence.replaceDemoCitationDocumentId( - currentCitations ?? undefined, - documentIdMap, - ) - if (!updated) return - - await databaseRuntime.runPromise( - chatMessageRepository.updateMessageCitationsEffect( - message.id, - chatCitationPersistence.normalizeCitations(updated), - ), - ) - }), - ) -} diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts index 524a657..beb8125 100644 --- a/src/app/api/sources/[sourceId]/chunks/route.test.ts +++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ deleteBlob: vi.fn(), ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), - fetchDemoChunkPage: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), getSourceParseAssetUrls: vi.fn(), @@ -21,17 +20,10 @@ vi.mock("next/headers", () => ({ headers: vi.fn(async () => new Headers({ cookie: "session=abc" })), })) -vi.mock("@/integrations/dashboard/api-key-service", () => ({ +vi.mock("@/integrations/knowhere-credentials", () => ({ ensureApiKeyForWorkspace: mocks.ensureApiKeyForWorkspace, })) -vi.mock("@/integrations/knowhere-demo", () => ({ - knowhereDemoApi: { - fetchCatalog: vi.fn(), - fetchChunkPage: mocks.fetchDemoChunkPage, - }, -})) - vi.mock("@/infrastructure/auth", () => ({ getCurrentUser: mocks.getCurrentUser, requireUser: mocks.requireUser, @@ -74,361 +66,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { mocks.updateSourceRevisionKey.mockResolvedValue(null) }) - it("serves API-owned demo chunks for anonymous canonical demo sources", async () => { - mocks.getCurrentUser.mockResolvedValue(null) - mocks.fetchDemoChunkPage.mockResolvedValue({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "Tesla demo content", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 1, - total: 70, - totalPages: 70, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks?page=1&pageSize=1", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { - chunkId: "demo-tsla-q4-2025:chunk_1", - documentId: "demo-doc-tsla-q4-2025", - sourceTitle: "TSLA-Q4-2025-Update.pdf", - }, - ], - pagination: { - page: 1, - pageSize: 1, - total: 70, - }, - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenCalledWith({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 1, - }) - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() - }) - - it("loads every API-owned demo chunk page for full anonymous chunk requests", async () => { - mocks.getCurrentUser.mockResolvedValue(null) - mocks.fetchDemoChunkPage - .mockResolvedValueOnce({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "First page", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 200, - total: 201, - totalPages: 2, - }, - }) - .mockResolvedValueOnce({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_201", - chunkId: "chunk_201", - chunkType: "text", - content: "Second page", - sectionPath: "Outlook", - sourceChunkPath: "Outlook", - filePath: null, - sortOrder: 200, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 2, - pageSize: 200, - total: 201, - totalPages: 2, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { chunkId: "demo-tsla-q4-2025:chunk_1" }, - { chunkId: "demo-tsla-q4-2025:chunk_201" }, - ], - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenNthCalledWith(1, { - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 200, - }) - expect(mocks.fetchDemoChunkPage).toHaveBeenNthCalledWith(2, { - demoSourceId: "demo-tsla-q4-2025", - page: 2, - pageSize: 200, - }) - }) - - it("serves API-owned demo chunks for authenticated canonical demo sources", async () => { - mocks.getCurrentUser.mockResolvedValue({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - userId: "knowhere-api-key-dev-user", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - }) - mocks.findSourceInWorkspace.mockResolvedValue(null) - mocks.fetchDemoChunkPage.mockResolvedValue({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "Tesla demo content", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - totalPages: 1, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks?page=1&pageSize=100", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { - chunkId: "demo-tsla-q4-2025:chunk_1", - documentId: "demo-doc-tsla-q4-2025", - sourceTitle: "TSLA-Q4-2025-Update.pdf", - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - }, - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenCalledWith({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) - expect(mocks.findSourceInWorkspace).not.toHaveBeenCalled() - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() - }) - - it("serves demo chunks for authenticated materialized demo sources", async () => { - mocks.getCurrentUser.mockResolvedValue({ - id: "user_1", - email: null, - name: null, - }) - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - userId: "user_1", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - }) - mocks.findSourceInWorkspace.mockResolvedValue({ - id: "00000000-0000-0000-0000-000000000001", - workspaceId: "workspace_1", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: "copied-doc-tsla-q4-2025", - stagedBlobPathname: null, - stagedBlobUrl: null, - originalBlobPathname: null, - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - demoKey: "demo-tsla-q4-2025", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - deletedAt: null, - }) - mocks.fetchDemoChunkPage.mockResolvedValue({ - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk_1", - chunkId: "chunk_1", - chunkType: "text", - content: "Tesla demo content", - sectionPath: "Summary", - sourceChunkPath: "Summary", - filePath: null, - sortOrder: 0, - metadata: {}, - assetUrl: null, - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - totalPages: 1, - }, - }) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000001/chunks?page=1&pageSize=100", - ), - { params: Promise.resolve({ sourceId: "00000000-0000-0000-0000-000000000001" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ - chunks: [ - { - chunkId: "demo-tsla-q4-2025:chunk_1", - documentId: "copied-doc-tsla-q4-2025", - sourceTitle: "TSLA-Q4-2025-Update.pdf", - }, - ], - pagination: { - page: 1, - pageSize: 100, - total: 70, - }, - }) - expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).toHaveBeenCalledWith({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() - expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() - }) - - it("logs the demo chunk load failure before returning 404", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) - try { - mocks.getCurrentUser.mockResolvedValue({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - mocks.ensureWorkspace.mockResolvedValue({ - id: "workspace_1", - userId: "knowhere-api-key-dev-user", - namespace: "notebook-workspace_1", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - }) - mocks.findSourceInWorkspace.mockResolvedValue(null) - mocks.fetchDemoChunkPage.mockRejectedValue( - new Error("Knowhere demo API failed: status=404"), - ) - - const response = await GET( - new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks?page=1&pageSize=100", - ), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ) - - expect(response.status).toBe(404) - const line = String(warnSpy.mock.calls[0]?.[0] ?? "") - const log = JSON.parse(line) as { - readonly msg?: unknown - readonly sourceId?: unknown - readonly page?: unknown - readonly pageSize?: unknown - readonly shouldLoadAll?: unknown - readonly error?: unknown - } - expect(log).toMatchObject({ - msg: "sources: demo chunk load failed", - sourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - shouldLoadAll: false, - error: "Knowhere demo API failed: status=404", - }) - } finally { - warnSpy.mockRestore() - } - }) - it("loads authenticated workspace chunks without probing the demo endpoint first", async () => { const knowhereClient = { documents: { @@ -480,7 +117,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, @@ -512,7 +148,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }, }) expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).not.toHaveBeenCalled() expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith("doc_1", { page: 1, pageSize: 1, @@ -573,7 +208,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), }) - mocks.fetchDemoChunkPage.mockRejectedValue(new Error("not a demo")) mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") mocks.makeKnowhereClient.mockReturnValue(knowhereClient) mocks.localizeRemoteDocument.mockResolvedValue({ @@ -590,7 +224,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, @@ -626,7 +259,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { expect(mocks.findSourceInWorkspace).not.toHaveBeenCalled() expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( "workspace_1", - "session=abc", ) expect(mocks.localizeRemoteDocument).toHaveBeenCalledWith( "workspace_1", diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index 6f82574..3744bd9 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -8,10 +8,8 @@ const mocks = vi.hoisted(() => { deleteBlob: vi.fn(), ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), - fetchDemoCatalog: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), - hideDemoSource: vi.fn(), makeKnowhereClient: vi.fn(), requireUser: vi.fn(), retrySourceToKnowhere: vi.fn(), @@ -28,17 +26,10 @@ vi.mock("@vercel/blob", () => ({ del: mocks.deleteBlob, })); -vi.mock("@/integrations/dashboard/api-key-service", () => ({ +vi.mock("@/integrations/knowhere-credentials", () => ({ ensureApiKeyForWorkspace: mocks.ensureApiKeyForWorkspace, })); -vi.mock("@/integrations/knowhere-demo", () => ({ - knowhereDemoApi: { - fetchCatalog: mocks.fetchDemoCatalog, - fetchChunkPage: vi.fn(), - }, -})) - vi.mock("@/infrastructure/auth", () => ({ getCurrentUser: mocks.getCurrentUser, requireUser: mocks.requireUser, @@ -55,7 +46,6 @@ vi.mock("@/domains/sources/background-reconcile", () => ({ vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, - hideDemoSource: mocks.hideDemoSource, retrySourceToKnowhere: mocks.retrySourceToKnowhere, softDelete: mocks.softDeleteSource, }, @@ -103,7 +93,6 @@ describe("PATCH /api/sources/[sourceId]", () => { }); expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( "workspace_1", - "session=abc", ); expect(mocks.makeKnowhereClient).toHaveBeenCalledWith("jwt_123"); expect(mocks.archive).toHaveBeenCalledWith("doc_123"); @@ -120,7 +109,6 @@ describe("PATCH /api/sources/[sourceId]", () => { mocks.requireUser.mockResolvedValue({ id: "user_1" }); mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); mocks.findSourceInWorkspace.mockResolvedValue(null); - mocks.fetchDemoCatalog.mockResolvedValue({ sources: [] }); const response = await PATCH( new NextRequest( @@ -183,86 +171,6 @@ describe("PATCH /api/sources/[sourceId]", () => { ); }); - it("archives materialized demo sources and records canonical visibility", async () => { - mocks.requireUser.mockResolvedValue({ id: "user_1" }); - mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); - mocks.findSourceInWorkspace.mockResolvedValue({ - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - knowhereDocumentId: "doc_user_copy", - originalBlobPathname: null, - }); - mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); - mocks.makeKnowhereClient.mockReturnValue({ - documents: { archive: mocks.archive }, - }); - mocks.archive.mockResolvedValue(undefined); - mocks.softDeleteSource.mockResolvedValue(true); - mocks.hideDemoSource.mockResolvedValue(undefined); - - const response = await PATCH( - new NextRequest("http://localhost:3001/api/sources/source_demo", { - method: "PATCH", - body: JSON.stringify({ archived: true }), - }), - { params: Promise.resolve({ sourceId: "source_demo" }) }, - ); - - await expect(response.json()).resolves.toEqual({ - id: "source_demo", - archived: true, - }); - expect(response.status).toBe(200); - expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( - "workspace_1", - "session=abc", - ); - expect(mocks.archive).toHaveBeenCalledWith("doc_user_copy"); - expect(mocks.deleteBlob).not.toHaveBeenCalled(); - expect(mocks.softDeleteSource).toHaveBeenCalledWith( - "workspace_1", - "source_demo", - ); - expect(mocks.hideDemoSource).toHaveBeenCalledWith( - "workspace_1", - "demo-tsla-q4-2025", - ); - }); - - it("hides a canonical demo source before it has a workspace row", async () => { - mocks.requireUser.mockResolvedValue({ id: "user_1" }); - mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); - mocks.findSourceInWorkspace.mockResolvedValue(null); - mocks.fetchDemoCatalog.mockResolvedValue({ - sources: [ - { - demoSourceId: "demo-tsla-q4-2025", - }, - ], - }); - mocks.hideDemoSource.mockResolvedValue(undefined); - - const response = await PATCH( - new NextRequest("http://localhost:3001/api/sources/demo-tsla-q4-2025", { - method: "PATCH", - body: JSON.stringify({ archived: true }), - }), - { params: Promise.resolve({ sourceId: "demo-tsla-q4-2025" }) }, - ); - - await expect(response.json()).resolves.toEqual({ - id: "demo-tsla-q4-2025", - archived: true, - }); - expect(response.status).toBe(200); - expect(mocks.hideDemoSource).toHaveBeenCalledWith( - "workspace_1", - "demo-tsla-q4-2025", - ); - expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); - expect(mocks.archive).not.toHaveBeenCalled(); - }); - it("retries a failed source and starts background reconciliation", async () => { mocks.requireUser.mockResolvedValue({ id: "user_1" }); mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); @@ -281,7 +189,6 @@ describe("PATCH /api/sources/[sourceId]", () => { originalBlobPathname: "source-uploads/upload_1/document.pdf", originalBlobUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, @@ -313,7 +220,6 @@ describe("PATCH /api/sources/[sourceId]", () => { originalBlobPathname: "source-uploads/upload_1/document.pdf", originalBlobUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/app/api/sources/route.test.ts b/src/app/api/sources/route.test.ts index a78639b..2c099bd 100644 --- a/src/app/api/sources/route.test.ts +++ b/src/app/api/sources/route.test.ts @@ -13,6 +13,9 @@ const mocks = vi.hoisted(() => { uploadSourceBlobToKnowhere: vi.fn(), uploadSourceToKnowhere: vi.fn(), ensureWorkspace: vi.fn(), + findWorkspaceByIdAndUserId: vi.fn(), + findByIdAndUserIdEffect: vi.fn(), + databaseRunPromise: vi.fn(), }; }); @@ -24,7 +27,7 @@ vi.mock("next/headers", () => ({ headers: vi.fn(async () => new Headers({ cookie: "session=abc" })), })); -vi.mock("@/integrations/dashboard/api-key-service", () => ({ +vi.mock("@/integrations/knowhere-credentials", () => ({ ensureApiKeyForWorkspace: mocks.ensureApiKeyForWorkspace, })); @@ -50,11 +53,24 @@ vi.mock("@/domains/workspace/service", () => ({ }, })); +vi.mock("@/domains/workspace/repository", () => ({ + workspaceRepository: { + findByIdAndUserIdEffect: mocks.findByIdAndUserIdEffect, + }, +})); + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.databaseRunPromise, + }, +})); + import { POST } from "./route"; const workspace: Workspace = { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00Z"), }; @@ -73,7 +89,6 @@ const source: Source = { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, @@ -88,6 +103,12 @@ describe("POST /api/sources", () => { mocks.makeKnowhereClient.mockReturnValue({ jobs: {} }); mocks.uploadSourceBlobToKnowhere.mockResolvedValue(source); mocks.uploadSourceToKnowhere.mockResolvedValue(source); + mocks.findByIdAndUserIdEffect.mockReturnValue( + Promise.resolve(workspace), + ); + mocks.databaseRunPromise.mockImplementation( + (effect: Promise) => Promise.resolve(effect), + ); }); it("uploads multipart files through the route handler", async () => { @@ -116,7 +137,6 @@ describe("POST /api/sources", () => { expect(response.status).toBe(201); expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( workspace.id, - "session=abc", ); expect(mocks.uploadSourceToKnowhere).toHaveBeenCalledWith( workspace, @@ -126,6 +146,44 @@ describe("POST /api/sources", () => { expect(mocks.revalidatePath).toHaveBeenCalledWith("/"); }); + it("uploads to an explicitly targeted workspace the user belongs to", async () => { + const targetWorkspace: Workspace = { + id: "workspace_target", + userId: "user_1", + activeKnowhereApiKeyId: null, + namespace: "adobe", + createdAt: new Date("2026-05-10T00:00:00Z"), + }; + mocks.findByIdAndUserIdEffect.mockReturnValue( + Promise.resolve(targetWorkspace), + ); + + const formData = new FormData(); + formData.set( + "file", + new File(["hello"], "notes.pdf", { type: "application/pdf" }), + ); + formData.set("workspaceId", "workspace_target"); + + const response = await POST( + new NextRequest("http://localhost:3001/api/sources", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(201); + expect(mocks.ensureWorkspace).not.toHaveBeenCalled(); + expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith( + "workspace_target", + ); + expect(mocks.uploadSourceToKnowhere).toHaveBeenCalledWith( + targetWorkspace, + expect.objectContaining({ name: "notes.pdf" }), + { jobs: {} }, + ); + }); + it("creates a source from a Blob-backed upload without sending the file through the route body", async () => { const response = await POST( new NextRequest("http://localhost:3001/api/sources", { diff --git a/src/app/api/sources/route.ts b/src/app/api/sources/route.ts index 3e197e4..44891fc 100644 --- a/src/app/api/sources/route.ts +++ b/src/app/api/sources/route.ts @@ -29,6 +29,7 @@ export async function POST(request: NextRequest): Promise { const result = await sourceRouteService.uploadSource({ cookieHeader: routeContext.cookieHeader, upload, + workspaceId: upload.workspaceId, onUploadFinished: () => { revalidatePath("/") }, diff --git a/src/app/api/workspaces/[workspaceId]/members/[userId]/route.ts b/src/app/api/workspaces/[workspaceId]/members/[userId]/route.ts new file mode 100644 index 0000000..9497ef9 --- /dev/null +++ b/src/app/api/workspaces/[workspaceId]/members/[userId]/route.ts @@ -0,0 +1,53 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { workspaceRepository } from "@/domains/workspace/repository" +import { workspaceMembersRepository } from "@/infrastructure/auth/workspace-members-repository" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function DELETE( + _request: NextRequest, + { + params, + }: { params: Promise<{ workspaceId: string; userId: string }> }, +): Promise { + return withApiErrorResponse( + "workspaces:members:remove", + async () => { + const { workspaceId, userId } = await params + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + const workspace = await databaseRuntime.runPromise( + workspaceRepository.findByIdAndUserIdEffect(workspaceId, user.id), + ) + if (!workspace) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "Workspace not found."), + ) + } + if (workspace.userId !== user.id) { + return nextRouteResponse.toNextResponse( + routeResult.error(403, "Only the workspace owner can remove members."), + ) + } + if (workspace.userId === userId) { + return nextRouteResponse.toNextResponse( + routeResult.error(400, "The owner cannot be removed."), + ) + } + + await databaseRuntime.runPromise( + workspaceMembersRepository.removeMemberEffect(workspaceId, userId), + ) + return nextRouteResponse.toNextResponse(routeResult.ok({})) + }, + "Could not remove the workspace member.", + ) +} diff --git a/src/app/api/workspaces/[workspaceId]/members/route.test.ts b/src/app/api/workspaces/[workspaceId]/members/route.test.ts new file mode 100644 index 0000000..358fd6a --- /dev/null +++ b/src/app/api/workspaces/[workspaceId]/members/route.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + getCurrentUser: vi.fn(), + runPromise: vi.fn(), + findByIdAndUserIdEffect: vi.fn(), + listMembersEffect: vi.fn(), + addMemberEffect: vi.fn(), + removeMemberEffect: vi.fn(), + findByEmailEffect: vi.fn(), + findByIdEffect: vi.fn(), +})) + +vi.mock("@/infrastructure/auth", () => ({ + getCurrentUser: mocks.getCurrentUser, +})) + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.runPromise, + }, +})) + +vi.mock("@/domains/workspace/repository", () => ({ + workspaceRepository: { + findByIdAndUserIdEffect: mocks.findByIdAndUserIdEffect, + }, +})) + +vi.mock("@/infrastructure/auth/workspace-members-repository", () => ({ + workspaceMembersRepository: { + listMembersEffect: mocks.listMembersEffect, + addMemberEffect: mocks.addMemberEffect, + removeMemberEffect: mocks.removeMemberEffect, + }, +})) + +vi.mock("@/infrastructure/auth/users-repository", () => ({ + usersRepository: { + findByEmailEffect: mocks.findByEmailEffect, + findByIdEffect: mocks.findByIdEffect, + }, +})) + +import type { NextRequest } from "next/server" +import { GET as listMembers, POST as addMember } from "./route" +import { DELETE as removeMember } from "./[userId]/route" + +const owner = { id: "user_owner", email: "owner@example.com" } +const workspace = { + id: "ws_1", + userId: "user_owner", + namespace: "default", + activeKnowhereApiKeyId: null, + createdAt: new Date(), +} + +describe("workspace members routes", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getCurrentUser.mockResolvedValue(owner) + mocks.findByIdAndUserIdEffect.mockReturnValue( + Promise.resolve(workspace), + ) + mocks.runPromise.mockImplementation( + (effect: { then: (fn: unknown) => unknown }) => + effect as unknown as Promise, + ) + }) + + it("lists members with user info", async () => { + mocks.listMembersEffect.mockReturnValue( + Promise.resolve([ + { id: "m1", workspaceId: "ws_1", userId: "user_2", createdAt: new Date(), deletedAt: null }, + ]), + ) + mocks.findByIdEffect.mockReturnValue( + Promise.resolve({ + id: "user_2", + email: "teammate@example.com", + name: "Teammate", + }), + ) + mocks.runPromise.mockImplementation((effect: unknown) => + Promise.resolve(effect), + ) + + const response = await listMembers(new Request("http://localhost/api/workspaces/ws_1/members") as NextRequest, { + params: Promise.resolve({ workspaceId: "ws_1" }), + }) + const body = (await response.json()) as { + members?: { userId: string; email: string | null }[] + } + + expect(response.status).toBe(200) + expect(body.members).toEqual([ + { userId: "user_2", email: "teammate@example.com", name: "Teammate" }, + ]) + }) + + it("rejects non-members and non-owners", async () => { + mocks.getCurrentUser.mockResolvedValue({ id: "user_other", email: "x@y.com" }) + mocks.findByIdAndUserIdEffect.mockReturnValue(Promise.resolve(null)) + + const response = await addMember( + new Request("http://localhost/api/workspaces/ws_1/members", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "teammate@example.com" }), + }) as NextRequest, + { params: Promise.resolve({ workspaceId: "ws_1" }) }, + ) + const body = (await response.json()) as { message?: string } + + expect(response.status).toBe(404) + expect(body.message).toMatch(/not found/i) + }) + + it("adds a member by email", async () => { + mocks.findByEmailEffect.mockReturnValue( + Promise.resolve({ id: "user_2", email: "teammate@example.com" }), + ) + mocks.addMemberEffect.mockReturnValue(Promise.resolve(undefined)) + + const response = await addMember( + new Request("http://localhost/api/workspaces/ws_1/members", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "Teammate@example.com " }), + }) as NextRequest, + { params: Promise.resolve({ workspaceId: "ws_1" }) }, + ) + const body = (await response.json()) as { member?: { userId: string } } + + expect(response.status).toBe(200) + expect(mocks.findByEmailEffect).toHaveBeenCalledWith("teammate@example.com") + expect(mocks.addMemberEffect).toHaveBeenCalledWith("ws_1", "user_2") + expect(body.member).toEqual({ userId: "user_2" }) + }) + + it("rejects unknown emails with a friendly message", async () => { + mocks.findByEmailEffect.mockReturnValue(Promise.resolve(null)) + + const response = await addMember( + new Request("http://localhost/api/workspaces/ws_1/members", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "nobody@example.com" }), + }) as NextRequest, + { params: Promise.resolve({ workspaceId: "ws_1" }) }, + ) + const body = (await response.json()) as { message?: string } + + expect(response.status).toBe(404) + expect(body.message).toMatch(/admin-provisioned/i) + }) + + it("allows only the owner to remove members, not the owner themselves", async () => { + mocks.removeMemberEffect.mockReturnValue(Promise.resolve(undefined)) + + const okResponse = await removeMember( + new Request("http://localhost/api/workspaces/ws_1/members/user_2", { method: "DELETE" }) as NextRequest, + { + params: Promise.resolve({ workspaceId: "ws_1", userId: "user_2" }), + }, + ) + expect(okResponse.status).toBe(200) + expect(mocks.removeMemberEffect).toHaveBeenCalledWith("ws_1", "user_2") + + const selfResponse = await removeMember( + new Request("http://localhost/api/workspaces/ws_1/members/user_owner", { method: "DELETE" }) as NextRequest, + { + params: Promise.resolve({ workspaceId: "ws_1", userId: "user_owner" }), + }, + ) + expect(selfResponse.status).toBe(400) + }) +}) diff --git a/src/app/api/workspaces/[workspaceId]/members/route.ts b/src/app/api/workspaces/[workspaceId]/members/route.ts new file mode 100644 index 0000000..fa7f93a --- /dev/null +++ b/src/app/api/workspaces/[workspaceId]/members/route.ts @@ -0,0 +1,118 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { workspaceRepository } from "@/domains/workspace/repository" +import { workspaceMembersRepository } from "@/infrastructure/auth/workspace-members-repository" +import { usersRepository } from "@/infrastructure/auth/users-repository" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ workspaceId: string }> }, +): Promise { + return withApiErrorResponse( + "workspaces:members:list", + async () => { + const { workspaceId } = await params + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + const workspace = await databaseRuntime.runPromise( + workspaceRepository.findByIdAndUserIdEffect(workspaceId, user.id), + ) + if (!workspace) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "Workspace not found."), + ) + } + const members = await databaseRuntime.runPromise( + workspaceMembersRepository.listMembersEffect(workspaceId), + ) + const memberUsers = await Promise.all( + members.map((member) => + databaseRuntime.runPromise( + usersRepository.findByIdEffect(member.userId), + ), + ), + ) + return nextRouteResponse.toNextResponse( + routeResult.ok({ + members: members.map((member, index) => ({ + userId: member.userId, + email: memberUsers[index]?.email ?? null, + name: memberUsers[index]?.name ?? null, + })), + }), + ) + }, + "Could not list workspace members.", + ) +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ workspaceId: string }> }, +): Promise { + return withApiErrorResponse( + "workspaces:members:add", + async () => { + const { workspaceId } = await params + const body = await routeResult.readJsonOrNull(request) + const email = + typeof body === "object" && body !== null && "email" in body + ? String((body as { email?: unknown }).email).trim().toLowerCase() + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!email) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("email is required."), + ) + } + + const workspace = await databaseRuntime.runPromise( + workspaceRepository.findByIdAndUserIdEffect(workspaceId, user.id), + ) + if (!workspace) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "Workspace not found."), + ) + } + if (workspace.userId !== user.id) { + return nextRouteResponse.toNextResponse( + routeResult.error(403, "Only the workspace owner can invite members."), + ) + } + + const memberUser = await databaseRuntime.runPromise( + usersRepository.findByEmailEffect(email), + ) + if (!memberUser) { + return nextRouteResponse.toNextResponse( + routeResult.error( + 404, + "No Notebook user with that email. Users are admin-provisioned.", + ), + ) + } + + await databaseRuntime.runPromise( + workspaceMembersRepository.addMemberEffect(workspaceId, memberUser.id), + ) + return nextRouteResponse.toNextResponse( + routeResult.ok({ member: { userId: memberUser.id } }), + ) + }, + "Could not add the workspace member.", + ) +} diff --git a/src/app/api/workspaces/activate/route.ts b/src/app/api/workspaces/activate/route.ts new file mode 100644 index 0000000..4cb300e --- /dev/null +++ b/src/app/api/workspaces/activate/route.ts @@ -0,0 +1,52 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { activeWorkspaceCookieName } from "@/domains/workspace/service" +import { workspaceRepository } from "@/domains/workspace/repository" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function POST(request: NextRequest): Promise { + return withApiErrorResponse( + "workspaces:activate", + async () => { + const body = await routeResult.readJsonOrNull(request) + const workspaceId = + typeof body === "object" && body !== null && "workspaceId" in body + ? String((body as { workspaceId?: unknown }).workspaceId) + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!workspaceId) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("workspaceId is required."), + ) + } + + const workspace = await databaseRuntime.runPromise( + workspaceRepository.findByIdAndUserIdEffect(workspaceId, user.id), + ) + if (!workspace) { + return nextRouteResponse.toNextResponse( + routeResult.error(404, "Workspace not found."), + ) + } + + const response = nextRouteResponse.toNextResponse(routeResult.ok({})) + response.cookies.set(activeWorkspaceCookieName, workspace.id, { + httpOnly: false, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }) + return response + }, + "Could not activate this workspace.", + ) +} diff --git a/src/app/api/workspaces/route.test.ts b/src/app/api/workspaces/route.test.ts new file mode 100644 index 0000000..cc70024 --- /dev/null +++ b/src/app/api/workspaces/route.test.ts @@ -0,0 +1,180 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; + +const mocks = vi.hoisted(() => { + return { + activeWorkspaceCookieName: "notebook-ws", + ensureWorkspaceForNamespace: vi.fn(), + findByIdAndUserEffect: vi.fn(), + setActiveEffect: vi.fn(), + runPromise: vi.fn(), + getCurrentUser: vi.fn(), + localizeWorkspaceNamespace: vi.fn(), + }; +}); + +vi.mock("@/domains/workspace/service", () => ({ + activeWorkspaceCookieName: mocks.activeWorkspaceCookieName, + workspaceService: { + ensureWorkspaceForNamespace: mocks.ensureWorkspaceForNamespace, + }, +})); + +vi.mock("@/infrastructure/auth/knowhere-api-keys-repository", () => ({ + knowhereApiKeysRepository: { + findByIdAndUserEffect: mocks.findByIdAndUserEffect, + setActiveEffect: mocks.setActiveEffect, + decryptStoredEffect: vi.fn(async () => "sk_test"), + }, +})); + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.runPromise, + }, +})); + +vi.mock("@/infrastructure/auth", () => ({ + getCurrentUser: mocks.getCurrentUser, +})); + +vi.mock("@/domains/sources/localize-namespace", () => ({ + localizeWorkspaceNamespace: mocks.localizeWorkspaceNamespace, +})); + +import { POST as activateWorkspace } from "./activate/route"; +import { POST as createWorkspace } from "./route"; + +const user = { id: "user_1", email: "ada@example.com", name: "Ada" }; +const workspace = { + id: "ws_1", + userId: "user_1", + namespace: "adobe", + activeKnowhereApiKeyId: null, + createdAt: new Date(), +}; +const key = { + id: "key_1", + userId: "user_1", + label: "domainA", + keyMask: "sk_te••••st", + createdAt: new Date(), +}; + +describe("POST /api/workspaces", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + mocks.runPromise.mockImplementation( + (effect: Effect.Effect) => + Effect.runPromise(effect), + ); + mocks.ensureWorkspaceForNamespace.mockResolvedValue(workspace); + mocks.findByIdAndUserEffect.mockReturnValue(Effect.succeed(key)); + mocks.setActiveEffect.mockReturnValue(Effect.succeed(undefined)); + mocks.localizeWorkspaceNamespace.mockResolvedValue([]); + }); + + it("creates a workspace for a (keyId, namespace) pair, sets the cookie, and localizes", async () => { + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyId: "key_1", namespace: "adobe" }), + }); + + const response = await createWorkspace(request); + const body = await response.json(); + + expect(mocks.ensureWorkspaceForNamespace).toHaveBeenCalledWith( + "user_1", + "adobe", + ); + expect(response.status).toBe(200); + expect(body.workspace).toEqual({ + id: "ws_1", + namespace: "adobe", + }); + expect(body.sources).toEqual([]); + expect(response.cookies.get("notebook-ws")?.value).toBe("ws_1"); + }); + + it("rejects requests without keyId or namespace", async () => { + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyId: "key_1" }), + }); + + const response = await createWorkspace(request); + + expect(response.status).toBe(400); + expect(mocks.ensureWorkspaceForNamespace).not.toHaveBeenCalled(); + }); + + it("rejects an unknown key", async () => { + mocks.findByIdAndUserEffect.mockReturnValue(null); + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyId: "missing", namespace: "adobe" }), + }); + + const response = await createWorkspace(request); + + expect(response.status).toBe(400); + }); + + it("rejects unauthenticated requests", async () => { + mocks.getCurrentUser.mockResolvedValue(null); + const request = new NextRequest("http://localhost/api/workspaces", { + method: "POST", + body: JSON.stringify({ keyId: "key_1", namespace: "adobe" }), + }); + + const response = await createWorkspace(request); + + expect(response.status).toBe(400); + }); +}); + +describe("POST /api/workspaces/activate", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentUser.mockResolvedValue(user); + mocks.runPromise.mockResolvedValue(workspace); + }); + + it("activates an owned workspace and sets the cookie", async () => { + const request = new NextRequest("http://localhost/api/workspaces/activate", { + method: "POST", + body: JSON.stringify({ workspaceId: "ws_1" }), + }); + + const response = await activateWorkspace(request); + + expect(mocks.runPromise).toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(response.cookies.get("notebook-ws")?.value).toBe("ws_1"); + }); + + it("rejects a workspace that does not belong to the user", async () => { + mocks.runPromise.mockResolvedValue(null); + const request = new NextRequest("http://localhost/api/workspaces/activate", { + method: "POST", + body: JSON.stringify({ workspaceId: "ws_other" }), + }); + + const response = await activateWorkspace(request); + + expect(response.status).toBe(404); + }); + + it("rejects requests without workspaceId", async () => { + const request = new NextRequest("http://localhost/api/workspaces/activate", { + method: "POST", + body: JSON.stringify({}), + }); + + const response = await activateWorkspace(request); + + expect(response.status).toBe(400); + }); +}); diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts new file mode 100644 index 0000000..6b8aebf --- /dev/null +++ b/src/app/api/workspaces/route.ts @@ -0,0 +1,87 @@ +import type { NextRequest, NextResponse } from "next/server" + +import { withApiErrorResponse } from "@/lib/api-error-response" +import { getCurrentUser } from "@/infrastructure/auth" +import { + workspaceService, + activeWorkspaceCookieName, +} from "@/domains/workspace/service" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { knowhereApiKeysRepository } from "@/infrastructure/auth/knowhere-api-keys-repository" +import { localizeWorkspaceNamespace } from "@/domains/sources/localize-namespace" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function POST(request: NextRequest): Promise { + return withApiErrorResponse( + "workspaces:create", + async () => { + const body = await routeResult.readJsonOrNull(request) + const keyId = + typeof body === "object" && body !== null && "keyId" in body + ? String((body as { keyId?: unknown }).keyId) + : "" + const namespace = + typeof body === "object" && body !== null && "namespace" in body + ? String((body as { namespace?: unknown }).namespace) + : "" + const user = await getCurrentUser() + if (!user) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("Not authenticated."), + ) + } + if (!keyId || !namespace) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("keyId and namespace are required."), + ) + } + + const key = await databaseRuntime + .runPromise( + knowhereApiKeysRepository.findByIdAndUserEffect(keyId, user.id), + ) + .catch(() => null) + if (!key) { + return nextRouteResponse.toNextResponse( + routeResult.badRequest("API key not found."), + ) + } + + const workspace = await workspaceService.ensureWorkspaceForNamespace( + user.id, + namespace, + ) + await databaseRuntime + .runPromise( + knowhereApiKeysRepository.setActiveEffect(workspace.id, key.id), + ) + .catch(() => {}) + + const apiKey = await databaseRuntime + .runPromise(knowhereApiKeysRepository.decryptStoredEffect(key)) + .catch(() => null) + const sources = apiKey + ? await localizeWorkspaceNamespace(workspace, apiKey) + : [] + + const response = nextRouteResponse.toNextResponse( + routeResult.ok({ + workspace: { + id: workspace.id, + namespace: workspace.namespace, + }, + sources, + }), + ) + response.cookies.set(activeWorkspaceCookieName, workspace.id, { + httpOnly: false, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }) + return response + }, + "Could not create this workspace.", + ) +} diff --git a/src/app/auth/logout/actions.ts b/src/app/auth/logout/actions.ts new file mode 100644 index 0000000..f564873 --- /dev/null +++ b/src/app/auth/logout/actions.ts @@ -0,0 +1,10 @@ +"use server" + +import { redirect } from "next/navigation" + +import { deleteSession } from "@/infrastructure/auth/session" + +export async function logoutAction(): Promise { + await deleteSession() + redirect("/login") +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6520102..f423745 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,11 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { GeistSans } from "geist/font/sans"; +import { GeistMono } from "geist/font/mono"; import { ThemeProvider } from "@/components/theme-provider"; import { appMetadata } from "@/lib/app-metadata"; import { PostHogInitializer } from "@/providers/posthog-initializer"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = appMetadata; export default function RootLayout({ @@ -25,7 +16,7 @@ export default function RootLayout({ return ( diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts new file mode 100644 index 0000000..7478d06 --- /dev/null +++ b/src/app/login/actions.ts @@ -0,0 +1,65 @@ +"use server" + +import { redirect } from "next/navigation" +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { usersRepository } from "@/infrastructure/auth/users-repository" +import { accountLinksRepository } from "@/infrastructure/auth/account-links-repository" +import { createSession } from "@/infrastructure/auth/session" +import { verifyPassword } from "@/lib/password" + +export type LoginActionState = { + readonly error: string | null +} + +export async function loginAction( + _previousState: LoginActionState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "").trim().toLowerCase() + const password = String(formData.get("password") ?? "") + + if (!email || !password) { + return { error: "Enter your email and password." } + } + + const user = await databaseRuntime + .runPromise( + Effect.gen(function* () { + const user = yield* usersRepository.findByEmailEffect(email) + if (!user) return null + + const link = yield* accountLinksRepository.findByUserIdAndProviderEffect( + user.id, + "password", + ) + if (!link?.passwordHash) return null + + return user + }), + ) + .catch(() => null) + + if (!user) { + return { error: "Incorrect email or password." } + } + + const link = await databaseRuntime + .runPromise( + accountLinksRepository.findByUserIdAndProviderEffect(user.id, "password"), + ) + .catch(() => null) + + if (!link?.passwordHash) { + return { error: "Incorrect email or password." } + } + + const ok = await verifyPassword(password, link.passwordHash) + if (!ok) { + return { error: "Incorrect email or password." } + } + + await createSession(user.id) + redirect("/") +} diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx new file mode 100644 index 0000000..753b38f --- /dev/null +++ b/src/app/login/login-form.tsx @@ -0,0 +1,105 @@ +"use client" + +import { useActionState, useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { loginAction, type LoginActionState } from "./actions" + +const initialState: LoginActionState = { error: null } + +export type LoginProvider = { + readonly name: string + readonly displayName: string +} + +export function LoginForm({ + providers = [], +}: { + readonly providers?: readonly LoginProvider[]; +}) { + const [state, formAction, isPending] = useActionState(loginAction, initialState); + const [oauthProvider, setOauthProvider] = useState(null); + const [providerError, setProviderError] = useState(null); + + async function handleOAuth(provider: string): Promise { + setOauthProvider(provider); + setProviderError(null); + try { + const response = await fetch(`/api/auth/${encodeURIComponent(provider)}/start`); + const body = (await response.json()) as { url?: string; message?: string }; + if (body.url) { + const anchor = document.createElement("a"); + anchor.href = body.url; + anchor.click(); + return; + } + setProviderError(body.message ?? "Sign-in could not be started."); + setOauthProvider(null); + } catch { + setProviderError("Sign-in could not be started."); + setOauthProvider(null); + } + } + + return ( +
+ {providers.length > 0 ? ( + <> +
+ {providers.map((provider) => ( + + ))} +
+ {providerError ? ( +

{providerError}

+ ) : null} + + + ) : null} +
+
+ + +
+
+ + +
+ {state.error ? ( +

{state.error}

+ ) : null} + +
+
+ ); +} diff --git a/src/app/login/page.test.ts b/src/app/login/page.test.ts index 906bc21..8f442ba 100644 --- a/src/app/login/page.test.ts +++ b/src/app/login/page.test.ts @@ -9,46 +9,27 @@ vi.mock("next/server", () => ({ import { LoginContent } from "./page"; describe("LoginPage", () => { - const originalDashboardOrigin = process.env.DASHBOARD_ORIGIN; - const originalNotebookPublicURL = process.env.NOTEBOOK_PUBLIC_URL; - beforeEach(() => { - process.env.DASHBOARD_ORIGIN = "http://localhost:3000"; - process.env.NOTEBOOK_PUBLIC_URL = "http://localhost:3001"; + vi.resetModules(); }); afterEach(() => { cleanup(); - - if (originalDashboardOrigin === undefined) { - delete process.env.DASHBOARD_ORIGIN; - } else { - process.env.DASHBOARD_ORIGIN = originalDashboardOrigin; - } - - if (originalNotebookPublicURL === undefined) { - delete process.env.NOTEBOOK_PUBLIC_URL; - } else { - process.env.NOTEBOOK_PUBLIC_URL = originalNotebookPublicURL; - } }); - it("links directly to Dashboard login with the Notebook callback URL", async () => { + it("renders a local email + password form", async () => { render(await LoginContent()); - const link = screen.getByRole("link", { name: "Sign in" }); - - expect(link.getAttribute("href")).toBe( - "http://localhost:3000/login?callbackURL=http%3A%2F%2Flocalhost%3A3001", - ); - expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull(); + expect(screen.getByLabelText("Email")).toBeTruthy(); + expect(screen.getByLabelText("Password")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(screen.getByText("Sign in with your Notebook account.")).toBeTruthy(); }); it("uses account language instead of implementation details", async () => { const { container } = render(await LoginContent()); - expect(screen.getByRole("link", { name: "Sign in" })).toBeTruthy(); - expect(screen.getByText("Use your Knowhere account to continue.")).toBeTruthy(); expect(container.textContent).not.toMatch(/dashboard/i); + expect(container.textContent).not.toMatch(/better.auth/i); }); }); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c802af3..a2c93cf 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,10 +1,9 @@ import { Suspense } from "react" -import Link from "next/link"; import { NotebookLogoMark } from "@/components/notebook-logo-mark"; -import { headers } from "next/headers"; import { Card, CardContent } from "@/components/ui/card"; -import { authURLs } from "@/infrastructure/auth/urls"; import { connection } from "next/server"; +import { listLoginProviders } from "@/infrastructure/auth/oauth-providers"; +import { LoginForm } from "./login-form"; export default function LoginPage() { return ( @@ -16,13 +15,8 @@ export default function LoginPage() { export async function LoginContent() { await connection() - const notebookPublicURL = - process.env.NOTEBOOK_PUBLIC_URL ?? - authURLs.resolveNotebookPublicURLFromHeaders(await headers()); - const loginHref = authURLs.buildDashboardLoginURL( - `${requireEnv("DASHBOARD_ORIGIN")}/login`, - notebookPublicURL, - ); + + const providers = listLoginProviders() return (
@@ -31,26 +25,17 @@ export async function LoginContent() {
-

+

Knowhere Notebook

- - Sign in - -

- Use your Knowhere account to continue. +

+ Sign in with your Notebook account.

+
+ +
); } - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} must be set.`); - return value; -} diff --git a/src/app/page.test.ts b/src/app/page.test.ts index f7e3525..35e3944 100644 --- a/src/app/page.test.ts +++ b/src/app/page.test.ts @@ -30,8 +30,7 @@ describe("Home", () => { it("renders the workspace shell from the API-backed initial state", async () => { mocks.loadWorkspaceShellInitialState.mockResolvedValue({ - isGuest: true, - loginUrl: "/login", + user: { id: "user_1", email: "a@b.com", name: "Ada" }, sources: [], chatMessages: [], }) @@ -42,6 +41,16 @@ describe("Home", () => { expect(mocks.loadWorkspaceShellInitialState).toHaveBeenCalledOnce() }) + it("redirects to /login when the initial state has no user", async () => { + mocks.loadWorkspaceShellInitialState.mockResolvedValue({ + sources: [], + chatMessages: [], + }) + + await expect(HomeContent()).rejects.toThrow(/NEXT_REDIRECT/) + expect(mocks.loadWorkspaceShellInitialState).toHaveBeenCalledOnce() + }) + it("logs a readable page-load failure before rethrowing", async () => { const failure = makeWorkspaceInitialStateFailureFixture() diff --git a/src/app/page.tsx b/src/app/page.tsx index 2ebc597..7075c1b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react" +import { redirect } from "next/navigation" import { WorkspaceShell } from "@/components/workspace-shell" import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state" import { effectOperation } from "@/lib/effect-operation" @@ -17,6 +18,9 @@ export default function Home() { export async function HomeContent() { await connection() const initialState = await loadWorkspaceInitialState() + if (!initialState.user) { + redirect("/login") + } return } diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 1d7051b..d9af4fc 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -9,13 +9,44 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatComposer } from "./chat-composer"; +const templatePrompts = { + "ipo-prospectus-risk-mining": + "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].", + "earnings-call-transcript-analysis": + "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].", +}; + +const promptTemplatesResponse = [ + { + id: "ipo-prospectus-risk-mining", + title: "IPO Prospectus Risk Mining", + prompt: templatePrompts["ipo-prospectus-risk-mining"], + }, + { + id: "earnings-call-transcript-analysis", + title: "Earnings Call Transcript Analysis", + prompt: templatePrompts["earnings-call-transcript-analysis"], + }, +]; + describe("ChatComposer", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => promptTemplatesResponse, + }), + ); + }); + afterEach(() => { cleanup(); + vi.unstubAllGlobals(); }); it("sends trimmed input and clears the composer", async () => { @@ -28,10 +59,46 @@ describe("ChatComposer", () => { await user.type(input, " Summarize this document "); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize this document"); + expect(onSend).toHaveBeenCalledWith("Summarize this document", { + rerank: true, + internalRecallK: 30, + topK: 8, + }); expect(input.value).toBe(""); }); + it("renders retrieval controls with defaults", () => { + render(React.createElement(ChatComposer)); + + const rerankSwitch = screen.getByRole("switch", { name: /^Rerank/ }); + expect(rerankSwitch.getAttribute("aria-checked")).toBe("true"); + expect(screen.getAllByRole("slider", { hidden: true }).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("Recall K")).toBeTruthy(); + expect(screen.getByText("Top K")).toBeTruthy(); + expect(screen.getByText("30")).toBeTruthy(); + expect(screen.getByText("8")).toBeTruthy(); + }); + + it("sends changed retrieval params when the switch is toggled", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + + render(React.createElement(ChatComposer, { onSend })); + + await user.click(screen.getByRole("switch", { name: /^Rerank/ })); + await user.type( + screen.getByPlaceholderText("Ask a question about your documents…"), + "Question", + ); + await user.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("Question", { + rerank: false, + internalRecallK: 30, + topK: 8, + }); + }); + it("caps long prompts and resets the composer after sending", async () => { const user = userEvent.setup(); const onSend = vi.fn(); @@ -97,7 +164,7 @@ describe("ChatComposer", () => { const input = getComposerTextArea(); input.scrollTop = 92; - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), ); @@ -133,7 +200,7 @@ describe("ChatComposer", () => { render(React.createElement(ChatComposer)); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), ); @@ -158,7 +225,7 @@ describe("ChatComposer", () => { render(React.createElement(ChatComposer)); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: /Earnings Call Transcript Analysis/, @@ -190,7 +257,7 @@ describe("ChatComposer", () => { expect(input.className).toContain("max-h-[192px]"); expect(input.className).toContain("border-0"); expect(input.className).toContain("shadow-none"); - expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Prompts / Chart" })).toBeTruthy(); }); }); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 4cfa62c..ad60ae5 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -10,8 +10,9 @@ import { type MouseEvent, type ReactElement, } from "react"; -import { BarChart3, FileText, Plus, Send } from "lucide-react"; +import { BarChart3, FileText, Send, WandSparkles } from "lucide-react"; +import { usePromptTemplates } from "@/components/use-prompt-templates"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -22,7 +23,16 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; -import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ChatPromptTemplate } from "@/domains/chat/prompt-templates"; +import type { RetrievalOverrides } from "@/domains/chat/contracts"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; const chatComposerName = "chat-composer"; const chatComposerTextAreaMinHeight = 128; const chatComposerTextAreaMaxHeight = 192; @@ -40,7 +50,7 @@ export type ChatComposerProps = { readonly isSending?: boolean; readonly onCreateDiagram?: () => void; readonly onLoginClick?: () => void; - readonly onSend?: (text: string) => void; + readonly onSend?: (text: string, retrievalParams: RetrievalOverrides) => void; }; export function ChatComposer({ @@ -53,9 +63,15 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [retrievalParams, setRetrievalParams] = useState({ + rerank: true, + internalRecallK: 30, + topK: 8, + }); const composerInputId = useId(); const pendingTemplatePromptRef = useRef(null); const textareaRef = useRef(null); + const { isLoading: isLoadingTemplates, templates } = usePromptTemplates(); const trimmedInput = input.trim(); const canSend = !isDisabled && !isSending && trimmedInput.length > 0; @@ -79,7 +95,7 @@ export function ChatComposer({ function handleSend(): void { if (!canSend) return; - onSend?.(trimmedInput); + onSend?.(trimmedInput, retrievalParams); setInput(""); } @@ -169,14 +185,23 @@ export function ChatComposer({ />
- +
+ + +
- + + + + + + + + Prompts / Chart + + - {chatPromptTemplates.map((template) => ( - onTemplateSelect(template.prompt)} - > - - {template.title} - - ))} - {onCreateDiagram ? ( + {isLoadingTemplates ? ( +
+ + Loading templates +
+ ) : ( <> - - - {isCreatingDiagram ? ( - - ) : ( - - )} - {isCreatingDiagram ? "Creating diagram" : "Create diagram"} - + {templates.map((template) => ( + onTemplateSelect(template.prompt)} + > + + {template.title} + + ))} + {onCreateDiagram ? ( + <> + + + {isCreatingDiagram ? ( + + ) : ( + + )} + {isCreatingDiagram ? "Creating diagram" : "Create diagram"} + + + ) : null} - ) : null} + )}
); diff --git a/src/components/chat-message-list.test.ts b/src/components/chat-message-list.test.ts index 8d58630..414cbfc 100644 --- a/src/components/chat-message-list.test.ts +++ b/src/components/chat-message-list.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import React from "react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -28,6 +28,13 @@ describe("ChatMessageList", () => { vi.restoreAllMocks(); }); + function expandSection(title: string): void { + const trigger = screen.getByRole("button", { + name: new RegExp(`^${title}`), + }); + fireEvent.click(trigger); + } + it("renders assistant citations using Notebook source labels", () => { render( React.createElement(ChatMessageList, { @@ -55,13 +62,106 @@ describe("ChatMessageList", () => { }), ); + expect( + screen.getByRole("button", { name: "Sources1" }).getAttribute( + "aria-expanded", + ), + ).toBe("false"); + + expandSection("Sources"); + expect( screen.getByRole("button", { name: "Open source Syllabus.pdf" }), ).toBeTruthy(); }); - it("renders citations in a bottom source area as file chips", async () => { + it("renders the transient retrieval trace for a fresh assistant message", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The deadline is Monday.", + retrievalTrace: { + durationSeconds: 1.2, + llmCallCount: 5, + inputTokens: 820, + outputTokens: 414, + queries: [ + { + query: "deadline monday", + namespace: "notebook-workspace", + resultCount: 3, + referencedChunkCount: 1, + topScores: [0.91, 0.8], + }, + ], + }, + }, + ], + }), + ); + + expandSection("Retrieval"); + + expect(screen.getByText("1.2s · 5 LLM calls · 820 in · 414 out")).toBeTruthy(); + expect(screen.getByText("deadline monday")).toBeTruthy(); + expect(screen.getByText("3 hits")).toBeTruthy(); + expect(screen.getByText("1 cited chunk")).toBeTruthy(); + expect(screen.getByText("top score: 0.910 · 0.800")).toBeTruthy(); + }); + + it("does not render answer stats when the trace has no stat fields", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The deadline is Monday.", + retrievalTrace: { + queries: [ + { + query: "deadline monday", + namespace: "notebook-workspace", + resultCount: 0, + referencedChunkCount: 0, + topScores: [], + }, + ], + }, + }, + ], + }), + ); + + expandSection("Retrieval"); + + expect(screen.queryByText(/s ·/u)).toBeNull(); + expect(screen.queryByText(/in ·/u)).toBeNull(); + }); + + it("does not render a retrieval trace without queries", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The deadline is Monday.", + retrievalTrace: { queries: [] }, + }, + ], + }), + ); + + expect(screen.queryByText("Retrieval")).toBeNull(); + }); + + it("renders citations in a bottom source area as file chips and inline markers as links", async () => { const user = userEvent.setup(); + const onCitationClick = vi.fn(); render( React.createElement(ChatMessageList, { @@ -106,15 +206,30 @@ describe("ChatMessageList", () => { ], }, ], - onCitationClick: vi.fn(), + onCitationClick, }), ); - expect(screen.getByText("Capital expenditure appears in the appendix.")) - .toBeTruthy(); - expect(screen.queryByText(/Source 1/u)).toBeNull(); - expect(screen.queryByText(/Source 3/u)).toBeNull(); - expect(screen.getByText("Sources")).toBeTruthy(); + expect( + screen.getByText((text) => text.startsWith("Capital expenditure appears")), + ).toBeTruthy(); + // Inline markers become [n] links instead of being stripped. + const inlineLinks = screen.getAllByRole("button", { + name: /^Open referenced chunk /u, + }); + expect(inlineLinks).toHaveLength(2); + expect(inlineLinks[0]!.textContent).toBe("1"); + expect(inlineLinks[1]!.textContent).toBe("3"); + + await user.click(inlineLinks[0]!); + expect(onCitationClick).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ documentId: "doc_1" }), + }), + "assistant_1:0", + ); + + expandSection("Sources"); const sourceChips = screen.getAllByRole("button", { name: "Open source spacex-s1.pdf", }); @@ -125,11 +240,11 @@ describe("ChatMessageList", () => { const tooltip = await screen.findByRole("tooltip"); expect(tooltip.textContent).toBe( - "spacex-s1.pdf · Assets / tables / table-25 Capital Expenditures.html", + "spacex-s1.pdf · Assets / tables / table-25 Capital Expenditures.htmlScore: 0.900", ); }); - it("removes description-only source labels without changing other markdown whitespace", () => { + it("converts description-only source labels into inline links without changing other markdown whitespace", () => { render( React.createElement(ChatMessageList, { messages: [ @@ -157,11 +272,15 @@ describe("ChatMessageList", () => { ], }, ], + onCitationClick: vi.fn(), }), ); - expect(screen.getByText("Revenue improved.")).toBeTruthy(); - expect(screen.queryByText(/Source 1/u)).toBeNull(); + expect(screen.getByText((text) => text.startsWith("Revenue improved"))).toBeTruthy(); + const inlineLink = screen.getByRole("button", { + name: "Open referenced chunk assistant_1:0", + }); + expect(inlineLink.textContent).toBe("1"); expect(document.querySelector("code.language-ts")?.textContent).toContain( "const value = 1;", ); @@ -320,6 +439,7 @@ describe("ChatMessageList", () => { ); expect(screen.queryByRole("img")).toBeNull(); + expandSection("Sources"); expect( screen.getByRole("button", { name: "Open source source.pdf", @@ -457,6 +577,7 @@ describe("ChatMessageList", () => { name: "商务标文件.pdf · 二、法定代表人身份证明", }), ).toBeTruthy(); + expandSection("Sources"); expect( screen.getAllByRole("button", { name: "Open source 商务标文件.pdf", diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx index e5e6460..bab3b0b 100644 --- a/src/components/chat-message-list.tsx +++ b/src/components/chat-message-list.tsx @@ -11,6 +11,8 @@ import remarkGfm from "remark-gfm"; import { ChatDiagramCard } from "@/components/chat-diagram-card"; import { useChatMessageListWorkflow } from "@/components/chat-message-list-workflow"; +import { CollapsibleSection } from "@/components/collapsible-section"; +import { ChatRetrievalTrace } from "@/components/chat-retrieval-trace"; import { chatPanelModel } from "@/components/chat-panel-model"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; @@ -323,6 +325,7 @@ function MessageBubble({ message.content, message.citations ?? [], sourceTitlesByDocumentId, + message.id, ); return ( @@ -330,6 +333,9 @@ function MessageBubble({
{displayDerivedTables.length > 0 && (
@@ -375,6 +381,9 @@ function MessageBubble({ onCitationClick={onCitationClick} pendingCitationId={pendingCitationId} /> + {message.retrievalTrace && ( + + )}
); @@ -422,8 +431,14 @@ function buildCitationContentMarkdown( content: string, citations: readonly ChatCitationView[], sourceTitlesByDocumentId: Readonly>, + messageId: string, ): string { + // Replace matched inline markers with links keyed by a private citation + // index href (`#citation-index-N`), then rewrite to the real citation id + // (`#citation-:`) in a second pass. The two-pass form + // keeps a later token from matching inside a link produced earlier. let rewrittenContent = content; + const matchedIndexes = new Set(); for (const [index, citation] of citations.entries()) { const displayCitation = { @@ -438,18 +453,42 @@ function buildCitationContentMarkdown( for (const token of getInlineCitationTokens(displayCitation, index)) { if (!rewrittenContent.includes(token)) continue; - rewrittenContent = removeInlineCitationToken(rewrittenContent, token); + matchedIndexes.add(index); + // Superscript-style [n] link (n = citation number) — the prose stays + // clean while the marker stays clickable. + rewrittenContent = replaceInlineCitationToken( + rewrittenContent, + token, + index + 1, + `citation-index-${index}`, + ); } } + if (matchedIndexes.size === 0) return rewrittenContent; + + for (const index of matchedIndexes) { + const citationId = chatPanelModel.getCitationId(messageId, index); + rewrittenContent = rewrittenContent.replaceAll( + `#citation-index-${index}`, + `#citation-${citationId}`, + ); + } + return rewrittenContent; } -function removeInlineCitationToken(content: string, token: string): string { +function replaceInlineCitationToken( + content: string, + token: string, + citationNumber: number, + href: string, +): string { + const link = `[${citationNumber}](#${href})`; return content - .replaceAll(` ${token}`, "") - .replaceAll(`${token} `, "") - .replaceAll(token, ""); + .replaceAll(` ${token}`, ` ${link}`) + .replaceAll(`${token} `, `${link} `) + .replaceAll(token, link); } function getInlineCitationTokens( @@ -540,12 +579,45 @@ function DerivedTableArtifactView({ function AssistantMessageContent({ content, + citations = [], + messageId, + onCitationClick, }: { readonly content: string; + readonly citations?: readonly ChatCitationView[]; + readonly messageId?: string; + readonly onCitationClick?: ( + citation: ChatCitationView, + citationId: string, + ) => void; }): ReactElement { const markdownComponents: Components = { ...assistantMarkdownComponents, a: ({ href, children }) => { + // Inline citation links (`#citation-:`) open the + // same chunk pane flow as the citation chips below the answer. + const citationId = extractCitationAnchorId(href); + const citation = getCitationById( + citations, + messageId, + citationId, + ); + if (citationId && citation && onCitationClick) { + return ( + + ); + } return ( -

- Sources -

+
{displayCitations.map((displayCitation) => ( @@ -607,7 +694,7 @@ function AssistantSources({ ))}
-
+ ); } @@ -652,12 +739,23 @@ function CitationChip({ align="start" className="max-w-[320px] bg-popover text-popover-foreground shadow-lg" > - {tooltipLabel} +
+ {tooltipLabel} + {isUsableCitationScore(citation.score) && ( + + Score: {citation.score!.toFixed(3)} + + )} +
); } +function isUsableCitationScore(score: number | null | undefined): boolean { + return typeof score === "number" && Number.isFinite(score) && score > 0; +} + function getDisplayCitations( message: ChatMessageView, sourceTitlesByDocumentId: Readonly>, diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts index 54ccce5..a2e2c6d 100644 --- a/src/components/chat-panel.test.ts +++ b/src/components/chat-panel.test.ts @@ -1,6 +1,12 @@ // @vitest-environment jsdom import React from "react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -41,6 +47,10 @@ describe("ChatPanel", () => { vi.restoreAllMocks(); }); + function expandSources(): void { + fireEvent.click(screen.getByRole("button", { name: /^Sources/ })); + } + it("explains answers in plain source-based language", () => { const { container } = render( React.createElement(C, { @@ -77,6 +87,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); expect( screen.getByRole("button", { name: "Open source syllabus.pdf", @@ -122,7 +133,7 @@ describe("ChatPanel", () => { }), ).toBeNull(); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: "Create diagram from latest answer", @@ -166,7 +177,7 @@ describe("ChatPanel", () => { }), ).toBeNull(); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Prompts / Chart" })); await user.click( screen.getByRole("menuitem", { name: "Create diagram from latest answer", @@ -240,7 +251,6 @@ describe("ChatPanel", () => { workspaceId: "workspace_1", workspaceNamespace: "demo", userId: "user_1", - isGuest: false, }, selectedSourcesCount: 2, sourceCount: 4, @@ -254,7 +264,11 @@ describe("ChatPanel", () => { ); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize revenue"); + expect(onSend).toHaveBeenCalledWith("Summarize revenue", { + rerank: true, + internalRecallK: 30, + topK: 8, + }); expect( analyticsMocks.trackNotebookAssistantQuestionSubmitted, ).toHaveBeenCalledWith({ @@ -262,7 +276,6 @@ describe("ChatPanel", () => { workspaceId: "workspace_1", workspaceNamespace: "demo", userId: "user_1", - isGuest: false, }, threadId: "thread_1", selectedSourcesCount: 2, @@ -299,6 +312,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); expect( screen.getByRole("button", { name: "Open source TSLA-Q4-2025-Update.pdf", @@ -361,6 +375,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); const duplicatedSourceLinks = screen.getAllByRole("button", { name: "Open source Micron Q1-26 Earnings Deck_R.pdf", }); @@ -417,6 +432,7 @@ describe("ChatPanel", () => { }), ); + expandSources(); const duplicatedLabelLinks = screen.getAllByRole("button", { name: "Open source report.pdf", }); @@ -460,11 +476,11 @@ describe("ChatPanel", () => { }), ); + expandSources(); const citationButton = screen.getByRole("button", { name: "Open source syllabus.pdf", }); - expect(screen.getByText("Sources")).toBeTruthy(); expect(citationButton.getAttribute("aria-busy")).toBe("true"); expect(citationButton.textContent).toBe("syllabus.pdf"); expect(citationButton.className).toContain("rounded-md"); @@ -514,11 +530,11 @@ describe("ChatPanel", () => { }), ); + expandSources(); const sourceLink = screen.getByRole("button", { name: /Open source TSLA-Q4-2025-UPDATE\.PDF/, }); - expect(screen.getByText("Sources")).toBeTruthy(); expect(sourceLink.className).toContain("max-w-[250px]"); expect(sourceLink.className).toContain("rounded-md"); expect(sourceLink.className).not.toContain("underline"); diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 92d3cbf..06a156c 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -37,6 +37,7 @@ import type { ChatMessageView, ChatThreadView, } from "@/domains/chat/types"; +import type { RetrievalOverrides } from "@/domains/chat/contracts"; import { workspaceClient } from "@/domains/workspace/client"; import { trackNotebookAssistantQuestionSubmitted, @@ -47,7 +48,7 @@ export type ChatPanelProps = { messages: ChatMessageView[]; threads: ChatThreadView[]; activeThreadId?: string | null; - onSend?: (text: string) => void; + onSend?: (text: string, retrievalParams?: RetrievalOverrides) => void; onNewChat?: () => void; onThreadSelect?: (threadId: string) => void; onThreadArchive?: (threadId: string) => void; @@ -152,7 +153,10 @@ export function ChatPanel({ } } - function handleComposerSend(text: string): void { + function handleComposerSend( + text: string, + retrievalParams?: RetrievalOverrides, + ): void { if (isCreateDiagramCommand(text)) { void handleCreateDiagramCommand(); return; @@ -165,7 +169,7 @@ export function ChatPanel({ sourceCountSnapshot: sourceCount, messageLength: text.length, }); - onSend?.(text); + onSend?.(text, retrievalParams); } return ( diff --git a/src/components/chat-retrieval-trace.tsx b/src/components/chat-retrieval-trace.tsx new file mode 100644 index 0000000..c8b03b9 --- /dev/null +++ b/src/components/chat-retrieval-trace.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { type ReactElement } from "react"; +import { Search } from "lucide-react"; + +import { CollapsibleSection } from "@/components/collapsible-section"; +import type { RetrievalTraceView } from "@/domains/chat/types"; + +export function ChatRetrievalTrace({ + trace, +}: { + readonly trace: RetrievalTraceView; +}): ReactElement | null { + if (trace.queries.length === 0) return null; + + return ( + } + badge={trace.queries.length} + > + {hasAnswerStats(trace) && ( +
+ {formatAnswerStats(trace)} +
+ )} +
+ {trace.queries.map((entry, index) => ( +
+
+ + {entry.query} + + + {entry.resultCount} {entry.resultCount === 1 ? "hit" : "hits"} + +
+ {entry.referencedChunkCount > 0 && ( + + {entry.referencedChunkCount} cited{" "} + {entry.referencedChunkCount === 1 ? "chunk" : "chunks"} + + )} + {entry.topScores.length > 0 && ( + + top score: {formatTopScores(entry.topScores)} + + )} +
+ ))} +
+
+ ); +} + +function formatTopScores(scores: readonly number[]): string { + return scores.map((score) => score.toFixed(3)).join(" · "); +} + +function hasAnswerStats(trace: RetrievalTraceView): boolean { + return ( + trace.durationSeconds !== undefined || + trace.llmCallCount !== undefined || + trace.inputTokens !== undefined || + trace.outputTokens !== undefined + ); +} + +function formatAnswerStats(trace: RetrievalTraceView): string { + const parts: string[] = []; + + if (trace.durationSeconds !== undefined) { + parts.push(`${trace.durationSeconds.toFixed(1)}s`); + } + if (trace.llmCallCount !== undefined) { + parts.push( + `${trace.llmCallCount} ${trace.llmCallCount === 1 ? "LLM call" : "LLM calls"}`, + ); + } + if ( + trace.inputTokens !== undefined || + trace.outputTokens !== undefined + ) { + parts.push( + `${trace.inputTokens ?? 0} in · ${trace.outputTokens ?? 0} out`, + ); + } + + return parts.join(" · "); +} diff --git a/src/components/chunks-panel-state.test.ts b/src/components/chunks-panel-state.test.ts index bcee0f0..4d50001 100644 --- a/src/components/chunks-panel-state.test.ts +++ b/src/components/chunks-panel-state.test.ts @@ -45,19 +45,16 @@ describe("chunksPanelState", () => { "chunk_page_7_second", "chunk_without_page", ]) + // A focused chunk shows alone: the list is filtered to the destination + // chunk instead of reordering the whole document around it. expect( chunksPanelState .getChunksWithFocusedFirst(chunks, "chunk_page_7") .map((chunk) => chunk.chunkId), - ).toEqual([ - "chunk_page_7", - "chunk_page_2", - "chunk_page_7_second", - "chunk_without_page", - ]) + ).toEqual(["chunk_page_7"]) }) - it("moves a focused Parsed Chunk to the front without mutating the input", () => { + it("shows only the focused chunk and never mutates the input", () => { const chunks: ParsedChunkView[] = [ { chunkId: "chunk_1", @@ -75,13 +72,28 @@ describe("chunksPanelState", () => { expect( chunksPanelState.getChunksWithFocusedFirst(chunks, "chunk_2"), - ).toEqual([chunks[1], chunks[0]]) + ).toEqual([chunks[1]]) expect(chunks.map((chunk) => chunk.chunkId)).toEqual([ "chunk_1", "chunk_2", ]) }) + it("returns an empty list when the focused chunk is not present", () => { + const chunks: ParsedChunkView[] = [ + { + chunkId: "chunk_1", + type: "text", + content: "First", + sourceTitle: "notes.pdf", + }, + ] + + expect( + chunksPanelState.getChunksWithFocusedFirst(chunks, "missing_chunk"), + ).toEqual([]) + }) + it("deduplicates repeated chunk ids before ordering and building the section tree", () => { type TestSectionTreeNode = { readonly chunkCount: number diff --git a/src/components/chunks-panel-state.ts b/src/components/chunks-panel-state.ts index dfd2ac7..abfede6 100644 --- a/src/components/chunks-panel-state.ts +++ b/src/components/chunks-panel-state.ts @@ -55,22 +55,16 @@ function getChunksWithFocusedFirst( chunks: readonly ParsedChunkView[], focusedChunkId: string | null, ): readonly ParsedChunkView[] { - const orderedChunks = getChunksOrderedByPageNumber( - dedupeChunksById(chunks), - ) - if (!focusedChunkId) return orderedChunks + if (!focusedChunkId) { + return getChunksOrderedByPageNumber(dedupeChunksById(chunks)) + } - const focusedIndex = orderedChunks.findIndex( + // When a specific chunk is focused (citation click or tree leaf click), + // show only that chunk — not the whole document reordered with it on top. + const focusedChunk = dedupeChunksById(chunks).find( (chunk) => chunk.chunkId === focusedChunkId, ) - if (focusedIndex <= 0) return orderedChunks - - const focusedChunk = orderedChunks[focusedIndex]! - return [ - focusedChunk, - ...orderedChunks.slice(0, focusedIndex), - ...orderedChunks.slice(focusedIndex + 1), - ] + return focusedChunk ? [focusedChunk] : [] } function getChunksOrderedByPageNumber( diff --git a/src/components/chunks-panel-workflow.test.ts b/src/components/chunks-panel-workflow.test.ts index 7f5717c..a0575d9 100644 --- a/src/components/chunks-panel-workflow.test.ts +++ b/src/components/chunks-panel-workflow.test.ts @@ -63,7 +63,7 @@ describe("useChunksPanelWorkflow", () => { expect(result.current.visibleChunks[0]?.chunkId).toBe("chunk_1") }) - it("records local reference focus and places the referenced chunk first", () => { + it("records local reference focus and shows only the referenced chunk", () => { const { result } = renderHook(() => useChunksPanelWorkflow( makeInput({ @@ -83,7 +83,6 @@ describe("useChunksPanelWorkflow", () => { expect(result.current.activeFocusedChunkId).toBe("chunk_2") expect(result.current.visibleChunks.map((chunk) => chunk.chunkId)).toEqual([ "chunk_2", - "chunk_1", ]) }) diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index f99a68b..17cbab5 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -116,13 +116,34 @@ describe("ChunksPanel", () => { expect( screen.getByRole("tree", { name: "Parsed chunk sections" }), ).toBeTruthy(); + // Default: root + 1 level only — level-1 sections visible. expect(screen.getByText("Overview")).toBeTruthy(); expect(screen.getByText("Outlook")).toBeTruthy(); + // Deeper nodes hidden until expanded. + expect( + screen.queryByRole("treeitem", { + name: /Robotics section with 2 chunks/i, + }), + ).toBeNull(); + + // Expand Outlook → Product visible; expand Product → Robotics visible. + fireEvent.click(screen.getByText("Outlook")); + expect(screen.getByText("Product")).toBeTruthy(); + fireEvent.click(screen.getByText("Product")); expect( screen.getByRole("treeitem", { name: /Robotics section with 2 chunks/i, }), ).toBeTruthy(); + + // Collapse Outlook → Product and Robotics hidden again. + fireEvent.click(screen.getByText("Outlook")); + expect(screen.queryByText("Product")).toBeNull(); + expect( + screen.queryByRole("treeitem", { + name: /Robotics section with 2 chunks/i, + }), + ).toBeNull(); }); it("deduplicates repeated chunks before rendering section tree keys", () => { @@ -157,6 +178,8 @@ describe("ChunksPanel", () => { name: "Overview section with 1 chunk", }), ).toBeTruthy(); + // Chunk is hidden by default (root + 1 level); expand to see it. + fireEvent.click(screen.getByText("Overview")); expect( screen.getAllByRole("treeitem", { name: "Overview text Text" }), ).toHaveLength(1); @@ -360,7 +383,7 @@ describe("ChunksPanel", () => { ); for (let i = 0; i < 7; i += 1) { - fireEvent.wheel(surface, { deltaY: 120 }); + fireEvent.wheel(surface, { deltaY: 120, ctrlKey: true }); } const zoomedOutSurfaceMinimumWidth = Number.parseInt( @@ -398,6 +421,7 @@ describe("ChunksPanel", () => { const surface = screen.getByTestId("chunk-section-tree-zoom-surface"); const zoomInEvent = new WheelEvent("wheel", { cancelable: true, + ctrlKey: true, deltaY: -120, }); @@ -408,7 +432,7 @@ describe("ChunksPanel", () => { expect(zoomInEvent.defaultPrevented).toBe(true); expect(tree.style.transform).toBe("scale(1.1)"); - fireEvent.wheel(surface, { deltaY: 120 }); + fireEvent.wheel(surface, { deltaY: 120, ctrlKey: true }); expect(tree.style.transform).toBe("scale(1)"); }); @@ -465,6 +489,10 @@ describe("ChunksPanel", () => { await user.click(screen.getByRole("button", { name: "Tree" })); + // Expand the collapsed path (root + 1 level by default). + fireEvent.click(screen.getByText("Outlook")); + fireEvent.click(screen.getByText("Robotics")); + const chunkNode = screen.getByRole("button", { name: /Robotics details\s*Text/, }); @@ -502,6 +530,11 @@ describe("ChunksPanel", () => { ); await user.click(screen.getByRole("button", { name: "Tree" })); + + // Expand the collapsed path to reach the chunk node. + fireEvent.click(screen.getByText("Outlook")); + fireEvent.click(screen.getByText("Robotics")); + await user.click( screen.getByRole("button", { name: /Robotics details\s*Text/ }), ); @@ -641,184 +674,6 @@ describe("ChunksPanel", () => { expect(screen.getByText("Preview is not available for this file.")).toBeTruthy(); }); - it("shows the existing unavailable state when a selected source has no public original", async () => { - const user = userEvent.setup(); - - render( - React.createElement(C, { - chunks: [], - selectedSource: "legacy-demo.pdf", - selectedSourceFile: null, - }), - ); - - await user.click(screen.getByRole("button", { name: "Original" })); - - expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - expect(screen.getByText("Original file is not available.")).toBeTruthy(); - }); - - it("renders browser-supported image originals inline", async () => { - const user = userEvent.setup(); - - render( - React.createElement(C, { - chunks: [], - selectedSource: "diagram.png", - selectedSourceFile: { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.png", - mimeType: "image/png", - }, - }), - ); - - await user.click(screen.getByRole("button", { name: "Original" })); - - const image = screen.getByRole("img", { name: "diagram.png" }); - expect(image.getAttribute("src")).toBe( - "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.png", - ); - }); - - it("opens the original PDF preview at the clicked chunk page", async () => { - mockVisibleVirtualViewport(); - const user = userEvent.setup(); - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 }), - ), - ), - ); - - render( - React.createElement(C, { - chunks: [ - { - chunkId: "chunk_1", - type: "text", - content: "Revenue details live on the second page.", - sourceTitle: "report.pdf", - pageNums: [2], - }, - ], - selectedSource: "report.pdf", - selectedSourceFile: { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/report.pdf", - mimeType: "application/pdf", - }, - }), - ); - selectListView(); - - await user.click( - screen.getByRole("button", { name: "Open page 2 in original file" }), - ); - - expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - expect(screen.getByTestId("source-original-preview").getAttribute( - "data-target-page", - )).toBe("2"); - }); - - it("keeps the original PDF preview mounted when switching back to parsed chunks", async () => { - mockVisibleVirtualViewport(); - const user = userEvent.setup(); - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 }), - ), - ), - ); - - render( - React.createElement(C, { - chunks: [ - { - chunkId: "chunk_1", - type: "text", - content: "Revenue details live on the second page.", - sourceTitle: "report.pdf", - pageNums: [2], - }, - ], - selectedSource: "report.pdf", - selectedSourceFile: { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/report.pdf", - mimeType: "application/pdf", - }, - }), - ); - selectListView(); - - await user.click( - screen.getByRole("button", { name: "Open page 2 in original file" }), - ); - - const mountedOriginalPreview = screen.getByTestId("source-original-preview"); - - await user.click(screen.getByRole("button", { name: "Parsed" })); - - expect(screen.getByRole("heading", { name: "Parsed Chunks" })).toBeTruthy(); - expect(screen.getByTestId("source-original-preview")).toBe( - mountedOriginalPreview, - ); - - await user.click(screen.getByRole("button", { name: "Original" })); - - expect(screen.getByTestId("source-original-preview")).toBe( - mountedOriginalPreview, - ); - }); - - it("returns to parsed chunks when a citation focuses a chunk from the original view", async () => { - mockVisibleVirtualViewport(); - const user = userEvent.setup(); - const chunks = [ - { - chunkId: "chunk_1", - type: "text", - content: "Referenced content from the parsed document.", - sourceTitle: "report.doc", - }, - ]; - const selectedSourceFile = { - url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.doc", - mimeType: "application/msword", - }; - const { rerender } = render( - React.createElement(C, { - chunks, - selectedSource: "report.doc", - selectedSourceFile, - }), - ); - selectListView(); - - await user.click(screen.getByRole("button", { name: "Original" })); - expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - - rerender( - React.createElement(C, { - chunks, - selectedSource: "report.doc", - selectedSourceFile, - focusedChunkId: "chunk_1", - focusedChunkRequestId: 1, - }), - ); - - await waitFor(() => { - expect( - screen.getByRole("heading", { name: "Referenced Chunks" }), - ).toBeTruthy(); - }); - expect(screen.getByTestId("chunk-card-shell-chunk_1")).toBeTruthy(); - }); - it("uses compact, non-folding spacing for the mobile chunk view", () => { render(React.createElement(C, { chunks: [] })); @@ -1011,7 +866,7 @@ describe("ChunksPanel", () => { }); }); - it("renders image chunks and moves resolved connection targets first", async () => { + it("renders image chunks and focuses the resolved connection target alone", async () => { mockVisibleVirtualViewport(); const user = userEvent.setup(); @@ -1070,11 +925,9 @@ describe("ChunksPanel", () => { expect(focusedRow?.getAttribute("data-index")).toBe("0"); expect(focusedRow?.getAttribute("data-focused-chunk")).toBe("true"); }); - expect( - screen - .getByRole("button", { name: "Missing" }) - .getAttribute("aria-disabled"), - ).toBe("true"); + // The unresolved reference lives on text_1, which is hidden once the + // image is focused. + expect(screen.queryByTestId("chunk-card-shell-text_1")).toBeNull(); }); it("lets in-chunk table references override the current citation focus", async () => { @@ -1205,7 +1058,7 @@ describe("ChunksPanel", () => { // the focused chunk reorders to index 0 (already checked above). }); - it("remeasures a tall focused chunk after citation reordering", async () => { + it("remeasures a tall focused chunk shown alone after citation focus", async () => { mockVirtualViewportWithChunkHeights({ chunk_1: 120, table_3: 520, @@ -1254,14 +1107,16 @@ describe("ChunksPanel", () => { const focusedRow = screen .getByTestId("chunk-card-shell-table_3") .closest("[data-index]"); - const followingRow = screen - .getByTestId("chunk-card-shell-chunk_1") - .closest("[data-index]"); expect(focusedRow?.getAttribute("data-index")).toBe("0"); - expect(followingRow?.getAttribute("data-index")).toBe("1"); - expect(followingRow?.style.transform).toBe("translateY(520px)"); + expect(focusedRow?.getAttribute("data-focused-chunk")).toBe("true"); }); + + // Only the focused chunk is shown — the rest of the document is hidden. + expect( + screen.queryByTestId("chunk-card-shell-chunk_1"), + ).toBeNull(); + expect(screen.queryByTestId("chunk-card-shell-chunk_2")).toBeNull(); }); it("reapplies the start position after the focused chunk layout pass", async () => { diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 898ca39..38f882b 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -18,6 +18,8 @@ import { type HierarchyPointNode, } from "d3-hierarchy"; import { + ChevronDown, + ChevronRight, FilePlus2, Layers, RotateCcw, @@ -59,6 +61,8 @@ export type ChunksPanelProps = { hasMoreChunks?: boolean; onLoadMore?: () => void; onLoadAllChunks?: () => void; + onClose?: () => void; + onClearFocus?: () => void; onLoginClick?: () => void; onSourceUploaded?: (source: SourceView) => void; analyticsContext?: AnalyticsContext; @@ -86,20 +90,18 @@ export function ChunksPanel({ hasMoreChunks = false, onLoadMore, onLoadAllChunks, + onClose, + onClearFocus, onLoginClick, onSourceUploaded, analyticsContext, sourceCountSnapshot = 0, }: Partial = {}) { - const originalPreviewCacheKey = selectedSourceFile?.url ?? null; const isOriginalPreviewAvailable = sourceOriginalPreviewModel.canPreviewOriginalFile( selectedSource, selectedSourceFile, ); - const [mountedOriginalPreviewKey, setMountedOriginalPreviewKey] = useState< - string | null - >(null); const [chunkDisplayModeState, setChunkDisplayModeState] = useState(() => ({ handledCitationListViewRequestId: citationListViewRequestId, @@ -112,11 +114,8 @@ export function ChunksPanel({ const { activeFocusedChunkId, handleChunkSelected: selectChunk, - handleOriginalViewSelected: selectOriginalView, - handleParsedViewSelected, handleViewportScroll, hasOriginalFile, - hasOriginalView, measureVirtualChunkElement, originalTargetPageNumber, originalTargetPageRequestId, @@ -143,23 +142,12 @@ export function ChunksPanel({ file: selectedSourceFile, }); - const rememberOriginalPreview = useCallback((): void => { - if (originalPreviewCacheKey) { - setMountedOriginalPreviewKey(originalPreviewCacheKey); - } - }, [originalPreviewCacheKey]); - const handleChunkSelected = useCallback( (chunk: ParsedChunkView): void => { - rememberOriginalPreview(); selectChunk(chunk); }, - [rememberOriginalPreview, selectChunk], + [selectChunk], ); - const handleOriginalViewSelected = useCallback((): void => { - rememberOriginalPreview(); - selectOriginalView(); - }, [rememberOriginalPreview, selectOriginalView]); const handleListModeSelected = useCallback((): void => { setChunkDisplayModeState({ handledCitationListViewRequestId: citationListViewRequestId, @@ -239,10 +227,7 @@ export function ChunksPanel({ ? "list" : chunkDisplayModeState.mode; const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks"; - const shouldMountOriginalPreview = - visibleView === "original" || - (originalPreviewCacheKey !== null && - mountedOriginalPreviewKey === originalPreviewCacheKey); + const shouldMountOriginalPreview = visibleView === "original"; const isTreeModeVisible = visibleView === "parsed" && chunkDisplayMode === "tree"; const headerSubtitle = visibleView === "original" ? ( @@ -305,44 +290,54 @@ export function ChunksPanel({
{visibleView === "parsed" && chunks.length > 0 ? ( -
- - + ) : null} +
- Tree - -
+ + +
+ ) : null} - {hasOriginalView ? ( -
- - -
+ {onClose ? ( + ) : null}
@@ -528,14 +523,33 @@ function ChunkSectionTree({ useState(initialSectionTreePan); const [sectionTreeDragState, setSectionTreeDragState] = useState(null); + const [expandedNodeIds, setExpandedNodeIds] = useState>( + () => new Set(), + ); const sectionTreeZoomSurfaceRef = useRef(null); const sectionTree = useMemo( () => chunksPanelState.buildSectionTree(chunks, sourceTitle), [chunks, sourceTitle], ); + const isNodeExpanded = useCallback( + (node: RenderableChunkTreeNode): boolean => + node.kind === "root" || expandedNodeIds.has(node.id), + [expandedNodeIds], + ); + const handleNodeToggle = useCallback((nodeId: string): void => { + setExpandedNodeIds((current) => { + const next = new Set(current); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); const layout = useMemo( - () => getChunkSectionTreeLayout(sectionTree), - [sectionTree], + () => getChunkSectionTreeLayout(sectionTree, isNodeExpanded), + [sectionTree, isNodeExpanded], ); const scaledLayoutWidth: number = Math.round( (layout.width * zoomPercent) / 100, @@ -566,6 +580,11 @@ function ChunkSectionTree({ if (!zoomSurface) return; const handleWheelZoom = (event: WheelEvent): void => { + // Only zoom on Ctrl/Cmd+wheel (trackpad pinch sends ctrl+wheel). Plain + // wheel events pass through to the enclosing ScrollArea so expanding a + // section past the pane height scrolls normally instead of zooming. + if (!event.ctrlKey && !event.metaKey) return; + if (event.deltaY === 0) return; event.preventDefault(); @@ -672,6 +691,8 @@ function ChunkSectionTree({ xOffset={layout.xOffset} yOffset={layout.yOffset} onChunkFocus={onChunkFocus} + onNodeToggle={handleNodeToggle} + isExpanded={isNodeExpanded(node.data)} /> ))} @@ -766,12 +787,16 @@ function SectionTreeItem({ xOffset, yOffset, onChunkFocus, + onNodeToggle, + isExpanded, }: { readonly focusedChunkId: string | null; readonly node: HierarchyPointNode; readonly xOffset: number; readonly yOffset: number; readonly onChunkFocus: (chunkId: string | null) => void; + readonly onNodeToggle: (nodeId: string) => void; + readonly isExpanded: boolean; }): ReactNode { const itemStyle: CSSProperties = { left: node.y + yOffset, @@ -781,11 +806,15 @@ function SectionTreeItem({ }; const isFocusedChunk = node.data.kind === "chunk" && node.data.chunk?.chunkId === focusedChunkId; + const hasChildren = node.data.children.length > 0; + const isToggleable = + node.data.kind === "section" && hasChildren; return (
onNodeToggle(node.data.id) : undefined + } > - + + {isToggleable ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} {node.data.label} @@ -839,11 +879,15 @@ function isInteractiveSectionTreeTarget(target: EventTarget): boolean { function getChunkSectionTreeLayout( sectionTree: ChunkSectionTreeNode, + isExpanded: (node: RenderableChunkTreeNode) => boolean, ): ChunkSectionTreeLayout { const renderableTree = toRenderableChunkTreeNode(sectionTree); const root = hierarchy( renderableTree, - (node) => (node.children.length > 0 ? [...node.children] : undefined), + (node) => + node.children.length > 0 && isExpanded(node) + ? [...node.children] + : undefined, ); const positionedRoot = createD3Tree() .nodeSize([sectionTreeRowGap, sectionTreeColumnGap])(root); diff --git a/src/components/collapsible-section.tsx b/src/components/collapsible-section.tsx new file mode 100644 index 0000000..4ca5823 --- /dev/null +++ b/src/components/collapsible-section.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { type ReactElement, type ReactNode } from "react"; +import { ChevronRight } from "lucide-react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +type CollapsibleSectionProps = { + readonly title: string; + readonly icon?: ReactNode; + readonly badge?: number; + readonly defaultOpen?: boolean; + readonly children: ReactNode; +}; + +export function CollapsibleSection({ + title, + icon, + badge, + defaultOpen = false, + children, +}: CollapsibleSectionProps): ReactElement { + return ( +
+ + + + {icon} + {title} + {typeof badge === "number" && badge > 0 && ( + + {badge} + + )} + + {children} + +
+ ); +} diff --git a/src/components/mobile-tab-bar.tsx b/src/components/mobile-tab-bar.tsx index 485d292..079b6fa 100644 --- a/src/components/mobile-tab-bar.tsx +++ b/src/components/mobile-tab-bar.tsx @@ -2,7 +2,6 @@ import { Files, - Layers, MessageCircle, } from "lucide-react"; import type { PanelId } from "@/components/workspace-shell"; @@ -19,7 +18,6 @@ export function MobileTabBar({ activePanel, onPanelChange, sourceCount, - chunkCount, hasMessages, }: MobileTabBarProps) { return ( @@ -36,14 +34,6 @@ export function MobileTabBar({ isActive={activePanel === "sources"} onClick={() => onPanelChange("sources")} /> - 0 ? String(chunkCount) : undefined} - isActive={activePanel === "content"} - onClick={() => onPanelChange("content")} - /> { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - it("uses the dashboard PDF icon for file cards", () => { - const { container } = render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onOfficialLibrarySourceAdd: vi.fn(), - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - const pdfIcon = container.querySelector( - '[data-testid="official-library-pdf-icon"] img', - ); - - expect(pdfIcon?.getAttribute("src")).toBe( - "/icons/official-library/pdf-document.svg", - ); - expect(screen.getByText("spacex-s1.pdf")).toBeTruthy(); - }); - - it("renders the header back button", () => { - const onBack = vi.fn(); - const { container } = render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onBack, - }), - ); - - const backButton = screen.getByRole("button", { name: "Back to sources" }); - const backIcon = container.querySelector( - '[data-testid="official-library-back-icon"]', - ); - - expect(backIcon?.className.baseVal).toContain("lucide-rotate-ccw"); - fireEvent.click(backButton); - expect(onBack).toHaveBeenCalledOnce(); - }); - - it("opens library documents as browser PDF previews", () => { - render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - const previewLink = screen.getByRole("link", { - name: "Open spacex-s1.pdf PDF preview", - }); - - expect(previewLink.getAttribute("href")).toBe( - "https://example.com/spacex-s1.pdf", - ); - expect(previewLink.getAttribute("target")).toBe("_blank"); - expect(previewLink.getAttribute("rel")).toBe("noopener noreferrer"); - }); - - it("keeps file add actions visible on mobile", () => { - render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onOfficialLibrarySourceAdd: vi.fn(), - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - const addButton = screen.getByRole("button", { - name: "Add spacex-s1.pdf to sources", - }); - - expect(addButton.className).toContain("opacity-100"); - expect(addButton.className).toContain("min-[1116px]:opacity-0"); - }); - - it("marks already added library documents and removes duplicate add actions", () => { - const onOfficialLibrarySourceAdd = vi.fn(); - - render( - React.createElement(OfficialLibraryPanel, { - sources: [ - { - id: "source_spacex", - kind: "workspace", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - status: "ready", - mimeType: "application/pdf", - documentId: "doc_user_copy", - }, - ], - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - onOfficialLibrarySourceAdd, - }), - ); - - fireEvent.click( - screen.getByRole("button", { name: "Open Financial Reports" }), - ); - - expect(screen.getByLabelText("spacex-s1.pdf already added")).toBeTruthy(); - expect(screen.getByText("Added")).toBeTruthy(); - expect( - screen.queryByRole("button", { name: "Add spacex-s1.pdf to sources" }), - ).toBeNull(); - expect(onOfficialLibrarySourceAdd).not.toHaveBeenCalled(); - }); - - it("opens to the all-categories view", () => { - render( - React.createElement(OfficialLibraryPanel, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - { - librarySourceId: "research-transformers", - categoryId: "research-papers", - categoryLabel: "Research Papers", - title: "transformers.pdf", - sourceUrl: "https://example.com/transformers.pdf", - mimeType: "application/pdf", - status: "planned", - }, - { - librarySourceId: "stem-calculus", - categoryId: "stem-books", - categoryLabel: "STEM Books", - title: "calculus.pdf", - sourceUrl: "https://example.com/calculus.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-calculus", - }, - { - librarySourceId: "other-contract", - categoryId: "other-docs", - categoryLabel: "Other Docs", - title: "contract.pdf", - sourceUrl: "https://example.com/contract.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-contract", - }, - ], - }), - ); - - expect( - screen - .getByRole("button", { name: "Open Financial Reports" }) - .getAttribute("style"), - ).toContain("/images/official-library/financial-reports.svg"); - expect( - screen - .getByRole("button", { name: "Open Research Papers" }) - .getAttribute("style"), - ).toContain("/images/official-library/research-papers.svg"); - expect( - screen - .getByRole("button", { name: "Open STEM Books" }) - .getAttribute("style"), - ).toContain("/images/official-library/stem-books.svg"); - expect( - screen - .getByRole("button", { name: "Open Other Docs" }) - .getAttribute("style"), - ).toContain("/images/official-library/other-docs.svg"); - }); -}); diff --git a/src/components/official-library-panel.tsx b/src/components/official-library-panel.tsx deleted file mode 100644 index 86dee08..0000000 --- a/src/components/official-library-panel.tsx +++ /dev/null @@ -1,455 +0,0 @@ -"use client"; - -import { type CSSProperties, type ReactElement, useMemo, useState } from "react"; -import { Check, ChevronRight, FileText, Plus, RotateCcw } from "lucide-react"; -import Image from "next/image"; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { - OfficialLibrarySourceView, - SourceView, -} from "@/domains/sources/types"; - -type OfficialLibraryPanelProps = { - readonly addingLibrarySourceIds?: readonly string[]; - readonly officialLibrarySources?: readonly OfficialLibrarySourceView[]; - readonly sources?: readonly SourceView[]; - readonly onBack?: () => void; - readonly onOfficialLibrarySourceAdd?: (demoSourceId: string) => void; -}; - -type LibraryItem = { - readonly categoryId: string; - readonly categoryLabel: string; - readonly chunkCount?: number; - readonly demoSourceId?: string; - readonly librarySourceId: string; - readonly mimeType: string; - readonly isAdded: boolean; - readonly sourceUrl: string; - readonly status: "ready" | "planned"; - readonly title: string; -}; - -type LibraryCategory = { - readonly backgroundImagePath: string; - readonly categoryId: string; - readonly categoryLabel: string; - readonly itemCount: number; - readonly readyCount: number; -}; - -const officialLibraryAssetPaths = { - categoryBackgrounds: { - financialReports: "/images/official-library/financial-reports.svg", - otherDocs: "/images/official-library/other-docs.svg", - researchPapers: "/images/official-library/research-papers.svg", - stemBooks: "/images/official-library/stem-books.svg", - }, - pdfDocumentIcon: "/icons/official-library/pdf-document.svg", -} as const; - -export function OfficialLibraryPanel({ - addingLibrarySourceIds = [], - officialLibrarySources = [], - sources = [], - onBack, - onOfficialLibrarySourceAdd, -}: OfficialLibraryPanelProps): ReactElement { - const libraryItems = useMemo( - () => getLibraryItems(sources, officialLibrarySources), - [officialLibrarySources, sources], - ); - const categories = useMemo( - () => getLibraryCategories(libraryItems), - [libraryItems], - ); - const [selectedCategoryId, setSelectedCategoryId] = useState( - null, - ); - const resolvedCategoryId = - selectedCategoryId !== null && - categories.some((category) => category.categoryId === selectedCategoryId) - ? selectedCategoryId - : null; - const selectedCategory = categories.find( - (category) => category.categoryId === resolvedCategoryId, - ); - const visibleItems = resolvedCategoryId - ? libraryItems.filter((item) => item.categoryId === resolvedCategoryId) - : libraryItems; - const addingLibrarySourceIdSet = new Set(addingLibrarySourceIds); - - return ( -
-
- -

- Library -

-
- - -
-
- - {selectedCategory ? ( - <> - - - {selectedCategory.categoryLabel} - - - ) : null} -
- - {libraryItems.length === 0 ? ( - - ) : resolvedCategoryId === null ? ( - - ) : ( -
- {visibleItems.map((item) => ( - onOfficialLibrarySourceAdd(item.demoSourceId!) - : undefined - } - /> - ))} -
- )} -
-
-
- ); -} - -function OfficialLibraryCategoryGrid({ - categories, - onCategorySelect, -}: { - readonly categories: readonly LibraryCategory[]; - readonly onCategorySelect: (categoryId: string) => void; -}): ReactElement { - return ( -
- {categories.map((category) => ( - - ))} -
- ); -} - -function OfficialLibraryCard({ - isAdding, - item, - onAdd, -}: { - readonly isAdding: boolean; - readonly item: LibraryItem; - readonly onAdd?: () => void; -}): ReactElement { - const canAdd = item.status === "ready" && Boolean(onAdd) && !item.isAdded; - - return ( -
- ); -} - -function PdfFileIcon(): ReactElement { - return ( -
- -
- ); -} - -function EmptyLibraryState(): ReactElement { - return ( -
-
- -
-

- No library files yet. -

-
- ); -} - -function getLibraryItems( - sources: readonly SourceView[], - officialLibrarySources: readonly OfficialLibrarySourceView[], -): LibraryItem[] { - const addedDemoSourceIdSet = new Set( - sources - .filter((source) => source.kind !== "demo") - .flatMap((source) => (source.demoSourceId ? [source.demoSourceId] : [])), - ); - const metadataByLibrarySourceId = new Map( - officialLibrarySources.map((source) => [source.librarySourceId, source]), - ); - const itemByLibrarySourceId = new Map(); - - for (const source of officialLibrarySources) { - itemByLibrarySourceId.set(source.librarySourceId, { - categoryId: source.categoryId, - categoryLabel: source.categoryLabel, - chunkCount: source.chunkCount, - demoSourceId: source.demoSourceId, - isAdded: - source.demoSourceId !== undefined && - addedDemoSourceIdSet.has(source.demoSourceId), - librarySourceId: source.librarySourceId, - mimeType: source.mimeType, - sourceUrl: source.sourceUrl, - status: source.status, - title: source.title, - }); - } - - for (const source of sources) { - if (!source.officialLibrary) continue; - - const metadata = metadataByLibrarySourceId.get( - source.officialLibrary.librarySourceId, - ); - itemByLibrarySourceId.set(source.officialLibrary.librarySourceId, { - categoryId: source.officialLibrary.categoryId, - categoryLabel: - metadata?.categoryLabel ?? - getCategoryLabel(source.officialLibrary.categoryId), - chunkCount: source.chunkCount ?? metadata?.chunkCount, - demoSourceId: source.demoSourceId ?? metadata?.demoSourceId, - isAdded: - (source.demoSourceId !== undefined && - addedDemoSourceIdSet.has(source.demoSourceId)) || - (metadata?.demoSourceId !== undefined && - addedDemoSourceIdSet.has(metadata.demoSourceId)), - librarySourceId: source.officialLibrary.librarySourceId, - mimeType: source.mimeType, - sourceUrl: source.officialLibrary.sourceUrl, - status: "ready", - title: source.title, - }); - } - - return Array.from(itemByLibrarySourceId.values()).sort((left, right) => { - if (left.categoryLabel !== right.categoryLabel) { - return left.categoryLabel.localeCompare(right.categoryLabel); - } - if (left.status !== right.status) return left.status === "ready" ? -1 : 1; - return left.title.localeCompare(right.title); - }); -} - -function getLibraryCategories( - items: readonly LibraryItem[], -): readonly LibraryCategory[] { - const categoryById = new Map< - string, - { - readonly categoryLabel: string; - itemCount: number; - readyCount: number; - } - >(); - for (const item of items) { - const currentCategory = categoryById.get(item.categoryId); - if (currentCategory) { - currentCategory.itemCount += 1; - if (item.status === "ready") currentCategory.readyCount += 1; - continue; - } - - categoryById.set(item.categoryId, { - categoryLabel: item.categoryLabel, - itemCount: 1, - readyCount: item.status === "ready" ? 1 : 0, - }); - } - - return Array.from(categoryById.entries()) - .map(([categoryId, category]) => ({ - backgroundImagePath: getCategoryBackgroundImagePath(categoryId), - categoryId, - categoryLabel: category.categoryLabel, - itemCount: category.itemCount, - readyCount: category.readyCount, - })) - .sort((left, right) => { - const orderDiff = - getCategorySortOrder(left.categoryId) - - getCategorySortOrder(right.categoryId); - if (orderDiff !== 0) return orderDiff; - - return left.categoryLabel.localeCompare(right.categoryLabel); - }); -} - -function getCategoryLabel(categoryId: string): string { - return categoryId - .split(/[-_]+/u) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function getLibraryMetadata(item: LibraryItem): string { - if (item.status !== "ready") return "Preparing"; - if (item.chunkCount !== undefined) return `${item.chunkCount} chunks`; - - return item.mimeType.includes("pdf") ? "PDF" : item.mimeType; -} - -function getCategoryBackgroundImagePath(categoryId: string): string { - const normalizedCategoryId = categoryId.toLowerCase(); - if (normalizedCategoryId.includes("financial")) { - return officialLibraryAssetPaths.categoryBackgrounds.financialReports; - } - if (normalizedCategoryId.includes("research")) { - return officialLibraryAssetPaths.categoryBackgrounds.researchPapers; - } - if (normalizedCategoryId.includes("stem")) { - return officialLibraryAssetPaths.categoryBackgrounds.stemBooks; - } - - return officialLibraryAssetPaths.categoryBackgrounds.otherDocs; -} - -function getCategoryCardBackgroundStyle( - backgroundImagePath: string, -): CSSProperties { - return { - backgroundImage: - `linear-gradient(180deg, rgba(10, 10, 12, 0.08) 0%, rgba(10, 10, 12, 0.76) 100%), url("${backgroundImagePath}")`, - }; -} - -function getCategorySortOrder(categoryId: string): number { - const normalizedCategoryId = categoryId.toLowerCase(); - if (normalizedCategoryId.includes("financial")) return 0; - if (normalizedCategoryId.includes("research")) return 1; - if (normalizedCategoryId.includes("stem")) return 2; - return 3; -} - -function getCategoryStatusLabel(category: LibraryCategory): string { - if (category.readyCount === category.itemCount) { - return `${category.itemCount} ready`; - } - - return `${category.readyCount}/${category.itemCount} ready`; -} diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts index 26e413f..567d9f2 100644 --- a/src/components/source-row.test.ts +++ b/src/components/source-row.test.ts @@ -168,12 +168,13 @@ describe("SourceRow", () => { ).toBeNull(); }); - it("links ready sources to the document chunk tree route", () => { + it("opens chunks overlay for ready sources via tree button", () => { const onSelect = vi.fn(); + const onTreeClick = vi.fn(); render( React.createElement(SourceRow, { - chunkTreeHref: "/inspect/doc_123/chunks", + onTreeClick, isArchiving: false, isSelected: false, onSelect, @@ -187,20 +188,19 @@ describe("SourceRow", () => { }), ); - const chunkTreeLink = screen.getByRole("link", { + const treeButton = screen.getByRole("button", { name: "Open lecture.pdf chunk tree link", }); - expect((chunkTreeLink as HTMLAnchorElement).getAttribute("href")).toBe( - "/inspect/doc_123/chunks", - ); + fireEvent.click(treeButton); + expect(onTreeClick).toHaveBeenCalledTimes(1); expect(onSelect).not.toHaveBeenCalled(); }); - it("does not link non-ready sources to the document chunk tree route", () => { + it("does not show tree button for non-ready sources", () => { render( React.createElement(SourceRow, { - chunkTreeHref: "/inspect/doc_123/chunks", + onTreeClick: vi.fn(), isArchiving: false, isSelected: false, onSelect: vi.fn(), @@ -215,7 +215,7 @@ describe("SourceRow", () => { ); expect( - screen.queryByRole("link", { + screen.queryByRole("button", { name: "Open lecture.pdf chunk tree link", }), ).toBeNull(); diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index abfe8cb..42727dc 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -1,21 +1,18 @@ "use client"; import type { ReactElement } from "react"; -import Link from "next/link"; -import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; +import { FileText, ListTree, RotateCcw, Trash2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; import type { SourceView } from "@/domains/sources/types"; export type SourceRowProps = { - readonly chunkTreeHref?: string; + readonly onTreeClick?: () => void; readonly isArchiving: boolean; - readonly isAdding?: boolean; readonly isNarrow?: boolean; readonly isRetrying?: boolean; readonly isSelected: boolean; - readonly onAddClick?: (sourceId: string) => void; readonly onArchiveClick?: (sourceId: string) => void; readonly onRetryClick?: (sourceId: string) => void; readonly onSelect: () => void; @@ -26,14 +23,12 @@ export type SourceRowProps = { export function SourceRow({ source, isSelected, - isAdding = false, isNarrow = false, - onAddClick, onSelect, onToggleIncluded, onArchiveClick, onRetryClick, - chunkTreeHref, + onTreeClick, isArchiving, isRetrying = false, }: SourceRowProps): ReactElement { @@ -41,7 +36,6 @@ export function SourceRow({ const isBusy = source.status === "uploading" || source.status === "parsing"; const isFailed = source.status === "failed"; const canRetry = isFailed && source.originalFile !== undefined; - const isLibrarySource = source.officialLibrary !== undefined; const isRemoteSource = source.kind === "remote"; const iconBg = fileIconTint(source.title); @@ -63,7 +57,7 @@ export function SourceRow({ > onToggleIncluded?.(source.id, checked === true) } @@ -117,36 +111,17 @@ export function SourceRow({
- {chunkTreeHref && isReady ? ( - - - ) : null} - {isLibrarySource && onAddClick && ( - - )} + ) : null} {canRetry && onRetryClick ? ( - ) : isNarrow ? ( + {isNarrow ? ( (
+ {!isNarrow ? ( +
+ +
+ ) : null}

Sources

- {hasLibrarySources && !isNarrow ? ( - - ) : null}
- {workspaceSources.length === 0 ? ( + {!activeWorkspace ? ( + 0} + userName={userName} + /> + ) : workspaceSources.length === 0 ? ( ) : (
@@ -233,7 +237,11 @@ export function SourcesPanel({ onOpenChunksOverlay(source.id) + : undefined + } isSelected={source.id === selectedSourceId} onSelect={() => onSelectSource?.( @@ -301,6 +309,32 @@ function EmptySourcesState(): ReactElement { ); } +function EmptySetupState({ + hasApiKeys, + userName, +}: { + readonly hasApiKeys: boolean; + readonly userName?: string; +}): ReactElement { + return ( +
+
+ +
+

+ {hasApiKeys + ? "Pick a namespace to get started" + : `${userName ? `${userName}, a` : "A"}dd an API key to get started`} +

+

+ {hasApiKeys + ? "Choose a namespace from the dropdown above to open its documents." + : "Your API key connects a Knowhere domain. A default workspace is created automatically."} +

+
+ ); +} + type SourcePagination = { readonly end: number; readonly page: number; @@ -350,12 +384,6 @@ function getSelectedSourcePage( return selectedIndex >= 0 ? getSourcePageForIndex(selectedIndex) : null; } -function getChunkTreeHref(source: SourceView): string | undefined { - return source.documentId - ? `/inspect/${encodeURIComponent(source.documentId)}/chunks` - : undefined; -} - function SourcePaginationControls({ end, isNarrow, diff --git a/src/components/top-nav.test.ts b/src/components/top-nav.test.ts index e07c634..47171c0 100644 --- a/src/components/top-nav.test.ts +++ b/src/components/top-nav.test.ts @@ -1,15 +1,10 @@ // @vitest-environment jsdom import { cleanup, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" import { createElement } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -const mocks = vi.hoisted(() => ({ - trackNotebookDashboardLinkClicked: vi.fn(), -})) - -vi.mock("@/lib/posthog", () => ({ - trackNotebookDashboardLinkClicked: mocks.trackNotebookDashboardLinkClicked, +vi.mock("@/app/auth/logout/actions", () => ({ + logoutAction: vi.fn(), })) import { ThemeProvider } from "@/components/theme-provider" @@ -35,10 +30,10 @@ describe("TopNav", () => { vi.unstubAllGlobals() }) - it("links to the configured Dashboard origin", async () => { - const user = userEvent.setup() + it("shows the user name and a sign-out button when a user is present", async () => { const topNavProps: TopNavProps = { - dashboardUrl: "https://dashboard.example.test", + userInitials: "GD", + userName: "Gordon", } render( @@ -49,23 +44,19 @@ describe("TopNav", () => { ), ) - const link = screen.getByRole("link", { name: "Open Dashboard" }) + expect(screen.getByText("Gordon")).toBeTruthy() + expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy() + }) - expect(link.getAttribute("href")).toBe("https://dashboard.example.test") - await user.click(link) - expect(mocks.trackNotebookDashboardLinkClicked).toHaveBeenCalledWith( - { - context: undefined, - targetUrl: "https://dashboard.example.test", - fromPage: "/", - hasSources: false, - hasChats: false, - }, + it("does not show sign-out when no user is present", () => { + render( + createElement( + ThemeProvider, + { attribute: "class" }, + createElement(TopNav, {}), + ), ) - await user.click(screen.getByRole("button", { name: "Toggle theme" })) - expect(screen.getByRole("menuitem", { name: "Light" })).toBeTruthy() - expect(screen.getByRole("menuitem", { name: "Dark" })).toBeTruthy() - expect(screen.getByRole("menuitem", { name: "System" })).toBeTruthy() + expect(screen.queryByRole("button", { name: "Sign out" })).toBeNull() }) }) diff --git a/src/components/top-nav.tsx b/src/components/top-nav.tsx index 5c32df8..9d43c2f 100644 --- a/src/components/top-nav.tsx +++ b/src/components/top-nav.tsx @@ -1,18 +1,11 @@ import { NotebookLogoMark } from "@/components/notebook-logo-mark"; import { Separator } from "@/components/ui/separator"; import { ThemeToggle } from "@/components/theme-toggle"; -import { - trackNotebookDashboardLinkClicked, - type AnalyticsContext, -} from "@/lib/posthog"; -import { ExternalLink } from "lucide-react"; +import { LogOut } from "lucide-react"; import type { ReactElement } from "react"; +import { logoutAction } from "@/app/auth/logout/actions"; export type TopNavProps = { - dashboardUrl?: string | null; - analyticsContext?: AnalyticsContext; - hasChats?: boolean; - hasSources?: boolean; userInitials?: string; userName?: string; userTierLabel?: string; @@ -20,10 +13,6 @@ export type TopNavProps = { }; export function TopNav({ - dashboardUrl, - analyticsContext, - hasChats = false, - hasSources = false, userInitials, userName, userTierLabel, @@ -45,28 +34,6 @@ export function TopNav({
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..488fb33 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" + +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return +} + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ) +} + +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +
-
- {props.contentView === "library" ? ( - - ) : ( - - )} -
{ - props.onMobilePanelChange("content") - props.onCitationClick(citation, citationId) - }} + onCitationClick={props.onCitationClick} />
@@ -466,6 +347,30 @@ export function WorkspaceShellLayout( hasMessages={props.hasMessages} /> + {props.isChunksOverlayVisible ? ( +
+ +
+ ) : null} + {props.chat.error && (
{props.chat.error} diff --git a/src/components/workspace-shell-state.test.ts b/src/components/workspace-shell-state.test.ts index 071533a..68c6a13 100644 --- a/src/components/workspace-shell-state.test.ts +++ b/src/components/workspace-shell-state.test.ts @@ -7,17 +7,13 @@ describe("workspaceShellState", () => { const widths = workspaceShellState.fitDesktopPanelWidthsToContainer(1280); const totalWidth = widths.sources + - widths.chunks + widths.chat + - workspaceShellState.desktopPanelGutterWidth * 2; + workspaceShellState.desktopPanelGutterWidth; expect(totalWidth).toBeLessThanOrEqual(1280); expect(widths.sources).toBeGreaterThanOrEqual( workspaceShellState.minimumDesktopPanelWidths.sources, ); - expect(widths.chunks).toBeGreaterThanOrEqual( - workspaceShellState.minimumDesktopPanelWidths.chunks, - ); expect(widths.chat).toBeGreaterThanOrEqual( workspaceShellState.minimumDesktopPanelWidths.chat, ); @@ -27,22 +23,20 @@ describe("workspaceShellState", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { leftPanel: "sources", - rightPanel: "chunks", + rightPanel: "chat", deltaX: 120, leftWidth: 350, - rightWidth: 600, + rightWidth: 800, }, ); expect(resized).toEqual({ sources: 470, - chunks: 480, - chat: 420, + chat: 680, }); }); @@ -50,45 +44,20 @@ describe("workspaceShellState", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { leftPanel: "sources", - rightPanel: "chunks", + rightPanel: "chat", deltaX: -170, leftWidth: 350, - rightWidth: 600, + rightWidth: 800, }, ); expect(resized).toEqual({ sources: 180, - chunks: 770, - chat: 420, - }); - }); - - it("allows the chat panel to narrow continuously before sidebar mode", () => { - const resized = workspaceShellState.resizeDesktopPanelWidths( - { - sources: 350, - chunks: 720, - chat: 420, - }, - { - leftPanel: "chunks", - rightPanel: "chat", - deltaX: 240, - leftWidth: 650, - rightWidth: 420, - }, - ); - - expect(resized).toEqual({ - sources: 350, - chunks: 890, - chat: 180, + chat: 970, }); }); @@ -96,59 +65,53 @@ describe("workspaceShellState", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { leftPanel: "sources", - rightPanel: "chunks", - deltaX: -300, + rightPanel: "chat", + deltaX: -400, leftWidth: 350, - rightWidth: 600, + rightWidth: 800, }, ); - expect(resized).toEqual({ - sources: workspaceShellState.collapsedDesktopPanelWidth, - chunks: 950 - workspaceShellState.collapsedDesktopPanelWidth, - chat: 420, - }); + expect(resized.sources).toBe( + workspaceShellState.collapsedDesktopPanelWidth, + ); + expect(resized.sources + resized.chat).toBe(1150); }); it("clamps the chat panel at the compact sidebar width", () => { const resized = workspaceShellState.resizeDesktopPanelWidths( { sources: 350, - chunks: 720, - chat: 420, + chat: 800, }, { - leftPanel: "chunks", + leftPanel: "sources", rightPanel: "chat", - deltaX: 400, - leftWidth: 650, - rightWidth: 420, + deltaX: 1200, + leftWidth: 350, + rightWidth: 800, }, ); - expect(resized).toEqual({ - sources: 350, - chunks: 1_070 - workspaceShellState.collapsedDesktopPanelWidth, - chat: workspaceShellState.collapsedDesktopPanelWidth, - }); + expect(resized.chat).toBe( + workspaceShellState.collapsedDesktopPanelWidth, + ); + expect(resized.sources + resized.chat).toBe(1150); }); it("includes compact sidebars when calculating the minimum desktop width", () => { const minimumWidth = workspaceShellState.getMinimumDesktopPanelWidth({ sources: workspaceShellState.collapsedDesktopPanelWidth, - chunks: 900, chat: workspaceShellState.collapsedDesktopPanelWidth, }); expect(minimumWidth).toBe( workspaceShellState.collapsedDesktopPanelWidth * 2 + - workspaceShellState.minimumDesktopPanelWidths.chunks + - workspaceShellState.desktopPanelGutterWidth * 2, + workspaceShellState.desktopPanelGutterWidth, ); }); }); diff --git a/src/components/workspace-shell-state.ts b/src/components/workspace-shell-state.ts index dadcb6d..96e86b6 100644 --- a/src/components/workspace-shell-state.ts +++ b/src/components/workspace-shell-state.ts @@ -4,22 +4,20 @@ const desktopSidePanelCompactThreshold = 120 const minimumDesktopPanelWidths = { sources: collapsedDesktopPanelWidth, - chunks: 480, chat: collapsedDesktopPanelWidth, } as const const defaultDesktopPanelWidths = { sources: 350, - chunks: 720, - chat: 420, + chat: 800, } as const type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths type DesktopPanelWidths = Record -type DesktopSidePanelKey = Exclude +type DesktopSidePanelKey = DesktopPanelKey -const desktopPanelKeys = ["sources", "chunks", "chat"] as const +const desktopPanelKeys = ["sources", "chat"] as const type DesktopPanelResizeInput = { readonly leftPanel: DesktopPanelKey @@ -162,30 +160,17 @@ function expandDesktopPanelWidth( currentWidths: Readonly, panel: DesktopSidePanelKey, ): DesktopPanelWidths { - if (panel === "sources") { - const totalWidth = currentWidths.sources + currentWidths.chunks - const expandedWidth = getExpandedSidePanelWidth(panel, totalWidth) - - return { - ...currentWidths, - sources: expandedWidth, - chunks: Math.max( - minimumDesktopPanelWidths.chunks, - totalWidth - expandedWidth, - ), - } - } - - const totalWidth = currentWidths.chunks + currentWidths.chat + const totalWidth = currentWidths.sources + currentWidths.chat const expandedWidth = getExpandedSidePanelWidth(panel, totalWidth) + const otherPanel = panel === "sources" ? "chat" : "sources" return { ...currentWidths, - chunks: Math.max( - minimumDesktopPanelWidths.chunks, + [panel]: expandedWidth, + [otherPanel]: Math.max( + minimumDesktopPanelWidths[otherPanel], totalWidth - expandedWidth, ), - chat: expandedWidth, } } @@ -195,7 +180,9 @@ function getExpandedSidePanelWidth( ): number { const preferredWidth = defaultDesktopPanelWidths[panel] const minimumWidth = minimumDesktopPanelWidths[panel] - const maximumSideWidth = totalWidth - minimumDesktopPanelWidths.chunks + const otherMinimum = + minimumDesktopPanelWidths[panel === "sources" ? "chat" : "sources"] + const maximumSideWidth = totalWidth - otherMinimum if (maximumSideWidth >= preferredWidth) return preferredWidth if (maximumSideWidth >= minimumWidth) return maximumSideWidth @@ -205,16 +192,13 @@ function getExpandedSidePanelWidth( function getVisibleDesktopPanelKeys( currentWidths: Readonly, ): DesktopPanelKey[] { - return desktopPanelKeys.filter((panel) => { - if (panel === "chunks") return true - return currentWidths[panel] > 0 - }) + return desktopPanelKeys.filter((panel) => currentWidths[panel] > 0) } function getVisibleDesktopPanelGutterCount( currentWidths: Readonly, ): number { - return getVisibleDesktopPanelKeys(currentWidths).length - 1 + return Math.max(0, getVisibleDesktopPanelKeys(currentWidths).length - 1) } function getDefaultDesktopPanelWidths( @@ -240,15 +224,12 @@ function getDesktopPanelWidthsForVisibility( visibleWidths: Readonly, ): DesktopPanelWidths { return { - sources: - isDesktopPanelCollapsed(currentWidths, "sources") - ? collapsedDesktopPanelWidth - : visibleWidths.sources, - chunks: visibleWidths.chunks, - chat: - isDesktopPanelCollapsed(currentWidths, "chat") - ? collapsedDesktopPanelWidth - : visibleWidths.chat, + sources: isDesktopPanelCollapsed(currentWidths, "sources") + ? collapsedDesktopPanelWidth + : visibleWidths.sources, + chat: isDesktopPanelCollapsed(currentWidths, "chat") + ? collapsedDesktopPanelWidth + : visibleWidths.chat, } } @@ -256,10 +237,8 @@ function getDefaultDesktopPanelWidth( panel: DesktopPanelKey, currentWidths: Readonly, ): number { - if (panel === "sources" || panel === "chat") { - if (isDesktopPanelCollapsed(currentWidths, panel)) { - return collapsedDesktopPanelWidth - } + if (isDesktopPanelCollapsed(currentWidths, panel)) { + return collapsedDesktopPanelWidth } return defaultDesktopPanelWidths[panel] @@ -269,10 +248,8 @@ function getMinimumDesktopPanelWidthForPanel( panel: DesktopPanelKey, currentWidths: Readonly, ): number { - if (panel === "sources" || panel === "chat") { - if (isDesktopPanelCollapsed(currentWidths, panel)) { - return collapsedDesktopPanelWidth - } + if (isDesktopPanelCollapsed(currentWidths, panel)) { + return collapsedDesktopPanelWidth } return minimumDesktopPanelWidths[panel] diff --git a/src/components/workspace-shell.test.ts b/src/components/workspace-shell.test.ts index 651f4ba..0d512a7 100644 --- a/src/components/workspace-shell.test.ts +++ b/src/components/workspace-shell.test.ts @@ -25,8 +25,22 @@ vi.mock("@vercel/blob/client", () => ({ upload: mocks.uploadBlob, })); +vi.mock("next/navigation", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRouter: () => ({ refresh: vi.fn() }), + }; +}); + const C = WorkspaceShell as React.FC>; +const shellWorkspaceProps = { + workspace: { id: "workspace_1", namespace: "adobe" }, + workspaces: [{ id: "workspace_1", namespace: "adobe" }], + knowhereKeyLabels: [], +}; + describe("WorkspaceShell", () => { beforeEach(() => { globalThis.ResizeObserver = class ResizeObserver { @@ -44,48 +58,45 @@ describe("WorkspaceShell", () => { vi.restoreAllMocks(); }); + async function expandSources( + panel: ReturnType, + ): Promise { + const trigger = await panel.findByRole("button", { name: /^Sources/ }); + fireEvent.click(trigger); + } + it("keeps desktop panels horizontally scrollable at their minimum widths", () => { render(React.createElement(C, { sources: [] })); const layout = screen.getByTestId("desktop-panel-layout"); const panels = screen.getByTestId("desktop-resizable-panels"); const sourcesPanel = screen.getByTestId("desktop-sources-panel"); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); const minimumTotalWidth = DESKTOP_PANEL_MIN_WIDTHS.sources + - DESKTOP_PANEL_MIN_WIDTHS.chunks + DESKTOP_PANEL_MIN_WIDTHS.chat + - DESKTOP_PANEL_GUTTER_WIDTH * 2; + DESKTOP_PANEL_GUTTER_WIDTH; expect(layout.className).toContain("overflow-x-auto"); expect(panels.style.minWidth).toBe(`${minimumTotalWidth}px`); expect(sourcesPanel.style.width).toBe("350px"); - expect(chunksPanel.style.minWidth).toBe( - `${DESKTOP_PANEL_MIN_WIDTHS.chunks}px`, - ); }); it("lets desktop users resize neighboring panels and collapse sources below the threshold", () => { render(React.createElement(C, { sources: [] })); const firstHandle = screen.getByRole("separator", { - name: "Resize sources and parsed chunks", + name: "Resize sources and chat", }); const sourcesPanel = screen.getByTestId("desktop-sources-panel"); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); fireEvent.pointerDown(firstHandle, { clientX: 0 }); fireEvent.pointerMove(window, { clientX: 120 }); fireEvent.pointerUp(window); expect(sourcesPanel.style.width).toBe("470px"); - expect(chunksPanel.style.width).toBe("600px"); - const resizedHandle = screen.getByRole("separator", { - name: "Resize sources and parsed chunks", - }); - fireEvent.pointerDown(resizedHandle, { clientX: 120 }); + fireEvent.pointerDown(firstHandle, { clientX: 120 }); fireEvent.pointerMove(window, { clientX: -1000 }); fireEvent.pointerUp(window); @@ -97,125 +108,6 @@ describe("WorkspaceShell", () => { ).toBeTruthy(); }); - it("lets desktop users expand the chat panel by shrinking parsed chunks further", () => { - render(React.createElement(C, { sources: [] })); - - const secondHandle = screen.getByRole("separator", { - name: "Resize parsed chunks and chat", - }); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); - const chatPanel = screen.getByTestId("desktop-chat-panel"); - - fireEvent.pointerDown(secondHandle, { clientX: 0 }); - fireEvent.pointerMove(window, { clientX: -500 }); - fireEvent.pointerUp(window); - - expect(chunksPanel.style.width).toBe("480px"); - expect(chatPanel.style.width).toBe("660px"); - }); - - it("uses rendered panel widths when resizing the flex-grown middle panel", () => { - render(React.createElement(C, { sources: [] })); - - const secondHandle = screen.getByRole("separator", { - name: "Resize parsed chunks and chat", - }); - const chunksPanel = screen.getByTestId("desktop-chunks-panel"); - const chatPanel = screen.getByTestId("desktop-chat-panel"); - vi.spyOn(chunksPanel, "getBoundingClientRect").mockReturnValue( - createElementRect(1100), - ); - vi.spyOn(chatPanel, "getBoundingClientRect").mockReturnValue( - createElementRect(420), - ); - - fireEvent.pointerDown(secondHandle, { clientX: 0 }); - fireEvent.pointerMove(window, { clientX: -900 }); - fireEvent.pointerUp(window); - - expect(chunksPanel.style.width).toBe("480px"); - expect(chatPanel.style.width).toBe("1040px"); - }); - - it("shows a login CTA instead of the chat composer for guests", () => { - render( - React.createElement(C, { - isGuest: true, - loginUrl: "/login", - sources: [ - { - id: "source_1", - title: "demo.pdf", - status: "ready", - documentId: "doc_1", - }, - ], - }), - ); - - const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); - - expect( - desktopChatPanel.queryByPlaceholderText( - "Ask a question about your documents…", - ), - ).toBeNull(); - expect( - desktopChatPanel.getByRole("button", { name: "Log in to start" }), - ).toBeTruthy(); - }); - - it("lets guests open the Official Library from the sources panel", async () => { - const user = userEvent.setup(); - - render( - React.createElement(C, { - isGuest: true, - loginUrl: "/login", - sources: [], - officialLibrarySources: [ - { - librarySourceId: "stem-transformers", - categoryId: "stem-books", - categoryLabel: "STEM books", - title: "Transformers.pdf", - sourceUrl: "https://example.com/transformers.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-transformers", - }, - ], - }), - ); - - const desktopSourcesPanel = within( - screen.getByTestId("desktop-sources-panel"), - ); - await user.click( - desktopSourcesPanel.getByRole("button", { name: "Open library" }), - ); - - const desktopLibraryPanel = within( - within(screen.getByTestId("desktop-chunks-panel")).getByTestId( - "official-library-panel", - ), - ); - expect(desktopLibraryPanel.getByRole("heading", { name: "Library" })) - .toBeTruthy(); - expect( - desktopLibraryPanel.getByRole("button", { name: "Open STEM books" }), - ).toBeTruthy(); - await user.click( - desktopLibraryPanel.getByRole("button", { name: "Back to sources" }), - ); - expect( - within(screen.getByTestId("desktop-chunks-panel")).queryByTestId( - "official-library-panel", - ), - ).toBeNull(); - expect(window.location.href).not.toContain("/login"); - }); - it("shows the first ready document chunks on workspace load", async () => { const fetch = vi.fn(async (input) => { const url = getRequestURL(input); @@ -247,6 +139,8 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, + chunkViewDocumentId: "doc_1", sources: [ { id: "source_1", @@ -264,183 +158,20 @@ describe("WorkspaceShell", () => { }), ); - const desktopChunksPanel = within(screen.getByTestId("desktop-chunks-panel")); + // chunkViewDocumentId auto-opens the chunks overlay; expand to see the chunk. + await waitFor(() => { + expect(screen.getByText("Overview")).toBeTruthy(); + }); + fireEvent.click(screen.getByText("Overview")); await waitFor(() => { expect( - desktopChunksPanel.getByText("First document chunk content."), + screen.getByText("First document chunk content."), ).toBeTruthy(); }); expect(countFetches(fetch, "/api/sources/source_1/chunks")).toBe(1); expect(countFetches(fetch, "/api/sources/source_2/chunks")).toBe(0); }); - it("focuses guest citations on desktop using loaded demo chunks", async () => { - const fetch = vi.fn(async (input) => { - const url = getRequestURL(input); - - if (url.pathname === "/api/sources/demo-source/chunks") { - return Response.json({ - chunks: [ - { - chunkId: "demo-source:chunk_1", - documentId: "doc_1", - sectionPath: "Demo", - type: "text", - content: "Demo cited section", - sourceTitle: "demo.pdf", - }, - ], - pagination: { - page: Number(url.searchParams.get("page") ?? "1"), - pageSize: 100, - total: 1, - totalPages: 1, - }, - }); - } - - return Response.json({ message: "Unexpected request" }, { status: 404 }); - }); - vi.stubGlobal("fetch", fetch); - - render( - React.createElement(C, { - isGuest: true, - sources: [ - { - id: "demo-source", - title: "demo.pdf", - status: "ready", - documentId: "doc_1", - }, - ], - chatMessages: [ - { - id: "assistant_1", - role: "assistant", - content: "Demo answer.", - citations: [ - { - content: "Demo cited section", - description: "Demo citation", - chunkType: "text", - score: 0.91, - source: { - documentId: "doc_1", - sourceFileName: "demo.pdf", - sectionPath: "Demo", - }, - }, - ], - }, - ], - }), - ); - - const citationButton = await findStableConnectedElement(() => { - const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); - return desktopChatPanel.getByRole("button", { - name: "Open source demo.pdf", - }); - }); - fireEvent.click(citationButton); - - await waitFor(() => { - const topRow = screen - .getByTestId("desktop-chunks-panel") - .querySelector('[data-index="0"]'); - - expect(topRow?.getAttribute("data-chunk-id")).toBe("demo-source:chunk_1"); - expect(topRow?.getAttribute("data-focused-chunk")).toBe("true"); - }); - expect( - fetch.mock.calls.some(([input]) => - getRequestPath(input).startsWith("/demo-sources/"), - ), - ).toBe(false); - }); - - it("focuses guest citations from the mobile chat panel", async () => { - const fetch = vi.fn(async (input) => { - const url = getRequestURL(input); - - if (url.pathname === "/api/sources/demo-source/chunks") { - return Response.json({ - chunks: [ - { - chunkId: "demo-source:chunk_1", - documentId: "doc_1", - sectionPath: "Demo", - type: "text", - content: "Demo cited section", - sourceTitle: "demo.pdf", - }, - ], - pagination: { - page: Number(url.searchParams.get("page") ?? "1"), - pageSize: 100, - total: 1, - totalPages: 1, - }, - }); - } - - return Response.json({ message: "Unexpected request" }, { status: 404 }); - }); - vi.stubGlobal("fetch", fetch); - - render( - React.createElement(C, { - isGuest: true, - sources: [ - { - id: "demo-source", - title: "demo.pdf", - status: "ready", - documentId: "doc_1", - }, - ], - chatMessages: [ - { - id: "assistant_1", - role: "assistant", - content: "Demo answer.", - citations: [ - { - content: "Demo cited section", - description: "Demo citation", - chunkType: "text", - score: 0.91, - source: { - documentId: "doc_1", - sourceFileName: "demo.pdf", - sectionPath: "Demo", - }, - }, - ], - }, - ], - }), - ); - - const citationButton = await findStableConnectedElement(() => { - const mobileChatPanel = within(document.getElementById("panel-chat")!); - return mobileChatPanel.getByRole("button", { - name: "Open source demo.pdf", - }); - }); - fireEvent.click(citationButton); - - await waitFor(() => { - const topRow = document - .getElementById("panel-content")! - .querySelector('[data-index="0"]'); - - expect(topRow?.getAttribute("data-chunk-id")).toBe("demo-source:chunk_1"); - expect(topRow?.getAttribute("data-focused-chunk")).toBe("true"); - }); - }); - it("reuses loaded chunks when users click another citation from the same source", async () => { const fetch = vi.fn(async (input) => { const path = getRequestPath(input); @@ -510,6 +241,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -535,9 +267,7 @@ describe("WorkspaceShell", () => { }); await user.click(sendButton); - await desktopChatPanel.findAllByRole("button", { - name: "Open source doc.pdf", - }); + await expandSources(desktopChatPanel); const citationButtons = desktopChatPanel.getAllByRole( "button", { @@ -556,7 +286,7 @@ describe("WorkspaceShell", () => { }); await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_1"); @@ -571,7 +301,7 @@ describe("WorkspaceShell", () => { await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_2"); @@ -631,6 +361,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -653,13 +384,14 @@ describe("WorkspaceShell", () => { await user.type(input, "Where?"); await user.click(sendButton); + await expandSources(desktopChatPanel); const citation = await desktopChatPanel.findByRole("button", { name: "Open source doc.pdf", }); await user.click(citation); await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_1"); @@ -669,7 +401,7 @@ describe("WorkspaceShell", () => { await user.click(citation); await waitFor(() => { const topRow = screen - .getByTestId("desktop-chunks-panel") + .getByTestId("chunks-panel") .querySelector('[data-index="0"]'); expect(topRow?.getAttribute("data-chunk-id")).toBe("chunk_1"); @@ -737,6 +469,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -790,6 +523,7 @@ describe("WorkspaceShell", () => { }); const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); + await expandSources(desktopChatPanel); await user.click( desktopChatPanel.getByRole("button", { name: "Open source doc.pdf", @@ -806,6 +540,7 @@ describe("WorkspaceShell", () => { it("renders the most recent recovered chat on workspace load", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -844,6 +579,51 @@ describe("WorkspaceShell", () => { expect(desktopChatPanel.getByText("This is the recovered answer.")).toBeTruthy(); }); + it("resets chat state when the active workspace changes (no stale thread)", () => { + const first = render( + React.createElement(C, { + ...shellWorkspaceProps, + sources: [], + chatThreads: [ + { + id: "thread_1", + title: "Old workspace chat", + createdAt: "2026-05-06T00:00:00.000Z", + updatedAt: "2026-05-06T00:00:00.000Z", + }, + ], + activeChatThreadId: "thread_1", + chatMessages: [ + { + id: "message_1", + role: "user", + content: "Old workspace question", + }, + ], + }), + ); + + const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); + expect(desktopChatPanel.getByText("Old workspace question")).toBeTruthy(); + + // Simulate adding a new API key: a brand-new workspace is activated. + first.rerender( + React.createElement(C, { + workspace: { id: "workspace_2", namespace: "default" }, + workspaces: [{ id: "workspace_2", namespace: "default" }], + knowhereKeyLabels: [], + sources: [], + chatThreads: [], + activeChatThreadId: null, + chatMessages: [], + }), + ); + + const refreshedPanel = within(screen.getByTestId("desktop-chat-panel")); + expect(refreshedPanel.queryByText("Old workspace question")).toBeNull(); + expect(refreshedPanel.queryByText("This is the recovered answer.")).toBeNull(); + }); + it("loads an old chat when selected from history", async () => { const fetch = vi.fn(async (input) => { const path = getRequestPath(input); @@ -873,6 +653,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -969,7 +750,7 @@ describe("WorkspaceShell", () => { vi.stubGlobal("fetch", fetch); const user = userEvent.setup(); - render(React.createElement(C, { sources: [] })); + render(React.createElement(C, { sources: [], ...shellWorkspaceProps })); await user.click(screen.getAllByRole("button", { name: "Upload Document" })[0]!); const input = document.querySelector("input[type='file']"); @@ -1010,6 +791,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -1042,186 +824,6 @@ describe("WorkspaceShell", () => { expect(desktopSourcesPanel.queryByText("No sources yet.")).toBeNull(); }); - it("refreshes the active chat after adding an Official Library source", async () => { - const fetch = vi.fn(async (input, init) => { - const request = input instanceof Request - ? input - : new Request(new URL(String(input), "http://localhost").toString(), init); - const path = getRequestPath(request); - - if (path === "/api/demo-sources/materialize" && request.method === "POST") { - return Response.json({ - sources: [ - { - id: "source_spacex", - kind: "workspace", - title: "spacex-s1.pdf", - status: "ready", - mimeType: "application/pdf", - demoSourceId: "demo-spacex-s1", - documentId: "doc_user_copy", - chunkCount: 1, - }, - ], - }); - } - - if (path === "/api/chat/threads/thread_1") { - return Response.json({ - thread: { - id: "thread_1", - title: "Current chat", - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - }, - messages: [ - { - id: "assistant_refreshed", - role: "assistant", - content: "Refreshed materialized answer.", - citations: [ - { - content: "User-copy cited section", - chunkType: "text", - score: 0.91, - source: { - documentId: "doc_user_copy", - sourceFileName: "spacex-s1.pdf", - sectionPath: "Overview", - }, - }, - ], - }, - ], - }); - } - - if (path === "/api/sources/source_spacex/chunks") { - return Response.json({ - chunks: [ - { - chunkId: "source_spacex:chunk_1", - documentId: "doc_user_copy", - sectionPath: "Overview", - type: "text", - content: "User-copy cited section", - sourceTitle: "spacex-s1.pdf", - }, - ], - }); - } - - return Response.json({ message: "Unexpected request" }, { status: 404 }); - }); - vi.stubGlobal("fetch", fetch); - const user = userEvent.setup(); - - render( - React.createElement(C, { - officialLibrarySources: [ - { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - categoryLabel: "Financial Reports", - title: "spacex-s1.pdf", - sourceUrl: "https://example.com/spacex-s1.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-spacex-s1", - chunkCount: 922, - }, - ], - sources: [ - { - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - status: "ready", - mimeType: "application/pdf", - documentId: "demo-doc-spacex-s1", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }, - ], - chatThreads: [ - { - id: "thread_1", - title: "Current chat", - createdAt: "2026-05-07T00:00:00.000Z", - updatedAt: "2026-05-07T00:00:00.000Z", - }, - ], - activeChatThreadId: "thread_1", - chatMessages: [ - { - id: "assistant_seeded", - role: "assistant", - content: "Seeded canonical answer.", - citations: [ - { - content: "Canonical cited section", - chunkType: "text", - score: 0.91, - source: { - documentId: "demo-doc-spacex-s1", - sourceFileName: "spacex-s1.pdf", - sectionPath: "Overview", - }, - }, - ], - }, - ], - }), - ); - - const desktopSourcesPanel = within( - screen.getByTestId("desktop-sources-panel"), - ); - await user.click(desktopSourcesPanel.getByRole("button", { name: "Open library" })); - - const desktopLibraryPanel = within( - within(screen.getByTestId("desktop-chunks-panel")).getByTestId( - "official-library-panel", - ), - ); - expect(desktopLibraryPanel.getByRole("heading", { name: "Library" })) - .toBeTruthy(); - await user.click( - desktopLibraryPanel.getByRole("button", { - name: "Open Financial Reports", - }), - ); - await user.click( - desktopLibraryPanel.getByRole("button", { - name: "Add spacex-s1.pdf to sources", - }), - ); - - const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel")); - await desktopChatPanel.findByText("Refreshed materialized answer."); - expect(desktopChatPanel.queryByText("Seeded canonical answer.")).toBeNull(); - const refreshedLibraryPanel = within( - within(screen.getByTestId("desktop-chunks-panel")).getByTestId( - "official-library-panel", - ), - ); - expect( - refreshedLibraryPanel.getByRole("heading", { name: "Library" }), - ).toBeTruthy(); - expect(refreshedLibraryPanel.getByLabelText("spacex-s1.pdf already added")) - .toBeTruthy(); - expect( - refreshedLibraryPanel.queryByRole("button", { - name: "Add spacex-s1.pdf to sources", - }), - ).toBeNull(); - expect(countFetches(fetch, "/api/chat/threads/thread_1")).toBe(1); - }); - it("uses cached chat data when reopening a previously loaded thread", async () => { const fetch = vi.fn(async (input) => { const path = getRequestPath(input); @@ -1269,6 +871,7 @@ describe("WorkspaceShell", () => { render( React.createElement(C, { + ...shellWorkspaceProps, sources: [ { id: "source_1", @@ -1332,24 +935,6 @@ describe("WorkspaceShell", () => { }); }); -function findStableConnectedElement( - getElement: () => HTMLElement, -): Promise { - let previousElement: HTMLElement | null = null; - - return waitFor(() => { - const element = getElement(); - expect(element.isConnected).toBe(true); - - if (element !== previousElement) { - previousElement = element; - throw new Error("Element is still settling."); - } - - return element; - }); -} - function getRequestPath(input: RequestInfo | URL): string { return getRequestURL(input).pathname; } @@ -1383,20 +968,6 @@ function countFetchesWithSearch( }).length; } -function createElementRect(width: number): DOMRect { - return { - bottom: 0, - height: 0, - left: 0, - right: width, - top: 0, - width, - x: 0, - y: 0, - toJSON: () => ({}), - }; -} - function makeUploadedBlob(): { readonly url: string; readonly downloadUrl: string; diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 254c91e..df8d1f0 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -6,7 +6,6 @@ import { usePathname } from "next/navigation" import { SWRConfig } from "swr" import { WorkspaceShellLayout, - type ContentView, type PanelId, } from "@/components/workspace-shell-layout" import { useWorkspaceDesktopPanels } from "@/components/workspace-desktop-panels" @@ -27,10 +26,7 @@ import type { ChatThreadView, } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" -import type { - OfficialLibrarySourceView, - SourceView, -} from "@/domains/sources/types" +import type { SourceView } from "@/domains/sources/types" export type { PanelId } from "@/components/workspace-shell-layout" @@ -49,16 +45,22 @@ export type WorkspaceShellProps = { id: string namespace: string } + workspaces?: readonly { + id: string + namespace: string + }[] + knowhereKeyLabels?: readonly { + id: string + label: string + mask: string + }[] + isBlobConfigured?: boolean sources?: SourceView[] - officialLibrarySources?: OfficialLibrarySourceView[] chatThreads?: ChatThreadView[] activeChatThreadId?: string | null chatMessages?: ChatMessageView[] chunkViewDocumentId?: string | null - dashboardUrl?: string initialPrefetchedChunksBySourceId?: Record - isGuest?: boolean - loginUrl?: string } export function WorkspaceShell(props: WorkspaceShellProps): ReactElement { @@ -72,7 +74,10 @@ export function WorkspaceShell(props: WorkspaceShellProps): ReactElement { revalidateOnReconnect: false, }} > - + ) } @@ -80,35 +85,32 @@ export function WorkspaceShell(props: WorkspaceShellProps): ReactElement { function WorkspaceShellContent({ user, sources: initialSources, - officialLibrarySources, chatThreads: initialChatThreads, activeChatThreadId, chatMessages: initialChatMessages, chunkViewDocumentId, - dashboardUrl, workspace, + workspaces, + knowhereKeyLabels, + isBlobConfigured, initialPrefetchedChunksBySourceId, - isGuest = false, - loginUrl, }: WorkspaceShellProps): ReactElement { - const [mobilePanel, setMobilePanel] = useState( - isGuest ? "content" : "chat", + const [mobilePanel, setMobilePanel] = useState("chat") + const [isChunksOverlayVisible, setIsChunksOverlayVisible] = useState( + Boolean(chunkViewDocumentId), ) const pathname = usePathname() - const [contentView, setContentView] = useState("chunks") const sourceWorkflow = useWorkspaceSourceWorkflow({ initialSelectedDocumentId: chunkViewDocumentId ?? null, initialSources: initialSources ?? [], - isGuest, }) const analyticsContext = useMemo( () => ({ workspaceId: workspace?.id, workspaceNamespace: workspace?.namespace, userId: user?.id, - isGuest, }), - [isGuest, user?.id, workspace?.id, workspace?.namespace], + [user?.id, workspace?.id, workspace?.namespace], ) const citationFocus = useWorkspaceCitationFocus({ fetchChunks: workspaceClient.fetchChunks, @@ -123,8 +125,6 @@ function WorkspaceShellContent({ analyticsContext, initialChatMessages: initialChatMessages ?? [], initialChatThreads: initialChatThreads ?? [], - isGuest, - onSourcesMaterialized: sourceWorkflow.handleSourcesMaterialized, sources: sourceWorkflow.sources, }) const { @@ -138,38 +138,29 @@ function WorkspaceShellContent({ handleDesktopPanelResizeStart, } = useWorkspaceDesktopPanels() - function redirectToLogin(): void { - window.location.href = loginUrl ?? "/login" - } - const selectedSourceTitle = citationFocus.selectedSource?.title ?? null function handleCitationSourceSelected(sourceId: string | null): void { - setContentView("chunks") sourceWorkflow.setSelectedSourceId(sourceId) } function handleSourceSelected(sourceId: string | null): void { - setContentView("chunks") citationFocus.handleSourceSelected(sourceId) } - async function handleOfficialLibrarySourceAdd( - demoSourceId: string, - ): Promise { - const didMaterialize = - await sourceWorkflow.handleOfficialLibrarySourceAdd(demoSourceId) - if (didMaterialize) { - await chatWorkflow.handleRefreshActiveChatThread() + function handleOpenChunksOverlay(sourceId?: string): void { + if (sourceId) { + citationFocus.handleSourceSelected(sourceId) } + setIsChunksOverlayVisible(true) } - function handleLibraryOpen(): void { - setContentView("library") + function handleCloseChunksOverlay(): void { + setIsChunksOverlayVisible(false) } - function handleLibraryBack(): void { - setContentView("chunks") + function handleClearChunkFocus(): void { + citationFocus.requestChunkFocus(null) } const hasMessages = chatWorkflow.chat.messages.length > 0 @@ -184,7 +175,7 @@ function WorkspaceShellContent({ }, [analyticsContext]) useEffect(() => { - if (isGuest || !userId) { + if (!userId) { void resetUser() return } @@ -194,7 +185,7 @@ function WorkspaceShellContent({ email: userEmail, name: userName, }) - }, [isGuest, userEmail, userId, userName]) + }, [userEmail, userId, userName]) useEffect(() => { void trackPageView(analyticsContextRef.current) @@ -214,19 +205,20 @@ function WorkspaceShellContent({ { + setIsChunksOverlayVisible(true) + citationFocus.handleCitationClick(citation, citationId) + }} + onCloseChunksOverlay={handleCloseChunksOverlay} + onClearChunkFocus={handleClearChunkFocus} onCreateChatThread={chatWorkflow.handleCreateChatThread} onDesktopLayoutElementChange={handleDesktopLayoutElementChange} onDesktopPanelElementChange={handleDesktopPanelElementChange} @@ -258,13 +254,10 @@ function WorkspaceShellContent({ onDesktopPanelResizeStart={handleDesktopPanelResizeStart} onLoadAllChunks={citationFocus.handleLoadAllChunks} onLoadMoreChunks={citationFocus.handleLoadMoreChunks} - onLoginClick={redirectToLogin} - onLibraryBack={handleLibraryBack} - onLibraryOpen={handleLibraryOpen} + onOpenChunksOverlay={handleOpenChunksOverlay} onMobilePanelChange={setMobilePanel} onSelectChatThread={chatWorkflow.handleSelectChatThread} onSourceSelected={handleSourceSelected} - onOfficialLibrarySourceAdd={handleOfficialLibrarySourceAdd} onSourceUploaded={handleSourceUploaded} onToggleIncluded={sourceWorkflow.handleToggleIncluded} /> diff --git a/src/components/workspace-source-state.test.ts b/src/components/workspace-source-state.test.ts index f1069d5..ad56781 100644 --- a/src/components/workspace-source-state.test.ts +++ b/src/components/workspace-source-state.test.ts @@ -28,21 +28,15 @@ describe("workspaceSourceState", () => { ); }); - it("can select an unmaterialized Official Library row for preview", () => { + it("can select a workspace Source row for preview", () => { const sources: readonly SourceView[] = [ { - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", + id: "source_spacex", + kind: "workspace", title: "spacex-s1.pdf", status: "ready", mimeType: "application/pdf", excludedFromQuery: false, - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, }, { id: "source_ready", @@ -54,7 +48,7 @@ describe("workspaceSourceState", () => { ]; expect(workspaceSourceState.getInitialSelectedSourceId(sources)).toBe( - "demo-spacex-s1", + "source_spacex", ); }); diff --git a/src/components/workspace-source-workflow.test.ts b/src/components/workspace-source-workflow.test.ts index c0414f5..332e263 100644 --- a/src/components/workspace-source-workflow.test.ts +++ b/src/components/workspace-source-workflow.test.ts @@ -9,7 +9,6 @@ import type { SourceView } from "@/domains/sources/types" const mocks = vi.hoisted(() => ({ archiveSource: vi.fn(), fetchSources: vi.fn(), - materializeDemoSources: vi.fn(), retrySource: vi.fn(), })) @@ -17,13 +16,11 @@ vi.mock("@/domains/workspace/client", () => ({ workspaceClient: { keys: { archiveSource: "archive-source", - materializeDemoSources: "/api/demo-sources/materialize", retrySource: "retry-source", sources: "/api/sources", }, archiveSource: mocks.archiveSource, fetchSources: mocks.fetchSources, - materializeDemoSources: mocks.materializeDemoSources, retrySource: mocks.retrySource, }, })) @@ -45,7 +42,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources, - isGuest: true, }) act(() => { @@ -78,7 +74,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources: [initialSource], - isGuest: false, }) act(() => { @@ -118,7 +113,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources: [failedSource], - isGuest: false, }) let retryAction: Promise | undefined @@ -157,7 +151,6 @@ describe("useWorkspaceSourceWorkflow", () => { const { result } = renderWorkspaceSourceWorkflow({ initialSources: [parsingSource], - isGuest: false, }) await waitFor(() => { @@ -169,103 +162,10 @@ describe("useWorkspaceSourceWorkflow", () => { documentId: "document_1", }) }) - - it("materializes one Official Library source through the workflow", async () => { - const demoSource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }) - const materializedSource = makeSource({ - id: "source_spacex", - kind: "workspace", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - documentId: "doc_spacex", - }) - mocks.fetchSources.mockResolvedValue([demoSource]) - mocks.materializeDemoSources.mockResolvedValue([materializedSource]) - - const { result } = renderWorkspaceSourceWorkflow({ - initialSources: [demoSource], - isGuest: false, - }) - - await act(async () => { - await expect( - result.current.handleOfficialLibrarySourceAdd("demo-spacex-s1"), - ).resolves.toBe(true) - }) - - expect(mocks.materializeDemoSources).toHaveBeenCalledWith({ - demoSourceIds: ["demo-spacex-s1"], - }) - expect(result.current.sources.map((source) => source.id)).toEqual([ - "source_spacex", - ]) - expect(result.current.sources[0]).toMatchObject({ - demoSourceId: "demo-spacex-s1", - }) - expect(result.current.selectedSourceId).toBe("source_spacex") - }) - - it("does not count unmaterialized Official Library sources as chat-ready", () => { - const librarySource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - officialLibrary: { - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - sourceUrl: "https://example.com/spacex-s1.pdf", - }, - }) - - const { result } = renderWorkspaceSourceWorkflow({ - initialSources: [librarySource], - isGuest: false, - }) - - expect(result.current.readySourceCount).toBe(0) - }) - - it("reports failed Official Library materialization without changing sources", async () => { - const demoSource = makeSource({ - id: "demo-spacex-s1", - kind: "demo", - demoSourceId: "demo-spacex-s1", - title: "spacex-s1.pdf", - }) - mocks.fetchSources.mockResolvedValue([demoSource]) - mocks.materializeDemoSources.mockRejectedValue(new Error("Bad gateway")) - - const { result } = renderWorkspaceSourceWorkflow({ - initialSources: [demoSource], - isGuest: false, - }) - - await act(async () => { - await expect( - result.current.handleOfficialLibrarySourceAdd("demo-spacex-s1"), - ).resolves.toBe(false) - }) - - expect(result.current.sources.map((source) => source.id)).toEqual([ - "demo-spacex-s1", - ]) - }) }) function renderWorkspaceSourceWorkflow(input: { readonly initialSources: readonly SourceView[] - readonly isGuest: boolean }) { return renderHook(() => useWorkspaceSourceWorkflow(input), { wrapper: ({ children }: { readonly children: ReactNode }) => diff --git a/src/components/workspace-source-workflow.ts b/src/components/workspace-source-workflow.ts index 9b9f1f8..fed193c 100644 --- a/src/components/workspace-source-workflow.ts +++ b/src/components/workspace-source-workflow.ts @@ -12,20 +12,13 @@ import type { SourceView } from "@/domains/sources/types" type WorkspaceSourceWorkflowInput = { readonly initialSelectedDocumentId?: string | null readonly initialSources?: readonly SourceView[] - readonly isGuest?: boolean } type WorkspaceSourceWorkflow = { - readonly addingLibrarySourceIds: string[] readonly archivingSourceIds: string[] readonly handleArchiveSource: (sourceId: string) => Promise readonly handleRetrySource: (sourceId: string) => Promise - readonly handleOfficialLibrarySourceAdd: (demoSourceId: string) => Promise readonly handleSelectedSourceChange: (sourceId: string | null) => void - readonly handleSourcesMaterialized: ( - demoSourceIds: readonly string[], - materializedSources: readonly SourceView[], - ) => void readonly handleSourceUploaded: (source: SourceView) => void readonly handleToggleIncluded: (sourceId: string, included: boolean) => void readonly readySourceCount: number @@ -39,12 +32,10 @@ type WorkspaceSourceWorkflow = { const sourcesSWRKey = workspaceClient.keys.sources const archiveSourceSWRKey = workspaceClient.keys.archiveSource const retrySourceSWRKey = workspaceClient.keys.retrySource -const materializeDemoSourceSWRKey = workspaceClient.keys.materializeDemoSources export function useWorkspaceSourceWorkflow({ initialSelectedDocumentId = null, initialSources = [], - isGuest = false, }: WorkspaceSourceWorkflowInput): WorkspaceSourceWorkflow { const initialSourceRows = useMemo(() => [...initialSources], [initialSources]) const initialSelectedSourceId = workspaceSourceState.getInitialSelectedSourceId( @@ -59,11 +50,8 @@ export function useWorkspaceSourceWorkflow({ >({}) const [archivingSourceIds, setArchivingSourceIds] = useState([]) const [retryingSourceIds, setRetryingSourceIds] = useState([]) - const [addingLibrarySourceIds, setAddingLibrarySourceIds] = useState( - [], - ) const shouldRefreshSourcesOnMount = - !isGuest && workspaceClientCache.hasPendingSources(initialSourceRows) + workspaceClientCache.hasPendingSources(initialSourceRows) const { data: serverSources, mutate: mutateSources } = useSWR( sourcesSWRKey, workspaceClient.fetchSources, @@ -103,10 +91,6 @@ export function useWorkspaceSourceWorkflow({ retrySourceSWRKey, retrySourceMutation, ) - const { trigger: materializeDemoSources } = useSWRMutation( - materializeDemoSourceSWRKey, - materializeDemoSourcesMutation, - ) function handleSourceUploaded(source: SourceView): void { void mutateSources( @@ -117,31 +101,6 @@ export function useWorkspaceSourceWorkflow({ void mutateSources() } - function handleSourcesMaterialized( - demoSourceIds: readonly string[], - materializedSources: readonly SourceView[], - ): void { - const materializedDemoSourceIdSet = new Set(demoSourceIds) - void mutateSources( - (current) => [ - ...(current ?? sourceRows).filter( - (source) => - !source.demoSourceId || - !materializedDemoSourceIdSet.has(source.demoSourceId), - ), - ...materializedSources, - ], - { revalidate: false }, - ) - setSelectedSourceId((current) => { - if (!current || materializedDemoSourceIdSet.has(current)) { - return materializedSources[0]?.id ?? current - } - - return current - }) - } - function handleToggleIncluded(sourceId: string, included: boolean): void { setSourceExclusionById((current) => ({ ...current, @@ -210,34 +169,11 @@ export function useWorkspaceSourceWorkflow({ } } - async function handleOfficialLibrarySourceAdd( - demoSourceId: string, - ): Promise { - setAddingLibrarySourceIds((current) => - workspaceSourceState.addPendingId(current, demoSourceId), - ) - try { - const materializedSources = await materializeDemoSources([demoSourceId]) - handleSourcesMaterialized([demoSourceId], materializedSources) - return true - } catch { - // Keep the library source visible when materialization fails. - return false - } finally { - setAddingLibrarySourceIds((current) => - workspaceSourceState.removePendingId(current, demoSourceId), - ) - } - } - return { - addingLibrarySourceIds, archivingSourceIds, handleArchiveSource, handleRetrySource, - handleOfficialLibrarySourceAdd, handleSelectedSourceChange, - handleSourcesMaterialized, handleSourceUploaded, handleToggleIncluded, readySourceCount, @@ -251,16 +187,7 @@ export function useWorkspaceSourceWorkflow({ function isQueryableReadySource(source: SourceView): boolean { if (source.status !== "ready") return false - - return !isUnmaterializedOfficialLibrarySource(source) && !isRemoteSource(source) -} - -function isUnmaterializedOfficialLibrarySource(source: SourceView): boolean { - return source.kind === "demo" && source.officialLibrary !== undefined -} - -function isRemoteSource(source: SourceView): boolean { - return source.kind === "remote" + return source.kind !== "remote" } function archiveSourceMutation( @@ -276,12 +203,3 @@ function retrySourceMutation( ): ReturnType { return workspaceClient.retrySource(sourceId) } - -function materializeDemoSourcesMutation( - _key: string, - { arg: demoSourceIds }: { readonly arg: readonly string[] }, -): ReturnType { - return workspaceClient.materializeDemoSources({ - demoSourceIds: [...demoSourceIds], - }) -} diff --git a/src/components/workspace-switcher.test.ts b/src/components/workspace-switcher.test.ts new file mode 100644 index 0000000..b208082 --- /dev/null +++ b/src/components/workspace-switcher.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import React from "react"; +import { + cleanup, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + activateWorkspace: vi.fn(), + createWorkspace: vi.fn(), + fetchApiKeyNamespaces: vi.fn(), + refresh: vi.fn(), +})); + +vi.mock("@/domains/workspace/client", () => ({ + workspaceClient: { + activateWorkspace: mocks.activateWorkspace, + createWorkspace: mocks.createWorkspace, + fetchApiKeyNamespaces: mocks.fetchApiKeyNamespaces, + fetchUserApiKeys: vi.fn(async () => []), + }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: mocks.refresh }), +})); + +import { WorkspaceSwitcher } from "./workspace-switcher"; + +const C = WorkspaceSwitcher as React.FC>; + +const keyLabels = [ + { id: "key_a", label: "domainA", mask: "sk_8aB••••GVB8" }, + { id: "key_b", label: "domainB", mask: "sk_f3a••••e2" }, +]; + +describe("WorkspaceSwitcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.activateWorkspace.mockResolvedValue(undefined); + mocks.createWorkspace.mockResolvedValue({ + id: "ws_new", + namespace: "adobe", + activeKeyLabel: "domainA", + }); + mocks.fetchApiKeyNamespaces.mockImplementation((keyId: string) => { + if (keyId === "key_a") { + return Promise.resolve([ + { namespace: "adobe", documentCount: 9 }, + { namespace: "docx", documentCount: 9 }, + ]); + } + return Promise.resolve([{ namespace: "lab-papers", documentCount: 3 }]); + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows 'key / namespace' for the active workspace and lists namespaces per key", async () => { + const user = userEvent.setup(); + render( + React.createElement(C, { + activeWorkspace: { + id: "ws_a1", + namespace: "adobe", + activeKeyLabel: "domainA", + }, + workspaces: [{ id: "ws_a1", namespace: "adobe" }], + knowhereKeyLabels: keyLabels, + }), + ); + + expect(screen.getByText("domainA / adobe")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /domainA \/ adobe/ })); + + expect(await screen.findByText("domainA")).toBeTruthy(); + expect(await screen.findByText("docx")).toBeTruthy(); + expect(await screen.findByText("domainB")).toBeTruthy(); + expect(await screen.findByText("lab-papers")).toBeTruthy(); + }); + + it("shows 'Add API key' when no keys are configured", () => { + render( + React.createElement(C, { + activeWorkspace: undefined, + workspaces: [], + knowhereKeyLabels: [], + }), + ); + + expect(screen.getByText("Add API key")).toBeTruthy(); + }); + + it("creates a workspace by picking a namespace and refreshes", async () => { + const user = userEvent.setup(); + render( + React.createElement(C, { + activeWorkspace: { + id: "ws_a1", + namespace: "adobe", + activeKeyLabel: "domainA", + }, + workspaces: [{ id: "ws_a1", namespace: "adobe" }], + knowhereKeyLabels: keyLabels, + }), + ); + + await user.click(screen.getByRole("button", { name: /domainA \/ adobe/ })); + await user.click(await screen.findByText("docx")); + + await waitFor(() => { + expect(mocks.createWorkspace).toHaveBeenCalledWith("key_a", "docx"); + expect(mocks.refresh).toHaveBeenCalled(); + }); + }); + + it("labels an existing workspace as 'exists' and shows a check on the active one", async () => { + const user = userEvent.setup(); + render( + React.createElement(C, { + activeWorkspace: { + id: "ws_a1", + namespace: "adobe", + activeKeyLabel: "domainA", + }, + workspaces: [ + { id: "ws_a1", namespace: "adobe" }, + { id: "ws_a2", namespace: "docx" }, + ], + knowhereKeyLabels: keyLabels, + }), + ); + + await user.click(screen.getByRole("button", { name: /domainA \/ adobe/ })); + + await waitFor(() => { + expect(screen.getAllByText("exists")).toHaveLength(1); + }); + }); +}); diff --git a/src/components/workspace-switcher.tsx b/src/components/workspace-switcher.tsx new file mode 100644 index 0000000..a31fd72 --- /dev/null +++ b/src/components/workspace-switcher.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { + type ReactElement, + useMemo, + useState, +} from "react"; +import { useRouter } from "next/navigation"; +import { Check, ChevronDown, KeyRound, Loader2, Users } from "lucide-react"; +import useSWR from "swr"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Spinner } from "@/components/ui/spinner"; +import { WorkspaceApiKeysDialog } from "@/components/workspace-api-keys-dialog"; +import { WorkspaceMembersDialog } from "@/components/workspace-members-dialog"; +import { workspaceClient } from "@/domains/workspace/client"; +import type { + KnowhereKeyLabelView, + WorkspaceView, +} from "@/domains/workspace/client"; + +export type WorkspaceSwitcherProps = { + readonly activeWorkspace?: WorkspaceView; + readonly knowhereKeyLabels?: readonly KnowhereKeyLabelView[]; + readonly userName?: string; + readonly workspaces?: readonly WorkspaceView[]; +}; + +export function WorkspaceSwitcher({ + activeWorkspace, + knowhereKeyLabels = [], + userName, + workspaces = [], +}: WorkspaceSwitcherProps): ReactElement { + const router = useRouter(); + const [isApiKeysOpen, setIsApiKeysOpen] = useState(false); + const [isMembersOpen, setIsMembersOpen] = useState(false); + const [creatingKeyLabel, setCreatingKeyLabel] = useState(null); + const [creatingNamespace, setCreatingNamespace] = useState( + null, + ); + const { data: keyNamespacesByKeyId, isLoading: isLoadingNamespaces } = useSWR( + knowhereKeyLabels.length > 0 + ? ["all-key-namespaces", knowhereKeyLabels.map((k) => k.id).join(",")] + : null, + async ([, ids]: readonly [string, string]) => { + const results = await Promise.all( + ids + .split(",") + .map(async (keyId) => ({ + keyId, + namespaces: await workspaceClient.fetchApiKeyNamespaces(keyId), + })), + ) + return Object.fromEntries( + results.map((entry) => [entry.keyId, entry.namespaces]), + ) + }, + { revalidateOnFocus: false }, + ); + + const workspacesByNamespace = useMemo(() => { + const byNamespace = new Map() + for (const workspace of workspaces) { + byNamespace.set(workspace.namespace, workspace) + } + return byNamespace + }, [workspaces]) + + const triggerText = activeWorkspace + ? `${activeWorkspace.activeKeyLabel ?? "default"} / ${activeWorkspace.namespace}` + : knowhereKeyLabels.length === 0 + ? "Add API key" + : "Pick a workspace" + + async function handlePickNamespace( + keyLabel: string, + namespace: string, + ): Promise { + if (creatingKeyLabel !== null) return + setCreatingKeyLabel(keyLabel) + setCreatingNamespace(namespace) + try { + const key = knowhereKeyLabels.find((k) => k.label === keyLabel) + if (!key) return + await workspaceClient.createWorkspace(key.id, namespace) + router.refresh() + } catch { + // Error swallowed; dropdown stays open. + } finally { + setCreatingKeyLabel(null) + setCreatingNamespace(null) + } + } + + return ( + <> + + + + + + {knowhereKeyLabels.length === 0 ? ( + + Add an API key to browse namespaces. + + ) : isLoadingNamespaces ? ( + + + Loading namespaces… + + ) : ( + knowhereKeyLabels.map((key) => { + const namespaces = keyNamespacesByKeyId?.[key.id] ?? [] + return ( +
+ + {key.label} + + {key.mask} + + + {namespaces.map((ns) => { + const existing = workspacesByNamespace.get(ns.namespace) + const isActive = + existing?.id === activeWorkspace?.id + const isCreating = + creatingKeyLabel === key.label && + creatingNamespace === ns.namespace + return ( + + void handlePickNamespace(key.label, ns.namespace) + } + className="flex items-center justify-between gap-2 text-xs" + > + + {ns.namespace} + + {isCreating ? ( + + ) : isActive ? ( + + ) : existing ? ( + + exists + + ) : ( + + )} + + ) + })} + {namespaces.length === 0 ? ( + + No namespaces for this key. + + ) : null} +
+ ) + }) + )} + + {activeWorkspace ? ( + <> + setIsMembersOpen(true)} + className="flex items-center gap-2 text-xs font-semibold" + > + + Members… + + + + ) : null} + setIsApiKeysOpen(true)} + className="flex items-center gap-2 text-xs font-semibold" + > + + API keys… + +
+
+ + { + // Re-fetch SSR state so the dropdown sees the new key and its + // namespaces (knowhereKeyLabels changes → namespaces SWR refires). + router.refresh(); + }} + userName={userName} + /> + + + + ); +} + +function PlusIcon({ + className, +}: { + readonly className?: string; +}): ReactElement { + return ( + + ); +} diff --git a/src/domains/chat/chat-citation-persistence.test.ts b/src/domains/chat/chat-citation-persistence.test.ts new file mode 100644 index 0000000..48dd697 --- /dev/null +++ b/src/domains/chat/chat-citation-persistence.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest" + +import { chatCitationPersistence } from "./chat-citation-persistence" +import { toChatMessageView } from "./view" +import type { ChatMessage } from "@/infrastructure/db/schema" + +describe("chatCitationPersistence", () => { + it("keeps the parser chunkId while stripping chunk content", () => { + const citations = chatCitationPersistence.normalizeCitations([ + { + content: "CHEUNG Hon-lam Gordon 2835 2147", + chunkType: "page", + score: 0.8, + chunkId: "parser_page_1", + source: { + documentId: "doc_1", + sourceFileName: "directory.pdf", + sectionPath: "Page 3", + }, + }, + ]) + + expect(citations).toEqual([ + { + chunkType: "page", + score: 0.8, + chunkId: "parser_page_1", + source: { + documentId: "doc_1", + sourceFileName: "directory.pdf", + sectionPath: "Page 3", + }, + }, + ]) + }) + + it("round-trips the chunkId back through persisted message views", () => { + const message: ChatMessage = { + id: "message_1", + threadId: "thread_1", + role: "assistant", + content: "Gordon is listed.", + citations: [ + { + chunkType: "page", + score: 0.8, + chunkId: "parser_page_1", + source: { + documentId: "doc_1", + sourceFileName: "directory.pdf", + sectionPath: "Page 3", + }, + }, + ], + artifacts: null, + createdAt: new Date("2026-05-10T00:00:00.000Z"), + } + + const view = toChatMessageView(message) + + expect(view.citations?.[0]).toMatchObject({ chunkId: "parser_page_1" }) + }) +}) diff --git a/src/domains/chat/chat-citation-persistence.ts b/src/domains/chat/chat-citation-persistence.ts index bf2a1a1..d047075 100644 --- a/src/domains/chat/chat-citation-persistence.ts +++ b/src/domains/chat/chat-citation-persistence.ts @@ -15,10 +15,6 @@ type ChatCitationPersistence = { readonly normalizeArtifacts: ( artifacts: readonly ChatArtifactView[] | null | undefined, ) => ChatArtifactView[] | null - readonly replaceDemoCitationDocumentId: ( - citations: readonly ChatCitationView[] | undefined, - documentIdMap: ReadonlyMap, - ) => ChatCitationView[] | undefined } function normalizeCitations( @@ -57,34 +53,13 @@ function toArtifactView(artifact: ChatArtifactView): ChatArtifactView { } } -function replaceDemoCitationDocumentId( - citations: readonly ChatCitationView[] | undefined, - documentIdMap: ReadonlyMap, -): ChatCitationView[] | undefined { - if (!citations) return undefined - - return citations.map((citation) => { - const newId = citation.source.documentId - ? documentIdMap.get(citation.source.documentId) - : undefined - if (!newId) return citation - - return { - ...citation, - source: { - ...citation.source, - documentId: newId, - }, - } - }) -} - function toCitationView( citation: ChatCitationView | CitationView | RetrievalResultView, ): CitationView { return { chunkType: citation.chunkType, score: citation.score, + chunkId: citation.chunkId, assetUrl: citation.assetUrl, description: "description" in citation ? citation.description : undefined, source: { @@ -98,5 +73,4 @@ function toCitationView( export const chatCitationPersistence: ChatCitationPersistence = { normalizeCitations, normalizeArtifacts, - replaceDemoCitationDocumentId, } diff --git a/src/domains/chat/chat-thread-repository.ts b/src/domains/chat/chat-thread-repository.ts index d7cc884..0da7828 100644 --- a/src/domains/chat/chat-thread-repository.ts +++ b/src/domains/chat/chat-thread-repository.ts @@ -3,32 +3,11 @@ import "server-only" import { and, desc, eq, isNull, sql } from "drizzle-orm" import { Effect } from "effect" -import { chatCitationPersistence } from "./chat-citation-persistence" import { DbClient } from "@/infrastructure/db" import { - chatMessages, chatThreads, - type ChatMessage, type ChatThread, } from "@/infrastructure/db/schema" -import type { ChatCitationView } from "./types" - -type SeedDemoChatMessage = { - readonly role: "user" | "assistant" - readonly content: string - readonly citations?: readonly ChatCitationView[] | null -} - -type SeedDemoChatThreadInput = { - readonly demoKey: string - readonly title: string - readonly messages: readonly SeedDemoChatMessage[] -} - -type SeedDemoChatThreadResult = { - readonly thread: ChatThread - readonly messages: ChatMessage[] -} type ChatThreadRepository = { readonly findThreadInWorkspaceEffect: ( @@ -44,18 +23,10 @@ type ChatThreadRepository = { readonly ensureDefaultThreadEffect: ( workspaceId: string, ) => Effect.Effect - readonly ensureDemoThreadEffect: ( - workspaceId: string, - input: SeedDemoChatThreadInput, - ) => Effect.Effect readonly softDeleteThreadEffect: ( workspaceId: string, threadId: string, ) => Effect.Effect - readonly findThreadByDemoKeyEffect: ( - workspaceId: string, - demoKey: string, - ) => Effect.Effect } const chatThreadListLimit = 50 @@ -148,88 +119,6 @@ const ensureDefaultThreadEffect: ChatThreadRepository["ensureDefaultThreadEffect return thread }) -const ensureDemoThreadEffect: ChatThreadRepository["ensureDemoThreadEffect"] = - (workspaceId: string, input: SeedDemoChatThreadInput) => - Effect.gen(function* () { - if (input.messages.length === 0) return null - - const db = yield* DbClient - return yield* Effect.promise(() => - db.transaction(async (tx) => { - const insertDemoMessages = async ( - threadId: string, - ): Promise => { - const createdAtMs = Date.now() - return await tx - .insert(chatMessages) - .values( - input.messages.map((message, index) => ({ - threadId, - role: message.role, - content: message.content, - citations: chatCitationPersistence.normalizeCitations( - message.citations, - ), - createdAt: new Date(createdAtMs + index), - })), - ) - .returning() - } - - const existing = ( - await tx - .select() - .from(chatThreads) - .where( - and( - eq(chatThreads.workspaceId, workspaceId), - eq(chatThreads.demoKey, input.demoKey), - ), - ) - .limit(1) - )[0] - - if (existing) { - if (existing.deletedAt !== null) return null - - const existingMessages = await tx - .select() - .from(chatMessages) - .where(eq(chatMessages.threadId, existing.id)) - .orderBy(chatMessages.createdAt) - if (existingMessages.length > 0) { - return { - thread: existing, - messages: existingMessages, - } - } - - const messages = await insertDemoMessages(existing.id) - return { - thread: existing, - messages, - } - } - - const [thread] = await tx - .insert(chatThreads) - .values({ - workspaceId, - demoKey: input.demoKey, - title: input.title, - }) - .returning() - - if (!thread) { - throw new Error("ensureDemoChatThread: insert did not return a row.") - } - - const messages = await insertDemoMessages(thread.id) - return { thread, messages } - }), - ) - }) - const softDeleteThreadEffect: ChatThreadRepository["softDeleteThreadEffect"] = ( workspaceId: string, threadId: string, @@ -253,32 +142,10 @@ const softDeleteThreadEffect: ChatThreadRepository["softDeleteThreadEffect"] = ( return result.length > 0 }) -const findThreadByDemoKeyEffect: ChatThreadRepository["findThreadByDemoKeyEffect"] = - (workspaceId: string, demoKey: string) => - Effect.gen(function* () { - const db = yield* DbClient - const row = yield* Effect.promise(() => - db - .select() - .from(chatThreads) - .where( - and( - eq(chatThreads.workspaceId, workspaceId), - eq(chatThreads.demoKey, demoKey), - isNull(chatThreads.deletedAt), - ), - ) - .limit(1), - ) - return row[0] ?? null - }) - export const chatThreadRepository: ChatThreadRepository = { findThreadInWorkspaceEffect, listThreadsForWorkspaceEffect, createThreadEffect, ensureDefaultThreadEffect, - ensureDemoThreadEffect, softDeleteThreadEffect, - findThreadByDemoKeyEffect, } diff --git a/src/domains/chat/chat-turn-persistence.test.ts b/src/domains/chat/chat-turn-persistence.test.ts index 3dbb85c..b55911e 100644 --- a/src/domains/chat/chat-turn-persistence.test.ts +++ b/src/domains/chat/chat-turn-persistence.test.ts @@ -46,7 +46,6 @@ function makeThread(): ChatThread { return { id: "thread_1", workspaceId: "workspace_1", - demoKey: null, title: "Revenue", createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), diff --git a/src/domains/chat/citations.test.ts b/src/domains/chat/citations.test.ts index ce681ec..ed66d98 100644 --- a/src/domains/chat/citations.test.ts +++ b/src/domains/chat/citations.test.ts @@ -31,6 +31,29 @@ describe("toChatCitationViews", () => { { ...secondResult, description: "margin expansion" }, ]) }) + + it("carries the parser chunkId through to the citation view", () => { + const pageResult = makeRetrievalResult({ + content: "CHEUNG Hon-lam Gordon 2835 2147", + chunkType: "page", + chunkId: "parser_page_1", + }) + + const citations = toChatCitationViews([pageResult], "Gordon is listed [Source 1: directory].") + + expect(citations[0]).toMatchObject({ + chunkId: "parser_page_1", + chunkType: "page", + }) + }) + + it("omits chunkId when the retrieval result does not carry one", () => { + const result = makeRetrievalResult() + + const citations = toChatCitationViews([result], "") + + expect("chunkId" in (citations[0] ?? {})).toBe(false) + }) }) function makeRetrievalResult( diff --git a/src/domains/chat/citations.ts b/src/domains/chat/citations.ts index e010bdc..7633837 100644 --- a/src/domains/chat/citations.ts +++ b/src/domains/chat/citations.ts @@ -15,6 +15,7 @@ export function toChatCitationViews( content: result.content, chunkType: result.chunkType, score: result.score, + ...(result.chunkId ? { chunkId: result.chunkId } : {}), ...(result.assetUrl ? { assetUrl: result.assetUrl } : {}), ...(description ? { description } : {}), source: { diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts index 4c8d224..d9b4831 100644 --- a/src/domains/chat/contracts.ts +++ b/src/domains/chat/contracts.ts @@ -8,6 +8,7 @@ import type { HarnessRunResult } from "@/agent-harness" import type { ChatArtifactView, ChatCitationView, + RetrievalTraceView, } from "@/domains/chat/types" import type { HardenMediaAssetUrls } from "./media-asset-hardening" import type { LoadSourceAssetUrls } from "./media-assets" @@ -29,6 +30,7 @@ export type AgenticRetrievalTargetContent = | "table" | "text_image" | "text_table" + | "page" export type AgenticRetrievalPlan = { targetContent: AgenticRetrievalTargetContent @@ -51,6 +53,17 @@ export type SearchSources = ( input: AgenticRetrievalQuery, ) => Promise +/** + * Optional per-request retrieval tuning from the chat composer UI. Each + * field overrides the equivalent hardcoded default (or, for topK, the + * harness-chosen value) when present. + */ +export type RetrievalOverrides = { + readonly rerank?: boolean + readonly internalRecallK?: number + readonly topK?: number +} + export type GenerateAnswer = (input: { question: string messages: readonly ChatHistoryMessage[] @@ -70,10 +83,12 @@ export type AnswerQuestionInput = { loadSourceAssetUrls?: LoadSourceAssetUrls hardenMediaAssetUrls?: HardenMediaAssetUrls messages: readonly ChatHistoryMessage[] + retrievalOverrides?: RetrievalOverrides } export type AnswerQuestionResult = { answer: string citations: ChatCitationView[] artifacts?: ChatArtifactView[] + retrievalTrace?: RetrievalTraceView } diff --git a/src/domains/chat/diagram.ts b/src/domains/chat/diagram.ts index 41b29c8..a01d1cc 100644 --- a/src/domains/chat/diagram.ts +++ b/src/domains/chat/diagram.ts @@ -3,7 +3,7 @@ import g2SkillIndex from "@antv/chart-visualization-skills/dist/index/g2.index.j import type { Skill } from "@antv/chart-visualization-skills" import { z } from "zod" -import { CHAT_MODEL } from "@/lib/ai" +import { getChatModel, getChatModelLabel, isChatConfigured } from "@/lib/ai" import { summarizeUnknownError } from "@/lib/format-log-value" import { logger } from "@/lib/logger" @@ -132,9 +132,11 @@ export function parseChatDiagramRequestBody( export async function generateChatDiagramSpec(input: { readonly answer: string }): Promise { - if (!process.env.AI_GATEWAY_API_KEY) { + if (!isChatConfigured()) { throw new Error( - "AI_GATEWAY_API_KEY environment variable is required. Set it in your .env.local file.", + "Chat is not configured. Set either AI_GATEWAY_API_KEY (Vercel AI " + + "Gateway) or CHAT_BASE_URL + CHAT_MODEL + CHAT_API_KEY (OpenAI-compatible) " + + "in .env.local.", ) } @@ -191,18 +193,18 @@ async function requestChatDiagramObject(input: { }): Promise { logger.info("chat-diagram: llm request", { attempt: input.attempt, - model: CHAT_MODEL, + model: getChatModelLabel(), promptCharLength: input.prompt.length, }) const response = await generateObject({ - model: CHAT_MODEL, + model: getChatModel(), schema: chatDiagramSpecSchema, prompt: input.prompt, }) const spec = normalizeChatDiagramSpec(response.object) logger.info("chat-diagram: llm response", { attempt: input.attempt, - model: CHAT_MODEL, + model: getChatModelLabel(), type: spec.type, dataPointCount: spec.type === "none" ? 0 : spec.data.length, }) diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 0c8cfd6..eaf997e 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -75,6 +75,8 @@ describe("answerQuestionWithRetrieval", () => { query: "What does the document say?", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, excludeDocumentIds: ["doc_excluded"], }); @@ -85,11 +87,88 @@ describe("answerQuestionWithRetrieval", () => { excludedSourceIds: ["source_2"], searchSources: expect.any(Function), }); - expect(answer).toEqual({ + expect(answer).toMatchObject({ answer: "The answer is grounded.", citations: [result], artifacts: [], }); + expect(answer.retrievalTrace).toMatchObject({ + durationSeconds: expect.any(Number), + queries: [ + { + namespace: "notebook-workspace", + query: "What does the document say?", + referencedChunkCount: 0, + resultCount: 1, + topScores: [0.9], + }, + ], + }); + }); + + it("never renders a literal null/undefined or empty string as the answer", async () => { + const retrieval = { + query: vi.fn().mockResolvedValue({ + results: [], + evidenceText: "", + referencedChunks: [], + namespace: "notebook-workspace", + query: "Gordon", + routerUsed: "workflow_single_step", + answerText: null, + }), + }; + const sources = [makeSource()]; + + for (const nullishAnswer of ["null", "undefined", " null ", "", " "]) { + const generateAnswer = vi.fn(async () => + makeHarnessRunResult(nullishAnswer), + ); + const answer = await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "Gordon", + namespace: "notebook-workspace", + sources, + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + }), + ); + expect(answer.answer).toBe("I couldn't find that in your sources."); + } + }); + + it("keeps a real answer containing the word null", async () => { + const retrieval = { + query: vi.fn().mockResolvedValue({ + results: [], + evidenceText: "", + referencedChunks: [], + namespace: "notebook-workspace", + query: "Gordon", + routerUsed: "workflow_single_step", + answerText: null, + }), + }; + const sources = [makeSource()]; + const generateAnswer = vi.fn(async () => + makeHarnessRunResult("Gordon is listed as null in the directory."), + ); + const answer = await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "Gordon", + namespace: "notebook-workspace", + sources, + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + }), + ); + expect(answer.answer).toBe( + "Gordon is listed as null in the directory.", + ); }); it("does not carry no-evidence metadata from default into a successful legacy namespace result", async () => { @@ -164,11 +243,30 @@ describe("answerQuestionWithRetrieval", () => { 2, expect.objectContaining({ namespace: "notebook-legacy" }), ); - expect(answer).toEqual({ + expect(answer).toMatchObject({ answer: "The legacy answer is grounded.", citations: [legacyResult], artifacts: [], }); + expect(answer.retrievalTrace).toMatchObject({ + durationSeconds: expect.any(Number), + queries: [ + { + namespace: "default", + query: "legacy document answer", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + { + namespace: "notebook-legacy", + query: "legacy document answer", + referencedChunkCount: 0, + resultCount: 1, + topScores: [0.9], + }, + ], + }); }); it("does not hide a failed namespace query behind an empty namespace result", async () => { @@ -481,6 +579,8 @@ describe("answerQuestionWithRetrieval", () => { query: "SpaceX rocket photos", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 3, }); expect(answer.answer).toBe("Use this launch photo."); @@ -498,6 +598,70 @@ describe("answerQuestionWithRetrieval", () => { ]); }); + it("maps a page-targeted retrieval query to dataType 7", async () => { + const pageResult = makeRetrievalResult({ + content: "CHEUNG Hon-lam Gordon 2835 2147", + chunkType: "page", + chunkId: "parser_page_1", + source: { + documentId: "doc_directory", + sourceFileName: "directory.pdf", + sectionPath: "Page 3", + }, + }); + const retrieval = { + query: vi.fn().mockResolvedValue({ + results: [pageResult], + evidenceText: "Directory evidence.", + referencedChunks: [], + namespace: "notebook-workspace", + query: "Gordon phone number", + routerUsed: "workflow_single_step", + answerText: null, + }), + }; + const generateAnswer = vi.fn(async ({ searchSources }) => { + await searchSources({ + query: "Gordon phone number", + targetContent: "page", + }); + return makeHarnessRunResult("Gordon can be reached at 2835 2147."); + }); + + const answer = await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "What is Gordon's phone number?", + namespace: "notebook-workspace", + sources: [ + makeSource({ + id: "source_directory", + title: "directory.pdf", + knowhereDocumentId: "doc_directory", + }), + ], + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + }), + ); + + expect(retrieval.query).toHaveBeenCalledWith({ + namespace: "notebook-workspace", + query: "Gordon phone number", + topK: 8, + useAgentic: true, + rerank: true, + internalRecallK: 30, + dataType: 7, + }); + expect(answer.answer).toBe("Gordon can be reached at 2835 2147."); + expect(answer.citations[0]).toMatchObject({ + chunkId: "parser_page_1", + chunkType: "page", + }); + }); + it("hardens citation and artifact asset URLs before returning the answer", async () => { const rawAssetUrl = "https://knowhere-storage.example/results/job_1/images/id-front.jpg?AWSAccessKeyId=test"; @@ -1253,6 +1417,8 @@ describe("answerQuestionWithRetrieval", () => { query: "公民身份证明 图片", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 3, }); const imageCitations = answer.citations.filter( @@ -1297,11 +1463,23 @@ describe("answerQuestionWithRetrieval", () => { }), ); - expect(answer).toEqual({ + expect(answer).toMatchObject({ answer: "I couldn't find that in your sources.", citations: [], artifacts: [], }); + expect(answer.retrievalTrace).toMatchObject({ + durationSeconds: expect.any(Number), + queries: [ + { + namespace: "notebook-workspace", + query: "Missing fact?", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + ], + }); }); it("lets the agent issue contextual retrieval queries while answering the original question", async () => { @@ -1350,6 +1528,8 @@ describe("answerQuestionWithRetrieval", () => { query: "Tesla Q4 2025 Update energy generation and storage deployments", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, }); expect(generateAnswer).toHaveBeenCalledWith({ @@ -1361,6 +1541,99 @@ describe("answerQuestionWithRetrieval", () => { }); }); + it("collects a retrieval trace entry per issued query", async () => { + const retrieval = { + query: vi + .fn() + .mockImplementation(async ({ query }: { readonly query: string }) => ({ + results: [], + evidenceText: null, + referencedChunks: [], + namespace: "notebook-workspace", + query, + routerUsed: "workflow_single_step", + answerText: null, + })), + }; + const generateAnswer = vi.fn(async ({ searchSources }) => { + await searchSources({ query: "query variant one" }); + await searchSources({ query: "query variant two" }); + return makeHarnessRunResult("Answer."); + }); + + const answer = await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "Question", + namespace: "notebook-workspace", + sources: [makeSource()], + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + }), + ); + + expect(answer.retrievalTrace?.queries).toEqual([ + { + namespace: "notebook-workspace", + query: "query variant one", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + { + namespace: "notebook-workspace", + query: "query variant two", + referencedChunkCount: 0, + resultCount: 0, + topScores: [], + }, + ]); + }); + + it("applies retrieval overrides over hardcoded and harness-chosen values", async () => { + const retrieval = { + query: vi.fn().mockResolvedValue({ + results: [makeRetrievalResult()], + evidenceText: "Evidence.", + referencedChunks: [], + namespace: "notebook-workspace", + query: "any", + routerUsed: "workflow_single_step", + answerText: null, + }), + }; + const generateAnswer = vi.fn(async ({ searchSources }) => { + await searchSources({ query: "query", topK: 12 }); + return makeHarnessRunResult("Answer."); + }); + + await Effect.runPromise( + answerQuestionWithRetrieval({ + question: "Question", + namespace: "notebook-workspace", + sources: [makeSource()], + excludedSourceIds: [], + retrieval, + generateAnswer, + messages: [], + retrievalOverrides: { + rerank: false, + internalRecallK: 45, + topK: 4, + }, + }), + ); + + expect(retrieval.query).toHaveBeenCalledWith( + expect.objectContaining({ + rerank: false, + internalRecallK: 45, + topK: 4, + }), + ); + }); + it("does not append chat history to Knowhere tool queries", async () => { const retrieval = { query: vi.fn().mockResolvedValue({ @@ -1406,6 +1679,8 @@ describe("answerQuestionWithRetrieval", () => { query: "Tesla energy storage deployments", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, }); expect(JSON.stringify(queryInput)).not.toContain( @@ -1465,6 +1740,7 @@ describe("answerQuestionWithRetrieval", () => { content: "", chunkType: "image", score: null, + chunkId: "chunk_1", assetUrl: "https://blob.example/images/launch.jpg", source: { documentId: "doc_spacex", @@ -1538,6 +1814,10 @@ describe("generateAgenticOutputManifest", () => { return { text: "This freeform text should be ignored.", + steps: [ + { stepNumber: 1, usage: { inputTokens: 120, outputTokens: 40 } }, + ], + totalUsage: { inputTokens: 120, outputTokens: 40 }, } as Awaited>; }, ); @@ -1602,6 +1882,9 @@ describe("generateAgenticOutputManifest", () => { carryHistory: "none", }); expect(result.trace.validationErrors).toEqual([]); + expect(result.trace.llmCallCount).toBe(1); + expect(result.trace.inputTokens).toBe(120); + expect(result.trace.outputTokens).toBe(40); expect(searchSources).toHaveBeenCalledWith({ query: "冯荣洲 身份证 图片", targetContent: "text_image", @@ -1676,6 +1959,10 @@ describe("generateAgenticOutputManifest", () => { return { text: "ignored", response: { messages: [] }, + steps: [ + { stepNumber: 1, usage: { inputTokens: 100, outputTokens: 30 } }, + ], + totalUsage: { inputTokens: 100, outputTokens: 30 }, } as unknown as Awaited>; }, ); @@ -1715,6 +2002,9 @@ describe("generateAgenticOutputManifest", () => { expect(generateCallCount).toBe(2); expect(result.trace.revisionsUsed).toBe(1); expect(result.trace.validationErrors).toEqual([]); + expect(result.trace.llmCallCount).toBe(2); + expect(result.trace.inputTokens).toBe(200); + expect(result.trace.outputTokens).toBe(60); expect( result.manifest.artifacts.filter((artifact) => artifact.display).length, ).toBe(2); @@ -1779,7 +2069,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts index 2d7245d..cd188ed 100644 --- a/src/domains/chat/index.ts +++ b/src/domains/chat/index.ts @@ -9,6 +9,7 @@ import { logger } from "@/lib/logger" import type { ChatArtifactView, ChatCitationView, + RetrievalTraceView, } from "@/domains/chat/types" import type { DerivedTableArtifact, @@ -29,6 +30,7 @@ import type { AgenticRetrievalResponse, AnswerQuestionInput, AnswerQuestionResult, + RetrievalOverrides, } from "./contracts" import { excludeDocuments, @@ -45,6 +47,7 @@ const MAX_CITATION_RESULTS = 20 const KNOWHERE_RESPONSE_TEXT_LOG_LIMIT = 200 const KNOWHERE_CHUNK_LOG_LIMIT = 100 const NO_RESULTS_ANSWER = "I couldn't find that in your sources." +const NULLISH_ANSWER_PATTERN = /^(?:null|undefined)$/i const HARNESS_VALIDATION_FAILURE_ANSWER = "I couldn't safely finish that response because the agent output did not pass Notebook's validation checks. Please try again." const RAW_URL_PATTERN = /https?:\/\/[^\s)\]}>"']+/g @@ -58,6 +61,7 @@ const RETRIEVAL_TARGET_CONTENT_DATA_TYPES: Readonly< table: 4, text_image: 5, text_table: 6, + page: 7, } as const type RetrievalDataType = NonNullable @@ -110,6 +114,7 @@ export const answerQuestionWithRetrieval = ( Effect.gen(function* () { const question = input.question.trim() const retrievalResponses: RetrievalQueryResponse[] = [] + const answerStartedAtMs = Date.now() logger.info("chat-agent: answer start", { questionLength: question.length, @@ -134,11 +139,14 @@ export const answerQuestionWithRetrieval = ( namespace, sources: input.sources, excludedSourceIds: input.excludedSourceIds, + retrievalOverrides: input.retrievalOverrides, }) logger.info("chat-agent: searchSources start", { namespace, query: retrievalQueryParams.query, topK: retrievalQueryParams.topK, + rerank: retrievalQueryParams.rerank, + internalRecallK: retrievalQueryParams.internalRecallK, dataType: retrievalQueryParams.dataType ?? null, signalPathCount: retrievalQueryParams.signalPaths?.length ?? 0, filterMode: retrievalQueryParams.filterMode ?? null, @@ -262,17 +270,29 @@ export const answerQuestionWithRetrieval = ( hardenedArtifacts: hardenedMedia.artifacts, }), }) + const finalAnswer = looksLikeNullishAnswer(answer) + ? NO_RESULTS_ANSWER + : answer const citationResults = hardenedMedia.results const displayArtifacts = hardenedMedia.artifacts ?? [] + const retrievalTrace = buildRetrievalTrace({ + responses: retrievalResponses, + durationSeconds: (Date.now() - answerStartedAtMs) / 1000, + llmCallCount: generatedAnswer.trace.llmCallCount, + inputTokens: generatedAnswer.trace.inputTokens, + outputTokens: generatedAnswer.trace.outputTokens, + }) logger.info("chat-agent: answer complete", { answerLength: answer.length, citationCount: citationResults.length, artifactCount: displayArtifacts.length, + retrievalQueryCount: retrievalTrace?.queries.length ?? 0, }) return { - answer, - citations: toChatCitationViews(citationResults, answer), + answer: finalAnswer, + citations: toChatCitationViews(citationResults, finalAnswer), artifacts: displayArtifacts, + retrievalTrace, } }) @@ -534,6 +554,11 @@ function sanitizeGeneratedAnswer({ return removeRetrievedMediaAssetUrls(answer, results) } +function looksLikeNullishAnswer(answer: string): boolean { + const trimmed = answer.trim() + return trimmed.length === 0 || NULLISH_ANSWER_PATTERN.test(trimmed) +} + function formatKnowhereQueryResponseForLog( response: RetrievalQueryResponse, ): KnowhereQueryResponseLog { @@ -682,23 +707,70 @@ function joinResponseText( return uniqueValues.length > 0 ? uniqueValues.join(",") : null } +function buildRetrievalTrace(input: { + readonly responses: readonly RetrievalQueryResponse[] + readonly durationSeconds: number + readonly llmCallCount?: number + readonly inputTokens?: number + readonly outputTokens?: number +}): RetrievalTraceView | undefined { + if (input.responses.length === 0) return undefined + + const queries = input.responses.map((response) => { + const topScores = response.results + .map((result) => result.score) + .filter((score): score is number => typeof score === "number") + .sort((left, right) => right - left) + .slice(0, 5) + return { + query: response.query, + namespace: response.namespace, + resultCount: response.results.length, + referencedChunkCount: response.referencedChunks.length, + topScores, + } + }) + + return { + durationSeconds: roundToTenths(input.durationSeconds), + ...(typeof input.llmCallCount === "number" + ? { llmCallCount: input.llmCallCount } + : {}), + ...(typeof input.inputTokens === "number" + ? { inputTokens: input.inputTokens } + : {}), + ...(typeof input.outputTokens === "number" + ? { outputTokens: input.outputTokens } + : {}), + queries, + } +} + +function roundToTenths(value: number): number { + return Math.round(value * 10) / 10 +} + function buildRetrievalQueryParams(input: { readonly input: AgenticRetrievalQuery readonly fallbackQuestion: string readonly namespace: string readonly sources: AnswerQuestionInput["sources"] readonly excludedSourceIds: readonly string[] + readonly retrievalOverrides?: RetrievalOverrides }): RetrievalQueryParams { const query = normalizeRetrievalQuery( input.input.query, input.fallbackQuestion, ) const dataType = normalizeRetrievalDataType(input.input.targetContent) + const overrides = input.retrievalOverrides return { namespace: input.namespace, query, - topK: normalizeTopK(input.input.topK), + topK: overrides?.topK ?? normalizeTopK(input.input.topK), useAgentic: true, + rerank: overrides?.rerank ?? true, + internalRecallK: overrides?.internalRecallK ?? 30, dataType, ...(input.input.signalPaths && input.input.signalPaths.length > 0 ? { signalPaths: input.input.signalPaths } @@ -796,6 +868,7 @@ function mapManifestCitationsToResults( content: chunk.content, chunkType: chunk.chunkType, score: chunk.score, + ...(chunk.chunkId ? { chunkId: chunk.chunkId } : {}), ...(chunk.assetUrl ? { assetUrl: chunk.assetUrl } : {}), source: { documentId: chunk.source.documentId ?? undefined, @@ -892,6 +965,7 @@ function toRetrievalResultFromEvidenceChunk( content: chunk.content, chunkType: chunk.chunkType, score: chunk.score, + ...(chunk.chunkId ? { chunkId: chunk.chunkId } : {}), ...(chunk.assetUrl ? { assetUrl: chunk.assetUrl } : {}), source: { documentId: chunk.source.documentId ?? undefined, @@ -924,6 +998,7 @@ function collectRetrievalResults( content: "", chunkType: chunk.chunkType, score: null, + ...(chunk.chunkId ? { chunkId: chunk.chunkId } : {}), ...(chunk.assetUrl ? { assetUrl: chunk.assetUrl } : {}), source: { documentId: chunk.documentId, diff --git a/src/domains/chat/media-asset-hardening.test.ts b/src/domains/chat/media-asset-hardening.test.ts index 70cf562..af07d90 100644 --- a/src/domains/chat/media-asset-hardening.test.ts +++ b/src/domains/chat/media-asset-hardening.test.ts @@ -116,47 +116,6 @@ describe("hardenChatMediaAssetUrls", () => { expect(result.results[0]?.assetUrl).toBe(parsedAssetUrl) }) - it("fetches demo asset routes from the upstream demo API", async () => { - process.env.KNOWHERE_BASE_URL = "https://knowhere.example" - const demoAssetUrl = - "/api/demo-sources/demo_source_1/assets/images/demo%20chart.png" - const blobStore = makeBlobStore( - "https://blob.example/workspaces/workspace_1/chat-assets/demo-demo_source_1/demo-chart.png", - ) - const fetchAsset = makeFetchAsset("demo-image", "image/png") - - const result = await hardenChatMediaAssetUrls({ - workspaceId: "workspace_1", - sources: [], - results: [ - makeRetrievalResult({ - chunkType: "image", - assetUrl: demoAssetUrl, - source: { - documentId: "demo_doc", - sourceFileName: "demo.pdf", - sectionPath: "images/demo chart.png", - }, - }), - ], - blobStore, - fetchAsset, - }) - - expect(fetchAsset).toHaveBeenCalledWith( - "https://knowhere.example/api/v1/demo/sources/demo_source_1/assets/images/demo%20chart.png", - ) - expect(fetchAsset).not.toHaveBeenCalledWith(demoAssetUrl) - expect(blobStore.put).toHaveBeenCalledWith( - expect.stringContaining("/chat-assets/demo-demo_source_1/"), - expect.any(Buffer), - expect.objectContaining({ contentType: "image/png" }), - ) - expect(result.results[0]?.assetUrl).toBe( - "https://blob.example/workspaces/workspace_1/chat-assets/demo-demo_source_1/demo-chart.png", - ) - }) - it("falls back to the raw URL when hardening fails", async () => { const rawAssetUrl = "https://knowhere-storage.example/results/job_1/tables/table-1.html?AWSAccessKeyId=test" @@ -287,7 +246,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/media-asset-hardening.ts b/src/domains/chat/media-asset-hardening.ts index e0a813e..255c888 100644 --- a/src/domains/chat/media-asset-hardening.ts +++ b/src/domains/chat/media-asset-hardening.ts @@ -8,7 +8,6 @@ import type { ChatCitationView, } from "@/domains/chat/types" import type { Source } from "@/infrastructure/db/schema" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" import { logger } from "@/lib/logger" import type { LoadSourceAssetUrls } from "./media-assets" import { resolveAssetUrlFromReferenceText } from "./media-assets" @@ -81,12 +80,6 @@ type HardeningContext = { readonly fetchAsset: FetchChatMediaAsset } -type DemoAssetRoute = { - readonly demoSourceId: string - readonly encodedAssetPath: string - readonly decodedAssetPath: string -} - const chatAssetsDirectoryName = "chat-assets" const parsedResultDirectoryName = "parsed-result" const fallbackContentType = "application/octet-stream" @@ -339,20 +332,6 @@ async function copyAssetToBlob(input: { } function resolveAssetFetchRequest(assetUrl: string): AssetFetchRequest | null { - const demoAsset = parseDemoAssetRoute(assetUrl) - if (demoAsset) { - return { - fetchUrl: knowhereDemoApi.resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent( - demoAsset.demoSourceId, - )}/assets/${demoAsset.encodedAssetPath}`, - ), - canonicalKey: `demo:${demoAsset.demoSourceId}:${demoAsset.decodedAssetPath}`, - sourceSegment: `demo-${toSafePathSegment(demoAsset.demoSourceId)}`, - suggestedFileName: getPathBasename(demoAsset.decodedAssetPath), - } - } - const absoluteUrl = parseAbsoluteHttpUrl(assetUrl) if (!absoluteUrl) return null @@ -364,27 +343,6 @@ function resolveAssetFetchRequest(assetUrl: string): AssetFetchRequest | null { } } -function parseDemoAssetRoute(assetUrl: string): DemoAssetRoute | null { - const pathname = getAssetUrlPathname(assetUrl) - const match = /^\/api\/demo-sources\/([^/]+)\/assets\/(.+)$/.exec(pathname) - const encodedDemoSourceId = match?.[1] - const encodedAssetPath = match?.[2] - if (!encodedDemoSourceId || !encodedAssetPath) return null - - const demoSourceId = decodeUrlComponent(encodedDemoSourceId) - const assetPathSegments = encodedAssetPath - .split("/") - .map(decodeUrlComponent) - .filter((segment): boolean => segment.length > 0) - if (!demoSourceId || assetPathSegments.length === 0) return null - - return { - demoSourceId, - encodedAssetPath: assetPathSegments.map(encodeURIComponent).join("/"), - decodedAssetPath: assetPathSegments.join("/"), - } -} - function isNotebookOwnedAssetUrl(assetUrl: string): boolean { const pathname = getAssetUrlPathname(assetUrl).toLowerCase() if ( diff --git a/src/domains/chat/media-assets.test.ts b/src/domains/chat/media-assets.test.ts index f79338b..7cdd1fe 100644 --- a/src/domains/chat/media-assets.test.ts +++ b/src/domains/chat/media-assets.test.ts @@ -304,7 +304,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-06-04T00:00:00Z"), updatedAt: new Date("2026-06-04T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/prompt-templates.ts b/src/domains/chat/prompt-templates.ts index 7b92d74..530a8c0 100644 --- a/src/domains/chat/prompt-templates.ts +++ b/src/domains/chat/prompt-templates.ts @@ -3,38 +3,3 @@ export type ChatPromptTemplate = { readonly title: string readonly prompt: string } - -export const chatPromptTemplates: readonly ChatPromptTemplate[] = [ - { - id: "ipo-prospectus-risk-mining", - title: "IPO Prospectus Risk Mining", - prompt: [ - "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].", - "Please complete the following tasks:", - '1. Extract all risk items from the "Risk Factors" section and categorize them into: Market Risk/Operational Risk/Legal and Compliance Risk/Technical Risk/Competitive Risk.', - '2. Identify which risk items use hedging language such as "may", "might", or "could", and which use more definitive language such as "will" or "has". Provide the results in a structured format.', - ].join("\n"), - }, - { - id: "earnings-call-transcript-analysis", - title: "Earnings Call Transcript Analysis", - prompt: [ - "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].", - "Please complete the following tasks:", - "1. Extract the management's original wording on the following topics: Revenue guidance/Gross margin pressure/Specific business line.", - "2. Identify analyst questions that management sidestepped or shifted away from.", - "3. Extract all forward-looking statements that contain specific numbers, and organize them into a guidance tracking table.", - ].join("\n"), - }, - { - id: "research-paper-method-comparison", - title: "Research Paper Method Comparison", - prompt: [ - "You are a PhD researcher writing a paper in [Research Area]. I have uploaded recent top conference and journal papers in this area.", - "Please analyze the papers and produce the following:", - "1. Extract the three core elements for each paper: Dataset/Evaluation metrics/Model architecture. Present the results in a comparison table.", - '2. Identify the unresolved issues repeatedly mentioned in the "Limitations" or "Future Work" sections across the papers, and present them as a list.', - "3. Identify emerging technical terms appearing in the papers, assess whether they indicate a new research trend, and output a list of trend keywords.", - ].join("\n"), - }, -] as const diff --git a/src/domains/chat/prompt.ts b/src/domains/chat/prompt.ts index b13831e..2404d74 100644 --- a/src/domains/chat/prompt.ts +++ b/src/domains/chat/prompt.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" -import { CHAT_MODEL } from "@/lib/ai" +import { getChatModel, getChatModelLabel, isChatConfigured } from "@/lib/ai" import { logger } from "@/lib/logger" import type { Source } from "@/infrastructure/db/schema" import type { ChatCitationView } from "@/domains/chat/types" @@ -35,11 +35,12 @@ export const generateAgenticOutputManifestEffect = ( input: GenerateAgenticOutputManifestInput, ): Effect.Effect => Effect.gen(function* () { - if (!process.env.AI_GATEWAY_API_KEY) { + if (!isChatConfigured()) { return yield* Effect.die( new Error( - "AI_GATEWAY_API_KEY environment variable is required. " + - "Set it in your .env.local file.", + "Chat is not configured. Set either AI_GATEWAY_API_KEY " + + "(Vercel AI Gateway) or CHAT_BASE_URL + CHAT_MODEL + CHAT_API_KEY " + + "(OpenAI-compatible) in .env.local.", ), ) } @@ -47,7 +48,7 @@ export const generateAgenticOutputManifestEffect = ( const turn = buildNotebookHarnessTurn(input) logger.info("chat-agent: harness request", { operation: "generateAgenticOutputManifest.initial", - model: CHAT_MODEL, + model: getChatModelLabel(), surface: turn.surface, recentTurnCount: turn.recentTurns.length, messageCharLength: turn.userText.length, @@ -55,7 +56,7 @@ export const generateAgenticOutputManifestEffect = ( const result = yield* Effect.tryPromise(() => runAgentHarness({ - model: CHAT_MODEL, + model: getChatModel(), turn, retrieval: { query: (request) => @@ -66,7 +67,7 @@ export const generateAgenticOutputManifestEffect = ( logger.info("chat-agent: harness response", { operation: "generateAgenticOutputManifest.final", - model: CHAT_MODEL, + model: getChatModelLabel(), answerLength: result.manifest.text.length, citationCount: result.manifest.citations.length, artifactCount: result.manifest.artifacts.length, @@ -139,6 +140,7 @@ function toAgenticRetrievalTargetContent( modalities: readonly TargetModality[], ): AgenticRetrievalTargetContent { const requestedModalities = new Set(modalities) + if (requestedModalities.has("page")) return "page" if (requestedModalities.has("image") && requestedModalities.has("text")) { return "text_image" } diff --git a/src/domains/chat/repository.ts b/src/domains/chat/repository.ts index 30ed1a5..1e49080 100644 --- a/src/domains/chat/repository.ts +++ b/src/domains/chat/repository.ts @@ -8,7 +8,6 @@ type ChatRepository = { readonly listThreadsForWorkspaceEffect: typeof chatThreadRepository.listThreadsForWorkspaceEffect readonly createThreadEffect: typeof chatThreadRepository.createThreadEffect readonly ensureDefaultThreadEffect: typeof chatThreadRepository.ensureDefaultThreadEffect - readonly ensureDemoThreadEffect: typeof chatThreadRepository.ensureDemoThreadEffect readonly listMessagesForThreadEffect: typeof chatMessageRepository.listMessagesForThreadEffect readonly softDeleteThreadEffect: typeof chatThreadRepository.softDeleteThreadEffect readonly appendMessageToThreadEffect: typeof chatMessageRepository.appendMessageToThreadEffect @@ -19,7 +18,6 @@ export const chatRepository: ChatRepository = { listThreadsForWorkspaceEffect: chatThreadRepository.listThreadsForWorkspaceEffect, createThreadEffect: chatThreadRepository.createThreadEffect, ensureDefaultThreadEffect: chatThreadRepository.ensureDefaultThreadEffect, - ensureDemoThreadEffect: chatThreadRepository.ensureDemoThreadEffect, listMessagesForThreadEffect: chatMessageRepository.listMessagesForThreadEffect, softDeleteThreadEffect: chatThreadRepository.softDeleteThreadEffect, appendMessageToThreadEffect: chatMessageRepository.appendMessageToThreadEffect, diff --git a/src/domains/chat/request.test.ts b/src/domains/chat/request.test.ts new file mode 100644 index 0000000..5eefb87 --- /dev/null +++ b/src/domains/chat/request.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest" + +import { parseChatRequestBody } from "./request" + +describe("parseChatRequestBody", () => { + it("parses retrieval params with defaults preserved when absent", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: ["source_1"], + }) + + expect(result).toEqual({ + ok: true, + value: { + question: "Question?", + excludedSourceIds: ["source_1"], + }, + }) + }) + + it("passes through valid retrieval params", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: [], + retrievalParams: { + rerank: false, + internalRecallK: 40, + topK: 6, + }, + }) + + expect(result).toEqual({ + ok: true, + value: { + question: "Question?", + excludedSourceIds: [], + retrievalParams: { + rerank: false, + internalRecallK: 40, + topK: 6, + }, + }, + }) + }) + + it("clamps out-of-range retrieval params and drops invalid types", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: [], + retrievalParams: { + internalRecallK: 500, + topK: 0, + }, + }) + + expect(result).toEqual({ + ok: true, + value: { + question: "Question?", + excludedSourceIds: [], + retrievalParams: { + internalRecallK: 50, + topK: 1, + }, + }, + }) + }) + + it("rejects the request when a retrieval param has the wrong type", () => { + const result = parseChatRequestBody({ + message: "Question?", + excludedSourceIds: [], + retrievalParams: { + rerank: "yes" as unknown as boolean, + }, + }) + + expect(result).toEqual({ + ok: false, + message: "Enter a question before sending.", + status: 400, + }) + }) +}) diff --git a/src/domains/chat/request.ts b/src/domains/chat/request.ts index c15a76e..ab219aa 100644 --- a/src/domains/chat/request.ts +++ b/src/domains/chat/request.ts @@ -1,21 +1,36 @@ import { Either, Schema } from "effect" +import type { RetrievalOverrides } from "./contracts" + export type ParsedChatRequest = { question: string threadId?: string excludedSourceIds: string[] + retrievalParams?: RetrievalOverrides } export type ParseChatRequestResult = | { ok: true; value: ParsedChatRequest } | { ok: false; message: string; status: 400 } +const ChatRetrievalParamsSchema = Schema.Struct({ + rerank: Schema.optional(Schema.Boolean), + internalRecallK: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), +}) + const ChatRequestBody = Schema.Struct({ message: Schema.String, threadId: Schema.optional(Schema.String), excludedSourceIds: Schema.optional(Schema.Array(Schema.Unknown)), + retrievalParams: Schema.optional(ChatRetrievalParamsSchema), }) +const maxInternalRecallK = 50 +const minInternalRecallK = 5 +const maxTopK = 12 +const minTopK = 1 + export function parseChatRequestBody(body: unknown): ParseChatRequestResult { return Either.match(Schema.decodeUnknownEither(ChatRequestBody)(body), { onLeft: () => ({ @@ -44,8 +59,50 @@ export function parseChatRequestBody(body: unknown): ParseChatRequestResult { ? parsed.threadId : undefined, excludedSourceIds, + ...(parsed.retrievalParams + ? { retrievalParams: normalizeRetrievalParams(parsed.retrievalParams) } + : {}), }, } }, }) } + +function normalizeRetrievalParams( + params: { + readonly rerank?: boolean + readonly internalRecallK?: number + readonly topK?: number + }, +): RetrievalOverrides | undefined { + let normalized: RetrievalOverrides | undefined + + if (typeof params.rerank === "boolean") { + normalized = { ...normalized, rerank: params.rerank } + } + + const internalRecallK = clampFinite( + params.internalRecallK, + minInternalRecallK, + maxInternalRecallK, + ) + if (internalRecallK !== undefined) { + normalized = { ...normalized, internalRecallK } + } + + const topK = clampFinite(params.topK, minTopK, maxTopK) + if (topK !== undefined) { + normalized = { ...normalized, topK } + } + + return normalized +} + +function clampFinite( + value: number | undefined, + min: number, + max: number, +): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined + return Math.min(Math.max(value, min), max) +} diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts index ea75943..2f7b9e7 100644 --- a/src/domains/chat/route-answer.ts +++ b/src/domains/chat/route-answer.ts @@ -16,7 +16,7 @@ import { sourceService } from "@/domains/sources/service" import { sourceWorkflowRuntime } from "@/domains/sources/workflow-runtime" import { notebookRequestContext } from "@/domains/workspace/request-context" import type { Source } from "@/infrastructure/db/schema" -import { isAuthError } from "@/integrations/dashboard/api-key-service" +import { isAuthError } from "@/integrations/knowhere-credentials" import { summarizeUnknownError } from "@/lib/format-log-value" import { logger } from "@/lib/logger" import { routeResult, type RouteResult } from "@/lib/route-result" @@ -79,6 +79,7 @@ const answerChatEffect = (input: AnswerChatInput) => question: body.value.question, threadId: body.value.threadId, excludedSourceIds: body.value.excludedSourceIds, + retrievalParams: body.value.retrievalParams, retrieval: client.retrieval, generateAnswer: generateAgenticOutputManifest, loadSourceAssetUrls, diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts index 4358e8a..a54299f 100644 --- a/src/domains/chat/route-service.test.ts +++ b/src/domains/chat/route-service.test.ts @@ -366,6 +366,7 @@ function makeWorkspace(overrides: Partial = {}): Workspace { return { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-06T00:00:00Z"), ...overrides, @@ -387,7 +388,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -400,7 +400,6 @@ function makeThread(overrides: Partial = {}): ChatThread { id: "thread_1", workspaceId: "workspace_1", title: "Chat title", - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/service.test.ts b/src/domains/chat/service.test.ts index e931f04..c1f9f81 100644 --- a/src/domains/chat/service.test.ts +++ b/src/domains/chat/service.test.ts @@ -58,6 +58,8 @@ describe("handleChatTurn", () => { query: "What does the document say?", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, excludeDocumentIds: ["doc_excluded"], }); @@ -222,6 +224,8 @@ describe("handleChatTurn", () => { query: "Tesla Q4 2025 Update energy generation and storage deployments", topK: 8, useAgentic: true, + rerank: true, + internalRecallK: 30, dataType: 1, }); }); @@ -255,6 +259,7 @@ function makeWorkspace(overrides: Partial = {}): Workspace { return { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-namespace", createdAt: new Date("2026-05-06T00:00:00Z"), ...overrides, @@ -276,7 +281,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -289,7 +293,6 @@ function makeThread(overrides: Partial = {}): ChatThread { id: "thread_1", workspaceId: "workspace_1", title: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts index 5361d5b..867d530 100644 --- a/src/domains/chat/service.ts +++ b/src/domains/chat/service.ts @@ -14,7 +14,9 @@ import type { ChatArtifactView, ChatCitationView, ChatMessageView, + RetrievalTraceView, } from "@/domains/chat/types" +import type { RetrievalOverrides } from "./contracts" export type ChatRepository = { ensureDefaultChatThread(workspaceId: string): Promise @@ -57,6 +59,7 @@ const threadNotFound = { export type ChatTurnValue = { threadId: string messages: [ChatMessageView, ChatMessageView] + retrievalTrace?: RetrievalTraceView } type ChatTurnInput = { @@ -65,6 +68,7 @@ type ChatTurnInput = { question: string threadId?: string excludedSourceIds: readonly string[] + retrievalParams?: RetrievalOverrides retrieval: RetrievalClient generateAnswer: GenerateAnswer loadSourceAssetUrls?: AnswerQuestionInput["loadSourceAssetUrls"] @@ -129,6 +133,7 @@ export const handleChatTurnEffect = (input: ChatTurnInput) => loadSourceAssetUrls: input.loadSourceAssetUrls, hardenMediaAssetUrls: input.hardenMediaAssetUrls, messages: chatHistoryMessages, + retrievalOverrides: input.retrievalParams, }).pipe(Effect.catchAllCause(Effect.die)) const assistantMessage = yield* tryPromiseOrDie(() => @@ -148,8 +153,14 @@ export const handleChatTurnEffect = (input: ChatTurnInput) => threadId: thread.id, messages: [ toChatMessageView(userMessage), - toChatMessageView(assistantMessage, answer.citations, answer.artifacts), + toChatMessageView( + assistantMessage, + answer.citations, + answer.artifacts, + answer.retrievalTrace, + ), ] as [ChatMessageView, ChatMessageView], + retrievalTrace: answer.retrievalTrace, } }) diff --git a/src/domains/chat/thread-service.ts b/src/domains/chat/thread-service.ts index f0c3401..a3c410e 100644 --- a/src/domains/chat/thread-service.ts +++ b/src/domains/chat/thread-service.ts @@ -1,10 +1,8 @@ import "server-only" import { databaseRuntime } from "@/domains/workspace/database-runtime" -import { demoView } from "@/domains/demo/view" import { chatRepository } from "./repository" import type { ChatMessage, ChatThread } from "@/infrastructure/db/schema" -import type { DemoCatalog } from "@/integrations/knowhere-demo" import type { ChatArtifactView, ChatCitationView, @@ -22,11 +20,6 @@ type AppendMessageInput = { readonly artifacts?: readonly ChatArtifactView[] | null } -type DemoChatThreadSeed = { - readonly thread: ChatThread - readonly messages: ChatMessage[] -} - type ChatThreadService = { readonly findInWorkspace: ( workspaceId: string, @@ -35,10 +28,6 @@ type ChatThreadService = { readonly listForWorkspace: (workspaceId: string) => Promise readonly create: (workspaceId: string) => Promise readonly ensureDefault: (workspaceId: string) => Promise - readonly ensureDemo: ( - workspaceId: string, - catalog: DemoCatalog, - ) => Promise readonly listMessages: ( workspaceId: string, threadId: string, @@ -53,8 +42,6 @@ type ChatThreadService = { ) => Promise } -const seededDemoChatKey = "knowhere-demo-chat" - const findInWorkspace: ChatThreadService["findInWorkspace"] = ( workspaceId: string, threadId: string, @@ -80,23 +67,6 @@ const ensureDefault: ChatThreadService["ensureDefault"] = ( chatRepository.ensureDefaultThreadEffect(workspaceId), ) -const ensureDemo: ChatThreadService["ensureDemo"] = ( - workspaceId: string, - catalog: DemoCatalog, -) => { - const messages = demoView.toChatMessages(catalog) - const firstUserMessage = messages.find((message) => message.role === "user") - if (!firstUserMessage) return Promise.resolve(null) - - return databaseRuntime.runPromise( - chatRepository.ensureDemoThreadEffect(workspaceId, { - demoKey: seededDemoChatKey, - title: firstUserMessage.content, - messages, - }), - ) -} - const listMessages: ChatThreadService["listMessages"] = ( workspaceId: string, threadId: string, @@ -126,7 +96,6 @@ export const chatThreadService: ChatThreadService = { listForWorkspace, create, ensureDefault, - ensureDemo, listMessages, softDelete, appendMessage, diff --git a/src/domains/chat/types.ts b/src/domains/chat/types.ts index f3f987d..89cc03c 100644 --- a/src/domains/chat/types.ts +++ b/src/domains/chat/types.ts @@ -1,11 +1,14 @@ /** * Chat citation / retrieval hit. Mirrors RetrievalResult from the SDK. - * No chunkId here; retrieval does not expose one. + * chunkId is the parser-provided chunk identifier returned by retrieval + * when available; it lets citations resolve to page chunks by id even + * when content is a snippet window. */ export type RetrievalResultView = { readonly content: string readonly chunkType: string readonly score: number | null + readonly chunkId?: string readonly assetUrl?: string readonly source: { readonly documentId?: string | null @@ -50,6 +53,32 @@ export type ChatMessageView = { readonly content: string readonly citations?: readonly ChatCitationView[] readonly artifacts?: readonly ChatArtifactView[] + readonly retrievalTrace?: RetrievalTraceView +} + +/** + * Transient retrieval trace attached to a fresh assistant message. It is + * returned by the chat route and held in client state only; it is never + * persisted to the chat message row. + */ +export type RetrievalTraceEntryView = { + readonly query: string + readonly namespace: string + readonly resultCount: number + readonly referencedChunkCount: number + readonly topScores: readonly number[] +} + +export type RetrievalTraceView = { + /** Wall-clock time to answer the question, in seconds (1 decimal). */ + readonly durationSeconds?: number + /** Total LLM step calls made by the agent harness for this answer. */ + readonly llmCallCount?: number + /** Total input tokens consumed by the harness for this answer. */ + readonly inputTokens?: number + /** Total output tokens produced by the harness for this answer. */ + readonly outputTokens?: number + readonly queries: readonly RetrievalTraceEntryView[] } export type ChatThreadView = { diff --git a/src/domains/chat/view.ts b/src/domains/chat/view.ts index 9afc4f6..9a013be 100644 --- a/src/domains/chat/view.ts +++ b/src/domains/chat/view.ts @@ -5,6 +5,7 @@ import type { ChatCitationView, ChatMessageView, ChatThreadView, + RetrievalTraceView, } from "@/domains/chat/types" export function toChatThreadView(thread: ChatThread): ChatThreadView { @@ -20,6 +21,7 @@ export function toChatMessageView( message: ChatMessage, citations: readonly ChatCitationView[] = [], artifacts?: readonly ChatArtifactView[], + retrievalTrace?: RetrievalTraceView, ): ChatMessageView { const citationViews = citations.length > 0 @@ -37,6 +39,7 @@ export function toChatMessageView( content: message.content, citations: citationViews, ...(artifactViews !== undefined ? { artifacts: artifactViews } : {}), + ...(retrievalTrace ? { retrievalTrace } : {}), } } @@ -50,6 +53,7 @@ function toPersistedCitationViews(value: unknown): ChatCitationView[] | undefine { chunkType: getString(item.chunkType) ?? "text", score: getNumber(item.score) ?? 0, + chunkId: getString(item.chunkId), assetUrl: getString(item.assetUrl), description: getString(item.description), source: { diff --git a/src/domains/chunks/index.test.ts b/src/domains/chunks/index.test.ts index 0dfc22f..ba490da 100644 --- a/src/domains/chunks/index.test.ts +++ b/src/domains/chunks/index.test.ts @@ -9,6 +9,7 @@ import { loadChunksForSource, resolveChunkConnectionTargets, resolveCitationChunk, + resolveCitationChunkByContent, toParsedChunkView, } from "." import type { ChatCitationView } from "@/domains/chat/types" @@ -518,6 +519,94 @@ describe("resolveCitationChunk", () => { expect(chunk).toBeNull(); }); + + it("resolves a snippet-window citation to its page chunk by chunkId", () => { + const chunk = resolveCitationChunk( + makeRetrievalResultView({ + content: "…window around CHEUNG Hon-lam Gordon…", + chunkId: "parser_page_1", + source: { + documentId: "doc_123", + sourceFileName: "directory.pdf", + sectionPath: "Page 3", + }, + }), + [ + makeParsedChunkView({ + chunkId: "row_page_1", + parserChunkId: "parser_page_1", + type: "page", + sectionPath: "Page 3", + content: "CHEUNG Hon-lam Gordon 2835 2147", + }), + makeParsedChunkView({ + chunkId: "row_page_2", + parserChunkId: "parser_page_2", + type: "page", + sectionPath: "Page 4", + content: "YUEN Chun-cheung Gordon 3752 8030", + }), + ], + ); + + expect(chunk?.chunkId).toBe("row_page_1"); + }); + + it("prefers a chunkId match over a content excerpt match on a different chunk", () => { + const chunk = resolveCitationChunk( + makeRetrievalResultView({ + content: "exact sentence from the second chunk", + chunkId: "parser_first", + source: { + documentId: "doc_123", + sourceFileName: "notes.txt", + sectionPath: "Shared Section", + }, + }), + [ + makeParsedChunkView({ + chunkId: "chunk_first", + parserChunkId: "parser_first", + sectionPath: "Shared Section", + content: "different sentence from the first chunk", + }), + makeParsedChunkView({ + chunkId: "chunk_second", + parserChunkId: "parser_second", + sectionPath: "More Specific Section", + content: "prefix exact sentence from the second chunk suffix", + }), + ], + ); + + expect(chunk?.chunkId).toBe("chunk_first"); + }); +}); + +describe("resolveCitationChunkByContent", () => { + it("resolves by chunkId when the loaded chunk set is partial", () => { + const chunk = resolveCitationChunkByContent( + makeRetrievalResultView({ + content: "…snippet window without an excerpt match…", + chunkId: "parser_page_1", + source: { + documentId: "doc_123", + sourceFileName: "directory.pdf", + sectionPath: "Page 3", + }, + }), + [ + makeParsedChunkView({ + chunkId: "row_page_1", + parserChunkId: "parser_page_1", + type: "page", + content: "CHEUNG Hon-lam Gordon 2835 2147", + }), + ], + ); + + expect(chunk?.chunkId).toBe("row_page_1"); + }); }); function makeDocumentChunk( @@ -584,7 +673,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chunks/index.ts b/src/domains/chunks/index.ts index 8a285d7..29ca6fd 100644 --- a/src/domains/chunks/index.ts +++ b/src/domains/chunks/index.ts @@ -32,6 +32,13 @@ export type ChunkKnowhereClient = { totalPages?: number } }> + getChunk?( + documentId: string, + chunkId: string, + params?: { includeAssetUrls?: boolean }, + ): Promise<{ + chunk: DocumentChunk & { assetUrl?: string | null } + }> } } diff --git a/src/domains/chunks/normalization.ts b/src/domains/chunks/normalization.ts index c6590b4..559277c 100644 --- a/src/domains/chunks/normalization.ts +++ b/src/domains/chunks/normalization.ts @@ -87,6 +87,9 @@ function resolveCitationChunk( chunks: readonly ParsedChunkView[], ): ParsedChunkView | null { const documentChunks = getCitationDocumentChunks(citation, chunks) + const byId = findUniqueByChunkId(documentChunks, citation.chunkId) + if (byId) return byId + const byContent = findByContent(documentChunks, citation.content) if (byContent) return byContent @@ -103,10 +106,11 @@ function resolveCitationChunkByContent( citation: ChatCitationView, chunks: readonly ParsedChunkView[], ): ParsedChunkView | null { - return findByContent( - getCitationDocumentChunks(citation, chunks), - citation.content, - ) + const documentChunks = getCitationDocumentChunks(citation, chunks) + const byId = findUniqueByChunkId(documentChunks, citation.chunkId) + if (byId) return byId + + return findByContent(documentChunks, citation.content) } function normalizeChunkType(value: unknown): ChunkType { @@ -186,6 +190,15 @@ function getConnectionPosition( return { start, end } } +function findUniqueByChunkId( + chunks: readonly ParsedChunkView[], + chunkId: string | undefined, +): ParsedChunkView | null { + if (!chunkId) return null + const matches = chunks.filter((chunk) => chunk.parserChunkId === chunkId) + return matches.length === 1 ? matches[0]! : null +} + function findUniqueBySectionPath( chunks: readonly ParsedChunkView[], sectionPath: string | null | undefined, diff --git a/src/domains/chunks/server.test.ts b/src/domains/chunks/server.test.ts index f58f5ea..c7b36b1 100644 --- a/src/domains/chunks/server.test.ts +++ b/src/domains/chunks/server.test.ts @@ -221,7 +221,7 @@ describe("server chunk cache", () => { expect(warmTasks).toHaveLength(1) }) - it("uses structure-only chunk loading for full-tree requests", async () => { + it("loads full-tree requests with asset URLs and enrichment (visible mode)", async () => { const warmTasks: Array<() => Promise> = [] const cacheStore = createCacheStore() const listChunks = vi.fn(async () => ({ @@ -257,16 +257,17 @@ describe("server chunk cache", () => { ) expect(chunks).toMatchObject([{ chunkId: "text_1" }]) + // The load-all path (citation focus + tree) requests asset URLs so + // table/image chunks carry real content instead of summaries. expect(listChunks).toHaveBeenCalledWith("doc_1", { page: 1, pageSize: 200, - includeAssetUrls: false, + includeAssetUrls: true, }) - expect(fetchAsset).not.toHaveBeenCalled() expect(warmTasks).toHaveLength(1) await warmTasks[0]?.() expect(cacheStore.putMock).toHaveBeenCalledWith( - expect.stringContaining("/structure/page-1-size-200.json"), + expect.stringContaining("/visible/page-1-size-200.json"), expect.any(String), expect.objectContaining({ contentType: "application/json; charset=utf-8" }), ) @@ -425,7 +426,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/chunks/server.ts b/src/domains/chunks/server.ts index a28ee3b..8f291df 100644 --- a/src/domains/chunks/server.ts +++ b/src/domains/chunks/server.ts @@ -88,7 +88,6 @@ type MirrorableChunkAsset = { const documentChunkPageSize = 200 const visibleChunkPageMode: ChunkPageMode = "visible" -const structureChunkPageMode: ChunkPageMode = "structure" const maximumMirroredAssetsPerWarmStep = 50 const maximumWarmStepDurationMs = 45_000 const assetMirrorConcurrency = 10 @@ -104,6 +103,18 @@ const defaultBlobStore: ChunkPageBlobStore = { }), } +/** + * The chunk-page cache is a best-effort optimization backed by Vercel Blob. + * Local/self-hosted dev (and any deploy without `BLOB_READ_WRITE_TOKEN`) has + * no Blob store, so `@vercel/blob` calls throw "No token found". Treat a + * missing token as "cache unavailable" and fetch from Knowhere directly + * instead of crashing the chunks route. An explicitly injected `cacheStore` + * (tests / custom stores) bypasses this gate. + */ +function isBlobCacheConfigured(): boolean { + return Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim()) +} + const defaultFetchAsset: FetchChunkAsset = (assetUrl: string) => fetch(assetUrl) const defaultScheduleWarm: ChunkPageWarmScheduler = ( @@ -129,12 +140,15 @@ export const loadChunksForSource = ( let totalPages = 1 do { + // Visible mode (asset URLs + table/image HTML enrichment) so chunks + // served through the load-all path — citation focus and the section + // tree — render real content, not summaries. const chunkPage = yield* loadChunkPageForSource(source, client, { page, pageSize: documentChunkPageSize, }, { ...options, - mode: structureChunkPageMode, + mode: options.mode ?? visibleChunkPageMode, }) chunks.push(...chunkPage.chunks) totalPages = chunkPage.pagination.totalPages @@ -159,6 +173,8 @@ export const loadChunkPageForSource = ( const mode = options.mode ?? visibleChunkPageMode const workspaceId = options.workspaceId ?? source.workspaceId const cacheStore = options.cacheStore ?? defaultBlobStore + const cacheAvailable = + options.cacheStore !== undefined || isBlobCacheConfigured() const includeAssetUrls = mode === visibleChunkPageMode const revisionProbeResponse = yield* Effect.promise(() => client.documents.listChunks(source.knowhereDocumentId!, { @@ -170,16 +186,31 @@ export const loadChunkPageForSource = ( const probeRevisionKey = getRevisionKey(revisionProbeResponse, source) if (probeRevisionKey) { scheduleRevisionKeyUpdate(source, probeRevisionKey, options.onRevisionKey) - const cachedPage = yield* Effect.promise(() => - readCachedChunkPage({ - cacheStore, - documentId: source.knowhereDocumentId!, - mode, - params, - revisionKey: probeRevisionKey, - workspaceId, - }), - ) + const cachedPage = cacheAvailable + ? yield* Effect.promise(() => + readCachedChunkPage({ + cacheStore, + documentId: source.knowhereDocumentId!, + mode, + params, + revisionKey: probeRevisionKey, + workspaceId, + }), + ).pipe( + Effect.catchAll((error) => + Effect.sync(() => { + logger.warn("chunks: cached chunk page read failed", { + documentId: source.knowhereDocumentId, + page: params.page, + pageSize: params.pageSize, + revisionKey: probeRevisionKey, + error: getErrorMessage(error), + }) + return null + }), + ), + ) + : null if (cachedPage) return cachedPage } @@ -192,6 +223,13 @@ export const loadChunkPageForSource = ( }), ) : revisionProbeResponse + if (includeAssetUrls) { + yield* Effect.tryPromise(() => + enrichChunksWithAssetUrls(source.knowhereDocumentId!, response), + ).pipe( + Effect.catchAll(() => Effect.void), + ) + } const revisionKey = getRevisionKey(response, source) ?? probeRevisionKey if (revisionKey && revisionKey !== probeRevisionKey) { scheduleRevisionKeyUpdate(source, revisionKey, options.onRevisionKey) @@ -207,7 +245,7 @@ export const loadChunkPageForSource = ( : {}, }) - if (revisionKey) { + if (revisionKey && cacheAvailable) { if (mode === visibleChunkPageMode) { scheduleChunkPageWarm({ source, @@ -652,6 +690,53 @@ function getMirroredAssetContentType( return "application/octet-stream" } +async function enrichChunksWithAssetUrls( + documentId: string, + response: { readonly chunks: readonly DocumentChunk[] }, +): Promise { + const tableChunks = response.chunks.filter( + (chunk) => + chunk.chunkType === "table" && chunk.assetUrl && chunk.id, + ) + if (tableChunks.length === 0) return + + const fetchAsset = defaultFetchAsset + const results = await Promise.allSettled( + tableChunks.map(async (chunk): Promise<{ chunkId: string; html: string | null }> => { + try { + const res = await fetchAsset(chunk.assetUrl!) + if (!res.ok) return { chunkId: chunk.id, html: null } + const html = await res.text() + return { chunkId: chunk.id, html } + } catch { + return { chunkId: chunk.id, html: null } + } + }), + ) + + const htmlByChunkId = new Map() + for (const result of results) { + if (result.status === "fulfilled" && result.value.html) { + htmlByChunkId.set(result.value.chunkId, result.value.html) + } + } + + let enriched = 0 + for (const chunk of response.chunks as DocumentChunk[]) { + const html = htmlByChunkId.get(chunk.id) + if (html) { + ;(chunk as DocumentChunk & { content?: string | null }).content = html + enriched++ + } + } + if (enriched > 0) { + logger.info("chunks: enriched table chunks with inline HTML", { + documentId, + enriched, + }) + } +} + function getRevisionKey( response: Pick, source: Source, diff --git a/src/domains/demo/original-file.test.ts b/src/domains/demo/original-file.test.ts deleted file mode 100644 index 7d19a40..0000000 --- a/src/domains/demo/original-file.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { demoOriginalFile } from "@/domains/demo/original-file" - -describe("demoOriginalFile", () => { - it("keeps public original URLs for demo source preview", () => { - expect( - demoOriginalFile.getPublicUrl( - makeDemoOriginalSource({ - originalUrl: "https://example.com/report.pdf", - }), - ), - ).toBe("https://example.com/report.pdf") - }) - - it("falls back to the Official Library file URL instead of Knowhere API originals", () => { - const source = makeDemoOriginalSource({ - originalUrl: - "https://api.knowhere.example/api/v1/demo/sources/demo-report/original", - sourceUrl: "https://example.com/library-report.pdf", - }) - - expect(demoOriginalFile.getPublicUrl(source)).toBe( - "https://example.com/library-report.pdf", - ) - expect(demoOriginalFile.toSourceOriginalFileView(source)).toMatchObject({ - url: "https://example.com/library-report.pdf", - pdfPreviewMode: "browser", - }) - }) - - it("returns no original URL for legacy demo originals without a public file", () => { - expect( - demoOriginalFile.getPublicUrl( - makeDemoOriginalSource({ - originalUrl: - "https://api.knowhere.example/api/v1/demo/sources/demo-report/original", - }), - ), - ).toBeNull() - }) -}) - -function makeDemoOriginalSource({ - originalUrl, - sourceUrl, -}: { - readonly originalUrl: string - readonly sourceUrl?: string -}): Parameters[0] { - return { - originalFile: { - url: originalUrl, - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - }, - ...(sourceUrl - ? { - officialLibrary: { - sourceUrl, - }, - } - : {}), - } -} diff --git a/src/domains/demo/original-file.ts b/src/domains/demo/original-file.ts deleted file mode 100644 index e31b79f..0000000 --- a/src/domains/demo/original-file.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { SourceOriginalFileView } from "@/domains/sources/types" - -type DemoOriginalSource = { - readonly originalFile: { - readonly url: string - readonly mimeType: string - readonly sizeBytes: number - readonly canDownload: boolean - } - readonly officialLibrary?: { - readonly sourceUrl: string - } -} - -export const demoOriginalFile = { - getPublicUrl, - toSourceOriginalFileView, -} as const - -function toSourceOriginalFileView( - source: DemoOriginalSource, -): SourceOriginalFileView | null { - const url = getPublicUrl(source) - if (!url) return null - - return { - url, - mimeType: source.originalFile.mimeType, - sizeBytes: source.originalFile.sizeBytes, - canDownload: source.originalFile.canDownload, - pdfPreviewMode: "browser", - } -} - -function getPublicUrl(source: DemoOriginalSource): string | null { - const originalUrl = toPublicHttpUrl(source.originalFile.url) - if (originalUrl) return originalUrl - - return source.officialLibrary - ? toPublicHttpUrl(source.officialLibrary.sourceUrl) - : null -} - -function toPublicHttpUrl(value: string): string | null { - try { - const parsedUrl = new URL(value) - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - return null - } - if (isDemoOriginalProxyPath(parsedUrl.pathname)) return null - return parsedUrl.toString() - } catch { - return null - } -} - -function isDemoOriginalProxyPath(pathname: string): boolean { - return ( - /^\/api\/v1\/demo\/sources\/[^/]+\/original\/?$/.test(pathname) || - /^\/api\/demo-sources\/[^/]+\/original\/?$/.test(pathname) - ) -} diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts deleted file mode 100644 index 0b9a39b..0000000 --- a/src/domains/demo/view.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { parsedChunkNormalization } from "@/domains/chunks/normalization" -import type { ChatMessageView } from "@/domains/chat/types" -import type { ParsedChunkView } from "@/domains/chunks/types" -import { demoOriginalFile } from "@/domains/demo/original-file" -import type { SourceView } from "@/domains/sources/types" -import type { - DemoCatalog, - DemoChunk, - DemoSource, -} from "@/integrations/knowhere-demo" - -export const demoView = { - toChatMessages, - toParsedChunkView, - toSourceView, -} as const - -function toSourceView(source: DemoSource): SourceView { - const originalFile = demoOriginalFile.toSourceOriginalFileView(source) - - return { - id: source.demoSourceId, - kind: "demo", - demoSourceId: source.demoSourceId, - title: source.title, - mimeType: source.mimeType, - status: "ready", - documentId: source.canonicalDocumentId, - ...(originalFile ? { originalFile } : {}), - ...(source.officialLibrary - ? { - officialLibrary: { - librarySourceId: source.officialLibrary.librarySourceId, - categoryId: source.officialLibrary.categoryId, - sourceUrl: source.officialLibrary.sourceUrl, - }, - } - : {}), - chunkCount: source.chunkCount, - } -} - -function toChatMessages(catalog: DemoCatalog): ChatMessageView[] { - return catalog.sources.flatMap((source) => - source.examples.flatMap((example): ChatMessageView[] => [ - { - id: `${example.id}-user`, - role: "user", - content: example.question, - }, - { - id: `${example.id}-assistant`, - role: "assistant", - content: example.answer, - citations: example.citations.map((citation) => ({ - chunkType: citation.chunkType, - score: 0.95, - content: citation.content, - ...(citation.description - ? { description: citation.description } - : {}), - source: { - documentId: citation.canonicalDocumentId, - sourceFileName: citation.source.sourceFileName, - sectionPath: citation.source.sectionPath, - }, - })), - }, - ]), - ) -} - -function toParsedChunkView( - source: SourceView, - chunk: DemoChunk, -): ParsedChunkView { - return parsedChunkNormalization.createParsedChunkView({ - chunkId: chunk.id, - parserChunkId: chunk.chunkId, - documentId: source.documentId, - sectionPath: chunk.sectionPath, - chunkType: chunk.chunkType, - content: chunk.content, - metadata: chunk.metadata, - filePathCandidates: [chunk.filePath], - assetUrl: chunk.assetUrl, - sourceTitle: source.title, - }) -} diff --git a/src/domains/demo/workspace-source-resolution.ts b/src/domains/demo/workspace-source-resolution.ts deleted file mode 100644 index 39abc5a..0000000 --- a/src/domains/demo/workspace-source-resolution.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Source } from "@/infrastructure/db/schema" -import type { DemoCatalog } from "@/integrations/knowhere-demo" - -type WorkspaceDemoSourceResolution = { - readonly materializedDemoSourceIds: ReadonlySet - readonly workspaceSources: readonly Source[] -} - -type SourceViewOptions = { - readonly chunkCount?: number -} - -export function resolveWorkspaceDemoSources( - sources: readonly Source[], - catalog: DemoCatalog, -): WorkspaceDemoSourceResolution { - const canonicalDocumentIdByDemoSourceId: Map = new Map( - catalog.sources.map((source) => [ - source.demoSourceId, - source.canonicalDocumentId, - ]), - ) - const workspaceSources: Source[] = sources.filter( - (source) => - !isLegacyCanonicalDemoSource(source, canonicalDocumentIdByDemoSourceId), - ) - const materializedDemoSourceIds: Set = new Set( - workspaceSources.flatMap((source) => { - if (!isMaterializedDemoSource(source, canonicalDocumentIdByDemoSourceId)) { - return [] - } - return source.demoKey ? [source.demoKey] : [] - }), - ) - - return { - materializedDemoSourceIds, - workspaceSources, - } -} - -export function getWorkspaceSourcesNeedingKnowhereChunkCount( - sources: readonly Source[], -): Source[] { - return sources.filter((source) => !source.demoKey) -} - -export function getMaterializedDemoSourceViewOptionsBySourceId( - sources: readonly Source[], - catalog: DemoCatalog, -): ReadonlyMap { - const chunkCountByDemoSourceId: ReadonlyMap = new Map( - catalog.sources.map((source) => [source.demoSourceId, source.chunkCount]), - ) - - return new Map( - sources.flatMap((source): readonly [string, SourceViewOptions][] => { - if (!source.demoKey) return [] - - const chunkCount = chunkCountByDemoSourceId.get(source.demoKey) - if (chunkCount === undefined) return [] - - return [[source.id, { chunkCount }]] - }), - ) -} - -function isLegacyCanonicalDemoSource( - source: Source, - canonicalDocumentIdByDemoSourceId: ReadonlyMap, -): boolean { - if (!source.demoKey) return false - if ( - source.knowhereJobId === null && - (source.knowhereDocumentId === null || - source.knowhereDocumentId.startsWith("demo-doc-")) - ) { - return true - } - - const canonicalDocumentId = canonicalDocumentIdByDemoSourceId.get( - source.demoKey, - ) - if (canonicalDocumentId === undefined) return false - return source.knowhereDocumentId === canonicalDocumentId -} - -function isMaterializedDemoSource( - source: Source, - canonicalDocumentIdByDemoSourceId: ReadonlyMap, -): boolean { - if (!source.demoKey || !source.knowhereDocumentId) return false - const canonicalDocumentId = canonicalDocumentIdByDemoSourceId.get( - source.demoKey, - ) - return ( - canonicalDocumentId === undefined || - source.knowhereDocumentId !== canonicalDocumentId - ) -} diff --git a/src/domains/sources/background-reconcile.test.ts b/src/domains/sources/background-reconcile.test.ts index f6ad6f3..769bd45 100644 --- a/src/domains/sources/background-reconcile.test.ts +++ b/src/domains/sources/background-reconcile.test.ts @@ -5,6 +5,9 @@ const mocks = vi.hoisted(() => ({ loggerInfo: vi.fn(), loggerWarn: vi.fn(), trigger: vi.fn(), + makeKnowhereClient: vi.fn(), + pollSourceReconciliation: vi.fn(), + markSourceReadyAfterReconciliation: vi.fn(), })) vi.mock("@upstash/workflow", () => ({ @@ -21,6 +24,15 @@ vi.mock("@/lib/logger", () => ({ }, })) +vi.mock("@/integrations/knowhere", () => ({ + makeKnowhereClient: mocks.makeKnowhereClient, +})) + +vi.mock("./source-reconcile-workflow", () => ({ + pollSourceReconciliation: mocks.pollSourceReconciliation, + markSourceReadyAfterReconciliation: mocks.markSourceReadyAfterReconciliation, +})) + describe("startBackgroundReconciliation", () => { afterEach(() => { vi.clearAllMocks() @@ -88,4 +100,77 @@ describe("startBackgroundReconciliation", () => { retries: 3, }) }) + + it("polls locally and marks the source ready when QStash is not configured", async () => { + vi.useFakeTimers() + mocks.makeKnowhereClient.mockReturnValue({ client: "client_1" }) + mocks.pollSourceReconciliation.mockResolvedValueOnce({ + kind: "waiting", + jobId: "job_1", + jobStatus: "processing", + }) + mocks.pollSourceReconciliation.mockResolvedValueOnce({ + kind: "ready-to-prepare", + jobId: "job_1", + documentId: "doc_1", + }) + mocks.markSourceReadyAfterReconciliation.mockResolvedValue({ + status: "ready", + }) + + const { startBackgroundReconciliation } = await import( + "./background-reconcile" + ) + + await startBackgroundReconciliation( + "workspace_1", + "source_1", + "knowhere_key", + ) + + // First attempt runs immediately (waiting), then the poll loop waits + // before the next attempt. + expect(mocks.pollSourceReconciliation).toHaveBeenCalledTimes(1) + expect(mocks.makeKnowhereClient).toHaveBeenCalledWith("knowhere_key") + expect(mocks.trigger).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(3_000) + + expect(mocks.pollSourceReconciliation).toHaveBeenCalledTimes(2) + expect(mocks.markSourceReadyAfterReconciliation).toHaveBeenCalledWith({ + workspaceId: "workspace_1", + sourceId: "source_1", + documentId: "doc_1", + }) + }) + + it("does not start a duplicate local poller for the same source", async () => { + vi.useFakeTimers() + mocks.makeKnowhereClient.mockReturnValue({ client: "client_1" }) + mocks.pollSourceReconciliation.mockResolvedValue({ + kind: "waiting", + jobId: "job_1", + jobStatus: "processing", + }) + + const { startBackgroundReconciliation } = await import( + "./background-reconcile" + ) + + await startBackgroundReconciliation( + "workspace_1", + "source_1", + "knowhere_key", + ) + await startBackgroundReconciliation( + "workspace_1", + "source_1", + "knowhere_key", + ) + + expect(mocks.pollSourceReconciliation).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(3_000) + expect(mocks.pollSourceReconciliation).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/domains/sources/background-reconcile.ts b/src/domains/sources/background-reconcile.ts index 604ad64..02fcf6b 100644 --- a/src/domains/sources/background-reconcile.ts +++ b/src/domains/sources/background-reconcile.ts @@ -4,6 +4,11 @@ import { Effect } from "effect" import { Client } from "@upstash/workflow" import { logger } from "@/lib/logger" +import { makeKnowhereClient } from "@/integrations/knowhere" +import { + markSourceReadyAfterReconciliation, + pollSourceReconciliation, +} from "./source-reconcile-workflow" // Re-trigger protection: bounded process guard plus bucketed workflow IDs. // @@ -18,6 +23,107 @@ function resolveBaseURL(): string { return process.env.NOTEBOOK_PUBLIC_URL ?? "http://localhost:3000" } +// --------------------------------------------------------------------------- +// Local (no-QStash) poll fallback +// --------------------------------------------------------------------------- +// +// Self-hosted deployments usually have no QSTASH_TOKEN, so the Upstash +// workflow trigger is skipped and parsing sources would stay "parsing" +// forever. When QStash is unavailable, poll the Knowhere job from this +// process with backoff (mirroring the workflow's poll-and-ready loop) and +// mark the source ready once the job is done. + +const localPollMaxAttempts = 25 +const localPollInitialDelayMs = 3_000 +const localPollMaxDelayMs = 30_000 + +const activeLocalPollersBySourceId: Map = new Map() + +function runLocalReconciliation( + workspaceId: string, + sourceId: string, + apiKey: string, +): void { + if (activeLocalPollersBySourceId.has(sourceId)) return + activeLocalPollersBySourceId.set(sourceId, true) + + const client = makeKnowhereClient(apiKey) + let delayMs = localPollInitialDelayMs + let finished = false + + const finish = (): void => { + if (finished) return + finished = true + activeLocalPollersBySourceId.delete(sourceId) + } + + const scheduleNext = (attempt: number): void => { + setTimeout(() => { + void pollOnce(attempt) + }, delayMs) + delayMs = Math.min(Math.round(delayMs * 1.5), localPollMaxDelayMs) + } + + const pollOnce = async (attempt: number): Promise => { + if (attempt >= localPollMaxAttempts) { + logger.warn( + "background-reconcile: local poll exhausted attempts; source stays parsing", + { sourceId, workspaceId, attempts: attempt }, + ) + finish() + return + } + + try { + const poll = await pollSourceReconciliation({ + workspaceId, + sourceId, + client, + }) + + if (poll.kind === "resolved") { + logger.info("background-reconcile: local poll resolved", { + sourceId, + workspaceId, + status: poll.status, + attempts: attempt + 1, + }) + finish() + return + } + + if (poll.kind === "ready-to-prepare") { + const ready = await markSourceReadyAfterReconciliation({ + workspaceId, + sourceId, + documentId: poll.documentId, + }) + logger.info("background-reconcile: local poll marked source ready", { + sourceId, + workspaceId, + status: ready.status, + attempts: attempt + 1, + }) + finish() + return + } + + // Still parsing: wait and poll again. + scheduleNext(attempt + 1) + } catch (error) { + logger.error("background-reconcile: local poll attempt failed", { + sourceId, + workspaceId, + attempt: attempt + 1, + message: error instanceof Error ? error.message : String(error), + }) + scheduleNext(attempt + 1) + } + } + + void pollOnce(0) +} + // --------------------------------------------------------------------------- // Effect core // --------------------------------------------------------------------------- @@ -40,11 +146,13 @@ const startBackgroundReconciliationEffect = ( const token = process.env.QSTASH_TOKEN if (!token) { - logger.warn("background-reconcile: skipping — QSTASH_TOKEN not set", { - sourceId, - workspaceId, - }) - lastTriggeredAtBySourceId.delete(sourceId) + // No Upstash: poll locally so self-hosted uploads still resolve from + // parsing to ready without a webhook service. + logger.info( + "background-reconcile: QSTASH_TOKEN not set; polling locally", + { sourceId, workspaceId }, + ) + runLocalReconciliation(workspaceId, sourceId, apiKey) return } diff --git a/src/domains/sources/counts.test.ts b/src/domains/sources/counts.test.ts index 22cdda9..15472f8 100644 --- a/src/domains/sources/counts.test.ts +++ b/src/domains/sources/counts.test.ts @@ -20,7 +20,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -75,31 +74,4 @@ describe("countChunksBySourceId", () => { expect(counts.size).toBe(0) }) - - it("does not count materialized demo sources through their copied document id", async () => { - const listChunks = vi.fn().mockResolvedValue({ - pagination: { total: 70 }, - }) - const mockClient = { - documents: { listChunks }, - } as unknown as Knowhere - - const { countChunksBySourceId } = await import("./counts") - - const counts = await Effect.runPromise( - countChunksBySourceId( - [ - makeSource({ - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - knowhereDocumentId: "doc_user_copy", - }), - ], - mockClient, - ), - ) - - expect(listChunks).not.toHaveBeenCalled() - expect(counts.size).toBe(0) - }) }) diff --git a/src/domains/sources/counts.ts b/src/domains/sources/counts.ts index 310a694..b611dbc 100644 --- a/src/domains/sources/counts.ts +++ b/src/domains/sources/counts.ts @@ -12,7 +12,6 @@ export const countChunksBySourceId = ( Effect.gen(function* () { const readySources = sources.filter( (source) => - !source.demoKey && source.status === "ready" && source.knowhereDocumentId, ) diff --git a/src/domains/sources/demo-source-repository.ts b/src/domains/sources/demo-source-repository.ts deleted file mode 100644 index dea38ce..0000000 --- a/src/domains/sources/demo-source-repository.ts +++ /dev/null @@ -1,137 +0,0 @@ -import "server-only" - -import { and, eq, isNotNull, or, sql } from "drizzle-orm" -import { Effect } from "effect" - -import { DbClient } from "@/infrastructure/db" -import { - demoSourceVisibilities, - sources, - type Source, -} from "@/infrastructure/db/schema" - -type UpsertMaterializedDemoSourceInput = { - readonly demoSourceId: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly knowhereDocumentId: string - readonly originalBlobUrl: string | null -} - -type DemoSourceRepository = { - readonly listHiddenDemoSourceIdsEffect: ( - workspaceId: string, - ) => Effect.Effect - readonly hideDemoSourceEffect: ( - workspaceId: string, - demoSourceId: string, - ) => Effect.Effect - readonly upsertMaterializedDemoSourceEffect: ( - workspaceId: string, - input: UpsertMaterializedDemoSourceInput, - ) => Effect.Effect -} - -const listHiddenDemoSourceIdsEffect: DemoSourceRepository["listHiddenDemoSourceIdsEffect"] = - (workspaceId: string) => - Effect.gen(function* () { - const db = yield* DbClient - const rows = yield* Effect.promise(() => - db - .select({ demoSourceId: demoSourceVisibilities.demoSourceId }) - .from(demoSourceVisibilities) - .where( - and( - eq(demoSourceVisibilities.workspaceId, workspaceId), - or( - isNotNull(demoSourceVisibilities.hiddenAt), - isNotNull(demoSourceVisibilities.deletedAt), - ), - ), - ), - ) - - return rows.map((row) => row.demoSourceId) - }) - -const hideDemoSourceEffect: DemoSourceRepository["hideDemoSourceEffect"] = ( - workspaceId: string, - demoSourceId: string, -) => - Effect.gen(function* () { - const db = yield* DbClient - yield* Effect.promise(() => - db - .insert(demoSourceVisibilities) - .values({ - workspaceId, - demoSourceId, - hiddenAt: sql`now()`, - deletedAt: sql`now()`, - }) - .onConflictDoUpdate({ - target: [ - demoSourceVisibilities.workspaceId, - demoSourceVisibilities.demoSourceId, - ], - set: { - hiddenAt: sql`now()`, - deletedAt: sql`now()`, - updatedAt: sql`now()`, - }, - }), - ) - }) - -const upsertMaterializedDemoSourceEffect: DemoSourceRepository["upsertMaterializedDemoSourceEffect"] = - (workspaceId: string, input: UpsertMaterializedDemoSourceInput) => - Effect.gen(function* () { - const db = yield* DbClient - const [source] = yield* Effect.promise(() => - db - .insert(sources) - .values({ - workspaceId, - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: input.knowhereDocumentId, - originalBlobUrl: input.originalBlobUrl, - demoKey: input.demoSourceId, - }) - .onConflictDoUpdate({ - target: [sources.workspaceId, sources.demoKey], - set: { - title: input.title, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: input.knowhereDocumentId, - originalBlobUrl: input.originalBlobUrl, - deletedAt: null, - updatedAt: sql`now()`, - }, - }) - .returning(), - ) - - if (!source) { - return yield* Effect.die( - new Error("upsertMaterializedDemoSource: upsert did not return a row."), - ) - } - - return source - }) - -export const demoSourceRepository: DemoSourceRepository = { - listHiddenDemoSourceIdsEffect, - hideDemoSourceEffect, - upsertMaterializedDemoSourceEffect, -} diff --git a/src/domains/sources/knowhere-upload.ts b/src/domains/sources/knowhere-upload.ts index cdfdca5..0acc971 100644 --- a/src/domains/sources/knowhere-upload.ts +++ b/src/domains/sources/knowhere-upload.ts @@ -13,7 +13,7 @@ import type { } from "./source-upload-contracts" import { validateUploadFile } from "./validation" import { TempFile, tempFileLayer } from "@/lib/temp-files" -import { getUploadNamespace } from "./namespace" +import { getWorkspaceNamespace } from "./namespace" import { sourceFailureMessage } from "./failure-message" /** @@ -50,7 +50,7 @@ export const uploadSourceToKnowhereEffect = ( deps.knowhere.jobs.create({ sourceType: "file", fileName: validation.title, - namespace: getUploadNamespace(), + namespace: getWorkspaceNamespace(workspace), documentMetadata: createNotebookDocumentMetadata({ title: validation.title, mimeType: validation.mimeType, @@ -119,7 +119,7 @@ export const uploadSourceBlobToKnowhereEffect = ( sourceType: "url", sourceUrl: input.url, fileName: validation.title, - namespace: getUploadNamespace(), + namespace: getWorkspaceNamespace(workspace), documentMetadata: createNotebookDocumentMetadata({ title: validation.title, mimeType: validation.mimeType, diff --git a/src/domains/sources/localize-namespace.ts b/src/domains/sources/localize-namespace.ts new file mode 100644 index 0000000..56152fb --- /dev/null +++ b/src/domains/sources/localize-namespace.ts @@ -0,0 +1,79 @@ +import "server-only" + +import { logger } from "@/lib/logger" +import type { Workspace } from "@/infrastructure/db/schema" +import { makeKnowhereClient } from "@/integrations/knowhere" +import { sourceService } from "./service" +import { sourceWorkflowRuntime } from "./workflow-runtime" +import { toSourceView } from "./view" +import type { SourceStatus } from "./types" + +/** + * Eagerly localize all documents in the workspace's own namespace into that + * workspace. Returns the freshly-localized SourceViews. Used when a workspace + * is created by picking a namespace, and after key add for the home + * workspace. + */ +export async function localizeWorkspaceNamespace( + workspace: Workspace, + apiKey: string, +): Promise[]> { + const client = makeKnowhereClient(apiKey) + + const localSources = await sourceWorkflowRuntime.listForWorkspace( + workspace.id, + ) + const localDocumentIds = new Set( + localSources.flatMap((source) => + source.knowhereDocumentId ? [source.knowhereDocumentId] : [], + ), + ) + + const newSources = [] + let page = 1 + let totalPages = 1 + do { + const response = await client.documents.list({ + namespace: workspace.namespace, + page, + pageSize: 200, + }) + for (const doc of response.documents ?? []) { + if (!doc.documentId) continue + if (localDocumentIds.has(doc.documentId)) continue + + const status: SourceStatus = + doc.status === "active" || doc.status === "ready" || doc.status === "done" + ? "ready" + : doc.status === "failed" + ? "failed" + : "parsing" + + const source = await sourceService.localizeRemoteDocument( + workspace.id, + { + documentId: doc.documentId, + namespace: doc.namespace ?? workspace.namespace, + status, + title: doc.sourceFileName ?? undefined, + revisionKey: doc.currentJobResultId ?? null, + }, + ) + newSources.push(source) + } + const pagination = response.pagination + const tp = pagination?.totalPages ?? 1 + totalPages = typeof tp === "number" && tp > 0 ? Math.floor(tp) : 1 + page += 1 + } while (page <= totalPages) + + if (newSources.length > 0) { + logger.info("workspaces: localized namespace documents", { + workspaceId: workspace.id, + namespace: workspace.namespace, + count: newSources.length, + }) + } + + return newSources.map((source) => toSourceView(source)) +} diff --git a/src/domains/sources/namespace.ts b/src/domains/sources/namespace.ts index 30ffc82..10a8496 100644 --- a/src/domains/sources/namespace.ts +++ b/src/domains/sources/namespace.ts @@ -1,22 +1,22 @@ -export const sharedLibraryNamespace = "default" - type SourceNamespace = { readonly namespace: string } -export function getUploadNamespace(): string { - return sharedLibraryNamespace -} +/** The home namespace every key add creates a workspace for. */ +export const defaultNamespace = "default" +/** + * Namespaces whose documents belong to a workspace. Since a workspace is + * bound to exactly one namespace (key-agnostic, one workspace per + * (user, namespace)), this is always just the workspace's own namespace. + */ export function getCompatibleNamespaces( workspace: SourceNamespace, ): readonly string[] { - const namespaces = [sharedLibraryNamespace] - if ( - workspace.namespace && - workspace.namespace !== sharedLibraryNamespace - ) { - namespaces.push(workspace.namespace) - } - return namespaces + return workspace.namespace ? [workspace.namespace] : [] +} + +/** The namespace uploads and retries land in: the workspace's own one. */ +export function getWorkspaceNamespace(workspace: SourceNamespace): string { + return workspace.namespace } diff --git a/src/domains/sources/reconcile.test.ts b/src/domains/sources/reconcile.test.ts index 4c90172..32b2ef1 100644 --- a/src/domains/sources/reconcile.test.ts +++ b/src/domains/sources/reconcile.test.ts @@ -7,6 +7,7 @@ import { applyKnowhereJobToSource } from "./lifecycle" const workspace: Workspace = { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-06T00:00:00Z"), } @@ -26,7 +27,6 @@ function makeSource(overrides: Partial): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/remote-library.ts b/src/domains/sources/remote-library.ts index 87d3fd8..b5f7ee0 100644 --- a/src/domains/sources/remote-library.ts +++ b/src/domains/sources/remote-library.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import type { Source } from "@/infrastructure/db/schema" import type { SourceView } from "./types" import type { SourceStatus } from "./types" -import { getCompatibleNamespaces, sharedLibraryNamespace } from "./namespace" +import { defaultNamespace, getCompatibleNamespaces } from "./namespace" type RemoteDocument = { readonly documentId: string @@ -199,8 +199,14 @@ export function localizeRemoteLibrarySources( input: RemoteLibraryLocalizationInput, ): Effect.Effect { return Effect.gen(function* () { + const localDocumentIds = new Set( + input.localSources.flatMap((source): string[] => + source.knowhereDocumentId ? [source.knowhereDocumentId] : [], + ), + ) const remoteDocuments = (yield* listRemoteLibraryDocuments(input)).filter( (document) => + !localDocumentIds.has(document.documentId) && !matchesActiveNotebookParsingSource(document, input.localSources), ) if (remoteDocuments.length === 0) return input.localSources @@ -231,7 +237,7 @@ function normalizeRemoteDocument( if (!documentId) return null const namespace = - getString(raw.namespace) ?? sharedLibraryNamespace + getString(raw.namespace) ?? defaultNamespace const sourceFileName = getString( raw.sourceFileName ?? raw.source_file_name, ) diff --git a/src/domains/sources/repository.ts b/src/domains/sources/repository.ts index c7c4b6f..fa7a044 100644 --- a/src/domains/sources/repository.ts +++ b/src/domains/sources/repository.ts @@ -1,6 +1,5 @@ import "server-only" -import { demoSourceRepository } from "./demo-source-repository" import { sourceParseResultRepository } from "./source-parse-result-repository" import { sourceRowRepository } from "./source-row-repository" @@ -9,9 +8,6 @@ type SourceRepository = { readonly listForWorkspaceEffect: typeof sourceRowRepository.listForWorkspaceEffect readonly createUploadingEffect: typeof sourceRowRepository.createUploadingEffect readonly localizeRemoteDocumentEffect: typeof sourceRowRepository.localizeRemoteDocumentEffect - readonly listHiddenDemoSourceIdsEffect: typeof demoSourceRepository.listHiddenDemoSourceIdsEffect - readonly hideDemoSourceEffect: typeof demoSourceRepository.hideDemoSourceEffect - readonly upsertMaterializedDemoSourceEffect: typeof demoSourceRepository.upsertMaterializedDemoSourceEffect readonly markParsingEffect: typeof sourceRowRepository.markParsingEffect readonly markReadyEffect: typeof sourceRowRepository.markReadyEffect readonly updateRevisionKeyEffect: typeof sourceRowRepository.updateRevisionKeyEffect @@ -30,10 +26,6 @@ export const sourceRepository: SourceRepository = { createUploadingEffect: sourceRowRepository.createUploadingEffect, localizeRemoteDocumentEffect: sourceRowRepository.localizeRemoteDocumentEffect, - listHiddenDemoSourceIdsEffect: demoSourceRepository.listHiddenDemoSourceIdsEffect, - hideDemoSourceEffect: demoSourceRepository.hideDemoSourceEffect, - upsertMaterializedDemoSourceEffect: - demoSourceRepository.upsertMaterializedDemoSourceEffect, markParsingEffect: sourceRowRepository.markParsingEffect, markReadyEffect: sourceRowRepository.markReadyEffect, updateRevisionKeyEffect: sourceRowRepository.updateRevisionKeyEffect, diff --git a/src/domains/sources/retry.test.ts b/src/domains/sources/retry.test.ts index 86c03a1..452510d 100644 --- a/src/domains/sources/retry.test.ts +++ b/src/domains/sources/retry.test.ts @@ -8,6 +8,7 @@ import { retrySourceToKnowhereEffect } from "./retry" const workspace: Workspace = { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00Z"), } @@ -51,7 +52,7 @@ describe("retrySourceToKnowhereEffect", () => { sourceUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", fileName: "notes.pdf", - namespace: "default", + namespace: "notebook-workspace_1", documentMetadata: { createdByClient: "notebook", sourceFileName: "notes.pdf", @@ -130,7 +131,6 @@ function makeSource(overrides: Partial = {}): Source { originalBlobPathname: "source-uploads/upload_1/document.pdf", originalBlobUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/retry.ts b/src/domains/sources/retry.ts index 15ada9a..e3f34bd 100644 --- a/src/domains/sources/retry.ts +++ b/src/domains/sources/retry.ts @@ -3,7 +3,7 @@ import "server-only" import { Effect } from "effect" import type { Source, Workspace } from "@/infrastructure/db/schema" -import { getUploadNamespace } from "./namespace" +import { getWorkspaceNamespace } from "./namespace" import type { UploadJobResult, UploadKnowhereClient, @@ -59,7 +59,7 @@ export const retrySourceToKnowhereEffect = ( sourceType: "url", sourceUrl: originalBlobUrl, fileName: source.title, - namespace: getUploadNamespace(), + namespace: getWorkspaceNamespace(workspace), documentMetadata: createNotebookDocumentMetadata({ title: source.title, mimeType: source.mimeType, diff --git a/src/domains/sources/route-archive.ts b/src/domains/sources/route-archive.ts index 897dee3..8ef64a3 100644 --- a/src/domains/sources/route-archive.ts +++ b/src/domains/sources/route-archive.ts @@ -12,7 +12,6 @@ import type { type RouteArchiveDependencies = Pick< SourceRouteServiceDependencies, | "deleteBlob" - | "demoApi" | "ensureApiKeyForWorkspace" | "ensureWorkspace" | "makeKnowhereClient" @@ -46,23 +45,17 @@ const archiveSourceEffect = ( const workspace = yield* Effect.tryPromise(() => deps.ensureWorkspace(user.id), ) + if (!workspace) { + return routeResult.badRequest( + "No workspace yet — pick a namespace from the dropdown.", + ) + } const source = yield* Effect.tryPromise(() => deps.sourceService.findInWorkspace(workspace.id, input.sourceId), ) if (!source) { - const catalog = yield* Effect.tryPromise(() => deps.demoApi.fetchCatalog()) - const isDemoSource = catalog.sources.some( - (candidate) => candidate.demoSourceId === input.sourceId, - ) - if (isDemoSource) { - yield* Effect.tryPromise(() => - deps.sourceService.hideDemoSource(workspace.id, input.sourceId), - ) - return routeResult.ok({ id: input.sourceId, archived: true as const }) - } - return routeResult.error(404, "Source not found.") } @@ -78,11 +71,6 @@ const archiveSourceEffect = ( yield* Effect.tryPromise(() => deps.sourceService.softDelete(workspace.id, input.sourceId), ) - if (source.demoKey) { - yield* Effect.tryPromise(() => - deps.sourceService.hideDemoSource(workspace.id, source.demoKey!), - ) - } if (source.originalBlobPathname) { yield* Effect.tryPromise(() => deps.deleteBlob(source.originalBlobPathname!), diff --git a/src/domains/sources/route-chunks.ts b/src/domains/sources/route-chunks.ts index 733ccc1..7fc1c12 100644 --- a/src/domains/sources/route-chunks.ts +++ b/src/domains/sources/route-chunks.ts @@ -1,8 +1,5 @@ import { Effect } from "effect" -import { demoView } from "@/domains/demo/view" -import type { DemoChunkPage } from "@/integrations/knowhere-demo" -import { logger } from "@/lib/logger" import { routeResult } from "@/lib/route-result" import { decodeRemoteSourceId, @@ -19,7 +16,6 @@ import type { type RouteChunksDependencies = Pick< SourceRouteServiceDependencies, - | "demoApi" | "ensureApiKeyForWorkspace" | "ensureWorkspace" | "getCurrentUser" @@ -42,19 +38,12 @@ function createRouteChunks(deps: RouteChunksDependencies): RouteChunks { } } -// --------------------------------------------------------------------------- -// Effect core -// --------------------------------------------------------------------------- - const loadSourceChunksEffect = ( input: LoadSourceChunksInput, deps: RouteChunksDependencies, ) => Effect.gen(function* () { if (!sourceRowRepository.isWorkspaceSourceId(input.sourceId)) { - const demoResult = yield* loadDemoChunkPageEffect(input, deps) - if (demoResult) return demoResult - const remoteResult = yield* loadRemoteChunkPageEffect(input, deps) return remoteResult ?? sourceNotFound() } @@ -67,6 +56,9 @@ const loadSourceChunksEffect = ( const workspace = yield* Effect.tryPromise(() => deps.ensureWorkspace(user.id), ) + if (!workspace) { + return sourceNotFound() + } const source = yield* Effect.tryPromise(() => deps.sourceService.findInWorkspace(workspace.id, input.sourceId), ) @@ -75,16 +67,6 @@ const loadSourceChunksEffect = ( return sourceNotFound() } - if (source.demoKey) { - const demoResult = yield* loadDemoChunkPageEffect( - input, - deps, - source.demoKey, - source.knowhereDocumentId, - ) - return demoResult ?? sourceNotFound() - } - const client = yield* Effect.tryPromise(() => getClientForWorkspace(workspace.id, input.cookieHeader, deps), ) @@ -137,6 +119,7 @@ const loadRemoteChunkPageEffect = ( const workspace = yield* Effect.tryPromise(() => deps.ensureWorkspace(user.id), ) + if (!workspace) return null const client = yield* Effect.tryPromise(() => getClientForWorkspace(workspace.id, input.cookieHeader, deps), ) @@ -192,104 +175,6 @@ const loadRemoteChunkPageEffect = ( return routeResult.ok(chunkPage) }) -const loadDemoChunkPageEffect = ( - input: LoadSourceChunksInput, - deps: RouteChunksDependencies, - demoSourceId: string = input.sourceId, - documentIdOverride?: string | null, -) => - Effect.gen(function* () { - const pages = input.shouldLoadAll - ? yield* Effect.tryPromise(() => - loadAllDemoChunkPages(input, deps, demoSourceId), - ) - : [ - yield* Effect.tryPromise(() => - deps.demoApi.fetchChunkPage({ - demoSourceId, - page: input.pageParams.page, - pageSize: input.pageParams.pageSize, - }), - ), - ] - const page = pages[0] - if (!page) return null - const source = { - id: page.demoSourceId, - kind: "demo" as const, - demoSourceId: page.demoSourceId, - title: page.title, - mimeType: page.mimeType, - status: "ready" as const, - documentId: documentIdOverride ?? page.canonicalDocumentId, - } - const chunks = pages.flatMap((demoChunkPage) => - demoChunkPage.chunks.map((chunk) => - demoView.toParsedChunkView(source, chunk), - ), - ) - - return routeResult.ok( - input.shouldLoadAll - ? { chunks } - : { - chunks, - pagination: page.pagination, - }, - ) - }).pipe( - Effect.catchAll((error) => - Effect.sync(() => { - logger.warn("sources: demo chunk load failed", { - sourceId: input.sourceId, - demoSourceId, - page: input.pageParams.page, - pageSize: input.pageParams.pageSize, - shouldLoadAll: input.shouldLoadAll, - knowhereBaseUrl: process.env.KNOWHERE_BASE_URL ?? "(default)", - error: getErrorMessage(error), - }) - return null - }), - ), - ) - -async function loadAllDemoChunkPages( - input: LoadSourceChunksInput, - deps: RouteChunksDependencies, - demoSourceId: string, -): Promise { - const pageSize = 200 - const firstPage = await deps.demoApi.fetchChunkPage({ - demoSourceId, - page: 1, - pageSize, - }) - const pages = [firstPage] - for ( - let pageNumber = 2; - pageNumber <= firstPage.pagination.totalPages; - pageNumber += 1 - ) { - pages.push( - await deps.demoApi.fetchChunkPage({ - demoSourceId, - page: pageNumber, - pageSize, - }), - ) - } - return pages -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - const inner = (error as Error & { error?: unknown }).error - return inner instanceof Error ? inner.message : error.message - } - return String(error) -} - function sourceNotFound(): JsonRouteResult<{ readonly message: string }> { return routeResult.error(404, "Source not found.") } diff --git a/src/domains/sources/route-dependencies.ts b/src/domains/sources/route-dependencies.ts index 25313ce..4fcf208 100644 --- a/src/domains/sources/route-dependencies.ts +++ b/src/domains/sources/route-dependencies.ts @@ -6,11 +6,12 @@ import { loadChunkPageForSource, loadChunksForSource, } from "@/domains/chunks/server" -import { ensureApiKeyForWorkspace } from "@/integrations/dashboard/api-key-service" +import { ensureApiKeyForWorkspace } from "@/integrations/knowhere-credentials" import { makeKnowhereClient as makeDefaultKnowhereClient } from "@/integrations/knowhere" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" import { getCurrentUser, requireUser } from "@/infrastructure/auth" import { workspaceService } from "@/domains/workspace/service" +import { workspaceRepository } from "@/domains/workspace/repository" +import { databaseRuntime } from "@/domains/workspace/database-runtime" import { sourceViewOptionsBySourceId as getDefaultSourceViewOptionsBySourceId } from "./counts" import { reconcileSourcesForWorkspace as reconcileDefaultSourcesForWorkspace } from "./reconcile" import { sourceWorkflowRuntime } from "./workflow-runtime" @@ -23,9 +24,12 @@ import type { const defaultDependencies: SourceRouteServiceDependencies = { deleteBlob: del, - demoApi: knowhereDemoApi, ensureApiKeyForWorkspace, ensureWorkspace: workspaceService.ensureWorkspace, + findWorkspaceByIdAndUserId: (workspaceId, userId) => + databaseRuntime.runPromise( + workspaceRepository.findByIdAndUserIdEffect(workspaceId, userId), + ), getCurrentUser, getSourceViewOptionsBySourceId: (sources, client) => getDefaultSourceViewOptionsBySourceId( @@ -46,13 +50,9 @@ const defaultDependencies: SourceRouteServiceDependencies = { sourceService: { findInWorkspace: defaultSourceService.findInWorkspace, getParseAssetUrls: defaultSourceService.getParseAssetUrls, - hideDemoSource: defaultSourceService.hideDemoSource, - listHiddenDemoSourceIds: defaultSourceService.listHiddenDemoSourceIds, localizeRemoteDocument: defaultSourceService.localizeRemoteDocument, updateSourceRevisionKey: defaultSourceService.updateSourceRevisionKey, softDelete: defaultSourceService.softDelete, - upsertMaterializedDemoSource: - defaultSourceService.upsertMaterializedDemoSource, retrySourceToKnowhere: defaultSourceService.retrySourceToKnowhere, uploadSourceBlobToKnowhere: defaultSourceService.uploadSourceBlobToKnowhere, uploadSourceToKnowhere: defaultSourceService.uploadSourceToKnowhere, @@ -65,10 +65,6 @@ function createSourceRouteDependencies( return { ...defaultDependencies, ...overrides, - demoApi: { - ...defaultDependencies.demoApi, - ...overrides.demoApi, - }, sourceService: { ...defaultDependencies.sourceService, ...overrides.sourceService, @@ -84,7 +80,7 @@ async function getClientForWorkspace( "ensureApiKeyForWorkspace" | "makeKnowhereClient" >, ): Promise { - const apiKey = await deps.ensureApiKeyForWorkspace(workspaceId, cookieHeader) + const apiKey = await deps.ensureApiKeyForWorkspace(workspaceId) return deps.makeKnowhereClient(apiKey) } diff --git a/src/domains/sources/route-listing.ts b/src/domains/sources/route-listing.ts index 9476285..b8fbc50 100644 --- a/src/domains/sources/route-listing.ts +++ b/src/domains/sources/route-listing.ts @@ -1,19 +1,12 @@ import { Effect } from "effect" -import { demoView } from "@/domains/demo/view" -import { - getMaterializedDemoSourceViewOptionsBySourceId, - getWorkspaceSourcesNeedingKnowhereChunkCount, - resolveWorkspaceDemoSources, -} from "@/domains/demo/workspace-source-resolution" import { routeResult } from "@/lib/route-result" import { logger } from "@/lib/logger" -import { knowhereDemoApi } from "@/integrations/knowhere-demo" import { toSourceView } from "./view" import { startBackgroundReconciliation as defaultStartBackgroundReconciliation, } from "./background-reconcile" -import { listRemoteLibrarySourceViews } from "./remote-library" +import { localizeRemoteLibrarySources } from "./remote-library" import type { Source } from "@/infrastructure/db/schema" import type { JsonRouteResult, @@ -31,13 +24,9 @@ type RouteListingDependencies = Pick< | "listSourcesForWorkspace" | "makeKnowhereClient" > & { - readonly demoApi: Pick< - SourceRouteServiceDependencies["demoApi"], - "fetchCatalog" - > readonly sourceService: Pick< SourceRouteServiceDependencies["sourceService"], - "listHiddenDemoSourceIds" | "localizeRemoteDocument" + "localizeRemoteDocument" > readonly reconcileSourcesForWorkspace: SourceRouteServiceDependencies[ "reconcileSourcesForWorkspace" @@ -69,37 +58,41 @@ const listSourcesEffect = ( Effect.gen(function* () { const user = yield* Effect.tryPromise(() => deps.getCurrentUser()) if (!user) { - const catalog = yield* Effect.tryPromise(() => deps.demoApi.fetchCatalog()) - return routeResult.ok({ - sources: catalog.sources.map(demoView.toSourceView), - }) + return routeResult.ok({ sources: [] }) } - const catalog = yield* Effect.tryPromise(() => - knowhereDemoApi.fetchOptionalCatalog(deps.demoApi.fetchCatalog), - ) const workspace = yield* Effect.tryPromise(() => deps.ensureWorkspace(user.id), ) + if (!workspace) { + return routeResult.ok({ sources: [] }) + } const listedSources = yield* Effect.tryPromise(() => deps.listSourcesForWorkspace(workspace.id), ) const apiKey = yield* Effect.tryPromise(() => - deps.ensureApiKeyForWorkspace(workspace.id, input.cookieHeader), + deps.ensureApiKeyForWorkspace(workspace.id), ) const client = deps.makeKnowhereClient(apiKey) const sources = listedSources - const demoSourceResolution = resolveWorkspaceDemoSources(sources, catalog) - const workspaceSources = demoSourceResolution.workspaceSources - const remoteSourceViews = yield* listRemoteLibrarySourceViews({ + const workspaceSources = sources + const localizedSources = yield* localizeRemoteLibrarySources({ workspace, client, - localSources: demoSourceResolution.workspaceSources, + localSources: workspaceSources, + localizeDocument: (document) => + deps.sourceService.localizeRemoteDocument(workspace.id, { + documentId: document.documentId, + namespace: document.namespace, + status: document.status, + title: document.title, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + revisionKey: document.revisionKey ?? null, + }), }) const sourcesNeedingKnowhereChunkCount = - getWorkspaceSourcesNeedingKnowhereChunkCount(workspaceSources) - const materializedDemoSourceOptions = - getMaterializedDemoSourceViewOptionsBySourceId(workspaceSources, catalog) + getWorkspaceSourcesNeedingKnowhereChunkCount(localizedSources) yield* Effect.sync(() => triggerBackgroundReconciliationForParsingSources({ workspaceId: workspace.id, @@ -114,38 +107,24 @@ const listSourcesEffect = ( sourcesNeedingKnowhereChunkCount, client, ) - const hiddenDemoSourceIds = new Set( - yield* Effect.tryPromise(() => - deps.sourceService.listHiddenDemoSourceIds(workspace.id), - ), - ) - const visibleDemoSources = catalog.sources - .filter( - (source) => - !demoSourceResolution.materializedDemoSourceIds.has( - source.demoSourceId, - ), - ) - .filter((source) => !hiddenDemoSourceIds.has(source.demoSourceId)) - .map(demoView.toSourceView) return routeResult.ok({ - sources: [ - ...visibleDemoSources, - ...workspaceSources.map((source) => - toSourceView( - source, - materializedDemoSourceOptions.get(source.id) ?? - sourceOptions.get(source.id), - ), - ), - ...remoteSourceViews, - ], + sources: localizedSources.map((source) => + toSourceView(source, sourceOptions.get(source.id)), + ), }) }) export { createRouteListing } +function getWorkspaceSourcesNeedingKnowhereChunkCount( + sources: readonly Source[], +): readonly Source[] { + return sources.filter( + (source) => source.status === "ready" && source.knowhereDocumentId, + ) +} + function triggerBackgroundReconciliationForParsingSources(input: { readonly workspaceId: string readonly sources: readonly Source[] diff --git a/src/domains/sources/route-retry.ts b/src/domains/sources/route-retry.ts index 68464bb..0a3d5f9 100644 --- a/src/domains/sources/route-retry.ts +++ b/src/domains/sources/route-retry.ts @@ -41,6 +41,11 @@ const retrySourceEffect = ( const workspace = yield* Effect.tryPromise(() => deps.ensureWorkspace(user.id), ) + if (!workspace) { + return routeResult.badRequest( + "No workspace yet — pick a namespace from the dropdown.", + ) + } const source = yield* Effect.tryPromise(() => deps.sourceService.findInWorkspace(workspace.id, input.sourceId), @@ -59,7 +64,7 @@ const retrySourceEffect = ( } const apiKey = yield* Effect.tryPromise(() => - deps.ensureApiKeyForWorkspace(workspace.id, input.cookieHeader), + deps.ensureApiKeyForWorkspace(workspace.id), ) const client = deps.makeKnowhereClient(apiKey) const retriedSource = yield* Effect.tryPromise(() => diff --git a/src/domains/sources/route-service.test.ts b/src/domains/sources/route-service.test.ts index 3d11b96..9867062 100644 --- a/src/domains/sources/route-service.test.ts +++ b/src/domains/sources/route-service.test.ts @@ -5,11 +5,11 @@ import type { Job } from "@ontos-ai/knowhere-sdk"; import type { Source, Workspace } from "@/infrastructure/db/schema"; import { createRouteListing } from "./route-listing"; import { createSourceRouteService } from "./route-service"; -import type { DemoCatalog } from "@/integrations/knowhere-demo"; const workspace: Workspace = { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00Z"), }; @@ -28,7 +28,6 @@ const source: Source = { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00Z"), updatedAt: new Date("2026-05-10T00:00:00Z"), deletedAt: null, @@ -64,11 +63,7 @@ describe("source route service", () => { const listSourcesForWorkspace = vi.fn(async () => [source]); const reconcileSourcesForWorkspace = vi.fn(async () => [source]); const startBackgroundReconciliation = vi.fn(async () => undefined); - const listHiddenDemoSourceIds = vi.fn(async () => []); const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => emptyDemoCatalog), - }, ensureApiKeyForWorkspace, ensureWorkspace: vi.fn(async () => workspace), getCurrentUser: vi.fn(async () => ({ @@ -82,7 +77,6 @@ describe("source route service", () => { reconcileSourcesForWorkspace, startBackgroundReconciliation, sourceService: { - listHiddenDemoSourceIds, localizeRemoteDocument: localizeNoRemoteDocuments, }, }); @@ -107,7 +101,6 @@ describe("source route service", () => { }); expect(ensureApiKeyForWorkspace).toHaveBeenCalledWith( workspace.id, - "session=abc", ); expect(listSourcesForWorkspace).toHaveBeenCalledWith(workspace.id); expect(reconcileSourcesForWorkspace).not.toHaveBeenCalled(); @@ -116,10 +109,9 @@ describe("source route service", () => { source.id, "jwt_123", ); - expect(listHiddenDemoSourceIds).toHaveBeenCalledWith(workspace.id); }); - it("lists shared default and legacy namespace documents as lightweight remote sources", async () => { + it("lists workspace-namespace documents as lightweight remote sources", async () => { const localReadySource: Source = { ...source, id: "source_ready", @@ -127,59 +119,31 @@ describe("source route service", () => { knowhereJobId: null, knowhereDocumentId: "doc_local", }; - const listDocuments = vi - .fn() - .mockResolvedValueOnce({ - documents: [ - { - documentId: "doc_default", - namespace: "default", - status: "active", - sourceFileName: "cli.pdf", - documentMetadata: { - mimeType: "application/pdf", - }, - }, - ], - pagination: { - page: 1, - page_size: 200, - total: 2, - total_pages: 2, + const listDocuments = vi.fn().mockResolvedValueOnce({ + documents: [ + { + documentId: "doc_local", + namespace: workspace.namespace, + status: "active", + sourceFileName: "local-duplicate.pdf", }, - }) - .mockResolvedValueOnce({ - documents: [ - { - documentId: "doc_local", - namespace: "default", - status: "active", - sourceFileName: "local-duplicate.pdf", - }, - ], - pagination: { - page: 2, - pageSize: 200, - total: 2, - totalPages: 2, - }, - }) - .mockResolvedValueOnce({ - documents: [ - { - documentId: "doc_legacy", - namespace: workspace.namespace, - status: "active", - sourceFileName: "legacy.pdf", + { + documentId: "doc_new", + namespace: workspace.namespace, + status: "active", + sourceFileName: "new.pdf", + documentMetadata: { + mimeType: "application/pdf", }, - ], - pagination: { - page: 1, - pageSize: 200, - total: 1, - totalPages: 1, }, - }); + ], + pagination: { + page: 1, + pageSize: 200, + total: 2, + totalPages: 1, + }, + }); const knowhereClient = { documents: { archive: vi.fn(async () => undefined), @@ -200,11 +164,17 @@ describe("source route service", () => { upload: vi.fn(), }, }; - const localizeRemoteDocument = vi.fn(); + const localizeRemoteDocument = vi.fn(async (_workspaceId: string, input: { documentId: string; title?: string; mimeType?: string }) => ({ + ...source, + id: `source_${input.documentId}`, + workspaceId: _workspaceId, + title: input.title ?? input.documentId, + mimeType: input.mimeType ?? "application/octet-stream", + status: "ready" as const, + knowhereJobId: null, + knowhereDocumentId: input.documentId, + })) as unknown as Parameters[0]["sourceService"]["localizeRemoteDocument"]; const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => emptyDemoCatalog), - }, ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), ensureWorkspace: vi.fn(async () => workspace), getCurrentUser: vi.fn(async () => ({ @@ -217,29 +187,21 @@ describe("source route service", () => { listSourcesForWorkspace: vi.fn(async () => [localReadySource]), reconcileSourcesForWorkspace: vi.fn(async () => [localReadySource]), sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), localizeRemoteDocument, }, }); const result = await listing.listSources({ cookieHeader: "session=abc" }); + expect(listDocuments).toHaveBeenCalledTimes(1); expect(listDocuments).toHaveBeenNthCalledWith(1, { - namespace: "default", - page: 1, - pageSize: 200, - }); - expect(listDocuments).toHaveBeenNthCalledWith(2, { - namespace: "default", - page: 2, - pageSize: 200, - }); - expect(listDocuments).toHaveBeenNthCalledWith(3, { namespace: workspace.namespace, page: 1, pageSize: 200, }); - expect(localizeRemoteDocument).not.toHaveBeenCalled(); + expect(localizeRemoteDocument).toHaveBeenCalledTimes(1); + expect(localizeRemoteDocument).toHaveBeenCalledWith(workspace.id, expect.objectContaining({ documentId: "doc_new" })); + expect(localizeRemoteDocument).not.toHaveBeenCalledWith(workspace.id, expect.objectContaining({ documentId: "doc_local" })); expect(result.body.sources).toEqual([ expect.objectContaining({ id: "source_ready", @@ -247,26 +209,14 @@ describe("source route service", () => { title: "notes.pdf", status: "ready", }), - { - id: "knowhere-doc:default:doc_default", - kind: "remote", - namespace: "default", - title: "cli.pdf", + expect.objectContaining({ + id: "source_doc_new", + kind: "workspace", + title: "new.pdf", mimeType: "application/pdf", status: "ready", - documentId: "doc_default", - excludedFromQuery: true, - }, - { - id: "knowhere-doc:notebook-workspace_1:doc_legacy", - kind: "remote", - namespace: workspace.namespace, - title: "legacy.pdf", - mimeType: "application/octet-stream", - status: "ready", - documentId: "doc_legacy", - excludedFromQuery: true, - }, + documentId: "doc_new", + }), ]); }); @@ -324,9 +274,6 @@ describe("source route service", () => { const localizeRemoteDocument = vi.fn(async () => parsingSource); const startBackgroundReconciliation = vi.fn(async () => undefined); const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => emptyDemoCatalog), - }, ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), ensureWorkspace: vi.fn(async () => workspace), getCurrentUser: vi.fn(async () => ({ @@ -340,7 +287,6 @@ describe("source route service", () => { reconcileSourcesForWorkspace, startBackgroundReconciliation, sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), localizeRemoteDocument, }, }); @@ -364,320 +310,9 @@ describe("source route service", () => { ]); }); - it("lists authenticated workspace sources when the demo catalog is unavailable", async () => { - const legacyFakeSource: Source = { - ...source, - id: "source_legacy_demo", - status: "ready", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => - Effect.succeed(new Map([[source.id, { chunkCount: 8 }]])), - ); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => { - throw new Error("Demo API unavailable."); - }), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource, source]), - reconcileSourcesForWorkspace: vi.fn(async () => [ - legacyFakeSource, - source, - ]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [source], - knowhereClient, - ); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - { - id: "source_1", - kind: "workspace", - title: "notes.pdf", - status: "parsing", - mimeType: "application/pdf", - documentId: undefined, - chunkCount: 8, - }, - ], - }, - }); - }); - - it("keeps API-owned demos visible when a legacy fake demo row exists", async () => { - const legacyFakeSource: Source = { - ...source, - id: "source_legacy_demo", - status: "ready", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource]), - reconcileSourcesForWorkspace: vi.fn(async () => [legacyFakeSource]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [], - knowhereClient, - ); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - { - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - pdfPreviewMode: "browser", - }, - chunkCount: 70, - }, - ], - }, - }); - }); - - it("keeps API-owned demos visible when a non-ready legacy demo row exists", async () => { - const nonReadyLegacySource: Source = { - ...source, - id: "source_non_ready_legacy_demo", - status: "parsing", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: null, - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [nonReadyLegacySource]), - reconcileSourcesForWorkspace: vi.fn(async () => [nonReadyLegacySource]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [], - knowhereClient, - ); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - }), - ], - }, - }); - }); - - it("uses demo catalog counts for materialized demo sources", async () => { - const materializedSource: Source = { - ...source, - id: "source_demo", - title: "TSLA-Q4-2025-Update.pdf", - status: "ready", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }; - const knowhereClient = { - documents: { - archive: vi.fn(async () => undefined), - listChunks: vi.fn(async () => ({ - chunks: [], - pagination: { - page: 1, - pageSize: 1, - total: 0, - totalPages: 0, - }, - })), - }, - jobs: { - create: vi.fn(), - get: vi.fn(), - upload: vi.fn(), - }, - }; - const getSourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())); - const listing = createRouteListing({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, - ensureApiKeyForWorkspace: vi.fn(async () => "jwt_123"), - ensureWorkspace: vi.fn(async () => workspace), - getCurrentUser: vi.fn(async () => ({ - id: "user_1", - email: null, - name: null, - })), - getSourceViewOptionsBySourceId, - makeKnowhereClient: vi.fn(() => knowhereClient), - listSourcesForWorkspace: vi.fn(async () => [materializedSource]), - reconcileSourcesForWorkspace: vi.fn(async () => [materializedSource]), - sourceService: { - listHiddenDemoSourceIds: vi.fn(async () => []), - localizeRemoteDocument: localizeNoRemoteDocuments, - }, - }); - - const result = await listing.listSources({ cookieHeader: "session=abc" }); - - expect(getSourceViewOptionsBySourceId).toHaveBeenCalledWith( - [], - knowhereClient, - ); - expect(knowhereClient.documents.listChunks).not.toHaveBeenCalled(); - expect(result).toEqual({ - status: 200, - body: { - sources: [ - expect.objectContaining({ - id: "source_demo", - kind: "workspace", - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - chunkCount: 70, - }), - ], - }, - }); - }); - - it("lists API-owned demo sources for anonymous users", async () => { + it("lists no sources for anonymous users", async () => { const ensureWorkspace = vi.fn(async () => workspace); const service = createSourceRouteService({ - demoApi: { - fetchCatalog: vi.fn(async () => demoCatalog), - }, ensureWorkspace, getCurrentUser: vi.fn(async () => null), }); @@ -687,25 +322,7 @@ describe("source route service", () => { expect(result).toEqual({ status: 200, body: { - sources: [ - { - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - pdfPreviewMode: "browser", - }, - chunkCount: 70, - }, - ], + sources: [], }, }); expect(ensureWorkspace).not.toHaveBeenCalled(); @@ -778,7 +395,6 @@ describe("source route service", () => { }); expect(ensureApiKeyForWorkspace).toHaveBeenCalledWith( workspace.id, - "session=abc", ); expect(uploadSourceToKnowhere).toHaveBeenCalledWith( workspace, @@ -864,7 +480,6 @@ describe("source route service", () => { }); expect(ensureApiKeyForWorkspace).toHaveBeenCalledWith( workspace.id, - "session=abc", ); expect(retrySourceToKnowhere).toHaveBeenCalledWith( workspace, @@ -913,30 +528,3 @@ describe("source route service", () => { expect(retrySourceToKnowhere).not.toHaveBeenCalled(); }); }); - -const emptyDemoCatalog: DemoCatalog = { - officialLibrary: { categories: [], sources: [] }, - sources: [], -}; - -const demoCatalog: DemoCatalog = { - officialLibrary: { categories: [], sources: [] }, - sources: [ - { - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - chunkCount: 70, - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - }, - examples: [], - }, - ], -}; diff --git a/src/domains/sources/route-types.ts b/src/domains/sources/route-types.ts index 7303ec3..87eac91 100644 --- a/src/domains/sources/route-types.ts +++ b/src/domains/sources/route-types.ts @@ -11,10 +11,6 @@ import type { ParsedChunkView } from "@/domains/chunks/types" import type { SourceStatus, SourceView } from "@/domains/sources/types" import type { AuthUser } from "@/infrastructure/auth" import type { Source, Workspace } from "@/infrastructure/db/schema" -import type { - DemoCatalog, - DemoChunkPage, -} from "@/integrations/knowhere-demo" import type { RouteResult } from "@/lib/route-result" import type { SourceBlobUploadInput } from "./blob-upload" import type { sourceViewOptionsBySourceId } from "./counts" @@ -117,6 +113,10 @@ type ListSourcesInput = { type UploadSourceInput = { readonly cookieHeader: string readonly upload: SourceUploadRequest + /** Optional target workspace (upload destination). Defaults to the active + * workspace; when set it must belong to the current user (owned or + * member). */ + readonly workspaceId?: string readonly onUploadFinished?: () => void } @@ -183,11 +183,6 @@ type SourceWorkflowService = { workspaceId: string, sourceId: string, ) => Promise>> - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise readonly localizeRemoteDocument: ( workspaceId: string, input: { @@ -205,36 +200,18 @@ type SourceWorkflowService = { sourceId: string, revisionKey: string, ) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: { - readonly demoSourceId: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly knowhereDocumentId: string - readonly originalBlobUrl: string | null - }, - ) => Promise -} - -type SourceRouteDemoApi = { - readonly fetchCatalog: () => Promise - readonly fetchChunkPage: (input: { - readonly demoSourceId: string - readonly page: number - readonly pageSize: number - }) => Promise } type SourceRouteServiceDependencies = { readonly deleteBlob: (pathname: string) => Promise - readonly demoApi: SourceRouteDemoApi readonly ensureApiKeyForWorkspace: ( workspaceId: string, - cookieHeader: string, ) => Promise - readonly ensureWorkspace: (userId: string) => Promise + readonly ensureWorkspace: (userId: string) => Promise + readonly findWorkspaceByIdAndUserId: ( + workspaceId: string, + userId: string, + ) => Promise readonly getCurrentUser: () => Promise readonly getSourceViewOptionsBySourceId: ( sources: readonly Source[], @@ -253,9 +230,8 @@ type SourceRouteServiceDependencies = { } type SourceRouteServiceOverrides = Partial< - Omit + Omit > & { - readonly demoApi?: Partial readonly sourceService?: Partial } @@ -269,7 +245,6 @@ export type { RetrySourceBody, RetrySourceInput, SourceChunksBody, - SourceRouteDemoApi, SourceRouteKnowhereClient, SourceRouteService, SourceRouteServiceDependencies, diff --git a/src/domains/sources/route-upload-request.ts b/src/domains/sources/route-upload-request.ts index 0d8d898..9813579 100644 --- a/src/domains/sources/route-upload-request.ts +++ b/src/domains/sources/route-upload-request.ts @@ -17,11 +17,19 @@ type SourceUploadRequest = readonly message: string } +type SourceRouteUploadRequestInput = { + readonly workspaceId?: string +} + type SourceRouteUploadRequestModule = { - readonly read: (request: Request) => Promise + readonly read: ( + request: Request, + ) => Promise } -async function read(request: Request): Promise { +async function read( + request: Request, +): Promise { const contentType = request.headers.get("content-type") ?? "" if (contentType.includes("application/json")) { return readBlobBackedUpload(request) @@ -32,25 +40,36 @@ async function read(request: Request): Promise { async function readBlobBackedUpload( request: Request, -): Promise { - const body = (await request.json()) as unknown +): Promise { + const body = (await request.json()) as Record const input = parseSourceBlobUploadBody(body) if (!input) return missingUpload() - return { type: "blob", input } + const workspaceId = + typeof body.workspaceId === "string" && body.workspaceId.length > 0 + ? body.workspaceId + : undefined + + return { type: "blob", input, workspaceId } } async function readMultipartFileUpload( request: Request, -): Promise { +): Promise { const formData = await request.formData() const file = formData.get("file") if (!(file instanceof File) || file.size === 0) return missingUpload() - return { type: "file", file } + const workspaceIdValue = formData.get("workspaceId") + const workspaceId = + typeof workspaceIdValue === "string" && workspaceIdValue.length > 0 + ? workspaceIdValue + : undefined + + return { type: "file", file, workspaceId } } -function missingUpload(): SourceUploadRequest { +function missingUpload(): SourceUploadRequest & SourceRouteUploadRequestInput { return { type: "error", message: "Choose a document to upload.", diff --git a/src/domains/sources/route-upload.ts b/src/domains/sources/route-upload.ts index c1f03dc..74e9d7a 100644 --- a/src/domains/sources/route-upload.ts +++ b/src/domains/sources/route-upload.ts @@ -19,6 +19,7 @@ type RouteUploadDependencies = Pick< SourceRouteServiceDependencies, | "ensureApiKeyForWorkspace" | "ensureWorkspace" + | "findWorkspaceByIdAndUserId" | "getCurrentUser" | "makeKnowhereClient" | "sourceService" @@ -64,10 +65,17 @@ const uploadSourceEffect = ( } const workspace = yield* Effect.tryPromise(() => - deps.ensureWorkspace(user.id), + input.workspaceId + ? deps.findWorkspaceByIdAndUserId(input.workspaceId, user.id) + : deps.ensureWorkspace(user.id), ) + if (!workspace) { + return routeResult.badRequest( + "No workspace yet — pick a namespace from the dropdown.", + ) + } const apiKey = yield* Effect.tryPromise(() => - deps.ensureApiKeyForWorkspace(workspace.id, input.cookieHeader), + deps.ensureApiKeyForWorkspace(workspace.id), ) const client = deps.makeKnowhereClient(apiKey) diff --git a/src/domains/sources/service.ts b/src/domains/sources/service.ts index 0d7c7f6..1f85be6 100644 --- a/src/domains/sources/service.ts +++ b/src/domains/sources/service.ts @@ -31,21 +31,10 @@ type SourceService = { sourceId: string, revisionKey: string, ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise readonly softDelete: ( workspaceId: string, sourceId: string, ) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: Parameters< - typeof sourceWorkflowRuntime.upsertMaterializedDemoSource - >[1], - ) => Promise readonly uploadSourceToKnowhere: ( workspace: Workspace, file: File, @@ -106,14 +95,10 @@ const retrySourceToKnowhere: SourceService["retrySourceToKnowhere"] = ( export const sourceService: SourceService = { findInWorkspace: sourceWorkflowRuntime.findInWorkspace, getParseAssetUrls: sourceWorkflowRuntime.getParseAssetUrls, - hideDemoSource: sourceWorkflowRuntime.hideDemoSource, - listHiddenDemoSourceIds: sourceWorkflowRuntime.listHiddenDemoSourceIds, listForWorkspace: sourceWorkflowRuntime.listForWorkspace, localizeRemoteDocument: sourceWorkflowRuntime.localizeRemoteDocument, updateSourceRevisionKey: sourceWorkflowRuntime.updateRevisionKey, softDelete: sourceWorkflowRuntime.softDelete, - upsertMaterializedDemoSource: - sourceWorkflowRuntime.upsertMaterializedDemoSource, uploadSourceToKnowhere, uploadSourceBlobToKnowhere, retrySourceToKnowhere, diff --git a/src/domains/sources/source-reconcile-workflow.test.ts b/src/domains/sources/source-reconcile-workflow.test.ts index 3f0632e..06ae700 100644 --- a/src/domains/sources/source-reconcile-workflow.test.ts +++ b/src/domains/sources/source-reconcile-workflow.test.ts @@ -10,6 +10,7 @@ import { const workspace: Workspace = { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-06T00:00:00Z"), } @@ -29,7 +30,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/source-row-repository.test.ts b/src/domains/sources/source-row-repository.test.ts index 4b2833f..b062b08 100644 --- a/src/domains/sources/source-row-repository.test.ts +++ b/src/domains/sources/source-row-repository.test.ts @@ -97,7 +97,6 @@ async function captureLocalizeConflictSet(input: { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-06-26T00:00:00Z"), updatedAt: new Date("2026-06-26T00:00:00Z"), deletedAt: null, diff --git a/src/domains/sources/source-row-repository.ts b/src/domains/sources/source-row-repository.ts index 77c56dd..e042170 100644 --- a/src/domains/sources/source-row-repository.ts +++ b/src/domains/sources/source-row-repository.ts @@ -357,7 +357,6 @@ async function localizeRemoteDocumentWithDb( stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, } const [source] = await db diff --git a/src/domains/sources/types.ts b/src/domains/sources/types.ts index e1abe9e..81c8427 100644 --- a/src/domains/sources/types.ts +++ b/src/domains/sources/types.ts @@ -8,25 +8,7 @@ export type SourceOriginalFileView = { readonly pdfPreviewMode?: "browser" } -export type SourceKind = "workspace" | "demo" | "remote" - -export type SourceOfficialLibraryView = { - readonly librarySourceId: string - readonly categoryId: string - readonly sourceUrl: string -} - -export type OfficialLibrarySourceView = { - readonly librarySourceId: string - readonly categoryId: string - readonly categoryLabel: string - readonly title: string - readonly sourceUrl: string - readonly mimeType: string - readonly status: "ready" | "planned" - readonly demoSourceId?: string - readonly chunkCount?: number -} +export type SourceKind = "workspace" | "remote" /** * Sources sidebar row. Metadata-only, per the MVP persistence rule. @@ -34,7 +16,6 @@ export type OfficialLibrarySourceView = { export type SourceView = { readonly id: string readonly kind?: SourceKind - readonly demoSourceId?: string readonly namespace?: string readonly title: string /** Browser-provided content type for preview routing. */ @@ -46,8 +27,6 @@ export type SourceView = { readonly documentId?: string /** Public Blob URL for original-file preview and download. */ readonly originalFile?: SourceOriginalFileView - /** Official Library metadata when this row is an API-owned catalog item. */ - readonly officialLibrary?: SourceOfficialLibraryView /** Count from the Knowhere chunks API, not a local aggregate. */ readonly chunkCount?: number /** User opt-out for this query session. Drives excludeDocumentIds. */ diff --git a/src/domains/sources/upload-request.test.ts b/src/domains/sources/upload-request.test.ts index 3643d64..3b06428 100644 --- a/src/domains/sources/upload-request.test.ts +++ b/src/domains/sources/upload-request.test.ts @@ -212,4 +212,68 @@ describe("postSourceUpload", () => { body: { pathname: "source-uploads/upload_1/document.pdf" }, }); }); + + it("uploads directly via multipart when Blob is not configured, targeting the chosen workspace", async () => { + let requestPath = ""; + let requestMethod = ""; + let requestBody: FormData | null = null; + const uploadedSource = { + id: "source_1", + title: "notes.pdf", + status: "parsing", + mimeType: "application/pdf", + originalFile: { + url: "https://example.com/source-uploads/notes.pdf", + mimeType: "application/pdf", + }, + } as const; + const file = new File(["hello"], "notes.pdf", { type: "application/pdf" }); + + vi.stubGlobal("location", { origin: "http://localhost" }); + const fetch = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async (input, init) => { + const request = + input instanceof Request + ? input + : new Request(new URL(String(input), "http://localhost").toString(), init); + requestPath = new URL(request.url).pathname; + requestMethod = request.method; + requestBody = (await request.formData()) as FormData; + return Response.json({ source: uploadedSource }, { status: 201 }); + }); + vi.stubGlobal("fetch", fetch); + + const result = await postSourceUpload(file, false, "workspace_2"); + + expect(result.status).toBe(201); + expect(mocks.uploadBlob).not.toHaveBeenCalled(); + expect(requestPath).toBe("/api/sources"); + expect(requestMethod).toBe("POST"); + expect((requestBody as FormData | null)?.get("file")).toEqual(file); + expect((requestBody as FormData | null)?.get("workspaceId")).toBe("workspace_2"); + }); + + it("uploads directly via multipart without a workspace override when none is chosen", async () => { + let requestBody: FormData | null = null; + const file = new File(["hello"], "notes.pdf", { type: "application/pdf" }); + + vi.stubGlobal("location", { origin: "http://localhost" }); + const fetch = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async (_input, init) => { + const request = init + ? new Request("http://localhost/api/sources", init) + : new Request("http://localhost/api/sources"); + requestBody = (await request.formData()) as FormData; + return Response.json({ source: { id: "source_1" } }, { status: 201 }); + }); + vi.stubGlobal("fetch", fetch); + + const result = await postSourceUpload(file, false); + + expect(result.status).toBe(201); + expect((requestBody as FormData | null)?.get("file")).toEqual(file); + expect((requestBody as FormData | null)?.get("workspaceId")).toBeNull(); + }); }); diff --git a/src/domains/sources/upload-request.ts b/src/domains/sources/upload-request.ts index 50ffe63..d9fba94 100644 --- a/src/domains/sources/upload-request.ts +++ b/src/domains/sources/upload-request.ts @@ -20,21 +20,43 @@ type SourceUploadResponse = { export async function postSourceUpload( file: File, + isBlobConfigured = true, + workspaceId?: string, ): Promise { - return postBlobBackedSourceUpload(file); + // Vercel Blob staging is only available when BLOB_READ_WRITE_TOKEN is set + // (self-hosted deployments usually don't have it). Without it, fall back to + // a direct multipart upload to /api/sources, which the server always + // supports. + return isBlobConfigured + ? postBlobBackedSourceUpload(file, workspaceId) + : postDirectFileUpload(file, workspaceId); } async function postBlobBackedSourceUpload( file: File, + workspaceId?: string, ): Promise { return stagedUploadWorkflow.upload(file, { cleanupBlob: cleanupSourceBlobUpload, getPathname: getSourceUploadBlobPathname, - postMetadata: postSourceBlobUpload, + postMetadata: (input) => postSourceBlobUpload(input, workspaceId), uploadBlob: uploadSourceBlob, }); } +async function postDirectFileUpload( + file: File, + workspaceId?: string, +): Promise { + const formData = new FormData(); + formData.append("file", file); + if (workspaceId) formData.append("workspaceId", workspaceId); + return workspaceRouteClient.postFormDataWithStatus( + "/api/sources", + formData, + ); +} + async function uploadSourceBlob(input: { readonly file: File; readonly fileName: string; @@ -60,13 +82,16 @@ async function uploadSourceBlob(input: { }; } -async function postSourceBlobUpload(input: { - readonly pathname: string; - readonly url: string; - readonly fileName: string; - readonly mimeType: string; - readonly sizeBytes: number; -}): Promise { +async function postSourceBlobUpload( + input: { + readonly pathname: string; + readonly url: string; + readonly fileName: string; + readonly mimeType: string; + readonly sizeBytes: number; + }, + workspaceId?: string, +): Promise { return workspaceRouteClient.postJsonWithStatus( "/api/sources", { @@ -78,6 +103,7 @@ async function postSourceBlobUpload(input: { mimeType: input.mimeType, sizeBytes: input.sizeBytes, }, + ...(workspaceId ? { workspaceId } : {}), }, ); } diff --git a/src/domains/sources/upload.test.ts b/src/domains/sources/upload.test.ts index 912ab9d..a697319 100644 --- a/src/domains/sources/upload.test.ts +++ b/src/domains/sources/upload.test.ts @@ -10,6 +10,7 @@ import type { Workspace } from "@/infrastructure/db/schema"; const workspace: Workspace = { id: "8fca7b54-c2da-48f4-9668-a4b39fbc4d4c", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-8fca7b54-c2da-48f4-9668-a4b39fbc4d4c", createdAt: new Date("2026-05-06T00:00:00Z"), }; @@ -29,7 +30,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -109,7 +109,7 @@ describe("uploadSourceToKnowhere", () => { expect(deps.knowhere.jobs.create).toHaveBeenCalledWith({ sourceType: "file", fileName: "notes.pdf", - namespace: "default", + namespace: "notebook-8fca7b54-c2da-48f4-9668-a4b39fbc4d4c", documentMetadata: { createdByClient: "notebook", sourceFileName: "notes.pdf", @@ -320,7 +320,7 @@ describe("uploadSourceToKnowhere", () => { sourceType: "url", sourceUrl: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", fileName: "large.pdf", - namespace: "default", + namespace: "notebook-8fca7b54-c2da-48f4-9668-a4b39fbc4d4c", documentMetadata: { createdByClient: "notebook", sourceFileName: "large.pdf", diff --git a/src/domains/sources/view.test.ts b/src/domains/sources/view.test.ts index e854c29..f9245b5 100644 --- a/src/domains/sources/view.test.ts +++ b/src/domains/sources/view.test.ts @@ -18,7 +18,6 @@ function makeSource(overrides: Partial = {}): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-06T00:00:00Z"), updatedAt: new Date("2026-05-06T00:00:00Z"), deletedAt: null, @@ -94,44 +93,4 @@ describe("toSourceView", () => { }); }); - it("hides the download action for persisted demo originals", () => { - expect( - toSourceView( - makeSource({ - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 5648867, - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "https://example.com/tsla-q4-2025.pdf", - }), - { chunkCount: 70 }, - ), - ).toMatchObject({ - title: "TSLA-Q4-2025-Update.pdf", - demoSourceId: "demo-tsla-q4-2025", - documentId: "doc_user_copy", - chunkCount: 70, - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - canDownload: false, - pdfPreviewMode: "browser", - }, - }); - }); - - it("does not expose legacy demo original proxy routes", () => { - const view = toSourceView( - makeSource({ - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - knowhereDocumentId: "doc_user_copy", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }), - ); - - expect(view.demoSourceId).toBe("demo-tsla-q4-2025"); - expect(view.originalFile).toBeUndefined(); - }); }); diff --git a/src/domains/sources/view.ts b/src/domains/sources/view.ts index 4096a81..a995e70 100644 --- a/src/domains/sources/view.ts +++ b/src/domains/sources/view.ts @@ -28,7 +28,6 @@ export function toSourceView( title: source.title, mimeType: source.mimeType, status, - ...(source.demoKey ? { demoSourceId: source.demoKey } : {}), documentId: source.knowhereDocumentId ?? undefined, ...(failureMessage ? { failureMessage } : {}), ...(originalFile ? { originalFile } : {}), @@ -48,34 +47,10 @@ function getSourceOriginalFile( source: Source, ): SourceView["originalFile"] | undefined { if (!source.originalBlobUrl) return undefined - if (source.demoKey && !isPublicDemoOriginalUrl(source.originalBlobUrl)) { - return undefined - } return { url: source.originalBlobUrl, mimeType: source.mimeType, sizeBytes: source.sizeBytes, - ...(source.demoKey ? { canDownload: false } : {}), - ...(source.demoKey ? { pdfPreviewMode: "browser" as const } : {}), - } -} - -function isPublicDemoOriginalUrl(value: string): boolean { - try { - const parsedUrl = new URL(value) - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - return false - } - return !isDemoOriginalProxyPath(parsedUrl.pathname) - } catch { - return false - } -} - -function isDemoOriginalProxyPath(pathname: string): boolean { - return ( - /^\/api\/v1\/demo\/sources\/[^/]+\/original\/?$/.test(pathname) || - /^\/api\/demo-sources\/[^/]+\/original\/?$/.test(pathname) - ) + }; } diff --git a/src/domains/sources/workflow-runtime.test.ts b/src/domains/sources/workflow-runtime.test.ts index 3cc714a..7dae7ee 100644 --- a/src/domains/sources/workflow-runtime.test.ts +++ b/src/domains/sources/workflow-runtime.test.ts @@ -37,7 +37,6 @@ function makeSource(status: Source["status"]): Source { stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, diff --git a/src/domains/sources/workflow-runtime.ts b/src/domains/sources/workflow-runtime.ts index bbec158..e87efb0 100644 --- a/src/domains/sources/workflow-runtime.ts +++ b/src/domains/sources/workflow-runtime.ts @@ -17,10 +17,6 @@ type SaveSourceParseResultInput = Parameters< typeof sourceRepository.saveParseResultEffect >[2] -type UpsertMaterializedDemoSourceInput = Parameters< - typeof sourceRepository.upsertMaterializedDemoSourceEffect ->[1] - type UploadRepositoryRuntime = { readonly createUploading: ( workspaceId: string, @@ -69,11 +65,6 @@ type SourceWorkflowRuntime = UploadRepositoryRuntime & { workspaceId: string, input: LocalizeRemoteDocumentInput, ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise readonly markReady: ( workspaceId: string, sourceId: string, @@ -98,10 +89,6 @@ type SourceWorkflowRuntime = UploadRepositoryRuntime & { workspaceId: string, sourceId: string, ) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: UpsertMaterializedDemoSourceInput, - ) => Promise } const findInWorkspace: SourceWorkflowRuntime["findInWorkspace"] = ( @@ -123,20 +110,6 @@ const localizeRemoteDocument: SourceWorkflowRuntime["localizeRemoteDocument"] = sourceRepository.localizeRemoteDocumentEffect(workspaceId, input), ) -const listHiddenDemoSourceIds: SourceWorkflowRuntime["listHiddenDemoSourceIds"] = - (workspaceId: string) => - databaseRuntime.runPromise( - sourceRepository.listHiddenDemoSourceIdsEffect(workspaceId), - ) - -const hideDemoSource: SourceWorkflowRuntime["hideDemoSource"] = ( - workspaceId: string, - demoSourceId: string, -) => - databaseRuntime.runPromise( - sourceRepository.hideDemoSourceEffect(workspaceId, demoSourceId), - ) - const createUploading: SourceWorkflowRuntime["createUploading"] = ( workspaceId: string, input: CreateUploadingSourceInput, @@ -210,12 +183,6 @@ const softDelete: SourceWorkflowRuntime["softDelete"] = ( sourceRepository.softDeleteEffect(workspaceId, sourceId), ) -const upsertMaterializedDemoSource: SourceWorkflowRuntime["upsertMaterializedDemoSource"] = - (workspaceId: string, input: UpsertMaterializedDemoSourceInput) => - databaseRuntime.runPromise( - sourceRepository.upsertMaterializedDemoSourceEffect(workspaceId, input), - ) - const saveParseResult: SourceWorkflowRuntime["saveParseResult"] = ( workspaceId: string, sourceId: string, @@ -287,9 +254,7 @@ export const sourceWorkflowRuntime: SourceWorkflowRuntime = { findInWorkspace, getParseAssetUrls, getParseResultProgress, - hideDemoSource, listForWorkspace, - listHiddenDemoSourceIds, localizeRemoteDocument, markFailed, markParsing, @@ -298,5 +263,4 @@ export const sourceWorkflowRuntime: SourceWorkflowRuntime = { mergeParseAssetUrls, saveParseResult, softDelete, - upsertMaterializedDemoSource, } diff --git a/src/domains/workspace/client.test.ts b/src/domains/workspace/client.test.ts index 42e8c65..7d02880 100644 --- a/src/domains/workspace/client.test.ts +++ b/src/domains/workspace/client.test.ts @@ -63,19 +63,6 @@ describe("workspaceClient", () => { }) }) - it("throws materialization route errors instead of treating them as empty sources", async () => { - mockRouteClient.postJsonWithStatus.mockResolvedValue({ - status: 502, - body: { message: "Demo sources could not be prepared right now." }, - }) - - await expect( - workspaceClient.materializeDemoSources({ - demoSourceIds: ["demo-tsla-q4-2025"], - }), - ).rejects.toThrow("Demo sources could not be prepared right now.") - }) - it("retries a source with an encoded source id", async () => { mockRouteClient.patchJsonWithStatus.mockResolvedValue({ status: 200, diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index 9a7b3cd..fa62a29 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -1,4 +1,5 @@ import type { ChatDiagramSpec } from "@/domains/chat/diagram" +import type { RetrievalOverrides } from "@/domains/chat/contracts" import type { ChatMessageView, ChatThreadView, @@ -12,7 +13,7 @@ const workspaceClientKeys = { chatThreads: "/api/chat/threads", chatDiagram: "/api/chat/diagram", chat: "/api/chat", - materializeDemoSources: "/api/demo-sources/materialize", + namespaces: "/api/namespaces", archiveSource: "archive-source", retrySource: "retry-source", archiveChatThread: "archive-chat-thread", @@ -46,10 +47,7 @@ type ChatMessageRequest = { message: string threadId?: string excludedSourceIds: string[] -} - -type MaterializeDemoSourcesRequest = { - demoSourceIds: string[] + retrievalParams?: RetrievalOverrides } type SourcesResponse = { @@ -85,6 +83,45 @@ type RetrySourceResponse = { message?: string } +type NamespaceView = { + namespace: string + documentCount: number +} + +type NamespacesResponse = { + namespaces?: NamespaceView[] +} + + +export type KnowhereKeyLabelView = { + id: string + label: string + mask: string +} + + +export type WorkspaceView = { + id: string + namespace: string + activeKeyLabel?: string | null +} + +type CreateWorkspaceResponse = { + workspace?: WorkspaceView + message?: string +} + +export type WorkspaceApiKeyView = KnowhereKeyLabelView & { + createdAt: string +} + +type WorkspaceApiKeysResponse = { + keys?: WorkspaceApiKeyView[] + key?: WorkspaceApiKeyView + workspace?: WorkspaceView | null + message?: string +} + export const workspaceClient = { keys: workspaceClientKeys, fetchChunks, @@ -95,10 +132,19 @@ export const workspaceClient = { createChatThread, createChatDiagram, sendChatMessage, - materializeDemoSources, + fetchUserApiKeys, + createUserApiKey, + fetchApiKeyNamespaces, + activateWorkspace, + createWorkspace, + setActiveWorkspaceApiKey, + deleteUserApiKey, archiveSource, retrySource, archiveChatThread, + fetchWorkspaceMembers, + addWorkspaceMember, + removeWorkspaceMember, } as const async function fetchChunks(sourceId: string): Promise { @@ -180,24 +226,6 @@ function sendChatMessage( ) } -async function materializeDemoSources( - input: MaterializeDemoSourcesRequest, -): Promise { - const response = await workspaceRouteClient.postJsonWithStatus< - SourcesResponse & { readonly message?: string } - >( - workspaceClientKeys.materializeDemoSources, - input, - ) - if (response.status < 200 || response.status >= 300) { - throw new Error( - response.body.message ?? "Demo sources could not be prepared right now.", - ) - } - const body = response.body - return Array.isArray(body.sources) ? body.sources : [] -} - function archiveSource(sourceId: string): Promise { return workspaceRouteClient.patchJson( `/api/sources/${encodeURIComponent(sourceId)}`, @@ -231,3 +259,124 @@ function archiveChatThread(threadId: string): Promise { }, ) } + +async function fetchUserApiKeys(): Promise { + const body = await workspaceRouteClient.getJson( + "/api/api-keys", + ) + return Array.isArray(body.keys) ? body.keys : [] +} + +async function createUserApiKey( + label: string, + apiKey: string, +): Promise<{ key: WorkspaceApiKeyView; workspace: WorkspaceView | null }> { + const response = await workspaceRouteClient.postJsonWithStatus< + WorkspaceApiKeysResponse + >("/api/api-keys", { label, apiKey }) + if (response.status < 200 || response.status >= 300) { + throw new Error(response.body.message ?? "Could not add the API key.") + } + if (!response.body.key) { + throw new Error("Could not add the API key.") + } + return { + key: response.body.key, + workspace: response.body.workspace ?? null, + } +} + +async function fetchApiKeyNamespaces( + apiKeyId: string, +): Promise { + const body = await workspaceRouteClient.getJson( + `/api/api-keys/${encodeURIComponent(apiKeyId)}/namespaces`, + ) + return Array.isArray(body.namespaces) ? body.namespaces : [] +} + +async function activateWorkspace(workspaceId: string): Promise { + await workspaceRouteClient.postJson( + "/api/workspaces/activate", + { workspaceId }, + ) +} + +async function createWorkspace( + keyId: string, + namespace: string, +): Promise { + const response = await workspaceRouteClient.postJsonWithStatus< + CreateWorkspaceResponse + >("/api/workspaces", { keyId, namespace }) + if (response.status < 200 || response.status >= 300) { + throw new Error(response.body.message ?? "Could not create this workspace.") + } + if (!response.body.workspace) { + throw new Error("Could not create this workspace.") + } + return response.body.workspace +} + +async function setActiveWorkspaceApiKey( + workspaceId: string, + apiKeyId: string, +): Promise { + await workspaceRouteClient.patchJson(`/api/api-keys/${encodeURIComponent(apiKeyId)}`, { + workspaceId, + }) +} + +async function deleteUserApiKey(apiKeyId: string): Promise { + await workspaceRouteClient.deleteJson( + `/api/api-keys/${encodeURIComponent(apiKeyId)}`, + {}, + ) +} + +export type WorkspaceMemberView = { + readonly userId: string + readonly email: string | null + readonly name: string | null +} + +type WorkspaceMembersResponse = { + members?: WorkspaceMemberView[] + message?: string +} + +async function fetchWorkspaceMembers( + workspaceId: string, +): Promise { + const body = await workspaceRouteClient.getJson( + `/api/workspaces/${encodeURIComponent(workspaceId)}/members`, + ) + return Array.isArray(body.members) ? body.members : [] +} + +async function addWorkspaceMember( + workspaceId: string, + email: string, +): Promise { + const response = await workspaceRouteClient.postJsonWithStatus< + WorkspaceMembersResponse + >(`/api/workspaces/${encodeURIComponent(workspaceId)}/members`, { email }) + if (response.status < 200 || response.status >= 300) { + throw new Error(response.body.message ?? "Could not add the member.") + } +} + +async function removeWorkspaceMember( + workspaceId: string, + userId: string, +): Promise { + const response = await workspaceRouteClient.deleteJsonWithStatus<{ + message?: string + }>( + `/api/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}`, + {}, + ) + if (response.status < 200 || response.status >= 300) { + throw new Error(response.body.message ?? "Could not remove the member.") + } +} diff --git a/src/domains/workspace/demo-migration.test.ts b/src/domains/workspace/demo-migration.test.ts deleted file mode 100644 index 26bb6c9..0000000 --- a/src/domains/workspace/demo-migration.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from "node:fs" -import { join } from "node:path" - -import { describe, expect, it } from "vitest" - -describe("demo source migration", () => { - it("backfills visibility rows for deleted legacy demo sources", () => { - const migrationSql: string = readFileSync( - join(process.cwd(), "drizzle/0007_normalize_legacy_demo_sources.sql"), - "utf8", - ) - - expect(migrationSql).toContain('INSERT INTO "demo_source_visibilities"') - expect(migrationSql).toContain('"demo_key" IS NOT NULL') - expect(migrationSql).toContain('"deleted_at" IS NOT NULL') - expect(migrationSql).toContain( - 'ON CONFLICT ("workspace_id", "demo_source_id") DO UPDATE', - ) - }) - - it("soft-deletes legacy fake demo rows regardless of readiness state", () => { - const migrationSql: string = readFileSync( - join(process.cwd(), "drizzle/0007_normalize_legacy_demo_sources.sql"), - "utf8", - ) - - expect(migrationSql).toContain('"knowhere_job_id" IS NULL') - expect(migrationSql).toContain('"knowhere_document_id" IS NULL') - expect(migrationSql).toContain('"knowhere_document_id" LIKE \'demo-doc-%\'') - expect(migrationSql).not.toContain('"status" = \'ready\'') - }) -}) diff --git a/src/domains/workspace/initial-state.test.ts b/src/domains/workspace/initial-state.test.ts index 9ea24e3..889f076 100644 --- a/src/domains/workspace/initial-state.test.ts +++ b/src/domains/workspace/initial-state.test.ts @@ -3,13 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { loadWorkspaceShellInitialState } from "./initial-state" import type { AuthUser } from "@/infrastructure/auth" -import type { - ChatMessage, - ChatThread, - Source, - Workspace, -} from "@/infrastructure/db/schema" -import type { DemoCatalog } from "@/integrations/knowhere-demo" +import type { Source, Workspace } from "@/infrastructure/db/schema" import { formatUnknownForLog } from "@/lib/format-log-value" type InitialStateDependencies = NonNullable< @@ -31,314 +25,34 @@ describe("loadWorkspaceShellInitialState", () => { process.env.DASHBOARD_ORIGIN = originalDashboardOrigin }) - it("returns guest demo state from the Knowhere demo API only", async () => { + it("returns an empty unauthenticated state when no session is present", async () => { const deps = createDependencies({ + getCurrentUser: vi.fn(async () => null), getOptionalAuthenticated: vi.fn(async () => null), }) const state = await loadWorkspaceShellInitialState(deps) - expect(state.isGuest).toBe(true) - expect(state.sources).toEqual([ - { - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "demo-doc-tsla-q4-2025", - originalFile: { - url: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - pdfPreviewMode: "browser", - }, - officialLibrary: { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - }, - chunkCount: 70, - }, - ]) - expect(state.officialLibrarySources).toEqual([ - { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - categoryLabel: "Financial reports", - title: "TSLA-Q4-2025-Update.pdf", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - chunkCount: 70, - }, - ]) - expect(state.chatMessages).toEqual([ - { - id: "demo-example-1-user", - role: "user", - content: "What happened in Tesla Q4?", - }, - { - id: "demo-example-1-assistant", - role: "assistant", - content: "Tesla delivered higher revenue.", - citations: [ - { - chunkType: "text", - score: 0.95, - content: "Automotive revenue increased.", - source: { - documentId: "demo-doc-tsla-q4-2025", - sourceFileName: "TSLA-Q4-2025-Update.pdf", - sectionPath: "Shareholder Deck", - }, - }, - ], - }, - ]) - expect(state.loginUrl).toBe("/login") + expect(state).toEqual({ + sources: [], + workspaces: [], + knowhereKeyLabels: [], + }) expect(deps.listSourcesForWorkspace).not.toHaveBeenCalled() }) - it("exposes the configured Dashboard origin to the shell", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.staging.example" - + it("loads the workspace, sources, and key labels for an authenticated user", async () => { const state = await loadWorkspaceShellInitialState(createDependencies()) - expect(state.dashboardUrl).toBe("https://dashboard.staging.example") - }) - - it("lists visible API demos before authenticated workspace sources", async () => { - const workspace = makeWorkspace() - const source = makeSource(workspace.id) - const thread = makeThread(workspace.id) - const deps = createDependencies({ - listChatThreads: vi.fn(async () => [thread]), - listSourcesForWorkspace: vi.fn(async () => [source]), - sourceViewOptionsBySourceId: vi.fn(() => - Effect.succeed(new Map([[source.id, { chunkCount: 2 }]])), - ), + expect(state.user?.id).toBe("user_1") + expect(state.workspace).toEqual({ + id: "workspace_1", + namespace: "notebook-workspace_1", + activeKeyLabel: null, }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(state.isGuest).toBeUndefined() - expect(state.activeChatThreadId).toBe(thread.id) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - }), - { - id: source.id, - kind: "workspace", - title: "notes.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "document_1", - chunkCount: 2, - }, - ]) - expect(deps.ensureDemoChatThread).not.toHaveBeenCalled() - }) - - it("keeps authenticated workspace sources when the demo catalog is unavailable", async () => { - const workspace = makeWorkspace() - const source = makeSource(workspace.id) - const legacyFakeSource = makeSource(workspace.id, { - id: "source_legacy_demo", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }) - const sourceViewOptionsBySourceId = vi.fn(() => - Effect.succeed(new Map([[source.id, { chunkCount: 2 }]])), - ) - const deps = createDependencies({ - fetchDemoCatalog: vi.fn(async () => { - throw new Error("Demo API unavailable.") - }), - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource, source]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith( - [source], - expect.any(Object), - ) - expect(state.sources).toEqual([ - { - id: source.id, - kind: "workspace", - title: "notes.pdf", - mimeType: "application/pdf", - status: "ready", - documentId: "document_1", - chunkCount: 2, - }, - ]) - }) - - it("hides canonical demos that are hidden or already materialized", async () => { - const workspace = makeWorkspace() - const materializedSource = makeSource(workspace.id, { - id: "source_demo", - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - knowhereDocumentId: "doc_user_copy", - }) - const sourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())) - const deps = createDependencies({ - listHiddenDemoSourceIds: vi.fn(async () => ["another-demo"]), - listSourcesForWorkspace: vi.fn(async () => [materializedSource]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith([], expect.any(Object)) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "source_demo", - kind: "workspace", - documentId: "doc_user_copy", - chunkCount: 70, - }), - ]) - }) - - it("does not treat legacy fake demo rows as materialized user copies", async () => { - const workspace = makeWorkspace() - const legacyFakeSource = makeSource(workspace.id, { - id: "source_legacy_demo", - demoKey: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - knowhereJobId: null, - knowhereDocumentId: "demo-doc-tsla-q4-2025", - }) - const sourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())) - const deps = createDependencies({ - listSourcesForWorkspace: vi.fn(async () => [legacyFakeSource]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith([], expect.any(Object)) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - demoSourceId: "demo-tsla-q4-2025", - documentId: "demo-doc-tsla-q4-2025", - }), - ]) - }) - - it("does not list non-ready legacy demo rows as workspace sources", async () => { - const workspace = makeWorkspace() - const nonReadyLegacySource = makeSource(workspace.id, { - id: "source_non_ready_legacy_demo", - status: "parsing", - demoKey: "demo-tsla-q4-2025", - knowhereJobId: null, - knowhereDocumentId: null, - }) - const sourceViewOptionsBySourceId = vi.fn(() => Effect.succeed(new Map())) - const deps = createDependencies({ - listSourcesForWorkspace: vi.fn(async () => [nonReadyLegacySource]), - sourceViewOptionsBySourceId, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(sourceViewOptionsBySourceId).toHaveBeenCalledWith([], expect.any(Object)) - expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - }), - ]) - }) - - it("hides API-owned demos when deleted legacy rows were backfilled into visibility", async () => { - const state = await loadWorkspaceShellInitialState( - createDependencies({ - listHiddenDemoSourceIds: vi.fn(async () => ["demo-tsla-q4-2025"]), - }), - ) - - expect(state.sources).toEqual([]) - }) - - it("seeds authenticated empty workspaces with persisted demo chat", async () => { - const workspace = makeWorkspace() - const demoThread = makeThread(workspace.id, { - id: "demo_thread_1", - title: "What happened in Tesla Q4?", - demoKey: "knowhere-demo-chat", - }) - const demoMessages = [ - makeMessage(demoThread.id, { - id: "demo_message_user", - role: "user", - content: "What happened in Tesla Q4?", - }), - makeMessage(demoThread.id, { - id: "demo_message_assistant", - role: "assistant", - content: "Tesla delivered higher revenue.", - }), - ] - const ensureDemoChatThread = vi.fn(async () => ({ - thread: demoThread, - messages: demoMessages, - })) - const deps = createDependencies({ - getOptionalAuthenticated: vi.fn(async () => ({ - user: { - id: "user_1", - email: "ada@example.com", - name: "Ada", - }, - workspace, - })), - ensureDemoChatThread, - }) - - const state = await loadWorkspaceShellInitialState(deps) - - expect(ensureDemoChatThread).toHaveBeenCalledWith( - workspace.id, - makeDemoCatalog(), - ) - expect(state.activeChatThreadId).toBe("demo_thread_1") - expect(state.chatThreads).toEqual([ - expect.objectContaining({ - id: "demo_thread_1", - title: "What happened in Tesla Q4?", - }), - ]) - expect(state.chatMessages).toEqual([ - { - id: "demo_message_user", - role: "user", - content: "What happened in Tesla Q4?", - citations: undefined, - }, - { - id: "demo_message_assistant", - role: "assistant", - content: "Tesla delivered higher revenue.", - citations: undefined, - }, + expect(state.workspaces).toHaveLength(1) + expect(state.knowhereKeyLabels).toEqual([ + { id: "key_1", label: "default", mask: "sk_te••••st" }, ]) }) @@ -368,10 +82,6 @@ describe("loadWorkspaceShellInitialState", () => { expect(listSourcesForWorkspace).toHaveBeenCalledWith(workspace.id) expect(deps.reconcileSourcesForWorkspace).not.toHaveBeenCalled() expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - }), { id: readySource.id, kind: "workspace", @@ -455,10 +165,6 @@ describe("loadWorkspaceShellInitialState", () => { "sk_test", ) expect(state.sources).toEqual([ - expect.objectContaining({ - id: "demo-tsla-q4-2025", - kind: "demo", - }), { id: parsingSource.id, kind: "workspace", @@ -522,114 +228,28 @@ function createDependencies( const client = {} as InitialStateClient return { - fetchDemoCatalog: vi.fn(async () => makeDemoCatalog()), getClientForWorkspace: vi.fn(async () => ({ client, apiKey: "sk_test" })), - getGuest: vi.fn(async () => ({ loginUrl: "/login" })), + getCurrentUser: vi.fn(async () => user), getOptionalAuthenticated: vi.fn(async () => ({ user, workspace })), - ensureDemoChatThread: vi.fn(async () => null), listChatThreads: vi.fn(async () => []), - listHiddenDemoSourceIds: vi.fn(async () => []), listMessages: vi.fn(async () => []), listSourcesForWorkspace: vi.fn(async () => []), + listWorkspacesForUser: vi.fn(async () => [workspace]), + listMaskedKnowhereKeys: vi.fn(async () => [ + { id: "key_1", label: "default", mask: "sk_te••••st" }, + ]), + localizeRemoteDocument: vi.fn(async () => makeSource("workspace_1")), reconcileSourcesForWorkspace: vi.fn(async () => []), sourceViewOptionsBySourceId: vi.fn(() => Effect.succeed(new Map())), ...overrides, } } -function makeDemoCatalog(): DemoCatalog { - return { - officialLibrary: { - categories: [ - { - categoryId: "financial-reports", - label: "Financial reports", - description: "Company filings.", - }, - { - categoryId: "stem-books", - label: "STEM books", - description: "Course materials.", - }, - ], - sources: [ - { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - title: "TSLA-Q4-2025-Update.pdf", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - sizeBytes: 1024, - chunkCount: 70, - }, - { - librarySourceId: "stem-transformers", - categoryId: "stem-books", - title: "Transformers.pdf", - sourceUrl: "https://example.com/transformers.pdf", - mimeType: "application/pdf", - status: "planned", - }, - ], - }, - sources: [ - { - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - chunkCount: 70, - originalFile: { - url: "/api/v1/demo/sources/demo-tsla-q4-2025/original", - mimeType: "application/pdf", - sizeBytes: 1024, - canDownload: false, - }, - officialLibrary: { - librarySourceId: "financial-tsla-q4-2025", - categoryId: "financial-reports", - title: "TSLA-Q4-2025-Update.pdf", - sourceUrl: "https://example.com/tsla-q4-2025.pdf", - mimeType: "application/pdf", - status: "ready", - demoSourceId: "demo-tsla-q4-2025", - }, - examples: [ - { - id: "demo-example-1", - question: "What happened in Tesla Q4?", - answer: "Tesla delivered higher revenue.", - citations: [ - { - demoSourceId: "demo-tsla-q4-2025", - canonicalDocumentId: "demo-doc-tsla-q4-2025", - canonicalChunkId: "demo-chunk-1", - chunkId: "parser-chunk-1", - chunkType: "text", - content: "Automotive revenue increased.", - source: { - documentId: "demo-doc-tsla-q4-2025", - sourceFileName: "TSLA-Q4-2025-Update.pdf", - sectionPath: "Shareholder Deck", - }, - }, - ], - }, - ], - }, - ], - } -} - function makeWorkspace(): Workspace { return { id: "workspace_1", userId: "user_1", + activeKnowhereApiKeyId: null, namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), } @@ -653,43 +273,9 @@ function makeSource( stagedBlobUrl: null, originalBlobPathname: null, originalBlobUrl: null, - demoKey: null, createdAt: new Date("2026-05-10T00:00:00.000Z"), updatedAt: new Date("2026-05-10T00:00:00.000Z"), deletedAt: null, ...overrides, } } - -function makeThread( - workspaceId: string, - overrides: Partial = {}, -): ChatThread { - return { - id: "thread_1", - workspaceId, - demoKey: null, - title: "Revenue", - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - - deletedAt: null, - ...overrides, - } -} - -function makeMessage( - threadId: string, - overrides: Partial = {}, -): ChatMessage { - return { - id: "message_1", - threadId, - role: "user", - content: "Hello", - citations: null, - artifacts: null, - createdAt: new Date("2026-05-10T00:00:00.000Z"), - ...overrides, - } -} diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts index 368d43b..2af37eb 100644 --- a/src/domains/workspace/initial-state.ts +++ b/src/domains/workspace/initial-state.ts @@ -4,27 +4,17 @@ import { Effect } from "effect" import type { ChatMessageView } from "@/domains/chat/types" import type { ParsedChunkView } from "@/domains/chunks/types" -import { demoView } from "@/domains/demo/view" -import { - getMaterializedDemoSourceViewOptionsBySourceId, - getWorkspaceSourcesNeedingKnowhereChunkCount, - resolveWorkspaceDemoSources, -} from "@/domains/demo/workspace-source-resolution" import { chatThreadService } from "@/domains/chat/thread-service" import { toChatMessageView, toChatThreadView } from "@/domains/chat/view" import { sourceViewOptionsBySourceId as getSourceViewOptionsBySourceId } from "@/domains/sources/counts" -import { listRemoteLibrarySourceViews } from "@/domains/sources/remote-library" +import { localizeRemoteLibrarySources } from "@/domains/sources/remote-library" import { reconcileSourcesForWorkspace as reconcileDefaultSourcesForWorkspace } from "@/domains/sources/reconcile" -import { sourceService } from "@/domains/sources/service" import { startBackgroundReconciliation as defaultStartBackgroundReconciliation, } from "@/domains/sources/background-reconcile" import { sourceWorkflowRuntime } from "@/domains/sources/workflow-runtime" -import type { - OfficialLibrarySourceView, - SourceView, -} from "@/domains/sources/types" +import type { SourceView } from "@/domains/sources/types" import { toSourceView } from "@/domains/sources/view" import type { AuthUser } from "@/infrastructure/auth" import type { @@ -33,24 +23,18 @@ import type { Source, Workspace, } from "@/infrastructure/db/schema" -import { - knowhereDemoApi, - type DemoCatalog, - type OfficialLibrarySource, -} from "@/integrations/knowhere-demo" import { effectOperation } from "@/lib/effect-operation" import { logger } from "@/lib/logger" import { notebookRequestContext } from "./request-context" +import { workspaceRepository } from "./repository" +import { databaseRuntime } from "./database-runtime" +import { knowhereApiKeysRepository } from "@/infrastructure/auth/knowhere-api-keys-repository" type WorkspaceShellInitialState = { readonly activeChatThreadId?: string | null readonly chatMessages?: ChatMessageView[] readonly chatThreads?: ReturnType[] - readonly dashboardUrl?: string readonly initialPrefetchedChunksBySourceId?: Record - readonly isGuest?: boolean - readonly loginUrl?: string - readonly officialLibrarySources?: OfficialLibrarySourceView[] readonly sources?: SourceView[] readonly user?: { readonly id: string @@ -60,38 +44,25 @@ type WorkspaceShellInitialState = { readonly workspace?: { readonly id: string readonly namespace: string + readonly activeKeyLabel: string | null } + readonly workspaces?: readonly { + readonly id: string + readonly namespace: string + readonly activeKeyLabel: string | null + }[] + readonly knowhereKeyLabels?: readonly { + readonly id: string + readonly label: string + readonly mask: string + }[] + /** Whether Vercel Blob is configured (BLOB_READ_WRITE_TOKEN). When false, + * uploads use the direct multipart path to /api/sources. */ + readonly isBlobConfigured?: boolean } -// Aligned with workspaceClientConfig.sourceChunkPageSize so the SSR -// prefetch doesn't overlap with the first client-side page request. -const DEMO_CHUNK_PREFETCH_PAGE_SIZE = 50 const workspaceInitialStateContext = "Workspace initial state" -async function getDemoChunksForSource( - demoSourceId: string, -): Promise { - const chunkPage = await knowhereDemoApi.fetchChunkPage({ - demoSourceId, - page: 1, - pageSize: DEMO_CHUNK_PREFETCH_PAGE_SIZE, - }) - // Only title and documentId are consumed by toParsedChunkView, - // so a minimal SourceView is sufficient. - const sourceView: SourceView = { - id: chunkPage.demoSourceId, - kind: "demo", - demoSourceId: chunkPage.demoSourceId, - title: chunkPage.title, - mimeType: chunkPage.mimeType, - status: "ready", - documentId: chunkPage.canonicalDocumentId, - } - return chunkPage.chunks.map((chunk) => - demoView.toParsedChunkView(sourceView, chunk), - ) -} - type WorkspaceShellInitialStateClient = Parameters[1] & Parameters[1] & { @@ -111,29 +82,20 @@ type WorkspaceShellInitialStateClient = } type WorkspaceShellInitialStateDependencies = { - readonly fetchDemoCatalog: () => Promise readonly getClientForWorkspace: ( workspace: Workspace, ) => Promise<{ readonly apiKey: string readonly client: WorkspaceShellInitialStateClient }> - readonly getGuest: () => Promise<{ readonly loginUrl: string }> + readonly getCurrentUser: () => Promise readonly getOptionalAuthenticated: () => Promise<{ readonly user: AuthUser readonly workspace: Workspace } | null> - readonly ensureDemoChatThread: ( - workspaceId: string, - catalog: DemoCatalog, - ) => Promise<{ - readonly thread: ChatThread - readonly messages: readonly ChatMessage[] - } | null> readonly listChatThreads: ( workspaceId: string, ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise readonly listMessages: ( workspaceId: string, threadId: string, @@ -141,6 +103,13 @@ type WorkspaceShellInitialStateDependencies = { readonly listSourcesForWorkspace: ( workspaceId: string, ) => Promise + readonly listWorkspacesForUser: ( + userId: string, + ) => Promise + readonly listMaskedKnowhereKeys: (userId: string) => Promise< + readonly { id: string; label: string; mask: string }[] + > + readonly localizeRemoteDocument: typeof sourceWorkflowRuntime.localizeRemoteDocument readonly reconcileSourcesForWorkspace: ( workspace: Workspace, client: WorkspaceShellInitialStateClient, @@ -153,15 +122,15 @@ type WorkspaceShellInitialStateDependencies = { } const defaultDependencies: WorkspaceShellInitialStateDependencies = { - fetchDemoCatalog: knowhereDemoApi.fetchCatalog, getClientForWorkspace: notebookRequestContext.getClientForWorkspace, - getGuest: notebookRequestContext.getGuest, + getCurrentUser: notebookRequestContext.getCurrentUser, getOptionalAuthenticated: notebookRequestContext.getOptionalAuthenticated, - ensureDemoChatThread: chatThreadService.ensureDemo, listChatThreads: chatThreadService.listForWorkspace, - listHiddenDemoSourceIds: sourceService.listHiddenDemoSourceIds, listMessages: chatThreadService.listMessages, listSourcesForWorkspace: sourceWorkflowRuntime.listForWorkspace, + listWorkspacesForUser: listAllForUser, + listMaskedKnowhereKeys: listMaskedKnowhereKeysDefault, + localizeRemoteDocument: sourceWorkflowRuntime.localizeRemoteDocument, reconcileSourcesForWorkspace: reconcileDefaultSourcesForWorkspace, startBackgroundReconciliation: defaultStartBackgroundReconciliation, sourceViewOptionsBySourceId: getSourceViewOptionsBySourceId, @@ -175,6 +144,29 @@ export const loadWorkspaceShellInitialStateEffect = ( deps: WorkspaceShellInitialStateDependencies = defaultDependencies, ) => Effect.gen(function* () { + const user = yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "getCurrentUser", + }, + () => deps.getCurrentUser(), + ) + if (!user) { + return { + sources: [], + workspaces: [], + knowhereKeyLabels: [], + } + } + + const workspacesForUser = yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "listWorkspacesForUser", + }, + () => deps.listWorkspacesForUser(user.id), + ) + const context = yield* effectOperation.tryPromise( { context: workspaceInitialStateContext, @@ -183,64 +175,39 @@ export const loadWorkspaceShellInitialStateEffect = ( () => deps.getOptionalAuthenticated(), ) - if (!context) { - const demoCatalog = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "fetchDemoCatalog", - }, - () => deps.fetchDemoCatalog(), - ) - const guestContext = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "getGuest", - }, - () => deps.getGuest(), - ) - - const firstDemoSource = demoCatalog.sources[0] - let initialPrefetchedChunksBySourceId: Record< - string, - ParsedChunkView[] - > = {} - if (firstDemoSource) { - const chunks = yield* Effect.catchAll( - effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "getDemoChunksForSource", - }, - () => getDemoChunksForSource(firstDemoSource.demoSourceId), - ), - () => Effect.succeed([] as ParsedChunkView[]), - ) - if (chunks.length > 0) { - initialPrefetchedChunksBySourceId = { - [firstDemoSource.demoSourceId]: chunks, - } - } - } + const userKeys = yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "listMaskedKnowhereKeys", + }, + () => deps.listMaskedKnowhereKeys(user.id), + ) + const workspaceView = (row: Workspace) => ({ + id: row.id, + namespace: row.namespace, + activeKeyLabel: + userKeys.find((key) => key.id === row.activeKnowhereApiKeyId)?.label ?? + null, + }) + // Authenticated but no workspace yet: new users must add an API key and + // pick a namespace before any workspace exists. + if (!context) { return { - isGuest: true, - officialLibrarySources: toOfficialLibrarySourceViews(demoCatalog), - sources: demoCatalog.sources.map(demoView.toSourceView), - chatMessages: demoView.toChatMessages(demoCatalog), - dashboardUrl: resolveDashboardUrl(), - initialPrefetchedChunksBySourceId, - loginUrl: guestContext.loginUrl, + user: { + id: user.id, + name: user.name ?? null, + email: user.email ?? null, + }, + workspace: undefined, + workspaces: workspacesForUser.map(workspaceView), + knowhereKeyLabels: userKeys, + isBlobConfigured: isBlobConfigured(), + sources: [], } } - const { user, workspace } = context - const demoCatalog = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "fetchOptionalCatalog", - }, - () => knowhereDemoApi.fetchOptionalCatalog(deps.fetchDemoCatalog), - ) + const { workspace } = context const listedSources = yield* effectOperation.tryPromise( { context: workspaceInitialStateContext, @@ -248,15 +215,6 @@ export const loadWorkspaceShellInitialStateEffect = ( }, () => deps.listSourcesForWorkspace(workspace.id), ) - const hiddenDemoSourceIds = new Set( - yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "listHiddenDemoSourceIds", - }, - () => deps.listHiddenDemoSourceIds(workspace.id), - ), - ) const listedChatThreads = yield* effectOperation.tryPromise( { context: workspaceInitialStateContext, @@ -264,31 +222,16 @@ export const loadWorkspaceShellInitialStateEffect = ( }, () => deps.listChatThreads(workspace.id), ) - const seededDemoChatThread = - listedChatThreads.length === 0 - ? yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "ensureDemoChatThread", - }, - () => deps.ensureDemoChatThread(workspace.id, demoCatalog), - ) - : null - const chatThreads = seededDemoChatThread - ? [seededDemoChatThread.thread] - : listedChatThreads - const activeChatThread = chatThreads[0] ?? null - const activeChatMessages = seededDemoChatThread - ? seededDemoChatThread.messages - : activeChatThread - ? yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "listMessages", - }, - () => deps.listMessages(workspace.id, activeChatThread.id), - ) - : [] + const activeChatThread = listedChatThreads[0] ?? null + const activeChatMessages = activeChatThread + ? yield* effectOperation.tryPromise( + { + context: workspaceInitialStateContext, + operation: "listMessages", + }, + () => deps.listMessages(workspace.id, activeChatThread.id), + ) + : [] const chatMessages = activeChatMessages ? activeChatMessages.map((message) => toChatMessageView(message)) : [] @@ -299,45 +242,31 @@ export const loadWorkspaceShellInitialStateEffect = ( }, () => deps.getClientForWorkspace(workspace), ) - const sources = yield* effectOperation.tryPromise( - { - context: workspaceInitialStateContext, - operation: "useListedSourcesForWorkspace", - }, - () => Promise.resolve(listedSources), - ) - const demoSourceResolution = resolveWorkspaceDemoSources( - sources, - demoCatalog, - ) - const visibleDemoCatalogSources = demoCatalog.sources - .filter( - (source) => - !demoSourceResolution.materializedDemoSourceIds.has( - source.demoSourceId, - ), - ) - .filter((source) => !hiddenDemoSourceIds.has(source.demoSourceId)) - const demoSources = visibleDemoCatalogSources.map(demoView.toSourceView) - const workspaceSources = demoSourceResolution.workspaceSources - const remoteSourceViews = yield* effectOperation.addContext( + const sources = listedSources + const workspaceSources = sources + const localizedSources = yield* effectOperation.addContext( { context: workspaceInitialStateContext, - operation: "listRemoteLibrarySourceViews", + operation: "localizeRemoteLibrarySources", }, - listRemoteLibrarySourceViews({ + localizeRemoteLibrarySources({ workspace, client, - localSources: demoSourceResolution.workspaceSources, + localSources: workspaceSources, + localizeDocument: (document) => + deps.localizeRemoteDocument(workspace.id, { + documentId: document.documentId, + namespace: document.namespace, + status: document.status, + title: document.title, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + revisionKey: document.revisionKey ?? null, + }), }), ) const sourcesNeedingKnowhereChunkCount = - getWorkspaceSourcesNeedingKnowhereChunkCount(workspaceSources) - const materializedDemoSourceOptions = - getMaterializedDemoSourceViewOptionsBySourceId( - workspaceSources, - demoCatalog, - ) + getWorkspaceSourcesNeedingKnowhereChunkCount(localizedSources) yield* Effect.sync(() => triggerBackgroundReconciliationForParsingSources({ workspaceId: workspace.id, @@ -365,24 +294,14 @@ export const loadWorkspaceShellInitialStateEffect = ( name: user.name ?? null, email: user.email ?? null, }, - workspace: { - id: workspace.id, - namespace: workspace.namespace, - }, - dashboardUrl: resolveDashboardUrl(), - sources: [ - ...demoSources, - ...workspaceSources.map((source) => - toSourceView( - source, - materializedDemoSourceOptions.get(source.id) ?? - sourceOptions.get(source.id), - ), - ), - ...remoteSourceViews, - ], - officialLibrarySources: toOfficialLibrarySourceViews(demoCatalog), - chatThreads: chatThreads.map(toChatThreadView), + workspace: workspaceView(workspace), + workspaces: workspacesForUser.map(workspaceView), + knowhereKeyLabels: userKeys, + isBlobConfigured: isBlobConfigured(), + sources: localizedSources.map((source) => + toSourceView(source, sourceOptions.get(source.id)), + ), + chatThreads: listedChatThreads.map(toChatThreadView), activeChatThreadId: activeChatThread?.id ?? null, chatMessages, } @@ -402,8 +321,37 @@ export async function loadWorkspaceShellInitialState( // Helpers // --------------------------------------------------------------------------- -function resolveDashboardUrl(): string | undefined { - return process.env.DASHBOARD_ORIGIN +function listAllForUser(userId: string): Promise { + return databaseRuntime.runPromise( + workspaceRepository.findAllByUserIdEffect(userId), + ) +} + +/** True when Vercel Blob storage is configured (chunk cache + staged uploads). */ +function isBlobConfigured(): boolean { + const token = process.env.BLOB_READ_WRITE_TOKEN?.trim() + return Boolean(token && token.length > 0) +} +function listMaskedKnowhereKeysDefault( + userId: string, +): Promise { + return databaseRuntime + .runPromise(knowhereApiKeysRepository.listByUserEffect(userId)) + .then((keys) => + keys.map((key) => ({ + id: key.id, + label: key.label, + mask: key.keyMask, + })), + ) +} + +function getWorkspaceSourcesNeedingKnowhereChunkCount( + sources: readonly Source[], +): readonly Source[] { + return sources.filter( + (source) => source.status === "ready" && source.knowhereDocumentId, + ) } function triggerBackgroundReconciliationForParsingSources(input: { @@ -435,39 +383,3 @@ function triggerBackgroundReconciliationForParsingSources(input: { }) } } - -function toOfficialLibrarySourceViews( - catalog: DemoCatalog, -): OfficialLibrarySourceView[] { - const categoryLabelById = new Map( - catalog.officialLibrary.categories.map((category) => [ - category.categoryId, - category.label, - ]), - ) - return catalog.officialLibrary.sources - .filter(isReadyOfficialLibrarySource) - .map((source) => ({ - librarySourceId: source.librarySourceId, - categoryId: source.categoryId, - categoryLabel: - categoryLabelById.get(source.categoryId) ?? source.categoryId, - title: source.title, - sourceUrl: source.sourceUrl, - mimeType: source.mimeType, - status: source.status, - demoSourceId: source.demoSourceId, - ...(source.chunkCount !== undefined - ? { chunkCount: source.chunkCount } - : {}), - })) -} - -function isReadyOfficialLibrarySource( - source: OfficialLibrarySource, -): source is OfficialLibrarySource & { - readonly status: "ready" - readonly demoSourceId: string -} { - return source.status === "ready" && source.demoSourceId !== undefined -} diff --git a/src/domains/workspace/integration.test.ts b/src/domains/workspace/integration.test.ts index 797cdbf..6666fb0 100644 --- a/src/domains/workspace/integration.test.ts +++ b/src/domains/workspace/integration.test.ts @@ -7,7 +7,6 @@ import * as schema from "@/infrastructure/db/schema"; import { chatMessages, chatThreads, - demoSourceVisibilities, sourceParseResults, sources, workspaces, @@ -99,17 +98,6 @@ describeIfDb("workspace helpers — integration", () => { workspaceId: string, sourceId: string, ) => Promise>> - readonly hideDemoSource: ( - workspaceId: string, - demoSourceId: string, - ) => Promise - readonly listHiddenDemoSourceIds: (workspaceId: string) => Promise - readonly upsertMaterializedDemoSource: ( - workspaceId: string, - input: Parameters< - typeof import("../sources/service").sourceService.upsertMaterializedDemoSource - >[1], - ) => Promise }; beforeEach(async () => { @@ -132,7 +120,13 @@ describeIfDb("workspace helpers — integration", () => { import("../chat/thread-service"), ]); workspaceHelpers = { - ensureWorkspace: workspaceService.ensureWorkspace, + ensureWorkspace: async (userId: string) => { + const row = await workspaceService.ensureWorkspaceForNamespace( + userId, + `ns-${userId}`, + ) + return row + }, findSourceInWorkspace: sourceService.findInWorkspace, softDeleteSource: sourceService.softDelete, appendMessageToThread: chatThreadService.appendMessage, @@ -146,17 +140,12 @@ describeIfDb("workspace helpers — integration", () => { markSourceFailed: sourceWorkflowRuntime.markFailed, saveSourceParseResult: sourceWorkflowRuntime.saveParseResult, getParseAssetUrls: sourceService.getParseAssetUrls, - hideDemoSource: sourceService.hideDemoSource, - listHiddenDemoSourceIds: sourceService.listHiddenDemoSourceIds, - upsertMaterializedDemoSource: - sourceService.upsertMaterializedDemoSource, }; // Clean slate on the tables these tests touch. Order respects FK. await testDb.delete(chatMessages); await testDb.delete(chatThreads); await testDb.delete(sourceParseResults); - await testDb.delete(demoSourceVisibilities); await testDb.delete(sources); await testDb.delete(workspaces); }); @@ -592,42 +581,4 @@ describeIfDb("workspace helpers — integration", () => { workspaceHelpers.getParseAssetUrls(otherWs.id, source.id), ).resolves.toEqual({}); }); - - it("tracks hidden demos and upserts materialized demo sources by demo id", async () => { - const ws = await workspaceHelpers.ensureWorkspace("user_1"); - - await workspaceHelpers.hideDemoSource(ws.id, "demo-tsla-q4-2025"); - await workspaceHelpers.hideDemoSource(ws.id, "demo-tsla-q4-2025"); - - await expect(workspaceHelpers.listHiddenDemoSourceIds(ws.id)).resolves.toEqual([ - "demo-tsla-q4-2025", - ]); - - const first = await workspaceHelpers.upsertMaterializedDemoSource(ws.id, { - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - knowhereDocumentId: "doc_user_copy_1", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }); - const second = await workspaceHelpers.upsertMaterializedDemoSource(ws.id, { - demoSourceId: "demo-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - knowhereDocumentId: "doc_user_copy_2", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }); - - expect(second.id).toBe(first.id); - expect(second).toMatchObject({ - demoKey: "demo-tsla-q4-2025", - status: "ready", - failureReason: null, - knowhereJobId: null, - knowhereDocumentId: "doc_user_copy_2", - originalBlobUrl: "/api/demo-sources/demo-tsla-q4-2025/original", - }); - }); }); diff --git a/src/domains/workspace/persistence.test.ts b/src/domains/workspace/persistence.test.ts index 47534d1..26b672c 100644 --- a/src/domains/workspace/persistence.test.ts +++ b/src/domains/workspace/persistence.test.ts @@ -12,12 +12,19 @@ import { chatRepository } from "../chat/repository" * which runs only when `TEST_DATABASE_URL` is set. */ -type Row = { id: string; userId: string; namespace: string; createdAt: Date } +type Row = { + id: string + userId: string + knowhereKeyLabel: string | null + namespace: string + createdAt: Date +} type SelectBuilder = { from: ReturnType where: ReturnType - limit: (n: number) => Promise + orderBy: ReturnType + limit: ReturnType } type InsertBuilder = { @@ -34,7 +41,6 @@ type ChatThreadRow = { id: string workspaceId: string title: string | null - demoKey: string | null createdAt: Date updatedAt: Date deletedAt: Date | null @@ -53,11 +59,24 @@ type ChatRepositoryDbMock = { } function buildDbMock(storage: { row: Row | null }): DbMock { - function makeSelect(): SelectBuilder { + // `select()` returns all columns; `select({ workspaceId })` (with a + // columns object) is the workspace_members probe which should resolve to + // an empty result set in these tests. + function makeSelect(rows: () => unknown[]): SelectBuilder { const builder: SelectBuilder = { from: vi.fn(() => builder), - where: vi.fn(() => builder), - limit: vi.fn(async () => (storage.row ? [storage.row] : [])), + // `where` must stay chainable (`where(...).orderBy(...)` in the real + // query) yet also serve as the terminal for the members probe + // (`select({...}).from(members).where(...)`). + where: vi.fn(function (this: SelectBuilder) { + const chainable = Object.assign(Promise.resolve(rows()), { + orderBy: async () => rows(), + limit: async () => rows(), + }) + return chainable + }), + orderBy: vi.fn(async () => rows()), + limit: vi.fn(async () => rows()), } return builder } @@ -68,6 +87,7 @@ function buildDbMock(storage: { row: Row | null }): DbMock { storage.row = { id: crypto.randomUUID(), userId: values.userId, + knowhereKeyLabel: values.knowhereKeyLabel ?? null, namespace: values.namespace, createdAt: new Date(), } @@ -79,7 +99,11 @@ function buildDbMock(storage: { row: Row | null }): DbMock { return builder } return { - select: vi.fn(() => makeSelect()), + select: vi.fn((columns?: unknown) => + columns + ? makeSelect(() => []) + : makeSelect(() => (storage.row ? [storage.row] : [])), + ), insert: vi.fn(() => makeInsert()), } } @@ -107,6 +131,7 @@ describe("workspaceService.ensureWorkspace", () => { const existing: Row = { id: "ws_1", userId: "user_1", + knowhereKeyLabel: null, namespace: "notebook-existing", createdAt: new Date(), } @@ -120,31 +145,30 @@ describe("workspaceService.ensureWorkspace", () => { expect(dbMock.insert).not.toHaveBeenCalled() }) - it("inserts a new workspace on the cold path with a derived namespace", async () => { + it("returns null when the user has no workspace (no auto-create)", async () => { const storage: { row: Row | null } = { row: null } const dbMock = buildDbMock(storage) const { workspaceService } = await loadWorkspaceService(dbMock) const got = await workspaceService.ensureWorkspace("user_2") - expect(dbMock.insert).toHaveBeenCalledOnce() - expect(got.userId).toBe("user_2") - expect(got.namespace).toMatch(/^notebook-[0-9a-f-]{36}$/) + expect(got).toBeNull() + expect(dbMock.insert).not.toHaveBeenCalled() }) - it("is idempotent across concurrent first-time calls for the same user", async () => { + it("creates a workspace for a specific namespace", async () => { const storage: { row: Row | null } = { row: null } const dbMock = buildDbMock(storage) const { workspaceService } = await loadWorkspaceService(dbMock) - const [a, b] = await Promise.all([ - workspaceService.ensureWorkspace("user_3"), - workspaceService.ensureWorkspace("user_3"), - ]) + const got = await workspaceService.ensureWorkspaceForNamespace( + "user_1", + "quarterly-reports", + ) - expect(a.id).toBe(b.id) - expect(a.namespace).toBe(b.namespace) - expect(a.userId).toBe("user_3") + expect(dbMock.insert).toHaveBeenCalledOnce() + expect(got.userId).toBe("user_1") + expect(got.namespace).toBe("quarterly-reports") }) }) @@ -155,7 +179,6 @@ describe("chatRepository", () => { id: "thread_1", workspaceId: "workspace_1", title: "Grounded answer", - demoKey: null, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), deletedAt: null, diff --git a/src/domains/workspace/repository.ts b/src/domains/workspace/repository.ts index bee08c9..ee23101 100644 --- a/src/domains/workspace/repository.ts +++ b/src/domains/workspace/repository.ts @@ -1,52 +1,175 @@ import "server-only" -import { eq, sql } from "drizzle-orm" +import { and, eq, inArray, isNull, sql } from "drizzle-orm" import { Effect } from "effect" import { DbClient } from "@/infrastructure/db" -import { workspaces, type Workspace } from "@/infrastructure/db/schema" +import { + workspaceMembers, + workspaces, + type Workspace, +} from "@/infrastructure/db/schema" type WorkspaceRepository = { - readonly findByUserIdEffect: ( + readonly findAllByUserIdEffect: ( userId: string, + ) => Effect.Effect + readonly findByIdEffect: ( + id: string, ) => Effect.Effect - readonly insertForUserEffect: ( + readonly findByIdAndUserIdEffect: ( + id: string, + userId: string, + ) => Effect.Effect + readonly findByUserIdAndNamespaceEffect: ( + userId: string, + namespace: string, + ) => Effect.Effect + readonly insertForUserNamespaceEffect: ( userId: string, namespace: string, ) => Effect.Effect readonly pingEffect: () => Effect.Effect } -const findByUserIdEffect: WorkspaceRepository["findByUserIdEffect"] = ( +/** + * Workspaces the user can see: their own rows plus any they are a member + * of (Phase 4 team sharing). + */ +const findAllByUserIdEffect: WorkspaceRepository["findAllByUserIdEffect"] = ( userId: string, ) => Effect.gen(function* () { const db = yield* DbClient - const row = yield* Effect.promise(() => + const owned = yield* Effect.promise(() => db .select() .from(workspaces) .where(eq(workspaces.userId, userId)) - .limit(1), + .orderBy(workspaces.createdAt), + ) + const memberWorkspaceIds = yield* Effect.promise(() => + db + .select({ workspaceId: workspaceMembers.workspaceId }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.userId, userId), + isNull(workspaceMembers.deletedAt), + ), + ), ) + if (memberWorkspaceIds.length === 0) return owned + const memberRows = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where( + inArray( + workspaces.id, + memberWorkspaceIds.map((row) => row.workspaceId), + ), + ), + ) + const seen = new Set(owned.map((workspace) => workspace.id)) + return [ + ...owned, + ...memberRows.filter((workspace) => { + if (seen.has(workspace.id)) return false + seen.add(workspace.id) + return true + }), + ] + }) + +const findByIdEffect: WorkspaceRepository["findByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where(eq(workspaces.id, id)) + .limit(1), + ) return row[0] ?? null }) -const insertForUserEffect: WorkspaceRepository["insertForUserEffect"] = ( +const findByIdAndUserIdEffect: WorkspaceRepository["findByIdAndUserIdEffect"] = ( + id: string, userId: string, - namespace: string, ) => Effect.gen(function* () { const db = yield* DbClient - yield* Effect.promise(() => + const owned = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where(and(eq(workspaces.id, id), eq(workspaces.userId, userId))) + .limit(1), + ) + if (owned[0]) return owned[0] + + // Phase 4: members can access the workspace too. + const membership = yield* Effect.promise(() => + db + .select() + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, id), + eq(workspaceMembers.userId, userId), + isNull(workspaceMembers.deletedAt), + ), + ) + .limit(1), + ) + if (membership.length === 0) return null + + const row = yield* Effect.promise(() => db - .insert(workspaces) - .values({ userId, namespace }) - .onConflictDoNothing({ target: workspaces.userId }), + .select() + .from(workspaces) + .where(eq(workspaces.id, id)) + .limit(1), ) + return row[0] ?? null }) +const findByUserIdAndNamespaceEffect: WorkspaceRepository["findByUserIdAndNamespaceEffect"] = + (userId: string, namespace: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where( + and( + eq(workspaces.userId, userId), + eq(workspaces.namespace, namespace), + ), + ) + .limit(1), + ) + return row[0] ?? null + }) + +const insertForUserNamespaceEffect: WorkspaceRepository["insertForUserNamespaceEffect"] = + (userId: string, namespace: string) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .insert(workspaces) + .values({ userId, namespace }) + .onConflictDoNothing({ + target: [workspaces.userId, workspaces.namespace], + }), + ) + }) + const pingEffect: WorkspaceRepository["pingEffect"] = () => Effect.gen(function* () { const db = yield* DbClient @@ -54,7 +177,10 @@ const pingEffect: WorkspaceRepository["pingEffect"] = () => }) export const workspaceRepository: WorkspaceRepository = { - findByUserIdEffect, - insertForUserEffect, + findAllByUserIdEffect, + findByIdEffect, + findByIdAndUserIdEffect, + findByUserIdAndNamespaceEffect, + insertForUserNamespaceEffect, pingEffect, } diff --git a/src/domains/workspace/request-context.ts b/src/domains/workspace/request-context.ts index dc0e669..4d83d49 100644 --- a/src/domains/workspace/request-context.ts +++ b/src/domains/workspace/request-context.ts @@ -1,12 +1,10 @@ import "server-only" import { Effect } from "effect" -import { headers } from "next/headers" -import { ensureApiKeyForWorkspace } from "@/integrations/dashboard/api-key-service" -import { authURLs } from "@/infrastructure/auth/urls" +import { ensureApiKeyForWorkspace } from "@/integrations/knowhere-credentials" import { - getCurrentUser, + getCurrentUser as getCurrentUserFromAuth, requireUser, type AuthUser, } from "@/infrastructure/auth" @@ -26,10 +24,6 @@ type AuthenticatedNotebookClientContext = AuthenticatedNotebookContext & { readonly client: NotebookClient } -type GuestNotebookContext = { - readonly loginUrl: string -} - // --------------------------------------------------------------------------- // Effect core // --------------------------------------------------------------------------- @@ -39,17 +33,27 @@ const getAuthenticatedEffect = Effect.gen(function* () { const workspace = yield* Effect.tryPromise(() => workspaceService.ensureWorkspace(user.id), ) + if (!workspace) { + return yield* Effect.die( + new Error( + "No workspace for this user. The user must add an API key and " + + "pick a namespace first.", + ), + ) + } return { user, workspace } }) const getOptionalAuthenticatedEffect = Effect.gen(function* () { - const user = yield* Effect.tryPromise(() => getCurrentUser()) + const user = yield* Effect.tryPromise(() => getCurrentUserFromAuth()) if (!user) return null const workspace = yield* Effect.tryPromise(() => workspaceService.ensureWorkspace(user.id), ) + if (!workspace) return null + return { user, workspace } }) @@ -65,33 +69,14 @@ const getAuthenticatedWithClientEffect = Effect.gen(function* () { const getClientForWorkspaceEffect = (workspace: Workspace) => Effect.gen(function* () { - const cookieHeader = - (yield* Effect.tryPromise(() => headers())).get("cookie") ?? "" const apiKey = yield* Effect.tryPromise(() => - ensureApiKeyForWorkspace(workspace.id, cookieHeader), + ensureApiKeyForWorkspace(workspace.id), ) const client = makeKnowhereClient(apiKey) return { apiKey, client } }) -const getGuestEffect = Effect.gen(function* () { - const dashboardOrigin = - process.env.DASHBOARD_ORIGIN ?? "http://localhost:3000" - const dashboardLoginURL = `${dashboardOrigin}/login` - const headersList = yield* Effect.tryPromise(() => headers()) - const notebookPublicURL = - process.env.NOTEBOOK_PUBLIC_URL ?? - authURLs.resolveNotebookPublicURLFromHeaders(headersList) - const loginUrl = authURLs.buildDashboardLoginURL( - dashboardLoginURL, - notebookPublicURL, - ) - - return { loginUrl } - }, -) - // --------------------------------------------------------------------------- // Async wrappers (backward-compatible) // --------------------------------------------------------------------------- @@ -100,6 +85,10 @@ async function getAuthenticated(): Promise { return Effect.runPromise(getAuthenticatedEffect) } +async function getCurrentUser(): Promise { + return Effect.runPromise(Effect.tryPromise(() => getCurrentUserFromAuth())) +} + async function getOptionalAuthenticated(): Promise { return Effect.runPromise(getOptionalAuthenticatedEffect) } @@ -114,14 +103,10 @@ async function getClientForWorkspace( return Effect.runPromise(getClientForWorkspaceEffect(workspace)) } -async function getGuest(): Promise { - return Effect.runPromise(getGuestEffect) -} - export const notebookRequestContext = { getAuthenticated, + getCurrentUser, getOptionalAuthenticated, getAuthenticatedWithClient, getClientForWorkspace, - getGuest, } as const diff --git a/src/domains/workspace/route-client.ts b/src/domains/workspace/route-client.ts index 11588da..7290161 100644 --- a/src/domains/workspace/route-client.ts +++ b/src/domains/workspace/route-client.ts @@ -7,12 +7,21 @@ import { Effect } from "effect" type WorkspaceRouteClientModule = { readonly deleteJson: (url: string, body: unknown) => Promise + readonly deleteJsonWithStatus: ( + url: string, + body: unknown, + ) => Promise> readonly getJson: (url: string) => Promise readonly postJson: (url: string, body: unknown) => Promise readonly postJsonWithStatus: ( url: string, body: unknown, ) => Promise> + readonly postFormData: (url: string, body: FormData) => Promise + readonly postFormDataWithStatus: ( + url: string, + body: FormData, + ) => Promise> readonly patchJson: (url: string, body: unknown) => Promise readonly patchJsonWithStatus: ( url: string, @@ -50,6 +59,46 @@ function postJsonWithStatus( return requestJson({ method: "POST", url, body }) } +async function postFormData(url: string, body: FormData): Promise { + return (await postFormDataWithStatus(url, body)).body +} + +function postFormDataWithStatus( + url: string, + body: FormData, +): Promise> { + return Effect.runPromise( + requestFormDataEffect(url, body).pipe( + Effect.provide(FetchHttpClient.layer), + ), + ) +} + +function requestFormDataEffect( + url: string, + body: FormData, +): Effect.Effect, unknown, HttpClient.HttpClient> { + return Effect.gen(function* () { + const request = yield* buildFormDataRequest(url, body) + const response = yield* HttpClient.execute(request) + const responseBody: unknown = yield* response.json + + return { + status: response.status, + body: responseBody as T, + } + }) +} + +function buildFormDataRequest(url: string, body: FormData) { + return Effect.succeed( + HttpClientRequest.post(resolveSameOriginUrl(url)).pipe( + HttpClientRequest.setHeaders({ Accept: "application/json" }), + HttpClientRequest.bodyFormData(body), + ), + ) +} + async function patchJson(url: string, body: unknown): Promise { return (await patchJsonWithStatus(url, body)).body } @@ -61,6 +110,13 @@ function patchJsonWithStatus( return requestJson({ method: "PATCH", url, body }) } +function deleteJsonWithStatus( + url: string, + body: unknown, +): Promise> { + return requestJson({ method: "DELETE", url, body }) +} + function requestJson( input: JsonRequestInput, ): Promise> { @@ -107,9 +163,12 @@ function resolveSameOriginUrl(path: string): string { export const workspaceRouteClient: WorkspaceRouteClientModule = { deleteJson, + deleteJsonWithStatus, getJson, postJson, postJsonWithStatus, + postFormData, + postFormDataWithStatus, patchJson, patchJsonWithStatus, } diff --git a/src/domains/workspace/service.test.ts b/src/domains/workspace/service.test.ts index b4c51a7..eb7f52c 100644 --- a/src/domains/workspace/service.test.ts +++ b/src/domains/workspace/service.test.ts @@ -6,6 +6,7 @@ import type { Db } from "@/infrastructure/db" type WorkspaceRow = { id: string userId: string + knowhereKeyLabel: string | null namespace: string createdAt: Date } @@ -13,6 +14,7 @@ type WorkspaceRow = { type SelectBuilder = { from: ReturnType where: ReturnType + orderBy: ReturnType limit: (limit: number) => Promise } @@ -32,7 +34,14 @@ function buildWorkspaceDbMock(storage: { function makeSelect(): SelectBuilder { const builder: SelectBuilder = { from: vi.fn(() => builder), - where: vi.fn(() => builder), + where: vi.fn(function (this: SelectBuilder) { + const chainable = Object.assign(Promise.resolve([]), { + orderBy: async () => (storage.row ? [storage.row] : []), + limit: async () => (storage.row ? [storage.row] : []), + }) + return chainable + }), + orderBy: vi.fn(async () => (storage.row ? [storage.row] : [])), limit: vi.fn(async () => (storage.row ? [storage.row] : [])), } return builder @@ -45,6 +54,7 @@ function buildWorkspaceDbMock(storage: { storage.row = { id: crypto.randomUUID(), userId: values.userId, + knowhereKeyLabel: values.knowhereKeyLabel ?? null, namespace: values.namespace, createdAt: new Date(), } @@ -81,15 +91,14 @@ afterEach(() => { }) describe("workspaceService", () => { - it("ensures a workspace through the service seam", async () => { + it("returns null through the service seam when the user has no workspace", async () => { const storage: { row: WorkspaceRow | null } = { row: null } const dbMock = buildWorkspaceDbMock(storage) const { workspaceService } = await loadWorkspaceService(dbMock) const workspace = await workspaceService.ensureWorkspace("user_1") - expect(dbMock.insert).toHaveBeenCalledOnce() - expect(workspace.userId).toBe("user_1") - expect(workspace.namespace).toMatch(/^notebook-[0-9a-f-]{36}$/) + expect(workspace).toBeNull() + expect(dbMock.insert).not.toHaveBeenCalled() }) }) diff --git a/src/domains/workspace/service.ts b/src/domains/workspace/service.ts index baf3af4..f437ad4 100644 --- a/src/domains/workspace/service.ts +++ b/src/domains/workspace/service.ts @@ -1,56 +1,130 @@ import "server-only" import { Effect } from "effect" +import { cookies } from "next/headers" import { databaseRuntime } from "./database-runtime" import { DbClient } from "@/infrastructure/db" import { workspaceRepository } from "./repository" import type { Workspace } from "@/infrastructure/db/schema" +/** Cookie that holds the active workspace id for the current browser session. */ +export const activeWorkspaceCookieName = "notebook-ws" + type WorkspaceService = { readonly ensureWorkspaceEffect: ( userId: string, + ) => Effect.Effect + readonly ensureWorkspaceForNamespaceEffect: ( + userId: string, + namespace: string, ) => Effect.Effect readonly pingDatabaseEffect: () => Effect.Effect - readonly ensureWorkspace: (userId: string) => Promise + readonly ensureWorkspace: (userId: string) => Promise + readonly ensureWorkspaceForNamespace: ( + userId: string, + namespace: string, + ) => Promise readonly pingDatabase: () => Promise } +/** + * Resolve the workspace that should serve the current request. + * + * 1. If the `notebook-ws` cookie names a workspace owned by the user, use it. + * 2. Otherwise use the user's first workspace. + * 3. If the user has no workspace yet, return null — callers must decide how + * to handle the empty state (a new user must add an API key and pick a + * namespace before any workspace exists). + */ const ensureWorkspaceEffect: WorkspaceService["ensureWorkspaceEffect"] = ( userId: string, ) => Effect.gen(function* () { - const existing = yield* workspaceRepository.findByUserIdEffect(userId) - if (existing) return existing - - const namespace = `notebook-${crypto.randomUUID()}` - yield* workspaceRepository.insertForUserEffect(userId, namespace) - - const row = yield* workspaceRepository.findByUserIdEffect(userId) - if (!row) { - return yield* Effect.die( - new Error( - `ensureWorkspace: workspace row not found for user ${userId} after ` + - "upsert. Check that the workspaces.user_id unique index exists.", - ), + const activeId = yield* readActiveWorkspaceIdEffect.pipe( + Effect.catchAll(() => Effect.succeed(null)), + ) + if (activeId) { + const byCookie = yield* workspaceRepository.findByIdAndUserIdEffect( + activeId, + userId, ) + if (byCookie) return byCookie } - return row + const all = yield* workspaceRepository.findAllByUserIdEffect(userId) + return all[0] ?? null }) +/** + * Find or create the workspace bound to a namespace for a user. Used by + * the namespace picker when the user selects a namespace that has no + * workspace row yet. + */ +const ensureWorkspaceForNamespaceEffect: WorkspaceService["ensureWorkspaceForNamespaceEffect"] = + (userId: string, namespace: string) => + Effect.gen(function* () { + const existing = yield* workspaceRepository.findByUserIdAndNamespaceEffect( + userId, + namespace, + ) + if (existing) return existing + + yield* workspaceRepository.insertForUserNamespaceEffect(userId, namespace) + + const row = yield* workspaceRepository.findByUserIdAndNamespaceEffect( + userId, + namespace, + ) + if (!row) { + return yield* Effect.die( + new Error( + `ensureWorkspaceForNamespace: workspace row not found ` + + `for user ${userId} (${namespace}) after upsert.`, + ), + ) + } + + return row + }) + const pingDatabaseEffect: WorkspaceService["pingDatabaseEffect"] = () => workspaceRepository.pingEffect() const ensureWorkspace: WorkspaceService["ensureWorkspace"] = (userId: string) => databaseRuntime.runPromise(ensureWorkspaceEffect(userId)) +const ensureWorkspaceForNamespace: WorkspaceService["ensureWorkspaceForNamespace"] = + (userId: string, namespace: string) => + databaseRuntime.runPromise( + ensureWorkspaceForNamespaceEffect(userId, namespace), + ) + const pingDatabase: WorkspaceService["pingDatabase"] = () => databaseRuntime.runPromise(pingDatabaseEffect()) +/** + * Read the active workspace id from the `notebook-ws` cookie. Returns null + * outside a request scope (background jobs, tests, CLI). + */ +const readActiveWorkspaceIdEffect: Effect.Effect< + string | null, + unknown, + never +> = Effect.tryPromise(async (): Promise => { + try { + const jar = await cookies() + return jar.get(activeWorkspaceCookieName)?.value ?? null + } catch { + return null + } +}) + export const workspaceService: WorkspaceService = { ensureWorkspaceEffect, + ensureWorkspaceForNamespaceEffect, pingDatabaseEffect, ensureWorkspace, + ensureWorkspaceForNamespace, pingDatabase, } diff --git a/src/infrastructure/auth/account-links-repository.ts b/src/infrastructure/auth/account-links-repository.ts new file mode 100644 index 0000000..d2f632e --- /dev/null +++ b/src/infrastructure/auth/account-links-repository.ts @@ -0,0 +1,83 @@ +import "server-only" + +import { and, eq } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { accountLinks, type AccountLink } from "@/infrastructure/db/schema" + +type AccountLinksRepository = { + readonly findByUserIdAndProviderEffect: ( + userId: string, + provider: string, + ) => Effect.Effect + readonly findByProviderAndProviderUserIdEffect: ( + provider: string, + providerUserId: string, + ) => Effect.Effect + readonly insertEffect: (input: { + readonly userId: string + readonly provider: string + readonly providerUserId: string | null + readonly passwordHash: string | null + }) => Effect.Effect +} + +const findByUserIdAndProviderEffect: AccountLinksRepository["findByUserIdAndProviderEffect"] = + (userId: string, provider: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(accountLinks) + .where( + and( + eq(accountLinks.userId, userId), + eq(accountLinks.provider, provider), + ), + ) + .limit(1), + ) + return row[0] ?? null + }) + +const findByProviderAndProviderUserIdEffect: AccountLinksRepository["findByProviderAndProviderUserIdEffect"] = + (provider: string, providerUserId: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(accountLinks) + .where( + and( + eq(accountLinks.provider, provider), + eq(accountLinks.providerUserId, providerUserId), + ), + ) + .limit(1), + ) + return row[0] ?? null + }) + +const insertEffect: AccountLinksRepository["insertEffect"] = (input) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db.insert(accountLinks).values(input).returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die( + new Error("account_links: insert returned no row."), + ) + } + return row + }) + +export const accountLinksRepository: AccountLinksRepository = { + findByUserIdAndProviderEffect, + findByProviderAndProviderUserIdEffect, + insertEffect, +} diff --git a/src/infrastructure/auth/index.test.ts b/src/infrastructure/auth/index.test.ts index 781c80c..b584ff0 100644 --- a/src/infrastructure/auth/index.test.ts +++ b/src/infrastructure/auth/index.test.ts @@ -1,171 +1,86 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Effect } from "effect" -const nextCacheMocks = vi.hoisted(() => ({ - cacheLife: vi.fn(), - cacheTag: vi.fn(), +const repositoryMocks = vi.hoisted(() => ({ + findByIdEffect: vi.fn(), + findUserByIdEffect: vi.fn(), + runPromise: vi.fn(), })) -vi.mock("next/cache", () => nextCacheMocks) +vi.mock("./sessions-repository", () => ({ + sessionsRepository: { + findByIdEffect: repositoryMocks.findByIdEffect, + }, +})) + +vi.mock("./users-repository", () => ({ + usersRepository: { + findByIdEffect: repositoryMocks.findUserByIdEffect, + }, +})) + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: repositoryMocks.runPromise, + }, +})) /** - * Tests for the auth module. + * Tests for the Phase 2+ auth module. * - * The scope is intentionally narrow — we assert the contract with - * Dashboard as Pi laid it out: - * - forward the incoming Cookie header verbatim - * - hit Dashboard's getCurrentUser oRPC endpoint server-side - * - treat `body.json.user === null` (or missing, or HTTP error, or - * malformed body, or network failure) as "anonymous" - * - never decode a JWT ourselves - * - * `requireUser`'s redirect behavior is not unit-tested here because - * `next/navigation`'s `redirect` throws a framework-internal error that - * is awkward to assert against in isolation; it is covered by the - * Playwright flow added in a later PR. + * The scope is intentionally narrow — we assert the Notebook-owned session + * contract: + * - no session cookie → null (no DB roundtrip) + * - a valid session id → session row → users row → AuthUser + * - expired / missing session or user → null + * - `requireUser` throws a redirect when unauthenticated */ -import { extractUser, sessionCookieNames } from "." - -const SESSION_PATH = "/api/orpc/users/getCurrentUser" - -type ParsedLogLine = { - readonly body?: unknown - readonly msg?: unknown -} - -function getHeaderValue(headers: HeadersInit | undefined, name: string): string | null { - if (headers === undefined) return null - if (headers instanceof Headers) return headers.get(name) - - const lowerName = name.toLowerCase() - if (Array.isArray(headers)) { - const pair = headers.find(([key]) => key.toLowerCase() === lowerName) - return pair?.[1] ?? null - } - - const entry = Object.entries(headers).find( - ([key]) => key.toLowerCase() === lowerName, - ) - return entry?.[1] ?? null -} - -async function readBodyText(body: BodyInit | null | undefined): Promise { - if (body === undefined || body === null) return null - if (typeof body === "string") return body - if (body instanceof Blob) return await body.text() - if (body instanceof URLSearchParams) return body.toString() - if (body instanceof ArrayBuffer) return new TextDecoder().decode(body) - if (ArrayBuffer.isView(body)) { - const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength) - return new TextDecoder().decode(bytes) - } - return null -} +import { extractUser } from "." describe("extractUser", () => { - it("returns null when body is not an object", () => { + it("returns null when value is not an object", () => { expect(extractUser(null)).toBeNull() expect(extractUser(undefined)).toBeNull() expect(extractUser("nope")).toBeNull() expect(extractUser(42)).toBeNull() }) - it("returns null when json envelope is missing", () => { + it("returns null when id is missing or empty", () => { expect(extractUser({})).toBeNull() - expect(extractUser({ data: { user: { id: "u1" } } })).toBeNull() - }) - - it("returns null when user is missing or explicitly null", () => { - expect(extractUser({ json: {} })).toBeNull() - expect(extractUser({ json: { user: null } })).toBeNull() - }) - - it("returns null when user.id is missing or empty", () => { - expect(extractUser({ json: { user: {} } })).toBeNull() - expect(extractUser({ json: { user: { id: "" } } })).toBeNull() - expect(extractUser({ json: { user: { id: 42 } } })).toBeNull() + expect(extractUser({ id: "" })).toBeNull() + expect(extractUser({ id: 42 })).toBeNull() }) it("returns the user with id, email, and name when present", () => { - const got = extractUser({ - json: { - user: { id: "user_123", email: "a@b.com", name: "Teacher" }, - }, - }) - expect(got).toEqual({ - id: "user_123", + expect(extractUser({ id: "u1", email: "a@b.com", name: "Ada" })).toEqual({ + id: "u1", email: "a@b.com", - name: "Teacher", + name: "Ada", }) }) it("coerces missing optional fields to null", () => { - const got = extractUser({ json: { user: { id: "u1" } } }) - expect(got).toEqual({ id: "u1", email: null, name: null }) - }) - - it("tolerates extra fields without failing", () => { - const got = extractUser({ - json: { - user: { - id: "u1", - email: "x@y", - name: "N", - someFutureField: "anything", - }, - }, - meta: { traceId: "abc" }, - }) - expect(got?.id).toBe("u1") - }) -}) - -describe("sessionCookieNames", () => { - const originalEnv = process.env.SESSION_COOKIE_NAMES - afterEach(() => { - if (originalEnv === undefined) delete process.env.SESSION_COOKIE_NAMES - else process.env.SESSION_COOKIE_NAMES = originalEnv - }) - - it("defaults to the Better Auth session cookie names", () => { - delete process.env.SESSION_COOKIE_NAMES - expect(sessionCookieNames()).toEqual([ - "better-auth.session_token", - "__Secure-better-auth.session_token", - ]) - }) - - it("honors a comma-separated override from env", () => { - process.env.SESSION_COOKIE_NAMES = "my-cookie, other-cookie ,x" - expect(sessionCookieNames()).toEqual(["my-cookie", "other-cookie", "x"]) - }) - - it("falls back to defaults when the override is blank", () => { - process.env.SESSION_COOKIE_NAMES = " " - expect(sessionCookieNames()).toEqual([ - "better-auth.session_token", - "__Secure-better-auth.session_token", - ]) + expect(extractUser({ id: "u1" })).toEqual({ id: "u1", email: null, name: null }) }) }) describe("getCurrentUser", () => { - const originalFetch = globalThis.fetch - const originalOrigin = process.env.DASHBOARD_ORIGIN const originalApiKey = process.env.KNOWHERE_API_KEY beforeEach(() => { vi.resetModules() - process.env.DASHBOARD_ORIGIN = "https://dashboard.example.test" delete process.env.KNOWHERE_API_KEY + repositoryMocks.runPromise.mockReset() + repositoryMocks.findByIdEffect.mockReset() + repositoryMocks.findUserByIdEffect.mockReset() + // Run the effect for real so the mocked repository Effects are executed. + repositoryMocks.runPromise.mockImplementation((effect: Effect.Effect) => + Effect.runPromise(effect), + ) }) afterEach(() => { - globalThis.fetch = originalFetch - nextCacheMocks.cacheLife.mockClear() - nextCacheMocks.cacheTag.mockClear() - if (originalOrigin === undefined) delete process.env.DASHBOARD_ORIGIN - else process.env.DASHBOARD_ORIGIN = originalOrigin if (originalApiKey === undefined) delete process.env.KNOWHERE_API_KEY else process.env.KNOWHERE_API_KEY = originalApiKey }) @@ -178,170 +93,65 @@ describe("getCurrentUser", () => { return await import(".") } - it("returns null when no Cookie header is present (no roundtrip)", async () => { - const fetchSpy = vi.fn() - globalThis.fetch = fetchSpy + it("returns null when no Cookie header is present", async () => { const { getCurrentUser } = await loadWithCookie("") - const got = await getCurrentUser() - expect(got).toBeNull() - expect(fetchSpy).not.toHaveBeenCalled() - }) - - it("returns the development user when KNOWHERE_API_KEY is configured", async () => { - process.env.KNOWHERE_API_KEY = "sk_dev_key" - delete process.env.DASHBOARD_ORIGIN - const fetchSpy = vi.fn() - globalThis.fetch = fetchSpy - const { getCurrentUser } = await loadWithCookie("") - - const user = await getCurrentUser() - - expect(user).toEqual({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) - expect(fetchSpy).not.toHaveBeenCalled() - }) - - it("allows requireUser without redirecting when KNOWHERE_API_KEY is configured", async () => { - process.env.KNOWHERE_API_KEY = "sk_dev_key" - delete process.env.DASHBOARD_ORIGIN - const { requireUser } = await loadWithCookie("") - - await expect(requireUser()).resolves.toEqual({ - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", - }) + expect(await getCurrentUser()).toBeNull() + expect(repositoryMocks.runPromise).not.toHaveBeenCalled() }) - it("POSTs to the Dashboard oRPC endpoint with the incoming Cookie", async () => { - const expectedUrl = `https://dashboard.example.test${SESSION_PATH}` - const fetchSpy = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ json: { user: { id: "u1", email: "a@b" } } }), - { status: 200, headers: { "content-type": "application/json" } }, - ), + it("returns the user for a valid session cookie", async () => { + repositoryMocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "session_1", + userId: "user_1", + expiresAt: new Date(Date.now() + 100_000), + createdAt: new Date(), + }), ) - globalThis.fetch = fetchSpy - const { getCurrentUser } = await loadWithCookie( - "better-auth.session_token=abc; other=val", + repositoryMocks.findUserByIdEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "ada@example.com", + name: "Ada", + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), ) + const { getCurrentUser } = await loadWithCookie("notebook-session=session_1") const user = await getCurrentUser() - expect(user).toEqual({ id: "u1", email: "a@b", name: null }) - expect(fetchSpy).toHaveBeenCalledOnce() - const [req, init] = fetchSpy.mock.calls[0]! - const requestUrl = - req instanceof Request ? req.url - : req instanceof URL ? req.href - : typeof req === "string" ? req - : String(req) - expect(requestUrl).toBe(expectedUrl) - const requestHeaders = - req instanceof Request ? req.headers : (init as RequestInit | undefined)?.headers - expect(getHeaderValue(requestHeaders, "cookie")).toBe( - "better-auth.session_token=abc; other=val", - ) - expect(getHeaderValue(requestHeaders, "content-type")).toContain( - "application/json", - ) - expect(await readBodyText((init as RequestInit | undefined)?.body)).toBe("{}") - }) - - it("does not reuse a stale user after Dashboard invalidates the session", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ json: { user: { id: "u1", email: "a@b" } } }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ json: { user: null } }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - globalThis.fetch = fetchSpy - const { getCurrentUser } = await loadWithCookie( - "better-auth.session_token=abc", - ) - - await expect(getCurrentUser()).resolves.toEqual({ - id: "u1", - email: "a@b", - name: null, - }) - await expect(getCurrentUser()).resolves.toBeNull() - expect(fetchSpy).toHaveBeenCalledTimes(2) - expect(nextCacheMocks.cacheLife).not.toHaveBeenCalled() - expect(nextCacheMocks.cacheTag).not.toHaveBeenCalled() - }) - - it("returns null on Dashboard non-2xx response", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(new Response("oops", { status: 503 })) - const { getCurrentUser } = await loadWithCookie("session=x") - expect(await getCurrentUser()).toBeNull() - }) - - it("returns null on network error without throwing", async () => { - globalThis.fetch = vi - .fn() - .mockRejectedValue(new Error("network down")) - const { getCurrentUser } = await loadWithCookie("session=x") - await expect(getCurrentUser()).resolves.toBeNull() + expect(user).toEqual({ id: "user_1", email: "ada@example.com", name: "Ada" }) }) - it("returns null when the response body is not JSON", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValue(new Response("not-json", { status: 200 })) - const { getCurrentUser } = await loadWithCookie("session=x") + it("returns null when the session row is missing", async () => { + repositoryMocks.findByIdEffect.mockReturnValue(Effect.succeed(null)) + const { getCurrentUser } = await loadWithCookie("notebook-session=missing") expect(await getCurrentUser()).toBeNull() }) - it("returns null when body.json.user is null", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ json: { user: null } }), { - status: 200, - headers: { "content-type": "application/json" }, + it("returns null when the session's user row is missing", async () => { + repositoryMocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "session_1", + userId: "user_gone", + expiresAt: new Date(Date.now() + 100_000), + createdAt: new Date(), }), ) - const { getCurrentUser } = await loadWithCookie("session=x") + repositoryMocks.findUserByIdEffect.mockReturnValue(Effect.succeed(null)) + const { getCurrentUser } = await loadWithCookie("notebook-session=session_1") expect(await getCurrentUser()).toBeNull() }) - it("logs the JSON body when Dashboard returns an unexpected response shape", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined) - try { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ user: { id: "u1" } }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - const { getCurrentUser } = await loadWithCookie("session=x") - - expect(await getCurrentUser()).toBeNull() - - const line = String(warnSpy.mock.calls[0]?.[0] ?? "") - const parsed = JSON.parse(line) as ParsedLogLine - expect(parsed.msg).toBe( - "dashboard: POST /api/orpc/users/getCurrentUser -> schema mismatch", - ) - expect(parsed.body).toBe(JSON.stringify({ user: { id: "u1" } })) - } finally { - warnSpy.mockRestore() - } + it("returns null on DB failure without throwing", async () => { + repositoryMocks.runPromise.mockRejectedValue(new Error("db down")) + const { getCurrentUser } = await loadWithCookie("notebook-session=session_1") + expect(await getCurrentUser()).toBeNull() }) - it("throws when DASHBOARD_ORIGIN is not configured", async () => { - delete process.env.DASHBOARD_ORIGIN - const { getCurrentUser } = await loadWithCookie("session=x") - await expect(getCurrentUser()).rejects.toThrow(/DASHBOARD_ORIGIN/) + it("throws a redirect when requireUser is called unauthenticated", async () => { + const { requireUser } = await loadWithCookie("") + await expect(requireUser()).rejects.toThrow(/NEXT_REDIRECT/) }) }) diff --git a/src/infrastructure/auth/index.ts b/src/infrastructure/auth/index.ts index b28ac70..a387172 100644 --- a/src/infrastructure/auth/index.ts +++ b/src/infrastructure/auth/index.ts @@ -1,186 +1,101 @@ import "server-only" - -import { cookies, headers } from "next/headers" +import { headers } from "next/headers" import { redirect } from "next/navigation" -import { Context, Effect, Either, Layer, Schedule, Schema } from "effect" -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, -} from "@effect/platform" -import { authURLs } from "./urls" -import { sessionCookieNames } from "./session-cookie-names" +import { Context, Effect, Layer } from "effect" + import { logger } from "@/lib/logger" -import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" -import { setEmptyJsonBody } from "@/integrations/dashboard/orpc-request" +import { sessionCookieName } from "./session" +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { sessionsRepository } from "./sessions-repository" +import { usersRepository } from "./users-repository" import { formatUnknownForLog } from "@/lib/format-log-value" -export { sessionCookieNames } +export { sessionCookieName } from "./session" +export { notebookSessionCookieName } from "./session-cookie-constants" /** - * Auth helpers for Knowhere Notebook. + * Auth helpers for Knowhere Notebook (Phase 2+: Notebook-owned auth). * - * Design (per @Pi's Dashboard investigation): - * - Dashboard is the auth source of truth. Notebook never decodes or - * verifies a JWT. - * - Dashboard sets a Better Auth session cookie on `Domain=.knowhereto.ai`. - * Notebook is served from `notebook.knowhereto.ai`, so the cookie arrives - * on every request automatically. - * - Every server-side check calls `getCurrentUser`, which forwards the - * incoming Cookie header to Dashboard's oRPC session lookup - * `users.getCurrentUser` and reads `body.json.user`. - * - `user === null` (including upstream 4xx/5xx or network failure) means - * "unauthenticated" — never try to distinguish failure modes, never - * leak upstream errors to the browser. + * Design: + * - Identity is Notebook-owned: a DB-backed session row keyed by the + * `notebook-session` cookie, joined to the `users` table. + * - `user === null` means "unauthenticated". */ // ---- Schema --------------------------------------------------------------- -const AuthUserFromORPC = Schema.Struct({ - id: Schema.String.pipe(Schema.minLength(1)), - email: Schema.Union(Schema.String, Schema.Null).pipe( - Schema.optionalWith({ default: () => null }), - ), - name: Schema.Union(Schema.String, Schema.Null).pipe( - Schema.optionalWith({ default: () => null }), - ), -}) - -export type AuthUser = typeof AuthUserFromORPC.Type - -/** oRPC response envelope: `{ json: { user: {...} } }` */ -const oRPCEnvelope = Schema.Struct({ - json: Schema.Struct({ user: Schema.Union(AuthUserFromORPC, Schema.Null).pipe(Schema.optionalWith({ default: () => null })) }), -}) - -const DASHBOARD_SESSION_TIMEOUT_MS = 3_000 - -// ---- Effect implementation ------------------------------------------------ - -const callGetCurrentUser = (cookieHeader: string) => - Effect.gen(function* () { - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - return yield* Effect.die( - new Error( - "DASHBOARD_ORIGIN is required. Set it to the Dashboard origin " + - "(see .env.local.example).", - ), - ) - } +export type AuthUser = { + readonly id: string + readonly email: string | null + readonly name: string | null +} - const http = yield* HttpClient.HttpClient - const url = `${origin}/api/orpc/users/getCurrentUser` - return yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("cookie", cookieHeader), - setEmptyJsonBody, - http.execute, - Effect.flatMap((response) => - Effect.gen(function* () { - const status = response.status - - if (status < 200 || status >= 300) { - const rawText = yield* Effect.either(response.text) - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> non-2xx", - { status, body: Either.getOrElse(rawText, () => "").slice(0, 1000) }, - ) - return null - } - - const parsed = yield* Effect.either(response.json) - if (Either.isLeft(parsed)) { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> invalid JSON", - { status, error: String(parsed.left) }, - ) - return null - } - - const result = Schema.decodeUnknownEither(oRPCEnvelope)(parsed.right) - if (Either.isLeft(result)) { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> schema mismatch", - { status, body: formatUnknownForLog(parsed.right).slice(0, 1000) }, - ) - return null - } - - return result.right.json.user - }), - ), - Effect.timeout(DASHBOARD_SESSION_TIMEOUT_MS), - Effect.catchAll((err) => { - logger.warn( - "dashboard: POST /api/orpc/users/getCurrentUser -> failed", - { error: String(err) }, - ) - return Effect.succeed(null) +// ---- Session lookup ------------------------------------------------------- + +function findUserBySessionCookie(cookieHeader: string): Promise { + const sessionId = parseSessionIdFromCookieHeader(cookieHeader) + if (!sessionId) return Promise.resolve(null) + + return databaseRuntime + .runPromise( + Effect.gen(function* () { + const session = yield* sessionsRepository.findByIdEffect(sessionId) + if (!session) return null + const user = yield* usersRepository.findByIdEffect(session.userId) + if (!user) return null + return { + id: user.id, + email: user.email, + name: user.name ?? null, + } }), ) - }) - -export const getCurrentUserEffect = Effect.gen(function* () { - const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() - if (developmentUser) return developmentUser - - const cookieHeader = (yield* Effect.promise(() => headers())).get("cookie") ?? "" - if (cookieHeader.length === 0) return null + .catch(() => null) +} - return yield* callGetCurrentUser(cookieHeader) -}) +function parseSessionIdFromCookieHeader(cookieHeader: string): string | null { + for (const part of cookieHeader.split(";")) { + const [name, ...rest] = part.trim().split("=") + if (name === sessionCookieName) { + const value = rest.join("=").trim() + return value.length > 0 ? decodeURIComponent(value) : null + } + } + return null +} // ---- Auth Service --------------------------------------------------------- -export const Auth = Context.GenericTag< - { readonly getCurrentUser: () => Effect.Effect } ->("@knowhere/Auth") +export const Auth = Context.GenericTag<{ + readonly getCurrentUser: () => Effect.Effect +}>("@knowhere/Auth") export const authLayer = Layer.effect( Auth, Effect.gen(function* () { - const http = (yield* HttpClient.HttpClient).pipe( - HttpClient.filterStatusOk, - HttpClient.retryTransient({ - schedule: Schedule.exponential(100), - times: 2, - }), - ) - const getCurrentUser = () => getCurrentUserEffect.pipe(Effect.provideService(HttpClient.HttpClient, http)) + const getCurrentUser = () => getCurrentUserEffect return { getCurrentUser } }), -).pipe(Layer.provide(FetchHttpClient.layer)) +) // ---- Public API (Promise-based, for Next.js compatibility) ---------------- export async function getCurrentUser(): Promise { - const developmentUser = knowhereApiKeyOverride.getDevelopmentUser() - if (developmentUser) { - logger.info("auth: using KNOWHERE_API_KEY development user", { - userId: developmentUser.id, - }) - return developmentUser - } - const cookieHeader = (await headers()).get("cookie") ?? "" if (cookieHeader.length === 0) { - logger.info("dashboard: POST /api/orpc/users/getCurrentUser skipped (no session cookie)") + logger.info("auth: getCurrentUser skipped (no session cookie)") return null } const start = Date.now() - const user = await Effect.runPromise( - callGetCurrentUser(cookieHeader).pipe( - Effect.provide(FetchHttpClient.layer), - ), - ) + const user = await findUserBySessionCookie(cookieHeader) if (user === null) { - logger.info("dashboard: POST /api/orpc/users/getCurrentUser -> no valid session", { + logger.info("auth: getCurrentUser -> no valid session", { durationMs: Date.now() - start, }) } else { - logger.info("dashboard: POST /api/orpc/users/getCurrentUser ok", { + logger.info("auth: getCurrentUser ok", { userId: user.id, durationMs: Date.now() - start, }) @@ -189,9 +104,12 @@ export async function getCurrentUser(): Promise { return user } +export const getCurrentUserEffect: Effect.Effect = + Effect.tryPromise(() => getCurrentUser()).pipe(Effect.catchAll(() => Effect.succeed(null))) + /** - * Page / server-action guard. Redirects to the Dashboard login page with - * a `callbackURL` pointing back at the Notebook public URL when the caller + * Page / server-action guard. Redirects to the local login page with a + * `callbackURL` pointing back at the Notebook public URL when the caller * is unauthenticated. * * Throws a Next.js redirect; callers never see the anonymous branch. @@ -200,44 +118,34 @@ export async function requireUser(): Promise { const user = await getCurrentUser() if (user !== null) return user - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - throw new Error("DASHBOARD_ORIGIN must be set.") - } - - const loginUrl = `${origin}/login` - const notebookUrl = - process.env.NOTEBOOK_PUBLIC_URL ?? - authURLs.resolveNotebookPublicURLFromHeaders(await headers()) - redirect(authURLs.buildDashboardLoginURL(loginUrl, notebookUrl)) + redirect("/login") } /** - * Cheap cookie-presence check usable from middleware (Edge runtime). - * Does not call Dashboard; used to short-circuit obvious anonymous - * requests without the round-trip. Always re-verify on the server with - * `getCurrentUser` / `requireUser` before trusting identity. + * Cheap cookie-presence check usable from the edge proxy. Does not touch + * the DB; used to short-circuit obvious anonymous requests. Always + * re-verify on the server with `getCurrentUser` / `requireUser`. */ export async function hasSessionCookie(): Promise { - if (knowhereApiKeyOverride.hasApiKey()) return true - - const jar = await cookies() - for (const name of sessionCookieNames()) { - if (jar.get(name) !== undefined) return true - } - return false + const jar = await import("next/headers").then(({ cookies }) => cookies()) + return jar.get(sessionCookieName) !== undefined } /** - * Parse the Dashboard oRPC response envelope `{ json: { user } }`. - * Tolerant to minor shape drift — any non-conforming response becomes `null`. + * Extract a user object from a raw lookup result. Kept for parity with the + * previous Dashboard envelope parsing; returns null for non-conforming input. */ -export function extractUser(body: unknown): AuthUser | null { - return Either.getOrElse( - Either.map( - Schema.decodeUnknownEither(oRPCEnvelope)(body), - (envelope) => envelope.json.user, - ), - () => null, - ) +export function extractUser(value: unknown): AuthUser | null { + if (typeof value !== "object" || value === null) return null + const candidate = value as Record + if (typeof candidate.id !== "string" || candidate.id.length === 0) return null + return { + id: candidate.id, + email: typeof candidate.email === "string" ? candidate.email : null, + name: typeof candidate.name === "string" ? candidate.name : null, + } +} + +export function formatAuthError(error: unknown): string { + return formatUnknownForLog(error) } diff --git a/src/infrastructure/auth/knowhere-api-keys-repository.ts b/src/infrastructure/auth/knowhere-api-keys-repository.ts new file mode 100644 index 0000000..b682557 --- /dev/null +++ b/src/infrastructure/auth/knowhere-api-keys-repository.ts @@ -0,0 +1,268 @@ +import "server-only" + +import { and, eq, isNull } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { + knowhereApiKeys, + workspaces, + type KnowhereApiKey, +} from "@/infrastructure/db/schema" +import { decryptSecret, encryptSecret } from "@/lib/secret-crypto" +import { maskApiKey } from "@/integrations/knowhere-keys" + +export type StoredKnowhereApiKey = { + readonly id: string + readonly userId: string + readonly label: string + readonly keyMask: string + readonly createdAt: Date +} + +type KnowhereApiKeysRepository = { + readonly createForUserEffect: (input: { + readonly userId: string + readonly label: string + readonly apiKey: string + }) => Effect.Effect + readonly listByUserEffect: ( + userId: string, + ) => Effect.Effect + readonly findByIdAndUserEffect: ( + id: string, + userId: string, + ) => Effect.Effect + readonly softDeleteEffect: ( + id: string, + userId: string, + ) => Effect.Effect + readonly getActiveForWorkspaceEffect: ( + workspaceId: string, + ) => Effect.Effect + readonly firstForUserEffect: ( + userId: string, + ) => Effect.Effect + readonly setActiveEffect: ( + workspaceId: string, + apiKeyId: string | null, + ) => Effect.Effect + readonly clearActiveForKeyEffect: ( + apiKeyId: string, + userId: string, + ) => Effect.Effect + readonly decryptStoredEffect: ( + stored: StoredKnowhereApiKey, + ) => Effect.Effect +} + +const toStored = (row: KnowhereApiKey): StoredKnowhereApiKey => ({ + id: row.id, + userId: row.userId, + label: row.label, + keyMask: row.keyMask, + createdAt: row.createdAt, +}) + +const createForUserEffect: KnowhereApiKeysRepository["createForUserEffect"] = ( + input, +) => + Effect.gen(function* () { + const db = yield* DbClient + const encrypted = encryptSecret(input.apiKey) + const rows = yield* Effect.promise(() => + db + .insert(knowhereApiKeys) + .values({ + userId: input.userId, + label: input.label, + keyMask: maskApiKey(input.apiKey), + cipherBlob: encrypted.cipherText, + cipherNonce: encrypted.nonce, + }) + .returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die( + new Error("knowhere_api_keys: insert returned no row."), + ) + } + return row + }) + +const listByUserEffect: KnowhereApiKeysRepository["listByUserEffect"] = ( + userId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db + .select() + .from(knowhereApiKeys) + .where( + and( + eq(knowhereApiKeys.userId, userId), + isNull(knowhereApiKeys.deletedAt), + ), + ) + .orderBy(knowhereApiKeys.createdAt), + ) + return rows.map(toStored) + }) + +const findByIdAndUserEffect: KnowhereApiKeysRepository["findByIdAndUserEffect"] = + (id: string, userId: string) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db + .select() + .from(knowhereApiKeys) + .where( + and( + eq(knowhereApiKeys.id, id), + eq(knowhereApiKeys.userId, userId), + isNull(knowhereApiKeys.deletedAt), + ), + ) + .limit(1), + ) + return rows[0] ? toStored(rows[0]) : null + }) + +const softDeleteEffect: KnowhereApiKeysRepository["softDeleteEffect"] = ( + id: string, + userId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .update(knowhereApiKeys) + .set({ deletedAt: new Date() }) + .where( + and( + eq(knowhereApiKeys.id, id), + eq(knowhereApiKeys.userId, userId), + ), + ), + ) + }) + +const getActiveForWorkspaceEffect: KnowhereApiKeysRepository["getActiveForWorkspaceEffect"] = + (workspaceId: string) => + Effect.gen(function* () { + const db = yield* DbClient + const workspaceRows = yield* Effect.promise(() => + db + .select({ activeKnowhereApiKeyId: workspaces.activeKnowhereApiKeyId }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .limit(1), + ) + const activeId = workspaceRows[0]?.activeKnowhereApiKeyId + if (!activeId) return null + + const rows = yield* Effect.promise(() => + db + .select() + .from(knowhereApiKeys) + .where( + and( + eq(knowhereApiKeys.id, activeId), + isNull(knowhereApiKeys.deletedAt), + ), + ) + .limit(1), + ) + return rows[0] ? toStored(rows[0]) : null + }) + +const firstForUserEffect: KnowhereApiKeysRepository["firstForUserEffect"] = ( + userId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db + .select() + .from(knowhereApiKeys) + .where( + and( + eq(knowhereApiKeys.userId, userId), + isNull(knowhereApiKeys.deletedAt), + ), + ) + .orderBy(knowhereApiKeys.createdAt) + .limit(1), + ) + return rows[0] ? toStored(rows[0]) : null + }) + +const setActiveEffect: KnowhereApiKeysRepository["setActiveEffect"] = ( + workspaceId: string, + apiKeyId: string | null, +) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .update(workspaces) + .set({ activeKnowhereApiKeyId: apiKeyId }) + .where(eq(workspaces.id, workspaceId)), + ) + }) + +const clearActiveForKeyEffect: KnowhereApiKeysRepository["clearActiveForKeyEffect"] = + (apiKeyId: string, userId: string) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .update(workspaces) + .set({ activeKnowhereApiKeyId: null }) + .where( + and( + eq(workspaces.userId, userId), + eq(workspaces.activeKnowhereApiKeyId, apiKeyId), + ), + ), + ) + }) + +const decryptStoredEffect: KnowhereApiKeysRepository["decryptStoredEffect"] = ( + stored: StoredKnowhereApiKey, +) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db + .select() + .from(knowhereApiKeys) + .where(eq(knowhereApiKeys.id, stored.id)) + .limit(1), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die( + new Error("knowhere_api_keys: row not found for decrypt."), + ) + } + return decryptSecret({ + cipherText: row.cipherBlob, + nonce: row.cipherNonce, + }) + }) + +export const knowhereApiKeysRepository: KnowhereApiKeysRepository = { + createForUserEffect, + listByUserEffect, + findByIdAndUserEffect, + softDeleteEffect, + getActiveForWorkspaceEffect, + firstForUserEffect, + setActiveEffect, + clearActiveForKeyEffect, + decryptStoredEffect, +} diff --git a/src/infrastructure/auth/oauth-providers.test.ts b/src/infrastructure/auth/oauth-providers.test.ts new file mode 100644 index 0000000..c60db89 --- /dev/null +++ b/src/infrastructure/auth/oauth-providers.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { + getDashboardProvider, + getOAuthProvider, + listLoginProviders, + listOAuthProviders, +} from "./oauth-providers" + +describe("oauth-providers", () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + delete process.env.OAUTH_GOOGLE_CLIENT_ID + delete process.env.OAUTH_GOOGLE_CLIENT_SECRET + delete process.env.OAUTH_GITHUB_CLIENT_ID + delete process.env.OAUTH_GITHUB_CLIENT_SECRET + delete process.env.DASHBOARD_ORIGIN + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it("returns no providers when none are configured", () => { + expect(listOAuthProviders()).toEqual([]) + expect(getOAuthProvider("google")).toBeNull() + expect(getDashboardProvider()).toBeNull() + expect(listLoginProviders()).toEqual([]) + }) + + it("lists only providers with both env credentials", () => { + process.env.OAUTH_GOOGLE_CLIENT_ID = "google_id" + process.env.OAUTH_GOOGLE_CLIENT_SECRET = "google_secret" + + const providers = listOAuthProviders() + + expect(providers).toHaveLength(1) + expect(providers[0]).toMatchObject({ + kind: "oauth", + name: "google", + displayName: "Google", + clientId: "google_id", + clientSecret: "google_secret", + }) + expect(getOAuthProvider("google")).not.toBeNull() + expect(getOAuthProvider("github")).toBeNull() + }) + + it("lists multiple configured providers", () => { + process.env.OAUTH_GOOGLE_CLIENT_ID = "g" + process.env.OAUTH_GOOGLE_CLIENT_SECRET = "g" + process.env.OAUTH_GITHUB_CLIENT_ID = "h" + process.env.OAUTH_GITHUB_CLIENT_SECRET = "h" + + expect(listOAuthProviders().map((p) => p.name)).toEqual([ + "google", + "github", + ]) + }) + + it("offers the dashboard provider only when DASHBOARD_ORIGIN is set", () => { + expect(getDashboardProvider()).toBeNull() + + process.env.DASHBOARD_ORIGIN = "http://localhost:3000" + + expect(getDashboardProvider()).toEqual({ + kind: "dashboard", + name: "dashboard", + displayName: "Dashboard", + dashboardOrigin: "http://localhost:3000", + }) + }) + + it("puts the dashboard provider first in the login list", () => { + process.env.DASHBOARD_ORIGIN = "http://localhost:3000" + process.env.OAUTH_GOOGLE_CLIENT_ID = "g" + process.env.OAUTH_GOOGLE_CLIENT_SECRET = "g" + + expect(listLoginProviders()).toEqual([ + { name: "dashboard", displayName: "Dashboard" }, + { name: "google", displayName: "Google" }, + ]) + }) +}) diff --git a/src/infrastructure/auth/oauth-providers.ts b/src/infrastructure/auth/oauth-providers.ts new file mode 100644 index 0000000..73c281d --- /dev/null +++ b/src/infrastructure/auth/oauth-providers.ts @@ -0,0 +1,145 @@ +import "server-only" + +/** + * Login provider registry. Each provider is configured entirely via env: + * + * OAuth2 (redirect-based): + * OAUTH_GOOGLE_CLIENT_ID=… OAUTH_GOOGLE_CLIENT_SECRET=… + * OAUTH_GITHUB_CLIENT_ID=… OAUTH_GITHUB_CLIENT_SECRET=… + * + * Dashboard session handoff (same host, any port): + * DASHBOARD_ORIGIN=http://localhost:3000 + * + * A provider without its env pair is simply not offered. + */ + +export type OAuthProviderName = "google" | "github" | string + +export type OAuthProviderConfig = { + readonly kind: "oauth" + readonly name: string + readonly displayName: string + readonly clientId: string + readonly clientSecret: string + readonly authorizeUrl: string + readonly tokenUrl: string + readonly userInfoUrl: string + readonly scope: string + readonly emailKey: string + readonly idKey: string + readonly nameKey: string +} + +/** + * Dashboard SSO: no redirect flow — the user is already logged into the + * Knowhere Dashboard on the same host (another port, or a shared parent + * domain via the Dashboard's AUTH_COOKIE_DOMAIN). The notebook forwards the + * incoming browser Cookie header to Dashboard's public `users.getCurrentUser` + * oRPC endpoint, which resolves the Better Auth session. + */ +export type DashboardProviderConfig = { + readonly kind: "dashboard" + readonly name: "dashboard" + readonly displayName: "Dashboard" + readonly dashboardOrigin: string +} + +export type LoginProviderConfig = OAuthProviderConfig | DashboardProviderConfig + +/** Login-page view: just what the client needs to render the button. */ +export type LoginProviderView = { + readonly name: string + readonly displayName: string +} + +const OAUTH_PROVIDERS: readonly { + readonly name: string + readonly displayName: string + readonly envKey: string + readonly authorizeUrl: string + readonly tokenUrl: string + readonly userInfoUrl: string + readonly scope: string + readonly emailKey: string + readonly idKey: string + readonly nameKey: string +}[] = [ + { + name: "google", + displayName: "Google", + envKey: "GOOGLE", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo", + scope: "openid email profile", + emailKey: "email", + idKey: "sub", + nameKey: "name", + }, + { + name: "github", + displayName: "GitHub", + envKey: "GITHUB", + authorizeUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + userInfoUrl: "https://api.github.com/user", + scope: "read:user user:email", + emailKey: "email", + idKey: "id", + nameKey: "name", + }, +] + +export function listOAuthProviders(): readonly OAuthProviderConfig[] { + return OAUTH_PROVIDERS.flatMap((provider) => { + const clientId = process.env[`OAUTH_${provider.envKey}_CLIENT_ID`]?.trim() + const clientSecret = process.env[ + `OAUTH_${provider.envKey}_CLIENT_SECRET` + ]?.trim() + if (!clientId || !clientSecret) return [] + return [ + { + kind: "oauth" as const, + name: provider.name, + displayName: provider.displayName, + clientId, + clientSecret, + authorizeUrl: provider.authorizeUrl, + tokenUrl: provider.tokenUrl, + userInfoUrl: provider.userInfoUrl, + scope: provider.scope, + emailKey: provider.emailKey, + idKey: provider.idKey, + nameKey: provider.nameKey, + }, + ] + }) +} + +export function getOAuthProvider(name: string): OAuthProviderConfig | null { + const configured = listOAuthProviders() + return configured.find((provider) => provider.name === name) ?? null +} + +export function getDashboardProvider(): DashboardProviderConfig | null { + const dashboardOrigin = process.env.DASHBOARD_ORIGIN?.trim() + if (!dashboardOrigin) return null + return { + kind: "dashboard", + name: "dashboard", + displayName: "Dashboard", + dashboardOrigin, + } +} + +/** Every provider that should be offered on the login page, in order. */ +export function listLoginProviders(): readonly LoginProviderView[] { + const oauth = listOAuthProviders().map((provider) => ({ + name: provider.name, + displayName: provider.displayName, + })) + const dashboard = getDashboardProvider() + ? [{ name: "dashboard", displayName: "Dashboard" }] + : [] + return [...dashboard, ...oauth] +} diff --git a/src/infrastructure/auth/oauth.test.ts b/src/infrastructure/auth/oauth.test.ts new file mode 100644 index 0000000..43ab696 --- /dev/null +++ b/src/infrastructure/auth/oauth.test.ts @@ -0,0 +1,488 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Effect } from "effect" + +const mocks = vi.hoisted(() => ({ + runPromise: vi.fn(), + findByProviderAndProviderUserIdEffect: vi.fn(), + findByIdEffect: vi.fn(), + findByEmailEffect: vi.fn(), + findByUserIdAndProviderEffect: vi.fn(), + insertUserEffect: vi.fn(), + insertLinkEffect: vi.fn(), + createSession: vi.fn(), + cookieJar: { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.runPromise, + }, +})) + +vi.mock("@/infrastructure/auth/users-repository", () => ({ + usersRepository: { + findByIdEffect: mocks.findByIdEffect, + findByEmailEffect: mocks.findByEmailEffect, + insertEffect: mocks.insertUserEffect, + }, +})) + +vi.mock("@/infrastructure/auth/account-links-repository", () => ({ + accountLinksRepository: { + findByProviderAndProviderUserIdEffect: + mocks.findByProviderAndProviderUserIdEffect, + findByUserIdAndProviderEffect: mocks.findByUserIdAndProviderEffect, + insertEffect: mocks.insertLinkEffect, + }, +})) + +vi.mock("@/infrastructure/auth/session", () => ({ + createSession: mocks.createSession, +})) + +vi.mock("next/headers", () => ({ + cookies: async () => mocks.cookieJar, +})) + +import { + buildOAuthAuthorizeUrl, + completeOAuthLogin, + loginWithDashboardSession, +} from "./oauth" +import type { OAuthProviderConfig } from "./oauth-providers" + +const provider: OAuthProviderConfig = { + kind: "oauth", + name: "google", + displayName: "Google", + clientId: "google_id", + clientSecret: "google_secret", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo", + scope: "openid email profile", + emailKey: "email", + idKey: "sub", + nameKey: "name", +} + +describe("oauth flow", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.clearAllMocks() + mocks.runPromise.mockImplementation( + (effect: Effect.Effect) => + Effect.runPromise(effect), + ) + mocks.cookieJar.get.mockReturnValue(undefined) + mocks.cookieJar.delete.mockReturnValue(undefined) + mocks.cookieJar.set.mockReturnValue(undefined) + mocks.createSession.mockResolvedValue("session_1") + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it("builds an authorize URL with state and PKCE challenge", async () => { + const { url, state } = await buildOAuthAuthorizeUrl( + provider, + "http://localhost/api/auth/google/callback", + ) + + const parsed = new URL(url) + expect(parsed.searchParams.get("client_id")).toBe("google_id") + expect(parsed.searchParams.get("redirect_uri")).toBe( + "http://localhost/api/auth/google/callback", + ) + expect(parsed.searchParams.get("response_type")).toBe("code") + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256") + expect(parsed.searchParams.get("state")).toBe(state) + expect(parsed.searchParams.get("code_challenge")).toBeTruthy() + expect(state.length).toBeGreaterThan(10) + expect(mocks.cookieJar.set).toHaveBeenCalledTimes(2) + }) + + it("exchanges the code, fetches userinfo, and creates a session for a new user", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "tok_123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ sub: "provider_1", email: "ada@example.com", name: "Ada" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + + mocks.cookieJar.get.mockImplementation((name: string) => + name === "oauth-state" + ? { value: "state_abc" } + : { value: "verifier_xyz" }, + ) + mocks.findByProviderAndProviderUserIdEffect.mockReturnValue( + Effect.succeed(null), + ) + mocks.insertUserEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "ada@example.com", + name: "Ada", + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), + ) + mocks.insertLinkEffect.mockReturnValue( + Effect.succeed({ + id: "link_1", + userId: "user_1", + provider: "google", + providerUserId: "provider_1", + passwordHash: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + + const destination = await completeOAuthLogin( + provider, + "http://localhost/api/auth/google/callback", + "code_123", + "state_abc", + ) + + expect(destination).toBe("/") + expect(mocks.findByProviderAndProviderUserIdEffect).toHaveBeenCalledWith( + "google", + "provider_1", + ) + expect(mocks.insertUserEffect).toHaveBeenCalledWith({ + email: "ada@example.com", + name: "Ada", + }) + expect(mocks.insertLinkEffect).toHaveBeenCalledWith({ + userId: "user_1", + provider: "google", + providerUserId: "provider_1", + passwordHash: null, + }) + expect(mocks.createSession).toHaveBeenCalledWith("user_1") + }) + + it("reuses an existing user when the provider link already exists", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "tok_123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ sub: "provider_1", email: "ada@example.com" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + + mocks.cookieJar.get.mockImplementation((name: string) => + name === "oauth-state" + ? { value: "state_abc" } + : { value: "verifier_xyz" }, + ) + mocks.findByProviderAndProviderUserIdEffect.mockReturnValue( + Effect.succeed({ + id: "link_1", + userId: "user_1", + provider: "google", + providerUserId: "provider_1", + passwordHash: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + mocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "ada@example.com", + name: null, + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), + ) + + await completeOAuthLogin( + provider, + "http://localhost/api/auth/google/callback", + "code_123", + "state_abc", + ) + + expect(mocks.insertUserEffect).not.toHaveBeenCalled() + expect(mocks.createSession).toHaveBeenCalledWith("user_1") + }) + + it("rejects a mismatched state", async () => { + mocks.cookieJar.get.mockReturnValue("other_state") + + await expect( + completeOAuthLogin( + provider, + "http://localhost/api/auth/google/callback", + "code_123", + "state_abc", + ), + ).rejects.toThrow(/state mismatch/) + }) + + it("rejects a failed token exchange", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("denied", { status: 401 })) + + mocks.cookieJar.get.mockImplementation((name: string) => + name === "oauth-state" + ? { value: "state_abc" } + : { value: "verifier_xyz" }, + ) + + await expect( + completeOAuthLogin( + provider, + "http://localhost/api/auth/google/callback", + "code_123", + "state_abc", + ), + ).rejects.toThrow(/token exchange failed/) + }) +}) + +describe("dashboard session handoff", () => { + const originalFetch = globalThis.fetch + const dashboardOrigin = "http://localhost:3000" + const dashboardUser = { + id: "dash_1", + email: "gordon@example.com", + name: "Gordon", + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.runPromise.mockImplementation( + (effect: Effect.Effect) => + Effect.runPromise(effect), + ) + mocks.createSession.mockResolvedValue("session_dash") + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function mockDashboardResponse(user: unknown) { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ json: { user } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + } + + it("forwards the browser cookie and logs in an existing linked user", async () => { + mockDashboardResponse(dashboardUser) + mocks.findByProviderAndProviderUserIdEffect.mockReturnValue( + Effect.succeed({ + id: "link_dash", + userId: "user_1", + provider: "dashboard", + providerUserId: "dash_1", + passwordHash: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + mocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "gordon@example.com", + name: "Gordon", + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), + ) + + const destination = await loginWithDashboardSession( + "better-auth.session_token=abc; notebook-session=xyz", + dashboardOrigin, + ) + + expect(destination).toBe("/") + expect(globalThis.fetch).toHaveBeenCalledWith( + "http://localhost:3000/api/orpc/users.getCurrentUser", + expect.objectContaining({ + method: "POST", + body: "{}", + headers: expect.objectContaining({ + "content-type": "application/json", + cookie: "better-auth.session_token=abc; notebook-session=xyz", + }), + }), + ) + expect(mocks.createSession).toHaveBeenCalledWith("user_1") + }) + + it("creates a new user on first dashboard login", async () => { + mockDashboardResponse({ id: "dash_9", email: "new@example.com", name: "New" }) + mocks.findByProviderAndProviderUserIdEffect.mockReturnValue( + Effect.succeed(null), + ) + mocks.findByEmailEffect.mockReturnValue(Effect.succeed(null)) + mocks.insertUserEffect.mockReturnValue( + Effect.succeed({ + id: "user_9", + email: "new@example.com", + name: "New", + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), + ) + mocks.insertLinkEffect.mockReturnValue( + Effect.succeed({ + id: "link_9", + userId: "user_9", + provider: "dashboard", + providerUserId: "dash_9", + passwordHash: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + + await loginWithDashboardSession("better-auth.session_token=abc", dashboardOrigin) + + expect(mocks.insertUserEffect).toHaveBeenCalledWith({ + email: "new@example.com", + name: "New", + }) + expect(mocks.insertLinkEffect).toHaveBeenCalledWith({ + userId: "user_9", + provider: "dashboard", + providerUserId: "dash_9", + passwordHash: null, + }) + expect(mocks.createSession).toHaveBeenCalledWith("user_9") + }) + + it("adopts an existing user by email when they have no password", async () => { + mockDashboardResponse(dashboardUser) + mocks.findByProviderAndProviderUserIdEffect.mockReturnValue( + Effect.succeed(null), + ) + mocks.findByEmailEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "gordon@example.com", + name: null, + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), + ) + mocks.findByUserIdAndProviderEffect.mockReturnValue( + Effect.succeed(null), + ) + mocks.insertLinkEffect.mockReturnValue( + Effect.succeed({ + id: "link_1", + userId: "user_1", + provider: "dashboard", + providerUserId: "dash_1", + passwordHash: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + + await loginWithDashboardSession("better-auth.session_token=abc", dashboardOrigin) + + expect(mocks.insertUserEffect).not.toHaveBeenCalled() + expect(mocks.insertLinkEffect).toHaveBeenCalledWith({ + userId: "user_1", + provider: "dashboard", + providerUserId: "dash_1", + passwordHash: null, + }) + expect(mocks.createSession).toHaveBeenCalledWith("user_1") + }) + + it("refuses to adopt a password-protected user with the same email", async () => { + mockDashboardResponse(dashboardUser) + mocks.findByProviderAndProviderUserIdEffect.mockReturnValue( + Effect.succeed(null), + ) + mocks.findByEmailEffect.mockReturnValue( + Effect.succeed({ + id: "user_1", + email: "gordon@example.com", + name: null, + emailVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }), + ) + mocks.findByUserIdAndProviderEffect.mockReturnValue( + Effect.succeed({ + id: "link_pass", + userId: "user_1", + provider: "password", + providerUserId: null, + passwordHash: "$argon2id$abc", + createdAt: new Date(), + updatedAt: new Date(), + }), + ) + + await expect( + loginWithDashboardSession( + "better-auth.session_token=abc", + dashboardOrigin, + ), + ).rejects.toMatchObject({ + code: "email-collision", + }) + expect(mocks.createSession).not.toHaveBeenCalled() + }) + + it("fails with no-dashboard-session when the dashboard has no user", async () => { + mockDashboardResponse(null) + + await expect( + loginWithDashboardSession( + "better-auth.session_token=abc", + dashboardOrigin, + ), + ).rejects.toMatchObject({ + code: "no-dashboard-session", + }) + expect(mocks.createSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/infrastructure/auth/oauth.ts b/src/infrastructure/auth/oauth.ts new file mode 100644 index 0000000..1da2eab --- /dev/null +++ b/src/infrastructure/auth/oauth.ts @@ -0,0 +1,403 @@ +import "server-only" + +import { createHash, randomBytes } from "node:crypto" +import { cookies } from "next/headers" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { usersRepository } from "@/infrastructure/auth/users-repository" +import { accountLinksRepository } from "@/infrastructure/auth/account-links-repository" +import { createSession } from "@/infrastructure/auth/session" +import type { OAuthProviderConfig } from "@/infrastructure/auth/oauth-providers" + +/** Cookie names for the OAuth state + PKCE verifier (transient, 10 min). */ +const oauthStateCookieName = "oauth-state" +const oauthVerifierCookieName = "oauth-verifier" + +const oauthStateTtlSeconds = 10 * 60 + +const DASHBOARD_SESSION_TIMEOUT_MS = 3_000 + +type OAuthUserInfo = { + readonly providerUserId: string + readonly email: string | null + readonly name: string | null +} + +/** Build the provider authorize URL with state + PKCE. */ +export async function buildOAuthAuthorizeUrl( + provider: OAuthProviderConfig, + callbackUrl: string, +): Promise<{ readonly url: string; readonly state: string }> { + const state = randomBytes(24).toString("base64url") + const verifier = randomBytes(32).toString("base64url") + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url") + + const jar = await cookies() + jar.set(oauthStateCookieName, state, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: oauthStateTtlSeconds, + }) + jar.set(oauthVerifierCookieName, verifier, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: oauthStateTtlSeconds, + }) + + const url = new URL(provider.authorizeUrl) + url.searchParams.set("client_id", provider.clientId) + url.searchParams.set("redirect_uri", callbackUrl) + url.searchParams.set("response_type", "code") + url.searchParams.set("scope", provider.scope) + url.searchParams.set("state", state) + url.searchParams.set("code_challenge", challenge) + url.searchParams.set("code_challenge_method", "S256") + return { url: url.toString(), state } +} + +/** + * Complete the OAuth code exchange and log the user in. + * - verifies state + PKCE verifier cookies + * - exchanges the code for a token + * - fetches userinfo + * - finds-or-creates the user + account_link, creates a session + * Returns the app path to redirect to, or throws on failure. + */ +export async function completeOAuthLogin( + provider: OAuthProviderConfig, + callbackUrl: string, + code: string, + state: string, +): Promise { + const jar = await cookies() + const expectedState = jar.get(oauthStateCookieName)?.value + const verifier = jar.get(oauthVerifierCookieName)?.value + jar.delete(oauthStateCookieName) + jar.delete(oauthVerifierCookieName) + + if (!expectedState || !verifier || expectedState !== state || !code) { + throw new Error("OAuth state mismatch or expired.") + } + + const accessToken = await exchangeCodeForToken( + provider, + callbackUrl, + code, + verifier, + ) + const userInfo = await fetchUserInfo(provider, accessToken) + const user = await findOrCreateUser(provider, userInfo) + + await createSession(user.id) + return "/" +} + +async function exchangeCodeForToken( + provider: OAuthProviderConfig, + callbackUrl: string, + code: string, + verifier: string, +): Promise { + const params = new URLSearchParams({ + client_id: provider.clientId, + client_secret: provider.clientSecret, + code, + code_verifier: verifier, + grant_type: "authorization_code", + redirect_uri: callbackUrl, + }) + + const response = await fetch(provider.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: params.toString(), + }) + if (!response.ok) { + throw new Error( + `OAuth token exchange failed (${response.status}) for ${provider.name}.`, + ) + } + const body = (await response.json()) as Record + const token = body.access_token + if (typeof token !== "string" || token.length === 0) { + throw new Error(`OAuth token exchange returned no access_token for ${provider.name}.`) + } + return token +} + +async function fetchUserInfo( + provider: OAuthProviderConfig, + accessToken: string, +): Promise { + const response = await fetch(provider.userInfoUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) { + throw new Error( + `OAuth userinfo failed (${response.status}) for ${provider.name}.`, + ) + } + const body = (await response.json()) as Record + + const providerUserId = getString(body[provider.idKey]) + if (!providerUserId) { + throw new Error(`OAuth userinfo missing ${provider.idKey} for ${provider.name}.`) + } + + // GitHub may require an extra email endpoint when the primary email is + // private. + let email = getString(body[provider.emailKey]) + if (!email && provider.name === "github") { + email = await fetchGitHubEmail(accessToken) + } + + return { + providerUserId, + email, + name: getString(body[provider.nameKey]), + } +} + +async function fetchGitHubEmail(accessToken: string): Promise { + const response = await fetch("https://api.github.com/user/emails", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) return null + const body = (await response.json()) as unknown + if (!Array.isArray(body)) return null + const primary = body.find( + (entry: unknown): entry is { email?: unknown; primary?: unknown } => + typeof entry === "object" && + entry !== null && + (entry as { primary?: unknown }).primary === true, + ) + const email = primary ? getString(primary.email) : null + return email ?? getString((body[0] as { email?: unknown } | undefined)?.email) +} + +async function findOrCreateUser( + provider: OAuthProviderConfig, + userInfo: OAuthUserInfo, +) { + const link = await databaseRuntime.runPromise( + accountLinksRepository.findByProviderAndProviderUserIdEffect( + provider.name, + userInfo.providerUserId, + ), + ) + if (link) { + const existing = await databaseRuntime.runPromise( + usersRepository.findByIdEffect(link.userId), + ) + if (existing) return existing + } + + // First-time OAuth login: create the user (email may be null for GitHub + // private emails that fail; still allow login with a generated handle). + const email = userInfo.email ?? `${userInfo.providerUserId}@${provider.name}.oauth` + const created = await databaseRuntime.runPromise( + usersRepository.insertEffect({ + email, + name: userInfo.name ?? null, + }), + ) + await databaseRuntime.runPromise( + accountLinksRepository.insertEffect({ + userId: created.id, + provider: provider.name, + providerUserId: userInfo.providerUserId, + passwordHash: null, + }), + ) + return created +} + +function getString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null +} + +// ---- Dashboard session handoff -------------------------------------------- + +export type DashboardLoginErrorCode = + | "no-dashboard-session" + | "email-collision" + +export class DashboardLoginError extends Error { + readonly code: DashboardLoginErrorCode + + constructor(code: DashboardLoginErrorCode, message: string) { + super(message) + this.code = code + } +} + +/** Dashboard's `users.getCurrentUser` response shape. */ +type DashboardUser = { + readonly id: string + readonly email: string | null + readonly name: string | null +} + +type DashboardUserInfo = DashboardUser + +/** + * Log the user in via the Knowhere Dashboard's current session. + * + * The browser already sends the Dashboard's Better Auth session cookie to + * this app (cookies are not port-scoped: same host, any port; or a shared + * parent domain via the Dashboard's AUTH_COOKIE_DOMAIN). We forward the + * incoming Cookie header to Dashboard's public `users.getCurrentUser` + * oRPC endpoint, which resolves it to a user. + * + * Errors are thrown as `DashboardLoginError` with a machine-readable code: + * - "no-dashboard-session" — Dashboard has no session for this cookie + * - "email-collision" — the Dashboard email matches a Notebook user that + * has a password; we never silently adopt a password-protected account + * + * Returns the destination app path on success. + */ +export async function loginWithDashboardSession( + cookieHeader: string, + dashboardOrigin: string, +): Promise { + const userInfo = await fetchDashboardCurrentUser(cookieHeader, dashboardOrigin) + if (!userInfo) { + throw new DashboardLoginError( + "no-dashboard-session", + "You are not logged into the Knowhere Dashboard.", + ) + } + + const user = await findOrCreateDashboardUser(userInfo) + await createSession(user.id) + return "/" +} + +async function fetchDashboardCurrentUser( + cookieHeader: string, + dashboardOrigin: string, +): Promise { + const controller = new AbortController() + const timeout = setTimeout( + () => controller.abort(), + DASHBOARD_SESSION_TIMEOUT_MS, + ) + try { + const response = await fetch( + `${dashboardOrigin.replace(/\/$/, "")}/api/orpc/users.getCurrentUser`, + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: cookieHeader, + }, + body: "{}", + signal: controller.signal, + }, + ) + if (!response.ok) { + throw new DashboardLoginError( + "no-dashboard-session", + `Dashboard session check failed (status=${response.status}).`, + ) + } + const body = (await response.json()) as { + json?: { user?: unknown } + } + const user = body.json?.user + if ( + typeof user !== "object" || + user === null || + typeof (user as { id?: unknown }).id !== "string" + ) { + return null + } + return { + id: (user as { id: string }).id, + email: getString((user as { email?: unknown }).email), + name: getString((user as { name?: unknown }).name), + } + } catch (error) { + if (error instanceof DashboardLoginError) throw error + throw new DashboardLoginError( + "no-dashboard-session", + "Could not reach the Knowhere Dashboard for session check.", + ) + } finally { + clearTimeout(timeout) + } +} + +async function findOrCreateDashboardUser(userInfo: DashboardUserInfo) { + // 1. Existing (dashboard, dashboardUserId) link → reuse that user. + const link = await databaseRuntime.runPromise( + accountLinksRepository.findByProviderAndProviderUserIdEffect( + "dashboard", + userInfo.id, + ), + ) + if (link) { + const existing = await databaseRuntime.runPromise( + usersRepository.findByIdEffect(link.userId), + ) + if (existing) return existing + } + + // 2. Email collision policy: only adopt an existing user if they have no + // password (pristine or OAuth-created). Never adopt a password- + // protected account — that would be an account takeover. + const email = userInfo.email ?? `${userInfo.id}@dashboard.sso` + const byEmail = await databaseRuntime.runPromise( + usersRepository.findByEmailEffect(email), + ) + if (byEmail) { + const passwordLink = await databaseRuntime.runPromise( + accountLinksRepository.findByUserIdAndProviderEffect( + byEmail.id, + "password", + ), + ) + if (passwordLink?.passwordHash) { + throw new DashboardLoginError( + "email-collision", + `A Notebook user with the email "${email}" already has a password. ` + + "Remove that user or log in with the password instead.", + ) + } + await databaseRuntime.runPromise( + accountLinksRepository.insertEffect({ + userId: byEmail.id, + provider: "dashboard", + providerUserId: userInfo.id, + passwordHash: null, + }), + ) + return byEmail + } + + // 3. New user. + const created = await databaseRuntime.runPromise( + usersRepository.insertEffect({ + email, + name: userInfo.name ?? null, + }), + ) + await databaseRuntime.runPromise( + accountLinksRepository.insertEffect({ + userId: created.id, + provider: "dashboard", + providerUserId: userInfo.id, + passwordHash: null, + }), + ) + return created +} diff --git a/src/infrastructure/auth/session-cookie-constants.ts b/src/infrastructure/auth/session-cookie-constants.ts new file mode 100644 index 0000000..39fb25b --- /dev/null +++ b/src/infrastructure/auth/session-cookie-constants.ts @@ -0,0 +1,6 @@ +/** + * Name of the Notebook session cookie. Edge-safe (no server-only imports) + * so the proxy can reference it without pulling the DB runtime into the + * edge bundle. + */ +export const notebookSessionCookieName = "notebook-session" diff --git a/src/infrastructure/auth/session-cookie-names.ts b/src/infrastructure/auth/session-cookie-names.ts deleted file mode 100644 index 8b81aee..0000000 --- a/src/infrastructure/auth/session-cookie-names.ts +++ /dev/null @@ -1,15 +0,0 @@ -const DEFAULT_SESSION_COOKIE_NAMES = [ - "better-auth.session_token", - "__Secure-better-auth.session_token", -] as const - -export function sessionCookieNames(): readonly string[] { - const override = process.env.SESSION_COOKIE_NAMES - if (override !== undefined && override.trim().length > 0) { - return override - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - } - return DEFAULT_SESSION_COOKIE_NAMES -} diff --git a/src/infrastructure/auth/session.ts b/src/infrastructure/auth/session.ts new file mode 100644 index 0000000..56f4b33 --- /dev/null +++ b/src/infrastructure/auth/session.ts @@ -0,0 +1,89 @@ +import "server-only" + +import { cookies } from "next/headers" +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { sessionsRepository } from "./sessions-repository" +import { notebookSessionCookieName } from "./session-cookie-constants" + +/** Cookie holding the DB session id. */ +export const sessionCookieName = notebookSessionCookieName + +/** Session lifetime: 30 days. */ +const sessionLifetimeMs = 30 * 24 * 60 * 60 * 1000 + +export type SessionDurations = { + readonly createdAt: Date + readonly expiresAt: Date +} + +function getCookieOptions(): { + readonly httpOnly: true + readonly sameSite: "lax" + readonly secure: boolean + readonly path: "/" + readonly maxAge: number +} { + return { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: sessionLifetimeMs / 1000, + } +} + +/** + * Create a DB session row for the user and set the `notebook-session` + * cookie. Server Action / Route Handler only (Next 16 constraint: cookies + * cannot be set from Server Components). + */ +export async function createSession(userId: string): Promise { + const expiresAt = new Date(Date.now() + sessionLifetimeMs) + const session = await databaseRuntime.runPromise( + sessionsRepository.createEffect({ userId, expiresAt }), + ) + const jar = await cookies() + jar.set(sessionCookieName, session.id, getCookieOptions()) + return session.id +} + +/** + * Delete the session row behind the current `notebook-session` cookie and + * clear the cookie. Safe to call when no session exists. + */ +export async function deleteSession(): Promise { + const jar = await cookies() + const sessionId = jar.get(sessionCookieName)?.value + if (sessionId) { + await databaseRuntime + .runPromise(sessionsRepository.deleteByIdEffect(sessionId)) + .catch(() => {}) + } + jar.delete(sessionCookieName) +} + +/** + * Read the session id from the cookie without touching the DB. Used by the + * edge proxy for the cheap presence check. + */ +export async function getSessionIdFromCookie(): Promise { + const jar = await cookies() + return jar.get(sessionCookieName)?.value ?? null +} + +/** + * Opportunistically sweep expired sessions. Best-effort; failures are + * swallowed so login is never blocked by a cleanup hiccup. + */ +export function sweepExpiredSessions(): Promise { + return databaseRuntime + .runPromise(sessionsRepository.deleteExpiredEffect()) + .catch(() => {}) +} + +export const sessionEffect = { + create: (userId: string): Effect.Effect => + Effect.tryPromise(() => createSession(userId)), +} as const diff --git a/src/infrastructure/auth/sessions-repository.ts b/src/infrastructure/auth/sessions-repository.ts new file mode 100644 index 0000000..cf7d278 --- /dev/null +++ b/src/infrastructure/auth/sessions-repository.ts @@ -0,0 +1,66 @@ +import "server-only" + +import { and, eq, gt, lt } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { sessions, type Session } from "@/infrastructure/db/schema" + +type SessionsRepository = { + readonly findByIdEffect: ( + id: string, + ) => Effect.Effect + readonly createEffect: (input: { + readonly userId: string + readonly expiresAt: Date + }) => Effect.Effect + readonly deleteByIdEffect: (id: string) => Effect.Effect + readonly deleteExpiredEffect: () => Effect.Effect +} + +const findByIdEffect: SessionsRepository["findByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(sessions) + .where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()))) + .limit(1), + ) + return row[0] ?? null + }) + +const createEffect: SessionsRepository["createEffect"] = (input) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db.insert(sessions).values(input).returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die(new Error("sessions: insert returned no row.")) + } + return row + }) + +const deleteByIdEffect: SessionsRepository["deleteByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => db.delete(sessions).where(eq(sessions.id, id))) + }) + +const deleteExpiredEffect: SessionsRepository["deleteExpiredEffect"] = () => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db.delete(sessions).where(lt(sessions.expiresAt, new Date())), + ) + }) + +export const sessionsRepository: SessionsRepository = { + findByIdEffect, + createEffect, + deleteByIdEffect, + deleteExpiredEffect, +} diff --git a/src/infrastructure/auth/urls.test.ts b/src/infrastructure/auth/urls.test.ts deleted file mode 100644 index 1dce54a..0000000 --- a/src/infrastructure/auth/urls.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { authURLs } from "./urls"; - -describe("buildDashboardLoginURL", () => { - it("adds the Notebook callbackURL to the Dashboard login URL", () => { - expect( - authURLs.buildDashboardLoginURL( - "http://localhost:3000/login", - "http://localhost:3001", - ), - ).toBe("http://localhost:3000/login?callbackURL=http%3A%2F%2Flocalhost%3A3001"); - }); -}); - -describe("resolveNotebookPublicURLFromHeaders", () => { - it("derives a localhost Notebook origin from the request host", () => { - const headers = new Headers({ host: "localhost:3001" }); - - expect(authURLs.resolveNotebookPublicURLFromHeaders(headers)).toBe( - "http://localhost:3001", - ); - }); - - it("prefers forwarded host and protocol behind a proxy", () => { - const headers = new Headers({ - host: "127.0.0.1:3001", - "x-forwarded-host": "notebook.knowhereto.ai", - "x-forwarded-proto": "https", - }); - - expect(authURLs.resolveNotebookPublicURLFromHeaders(headers)).toBe( - "https://notebook.knowhereto.ai", - ); - }); -}); diff --git a/src/infrastructure/auth/urls.ts b/src/infrastructure/auth/urls.ts deleted file mode 100644 index 1771f47..0000000 --- a/src/infrastructure/auth/urls.ts +++ /dev/null @@ -1,55 +0,0 @@ -const LOCALHOST_HOSTNAMES: ReadonlySet = new Set([ - "localhost", - "127.0.0.1", - "::1", -]); - -function buildDashboardLoginURL( - dashboardLoginURL: string, - notebookPublicURL: string, -): string { - const url = new URL(dashboardLoginURL); - url.searchParams.set("callbackURL", notebookPublicURL); - return url.toString(); -} - -function resolveNotebookPublicURLFromHeaders(headers: Headers): string { - const host = readFirstHeaderValue(headers, "x-forwarded-host") ?? headers.get("host"); - if (!host) { - throw new Error( - "NOTEBOOK_PUBLIC_URL is required when the request host is unavailable.", - ); - } - - const protocol = - readFirstHeaderValue(headers, "x-forwarded-proto") ?? inferProtocol(host); - return `${protocol}://${host}`; -} - -function readFirstHeaderValue(headers: Headers, name: string): string | null { - const value = headers.get(name); - if (!value) return null; - - const first = value - .split(",") - .map((part) => part.trim()) - .find((part) => part.length > 0); - return first ?? null; -} - -function inferProtocol(host: string): "http" | "https" { - return LOCALHOST_HOSTNAMES.has(hostnameOf(host)) ? "http" : "https"; -} - -function hostnameOf(host: string): string { - if (host.startsWith("[")) { - const end = host.indexOf("]"); - return end === -1 ? host : host.slice(1, end); - } - return host.split(":")[0] ?? host; -} - -export const authURLs = { - buildDashboardLoginURL, - resolveNotebookPublicURLFromHeaders, -} as const; diff --git a/src/infrastructure/auth/users-repository.ts b/src/infrastructure/auth/users-repository.ts new file mode 100644 index 0000000..86d191b --- /dev/null +++ b/src/infrastructure/auth/users-repository.ts @@ -0,0 +1,64 @@ +import "server-only" + +import { and, eq, isNull } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { users, type User } from "@/infrastructure/db/schema" + +type UsersRepository = { + readonly findByEmailEffect: ( + email: string, + ) => Effect.Effect + readonly findByIdEffect: ( + id: string, + ) => Effect.Effect + readonly insertEffect: ( + input: { readonly email: string; readonly name: string | null }, + ) => Effect.Effect +} + +const findByEmailEffect: UsersRepository["findByEmailEffect"] = (email: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(users) + .where(and(eq(users.email, email), isNull(users.deletedAt))) + .limit(1), + ) + return row[0] ?? null + }) + +const findByIdEffect: UsersRepository["findByIdEffect"] = (id: string) => + Effect.gen(function* () { + const db = yield* DbClient + const row = yield* Effect.promise(() => + db + .select() + .from(users) + .where(and(eq(users.id, id), isNull(users.deletedAt))) + .limit(1), + ) + return row[0] ?? null + }) + +const insertEffect: UsersRepository["insertEffect"] = (input) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db.insert(users).values(input).returning(), + ) + const row = rows[0] + if (!row) { + return yield* Effect.die(new Error("users: insert returned no row.")) + } + return row + }) + +export const usersRepository: UsersRepository = { + findByEmailEffect, + findByIdEffect, + insertEffect, +} diff --git a/src/infrastructure/auth/workspace-members-repository.ts b/src/infrastructure/auth/workspace-members-repository.ts new file mode 100644 index 0000000..6b4e66f --- /dev/null +++ b/src/infrastructure/auth/workspace-members-repository.ts @@ -0,0 +1,130 @@ +import "server-only" + +import { and, eq, isNull } from "drizzle-orm" +import { Effect } from "effect" + +import { DbClient } from "@/infrastructure/db" +import { workspaceMembers, type WorkspaceMember } from "@/infrastructure/db/schema" + +type WorkspaceMembersRepository = { + readonly isMemberEffect: ( + workspaceId: string, + userId: string, + ) => Effect.Effect + readonly listMembersEffect: ( + workspaceId: string, + ) => Effect.Effect + readonly listWorkspaceIdsForUserEffect: ( + userId: string, + ) => Effect.Effect + readonly addMemberEffect: ( + workspaceId: string, + userId: string, + ) => Effect.Effect + readonly removeMemberEffect: ( + workspaceId: string, + userId: string, + ) => Effect.Effect +} + +const isMemberEffect: WorkspaceMembersRepository["isMemberEffect"] = ( + workspaceId: string, + userId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db + .select({ id: workspaceMembers.id }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.userId, userId), + isNull(workspaceMembers.deletedAt), + ), + ) + .limit(1), + ) + return rows.length > 0 + }) + +const listMembersEffect: WorkspaceMembersRepository["listMembersEffect"] = ( + workspaceId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + return yield* Effect.promise(() => + db + .select() + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + isNull(workspaceMembers.deletedAt), + ), + ), + ) + }) + +const listWorkspaceIdsForUserEffect: WorkspaceMembersRepository["listWorkspaceIdsForUserEffect"] = + (userId: string) => + Effect.gen(function* () { + const db = yield* DbClient + const rows = yield* Effect.promise(() => + db + .select({ workspaceId: workspaceMembers.workspaceId }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.userId, userId), + isNull(workspaceMembers.deletedAt), + ), + ), + ) + return rows.map((row) => row.workspaceId) + }) + +const addMemberEffect: WorkspaceMembersRepository["addMemberEffect"] = ( + workspaceId: string, + userId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .insert(workspaceMembers) + .values({ workspaceId, userId }) + .onConflictDoUpdate({ + target: [workspaceMembers.workspaceId, workspaceMembers.userId], + set: { deletedAt: null }, + }), + ) + }) + +const removeMemberEffect: WorkspaceMembersRepository["removeMemberEffect"] = ( + workspaceId: string, + userId: string, +) => + Effect.gen(function* () { + const db = yield* DbClient + yield* Effect.promise(() => + db + .update(workspaceMembers) + .set({ deletedAt: new Date() }) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.userId, userId), + ), + ), + ) + }) + +export const workspaceMembersRepository: WorkspaceMembersRepository = { + isMemberEffect, + listMembersEffect, + listWorkspaceIdsForUserEffect, + addMemberEffect, + removeMemberEffect, +} diff --git a/src/infrastructure/db/schema.ts b/src/infrastructure/db/schema.ts index 720a303..d6621ec 100644 --- a/src/infrastructure/db/schema.ts +++ b/src/infrastructure/db/schema.ts @@ -33,28 +33,101 @@ import { */ /** - * One workspace per user for the MVP. `user_id` is the Dashboard user id - * as returned by `users.getCurrentUser` (not a Notebook-local id). + * Workspaces: the persistence unit for a namespace-scoped document set. * - * `namespace` is the Knowhere namespace this workspace's sources all live - * in. It is derived once from the workspace id and never mutated. + * A workspace binds one user to one Knowhere namespace. The credential used + * to access that namespace is the mutable `active_knowhere_api_key_id` + * pointer (key-agnostic: the user can re-point it to any of their API keys + * at any time). One workspace per (user, namespace) tuple. */ export const workspaces = pgTable( "workspaces", { id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id").notNull().unique(), - namespace: text("namespace").notNull().unique(), + userId: text("user_id").notNull(), + namespace: text("namespace").notNull(), + activeKnowhereApiKeyId: uuid("active_knowhere_api_key_id"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, - (t) => [index("workspaces_user_id_idx").on(t.userId)], + (t) => [ + index("workspaces_user_id_idx").on(t.userId), + uniqueIndex("workspaces_user_namespace_idx").on(t.userId, t.namespace), + ], ); export type Workspace = typeof workspaces.$inferSelect; export type NewWorkspace = typeof workspaces.$inferInsert; +/** + * Workspace membership for team sharing (Phase 4). + * + * `workspaces.user_id` remains the owner (implicit owner role). Members are + * invited by email; each membership row grants access to the workspace's + * sources/chats under the member's own user id. + */ +export const workspaceMembers = pgTable( + "workspace_members", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [ + index("workspace_members_workspace_id_idx").on(t.workspaceId), + index("workspace_members_user_id_idx").on(t.userId), + uniqueIndex("workspace_members_workspace_user_idx") + .on(t.workspaceId, t.userId), + ], +); + +export type WorkspaceMember = typeof workspaceMembers.$inferSelect; +export type NewWorkspaceMember = typeof workspaceMembers.$inferInsert; + +/** + * Encrypted Knowhere API keys, owned by a user (not a workspace) — the + * credential for any of the user's workspaces. + * + * The raw key never touches the browser or the logs: the server encrypts it + * with AES-256-GCM (key from `KNOWHERE_KEY_ENCRYPTION_KEY`) before storing + * `cipher_blob` + `cipher_nonce`, and decrypts on demand only when a + * Knowhere request needs the credential. `key_mask` is computed once at + * save time so listing keys never needs to decrypt. + */ +export const knowhereApiKeys = pgTable( + "knowhere_api_keys", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + label: text("label").notNull(), + keyMask: text("key_mask").notNull(), + cipherBlob: text("cipher_blob").notNull(), + cipherNonce: text("cipher_nonce").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [ + index("knowhere_api_keys_user_id_idx").on(t.userId), + uniqueIndex("knowhere_api_keys_user_label_idx") + .on(t.userId, t.label) + .where(sql`deleted_at IS NULL`), + ], +); + +export type KnowhereApiKey = typeof knowhereApiKeys.$inferSelect; +export type NewKnowhereApiKey = typeof knowhereApiKeys.$inferInsert; + /** * One row per user-uploaded source. The row is the Notebook-owned record * of a Knowhere parse + index job; the actual chunks / file bytes live @@ -74,8 +147,6 @@ export type NewWorkspace = typeof workspaces.$inferInsert; * and download path * - `staged_blob_*` — legacy temporary Blob staging pointer retained for * older rows during the PR #28 transition - * - `demo_key` — canonical demo source identifier when this row is a - * materialized API-owned demo copy * - `deleted_at` — soft delete timestamp; reads filter it out * * Indexes: @@ -101,7 +172,6 @@ export const sources = pgTable( stagedBlobUrl: text("staged_blob_url"), originalBlobPathname: text("original_blob_pathname"), originalBlobUrl: text("original_blob_url"), - demoKey: text("demo_key"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -115,7 +185,6 @@ export const sources = pgTable( .on(t.workspaceId, t.createdAt.desc()) .where(sql`deleted_at IS NULL`), index("sources_workspace_status_idx").on(t.workspaceId, t.status), - uniqueIndex("sources_workspace_demo_key_idx").on(t.workspaceId, t.demoKey), uniqueIndex("sources_workspace_document_idx") .on(t.workspaceId, t.knowhereDocumentId) .where(sql`knowhere_document_id IS NOT NULL AND deleted_at IS NULL`), @@ -125,39 +194,6 @@ export const sources = pgTable( export type Source = typeof sources.$inferSelect; export type NewSource = typeof sources.$inferInsert; -/** - * User presentation state for canonical demo sources before they are copied - * into a real workspace source. - */ -export const demoSourceVisibilities = pgTable( - "demo_source_visibilities", - { - id: uuid("id").primaryKey().defaultRandom(), - workspaceId: uuid("workspace_id") - .notNull() - .references(() => workspaces.id, { onDelete: "cascade" }), - demoSourceId: text("demo_source_id").notNull(), - hiddenAt: timestamp("hidden_at", { withTimezone: true }), - deletedAt: timestamp("deleted_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => [ - uniqueIndex("demo_source_visibilities_workspace_source_idx").on( - t.workspaceId, - t.demoSourceId, - ), - index("demo_source_visibilities_workspace_idx").on(t.workspaceId), - ], -); - -export type DemoSourceVisibility = typeof demoSourceVisibilities.$inferSelect; -export type NewDemoSourceVisibility = typeof demoSourceVisibilities.$inferInsert; - /** * Notebook-owned parse-result artifact index for one source. * @@ -192,8 +228,7 @@ export type SourceParseResult = typeof sourceParseResults.$inferSelect; export type NewSourceParseResult = typeof sourceParseResults.$inferInsert; /** - * A chat thread is a conversation within a workspace. `demo_key` is retained - * for legacy seeded demo conversations. + * A chat thread is a conversation within a workspace. */ export const chatThreads = pgTable( "chat_threads", @@ -203,7 +238,6 @@ export const chatThreads = pgTable( .notNull() .references(() => workspaces.id, { onDelete: "cascade" }), title: text("title"), - demoKey: text("demo_key"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -219,10 +253,6 @@ export const chatThreads = pgTable( index("chat_threads_workspace_updated_idx") .on(t.workspaceId, t.updatedAt.desc()) .where(sql`deleted_at IS NULL`), - uniqueIndex("chat_threads_workspace_demo_key_idx").on( - t.workspaceId, - t.demoKey, - ), ], ); @@ -266,3 +296,95 @@ export const chatMessages = pgTable( export type ChatMessage = typeof chatMessages.$inferSelect; export type NewChatMessage = typeof chatMessages.$inferInsert; + +/** + * Notebook-owned users. Created by the admin CLI (scripts/create-user.ts) + * in Phase 2; OAuth/SSO links attach via `account_links`. + * + * `email` is unique and serves as the login handle. `email_verified_at` + * is set once email verification exists (deferred; null for now). + */ +export const users = pgTable( + "users", + { + id: uuid("id").primaryKey().defaultRandom(), + email: text("email").notNull().unique(), + name: text("name"), + emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [index("users_email_idx").on(t.email)], +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +/** + * Credential links for modular auth providers. + * + * One row per (user, provider) pair — a user can sign in with password + * AND Google/GitHub later. `password_hash` lives here (only for the + * "password" provider), keeping OAuth-only users hash-free. + */ +export const accountLinks = pgTable( + "account_links", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + providerUserId: text("provider_user_id"), + passwordHash: text("password_hash"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("account_links_user_provider_idx").on(t.userId, t.provider), + uniqueIndex("account_links_provider_provider_user_idx").on( + t.provider, + t.providerUserId, + ), + ], +); + +export type AccountLink = typeof accountLinks.$inferSelect; +export type NewAccountLink = typeof accountLinks.$inferInsert; + +/** + * DB-backed sessions: one row per active login, revocable server-side. + * + * The `notebook-session` cookie holds the session id; `getCurrentUser` + * joins this table to `users` on every request. Expired rows are ignored + * (and swept opportunistically). + */ +export const sessions = pgTable( + "sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("sessions_user_id_idx").on(t.userId), + index("sessions_expires_at_idx").on(t.expiresAt), + ], +); + +export type Session = typeof sessions.$inferSelect; +export type NewSession = typeof sessions.$inferInsert; diff --git a/src/integrations/dashboard/api-key-service.test.ts b/src/integrations/dashboard/api-key-service.test.ts deleted file mode 100644 index 88347e0..0000000 --- a/src/integrations/dashboard/api-key-service.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" - -const nextCacheMocks = vi.hoisted(() => ({ - cacheLife: vi.fn(), - cacheTag: vi.fn(), -})) - -vi.mock("next/cache", () => nextCacheMocks) - -import { - ensureApiKeyForWorkspace, - fetchKnowhereJwt, - isAuthError, -} from "./api-key-service" - -function getHeaderValue(headers: HeadersInit | undefined, name: string): string | null { - if (headers === undefined) return null - if (headers instanceof Headers) return headers.get(name) - - const lowerName = name.toLowerCase() - if (Array.isArray(headers)) { - const pair = headers.find(([key]) => key.toLowerCase() === lowerName) - return pair?.[1] ?? null - } - - const entry = Object.entries(headers).find( - ([key]) => key.toLowerCase() === lowerName, - ) - return entry?.[1] ?? null -} - -async function readBodyText(body: BodyInit | null | undefined): Promise { - if (body === undefined || body === null) return null - if (typeof body === "string") return body - if (body instanceof Blob) return await body.text() - if (body instanceof URLSearchParams) return body.toString() - if (body instanceof ArrayBuffer) return new TextDecoder().decode(body) - if (ArrayBuffer.isView(body)) { - const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength) - return new TextDecoder().decode(bytes) - } - return null -} - -describe("isAuthError", () => { - it("detects 401 status on a Response object", () => { - expect(isAuthError(new Response(null, { status: 401 }))).toBe(true) - }) - - it("detects 403 status on a Response object", () => { - expect(isAuthError(new Response(null, { status: 403 }))).toBe(true) - }) - - it("returns false for non-auth status on Response", () => { - expect(isAuthError(new Response(null, { status: 500 }))).toBe(false) - expect(isAuthError(new Response(null, { status: 200 }))).toBe(false) - }) - - it("detects 401 on an object with status property", () => { - expect(isAuthError({ status: 401 })).toBe(true) - }) - - it("detects 403 on an object with statusCode property", () => { - expect(isAuthError({ statusCode: 403 })).toBe(true) - }) - - it("detects auth-related substrings in error message", () => { - expect(isAuthError({ message: "Unauthorized request" })).toBe(true) - expect(isAuthError({ message: "Forbidden" })).toBe(true) - expect(isAuthError({ message: "Invalid API key" })).toBe(true) - expect(isAuthError({ message: "Auth error occurred" })).toBe(true) - expect(isAuthError({ message: "unauthenticated" })).toBe(true) - }) - - it("detects 401/403 in error message string", () => { - expect(isAuthError({ message: "HTTP 401: bad credentials" })).toBe(true) - expect(isAuthError({ message: "Got 403 from server" })).toBe(true) - }) - - it("returns false for null / undefined / non-matching errors", () => { - expect(isAuthError(null)).toBe(false) - expect(isAuthError(undefined)).toBe(false) - expect(isAuthError({})).toBe(false) - expect(isAuthError({ message: "Network timeout" })).toBe(false) - expect(isAuthError(new Error("Something went wrong"))).toBe(false) - }) -}) - -const JWT_PATH = "/api/orpc/users/issueServiceJwt" - -describe("fetchKnowhereJwt", () => { - const originalFetch = globalThis.fetch - const originalOrigin = process.env.DASHBOARD_ORIGIN - - afterEach(() => { - globalThis.fetch = originalFetch - nextCacheMocks.cacheLife.mockClear() - nextCacheMocks.cacheTag.mockClear() - if (originalOrigin === undefined) - delete process.env.DASHBOARD_ORIGIN - else process.env.DASHBOARD_ORIGIN = originalOrigin - }) - - it("POSTs to the JWT endpoint with the incoming cookie and empty JSON body", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.example" - const expectedUrl = `https://dashboard.example${JWT_PATH}` - const fetchSpy = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - json: { token: "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InVzZXIifQ.abc", expiresInSeconds: 900 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - globalThis.fetch = fetchSpy - - const token = await fetchKnowhereJwt("session=xyz; other=val") - - expect(token).toBe("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InVzZXIifQ.abc") - expect(fetchSpy).toHaveBeenCalledOnce() - const [url, init] = fetchSpy.mock.calls[0]! - expect(url instanceof URL ? url.href : url).toBe(expectedUrl) - expect((init as RequestInit)?.method).toBe("POST") - - const reqHeaders = (init as RequestInit)?.headers - expect(getHeaderValue(reqHeaders, "cookie")).toBe("session=xyz; other=val") - expect(getHeaderValue(reqHeaders, "content-type")).toContain( - "application/json", - ) - expect(await readBodyText((init as RequestInit)?.body)).toBe("{}") - }) - - it("throws when DASHBOARD_ORIGIN is not set", async () => { - delete process.env.DASHBOARD_ORIGIN - await expect( - fetchKnowhereJwt("session=x"), - ).rejects.toThrow(/DASHBOARD_ORIGIN/) - }) - - it("throws on non-2xx from Dashboard", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.example" - globalThis.fetch = vi - .fn() - .mockResolvedValue(new Response("oops", { status: 503 })) - await expect( - fetchKnowhereJwt("session=x"), - ).rejects.toThrow(/Dashboard JWT issuance: non-2xx/) - }) - - it("throws on malformed response body", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.example" - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ json: null }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - await expect( - fetchKnowhereJwt("session=x"), - ).rejects.toThrow(/Dashboard JWT issuance: schema mismatch .*"json":null/) - }) - - it("throws if the token string is empty", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.example" - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ json: { token: "", expiresInSeconds: 900 } }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - await expect( - fetchKnowhereJwt("session=x"), - ).rejects.toThrow(/Dashboard JWT issuance: schema mismatch .*"token":""/) - }) - - it("sets cache expiration from the Dashboard JWT lifetime", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.example" - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - json: { token: "jwt-short", expiresInSeconds: 45 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - - await expect(fetchKnowhereJwt("session=x")).resolves.toBe("jwt-short") - - expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith({ - stale: 30, - revalidate: 30, - expire: 45, - }) - }) - - it("refreshes long-lived Dashboard JWTs before expiration", async () => { - process.env.DASHBOARD_ORIGIN = "https://dashboard.example" - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - json: { token: "jwt-long", expiresInSeconds: 900 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - - await expect(fetchKnowhereJwt("session=x")).resolves.toBe("jwt-long") - - expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith({ - stale: 60, - revalidate: 60, - expire: 900, - }) - }) -}) - -describe("ensureApiKeyForWorkspace", () => { - const originalFetch = globalThis.fetch - const originalApiKey = process.env.KNOWHERE_API_KEY - const originalOrigin = process.env.DASHBOARD_ORIGIN - - afterEach(() => { - globalThis.fetch = originalFetch - if (originalApiKey === undefined) delete process.env.KNOWHERE_API_KEY - else process.env.KNOWHERE_API_KEY = originalApiKey - if (originalOrigin === undefined) - delete process.env.DASHBOARD_ORIGIN - else process.env.DASHBOARD_ORIGIN = originalOrigin - }) - - it("uses KNOWHERE_API_KEY without issuing a Dashboard JWT", async () => { - process.env.KNOWHERE_API_KEY = "sk_dev_key" - delete process.env.DASHBOARD_ORIGIN - const fetchSpy = vi.fn() - globalThis.fetch = fetchSpy - - const apiKey = await ensureApiKeyForWorkspace("workspace_1", "") - - expect(apiKey).toBe("sk_dev_key") - expect(fetchSpy).not.toHaveBeenCalled() - }) -}) diff --git a/src/integrations/dashboard/api-key-service.ts b/src/integrations/dashboard/api-key-service.ts deleted file mode 100644 index 743c99d..0000000 --- a/src/integrations/dashboard/api-key-service.ts +++ /dev/null @@ -1,209 +0,0 @@ -import "server-only" - -import { Effect, Either, Schema } from "effect" -import { cacheLife, cacheTag } from "next/cache" -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, -} from "@effect/platform" -import { logger } from "@/lib/logger" -import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" -import { setEmptyJsonBody } from "./orpc-request" -import { formatUnknownForLog } from "@/lib/format-log-value" - -/** - * Shape of the Dashboard JWT issuance response (oRPC envelope). - * `{ json: { token: string; expiresInSeconds: number } }` - */ -const JwtResponse = Schema.Struct({ - json: Schema.Struct({ - token: Schema.String.pipe(Schema.minLength(1)), - expiresInSeconds: Schema.Number, - }), -}) - -/** - * Request a short-lived Knowhere JWT from Dashboard's generic issuance - * endpoint. The returned token is passed directly to the Knowhere SDK - * as `apiKey` — no persistent API key is created or stored. - * - * Notebook never calls Knowhere `/v1/auth/create`. Dashboard owns JWT - * signing; Knowhere validates the JWT via Dashboard JWKS. - */ -export const fetchKnowhereJwtEffect = (cookieHeader: string) => - Effect.gen(function* () { - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - return yield* Effect.die( - new Error( - "DASHBOARD_ORIGIN must be set. " + - "It should point to the Dashboard origin (see .env.local.example).", - ), - ) - } - - const http = yield* HttpClient.HttpClient - const url = `${origin}/api/orpc/users/issueServiceJwt` - const body = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("cookie", cookieHeader), - setEmptyJsonBody, - http.execute, - Effect.flatMap((response) => - Effect.gen(function* () { - const status = response.status - - if (status < 200 || status >= 300) { - const rawText = yield* Effect.either(response.text) - return yield* Effect.die( - new Error( - `Dashboard JWT issuance: non-2xx (status=${status}) body=${Either.getOrElse(rawText, () => "").slice(0, 1000)}`, - ), - ) - } - - const parsed = yield* Effect.either(response.json) - if (Either.isLeft(parsed)) { - return yield* Effect.die( - new Error( - `Dashboard JWT issuance: invalid JSON (status=${status}) error=${String(parsed.left)}`, - ), - ) - } - - const result = Schema.decodeUnknownEither(JwtResponse)(parsed.right) - if (Either.isLeft(result)) { - return yield* Effect.die( - new Error( - `Dashboard JWT issuance: schema mismatch (status=${status}) body=${formatUnknownForLog(parsed.right).slice(0, 1000)}`, - ), - ) - } - - return result.right - }), - ), - ) - - return body.json - }) - -type KnowhereJwt = { - readonly token: string - readonly expiresInSeconds: number -} - -const minimumJwtCacheSeconds = 30 -const jwtExpirationSafetySeconds = 15 -const maximumJwtRefreshSeconds = 60 - -// Cache only inside the issued JWT's lifetime. The cache profile is computed -// after Dashboard responds so short-lived JWTs cannot outlive their expiration. -async function fetchKnowhereJwtCached( - cookieHeader: string, -): Promise { - "use cache" - cacheTag("knowhere-jwt") - - const jwt = await Effect.runPromise( - fetchKnowhereJwtEffect(cookieHeader).pipe( - Effect.provide(FetchHttpClient.layer), - ), - ) - const cacheSeconds = normalizeJwtCacheSeconds(jwt.expiresInSeconds) - cacheLife(getJwtCacheLife(cacheSeconds)) - - return jwt -} - -/** - * Async wrapper for Next.js boundary callers. - */ -export async function fetchKnowhereJwt( - cookieHeader: string, -): Promise { - const start = Date.now() - try { - const jwt = await fetchKnowhereJwtCached(cookieHeader) - logger.info("dashboard: POST /api/orpc/users/issueServiceJwt ok", { - durationMs: Date.now() - start, - }) - return jwt.token - } catch (error) { - logger.error("dashboard: POST /api/orpc/users/issueServiceJwt failed", { - durationMs: Date.now() - start, - error: error instanceof Error ? error.message : String(error), - }) - throw error - } -} - -/** - * Resolve the credential used for Knowhere SDK calls. Development can - * short-circuit Dashboard JWT issuance by setting KNOWHERE_API_KEY. - */ -export async function ensureApiKeyForWorkspace( - _workspaceId: string, - cookieHeader: string, -): Promise { - const apiKey = knowhereApiKeyOverride.getApiKey() - if (apiKey) return apiKey - - return fetchKnowhereJwt(cookieHeader) -} - -/** - * Heuristic: classify an error thrown by the Knowhere SDK or fetch as - * auth-related (401/403). Covers the SDK's error shape and raw fetch - * Response objects. - */ -export function isAuthError(error: unknown): boolean { - if (error instanceof Response) { - return error.status === 401 || error.status === 403 - } - const err = error as Record | null | undefined - if (!err) return false - if (typeof err.status === "number") { - if (err.status === 401 || err.status === 403) return true - } - if (typeof err.statusCode === "number") { - if (err.statusCode === 401 || err.statusCode === 403) return true - } - if (typeof err.message === "string") { - const msg = err.message.toLowerCase() - if (msg.includes("401") || msg.includes("403")) return true - if ( - msg.includes("unauthorized") || - msg.includes("unauthenticated") || - msg.includes("forbidden") || - msg.includes("invalid api key") || - msg.includes("auth error") - ) - return true - } - return false -} - -function normalizeJwtCacheSeconds(expiresInSeconds: number): number { - if (!Number.isFinite(expiresInSeconds)) return minimumJwtCacheSeconds - return Math.max(1, Math.floor(expiresInSeconds)) -} - -function getJwtCacheLife(expiresInSeconds: number): { - readonly stale: number - readonly revalidate: number - readonly expire: number -} { - const refreshSeconds = Math.max( - 1, - Math.min( - maximumJwtRefreshSeconds, - expiresInSeconds - jwtExpirationSafetySeconds, - ), - ) - return { - stale: refreshSeconds, - revalidate: refreshSeconds, - expire: expiresInSeconds, - } -} diff --git a/src/integrations/dashboard/orpc-request.ts b/src/integrations/dashboard/orpc-request.ts deleted file mode 100644 index ea73d57..0000000 --- a/src/integrations/dashboard/orpc-request.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpClientRequest } from "@effect/platform" - -const EMPTY_JSON_BODY = "{}" as const -const JSON_CONTENT_TYPE = "application/json" as const - -/** - * Dashboard selects its RPC handler by inspecting `Content-Type`. - * Effect's `bodyText` defaults to `text/plain` and overwrites any earlier - * content-type header, so the JSON content type must be passed here. - */ -export function setEmptyJsonBody( - request: HttpClientRequest.HttpClientRequest, -): HttpClientRequest.HttpClientRequest { - return HttpClientRequest.bodyText( - request, - EMPTY_JSON_BODY, - JSON_CONTENT_TYPE, - ) -} diff --git a/src/integrations/knowhere-api-key.ts b/src/integrations/knowhere-api-key.ts deleted file mode 100644 index 4645c39..0000000 --- a/src/integrations/knowhere-api-key.ts +++ /dev/null @@ -1,31 +0,0 @@ -type KnowhereDevelopmentUser = { - readonly id: string - readonly email: string | null - readonly name: string | null -} - -const developmentUser: KnowhereDevelopmentUser = { - id: "knowhere-api-key-dev-user", - email: null, - name: "Knowhere API Key Development", -} - -function getApiKey(): string | null { - const value = process.env.KNOWHERE_API_KEY?.trim() - return value && value.length > 0 ? value : null -} - -function hasApiKey(): boolean { - return getApiKey() !== null -} - -function getDevelopmentUser(): KnowhereDevelopmentUser | null { - if (!hasApiKey()) return null - return developmentUser -} - -export const knowhereApiKeyOverride = { - getApiKey, - hasApiKey, - getDevelopmentUser, -} as const diff --git a/src/integrations/knowhere-credentials.test.ts b/src/integrations/knowhere-credentials.test.ts new file mode 100644 index 0000000..09687e3 --- /dev/null +++ b/src/integrations/knowhere-credentials.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Effect } from "effect" + +const mocks = vi.hoisted(() => ({ + findByIdEffect: vi.fn(), + runPromise: vi.fn(), + getDefaultKnowhereKey: vi.fn(), + getActiveForWorkspaceEffect: vi.fn(), + firstForUserEffect: vi.fn(), + decryptStoredEffect: vi.fn(), +})) + +vi.mock("@/domains/workspace/repository", () => ({ + workspaceRepository: { + findByIdEffect: mocks.findByIdEffect, + }, +})) + +vi.mock("@/domains/workspace/database-runtime", () => ({ + databaseRuntime: { + runPromise: mocks.runPromise, + }, +})) + +vi.mock("@/infrastructure/auth/knowhere-api-keys-repository", () => ({ + knowhereApiKeysRepository: { + getActiveForWorkspaceEffect: mocks.getActiveForWorkspaceEffect, + firstForUserEffect: mocks.firstForUserEffect, + decryptStoredEffect: mocks.decryptStoredEffect, + }, +})) + +vi.mock("@/integrations/knowhere-keys", () => ({ + getDefaultKnowhereKey: mocks.getDefaultKnowhereKey, +})) + +import { ensureApiKeyForWorkspace, isAuthError } from "./knowhere-credentials" + +describe("ensureApiKeyForWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.runPromise.mockImplementation((effect: Effect.Effect) => + Effect.runPromise(effect), + ) + mocks.getDefaultKnowhereKey.mockResolvedValue(null) + mocks.getActiveForWorkspaceEffect.mockReturnValue(Effect.succeed(null)) + mocks.firstForUserEffect.mockReturnValue(Effect.succeed(null)) + mocks.decryptStoredEffect.mockReturnValue( + Effect.succeed("sk_decrypted_db_key"), + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("decrypts the workspace's active DB key when one is set", async () => { + mocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "workspace_db", + userId: "user_1", + activeKnowhereApiKeyId: "key_1", + namespace: "adobe", + createdAt: new Date(), + }), + ) + mocks.getActiveForWorkspaceEffect.mockReturnValue( + Effect.succeed({ + id: "key_1", + userId: "user_1", + label: "domainA", + keyMask: "sk_te••••st", + createdAt: new Date(), + }), + ) + + const apiKey = await ensureApiKeyForWorkspace("workspace_db") + + expect(apiKey).toBe("sk_decrypted_db_key") + expect(mocks.decryptStoredEffect).toHaveBeenCalled() + }) + + it("falls back to the user's first key when no active key is set", async () => { + mocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "workspace_1", + userId: "user_1", + activeKnowhereApiKeyId: null, + namespace: "adobe", + createdAt: new Date(), + }), + ) + mocks.getActiveForWorkspaceEffect.mockReturnValue(Effect.succeed(null)) + mocks.firstForUserEffect.mockReturnValue( + Effect.succeed({ + id: "key_9", + userId: "user_1", + label: "domainA", + keyMask: "sk_te••••st", + createdAt: new Date(), + }), + ) + + const apiKey = await ensureApiKeyForWorkspace("workspace_1") + + expect(apiKey).toBe("sk_decrypted_db_key") + expect(mocks.firstForUserEffect).toHaveBeenCalledWith("user_1") + }) + + it("falls back to the file/env key when the user has no DB keys", async () => { + mocks.findByIdEffect.mockReturnValue( + Effect.succeed({ + id: "workspace_3", + userId: "user_1", + activeKnowhereApiKeyId: null, + namespace: "adobe", + createdAt: new Date(), + }), + ) + mocks.getActiveForWorkspaceEffect.mockReturnValue(Effect.succeed(null)) + mocks.firstForUserEffect.mockReturnValue(Effect.succeed(null)) + mocks.getDefaultKnowhereKey.mockResolvedValue({ + label: "default", + apiKey: "sk_file_key", + }) + + const apiKey = await ensureApiKeyForWorkspace("workspace_3") + + expect(apiKey).toBe("sk_file_key") + }) + + it("throws when no key is configured anywhere", async () => { + mocks.findByIdEffect.mockReturnValue(Effect.succeed(null)) + + await expect(ensureApiKeyForWorkspace("workspace_4")).rejects.toThrow( + /No Knowhere API key configured/, + ) + }) +}) + +describe("isAuthError", () => { + it("classifies 401/403 statuses", () => { + expect(isAuthError({ status: 401 })).toBe(true) + expect(isAuthError({ status: 403 })).toBe(true) + expect(isAuthError({ status: 404 })).toBe(false) + }) + + it("classifies auth phrases in error messages", () => { + expect(isAuthError({ message: "Unauthorized" })).toBe(true) + expect(isAuthError({ message: "Invalid API Key" })).toBe(true) + expect(isAuthError({ message: "Internal server error" })).toBe(false) + }) + + it("classifies raw Response objects", () => { + expect(isAuthError(new Response("x", { status: 401 }))).toBe(true) + expect(isAuthError(new Response("x", { status: 500 }))).toBe(false) + }) + + it("returns false for non-error input", () => { + expect(isAuthError(null)).toBe(false) + expect(isAuthError(undefined)).toBe(false) + expect(isAuthError("nope")).toBe(false) + }) +}) diff --git a/src/integrations/knowhere-credentials.ts b/src/integrations/knowhere-credentials.ts new file mode 100644 index 0000000..e78134b --- /dev/null +++ b/src/integrations/knowhere-credentials.ts @@ -0,0 +1,88 @@ +import "server-only" + +import { Effect } from "effect" + +import { databaseRuntime } from "@/domains/workspace/database-runtime" +import { workspaceRepository } from "@/domains/workspace/repository" +import { knowhereApiKeysRepository } from "@/infrastructure/auth/knowhere-api-keys-repository" +import { getDefaultKnowhereKey } from "@/integrations/knowhere-keys" + +/** + * Resolve the credential used for Knowhere SDK calls. + * + * Order (key-agnostic, user-scoped keys): + * 1. Active DB key for the workspace (workspaces.active_knowhere_api_key_id) + * — decrypted on demand, never logged or sent to the browser. + * 2. The user's first non-deleted key. + * 3. File/env fallback (`config/knowhere-keys.json`, then KNOWHERE_API_KEY) + * — kept as the bootstrap for fresh deployments before any UI key is + * added. + */ +export async function ensureApiKeyForWorkspace( + workspaceId: string, +): Promise { + const dbKey = await databaseRuntime + .runPromise( + Effect.gen(function* () { + const workspace = yield* workspaceRepository.findByIdEffect(workspaceId) + if (!workspace) return null + + const active = yield* knowhereApiKeysRepository.getActiveForWorkspaceEffect( + workspaceId, + ) + if (active) return active + + return yield* knowhereApiKeysRepository.firstForUserEffect( + workspace.userId, + ) + }), + ) + .catch(() => null) + + if (dbKey) { + const apiKey = await databaseRuntime + .runPromise(knowhereApiKeysRepository.decryptStoredEffect(dbKey)) + .catch(() => null) + if (apiKey) return apiKey + } + + const defaultKey = await getDefaultKnowhereKey() + if (defaultKey) return defaultKey.apiKey + + throw new Error( + "No Knowhere API key configured. Add one via the API keys dialog, " + + "set KNOWHERE_API_KEY, or provide config/knowhere-keys.json.", + ) +} + +/** + * Heuristic: classify an error thrown by the Knowhere SDK or fetch as + * auth-related (401/403). Covers the SDK's error shape and raw fetch + * Response objects. + */ +export function isAuthError(error: unknown): boolean { + if (error instanceof Response) { + return error.status === 401 || error.status === 403 + } + const err = error as Record | null | undefined + if (!err) return false + if (typeof err.status === "number") { + if (err.status === 401 || err.status === 403) return true + } + if (typeof err.statusCode === "number") { + if (err.statusCode === 401 || err.statusCode === 403) return true + } + if (typeof err.message === "string") { + const msg = err.message.toLowerCase() + if (msg.includes("401") || msg.includes("403")) return true + if ( + msg.includes("unauthorized") || + msg.includes("unauthenticated") || + msg.includes("forbidden") || + msg.includes("invalid api key") || + msg.includes("auth error") + ) + return true + } + return false +} diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts deleted file mode 100644 index 7926818..0000000 --- a/src/integrations/knowhere-demo.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" - -const nextCacheMocks = vi.hoisted(() => ({ - cacheLife: vi.fn(), - cacheTag: vi.fn(), -})) - -vi.mock("next/cache", () => nextCacheMocks) - -import { knowhereDemoApi } from "./knowhere-demo" - -describe("knowhereDemoApi", () => { - const originalBaseURL = process.env.KNOWHERE_BASE_URL - const originalFetch = globalThis.fetch - - afterEach(() => { - restoreEnv("KNOWHERE_BASE_URL", originalBaseURL) - globalThis.fetch = originalFetch - nextCacheMocks.cacheLife.mockClear() - nextCacheMocks.cacheTag.mockClear() - }) - - it("uses the configured Knowhere base URL for demo requests", () => { - process.env.KNOWHERE_BASE_URL = "https://api-staging.knowhereto.ai" - - const url = knowhereDemoApi.resolveApiURL("/api/v1/demo/catalog") - - expect(url).toBe("https://api-staging.knowhereto.ai/api/v1/demo/catalog") - }) - - it("falls back to production API instead of localhost", () => { - delete process.env.KNOWHERE_BASE_URL - - const url = knowhereDemoApi.resolveApiURL("/api/v1/demo/catalog") - - expect(url).toBe("https://api.knowhereto.ai/api/v1/demo/catalog") - }) - - it("uses deploy-lifetime cache profiles for demo catalog data", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ sources: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) - - await expect(knowhereDemoApi.fetchCatalog()).resolves.toEqual({ - sources: [], - officialLibrary: { - categories: [], - sources: [], - }, - }) - - expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith("max") - expect(nextCacheMocks.cacheTag).toHaveBeenCalledWith("demo-catalog") - }) - - it("accepts empty demo chunk content from parser output", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - demo_source_id: "demo-tsla-q4-2025", - canonical_document_id: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", - mime_type: "application/pdf", - chunks: [ - { - id: "demo-tsla-q4-2025:chunk-empty", - chunk_id: "chunk-empty", - chunk_type: "text", - content: "", - section_path: "Default_Root/TSLA-Q4-2025-Update.pdf-->OUTLOOK", - source_chunk_path: "Default_Root/TSLA-Q4-2025-Update.pdf-->OUTLOOK", - file_path: null, - sort_order: 27, - metadata: {}, - asset_url: null, - }, - ], - pagination: { - page: 1, - page_size: 100, - total: 1, - total_pages: 1, - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - - const page = await knowhereDemoApi.fetchChunkPage({ - demoSourceId: "demo-tsla-q4-2025", - page: 1, - pageSize: 100, - }) - - expect(page.chunks[0]).toMatchObject({ - id: "demo-tsla-q4-2025:chunk-empty", - content: "", - }) - expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith("max") - expect(nextCacheMocks.cacheTag).toHaveBeenCalledWith( - "demo-chunks", - "demo-tsla-q4-2025", - ) - }) - - it("maps Official Library metadata from the demo catalog", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - sources: [ - { - demo_source_id: "demo-spacex-s1", - canonical_document_id: "demo-doc-spacex-s1", - title: "spacex-s1.pdf", - mime_type: "application/pdf", - size_bytes: 7441414, - status: "ready", - chunk_count: 922, - original_file: { - url: "/api/v1/demo/sources/demo-spacex-s1/original", - mime_type: "application/pdf", - size_bytes: 7441414, - can_download: false, - }, - official_library: { - library_source_id: "financial-spacex-s1", - category_id: "financial-reports", - title: "spacex-s1.pdf", - source_url: "https://data.olivierroy.dev/spacex-s1.pdf", - mime_type: "application/pdf", - status: "ready", - demo_source_id: "demo-spacex-s1", - }, - examples: [], - }, - ], - official_library: { - categories: [ - { - category_id: "financial-reports", - label: "Financial reports", - description: "Company filings.", - }, - ], - sources: [ - { - library_source_id: "financial-spacex-s1", - category_id: "financial-reports", - title: "spacex-s1.pdf", - source_url: "https://data.olivierroy.dev/spacex-s1.pdf", - mime_type: "application/pdf", - status: "ready", - demo_source_id: "demo-spacex-s1", - canonical_document_id: "demo-doc-spacex-s1", - size_bytes: 7441414, - chunk_count: 922, - }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - - const catalog = await knowhereDemoApi.fetchCatalog() - - expect(catalog.sources[0]?.officialLibrary).toMatchObject({ - librarySourceId: "financial-spacex-s1", - categoryId: "financial-reports", - demoSourceId: "demo-spacex-s1", - }) - expect(catalog.officialLibrary.sources[0]).toMatchObject({ - librarySourceId: "financial-spacex-s1", - status: "ready", - chunkCount: 922, - }) - }) -}) - -function restoreEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key] - return - } - - process.env[key] = value -} diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts deleted file mode 100644 index 8507dab..0000000 --- a/src/integrations/knowhere-demo.ts +++ /dev/null @@ -1,624 +0,0 @@ -import "server-only" - -import { Effect, Schema } from "effect" -import { cacheLife, cacheTag } from "next/cache" - -export type DemoCitation = { - readonly demoSourceId: string - readonly canonicalDocumentId: string - readonly canonicalChunkId: string - readonly chunkId: string - readonly chunkType: string - readonly content: string - readonly description?: string - readonly source: { - readonly documentId: string - readonly sourceFileName: string - readonly sectionPath: string - } -} - -export type DemoExample = { - readonly id: string - readonly question: string - readonly answer: string - readonly citations: readonly DemoCitation[] -} - -export type DemoSource = { - readonly demoSourceId: string - readonly canonicalDocumentId: string - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly status: "ready" - readonly chunkCount: number - readonly originalFile: { - readonly url: string - readonly mimeType: string - readonly sizeBytes: number - readonly canDownload: boolean - } - readonly officialLibrary?: OfficialLibrarySource - readonly examples: readonly DemoExample[] -} - -export type DemoCatalog = { - readonly sources: readonly DemoSource[] - readonly officialLibrary: OfficialLibraryCatalog -} - -export type OfficialLibraryCategory = { - readonly categoryId: string - readonly label: string - readonly description: string -} - -export type OfficialLibrarySource = { - readonly librarySourceId: string - readonly categoryId: string - readonly title: string - readonly sourceUrl: string - readonly mimeType: string - readonly status: "ready" | "planned" - readonly demoSourceId?: string - readonly canonicalDocumentId?: string - readonly sizeBytes?: number - readonly chunkCount?: number -} - -export type OfficialLibraryCatalog = { - readonly categories: readonly OfficialLibraryCategory[] - readonly sources: readonly OfficialLibrarySource[] -} - -export type DemoChunk = { - readonly id: string - readonly chunkId: string - readonly chunkType: string - readonly content: string - readonly sectionPath?: string | null - readonly sourceChunkPath?: string | null - readonly filePath?: string | null - readonly sortOrder: number - readonly metadata: Readonly> - readonly assetUrl?: string | null -} - -export type DemoChunkPage = { - readonly demoSourceId: string - readonly canonicalDocumentId: string - readonly title: string - readonly mimeType: string - readonly chunks: readonly DemoChunk[] - readonly pagination: { - readonly page: number - readonly pageSize: number - readonly total: number - readonly totalPages: number - } -} - -export type MaterializedDemoSource = { - readonly demoSourceId: string - readonly documentId: string - readonly status: "created" | "existing" - readonly title: string - readonly mimeType: string - readonly sizeBytes: number - readonly chunkCount: number - readonly originalFile: { - readonly url: string - readonly mimeType: string - readonly sizeBytes: number - readonly canDownload: boolean - } -} - -type DemoCatalogResponse = { - readonly sources?: readonly DemoSourceResponse[] - readonly official_library?: OfficialLibraryCatalogResponse -} - -type DemoSourceResponse = { - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly title?: unknown - readonly mime_type?: unknown - readonly size_bytes?: unknown - readonly status?: unknown - readonly chunk_count?: unknown - readonly original_file?: DemoOriginalFileResponse - readonly official_library?: OfficialLibrarySourceResponse - readonly examples?: readonly DemoExampleResponse[] -} - -type DemoOriginalFileResponse = { - readonly url?: unknown - readonly mime_type?: unknown - readonly size_bytes?: unknown - readonly can_download?: unknown -} - -type DemoExampleResponse = { - readonly id?: unknown - readonly question?: unknown - readonly answer?: unknown - readonly citations?: readonly DemoCitationResponse[] -} - -type DemoCitationResponse = { - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly canonical_chunk_id?: unknown - readonly chunk_id?: unknown - readonly chunk_type?: unknown - readonly content?: unknown - readonly description?: unknown - readonly source?: { - readonly document_id?: unknown - readonly source_file_name?: unknown - readonly section_path?: unknown - } -} - -type DemoChunkPageResponse = { - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly title?: unknown - readonly mime_type?: unknown - readonly chunks?: readonly DemoChunkResponse[] - readonly pagination?: { - readonly page?: unknown - readonly page_size?: unknown - readonly total?: unknown - readonly total_pages?: unknown - } -} - -type DemoChunkResponse = { - readonly id?: unknown - readonly chunk_id?: unknown - readonly chunk_type?: unknown - readonly content?: unknown - readonly section_path?: unknown - readonly source_chunk_path?: unknown - readonly file_path?: unknown - readonly sort_order?: unknown - readonly metadata?: unknown - readonly asset_url?: unknown -} - -type OfficialLibraryCatalogResponse = { - readonly categories?: readonly OfficialLibraryCategoryResponse[] - readonly sources?: readonly OfficialLibrarySourceResponse[] -} - -type OfficialLibraryCategoryResponse = { - readonly category_id?: unknown - readonly label?: unknown - readonly description?: unknown -} - -type OfficialLibrarySourceResponse = { - readonly library_source_id?: unknown - readonly category_id?: unknown - readonly title?: unknown - readonly source_url?: unknown - readonly mime_type?: unknown - readonly status?: unknown - readonly demo_source_id?: unknown - readonly canonical_document_id?: unknown - readonly size_bytes?: unknown - readonly chunk_count?: unknown -} - -type MaterializeResponse = { - readonly sources?: readonly MaterializedDemoSourceResponse[] -} - -type MaterializedDemoSourceResponse = { - readonly demo_source_id?: unknown - readonly document_id?: unknown - readonly status?: unknown - readonly title?: unknown - readonly mime_type?: unknown - readonly size_bytes?: unknown - readonly chunk_count?: unknown - readonly original_file?: DemoOriginalFileResponse -} - -const DEFAULT_KNOWHERE_BASE_URL = "https://api.knowhereto.ai" - -const emptyCatalog: DemoCatalog = { - sources: [], - officialLibrary: { categories: [], sources: [] }, -} - -// --------------------------------------------------------------------------- -// Effect core -// --------------------------------------------------------------------------- - -const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () { - const response = yield* Effect.tryPromise(() => - fetch(resolveApiURL("/api/v1/demo/catalog")), - ) - yield* assertOkEffect(response) - - const body = (yield* Effect.tryPromise(() => - response.json(), - )) as DemoCatalogResponse - return { - sources: (body.sources ?? []).map(toDemoSource), - officialLibrary: toOfficialLibraryCatalog(body.official_library), - } -}) - -const fetchChunkPageEffect = Effect.fn("knowhereDemo.fetchChunkPage")( - function* (input: { - readonly demoSourceId: string - readonly page: number - readonly pageSize: number - }) { - const params = new URLSearchParams({ - page: String(input.page), - page_size: String(input.pageSize), - }) - const response = yield* Effect.tryPromise(() => - fetch( - resolveApiURL( - `/api/v1/demo/sources/${encodeURIComponent(input.demoSourceId)}/chunks?${params.toString()}`, - ), - ), - ) - yield* assertOkEffect(response) - - return toDemoChunkPage( - (yield* Effect.tryPromise(() => - response.json(), - )) as DemoChunkPageResponse, - ) - }, -) - -const materializeSourcesEffect = Effect.fn("knowhereDemo.materializeSources")( - function* (input: { - readonly apiKey: string - readonly namespace: string - readonly demoSourceIds: readonly string[] - }) { - const requestBody = yield* Schema.encode(MaterializeSourcesRequestJson)({ - namespace: input.namespace, - demo_source_ids: input.demoSourceIds, - }) - const response = yield* Effect.tryPromise(() => - fetch(resolveApiURL("/api/v1/demo/materializations"), { - method: "POST", - headers: { - authorization: `Bearer ${input.apiKey}`, - "content-type": "application/json", - }, - body: requestBody, - }), - ) - yield* assertOkEffect(response) - - const body = (yield* Effect.tryPromise(() => - response.json(), - )) as MaterializeResponse - return (body.sources ?? []).map(toMaterializedDemoSource) - }, -) - -const MaterializeSourcesRequestJson = Schema.parseJson( - Schema.Struct({ - namespace: Schema.String, - demo_source_ids: Schema.Array(Schema.String), - }), -) - -const fetchOptionalCatalogEffect = ( - fetcher?: () => Effect.Effect, -) => - (fetcher ?? fetchCatalogEffect)().pipe( - Effect.catchAll(() => Effect.succeed(emptyCatalog)), - ) - -// --------------------------------------------------------------------------- -// Async wrappers (backward-compatible) -// --------------------------------------------------------------------------- - -async function fetchCatalog(): Promise { - "use cache" - cacheLife("max") - cacheTag("demo-catalog") - - return Effect.runPromise(fetchCatalogEffect()) -} - -async function fetchOptionalCatalog( - fetcher?: () => Promise, -): Promise { - const effectFetcher = fetcher - ? () => - Effect.tryPromise(() => fetcher()).pipe( - Effect.catchAll(() => Effect.succeed(emptyCatalog)), - ) - : undefined - return Effect.runPromise(fetchOptionalCatalogEffect(effectFetcher)) -} - -async function fetchChunkPage(input: { - readonly demoSourceId: string - readonly page: number - readonly pageSize: number -}): Promise { - "use cache" - cacheLife("max") - cacheTag("demo-chunks", input.demoSourceId) - - return Effect.runPromise(fetchChunkPageEffect(input)) -} - -async function materializeSources(input: { - readonly apiKey: string - readonly namespace: string - readonly demoSourceIds: readonly string[] -}): Promise { - return Effect.runPromise(materializeSourcesEffect(input)) -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export const knowhereDemoApi = { - fetchCatalog, - fetchOptionalCatalog, - fetchChunkPage, - materializeSources, - resolveApiURL, -} as const - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function resolveApiURL(path: string): string { - const baseURL = process.env.KNOWHERE_BASE_URL ?? DEFAULT_KNOWHERE_BASE_URL - return new URL(path, baseURL).toString() -} - -class KnowhereDemoApiError { - readonly _tag = "KnowhereDemoApiError" - constructor( - readonly status: number, - readonly body: string, - ) {} -} - -function assertOkEffect( - response: Response, -): Effect.Effect { - if (response.ok) return Effect.void - - return Effect.gen(function* () { - const body = yield* Effect.tryPromise(() => - response.text().catch(() => ""), - ).pipe(Effect.orDie) - return yield* Effect.fail( - new KnowhereDemoApiError(response.status, body), - ) - }) -} - -function toDemoSource(source: DemoSourceResponse): DemoSource { - const officialLibrary = source.official_library - ? toOfficialLibrarySource(source.official_library) - : undefined - return { - demoSourceId: requireString(source.demo_source_id), - canonicalDocumentId: requireString(source.canonical_document_id), - title: requireString(source.title), - mimeType: requireString(source.mime_type), - sizeBytes: requireNumber(source.size_bytes), - status: "ready", - chunkCount: requireNumber(source.chunk_count), - originalFile: toOriginalFile(source.original_file), - ...(officialLibrary ? { officialLibrary } : {}), - examples: (source.examples ?? []).map(toDemoExample), - } -} - -function toDemoExample(example: DemoExampleResponse): DemoExample { - return { - id: requireString(example.id), - question: requireString(example.question), - answer: requireString(example.answer), - citations: (example.citations ?? []).map(toDemoCitation), - } -} - -function toDemoCitation(citation: DemoCitationResponse): DemoCitation { - const source = citation.source ?? {} - const description = optionalString(citation.description) - return { - demoSourceId: requireString(citation.demo_source_id), - canonicalDocumentId: requireString(citation.canonical_document_id), - canonicalChunkId: requireString(citation.canonical_chunk_id), - chunkId: requireString(citation.chunk_id), - chunkType: requireString(citation.chunk_type), - content: requireString(citation.content), - ...(description ? { description } : {}), - source: { - documentId: requireString(source.document_id), - sourceFileName: requireString(source.source_file_name), - sectionPath: requireString(source.section_path), - }, - } -} - -function toDemoChunkPage(response: DemoChunkPageResponse): DemoChunkPage { - const pagination = response.pagination ?? {} - return { - demoSourceId: requireString(response.demo_source_id), - canonicalDocumentId: requireString(response.canonical_document_id), - title: requireString(response.title), - mimeType: requireString(response.mime_type), - chunks: (response.chunks ?? []).map((chunk) => - toDemoChunk(requireString(response.demo_source_id), chunk), - ), - pagination: { - page: requireNumber(pagination.page), - pageSize: requireNumber(pagination.page_size), - total: requireNumber(pagination.total), - totalPages: requireNumber(pagination.total_pages), - }, - } -} - -function toDemoChunk( - demoSourceId: string, - chunk: DemoChunkResponse, -): DemoChunk { - return { - id: requireString(chunk.id), - chunkId: requireString(chunk.chunk_id), - chunkType: requireString(chunk.chunk_type), - content: requireContentString(chunk.content), - sectionPath: optionalString(chunk.section_path) ?? null, - sourceChunkPath: optionalString(chunk.source_chunk_path) ?? null, - filePath: optionalString(chunk.file_path) ?? null, - sortOrder: requireNumber(chunk.sort_order), - metadata: toRecord(chunk.metadata), - assetUrl: toDemoAssetUrl(demoSourceId, optionalString(chunk.asset_url)), - } -} - -function toMaterializedDemoSource( - source: MaterializedDemoSourceResponse, -): MaterializedDemoSource { - const status = requireString(source.status) - return { - demoSourceId: requireString(source.demo_source_id), - documentId: requireString(source.document_id), - status: status === "existing" ? "existing" : "created", - title: requireString(source.title), - mimeType: requireString(source.mime_type), - sizeBytes: requireNumber(source.size_bytes), - chunkCount: requireNumber(source.chunk_count), - originalFile: toOriginalFile(source.original_file), - } -} - -function toOfficialLibraryCatalog( - input: OfficialLibraryCatalogResponse | undefined, -): OfficialLibraryCatalog { - const officialLibrary = input ?? {} - return { - categories: (officialLibrary.categories ?? []).map( - toOfficialLibraryCategory, - ), - sources: (officialLibrary.sources ?? []).map(toOfficialLibrarySource), - } -} - -function toOfficialLibraryCategory( - category: OfficialLibraryCategoryResponse, -): OfficialLibraryCategory { - return { - categoryId: requireString(category.category_id), - label: requireString(category.label), - description: requireString(category.description), - } -} - -function toOfficialLibrarySource( - source: OfficialLibrarySourceResponse, -): OfficialLibrarySource { - const status = requireString(source.status) - const demoSourceId = optionalString(source.demo_source_id) - const canonicalDocumentId = optionalString(source.canonical_document_id) - const sizeBytes = optionalNumber(source.size_bytes) - const chunkCount = optionalNumber(source.chunk_count) - return { - librarySourceId: requireString(source.library_source_id), - categoryId: requireString(source.category_id), - title: requireString(source.title), - sourceUrl: requireString(source.source_url), - mimeType: requireString(source.mime_type), - status: status === "ready" ? "ready" : "planned", - ...(demoSourceId ? { demoSourceId } : {}), - ...(canonicalDocumentId ? { canonicalDocumentId } : {}), - ...(sizeBytes !== undefined ? { sizeBytes } : {}), - ...(chunkCount !== undefined ? { chunkCount } : {}), - } -} - -function toOriginalFile( - input: DemoOriginalFileResponse | undefined, -): DemoSource["originalFile"] { - const originalFile = input ?? {} - return { - url: requireString(originalFile.url), - mimeType: requireString(originalFile.mime_type), - sizeBytes: requireNumber(originalFile.size_bytes), - canDownload: originalFile.can_download === true, - } -} - -function requireString(value: unknown): string { - if (typeof value === "string" && value.trim().length > 0) { - return value - } - throw new Error("Expected non-empty string from Knowhere demo API.") -} - -function requireContentString(value: unknown): string { - if (typeof value === "string") return value - throw new Error("Expected string content from Knowhere demo API.") -} - -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 - ? value - : undefined -} - -function requireNumber(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - throw new Error("Expected finite number from Knowhere demo API.") -} - -function optionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function toRecord(value: unknown): Readonly> { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return {} - } - return value as Readonly> -} - -function toDemoAssetUrl( - demoSourceId: string, - assetUrl: string | undefined, -): string | null { - if (!assetUrl) return null - - const assetPath = extractDemoAssetPath(assetUrl) - if (!assetPath) return null - - return `/api/demo-sources/${encodeURIComponent(demoSourceId)}/assets/${assetPath}` -} - -function extractDemoAssetPath(assetUrl: string): string | null { - const marker = "/assets/" - const markerIndex = assetUrl.indexOf(marker) - if (markerIndex === -1) return null - - return assetUrl.slice(markerIndex + marker.length) -} diff --git a/src/integrations/knowhere-keys.test.ts b/src/integrations/knowhere-keys.test.ts new file mode 100644 index 0000000..f899b0f --- /dev/null +++ b/src/integrations/knowhere-keys.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + getDefaultKnowhereKeyLabel, + getKnowhereKeyByLabel, + listKnowhereKeys, + listMaskedKnowhereKeys, + maskApiKey, +} from "./knowhere-keys" + +describe("knowhere-keys", () => { + let tempDir: string + const originalEnv = { ...process.env } + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "knowhere-keys-test-")) + delete process.env.KNOWHERE_KEYS_FILE + delete process.env.KNOWHERE_API_KEY + vi.resetModules() + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + process.env.KNOWHERE_KEYS_FILE = originalEnv.KNOWHERE_KEYS_FILE + process.env.KNOWHERE_API_KEY = originalEnv.KNOWHERE_API_KEY + }) + + it("falls back to KNOWHERE_API_KEY env as a single 'default' key", async () => { + process.env.KNOWHERE_API_KEY = "sk_env_key_123" + + expect(await listKnowhereKeys()).toEqual([ + { label: "default", apiKey: "sk_env_key_123" }, + ]) + expect(await getDefaultKnowhereKeyLabel()).toBe("default") + expect(await getKnowhereKeyByLabel("default")).toEqual({ + label: "default", + apiKey: "sk_env_key_123", + }) + }) + + it("returns no keys when neither env nor file is configured", async () => { + expect(await listKnowhereKeys()).toEqual([]) + expect(await getDefaultKnowhereKeyLabel()).toBe("default") + expect(await getKnowhereKeyByLabel("default")).toBeNull() + }) + + it("reads labeled keys from the keys file", async () => { + const filePath = join(tempDir, "keys.json") + await writeFile( + filePath, + JSON.stringify([ + { label: "domainA", apiKey: "sk_a_1" }, + { label: "domainB", apiKey: "sk_b_2" }, + ]), + ) + process.env.KNOWHERE_KEYS_FILE = filePath + + expect(await listKnowhereKeys()).toEqual([ + { label: "domainA", apiKey: "sk_a_1" }, + { label: "domainB", apiKey: "sk_b_2" }, + ]) + expect(await getDefaultKnowhereKeyLabel()).toBe("domainA") + expect(await getKnowhereKeyByLabel("domainB")).toEqual({ + label: "domainB", + apiKey: "sk_b_2", + }) + expect(await getKnowhereKeyByLabel("missing")).toBeNull() + }) + + it("re-reads the file when its mtime changes (no-restart edits)", async () => { + const filePath = join(tempDir, "keys.json") + await writeFile(filePath, JSON.stringify([{ label: "a", apiKey: "sk_1" }])) + process.env.KNOWHERE_KEYS_FILE = filePath + + expect(await listKnowhereKeys()).toEqual([{ label: "a", apiKey: "sk_1" }]) + + // Give the file a different mtime so the cache invalidates. + await new Promise((resolve) => setTimeout(resolve, 1100)) + await writeFile( + filePath, + JSON.stringify([ + { label: "a", apiKey: "sk_1" }, + { label: "b", apiKey: "sk_2" }, + ]), + ) + + expect(await listKnowhereKeys()).toEqual([ + { label: "a", apiKey: "sk_1" }, + { label: "b", apiKey: "sk_2" }, + ]) + }) + + it("ignores malformed entries and a missing file", async () => { + const filePath = join(tempDir, "keys.json") + await writeFile( + filePath, + JSON.stringify([ + { label: "ok", apiKey: "sk_ok" }, + { label: "", apiKey: "sk_no_label" }, + { label: "no-key" }, + "not-an-object", + ]), + ) + process.env.KNOWHERE_KEYS_FILE = filePath + + expect(await listKnowhereKeys()).toEqual([{ label: "ok", apiKey: "sk_ok" }]) + }) + + it("masks keys for display", () => { + expect(maskApiKey("sk_8aBdXbOvF_Qibah2-_BDNo1-VCd50A16CwfiremGVB8")).toBe( + "sk_8aB••••GVB8", + ) + expect(listMaskedKnowhereKeys).toBeTypeOf("function") + }) +}) diff --git a/src/integrations/knowhere-keys.ts b/src/integrations/knowhere-keys.ts new file mode 100644 index 0000000..8b652bf --- /dev/null +++ b/src/integrations/knowhere-keys.ts @@ -0,0 +1,113 @@ +import "server-only" + +import { readFile, stat } from "node:fs/promises" + +export type KnowhereKey = { + readonly label: string + readonly apiKey: string +} + +export type MaskedKnowhereKey = { + readonly label: string + readonly mask: string +} + +/** + * Source of Knowhere API keys (server-side only). + * + * Priority: + * 1. `config/knowhere-keys.json` (path from `KNOWHERE_KEYS_FILE`, default + * `./config/knowhere-keys.json`) — an array of `{ label, apiKey }`. + * Re-read when the file mtime changes, so edits take effect without a + * restart. + * 2. Fallback: the `KNOWHERE_API_KEY` env var as a single key labeled + * `"default"` (bootstrap for fresh deployments before UI-managed DB + * keys are added). + * + * The edge proxy only checks the session cookie; server code resolves + * credentials through this module or the DB-backed + * `knowhere-api-keys-repository`. + */ +const defaultKeysFilePath = "./config/knowhere-keys.json" + +let cachedFileKeys: readonly KnowhereKey[] | null = null +let cachedFileMtimeMs: number | null = null + +async function readKeysFile(): Promise { + const path = process.env.KNOWHERE_KEYS_FILE?.trim() || defaultKeysFilePath + + try { + const fileStat = await stat(path) + if (cachedFileMtimeMs === fileStat.mtimeMs && cachedFileKeys !== null) { + return cachedFileKeys + } + + const raw = await readFile(path, "utf8") + const keys = normalizeFileKeys(JSON.parse(raw)) + cachedFileMtimeMs = fileStat.mtimeMs + cachedFileKeys = keys + return keys + } catch { + return [] + } +} + +function normalizeFileKeys(value: unknown): readonly KnowhereKey[] { + if (!Array.isArray(value)) return [] + const keys: KnowhereKey[] = [] + for (const entry of value) { + if (typeof entry !== "object" || entry === null) continue + const candidate = entry as Record + const label = typeof candidate.label === "string" ? candidate.label.trim() : "" + const apiKey = + typeof candidate.apiKey === "string" ? candidate.apiKey.trim() : "" + if (label.length === 0 || apiKey.length === 0) continue + keys.push({ label, apiKey }) + } + return keys +} + +function getEnvKey(): KnowhereKey | null { + const value = process.env.KNOWHERE_API_KEY?.trim() + if (!value || value.length === 0) return null + return { label: "default", apiKey: value } +} + +export async function listKnowhereKeys(): Promise { + const fileKeys = await readKeysFile() + if (fileKeys.length > 0) return fileKeys + + const envKey = getEnvKey() + return envKey ? [envKey] : [] +} + +export async function listMaskedKnowhereKeys(): Promise< + readonly MaskedKnowhereKey[] +> { + const keys = await listKnowhereKeys() + return keys.map((key) => ({ label: key.label, mask: maskApiKey(key.apiKey) })) +} + +export async function getKnowhereKeyByLabel( + label: string, +): Promise { + const normalized = label?.trim() + if (!normalized) return null + const keys = await listKnowhereKeys() + return keys.find((candidate) => candidate.label === normalized) ?? null +} + +export async function getDefaultKnowhereKeyLabel(): Promise { + const keys = await listKnowhereKeys() + return keys[0]?.label ?? "default" +} + +export async function getDefaultKnowhereKey(): Promise { + const keys = await listKnowhereKeys() + return keys[0] ?? null +} + +export function maskApiKey(apiKey: string): string { + if (apiKey.length <= 12) return `${apiKey.slice(0, 4)}••••` + return `${apiKey.slice(0, 6)}••••${apiKey.slice(-4)}` +} diff --git a/src/integrations/knowhere.ts b/src/integrations/knowhere.ts index 81f6726..daac9d5 100644 --- a/src/integrations/knowhere.ts +++ b/src/integrations/knowhere.ts @@ -14,6 +14,64 @@ export function makeKnowhereClient(apiKey: string): Knowhere { return wrapKnowhereClient(client) } +export type KnowhereNamespace = { + readonly namespace: string + readonly documentCount: number +} + +/** + * List all namespaces from the Knowhere API. + * The SDK does not expose this endpoint, so we call it directly. + */ +export async function listKnowhereNamespaces( + apiKey: string, +): Promise { + const baseURL = process.env.KNOWHERE_BASE_URL ?? "https://api.knowhere.com" + const response = await fetch(`${baseURL}/v1/documents/namespaces`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (!response.ok) { + logger.warn("knowhere: listNamespaces failed", { + status: response.status, + }) + return [] + } + const data = (await response.json()) as unknown + const items = Array.isArray(data) + ? data + : (data as { namespaces?: unknown[] })?.namespaces + if (!Array.isArray(items)) return [] + return items + .filter( + (item): item is { namespace: string; document_count?: number; documentCount?: number } => + typeof item === "object" && + item !== null && + typeof (item as { namespace?: unknown }).namespace === "string", + ) + .map((item) => ({ + namespace: item.namespace, + documentCount: item.documentCount ?? item.document_count ?? 0, + })) +} + +/** + * Probe whether an API key is valid by listing namespaces. 200 → valid; + * 401/403 (or any failure) → invalid. Used when a user adds a key. + */ +export async function validateKnowhereApiKey( + apiKey: string, +): Promise { + const baseURL = process.env.KNOWHERE_BASE_URL ?? "https://api.knowhere.com" + try { + const response = await fetch(`${baseURL}/v1/documents/namespaces`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + return response.ok + } catch { + return false + } +} + function wrapKnowhereClient(client: Knowhere): Knowhere { return new Proxy(client, { get(target, prop, receiver) { diff --git a/src/lib/ai.test.ts b/src/lib/ai.test.ts new file mode 100644 index 0000000..3423a33 --- /dev/null +++ b/src/lib/ai.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { getChatModel, getChatModelLabel, isChatConfigured } from "./ai" + +const original = { + AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY, + CHAT_BASE_URL: process.env.CHAT_BASE_URL, + CHAT_MODEL: process.env.CHAT_MODEL, + CHAT_API_KEY: process.env.CHAT_API_KEY, +} + +beforeEach(() => { + delete process.env.AI_GATEWAY_API_KEY + delete process.env.CHAT_BASE_URL + delete process.env.CHAT_MODEL + delete process.env.CHAT_API_KEY +}) + +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe("isChatConfigured", () => { + it("is false when no chat env is set", () => { + expect(isChatConfigured()).toBe(false) + }) + + it("is true when AI_GATEWAY_API_KEY is set", () => { + process.env.AI_GATEWAY_API_KEY = "vck_test" + expect(isChatConfigured()).toBe(true) + }) + + it("is true when CHAT_BASE_URL is set", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + expect(isChatConfigured()).toBe(true) + }) +}) + +describe("getChatModel", () => { + it("returns the gateway model string when CHAT_BASE_URL is unset", () => { + expect(getChatModel()).toBe("google/gemini-3-flash") + }) + + it("builds an OpenAI-compatible model from CHAT_BASE_URL + CHAT_MODEL + CHAT_API_KEY", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + process.env.CHAT_MODEL = "qwen-plus" + process.env.CHAT_API_KEY = "sk_test" + + const model = getChatModel() + + expect(typeof model).toBe("object") + expect((model as { readonly modelId: string }).modelId).toBe("qwen-plus") + expect( + (model as { readonly specificationVersion: string }).specificationVersion, + ).toBe("v3") + }) + + it("throws when CHAT_BASE_URL is set without CHAT_MODEL", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + delete process.env.CHAT_MODEL + delete process.env.CHAT_API_KEY + + expect(() => getChatModel()).toThrow(/CHAT_MODEL is required/) + }) + + it("throws when CHAT_BASE_URL is set without CHAT_API_KEY", () => { + process.env.CHAT_BASE_URL = "http://localhost:11434/v1" + process.env.CHAT_MODEL = "qwen-plus" + delete process.env.CHAT_API_KEY + + expect(() => getChatModel()).toThrow(/CHAT_API_KEY is required/) + }) +}) + +describe("getChatModelLabel", () => { + it("returns the default model id when CHAT_MODEL is unset", () => { + expect(getChatModelLabel()).toBe("google/gemini-3-flash") + }) +}) diff --git a/src/lib/ai.ts b/src/lib/ai.ts index b1313ff..483752d 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,12 +1,69 @@ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" + /** - * Server-side AI configuration — routes through the Vercel AI Gateway. + * Server-side AI configuration. + * + * Two mutually exclusive chat backends, selected by environment: + * + * 1. Vercel AI Gateway (default): set `AI_GATEWAY_API_KEY`. The model is passed + * to the AI SDK as a plain string (e.g. "google/gemini-3-flash"); the SDK + * resolves it through the Gateway and reads the key automatically. Override + * the model with `CHAT_MODEL`. * - * The Gateway gives us one key, usage monitoring, and easy provider/model - * swaps without code changes. The AI SDK picks up `AI_GATEWAY_API_KEY` - * automatically when a model is passed as a plain string like - * `"google/gemini-3-flash"`, so this module just owns the model choice. + * 2. Generic OpenAI-compatible API: set `CHAT_BASE_URL` (plus `CHAT_API_KEY`). + * The model is built as a LanguageModelV3 against that base URL, so any + * OpenAI-compatible endpoint (local LLM, self-hosted gateway, etc.) works + * without the Vercel AI Gateway. `CHAT_MODEL` is MANDATORY in this mode. + */ + +const DEFAULT_GATEWAY_MODEL = "google/gemini-3-flash" + +/** Model id string, used as the Gateway model and as a log label. */ +export const CHAT_MODEL = process.env.CHAT_MODEL ?? DEFAULT_GATEWAY_MODEL + +/** + * True when chat is wired up: either the Vercel AI Gateway key is set, or the + * OpenAI-compatible `CHAT_BASE_URL` is set. + */ +export function isChatConfigured(): boolean { + return ( + Boolean(process.env.AI_GATEWAY_API_KEY?.trim()) || + Boolean(process.env.CHAT_BASE_URL?.trim()) + ) +} + +/** + * Resolve the model to pass to AI SDK calls (`generateObject`, `ToolLoopAgent`). * - * Change CHAT_MODEL here (or via env) when we want to try a different model. + * - OpenAI-compatible mode (`CHAT_BASE_URL` set): returns a `LanguageModelV3` + * built from `CHAT_BASE_URL` + `CHAT_API_KEY`. Requires `CHAT_MODEL`. + * - Gateway mode (default): returns the plain model id string; the AI SDK + * resolves it via the Vercel AI Gateway using `AI_GATEWAY_API_KEY`. */ +export function getChatModel() { + const baseURL = process.env.CHAT_BASE_URL?.trim() + if (baseURL) { + const modelId = process.env.CHAT_MODEL?.trim() + if (!modelId) { + throw new Error( + "CHAT_MODEL is required when CHAT_BASE_URL is set. Provide the model " + + "id your OpenAI-compatible endpoint exposes.", + ) + } + const apiKey = process.env.CHAT_API_KEY?.trim() + if (!apiKey) { + throw new Error("CHAT_API_KEY is required when CHAT_BASE_URL is set.") + } + return createOpenAICompatible({ + name: "chat", + baseURL, + apiKey, + }).chatModel(modelId) + } + return CHAT_MODEL +} -export const CHAT_MODEL = process.env.CHAT_MODEL ?? "google/gemini-3-flash" +/** Stable model label for logs, regardless of backend. */ +export function getChatModelLabel(): string { + return CHAT_MODEL +} diff --git a/src/lib/password.test.ts b/src/lib/password.test.ts new file mode 100644 index 0000000..e08f134 --- /dev/null +++ b/src/lib/password.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" + +import { hashPassword, verifyPassword } from "./password" + +describe("password", () => { + it("round-trips a password through hash and verify", async () => { + const passwordHash = await hashPassword("correct horse battery staple") + + expect(passwordHash).toContain("$argon2id$") + expect(await verifyPassword("correct horse battery staple", passwordHash)).toBe( + true, + ) + }) + + it("rejects a wrong password", async () => { + const passwordHash = await hashPassword("right-password") + + expect(await verifyPassword("wrong-password", passwordHash)).toBe(false) + }) + + it("returns false for a malformed hash instead of throwing", async () => { + expect(await verifyPassword("anything", "not-a-valid-hash")).toBe(false) + }) +}) diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..03046ed --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,25 @@ +import "server-only" + +import { hash, verify } from "@node-rs/argon2" + +/** Argon2id defaults tuned for interactive login (≈1s on modern hardware). */ +const passwordHashOptions = { + memoryCost: 19456, // 19 MiB + timeCost: 2, + parallelism: 1, +} as const + +export async function hashPassword(password: string): Promise { + return hash(password, passwordHashOptions) +} + +export async function verifyPassword( + password: string, + passwordHash: string, +): Promise { + try { + return await verify(passwordHash, password, passwordHashOptions) + } catch { + return false + } +} diff --git a/src/lib/posthog.test.ts b/src/lib/posthog.test.ts index 0d4304f..ede4006 100644 --- a/src/lib/posthog.test.ts +++ b/src/lib/posthog.test.ts @@ -93,16 +93,13 @@ describe("posthog", () => { window.history.pushState({}, "", "/workspace/guest"); resetGuest(); - await trackView({ - isGuest: true, - }); + await trackView(); expect(mocks.reset).toHaveBeenCalledOnce(); expect(mocks.capture).toHaveBeenCalledWith( "$pageview", expect.objectContaining({ $pathname: "/workspace/guest", - is_guest: true, }), ); expect(mocks.reset.mock.invocationCallOrder[0]).toBeLessThan( @@ -119,7 +116,6 @@ describe("posthog", () => { await initClient(); await trackView({ workspaceId: "ws_1", - isGuest: false, }); expect(mocks.capture).toHaveBeenCalledWith( @@ -128,7 +124,6 @@ describe("posthog", () => { $current_url: window.location.href, $pathname: "/workspace/test", workspace_id: "ws_1", - is_guest: false, }), ); const payload = mocks.capture.mock.calls[0]?.[1] as diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 65baf5c..86aad34 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -9,7 +9,6 @@ export type AnalyticsContext = { readonly workspaceId?: string; readonly workspaceNamespace?: string; readonly userId?: string; - readonly isGuest?: boolean; }; type AnalyticsEnvelope = { @@ -66,7 +65,6 @@ function buildBaseProperties(context?: AnalyticsContext): Properties { workspace_id: context?.workspaceId, workspace_namespace: context?.workspaceNamespace, user_id: context?.userId, - is_guest: context?.isGuest, }; } @@ -142,22 +140,6 @@ export function trackNotebookAssistantQuestionSubmitted(input: { }); } -export function trackNotebookDashboardLinkClicked(input: { - readonly context?: AnalyticsContext; - readonly targetUrl: string; - readonly fromPage: string; - readonly hasSources: boolean; - readonly hasChats: boolean; -}): Promise { - return trackEvent("notebook_dashboard_link_clicked", { - ...buildBaseProperties(input.context), - from_page: input.fromPage, - target_url: input.targetUrl, - has_sources: input.hasSources, - has_chats: input.hasChats, - }); -} - export function trackNotebookDocumentUploadFailed(input: { readonly context?: AnalyticsContext; readonly fileType: string | null; diff --git a/src/lib/secret-crypto.test.ts b/src/lib/secret-crypto.test.ts new file mode 100644 index 0000000..04baf88 --- /dev/null +++ b/src/lib/secret-crypto.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { decryptSecret, encryptSecret } from "./secret-crypto" + +describe("secret-crypto", () => { + const originalKey = process.env.KNOWHERE_KEY_ENCRYPTION_KEY + + beforeEach(() => { + process.env.KNOWHERE_KEY_ENCRYPTION_KEY = "test-encryption-key-1234567890" + }) + + afterEach(() => { + if (originalKey === undefined) delete process.env.KNOWHERE_KEY_ENCRYPTION_KEY + else process.env.KNOWHERE_KEY_ENCRYPTION_KEY = originalKey + }) + + it("round-trips a secret through encrypt and decrypt", () => { + const encrypted = encryptSecret("sk_super_secret_123") + + expect(encrypted.cipherText).not.toContain("super_secret") + expect(decryptSecret(encrypted)).toBe("sk_super_secret_123") + }) + + it("produces a different cipher for the same plaintext (random nonce)", () => { + const first = encryptSecret("sk_same") + const second = encryptSecret("sk_same") + + expect(first.cipherText).not.toBe(second.cipherText) + expect(first.nonce).not.toBe(second.nonce) + expect(decryptSecret(first)).toBe("sk_same") + expect(decryptSecret(second)).toBe("sk_same") + }) + + it("fails to decrypt tampered cipher text", () => { + const encrypted = encryptSecret("sk_secret") + const flippedChar = + encrypted.cipherText[0] === "A" ? "B" : "A" + const tampered = { + ...encrypted, + cipherText: `${flippedChar}${encrypted.cipherText.slice(1)}`, + } + + expect(() => decryptSecret(tampered)).toThrow() + }) + + it("throws when no encryption key is configured", () => { + delete process.env.KNOWHERE_KEY_ENCRYPTION_KEY + + expect(() => encryptSecret("sk_x")).toThrow(/KNOWHERE_KEY_ENCRYPTION_KEY/) + }) + + it("accepts a 32-byte base64 key directly", () => { + const base64Key = Buffer.from( + Array.from({ length: 32 }, (_, index) => index), + ).toString("base64") + process.env.KNOWHERE_KEY_ENCRYPTION_KEY = base64Key + + const encrypted = encryptSecret("sk_x") + expect(decryptSecret(encrypted)).toBe("sk_x") + }) +}) diff --git a/src/lib/secret-crypto.ts b/src/lib/secret-crypto.ts new file mode 100644 index 0000000..bc60672 --- /dev/null +++ b/src/lib/secret-crypto.ts @@ -0,0 +1,69 @@ +import "server-only" + +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from "node:crypto" + +/** + * AES-256-GCM encryption for secrets at rest (Knowhere API keys). + * + * The encryption key comes from `KNOWHERE_KEY_ENCRYPTION_KEY` env: if it is + * a 32-byte base64 string it is used directly, otherwise it is SHA-256-hashed + * to 32 bytes (so any string works). GCM auth tag is appended to the cipher + * text and verified on decrypt. + */ + +export type EncryptedSecret = { + readonly cipherText: string + readonly nonce: string +} + +function getEncryptionKey(): Buffer { + const configured = process.env.KNOWHERE_KEY_ENCRYPTION_KEY?.trim() + if (!configured || configured.length === 0) { + throw new Error( + "KNOWHERE_KEY_ENCRYPTION_KEY is required to store Knowhere API keys. " + + "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('base64'))\"", + ) + } + + // Accept either a raw 32-byte base64 key or any passphrase (hashed to 32B). + try { + const decoded = Buffer.from(configured, "base64") + if (decoded.length === 32) return decoded + } catch { + // fall through to hashing + } + return createHash("sha256").update(configured).digest() +} + +export function encryptSecret(plainText: string): EncryptedSecret { + const nonce = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), nonce) + const cipherText = Buffer.concat([ + cipher.update(plainText, "utf8"), + cipher.final(), + cipher.getAuthTag(), + ]) + return { + cipherText: cipherText.toString("base64"), + nonce: nonce.toString("base64"), + } +} + +export function decryptSecret(encrypted: EncryptedSecret): string { + const nonce = Buffer.from(encrypted.nonce, "base64") + const payload = Buffer.from(encrypted.cipherText, "base64") + + const authTag = payload.subarray(payload.length - 16) + const data = payload.subarray(0, payload.length - 16) + + const decipher = createDecipheriv("aes-256-gcm", getEncryptionKey(), nonce) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(data), decipher.final()]).toString( + "utf8", + ) +} diff --git a/src/proxy.test.ts b/src/proxy.test.ts index b965699..ee27e86 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -25,30 +25,22 @@ describe("proxy", () => { } }); - it("allows anonymous guest source reads", () => { + it("keeps anonymous source reads protected", () => { const sourcesResponse = proxy( new NextRequest("http://localhost:3001/api/sources"), ); const chunksResponse = proxy( new NextRequest( - "http://localhost:3001/api/sources/demo-tsla-q4-2025/chunks", + "http://localhost:3001/api/sources/source-1/chunks", ), ); - const originalResponse = proxy( - new NextRequest( - "http://localhost:3001/api/demo-sources/demo-tsla-q4-2025/original", - ), + + expect(sourcesResponse.headers.get("location")).toBe( + "http://localhost:3001/login", ); - const assetResponse = proxy( - new NextRequest( - "http://localhost:3001/api/demo-sources/demo-tsla-q4-2025/assets/images/image-1.jpg", - ), + expect(chunksResponse.headers.get("location")).toBe( + "http://localhost:3001/login", ); - - expect(sourcesResponse.headers.get("x-middleware-next")).toBe("1"); - expect(chunksResponse.headers.get("x-middleware-next")).toBe("1"); - expect(originalResponse.headers.get("x-middleware-next")).toBe("1"); - expect(assetResponse.headers.get("x-middleware-next")).toBe("1"); }); it("keeps anonymous source mutations protected", () => { @@ -63,15 +55,31 @@ describe("proxy", () => { ); }); - it("allows protected app routes without a session when KNOWHERE_API_KEY is configured", () => { - process.env.KNOWHERE_API_KEY = "sk_dev_key"; - + it("redirects protected routes to /login when no session cookie is present", () => { const response = proxy( new NextRequest("http://localhost:3001/api/sources/source-1", { method: "PATCH", }), ); - expect(response.headers.get("x-middleware-next")).toBe("1"); + expect(response.headers.get("location")).toBe( + "http://localhost:3001/login", + ); + }); + + it("lets anonymous auth routes through (login flows)", () => { + const oauthStart = proxy( + new NextRequest("http://localhost:3001/api/auth/google/start"), + ); + const oauthCallback = proxy( + new NextRequest("http://localhost:3001/api/auth/google/callback?code=x"), + ); + const dashboardStart = proxy( + new NextRequest("http://localhost:3001/api/auth/dashboard/start"), + ); + + expect(oauthStart.status).toBe(200); + expect(oauthCallback.status).toBe(200); + expect(dashboardStart.status).toBe(200); }); }); diff --git a/src/proxy.ts b/src/proxy.ts index 6177d3b..567ac4a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,25 +1,23 @@ import { NextResponse, type NextRequest } from "next/server" -import { authURLs } from "@/infrastructure/auth/urls" -import { sessionCookieNames } from "@/infrastructure/auth/session-cookie-names" -import { knowhereApiKeyOverride } from "@/integrations/knowhere-api-key" +import { notebookSessionCookieName } from "@/infrastructure/auth/session-cookie-constants" /** * Edge-runtime proxy (renamed from middleware.ts in Next.js 16). * * Purpose: cheap short-circuit for obviously-anonymous requests to * protected routes. If no session cookie is present, redirect to the - * Dashboard login page without making any DB or upstream calls. + * local login page without making any DB or upstream calls. * * This is NOT the authoritative auth check. A present cookie is never - * trusted here — the real verification happens in `src/infrastructure/auth`. - * via the Dashboard oRPC lookup. The proxy only catches the easy case - * where there's nothing to verify. + * trusted here — the real verification happens in `src/infrastructure/auth` + * via the DB session lookup. The proxy only catches the easy case where + * there's nothing to verify. */ /** * Routes that stay accessible without a session. Everything else under - * `/` is considered app-protected and will redirect to Dashboard login - * when no cookie is present. + * `/` is considered app-protected and will redirect to login when no + * cookie is present. */ const PUBLIC_PATHS: readonly string[] = [ "/", @@ -27,52 +25,25 @@ const PUBLIC_PATHS: readonly string[] = [ "/favicon.ico", "/api/internal/health", "/api/sources/reconcile", + "/api/auth", ] const STATIC_EXTENSIONS = /\.(?:svg|png|jpe?g|gif|webp|ico|woff2?|ttf|eot|css|js|map|txt|xml|webmanifest|json|pdf)$/i -const GUEST_SOURCE_CHUNKS_PATH = /^\/api\/sources\/[^/]+\/chunks$/u -const GUEST_DEMO_ORIGINAL_PATH = /^\/api\/demo-sources\/[^/]+\/original$/u -const GUEST_DEMO_ASSET_PATH = /^\/api\/demo-sources\/[^/]+\/assets\/.+$/u function isPublicPath(req: NextRequest): boolean { const pathname = req.nextUrl.pathname - if (isGuestSourceReadPath(req.method, pathname)) return true if (pathname.startsWith("/_next")) return true if (pathname.startsWith("/api/internal/")) return true if (STATIC_EXTENSIONS.test(pathname)) return true return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/")) } -function isGuestSourceReadPath(method: string, pathname: string): boolean { - if (method !== "GET") return false - return ( - pathname === "/api/sources" || - GUEST_SOURCE_CHUNKS_PATH.test(pathname) || - GUEST_DEMO_ORIGINAL_PATH.test(pathname) || - GUEST_DEMO_ASSET_PATH.test(pathname) - ) -} - export function proxy(req: NextRequest): NextResponse { - if (knowhereApiKeyOverride.hasApiKey()) return NextResponse.next() - if (isPublicPath(req)) return NextResponse.next() - for (const name of sessionCookieNames()) { - if (req.cookies.get(name)) return NextResponse.next() - } - - const origin = process.env.DASHBOARD_ORIGIN - if (!origin) { - return NextResponse.redirect(new URL("/login", req.url)) - } - const loginUrl = `${origin}/login` + if (req.cookies.get(notebookSessionCookieName)) return NextResponse.next() - const notebookUrl = - process.env.NOTEBOOK_PUBLIC_URL ?? new URL(req.url).origin - return NextResponse.redirect( - authURLs.buildDashboardLoginURL(loginUrl, notebookUrl), - ) + return NextResponse.redirect(new URL("/login", req.url)) } export const config = { diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..94b7525 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "server-only": ["./src/test/server-only-stub.ts"] + } + }, + "include": ["scripts/**/*.ts", "src/**/*.ts"] +}