Compare commits

...

50 commits
v0.2.0 ... main

Author SHA1 Message Date
Will Chen
c0ff4404b3
Merge pull request #233 from amal66/olp-pr/backend-integration-tests
Some checks are pending
CI / Backend build and tests (push) Waiting to run
CI / Frontend build and tests (push) Waiting to run
CI / Eval harness (push) Waiting to run
[Testing 08] test: route-level integration tests + real-Supabase auth/RLS suites
2026-07-23 21:48:55 +08:00
willchen96
b2dbb39c62 narrow service role schema grants 2026-07-23 21:37:11 +08:00
willchen96
9399fce86d fix stack test database initialization 2026-07-23 19:08:08 +08:00
Will Chen
7460b065e5
Merge pull request #240 from amal66/olp-pr/lockfile-merge-guard
ci: guard against silently merge-corrupted lockfiles
2026-07-23 18:27:51 +08:00
QA Runner
e25c2637ec ci: guard against silently merge-corrupted lockfiles
PR #233's CI failed with "npm ci can only install with an existing
package-lock.json" even though the file existed: a "Merge branch 'main'"
commit had auto-merged backend/package.json and package-lock.json into
invalid JSON with no conflict raised, and npm reports an unparseable
lockfile as if it were missing.

Two guards: .gitattributes marks package-lock.json/bun.lock merge=binary so
concurrent lockfile changes surface as explicit conflicts (resolve by
regenerating, never hand-merging), and CI parse-checks package.json and the
lockfile before npm ci so any corruption that still lands fails with the
real reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:23:15 -07:00
QA Runner
6aed350a3c fix: repair JSON corrupted by the merge of main (package.json + lockfile)
The "Merge branch 'main'" commit (71a7aba) auto-merged backend/package.json
and backend/package-lock.json without raising a conflict, but git's
line-level merge produced invalid JSON in both files: package.json lost the
comma after the new "test:stack" script, and package-lock.json lost the two
closing-brace lines of the supertest/cookie-signature entry. npm treats an
unparseable lockfile as absent, which is why CI failed with the misleading
"npm ci can only install with an existing package-lock.json".

package.json: restore the comma. package-lock.json: rebuilt from main's
known-good copy via `npm install --package-lock-only` against this branch's
package.json. Verified locally: npm ci, npm test (259 passed), npm run build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:22:26 -07:00
Will Chen
71a7aba337
Merge branch 'main' into olp-pr/backend-integration-tests 2026-07-22 19:56:19 +08:00
Will Chen
2ec89a7c52
Merge pull request #230 from amal66/olp-pr/frontend-unit-tests
Some checks failed
CI / Backend build and tests (push) Has been cancelled
CI / Frontend build and tests (push) Has been cancelled
CI / Eval harness (push) Has been cancelled
[Testing 07] test: component and hook unit tests for the web app
2026-07-22 16:59:39 +08:00
Will Chen
7d2ba4b87f
Merge pull request #235 from amal66/olp-pr/lint-burndown
[Lint 04] fix: burn down frontend eslint errors; make CI lint blocking
2026-07-22 16:33:52 +08:00
Will Chen
73b9cd1692
Merge pull request #232 from amal66/olp-pr/ci-workflows
[CI 03] ci: build and test workflow for backend and frontend
2026-07-22 16:07:57 +08:00
Will Chen
39dc609f95
Merge pull request #234 from amal66/olp-pr/testing-docs
[Docs 02] docs: testing policy in CONTRIBUTING + PR template
2026-07-21 22:16:01 +08:00
Will Chen
948e9bdd4d
Merge pull request #237 from amal66/olp-pr/coverage-critical-libs
[Testing 09] test: unit-cover the highest-risk backend libs + raise the coverage ratchet
2026-07-21 18:16:40 +08:00
Will Chen
eda088e143
Merge pull request #238 from amal66/olp-pr/directory-fetch-storm
[Fix 01] Batch the directory modal's project fetch (N+1 getProject storm)
2026-07-21 16:51:36 +08:00
cosimoastrada
8a34a6457f
Merge pull request #228 from amal66/olp-pr/test-harness
[Testing 05] test: minimal vitest harness for backend and frontend
2026-07-21 16:21:11 +08:00
QA Runner
36cddb2566 fix: batch directory-modal project fetch instead of N+1 getProject burst
Every directory picker (AddDocumentsModal, UseWorkflowModal, the assistant
project selector) loads its "Projects" tab through useDirectoryData, which
fired one GET /projects/:id for EVERY existing project the moment the modal
opened. Each of those requests costs an auth verification against GoTrue
plus ~6 PostgREST queries, so an account with N projects produced an
~8xN-request burst on the Supabase gateway per modal open.

Under that burst the gateway genuinely falls over: measured locally against
the Supabase CLI stack, overlapping modal-open storms drove GoTrue into
Postgres connection exhaustion ("failed to connect ... context deadline
exceeded") and produced 467x 500 + 641x 504 on /auth/v1/user in a single
run — surfacing to users as failed project creates/loads, and to the e2e
suite as the intermittent Kong 502s its specs currently retry around.

Fix: GET /projects now accepts ?include=documents and returns each
project's documents from one batched query (same attach helpers as
GET /projects/:id, run once across all documents), and useDirectoryData
uses it. A modal open is now 1 API request and a fixed number of DB
queries regardless of project count. After the change the same storm
harness produced zero 5xx and zero auth failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:48 -07:00
cosimoastrada
0843e2909a
Merge pull request #212 from jmooves/upstream-pr/chat-messages-workflow-column
fix(db): add missing chat_messages.workflow column (user prompts dropped on reload)
2026-07-21 12:49:06 +08:00
QA Runner
2053ca15c8 test: cover critical backend libs, raise coverage ratchet, add roadmap
Six new unit suites (102 tests) for the highest-risk untested libs:
userDataCleanup (destructive account/project deletes), documentVersions
(document integrity), chat/citations (legal-citation parsing), safeError
(secret redaction), llm/models (model resolution), and userLookup
(profile email sync). Coverage floors ratchet up 2/2/4/2 ->
11/10/14/10 (measured 11.18/10.98/14.43/10.91), and
docs/testing-coverage.md gives contributors a prioritized backlog for
the remaining untested libs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:21:35 -07:00
QA Runner
0740f656e6 fix: burn down frontend eslint errors; make CI lint blocking
Takes frontend/npm run lint from 23 errors / 40 warnings to 0 errors /
40 warnings, then removes continue-on-error from the CI lint step so it
gates merges.

Errors fixed outright:
- @typescript-eslint/no-explicit-any (5, ChatView.tsx): dropped
  redundant (msg as any) casts — Message already declares files,
  workflow, and error with the exact shapes the props expect.
- react/no-unescaped-entities (1, support/page.tsx): "We'll" ->
  "We&apos;ll".

Targeted disables (17, all react-hooks/set-state-in-effect): every site
is an intentional effect-driven state pattern — SSR/hydration mount
gates (Modal, useSelectedModel), reset-on-prop/identity-change
(CaseLawPanel x3, ChatView chat switch, CitationQuotesHeader,
ChatHistoryContext logout, useFetchDocxBytes), sync fast paths of async
fetch/check effects (CaseLawPanel, MfaLoginGate x2), DOM-measured state
(ChatView scroll button, message-visibility restore), and timed UI
latches (PreResponseWrapper, TRChatPanel, AskInputPopup auto-submit).
Rewriting any of them would change runtime behavior, so each carries a
// eslint-disable-next-line with a one-line reason instead of a fix or
a repo-wide rule change.

CI: lint step is now blocking; comments updated to say the backlog is
at zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:17:24 -07:00
QA Runner
45a4f7508c test: route-level integration tests + app/index split
Ports the backend route-level integration suites from the amal66 fork
(index: Open-Legal-Products/mike#205), adapted from the fork's
apps/api modules/services layout to this repo's monolithic
backend/src/routes/*.ts layout.

app/index split (the only production change; mechanical, zero behavior
change): backend/src/index.ts previously built the express app and
called app.listen at the bottom. Everything except the listen call
moved verbatim into backend/src/app.ts, which now exports `app` (same
middleware order, same routes, same rate-limiter setup, dotenv/config
still imported first). index.ts is now a tiny entry that imports
{ app } and calls listen with the same log line. `npm run build` still
emits dist/index.js and the `start` script is unchanged.

New suites under backend/src/__tests__/integration/ (94 tests):
- health.test.ts (5): /health, requireAuth 401 paths, 404 fallthrough
- chat.routes.test.ts (6): POST /chat validation + SSE happy/error paths
- projects.routes.test.ts (18): overview/create/detail/patch/delete,
  sharing normalisation, ownership guards
- projectChat.routes.test.ts (3): project access guard + SSE paths
- tabular.routes.test.ts (31): review CRUD, access guards,
  document-access filtering, missing_api_key guards
- user.routes.test.ts (27): profile, API-key crypto boundary, MFA
  guards, export/deletion endpoints
- documentsUpload.routes.test.ts (4): upload validation + download-zip
  bounds/access
- access.supabase.test.ts (1) + stack.supabase.test.ts (4): gated on
  SUPABASE_TEST_URL / SUPABASE_TEST_SERVICE_ROLE_KEY (stack suite also
  needs SUPABASE_TEST_ANON_KEY); describe.skip otherwise
- scripts/test-stack.sh + `npm run test:stack`: reads a running
  `supabase status -o json` and runs the gated suites

Adds supertest + @types/supertest as devDependencies.

Dropped relative to the fork (subjects that do not exist in this repo):
- orgs.routes, credits.concurrency.supabase, dmsConnectors suites
  (fork-only features)
- all credit-reservation cases (429 CREDIT_LIMIT_EXCEEDED,
  reserve-then-refund) in chat/projectChat: no credit system here
- health /ready case: no /ready endpoint here
- org-membership project access case: no org model here
- upload magic-byte validation cases: this repo validates extension
  only (the fork adds content sniffing; replaced with a missing-file
  400 case)
- download-zip 50-document cap case: no cap here (replaced with a
  no-accessible-documents 404 case)
- stack.supabase PUBLIC_TABLES updated to this repo's schema; the
  fork's credit-RPC coverage is n/a

Verified locally: backend `npm test` -> 8 files passed, 2 skipped;
106 tests passed, 5 skipped (gated suites skip without env).
`npm run build` passes. The gated suites were additionally run against
a live local Supabase stack: 2 files, 5/5 tests passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:00:01 -07:00
QA Runner
a29e67deac test: component and hook unit tests for the web app
Ported from the amal66 fork (index: Open-Legal-Products/mike#205), adapted
to this repo's frontend/ layout and current component behavior. Adds the
jsdom + testing-library harness on top of the existing vitest setup:
vitest.config.mts (jsdom environment, @/ alias mirroring tsconfig paths,
dummy Supabase env for modules that build a client at import time) and
vitest.setup.ts (jest-dom matchers). New devDependencies: jsdom,
@testing-library/react, @testing-library/jest-dom,
@testing-library/user-event, @vitejs/plugin-react.

Ported suites (30 new tests):
- FileTypeIcon.test.tsx (11) — fileTypeKind mapping + icon rendering
- TRTable.test.tsx (1) — header/row render smoke test; the fork's ARIA
  role assertions (table/columnheader, "Tabular review" label) target
  fork-only markup — this repo's grid is div-based, so the test asserts
  rendered content instead
- button.test.tsx (4), pill-button.test.tsx (8), cite-button.test.tsx (3)
- useSmoothedReveal.test.ts (3) — passes against this repo's early-return
  snap behavior unchanged

Dropped (subjects don't exist here):
- HistoryDropdown.test.tsx — no tr-chat-panel/HistoryDropdown component
- applyAssistantStreamEvent.test.ts — no such module
- useProjectsQuery/useTabularReviewsQuery/useWorkflowsQuery tests —
  react-query is fork-only
- lib/toast.test.ts — no frontend/src/lib/toast.ts
- useAssistantChat.parsers.test.ts — the parser helpers exist inside
  useAssistantChat.ts but are not exported, and the fork's extracted
  module doesn't exist here

Verified: frontend npm test 38/38 passing (30 new + 8 pre-existing cn()
utils tests); npx tsc --noEmit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:50:11 -07:00
QA Runner
c139acc3c6 test: unit tests for access, storage, userApiKeys, chat doc resolution
Ported from the amal66 fork (see index Open-Legal-Products/mike#205)
onto current main, adapted to this repo's backend/ layout
(apps/api/src/lib -> backend/src/lib), plus a v8 coverage ratchet.

Suites ported (51 new tests, verified locally):
- access.test.ts (7): owner/shared/private project access, doc access,
  review sharing, document-ID filtering. Dropped the fork's "org RBAC"
  describe block (7 cases) — org_id/org_members multi-tenancy and the
  role/canManage fields do not exist in this repo's access.ts.
- storage.test.ts (25): filename normalization/sanitization, RFC 5987
  encoding, Content-Disposition, storage key helpers. Dropped the fork's
  vi.mock of lib/env — this repo has no env module; storage reads
  process.env directly and the tested helpers are pure.
- userApiKeys.test.ts (10): normalizeApiKeyProvider + hasEnvApiKey.
  Added a beforeEach env clear so shell-exported API keys can't leak
  into assertions.
- chatTypes.test.ts (9): resolveDoc/resolveDocLabel, which live in
  lib/chat/types.ts here (the fork's lib/chatTools.ts equivalent).
  Dropped generateSpotlightNonce cases (2) — no such export here.

Suites dropped entirely (subject not present in this repo):
- upload.test.ts — tested hasMagicBytes; this repo's lib/upload.ts is
  only the multer middleware and exports no magic-byte checker.
- userSettings.test.ts — tested resolveTabularModel (fork-only keyed-
  provider fallback); this repo resolves tabular_model via
  resolveModel with a static default.

Coverage ratchet: vitest.config.mts adds v8 coverage over src/lib/**
with floors measured against this tree (2.58% stmts, 2.00% branches,
4.61% funcs, 2.58% lines -> floors 2/2/4/2). Full suite: 5 files,
63 tests passing (incl. the pre-existing 12 in downloadTokens.test.ts);
npm run test:coverage and npm run build both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:49:27 -07:00
QA Runner
15b7b4c925 docs: testing policy in CONTRIBUTING + PR template with verification checklist
Adds a Testing section documenting every suite the testing PR series
introduces (unit/integration via vitest, Playwright e2e, offline evals,
gated real-Supabase stack tests), the expectation that changes carry tests
at the lowest layer that catches the regression, and a PR template (ported
from the amal66 fork, Open-Legal-Products/mike#205) whose checklist asks how
the change was verified. Intended as the capstone of the series — the
commands it documents are introduced by the sibling test PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:46:47 -07:00
QA Runner
b20109ac6c ci: build and test workflow for backend and frontend
Ported from amal66/mike#46 onto current main, extended for this tree:
backend job runs npm ci, tests (--if-present, so it is safe to merge in any
order relative to the test-harness PR) and tsc build; frontend job runs
tests, eslint as an advisory step (main currently carries 23 lint errors —
flip to blocking once burned down), and a production next build with
placeholder NEXT_PUBLIC_* env (verified locally that the build succeeds and
contacts nothing); evals job runs node evals/run.mjs when present, else
skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:45:48 -07:00
QA Runner
4039b94980 test: minimal vitest harness for backend and frontend
Ported from amal66/mike#24 onto current main; lockfiles regenerated against
this tree. Adds vitest as a dev dependency with a `test` script in both
packages, excludes test files from the backend tsc build, and seeds one
suite per package (backend: downloadTokens, 12 tests; frontend: cn() utils,
8 tests). Verified locally: backend 12/12, frontend 8/8 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:42:28 -07:00
cosimoastrada
dafac6b0a4
Merge pull request #219 from Open-Legal-Products/quick-fixes
feat: refresh workflows and workflow editor UI
2026-07-20 03:06:16 +08:00
willchen96
fa21ac8fd7 feat: refresh workflows and workflow editor UI 2026-07-20 03:04:47 +08:00
cosimoastrada
0ed5050b21
Merge pull request #218 from Open-Legal-Products/readme-update
docs: update readme
2026-07-17 16:19:04 +08:00
willchen96
9eb63ef3fd docs: update readme 2026-07-17 16:18:29 +08:00
cosimoastrada
81590ea137
Merge pull request #216 from Open-Legal-Products/more-ui-updates
feat: refine liquid surfaces and document review panels
2026-07-17 01:25:02 +08:00
willchen96
f5abbac42c feat: refine liquid surfaces and document review panels 2026-07-17 01:22:12 +08:00
cosimoastrada
1d61634a37
Merge pull request #215 from Open-Legal-Products/library-ui-updates
feat: add library and refresh shared table UI
2026-07-16 20:09:55 +08:00
willchen96
f0b90ab3b4 feat: add library and refresh shared table UI 2026-07-16 20:07:12 +08:00
JJ
cfdcda6d2c fix(db): add missing chat_messages.workflow column
User messages are persisted with a `workflow` field
(backend/src/routes/chat.ts, projectChat.ts) and it is read back when
rendering chat history (ChatView -> UserMessage) to show which workflow a
message was sent under. But the column is never created by schema.sql or any
migration, so every user-message insert fails with PostgREST PGRST204
("Could not find the 'workflow' column of 'chat_messages'") and is dropped
silently (the insert result is not checked). The assistant insert has no
workflow column, so it succeeds — the net effect on a self-hosted install is
that reloading a thread shows the assistant reply but not the user's prompt.

Add the `workflow jsonb` column to schema.sql (fresh installs) and a
migration (existing installs), mirroring the adjacent content/files jsonb
columns.
2026-07-16 07:44:25 +10:00
cosimoastrada
e32daad5a4
Merge pull request #206 from Open-Legal-Products/workflows-ui-excel-ppt-updates
Workflows UI excel ppt updates
2026-07-08 18:30:54 +08:00
willchen96
82dcaefc43 feat: workflow, UI and document support updates 2026-07-08 18:27:28 +08:00
willchen96
a5fe6d6e04 Sync workflow, asst extra input, excel + ppt support and modal updates 2026-07-04 23:24:31 +08:00
cosimoastrada
93f921be90
Merge pull request #147 from willchen96/add-security-reporting-guidance
docs: add security reporting guidance
2026-06-27 03:01:04 +08:00
cosimoastrada
457d4a18a4
Merge pull request #183 from willchen96/mcp-connectors
refactor: add table primitive and migrations by date; feat: add mcp connectors
2026-06-15 17:45:31 +08:00
willchen96
9a1277ba99 refactor: add table primitive and migrations by date; feat: add mcp connectors 2026-06-15 17:34:58 +08:00
cosimoastrada
01dfcfe0d4
Merge pull request #180 from willchen96/ui-fixes-modals-headers
Modal, header, mobile display and workflow UI updates
2026-06-11 22:44:35 +08:00
willchen96
3132e04ac0 Modal, header, mobile display and workflow UI updates 2026-06-11 22:43:13 +08:00
cosimoastrada
8a2dc05181
Merge pull request #178 from willchen96/feat/mfa-data-deletion-and-exports
Refactor ProjectPageParts and ProjectPageHeader components
2026-06-11 22:24:20 +08:00
willchen96
1fa0554ea5 Refactor ProjectPageParts and ProjectPageHeader components for improved loading states and skeleton UI. Update Modal and PageHeader components to support loading states. Enhance RenameableTitle for better caret positioning. Adjust DisplayWorkflowModal to utilize the new Modal component structure. Update WorkflowList to include loading indicators and improve sticky header behavior. 2026-06-11 21:50:58 +08:00
cosimoastrada
a211b92f38
Merge pull request #172 from willchen96/feat/mfa-data-deletion-and-exports
feat: implement multi-factor authentication (MFA) setup and verification flow and data deletion and export
2026-06-10 20:46:45 +08:00
willchen96
444d1d38e4 feat: enhance user profile management and MFA login flow
- Refactor user profile loading and updating logic to improve state management and reduce unnecessary checks.
- Update MFA login gate to streamline verification checks and improve user experience.
- Ensure consistent handling of user ID across profile context and components.
- Improve error handling and loading states in user-related API calls.
2026-06-10 18:55:33 +08:00
willchen96
3a10943200 feat: implement multi-factor authentication (MFA) setup and verification flow
- Add SecurityPage component for managing MFA settings, including enrollment and verification.
- Create MfaLoginGate to handle MFA verification state during login.
- Develop MfaVerificationPopup for user input of verification codes.
- Implement VerifyMfaPage for the MFA verification process after login.
- Introduce reusable VerificationCodeInput component for entering verification codes.
- Integrate Supabase MFA API for managing factors and verification.
- Add loading states and error handling for a better user experience.
2026-06-10 03:48:08 +08:00
cosimoastrada
15c96b0dd4
Merge pull request #171 from willchen96/feat/courtlistener-versioning-liquid-glass
Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes
2026-06-09 01:48:40 +08:00
willchen96
f32a194b33 Sync CourtListener verification and document safety updates
- Refine CourtListener citation verification, bulk lookup logging, and API fallback behavior
- Persist cancelled chat stream output and render cancellation as the final assistant message
- Add document/version deletion safety fixes and shared warning/modal UI updates
- Sync document panel, case law panel, and response UI styling refinements
- Harden OSS sync script to preserve local env, dependency, and generated files
2026-06-09 01:46:58 +08:00
willchen96
44e868eb42 Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes 2026-06-06 15:48:47 +08:00
willchen96
f656e14ef7 docs: add security reporting guidance 2026-05-17 01:57:19 +08:00
363 changed files with 67226 additions and 21197 deletions

8
.gitattributes vendored Normal file
View file

@ -0,0 +1,8 @@
# Lockfiles are generated files. Git's line-level text merge can combine
# both sides' insertions into syntactically invalid JSON *without raising a
# conflict* (this silently broke backend/package.json + package-lock.json in
# PR #233). Merge them as binary so any concurrent change surfaces as an
# explicit conflict; resolve by taking main's copy and regenerating:
# git checkout origin/main -- package-lock.json && npm install
package-lock.json merge=binary
bun.lock merge=binary

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,30 @@
## Summary
<!-- What does this PR do, in one or two sentences? -->
## Why / Motivation
<!-- What problem does this solve? Why now, and why this approach?
Link the issue or context that prompted it. -->
## Changes
<!-- The notable changes, at a high level. Describe the outcome, not the diff. -->
## Tradeoffs & risks
<!-- What did you weigh? What could break, what's out of scope, and what
follow-ups (if any) does this leave behind? Note any security or
migration implications. -->
## How verified
<!-- How do you know this works? Tests added/run, manual steps, commands,
screenshots. Include the exact commands where relevant. -->
## Checklist
- [ ] Ran the relevant build/test command for the area changed.
- [ ] Reviewed `git diff` and removed unrelated changes.
- [ ] Updated docs / env examples if setup, config, or behavior changed.
- [ ] No secrets, API keys, real documents, or `.env` files committed.

112
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,112 @@
# CI: build and test.
#
# Adapted from the amal66/mike fork's .github/workflows/ci.yml (monorepo
# layout) to this repository's backend/ + frontend/ layout. Test steps use
# `npm test --if-present`, and the eval job checks for evals/run.mjs, so this
# workflow is safe to merge before or after the test-harness and evals PRs:
# on a tree without those pieces the test steps no-op and the build still
# gates the merge. Beyond the fork version this also builds the frontend
# (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and
# runs eslint as a blocking gate (the error backlog is at zero).
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend build and tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: backend/package-lock.json
# Git's line-level merge can splice both sides of package(-lock).json
# into invalid JSON without a conflict (bit PR #233). npm's own error
# for an unparseable lockfile is the misleading "npm ci can only
# install with an existing package-lock.json" — fail fast with the real
# reason instead.
- name: Validate package.json and lockfile parse
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"
- run: npm ci
# No-ops on a tree without a "test" script (e.g. before the vitest
# harness PR merges); runs the suite once it exists.
- run: npm test --if-present
- run: npm run build
frontend:
name: Frontend build and tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
# Same silent-merge-corruption guard as the backend job.
- name: Validate package.json and lockfile parse
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"
- run: npm ci
# No-ops on a tree without a "test" script (e.g. before the vitest
# harness PR merges); runs the suite once it exists.
- run: npm test --if-present
# Blocking gate: the eslint error backlog was burned down in this PR
# (0 errors; warnings do not fail the step), so any new error fails CI.
- run: npm run lint
# Production build. NEXT_PUBLIC_* values are inlined at build time and
# only need to be well-formed here — nothing is contacted during build.
# This catches type errors (next build runs tsc) and broken routes/imports.
- run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sb_publishable_placeholder
NEXT_PUBLIC_API_BASE_URL: http://localhost:3001
# -----------------------------------------------------------------------
# Offline eval harness: deterministic scorecard for citation accuracy,
# prompt-injection resistance, and privilege/PII leakage. Runs against
# committed fixtures (no network, no LLM calls, no secrets), so it is cheap
# enough to gate every PR. --threshold 1.0 = every case must pass.
# Skips gracefully until the evals PR merges.
# -----------------------------------------------------------------------
evals:
name: Eval harness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Run eval harness (skips if evals/ not present)
run: |
if [ -f evals/run.mjs ]; then
node evals/run.mjs --threshold 1.0
else
echo "evals/run.mjs not present on this tree; skipping"
fi

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ build
!.env.local.example !.env.local.example
*.log *.log
*.raw-llm-stream.json
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.DS_Store .DS_Store

View file

@ -20,9 +20,24 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
- why - why
- testing - testing
## System Workflows
System workflows live in the sibling
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
repository under `assistant-workflows/` and `tabular-review-workflows/`. Put
structured metadata in the YAML frontmatter at the top of `SKILL.md`, set
`metadata.mike-availability` to `system`, put workflow instructions in the body
of `SKILL.md`, and use `table-columns.yaml` for tabular review columns.
After changing system workflows, regenerate the app files:
```bash
node scripts/build-workflows.js
```
## Security ## Security
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead. Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/Open-Legal-Products/mike/security/advisories/new) instead.
We will aim to respond promptly and coordinate a disclosure timeline with you. We will aim to respond promptly and coordinate a disclosure timeline with you.
@ -39,3 +54,22 @@ Frontend:
```bash ```bash
npm run build --prefix frontend npm run build --prefix frontend
``` ```
## Testing
```bash
npm test --prefix backend # backend unit + route integration tests (vitest)
npm test --prefix frontend # frontend component/hook tests (vitest + jsdom)
npm run test:e2e # Playwright end-to-end suite — see docs/e2e-ci.md
node evals/run.mjs --threshold 1.0 # offline eval harness (no network, no API keys)
npm run test:stack --prefix backend # gated: real-Supabase auth/access tests (run `supabase start` first)
```
- New features and bug fixes should come with a test at the lowest layer that
can catch the regression: unit first, then route-level integration, then
end-to-end only for flows a browser is genuinely needed to prove.
- CI runs the build, unit/integration tests, and the eval harness on every PR
(`.github/workflows/ci.yml`), and the Playwright suite in a full local stack
(`.github/workflows/e2e.yml`).
- Tests that need a live Supabase or an LLM key are env-gated and skip cleanly
when the environment is absent — a plain `npm test` should always be green.

View file

@ -1,6 +1,10 @@
# Mike # Mike
Mike is a legal document assistant with a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloudflare R2-compatible object storage. ![Mike](https://mikeoss.com/link-image.jpg)
Mike or MIkeOSS is a legal AI platform that is able to assist you with document review, drafting and legal research.
It has a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloudflare R2-compatible object storage.
Website: [mikeoss.com](https://mikeoss.com) Website: [mikeoss.com](https://mikeoss.com)
@ -9,7 +13,13 @@ Website: [mikeoss.com](https://mikeoss.com)
- `frontend/` - Next.js application - `frontend/` - Next.js application
- `backend/` - Express API, Supabase access, document processing, and database schema - `backend/` - Express API, Supabase access, document processing, and database schema
- `backend/schema.sql` - Supabase schema for fresh databases - `backend/schema.sql` - Supabase schema for fresh databases
- `backend/migrations/` - incremental database updates for existing deployments - `backend/migrations/` - dated, incremental schema migrations; on an existing database, apply the files dated after the Mike version you deployed
## System Workflows
Mike's system assistant and tabular review workflows are maintained in the
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
repository.
## Prerequisites ## Prerequisites
@ -19,6 +29,7 @@ Website: [mikeoss.com](https://mikeoss.com)
- A Supabase project - A Supabase project
- A Cloudflare R2 bucket, MinIO bucket, or another S3-compatible bucket - A Cloudflare R2 bucket, MinIO bucket, or another S3-compatible bucket
- At least one supported model provider API key: Anthropic, Google Gemini, or OpenAI - At least one supported model provider API key: Anthropic, Google Gemini, or OpenAI
- Optional: a CourtListener API token for case law lookup and citation verification
- LibreOffice installed locally if you need DOC/DOCX to PDF conversion - LibreOffice installed locally if you need DOC/DOCX to PDF conversion
## Database Setup ## Database Setup
@ -30,9 +41,9 @@ For a new Supabase database, open the Supabase SQL editor and run:
-- backend/schema.sql -- backend/schema.sql
``` ```
The schema file is based on `supabase-migration.sql` and folds in the later files in `backend/migrations/`. The schema file is for fresh deployments and already includes the latest database shape.
For an existing database, do not run the full schema file over production data. Apply the incremental files in `backend/migrations/` instead. For an existing database, do not run the full schema file over production data. Instead, apply the incremental files in `backend/migrations/`: run the migrations dated **after** the version of Mike you currently have deployed, in filename order. Each file is named `YYYYMMDD_<name>.sql` (the date is also recorded in a comment at the top of the file) and is written to be safe to re-run, so when unsure you can re-apply the most recent migrations without harm.
## Environment ## Environment
@ -62,6 +73,12 @@ ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key OPENAI_API_KEY=your-openai-key
RESEND_API_KEY=your-resend-key RESEND_API_KEY=your-resend-key
USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret
# Optional: enables CourtListener case law and citation tools.
COURTLISTENER_API_TOKEN=your-courtlistener-token
# Optional: use locally imported CourtListener bulk data for faster case reads.
COURTLISTENER_BULK_DATA_ENABLED=false
``` ```
Create `frontend/.env.local`: Create `frontend/.env.local`:
@ -74,7 +91,23 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
Supabase values come from the project dashboard. Use the project URL for `SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_URL`, the service role key for the backend `SUPABASE_SECRET_KEY`, and the anon/public key for `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY`. If your Supabase project shows multiple key formats, use the legacy JWT-style anon and service role keys expected by the Supabase client libraries. Supabase values come from the project dashboard. Use the project URL for `SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_URL`, the service role key for the backend `SUPABASE_SECRET_KEY`, and the anon/public key for `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY`. If your Supabase project shows multiple key formats, use the legacy JWT-style anon and service role keys expected by the Supabase client libraries.
Provider keys are only needed for the models and email features you plan to use. Model provider keys can be configured in `backend/.env` for the whole instance, or per user in **Account > Models & API Keys**. If a provider key is present in `backend/.env`, that provider is available by default and the matching browser API key field is read-only. Provider keys are only needed for the models, legal research, and email features you plan to use. Model provider keys and the CourtListener token can be configured in `backend/.env` for the whole instance, or per user in **Account > Models & API Keys**. If a provider key is present in `backend/.env`, that provider is available by default and the matching browser API key field is read-only.
## CourtListener Integration
Mike can use CourtListener for US case law citation verification, case fetching, targeted opinion search, and case-law panels in assistant responses.
To enable live CourtListener access, set `COURTLISTENER_API_TOKEN` in `backend/.env` and restart the backend. Users can also add their own CourtListener token from **Account > Models & API Keys** when the instance does not provide one globally.
Fresh databases created from `backend/schema.sql` already include the CourtListener support tables. Existing deployments should apply the matching dated migration in `backend/migrations/` before enabling the feature.
Bulk data is optional. When `COURTLISTENER_BULK_DATA_ENABLED=true`, Mike first tries local Supabase/R2 data before falling back to CourtListener's API:
- citation metadata is read from `public.courtlistener_citation_index`
- case cluster metadata is read from `public.courtlistener_opinion_cluster_index`
- cached opinion JSON is read from the R2 prefix `courtlistener/opinions/by-cluster/{clusterId}/{opinionId}.json`
If you do not import bulk data, leave `COURTLISTENER_BULK_DATA_ENABLED=false`; live CourtListener tools still work with a valid token, subject to CourtListener rate limits.
## Install ## Install
@ -105,7 +138,8 @@ Open `http://localhost:3000`.
1. Sign up in the app. 1. Sign up in the app.
2. If you did not set provider keys in `backend/.env`, open **Account > Models & API Keys** and add an Anthropic, Gemini, or OpenAI API key. 2. If you did not set provider keys in `backend/.env`, open **Account > Models & API Keys** and add an Anthropic, Gemini, or OpenAI API key.
3. Create or open a project and start chatting with documents. 3. To use legal research tools, add a CourtListener token in `backend/.env` or **Account > Models & API Keys**.
4. Create or open a project and start chatting with documents.
## Troubleshooting ## Troubleshooting
@ -113,6 +147,10 @@ Open `http://localhost:3000`.
**The model picker shows a missing-key warning.** Add a key for that provider in **Account > Models & API Keys**, or configure the provider key in `backend/.env` and restart the backend. **The model picker shows a missing-key warning.** Add a key for that provider in **Account > Models & API Keys**, or configure the provider key in `backend/.env` and restart the backend.
**CourtListener tools say the API token is missing.** Set `COURTLISTENER_API_TOKEN` in `backend/.env`, or add a CourtListener token in **Account > Models & API Keys** for the signed-in user. Restart the backend after changing `.env`.
**CourtListener bulk lookup is not returning local results.** Confirm `COURTLISTENER_BULK_DATA_ENABLED=true`, the two CourtListener tables have been populated, and opinion JSON exists in R2 under `courtlistener/opinions/by-cluster/`. If bulk data is unavailable, Mike falls back to the live API when a token is configured.
**DOC or DOCX conversion fails.** Install LibreOffice locally and restart the backend so document conversion commands are available on the process path. **DOC or DOCX conversion fails.** Install LibreOffice locally and restart the backend so document conversion commands are available on the process path.
## Useful Checks ## Useful Checks

View file

@ -18,3 +18,6 @@ ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key OPENAI_API_KEY=your-openai-key
RESEND_API_KEY=your-resend-key RESEND_API_KEY=your-resend-key
USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret
# Optional: enables higher-rate CourtListener case law/citation lookup tools.
COURTLISTENER_API_TOKEN=your-courtlistener-token

1
backend/.gitignore vendored
View file

@ -3,5 +3,6 @@ dist
.env* .env*
!.env.example !.env.example
*.log *.log
*.raw-llm-stream.json
logs/ logs/
.DS_Store .DS_Store

View file

@ -9,6 +9,7 @@
"@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0",
"@google/genai": "^1.50.1", "@google/genai": "^1.50.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.0", "docx": "^9.5.0",
@ -24,6 +25,8 @@
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"resend": "^4.5.1", "resend": "^4.5.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76",
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
@ -179,6 +182,10 @@
"@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="], "@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="], "@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="], "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="],
@ -379,6 +386,10 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@ -423,6 +434,8 @@
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@ -471,16 +484,22 @@
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="], "fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
@ -523,6 +542,8 @@
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="], "helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
"hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
@ -533,7 +554,7 @@
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
@ -543,12 +564,22 @@
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
@ -577,9 +608,9 @@
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
@ -605,6 +636,8 @@
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
@ -619,12 +652,16 @@
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="], "pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@ -637,7 +674,7 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@ -647,12 +684,16 @@
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@ -671,6 +712,10 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
@ -719,8 +764,14 @@
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }],
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
@ -729,6 +780,10 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@ -737,20 +792,36 @@
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], "docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], "protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@ -761,16 +832,58 @@
"@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="], "@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@modelcontextprotocol/sdk/express/body-parser/qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"@modelcontextprotocol/sdk/express/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@modelcontextprotocol/sdk/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
} }
} }

View file

@ -0,0 +1,32 @@
-- Migration date: 2026-04-19
-- Migration: Convert tabular_review_chat_messages.content from TEXT to JSONB
-- and add annotations JSONB column.
--
-- User messages: content TEXT → JSON string (e.g. "hello" → '"hello"')
-- Assistant messages: content TEXT → events array (e.g. "answer" → '[{"type":"content","text":"answer"}]')
--
-- Only convert while content is still TEXT. Re-running over jsonb content would
-- double-wrap assistant events, so the type check makes this safe to re-run.
DO $$
BEGIN
IF (
SELECT data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'tabular_review_chat_messages'
AND column_name = 'content'
) = 'text' THEN
ALTER TABLE tabular_review_chat_messages
ALTER COLUMN content TYPE jsonb
USING CASE
WHEN role = 'user'
THEN to_jsonb(content)
ELSE
jsonb_build_array(jsonb_build_object('type', 'content', 'text', content))
END;
END IF;
END $$;
ALTER TABLE tabular_review_chat_messages
ADD COLUMN IF NOT EXISTS annotations jsonb;

View file

@ -0,0 +1,52 @@
-- Migration date: 2026-04-21
-- Migration: DOCX editing with tracked changes.
-- Adds per-edit Accept/Reject state and a pointer to the document's current version.
-- Assumes document_versions table already exists (see separate migration).
-- 1. Broaden document_versions.source to include 'user_reject'.
ALTER TABLE public.document_versions
DROP CONSTRAINT IF EXISTS document_versions_source_check;
ALTER TABLE public.document_versions
ADD CONSTRAINT document_versions_source_check
CHECK (source = ANY (ARRAY[
'upload'::text,
'assistant_edit'::text,
'user_accept'::text,
'user_reject'::text,
'generated'::text
]));
-- 2. Point each document at its currently active version (null = original upload).
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS current_version_id uuid
REFERENCES public.document_versions(id) ON DELETE SET NULL;
-- 3. Per-edit registry. One row per tracked change proposed by the assistant.
-- change_id is the w:id written into document.xml so Accept/Reject can
-- locate the specific w:ins/w:del pair on the latest version.
CREATE TABLE IF NOT EXISTS public.document_edits (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
chat_message_id uuid REFERENCES public.chat_messages(id) ON DELETE SET NULL,
version_id uuid NOT NULL REFERENCES public.document_versions(id) ON DELETE CASCADE,
change_id text NOT NULL,
deleted_text text NOT NULL DEFAULT '',
inserted_text text NOT NULL DEFAULT '',
context_before text,
context_after text,
status text NOT NULL DEFAULT 'pending'
CHECK (status = ANY (ARRAY['pending'::text, 'accepted'::text, 'rejected'::text])),
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz
);
CREATE INDEX IF NOT EXISTS document_edits_document_id_idx
ON public.document_edits (document_id, created_at DESC);
CREATE INDEX IF NOT EXISTS document_edits_message_id_idx
ON public.document_edits (chat_message_id);
CREATE INDEX IF NOT EXISTS document_edits_version_id_idx
ON public.document_edits (version_id);

View file

@ -0,0 +1,9 @@
-- Migration date: 2026-04-21
-- Migration: add optional per-user API keys for direct provider access.
-- When set, these keys override the server-wide env keys for that user's
-- requests; callers must fall back to env when null.
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS claude_api_key TEXT,
ADD COLUMN IF NOT EXISTS gemini_api_key TEXT;

View file

@ -0,0 +1,10 @@
-- Migration date: 2026-04-23
-- Migration: persist the actual w:ins / w:del numeric ids alongside the
-- logical change_id. Accept/Reject needs these to locate the wrapper
-- elements inside document.xml; change_id is our own opaque label and
-- never lands in the file.
ALTER TABLE public.document_edits
ADD COLUMN IF NOT EXISTS del_w_id text,
ADD COLUMN IF NOT EXISTS ins_w_id text;

View file

@ -0,0 +1,32 @@
-- Migration date: 2026-04-23
-- Migration: give each assistant-produced version of a document a
-- monotonic per-document version number (V1, V2, …). Only
-- `source = 'assistant_edit'` rows carry a number; the original upload
-- and the ephemeral user_accept/user_reject rows stay NULL. Numbers are
-- stable once written — accept/reject now overwrite bytes in place
-- rather than insert new rows, so the sequence never has gaps.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS version_number integer;
-- Backfill: assign 1..N to the existing assistant_edit rows per doc,
-- ordered by created_at ascending. Safe to re-run (only writes NULLs).
WITH numbered AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY document_id
ORDER BY created_at ASC
) AS rn
FROM public.document_versions
WHERE source = 'assistant_edit'
)
UPDATE public.document_versions dv
SET version_number = n.rn
FROM numbered n
WHERE dv.id = n.id
AND dv.version_number IS NULL;
CREATE INDEX IF NOT EXISTS document_versions_doc_vnum_idx
ON public.document_versions (document_id, version_number);

View file

@ -0,0 +1,35 @@
-- Migration date: 2026-04-24
-- Migration: per-version user-editable display name + user_upload source.
-- Lets users rename individual versions (the assistant-edit default is
-- "[Edited V{n}]") and differentiate manually-uploaded new versions from
-- the original upload.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS display_name text;
-- Broaden source to include 'user_upload' for versions the user uploads
-- after the original document creation.
ALTER TABLE public.document_versions
DROP CONSTRAINT IF EXISTS document_versions_source_check;
ALTER TABLE public.document_versions
ADD CONSTRAINT document_versions_source_check
CHECK (source = ANY (ARRAY[
'upload'::text,
'user_upload'::text,
'assistant_edit'::text,
'user_accept'::text,
'user_reject'::text,
'generated'::text
]));
-- Backfill: default display_name to the parent document's filename. New
-- assistant edits inherit the prior version's display_name (see
-- runEditDocument), so the version number is no longer baked into the
-- default label — it's surfaced as a separate tag in the UI.
UPDATE public.document_versions dv
SET display_name = d.filename
FROM public.documents d
WHERE dv.display_name IS NULL
AND d.id = dv.document_id;

View file

@ -0,0 +1,31 @@
-- Migration date: 2026-04-24
-- Migration: number the original upload as V1 so assistant edits start at V2.
-- Before: upload rows had version_number NULL, assistant_edit rows started at 1.
-- After: every row in document_versions has a monotonic per-document V# with
-- the upload as V1.
-- Guard: this shift is not naturally idempotent (re-running would bump the
-- numbers again). An unnumbered upload row is the signal that the migration
-- has not run yet; once every upload row is numbered there is nothing to do.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM public.document_versions
WHERE source = 'upload' AND version_number IS NULL
) THEN
-- 1. Shift existing assistant_edit + user_upload numbers up by 1 so they no
-- longer collide with the upload's new V1. Done first so we don't violate
-- any uniqueness constraint while the upload row still lacks a number.
UPDATE public.document_versions
SET version_number = version_number + 1
WHERE source IN ('assistant_edit', 'user_upload')
AND version_number IS NOT NULL;
-- 2. Backfill every upload row's version_number to 1.
UPDATE public.document_versions
SET version_number = 1
WHERE source = 'upload'
AND version_number IS NULL;
END IF;
END $$;

View file

@ -0,0 +1,81 @@
-- Migration date: 2026-04-27
-- Move storage_path and pdf_storage_path from documents to document_versions.
--
-- Rationale: there were two sources of truth for "where the bytes live"
-- - documents.{storage_path, pdf_storage_path} (set on initial upload)
-- - document_versions.storage_path (set on each new version)
-- New-version uploads only updated the latter, so /display, downloads,
-- and assistant context all drifted to the original upload's bytes.
--
-- After this migration:
-- - document_versions owns storage_path and pdf_storage_path.
-- - documents.current_version_id is the only "which version is live" pointer.
-- - documents.{storage_path, pdf_storage_path} are dropped.
-- 1. Add pdf_storage_path to document_versions.
alter table public.document_versions
add column if not exists pdf_storage_path text;
-- 2. Backfill: ensure every document has at least one document_versions row
-- (the original upload). Older docs may predate document_versions entirely.
insert into public.document_versions (
document_id,
storage_path,
pdf_storage_path,
source,
version_number,
display_name,
created_at
)
select
d.id,
d.storage_path,
d.pdf_storage_path,
'upload',
1,
d.filename,
d.created_at
from public.documents d
left join public.document_versions dv
on dv.document_id = d.id and dv.source = 'upload'
where dv.id is null
and d.storage_path is not null;
-- 3. Backfill pdf_storage_path onto the existing 'upload' rows for docs
-- that already had one but predate document_versions.pdf_storage_path.
update public.document_versions dv
set pdf_storage_path = d.pdf_storage_path
from public.documents d
where dv.document_id = d.id
and dv.source = 'upload'
and dv.pdf_storage_path is null
and d.pdf_storage_path is not null;
-- 4. Backfill current_version_id for any document missing one — point it
-- at the most recent version (assistant_edit / user_upload preferred,
-- else the upload row).
update public.documents d
set current_version_id = sub.id
from (
select distinct on (document_id) id, document_id
from public.document_versions
order by document_id,
case source
when 'assistant_edit' then 1
when 'user_upload' then 2
when 'user_accept' then 3
when 'user_reject' then 4
when 'generated' then 5
when 'upload' then 6
else 7
end,
version_number desc nulls last,
created_at desc
) sub
where d.id = sub.document_id
and d.current_version_id is null;
-- 5. Drop the columns from documents.
alter table public.documents drop column if exists storage_path;
alter table public.documents drop column if exists pdf_storage_path;

View file

@ -0,0 +1,13 @@
-- Migration date: 2026-04-27
-- Migration: add shared_with to tabular_reviews so standalone reviews
-- (project_id IS NULL) can be shared by email, mirroring projects.shared_with.
-- Project-scoped reviews continue to inherit access from their parent project.
alter table public.tabular_reviews
add column if not exists shared_with jsonb not null default '[]';
-- Optional but worth it: a generic GIN index speeds up the contains-query
-- the backend uses to fan out shared-review listings.
create index if not exists tabular_reviews_shared_with_idx
on public.tabular_reviews using gin (shared_with);

View file

@ -0,0 +1,8 @@
-- Migration date: 2026-04-27
-- Migration: capture the user's organisation alongside display_name.
-- Collected on the signup form (optional) and editable from the account
-- page. Used for display only; no business logic depends on it yet.
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS organisation TEXT;

View file

@ -0,0 +1,28 @@
-- Migration date: 2026-04-28
-- Migration: enforce one share row per (workflow, recipient email) so
-- re-sharing to the same person updates the existing row instead of
-- creating duplicates. Without this, DELETE only removes one of N copies
-- and the recipient retains access after the owner thinks they revoked.
-- Collapse any existing duplicates first, keeping the most recent row.
delete from public.workflow_shares a
using public.workflow_shares b
where a.workflow_id = b.workflow_id
and a.shared_with_email = b.shared_with_email
and a.created_at < b.created_at;
-- Add the unique constraint only if it is not already present (ADD CONSTRAINT
-- has no IF NOT EXISTS form, so re-running the bare statement would error).
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'workflow_shares_workflow_email_unique'
and conrelid = 'public.workflow_shares'::regclass
) then
alter table public.workflow_shares
add constraint workflow_shares_workflow_email_unique
unique (workflow_id, shared_with_email);
end if;
end $$;

View file

@ -0,0 +1,25 @@
-- Migration date: 2026-05-02
-- Migration: move BYO provider API keys into encrypted, server-only storage.
-- The backend encrypts values before writing them. RLS is enabled with no
-- client policies so browser Supabase clients cannot read key material.
CREATE TABLE IF NOT EXISTS public.user_api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider text NOT NULL CHECK (provider IN ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener')),
encrypted_key text NOT NULL,
iv text NOT NULL,
auth_tag text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(user_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_user_api_keys_user
ON public.user_api_keys(user_id);
ALTER TABLE public.user_api_keys ENABLE ROW LEVEL SECURITY;
-- Legacy plaintext columns remain temporarily so the backend can migrate
-- existing users on first use, then clear each migrated value.

View file

@ -0,0 +1,36 @@
-- Migration date: 2026-05-08
-- Migration: make application data tables backend-only.
-- RLS remains enabled as defense in depth, but direct browser Supabase clients
-- should not be able to query or mutate these tables with anon/authenticated.
DO $$
DECLARE
table_name text;
backend_only_tables text[] := ARRAY[
'projects',
'project_subfolders',
'documents',
'document_versions',
'document_edits',
'workflows',
'hidden_workflows',
'workflow_shares',
'chats',
'chat_messages',
'tabular_reviews',
'tabular_cells',
'tabular_review_chats',
'tabular_review_chat_messages',
'user_api_keys'
];
BEGIN
FOREACH table_name IN ARRAY backend_only_tables LOOP
IF to_regclass(format('public.%I', table_name)) IS NOT NULL THEN
EXECUTE format(
'REVOKE ALL PRIVILEGES ON TABLE public.%I FROM anon, authenticated',
table_name
);
END IF;
END LOOP;
END $$;

View file

@ -0,0 +1,16 @@
-- Migration date: 2026-05-08
-- Migration: move user_profiles behind the backend API.
-- The frontend should use Supabase only for auth; profile reads and writes go
-- through /user/profile so internal fields cannot be mutated from the browser.
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view their own profile"
ON public.user_profiles;
DROP POLICY IF EXISTS "Users can update their own profile"
ON public.user_profiles;
REVOKE ALL PRIVILEGES ON TABLE public.user_profiles
FROM anon, authenticated;

View file

@ -0,0 +1,12 @@
-- Migration date: 2026-05-09
-- Allow users to store an OpenAI API key alongside Claude and Gemini keys.
do $$
begin
alter table public.user_api_keys
drop constraint if exists user_api_keys_provider_check;
alter table public.user_api_keys
add constraint user_api_keys_provider_check
check (provider in ('claude', 'gemini', 'openai'));
end $$;

View file

@ -0,0 +1,26 @@
-- Migration date: 2026-05-11
-- Store landing-page contact form submissions.
-- The landing server route writes with the Supabase service role; browser
-- anon/authenticated roles should not have direct table access.
create table if not exists public.contact_messages (
id uuid primary key default gen_random_uuid(),
name text,
email text not null,
subject text,
message text not null,
source text not null default 'landing',
user_agent text,
ip_hash text,
created_at timestamptz not null default now(),
responded_at timestamptz
);
create index if not exists idx_contact_messages_created_at
on public.contact_messages(created_at desc);
alter table public.contact_messages enable row level security;
revoke all privileges on table public.contact_messages
from anon, authenticated;

View file

@ -0,0 +1,36 @@
-- Migration date: 2026-05-13
-- Migration: convert projects.shared_with from text[] to jsonb.
-- tabular_reviews.shared_with is already jsonb and is intentionally untouched.
-- Only convert while shared_with is still text[]. Re-running the type change
-- over an already-jsonb column is unnecessary and the guard keeps it a no-op.
do $$
begin
if (
select data_type
from information_schema.columns
where table_schema = 'public'
and table_name = 'projects'
and column_name = 'shared_with'
) = 'ARRAY' then
alter table public.projects
alter column shared_with drop default;
alter table public.projects
alter column shared_with type jsonb
using case
when shared_with is null then '[]'::jsonb
else to_jsonb(shared_with)
end;
end if;
end $$;
alter table public.projects
alter column shared_with set default '[]'::jsonb;
alter table public.projects
alter column shared_with set not null;
create index if not exists projects_shared_with_idx
on public.projects using gin (shared_with);

View file

@ -0,0 +1,12 @@
-- Migration date: 2026-05-17
-- Persist selected document rows independently from generated cells.
-- This lets project-based tabular reviews keep an explicit document list even
-- when the review has no columns/cells or all rows have been removed.
alter table public.tabular_reviews
add column if not exists document_ids jsonb;
alter table public.tabular_reviews
alter column document_ids drop not null,
alter column document_ids drop default;

View file

@ -0,0 +1,41 @@
-- Migration date: 2026-05-23
-- CourtListener bulk-data indexes.
--
-- These tables hold lightweight lookup metadata imported from CourtListener
-- CSV exports. Full opinion bodies are stored in R2 at:
-- courtlistener/opinions/by-cluster/{cluster_id}/{opinion_id}.json
create table if not exists public.courtlistener_citation_index (
id bigint primary key,
volume text not null,
reporter text not null,
page text not null,
type integer,
cluster_id bigint not null,
date_created timestamptz,
date_modified timestamptz
);
create index if not exists courtlistener_citation_lookup_idx
on public.courtlistener_citation_index(volume, reporter, page);
create index if not exists courtlistener_citation_cluster_idx
on public.courtlistener_citation_index(cluster_id);
create table if not exists public.courtlistener_opinion_cluster_index (
id bigint primary key,
case_name text,
case_name_short text,
case_name_full text,
slug text,
date_filed date,
citation_count integer,
precedential_status text,
filepath_pdf_harvard text,
filepath_json_harvard text,
docket_id bigint
);
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;

View file

@ -0,0 +1,8 @@
-- Migration date: 2026-05-28
ALTER TABLE public.user_api_keys
DROP CONSTRAINT IF EXISTS user_api_keys_provider_check;
ALTER TABLE public.user_api_keys
ADD CONSTRAINT user_api_keys_provider_check
CHECK (provider IN ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));

View file

@ -0,0 +1,8 @@
-- Migration date: 2026-05-28
ALTER TABLE public.user_api_keys
DROP CONSTRAINT IF EXISTS user_api_keys_provider_check;
ALTER TABLE public.user_api_keys
ADD CONSTRAINT user_api_keys_provider_check
CHECK (provider IN ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));

View file

@ -0,0 +1,5 @@
-- Migration date: 2026-05-28
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS title_model text,
ADD COLUMN IF NOT EXISTS quote_model text;

View file

@ -0,0 +1,28 @@
-- Migration date: 2026-06-02
-- Add per-version file metadata.
--
-- documents is the stable container. document_versions owns the bytes for each
-- version, so file metadata that describes those bytes belongs here too.
--
-- Safe to run before application code changes: this only adds nullable columns
-- and backfills them from the parent document.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS file_type text,
ADD COLUMN IF NOT EXISTS size_bytes integer,
ADD COLUMN IF NOT EXISTS page_count integer;
UPDATE public.document_versions dv
SET
file_type = COALESCE(NULLIF(btrim(dv.file_type), ''), d.file_type),
size_bytes = COALESCE(dv.size_bytes, d.size_bytes),
page_count = COALESCE(dv.page_count, d.page_count)
FROM public.documents d
WHERE dv.document_id = d.id
AND (
dv.file_type IS NULL
OR btrim(dv.file_type) = ''
OR dv.size_bytes IS NULL
OR dv.page_count IS NULL
);

View file

@ -0,0 +1,13 @@
-- Migration date: 2026-06-02
-- Temporary live-Supabase migration: add document_versions.filename without
-- renaming or dropping document_versions.display_name yet.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS filename text;
UPDATE public.document_versions
SET filename = display_name
WHERE (filename IS NULL OR btrim(filename) = '')
AND display_name IS NOT NULL
AND btrim(display_name) <> '';

View file

@ -0,0 +1,51 @@
-- Migration date: 2026-06-02
-- Destructive follow-up migration: remove legacy document-level file metadata.
--
-- Run this only after application code writes file_type, size_bytes,
-- and page_count to document_versions and reads those values
-- from the active version.
DO $$
DECLARE
documents_file_metadata_count integer;
BEGIN
SELECT count(*)
INTO documents_file_metadata_count
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'documents'
AND column_name IN (
'file_type',
'size_bytes',
'page_count'
);
IF documents_file_metadata_count = 3 THEN
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS file_type text,
ADD COLUMN IF NOT EXISTS size_bytes integer,
ADD COLUMN IF NOT EXISTS page_count integer;
UPDATE public.document_versions dv
SET
file_type = COALESCE(NULLIF(btrim(dv.file_type), ''), d.file_type),
size_bytes = COALESCE(dv.size_bytes, d.size_bytes),
page_count = COALESCE(dv.page_count, d.page_count)
FROM public.documents d
WHERE dv.document_id = d.id
AND (
dv.file_type IS NULL
OR btrim(dv.file_type) = ''
OR dv.size_bytes IS NULL
OR dv.page_count IS NULL
);
END IF;
IF documents_file_metadata_count > 0 THEN
ALTER TABLE public.documents
DROP COLUMN IF EXISTS file_type,
DROP COLUMN IF EXISTS size_bytes,
DROP COLUMN IF EXISTS page_count;
END IF;
END $$;

View file

@ -0,0 +1,27 @@
-- Migration date: 2026-06-02
-- Migration: remove legacy document-level filename.
--
-- Before dropping the old column, copy any remaining legacy names onto
-- version rows that do not yet have their own filename/display value.
-- A later migration renames document_versions.display_name to filename.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'documents'
AND column_name = 'filename'
) THEN
UPDATE public.document_versions dv
SET display_name = d.filename
FROM public.documents d
WHERE dv.document_id = d.id
AND (dv.display_name IS NULL OR btrim(dv.display_name) = '');
ALTER TABLE public.documents
DROP COLUMN filename;
END IF;
END $$;

View file

@ -0,0 +1,12 @@
-- Migration date: 2026-06-03
-- Remove unused document structure trees.
--
-- Safe to run before or after the document metadata migration because both
-- columns are optional and dropped conditionally.
ALTER TABLE public.document_versions
DROP COLUMN IF EXISTS structure_tree;
ALTER TABLE public.documents
DROP COLUMN IF EXISTS structure_tree;

View file

@ -0,0 +1,192 @@
-- Migration date: 2026-06-06
-- OSS migration for the current backend/schema.sql diff.
--
-- This brings existing OSS Supabase databases in line with the updated fresh
-- schema: model preference columns, BYO provider expansion, per-version
-- document metadata, and CourtListener bulk lookup tables.
-- ---------------------------------------------------------------------------
-- User profiles
-- ---------------------------------------------------------------------------
alter table public.user_profiles
add column if not exists title_model text,
add column if not exists mfa_on_login boolean not null default false,
add column if not exists quote_model text;
-- ---------------------------------------------------------------------------
-- User API keys
-- ---------------------------------------------------------------------------
alter table public.user_api_keys
drop constraint if exists user_api_keys_provider_check;
alter table public.user_api_keys
add constraint user_api_keys_provider_check
check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));
alter table public.user_api_keys enable row level security;
drop policy if exists user_api_keys_own on public.user_api_keys;
-- ---------------------------------------------------------------------------
-- Document metadata now lives on document_versions
-- ---------------------------------------------------------------------------
alter table public.document_versions
add column if not exists filename text,
add column if not exists file_type text,
add column if not exists size_bytes integer,
add column if not exists page_count integer;
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'document_versions'
and column_name = 'display_name'
) then
update public.document_versions dv
set filename = dv.display_name
where (dv.filename is null or btrim(dv.filename) = '')
and dv.display_name is not null
and btrim(dv.display_name) <> '';
end if;
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'filename'
) then
update public.document_versions dv
set filename = d.filename
from public.documents d
where dv.document_id = d.id
and (dv.filename is null or btrim(dv.filename) = '')
and d.filename is not null
and btrim(d.filename) <> '';
end if;
end $$;
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'file_type'
) then
update public.document_versions dv
set file_type = coalesce(nullif(btrim(dv.file_type), ''), d.file_type)
from public.documents d
where dv.document_id = d.id
and (dv.file_type is null or btrim(dv.file_type) = '');
end if;
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'size_bytes'
) then
update public.document_versions dv
set size_bytes = d.size_bytes
from public.documents d
where dv.document_id = d.id
and dv.size_bytes is null
and d.size_bytes is not null;
end if;
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'page_count'
) then
update public.document_versions dv
set page_count = d.page_count
from public.documents d
where dv.document_id = d.id
and dv.page_count is null
and d.page_count is not null;
end if;
end $$;
alter table public.document_versions
drop column if exists display_name;
alter table public.documents
drop column if exists filename,
drop column if exists file_type,
drop column if exists size_bytes,
drop column if exists page_count,
drop column if exists structure_tree;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'document_versions_doc_version_unique'
and conrelid = 'public.document_versions'::regclass
) then
alter table public.document_versions
add constraint document_versions_doc_version_unique
unique (document_id, version_number);
end if;
end;
$$;
-- ---------------------------------------------------------------------------
-- CourtListener bulk-data indexes
-- ---------------------------------------------------------------------------
create table if not exists public.courtlistener_citation_index (
id bigint primary key,
volume text not null,
reporter text not null,
page text not null,
type integer,
cluster_id bigint not null,
date_created timestamptz,
date_modified timestamptz
);
create index if not exists courtlistener_citation_lookup_idx
on public.courtlistener_citation_index(volume, reporter, page);
create index if not exists courtlistener_citation_cluster_idx
on public.courtlistener_citation_index(cluster_id);
alter table public.courtlistener_citation_index enable row level security;
drop policy if exists cl_citation_read on public.courtlistener_citation_index;
create table if not exists public.courtlistener_opinion_cluster_index (
id bigint primary key,
case_name text,
case_name_short text,
case_name_full text,
slug text,
date_filed date,
citation_count integer,
precedential_status text,
filepath_pdf_harvard text,
filepath_json_harvard text,
docket_id bigint
);
alter table public.courtlistener_opinion_cluster_index enable row level security;
drop policy if exists cl_cluster_read on public.courtlistener_opinion_cluster_index;
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;

View file

@ -0,0 +1,16 @@
-- Migration date: 2026-06-10
-- Keep document version tombstones after deleting version file bytes.
-- Deleted versions remain visible in history but are ignored by active-file
-- lookups and cannot be opened/downloaded/replaced.
alter table public.document_versions
alter column storage_path drop not null;
alter table public.document_versions
add column if not exists deleted_at timestamptz,
add column if not exists deleted_by uuid;
create index if not exists document_versions_active_document_id_idx
on public.document_versions(document_id, created_at desc)
where deleted_at is null;

View file

@ -0,0 +1,4 @@
-- Migration date: 2026-06-10
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS mfa_on_login boolean NOT NULL DEFAULT false;

View file

@ -0,0 +1,14 @@
-- Migration date: 2026-06-11
-- Per-user toggle for US legal research (CourtListener) tools in chat.
--
-- When true (the default), the CourtListener case-law tools and their system
-- prompt are exposed to the chat assistant. When false, both the tools and the
-- prompt are excluded from the chat. Surfaced in account settings under
-- Features > Legal Research > Jurisdiction > US.
--
-- Safe to run before application code changes: this only adds a column with a
-- default that preserves the existing (enabled) behaviour for all rows.
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS legal_research_us boolean NOT NULL DEFAULT true;

View file

@ -0,0 +1,39 @@
-- Migration date: 2026-06-13
-- Global assistant chats overview read model.
-- Returns the user's own chats plus chats under projects they own.
create or replace function public.get_chats_overview(
p_user_id text,
p_limit integer default null
)
returns table (
id uuid,
project_id uuid,
user_id text,
title text,
created_at timestamptz
)
language sql
stable
as $$
select
c.id,
c.project_id,
c.user_id,
c.title,
c.created_at
from public.chats c
where c.user_id = p_user_id
or exists (
select 1
from public.projects p
where p.id = c.project_id
and p.user_id = p_user_id
)
order by c.created_at desc
limit case
when p_limit is null then null
else greatest(1, least(p_limit, 100))
end;
$$;

View file

@ -0,0 +1,81 @@
-- Migration date: 2026-06-13
-- Projects overview read model.
-- Returns the project list, owner display name, and per-project counts in one
-- database call for the /projects table.
create or replace function public.get_projects_overview(
p_user_id text,
p_user_email text default null
)
returns table (
id uuid,
user_id text,
name text,
cm_number text,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
is_owner boolean,
owner_display_name text,
owner_email text,
document_count integer,
chat_count integer,
review_count integer
)
language sql
stable
as $$
with visible_projects as (
select p.*
from public.projects p
where p.user_id = p_user_id
or (
coalesce(p_user_email, '') <> ''
and p.user_id <> p_user_id
and p.shared_with @> jsonb_build_array(p_user_email)
)
),
document_counts as (
select d.project_id, count(*)::integer as document_count
from public.documents d
where d.project_id in (select vp.id from visible_projects vp)
group by d.project_id
),
chat_counts as (
select c.project_id, count(*)::integer as chat_count
from public.chats c
where c.project_id in (select vp.id from visible_projects vp)
group by c.project_id
),
review_counts as (
select tr.project_id, count(*)::integer as review_count
from public.tabular_reviews tr
where tr.project_id in (select vp.id from visible_projects vp)
group by tr.project_id
)
select
vp.id,
vp.user_id,
vp.name,
vp.cm_number,
vp.shared_with,
vp.created_at,
vp.updated_at,
vp.user_id = p_user_id as is_owner,
nullif(trim(up.display_name), '') as owner_display_name,
null::text as owner_email,
coalesce(dc.document_count, 0) as document_count,
coalesce(cc.chat_count, 0) as chat_count,
coalesce(rc.review_count, 0) as review_count
from visible_projects vp
left join public.user_profiles up
on up.user_id::text = vp.user_id
left join document_counts dc
on dc.project_id = vp.id
left join chat_counts cc
on cc.project_id = vp.id
left join review_counts rc
on rc.project_id = vp.id
order by vp.created_at desc;
$$;

View file

@ -0,0 +1,96 @@
-- Migration date: 2026-06-13
-- Tabular reviews overview read model.
-- Returns visible reviews plus document_count in one database call.
create or replace function public.get_tabular_reviews_overview(
p_user_id text,
p_user_email text default null,
p_project_id text default null
)
returns table (
id uuid,
project_id uuid,
user_id text,
title text,
columns_config jsonb,
document_ids jsonb,
workflow_id uuid,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
is_owner boolean,
document_count integer
)
language sql
stable
as $$
with accessible_projects as (
select p.id
from public.projects p
where p.user_id = p_user_id
or (
coalesce(p_user_email, '') <> ''
and p.user_id <> p_user_id
and p.shared_with @> jsonb_build_array(p_user_email)
)
),
visible_reviews as (
select tr.*
from public.tabular_reviews tr
where (p_project_id is null or tr.project_id::text = p_project_id)
and (
p_project_id is null
or exists (
select 1
from accessible_projects ap
where ap.id::text = p_project_id
)
)
and (
tr.user_id = p_user_id
or (
tr.project_id in (select ap.id from accessible_projects ap)
and tr.user_id <> p_user_id
)
or (
p_project_id is null
and coalesce(p_user_email, '') <> ''
and tr.user_id <> p_user_id
and tr.shared_with @> jsonb_build_array(p_user_email)
)
)
),
cell_document_counts as (
select
tc.review_id,
count(distinct tc.document_id)::integer as document_count
from public.tabular_cells tc
where tc.review_id in (select vr.id from visible_reviews vr)
group by tc.review_id
)
select
vr.id,
vr.project_id,
vr.user_id,
vr.title,
vr.columns_config,
vr.document_ids,
vr.workflow_id,
vr.shared_with,
vr.created_at,
vr.updated_at,
vr.user_id = p_user_id as is_owner,
case
when jsonb_typeof(vr.document_ids) = 'array'
then (
select count(distinct doc_id.value)::integer
from jsonb_array_elements_text(vr.document_ids) as doc_id(value)
)
else coalesce(cdc.document_count, 0)
end as document_count
from visible_reviews vr
left join cell_document_counts cdc
on cdc.review_id = vr.id
order by vr.created_at desc;
$$;

View file

@ -0,0 +1,92 @@
-- Server-side MCP client connector storage.
-- Auth material is encrypted by the backend before insert. RLS is enabled with
-- no browser policies so only the service-role backend can read connector
-- URLs, encrypted auth config, token material, tool cache, and audit logs.
CREATE TABLE IF NOT EXISTS public.user_mcp_connectors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL,
transport text NOT NULL DEFAULT 'streamable_http'
CHECK (transport IN ('streamable_http')),
server_url text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
tool_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
encrypted_auth_config text,
auth_config_iv text,
auth_config_tag text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_mcp_connectors_user
ON public.user_mcp_connectors(user_id);
ALTER TABLE public.user_mcp_connectors ENABLE ROW LEVEL SECURITY;
CREATE TABLE IF NOT EXISTS public.user_mcp_oauth_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
connector_id uuid NOT NULL REFERENCES public.user_mcp_connectors(id) ON DELETE CASCADE,
encrypted_access_token text,
access_token_iv text,
access_token_tag text,
encrypted_refresh_token text,
refresh_token_iv text,
refresh_token_tag text,
token_type text,
scope text,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(connector_id)
);
ALTER TABLE public.user_mcp_oauth_tokens ENABLE ROW LEVEL SECURITY;
CREATE TABLE IF NOT EXISTS public.user_mcp_connector_tools (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
connector_id uuid NOT NULL REFERENCES public.user_mcp_connectors(id) ON DELETE CASCADE,
tool_name text NOT NULL,
openai_tool_name text NOT NULL,
title text,
description text,
input_schema jsonb NOT NULL DEFAULT '{"type":"object","properties":{}}'::jsonb,
output_schema jsonb,
annotations jsonb NOT NULL DEFAULT '{}'::jsonb,
enabled boolean NOT NULL DEFAULT true,
requires_confirmation boolean NOT NULL DEFAULT false,
last_seen_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(connector_id, tool_name),
UNIQUE(openai_tool_name)
);
CREATE INDEX IF NOT EXISTS idx_user_mcp_connector_tools_connector
ON public.user_mcp_connector_tools(connector_id);
ALTER TABLE public.user_mcp_connector_tools ENABLE ROW LEVEL SECURITY;
CREATE TABLE IF NOT EXISTS public.user_mcp_tool_audit_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
connector_id uuid NOT NULL REFERENCES public.user_mcp_connectors(id) ON DELETE CASCADE,
tool_id uuid REFERENCES public.user_mcp_connector_tools(id) ON DELETE SET NULL,
tool_name text NOT NULL,
openai_tool_name text NOT NULL,
status text NOT NULL CHECK (status IN ('ok', 'error')),
error_message text,
duration_ms integer NOT NULL DEFAULT 0,
result_size_chars integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_mcp_tool_audit_logs_user_created
ON public.user_mcp_tool_audit_logs(user_id, created_at DESC);
ALTER TABLE public.user_mcp_tool_audit_logs ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.user_mcp_connectors FROM anon, authenticated;
REVOKE ALL ON public.user_mcp_oauth_tokens FROM anon, authenticated;
REVOKE ALL ON public.user_mcp_connector_tools FROM anon, authenticated;
REVOKE ALL ON public.user_mcp_tool_audit_logs FROM anon, authenticated;

View file

@ -0,0 +1,90 @@
-- Migration date: 2026-06-13
-- Workflows overview read model.
-- Returns owned and shared workflows in one database call.
create or replace function public.get_workflows_overview(
p_user_id text,
p_user_email text default null,
p_type text default null
)
returns table (
id uuid,
user_id text,
title text,
type text,
prompt_md text,
columns_config jsonb,
practice text,
is_system boolean,
created_at timestamptz,
allow_edit boolean,
is_owner boolean,
shared_by_name text
)
language sql
stable
as $$
with owned as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.practice,
false as is_system,
w.created_at,
true as allow_edit,
true as is_owner,
null::text as shared_by_name,
0 as sort_bucket
from public.workflows w
where w.user_id::text = p_user_id
and (p_type is null or w.type = p_type)
),
shared as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.practice,
false as is_system,
w.created_at,
ws.allow_edit,
false as is_owner,
nullif(trim(up.display_name), '') as shared_by_name,
1 as sort_bucket
from public.workflow_shares ws
join public.workflows w
on w.id = ws.workflow_id
left join public.user_profiles up
on up.user_id::text = ws.shared_by_user_id::text
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
and (p_type is null or w.type = p_type)
),
visible_workflows as (
select * from owned
union all
select * from shared
)
select
vw.id,
vw.user_id,
vw.title,
vw.type,
vw.prompt_md,
vw.columns_config,
vw.practice,
vw.is_system,
vw.created_at,
vw.allow_edit,
vw.is_owner,
vw.shared_by_name
from visible_workflows vw
order by vw.sort_bucket asc, vw.created_at desc;
$$;

View file

@ -0,0 +1,42 @@
-- Migration date: 2026-06-15
-- Adds OAuth metadata/state needed for HTTP MCP connectors that authorize via
-- OAuth 2.x instead of pasted bearer tokens.
ALTER TABLE public.user_mcp_connectors
ADD COLUMN IF NOT EXISTS auth_type text NOT NULL DEFAULT 'none'
CHECK (auth_type IN ('none', 'bearer', 'oauth'));
UPDATE public.user_mcp_connectors
SET auth_type = CASE
WHEN encrypted_auth_config IS NOT NULL THEN 'bearer'
ELSE 'none'
END
WHERE auth_type IS NULL OR auth_type = 'none';
ALTER TABLE public.user_mcp_oauth_tokens
ADD COLUMN IF NOT EXISTS authorization_server text,
ADD COLUMN IF NOT EXISTS token_endpoint text,
ADD COLUMN IF NOT EXISTS client_id text,
ADD COLUMN IF NOT EXISTS encrypted_client_secret text,
ADD COLUMN IF NOT EXISTS client_secret_iv text,
ADD COLUMN IF NOT EXISTS client_secret_tag text,
ADD COLUMN IF NOT EXISTS resource text;
CREATE TABLE IF NOT EXISTS public.user_mcp_oauth_states (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
connector_id uuid NOT NULL REFERENCES public.user_mcp_connectors(id) ON DELETE CASCADE,
state_hash text NOT NULL UNIQUE,
encrypted_state_config text NOT NULL,
state_config_iv text NOT NULL,
state_config_tag text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_mcp_oauth_states_expires
ON public.user_mcp_oauth_states(expires_at);
ALTER TABLE public.user_mcp_oauth_states ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.user_mcp_oauth_states FROM anon, authenticated;

View file

@ -0,0 +1,120 @@
-- Migration date: 2026-06-25
-- Custom workflow metadata fields and workflow overview read model. System
-- workflow versions remain generated from the repository metadata and are not
-- stored on user workflow rows.
drop function if exists public.get_workflows_overview(text, text, text);
alter table public.workflows
drop column if exists author,
drop column if exists category,
drop column if exists is_system,
add column if not exists language text default 'English',
add column if not exists jurisdictions text[] default array['General']::text[];
alter table public.workflows
alter column language set default 'English',
alter column practice set default 'General Transactions',
alter column jurisdictions set default array['General']::text[];
update public.workflows
set
language = coalesce(nullif(trim(language), ''), 'English'),
practice = coalesce(nullif(trim(practice), ''), 'General Transactions'),
jurisdictions = coalesce(jurisdictions, array['General']::text[])
where user_id is not null;
create or replace function public.get_workflows_overview(
p_user_id text,
p_user_email text default null,
p_type text default null
)
returns table (
id uuid,
user_id text,
title text,
type text,
prompt_md text,
columns_config jsonb,
language text,
practice text,
jurisdictions text[],
is_system boolean,
created_at timestamptz,
allow_edit boolean,
is_owner boolean,
shared_by_name text
)
language sql
stable
as $$
with owned as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.language,
w.practice,
w.jurisdictions,
false as is_system,
w.created_at,
true as allow_edit,
true as is_owner,
null::text as shared_by_name,
0 as sort_bucket
from public.workflows w
where w.user_id::text = p_user_id
and (p_type is null or w.type = p_type)
),
shared as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.language,
w.practice,
w.jurisdictions,
false as is_system,
w.created_at,
ws.allow_edit,
false as is_owner,
nullif(trim(up.display_name), '') as shared_by_name,
1 as sort_bucket
from public.workflow_shares ws
join public.workflows w
on w.id = ws.workflow_id
left join public.user_profiles up
on up.user_id::text = ws.shared_by_user_id::text
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
and (p_type is null or w.type = p_type)
),
visible_workflows as (
select * from owned
union all
select * from shared
)
select
vw.id,
vw.user_id,
vw.title,
vw.type,
vw.prompt_md,
vw.columns_config,
vw.language,
vw.practice,
vw.jurisdictions,
vw.is_system,
vw.created_at,
vw.allow_edit,
vw.is_owner,
vw.shared_by_name
from visible_workflows vw
order by vw.sort_bucket asc, vw.created_at desc;
$$;

View file

@ -0,0 +1,38 @@
-- Migration date: 2026-06-29
-- Review queue for user-submitted workflows that may later be published to the
-- open-source workflow repository. The backend writes with the service role.
create table if not exists public.workflow_open_source_submissions (
id uuid primary key default gen_random_uuid(),
workflow_id uuid not null references public.workflows(id) on delete cascade,
submitted_by_user_id text not null,
submitter_email text,
submitter_name text,
contributor_mode text not null default 'anonymous',
status text not null default 'pending',
snapshot jsonb not null,
submitted_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
reviewed_at timestamptz,
review_notes text,
constraint workflow_open_source_submissions_status_check
check (status in ('pending', 'approved', 'rejected')),
constraint workflow_open_source_submissions_contributor_mode_check
check (contributor_mode in ('named', 'anonymous'))
);
create unique index if not exists idx_workflow_open_source_submissions_pending
on public.workflow_open_source_submissions(workflow_id, submitted_by_user_id)
where status = 'pending';
create index if not exists idx_workflow_open_source_submissions_reviewer_queue
on public.workflow_open_source_submissions(status, submitted_at desc);
create index if not exists idx_workflow_open_source_submissions_submitter
on public.workflow_open_source_submissions(submitted_by_user_id, submitted_at desc);
alter table public.workflow_open_source_submissions enable row level security;
revoke all privileges on table public.workflow_open_source_submissions
from anon, authenticated;

View file

@ -0,0 +1,41 @@
-- Mirror auth.users.email into user_profiles so backend sharing checks can
-- resolve one email without scanning Supabase Auth users.
alter table public.user_profiles
add column if not exists email text;
update public.user_profiles up
set email = lower(au.email)
from auth.users au
where up.user_id = au.id
and au.email is not null
and (
up.email is null
or up.email <> lower(au.email)
);
create unique index if not exists user_profiles_email_lower_unique
on public.user_profiles (lower(email))
where email is not null and btrim(email) <> '';
create index if not exists idx_user_profiles_email
on public.user_profiles(email);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.user_profiles (user_id, email)
values (new.id, lower(new.email))
on conflict (user_id) do update
set email = excluded.email,
updated_at = now();
return new;
exception when others then
-- Never block signup if the profile insert fails.
return new;
end;
$$;

View file

@ -0,0 +1,84 @@
-- Add optional practice metadata to projects and expose it in the overview RPC.
alter table public.projects
add column if not exists practice text;
drop function if exists public.get_projects_overview(text, text);
create or replace function public.get_projects_overview(
p_user_id text,
p_user_email text default null
)
returns table (
id uuid,
user_id text,
name text,
cm_number text,
practice text,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
is_owner boolean,
owner_display_name text,
owner_email text,
document_count integer,
chat_count integer,
review_count integer
)
language sql
stable
as $$
with visible_projects as (
select p.*
from public.projects p
where p.user_id = p_user_id
or (
coalesce(p_user_email, '') <> ''
and p.user_id <> p_user_id
and p.shared_with @> jsonb_build_array(p_user_email)
)
),
document_counts as (
select d.project_id, count(*)::integer as document_count
from public.documents d
where d.project_id in (select vp.id from visible_projects vp)
group by d.project_id
),
chat_counts as (
select c.project_id, count(*)::integer as chat_count
from public.chats c
where c.project_id in (select vp.id from visible_projects vp)
group by c.project_id
),
review_counts as (
select tr.project_id, count(*)::integer as review_count
from public.tabular_reviews tr
where tr.project_id in (select vp.id from visible_projects vp)
group by tr.project_id
)
select
vp.id,
vp.user_id,
vp.name,
vp.cm_number,
vp.practice,
vp.shared_with,
vp.created_at,
vp.updated_at,
vp.user_id = p_user_id as is_owner,
nullif(trim(up.display_name), '') as owner_display_name,
null::text as owner_email,
coalesce(dc.document_count, 0) as document_count,
coalesce(cc.chat_count, 0) as chat_count,
coalesce(rc.review_count, 0) as review_count
from visible_projects vp
left join public.user_profiles up
on up.user_id::text = vp.user_id
left join document_counts dc
on dc.project_id = vp.id
left join chat_counts cc
on cc.project_id = vp.id
left join review_counts rc
on rc.project_id = vp.id
order by vp.created_at desc;
$$;

View file

@ -0,0 +1,19 @@
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_messages'
and column_name = 'annotations'
) and not exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_messages'
and column_name = 'citations'
) then
alter table public.chat_messages
rename column annotations to citations;
end if;
end $$;

View file

@ -0,0 +1,71 @@
-- Migration date: 2026-07-10
alter table public.documents
add column if not exists library_kind text default 'file';
update public.documents
set library_kind = 'file'
where library_kind is null;
alter table public.documents
alter column library_kind set default 'file',
alter column library_kind set not null;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'documents_library_kind_check'
and conrelid = 'public.documents'::regclass
) then
alter table public.documents
add constraint documents_library_kind_check
check (library_kind in ('file', 'template'));
end if;
end;
$$;
create table if not exists public.library_folders (
id uuid primary key default gen_random_uuid(),
user_id text not null,
library_kind text not null default 'file',
name text not null,
parent_folder_id uuid references public.library_folders(id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint library_folders_kind_check
check (library_kind in ('file', 'template'))
);
alter table public.documents
add column if not exists library_folder_id uuid;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'documents_library_folder_id_fkey'
and conrelid = 'public.documents'::regclass
) then
alter table public.documents
add constraint documents_library_folder_id_fkey
foreign key (library_folder_id)
references public.library_folders(id)
on delete set null;
end if;
end;
$$;
create index if not exists idx_library_folders_user_kind
on public.library_folders(user_id, library_kind);
create index if not exists idx_library_folders_parent
on public.library_folders(parent_folder_id);
create index if not exists idx_documents_library_kind_folder
on public.documents(user_id, library_kind, library_folder_id)
where project_id is null;
revoke all on public.library_folders from anon, authenticated;

View file

@ -0,0 +1,12 @@
-- Add chat_messages.workflow.
--
-- The app persists a `workflow` field on user messages
-- (backend/src/routes/chat.ts, projectChat.ts) and reads it back when rendering
-- chat history (frontend ChatView -> UserMessage, to show which workflow the
-- message was sent under). The column was referenced in code but never created
-- by schema.sql or any migration, so every user-message insert failed with
-- PostgREST PGRST204 ("Could not find the 'workflow' column") and was dropped
-- silently — user prompts vanished on reload while the assistant reply
-- persisted. Mirrors the existing content/files jsonb columns.
alter table public.chat_messages
add column if not exists workflow jsonb;

2427
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,13 +5,17 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js" "start": "node dist/index.js",
"test": "vitest run",
"test:stack": "bash scripts/test-stack.sh",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.90.0", "@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0",
"@google/genai": "^1.50.1", "@google/genai": "^1.50.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.0", "docx": "^9.5.0",
@ -26,16 +30,22 @@
"mammoth": "^1.9.0", "mammoth": "^1.9.0",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"resend": "^4.5.1" "resend": "^4.5.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.14.1", "@types/node": "^22.14.1",
"@types/supertest": "^7.2.1",
"@vitest/coverage-v8": "^4.1.9",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"supertest": "^7.2.2",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"vitest": "^4.1.9"
}, },
"license": "AGPL-3.0-only" "license": "AGPL-3.0-only"
} }

View file

@ -1,7 +1,7 @@
-- Mike Supabase schema -- Mike Supabase schema
-- Based on supabase-migration.sql plus the later backend/migrations/*.sql files. -- Use this for a fresh Supabase database. Existing deployments should instead
-- Use this for a fresh Supabase database. Existing deployments should continue -- apply the dated incremental migration files in backend/migrations that are
-- to apply the incremental migration files instead. -- newer than the version of Mike they currently have deployed.
create extension if not exists "pgcrypto"; create extension if not exists "pgcrypto";
@ -12,12 +12,17 @@ create extension if not exists "pgcrypto";
create table if not exists public.user_profiles ( create table if not exists public.user_profiles (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
user_id uuid not null unique references auth.users(id) on delete cascade, user_id uuid not null unique references auth.users(id) on delete cascade,
email text,
display_name text, display_name text,
organisation text, organisation text,
tier text not null default 'Free', tier text not null default 'Free',
message_credits_used integer not null default 0, message_credits_used integer not null default 0,
credits_reset_date timestamptz not null default (now() + interval '30 days'), credits_reset_date timestamptz not null default (now() + interval '30 days'),
title_model text,
tabular_model text not null default 'gemini-3-flash-preview', tabular_model text not null default 'gemini-3-flash-preview',
quote_model text,
mfa_on_login boolean not null default false,
legal_research_us boolean not null default true,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_at timestamptz not null default now() updated_at timestamptz not null default now()
); );
@ -25,6 +30,13 @@ create table if not exists public.user_profiles (
create index if not exists idx_user_profiles_user create index if not exists idx_user_profiles_user
on public.user_profiles(user_id); on public.user_profiles(user_id);
create unique index if not exists user_profiles_email_lower_unique
on public.user_profiles (lower(email))
where email is not null and btrim(email) <> '';
create index if not exists idx_user_profiles_email
on public.user_profiles(email);
create or replace function public.handle_new_user() create or replace function public.handle_new_user()
returns trigger returns trigger
language plpgsql language plpgsql
@ -32,9 +44,11 @@ security definer
set search_path = public set search_path = public
as $$ as $$
begin begin
insert into public.user_profiles (user_id) insert into public.user_profiles (user_id, email)
values (new.id) values (new.id, lower(new.email))
on conflict (user_id) do nothing; on conflict (user_id) do update
set email = excluded.email,
updated_at = now();
return new; return new;
exception when others then exception when others then
-- Never block signup if the profile insert fails. -- Never block signup if the profile insert fails.
@ -50,7 +64,7 @@ create trigger on_auth_user_created
create table if not exists public.user_api_keys ( create table if not exists public.user_api_keys (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade,
provider text not null check (provider in ('claude', 'gemini', 'openai')), provider text not null check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener')),
encrypted_key text not null, encrypted_key text not null,
iv text not null, iv text not null,
auth_tag text not null, auth_tag text not null,
@ -62,6 +76,117 @@ create table if not exists public.user_api_keys (
create index if not exists idx_user_api_keys_user create index if not exists idx_user_api_keys_user
on public.user_api_keys(user_id); on public.user_api_keys(user_id);
alter table public.user_api_keys enable row level security;
create table if not exists public.user_mcp_connectors (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
transport text not null default 'streamable_http'
check (transport in ('streamable_http')),
server_url text not null,
auth_type text not null default 'none'
check (auth_type in ('none', 'bearer', 'oauth')),
enabled boolean not null default true,
tool_policy jsonb not null default '{}'::jsonb,
encrypted_auth_config text,
auth_config_iv text,
auth_config_tag text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_user_mcp_connectors_user
on public.user_mcp_connectors(user_id);
alter table public.user_mcp_connectors enable row level security;
create table if not exists public.user_mcp_oauth_tokens (
id uuid primary key default gen_random_uuid(),
connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade,
encrypted_access_token text,
access_token_iv text,
access_token_tag text,
encrypted_refresh_token text,
refresh_token_iv text,
refresh_token_tag text,
token_type text,
scope text,
expires_at timestamptz,
authorization_server text,
token_endpoint text,
client_id text,
encrypted_client_secret text,
client_secret_iv text,
client_secret_tag text,
resource text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(connector_id)
);
alter table public.user_mcp_oauth_tokens enable row level security;
create table if not exists public.user_mcp_oauth_states (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade,
state_hash text not null unique,
encrypted_state_config text not null,
state_config_iv text not null,
state_config_tag text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create index if not exists idx_user_mcp_oauth_states_expires
on public.user_mcp_oauth_states(expires_at);
alter table public.user_mcp_oauth_states enable row level security;
create table if not exists public.user_mcp_connector_tools (
id uuid primary key default gen_random_uuid(),
connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade,
tool_name text not null,
openai_tool_name text not null,
title text,
description text,
input_schema jsonb not null default '{"type":"object","properties":{}}'::jsonb,
output_schema jsonb,
annotations jsonb not null default '{}'::jsonb,
enabled boolean not null default true,
requires_confirmation boolean not null default false,
last_seen_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(connector_id, tool_name),
unique(openai_tool_name)
);
create index if not exists idx_user_mcp_connector_tools_connector
on public.user_mcp_connector_tools(connector_id);
alter table public.user_mcp_connector_tools enable row level security;
create table if not exists public.user_mcp_tool_audit_logs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade,
tool_id uuid references public.user_mcp_connector_tools(id) on delete set null,
tool_name text not null,
openai_tool_name text not null,
status text not null check (status in ('ok', 'error')),
error_message text,
duration_ms integer not null default 0,
result_size_chars integer not null default 0,
created_at timestamptz not null default now()
);
create index if not exists idx_user_mcp_tool_audit_logs_user_created
on public.user_mcp_tool_audit_logs(user_id, created_at desc);
alter table public.user_mcp_tool_audit_logs enable row level security;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Projects and documents -- Projects and documents
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -71,6 +196,7 @@ create table if not exists public.projects (
user_id text not null, user_id text not null,
name text not null, name text not null,
cm_number text, cm_number text,
practice text,
visibility text not null default 'private', visibility text not null default 'private',
shared_with jsonb not null default '[]'::jsonb, shared_with jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
@ -96,19 +222,36 @@ create table if not exists public.project_subfolders (
create index if not exists idx_project_subfolders_project create index if not exists idx_project_subfolders_project
on public.project_subfolders(project_id); on public.project_subfolders(project_id);
create table if not exists public.library_folders (
id uuid primary key default gen_random_uuid(),
user_id text not null,
library_kind text not null default 'file',
name text not null,
parent_folder_id uuid references public.library_folders(id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint library_folders_kind_check
check (library_kind in ('file', 'template'))
);
create index if not exists idx_library_folders_user_kind
on public.library_folders(user_id, library_kind);
create index if not exists idx_library_folders_parent
on public.library_folders(parent_folder_id);
create table if not exists public.documents ( create table if not exists public.documents (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade, project_id uuid references public.projects(id) on delete cascade,
user_id text not null, user_id text not null,
filename text not null,
file_type text,
size_bytes integer not null default 0,
page_count integer,
structure_tree jsonb,
status text not null default 'pending', status text not null default 'pending',
folder_id uuid references public.project_subfolders(id) on delete set null, folder_id uuid references public.project_subfolders(id) on delete set null,
library_kind text not null default 'file',
library_folder_id uuid references public.library_folders(id) on delete set null,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_at timestamptz not null default now() updated_at timestamptz not null default now(),
constraint documents_library_kind_check
check (library_kind in ('file', 'template'))
); );
create index if not exists idx_documents_user_project create index if not exists idx_documents_user_project
@ -117,14 +260,23 @@ create index if not exists idx_documents_user_project
create index if not exists idx_documents_project_folder create index if not exists idx_documents_project_folder
on public.documents(project_id, folder_id); on public.documents(project_id, folder_id);
create index if not exists idx_documents_library_kind_folder
on public.documents(user_id, library_kind, library_folder_id)
where project_id is null;
create table if not exists public.document_versions ( create table if not exists public.document_versions (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade, document_id uuid not null references public.documents(id) on delete cascade,
storage_path text not null, storage_path text,
pdf_storage_path text, pdf_storage_path text,
source text not null default 'upload', source text not null default 'upload',
version_number integer, version_number integer,
display_name text, filename text,
file_type text,
size_bytes integer,
page_count integer,
deleted_at timestamptz,
deleted_by uuid,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
constraint document_versions_source_check constraint document_versions_source_check
check (source = any (array[ check (source = any (array[
@ -140,9 +292,28 @@ create table if not exists public.document_versions (
create index if not exists document_versions_document_id_idx create index if not exists document_versions_document_id_idx
on public.document_versions(document_id, created_at desc); on public.document_versions(document_id, created_at desc);
create index if not exists document_versions_active_document_id_idx
on public.document_versions(document_id, created_at desc)
where deleted_at is null;
create index if not exists document_versions_doc_vnum_idx create index if not exists document_versions_doc_vnum_idx
on public.document_versions(document_id, version_number); on public.document_versions(document_id, version_number);
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'document_versions_doc_version_unique'
and conrelid = 'public.document_versions'::regclass
) then
alter table public.document_versions
add constraint document_versions_doc_version_unique
unique (document_id, version_number);
end if;
end;
$$;
alter table public.documents alter table public.documents
add column if not exists current_version_id uuid add column if not exists current_version_id uuid
references public.document_versions(id) on delete set null; references public.document_versions(id) on delete set null;
@ -189,8 +360,9 @@ create table if not exists public.workflows (
type text not null, type text not null,
prompt_md text, prompt_md text,
columns_config jsonb, columns_config jsonb,
practice text, language text default 'English',
is_system boolean not null default false, practice text default 'General Transactions',
jurisdictions text[] default array['General']::text[],
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );
@ -225,6 +397,100 @@ create index if not exists workflow_shares_workflow_id_idx
create index if not exists workflow_shares_email_idx create index if not exists workflow_shares_email_idx
on public.workflow_shares(shared_with_email); on public.workflow_shares(shared_with_email);
create or replace function public.get_workflows_overview(
p_user_id text,
p_user_email text default null,
p_type text default null
)
returns table (
id uuid,
user_id text,
title text,
type text,
prompt_md text,
columns_config jsonb,
language text,
practice text,
jurisdictions text[],
is_system boolean,
created_at timestamptz,
allow_edit boolean,
is_owner boolean,
shared_by_name text
)
language sql
stable
as $$
with owned as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.language,
w.practice,
w.jurisdictions,
false as is_system,
w.created_at,
true as allow_edit,
true as is_owner,
null::text as shared_by_name,
0 as sort_bucket
from public.workflows w
where w.user_id::text = p_user_id
and (p_type is null or w.type = p_type)
),
shared as (
select
w.id,
w.user_id::text as user_id,
w.title,
w.type,
w.prompt_md,
w.columns_config,
w.language,
w.practice,
w.jurisdictions,
false as is_system,
w.created_at,
ws.allow_edit,
false as is_owner,
nullif(trim(up.display_name), '') as shared_by_name,
1 as sort_bucket
from public.workflow_shares ws
join public.workflows w
on w.id = ws.workflow_id
left join public.user_profiles up
on up.user_id::text = ws.shared_by_user_id::text
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
and (p_type is null or w.type = p_type)
),
visible_workflows as (
select * from owned
union all
select * from shared
)
select
vw.id,
vw.user_id,
vw.title,
vw.type,
vw.prompt_md,
vw.columns_config,
vw.language,
vw.practice,
vw.jurisdictions,
vw.is_system,
vw.created_at,
vw.allow_edit,
vw.is_owner,
vw.shared_by_name
from visible_workflows vw
order by vw.sort_bucket asc, vw.created_at desc;
$$;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Assistant chats -- Assistant chats
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -243,13 +509,49 @@ create index if not exists idx_chats_user
create index if not exists idx_chats_project create index if not exists idx_chats_project
on public.chats(project_id); on public.chats(project_id);
create or replace function public.get_chats_overview(
p_user_id text,
p_limit integer default null
)
returns table (
id uuid,
project_id uuid,
user_id text,
title text,
created_at timestamptz
)
language sql
stable
as $$
select
c.id,
c.project_id,
c.user_id,
c.title,
c.created_at
from public.chats c
where c.user_id = p_user_id
or exists (
select 1
from public.projects p
where p.id = c.project_id
and p.user_id = p_user_id
)
order by c.created_at desc
limit case
when p_limit is null then null
else greatest(1, least(p_limit, 100))
end;
$$;
create table if not exists public.chat_messages ( create table if not exists public.chat_messages (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
chat_id uuid not null references public.chats(id) on delete cascade, chat_id uuid not null references public.chats(id) on delete cascade,
role text not null, role text not null,
content jsonb, content jsonb,
files jsonb, files jsonb,
annotations jsonb, workflow jsonb,
citations jsonb,
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );
@ -300,6 +602,84 @@ create index if not exists idx_tabular_reviews_project
create index if not exists tabular_reviews_shared_with_idx create index if not exists tabular_reviews_shared_with_idx
on public.tabular_reviews using gin (shared_with); on public.tabular_reviews using gin (shared_with);
create or replace function public.get_projects_overview(
p_user_id text,
p_user_email text default null
)
returns table (
id uuid,
user_id text,
name text,
cm_number text,
practice text,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
is_owner boolean,
owner_display_name text,
owner_email text,
document_count integer,
chat_count integer,
review_count integer
)
language sql
stable
as $$
with visible_projects as (
select p.*
from public.projects p
where p.user_id = p_user_id
or (
coalesce(p_user_email, '') <> ''
and p.user_id <> p_user_id
and p.shared_with @> jsonb_build_array(p_user_email)
)
),
document_counts as (
select d.project_id, count(*)::integer as document_count
from public.documents d
where d.project_id in (select vp.id from visible_projects vp)
group by d.project_id
),
chat_counts as (
select c.project_id, count(*)::integer as chat_count
from public.chats c
where c.project_id in (select vp.id from visible_projects vp)
group by c.project_id
),
review_counts as (
select tr.project_id, count(*)::integer as review_count
from public.tabular_reviews tr
where tr.project_id in (select vp.id from visible_projects vp)
group by tr.project_id
)
select
vp.id,
vp.user_id,
vp.name,
vp.cm_number,
vp.practice,
vp.shared_with,
vp.created_at,
vp.updated_at,
vp.user_id = p_user_id as is_owner,
nullif(trim(up.display_name), '') as owner_display_name,
null::text as owner_email,
coalesce(dc.document_count, 0) as document_count,
coalesce(cc.chat_count, 0) as chat_count,
coalesce(rc.review_count, 0) as review_count
from visible_projects vp
left join public.user_profiles up
on up.user_id::text = vp.user_id
left join document_counts dc
on dc.project_id = vp.id
left join chat_counts cc
on cc.project_id = vp.id
left join review_counts rc
on rc.project_id = vp.id
order by vp.created_at desc;
$$;
create table if not exists public.tabular_cells ( create table if not exists public.tabular_cells (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade, review_id uuid not null references public.tabular_reviews(id) on delete cascade,
@ -314,6 +694,98 @@ create table if not exists public.tabular_cells (
create index if not exists idx_tabular_cells_review create index if not exists idx_tabular_cells_review
on public.tabular_cells(review_id, document_id, column_index); on public.tabular_cells(review_id, document_id, column_index);
create or replace function public.get_tabular_reviews_overview(
p_user_id text,
p_user_email text default null,
p_project_id text default null
)
returns table (
id uuid,
project_id uuid,
user_id text,
title text,
columns_config jsonb,
document_ids jsonb,
workflow_id uuid,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
is_owner boolean,
document_count integer
)
language sql
stable
as $$
with accessible_projects as (
select p.id
from public.projects p
where p.user_id = p_user_id
or (
coalesce(p_user_email, '') <> ''
and p.user_id <> p_user_id
and p.shared_with @> jsonb_build_array(p_user_email)
)
),
visible_reviews as (
select tr.*
from public.tabular_reviews tr
where (p_project_id is null or tr.project_id::text = p_project_id)
and (
p_project_id is null
or exists (
select 1
from accessible_projects ap
where ap.id::text = p_project_id
)
)
and (
tr.user_id = p_user_id
or (
tr.project_id in (select ap.id from accessible_projects ap)
and tr.user_id <> p_user_id
)
or (
p_project_id is null
and coalesce(p_user_email, '') <> ''
and tr.user_id <> p_user_id
and tr.shared_with @> jsonb_build_array(p_user_email)
)
)
),
cell_document_counts as (
select
tc.review_id,
count(distinct tc.document_id)::integer as document_count
from public.tabular_cells tc
where tc.review_id in (select vr.id from visible_reviews vr)
group by tc.review_id
)
select
vr.id,
vr.project_id,
vr.user_id,
vr.title,
vr.columns_config,
vr.document_ids,
vr.workflow_id,
vr.shared_with,
vr.created_at,
vr.updated_at,
vr.user_id = p_user_id as is_owner,
case
when jsonb_typeof(vr.document_ids) = 'array'
then (
select count(distinct doc_id.value)::integer
from jsonb_array_elements_text(vr.document_ids) as doc_id(value)
)
else coalesce(cdc.document_count, 0)
end as document_count
from visible_reviews vr
left join cell_document_counts cdc
on cdc.review_id = vr.id
order by vr.created_at desc;
$$;
create table if not exists public.tabular_review_chats ( create table if not exists public.tabular_review_chats (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade, review_id uuid not null references public.tabular_reviews(id) on delete cascade,
@ -341,6 +813,45 @@ create table if not exists public.tabular_review_chat_messages (
create index if not exists tabular_review_chat_messages_chat_idx create index if not exists tabular_review_chat_messages_chat_idx
on public.tabular_review_chat_messages(chat_id, created_at); on public.tabular_review_chat_messages(chat_id, created_at);
-- ---------------------------------------------------------------------------
-- CourtListener bulk-data indexes
-- ---------------------------------------------------------------------------
create table if not exists public.courtlistener_citation_index (
id bigint primary key,
volume text not null,
reporter text not null,
page text not null,
type integer,
cluster_id bigint not null,
date_created timestamptz,
date_modified timestamptz
);
create index if not exists courtlistener_citation_lookup_idx
on public.courtlistener_citation_index(volume, reporter, page);
create index if not exists courtlistener_citation_cluster_idx
on public.courtlistener_citation_index(cluster_id);
alter table public.courtlistener_citation_index enable row level security;
create table if not exists public.courtlistener_opinion_cluster_index (
id bigint primary key,
case_name text,
case_name_short text,
case_name_full text,
slug text,
date_filed date,
citation_count integer,
precedential_status text,
filepath_pdf_harvard text,
filepath_json_harvard text,
docket_id bigint
);
alter table public.courtlistener_opinion_cluster_index enable row level security;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Direct client grant hardening -- Direct client grant hardening
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -353,6 +864,7 @@ create index if not exists tabular_review_chat_messages_chat_idx
revoke all on public.user_profiles from anon, authenticated; revoke all on public.user_profiles from anon, authenticated;
revoke all on public.projects from anon, authenticated; revoke all on public.projects from anon, authenticated;
revoke all on public.project_subfolders from anon, authenticated; revoke all on public.project_subfolders from anon, authenticated;
revoke all on public.library_folders from anon, authenticated;
revoke all on public.documents from anon, authenticated; revoke all on public.documents from anon, authenticated;
revoke all on public.document_versions from anon, authenticated; revoke all on public.document_versions from anon, authenticated;
revoke all on public.document_edits from anon, authenticated; revoke all on public.document_edits from anon, authenticated;
@ -366,3 +878,21 @@ revoke all on public.tabular_cells from anon, authenticated;
revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated;
revoke all on public.tabular_review_chat_messages from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated;
revoke all on public.user_api_keys from anon, authenticated; revoke all on public.user_api_keys from anon, authenticated;
revoke all on public.user_mcp_connectors from anon, authenticated;
revoke all on public.user_mcp_oauth_tokens from anon, authenticated;
revoke all on public.user_mcp_oauth_states from anon, authenticated;
revoke all on public.user_mcp_connector_tools from anon, authenticated;
revoke all on public.user_mcp_tool_audit_logs from anon, authenticated;
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;
-- Tables created by this file are owned by the database bootstrap role. The
-- backend connects as service_role, so grant it only the data privileges that
-- the direct browser roles above intentionally do not have. RLS is still
-- enabled as defense in depth; service_role bypasses it for the backend path.
grant select, insert, update, delete
on all tables in schema public
to service_role;
grant usage, select
on all sequences in schema public
to service_role;

63
backend/scripts/test-stack.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Run the gated stack-level integration tests against a local Supabase stack.
#
# These tests exercise the REAL stack (GoTrue auth + Postgres RLS) instead of
# mocks. They are the harness you re-run on every Supabase image bump to prove
# the auth↔API contract and the deny-all RLS firewall still hold.
#
# Usage: supabase start # in the repo, once
# npm run test:stack (from backend/)
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
SCHEMA_FILE="$BACKEND_DIR/schema.sql"
if ! command -v supabase >/dev/null 2>&1; then
echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2
exit 1
fi
STATUS="$(supabase status -o json 2>/dev/null)" || {
echo "No running Supabase stack. Start one with: supabase start" >&2
exit 1
}
read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(String(JSON.parse(s)['$1']??'')))" <<<"$STATUS"; }
SUPABASE_TEST_URL="$(read_key API_URL)"
SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)"
SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)"
SUPABASE_TEST_DB_URL="$(read_key DB_URL)"
if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" || -z "$SUPABASE_TEST_DB_URL" ]]; then
echo "Could not read API_URL/DB_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2
exit 1
fi
export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY
if ! command -v psql >/dev/null 2>&1; then
echo "psql not found. Install PostgreSQL's client tools before running stack tests." >&2
exit 1
fi
# A newly started local stack contains Supabase's system schemas but none of
# Mike's application tables. Initialize only an empty stack: silently resetting
# or modifying an existing application database would be surprising.
PROJECTS_TABLE="$(
psql "$SUPABASE_TEST_DB_URL" -XAtq \
-c "select to_regclass('public.projects');"
)"
if [[ "$PROJECTS_TABLE" != "projects" ]]; then
echo "Mike schema not found; loading $SCHEMA_FILE"
psql "$SUPABASE_TEST_DB_URL" -X \
--set ON_ERROR_STOP=1 \
--file "$SCHEMA_FILE"
fi
echo "Running stack integration tests against $SUPABASE_TEST_URL"
cd "$BACKEND_DIR"
exec npx vitest run \
src/__tests__/integration/stack.supabase.test.ts \
src/__tests__/integration/access.supabase.test.ts \
"$@"

View file

@ -0,0 +1,97 @@
import { createClient } from "@supabase/supabase-js";
import { describe, expect, it } from "vitest";
import {
filterAccessibleDocumentIds,
listAccessibleProjectIds,
} from "../../lib/access";
// Gated: runs only against a real (local) Supabase stack.
// supabase start, then export:
// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY
// or use scripts/test-stack.sh which reads them from `supabase status`.
const url = process.env.SUPABASE_TEST_URL;
const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY;
const maybeDescribe = url && serviceKey ? describe : describe.skip;
maybeDescribe("Supabase access integration", () => {
it("proves tabular document filtering drops foreign document IDs", async () => {
const admin = createClient(url!, serviceKey!, {
auth: { persistSession: false },
});
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const ownerId = crypto.randomUUID();
const reviewerId = crypto.randomUUID();
const sharedProjectId = crypto.randomUUID();
const privateProjectId = crypto.randomUUID();
const sharedDocId = crypto.randomUUID();
const privateDocId = crypto.randomUUID();
try {
const projectsInsert = await admin.from("projects").insert([
{
id: sharedProjectId,
user_id: ownerId,
name: `shared-${suffix}`,
shared_with: [`reviewer-${suffix}@example.com`],
},
{
id: privateProjectId,
user_id: ownerId,
name: `private-${suffix}`,
shared_with: [],
},
]);
if (projectsInsert.error) {
throw new Error(
`Could not seed projects: ${projectsInsert.error.message}`,
{ cause: projectsInsert.error },
);
}
// filename/file_type live on document_versions in this schema —
// the documents rows only need identity + ownership columns.
const documentsInsert = await admin.from("documents").insert([
{
id: sharedDocId,
user_id: ownerId,
project_id: sharedProjectId,
},
{
id: privateDocId,
user_id: ownerId,
project_id: privateProjectId,
},
]);
if (documentsInsert.error) {
throw new Error(
`Could not seed documents: ${documentsInsert.error.message}`,
{ cause: documentsInsert.error },
);
}
await expect(
listAccessibleProjectIds(
reviewerId,
`reviewer-${suffix}@example.com`,
admin as any,
),
).resolves.toContain(sharedProjectId);
await expect(
filterAccessibleDocumentIds(
[sharedDocId, privateDocId],
reviewerId,
`reviewer-${suffix}@example.com`,
admin as any,
),
).resolves.toEqual([sharedDocId]);
} finally {
await admin.from("documents").delete().in("id", [sharedDocId, privateDocId]);
await admin
.from("projects")
.delete()
.in("id", [sharedProjectId, privateProjectId]);
}
});
});

View file

@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// Hoisted mock fn so the vi.mock factory below (which is itself hoisted above
// the imports) can reference it. Lets each test drive the stream outcome.
const { runLLMStream } = vi.hoisted(() => ({
runLLMStream: vi.fn(),
}));
// A permissive, chainable Supabase stub. Every query-builder method returns the
// same object (so arbitrary chains work), the object is awaitable (thenable),
// and the terminal single()/maybeSingle() resolve to a chat row. The chat
// routes only read `.id`/`.title` and check `.error`, so this is enough to let
// a request flow through chat creation and message inserts without real IO.
function makeQuery() {
const result = { data: { id: "chat-1", title: null }, error: null };
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.single = vi.fn(() => Promise.resolve(result));
q.maybeSingle = vi.fn(() => Promise.resolve(result));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(result).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn(() => makeQuery()),
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
// Authenticate every request as user "u1" without exercising the real Supabase
// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by
// the app) imports it at module load.
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));
// Keep the real error helpers (the failure-path test relies on genuine
// isAbortError + AssistantStreamError behavior) but stub the functions that
// would otherwise hit the DB or the LLM.
vi.mock("../../lib/chat", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/chat")>();
return {
...actual,
buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })),
enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages),
buildWorkflowStore: vi.fn(async () => new Map()),
buildMessages: vi.fn(() => []),
runLLMStream: (...args: unknown[]) => runLLMStream(...args),
};
});
vi.mock("../../lib/userSettings", () => ({
getUserModelSettings: vi.fn(async () => ({
legal_research_us: false,
title_model: "test-model",
tabular_model: "test-model",
api_keys: {},
})),
getUserApiKeys: vi.fn(async () => ({})),
}));
import { app } from "../../app";
const VALID_BODY = { messages: [{ role: "user", content: "hello" }] };
describe("POST /chat — streaming endpoint", () => {
beforeEach(() => {
vi.clearAllMocks();
runLLMStream.mockResolvedValue({
fullText: "hi there",
events: [],
citations: [],
});
});
it("streams SSE with a chat_id event on the happy path", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/event-stream");
expect(res.text).toContain('"type":"chat_id"');
expect(runLLMStream).toHaveBeenCalledTimes(1);
});
it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => {
runLLMStream.mockRejectedValue(new Error("upstream LLM failure"));
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
// Headers were already flushed (200) before the stream threw, so the
// failure surfaces as an in-stream error event + [DONE].
expect(res.status).toBe(200);
expect(res.text).toContain('"type":"error"');
expect(res.text).toContain("[DONE]");
});
it("returns 400 on an empty messages array (never starts a stream)", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({ messages: [] });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty("detail");
expect(runLLMStream).not.toHaveBeenCalled();
});
it("returns 400 when messages is missing entirely", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({});
expect(res.status).toBe(400);
expect(runLLMStream).not.toHaveBeenCalled();
});
it("returns 400 when chat_id is not a non-empty string", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({ ...VALID_BODY, chat_id: " " });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("chat_id must be a non-empty string");
expect(runLLMStream).not.toHaveBeenCalled();
});
});
describe("PATCH /chat/:chatId", () => {
it("returns 400 when title is missing", async () => {
const res = await request(app)
.patch("/chat/chat-1")
.set("Authorization", "Bearer test")
.send({});
expect(res.status).toBe(400);
expect(res.body.detail).toBe("title is required");
});
});

View file

@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
function mockSupabase() {
const result = { data: null, error: null };
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "order", "limit",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.single = vi.fn(() => Promise.resolve(result));
q.maybeSingle = vi.fn(() => Promise.resolve(result));
q.then = (resolve: (v: unknown) => unknown) =>
Promise.resolve(result).then(resolve);
return {
from: vi.fn(() => q),
rpc: vi.fn(() => Promise.resolve(result)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));
// Stub the storage IO functions so a request that clears validation never
// touches R2/S3, while keeping the rest of the storage module (key builders,
// disposition helpers) real. The validation tests below reject before storage
// is reached, but this guards against accidental real IO regardless.
vi.mock("../../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/storage")>();
return {
...actual,
uploadFile: vi.fn(async () => {}),
downloadFile: vi.fn(async () => null),
deleteFile: vi.fn(async () => {}),
};
});
import { app } from "../../app";
describe("POST /single-documents — upload validation", () => {
it("rejects an unsupported file extension with 400", async () => {
const res = await request(app)
.post("/single-documents")
.set("Authorization", "Bearer test")
.attach("file", Buffer.from("hello world"), {
filename: "notes.txt",
contentType: "text/plain",
});
expect(res.status).toBe(400);
expect(res.body.detail).toMatch(/unsupported file type/i);
});
it("rejects a request with no file attached with 400", async () => {
const res = await request(app)
.post("/single-documents")
.set("Authorization", "Bearer test")
.field("note", "no file here");
expect(res.status).toBe(400);
expect(res.body.detail).toBe("file is required");
});
});
describe("POST /single-documents/download-zip — bounds", () => {
it("returns 400 when document_ids is empty", async () => {
const res = await request(app)
.post("/single-documents/download-zip")
.set("Authorization", "Bearer test")
.send({ document_ids: [] });
expect(res.status).toBe(400);
expect(res.body.detail).toMatch(/document_ids is required/i);
});
it("returns 404 when none of the requested documents are accessible", async () => {
// The documents lookup resolves to no rows (stubbed DB), so the
// access filter leaves nothing to zip.
const res = await request(app)
.post("/single-documents/download-zip")
.set("Authorization", "Bearer test")
.send({ document_ids: ["d-other-user"] });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("No documents found");
});
});

View file

@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at
// request time (not import time), so setting them here is early enough even
// though imported modules evaluate before this assignment runs.
process.env.SUPABASE_URL = "http://supabase.test.local";
process.env.SUPABASE_SECRET_KEY = "test-service-key";
// Mock the supabase-js client factory so the real requireAuth middleware never
// makes a network call: auth.getUser() resolves to no user for any token,
// simulating an invalid/expired JWT.
vi.mock("@supabase/supabase-js", () => ({
createClient: vi.fn(() => ({
from: () => {
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "filter",
"order", "limit",
];
for (const m of chain) q[m] = () => q;
q.single = () => Promise.resolve({ data: null, error: null });
q.maybeSingle = () => Promise.resolve({ data: null, error: null });
q.then = (resolve: (v: unknown) => unknown) =>
Promise.resolve({ data: null, error: null }).then(resolve);
return q;
},
rpc: () => Promise.resolve({ data: null, error: null }),
auth: {
getUser: () =>
Promise.resolve({ data: { user: null }, error: null }),
},
})),
}));
// Vitest hoists vi.mock() calls before all imports, so this regular import
// receives the mocked supabase-js module even though it appears after the
// vi.mock() call in source order.
import { app } from "../../app";
describe("GET /health", () => {
it("returns 200 with { ok: true }", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
});
describe("requireAuth middleware", () => {
it("rejects requests with no Authorization header (401)", async () => {
const res = await request(app).get("/chat");
expect(res.status).toBe(401);
expect(res.body).toHaveProperty("detail");
});
it("rejects requests with a non-Bearer Authorization header (401)", async () => {
const res = await request(app)
.get("/chat")
.set("Authorization", "Basic dXNlcjpwYXNz");
expect(res.status).toBe(401);
});
it("rejects requests with an invalid Bearer token (401)", async () => {
// The mocked createClient().auth.getUser returns { user: null } for
// any token — simulating an expired/invalid token.
const res = await request(app)
.get("/chat")
.set("Authorization", "Bearer invalid-token");
expect(res.status).toBe(401);
expect(res.body.detail).toMatch(/invalid|expired/i);
});
});
describe("404 handling", () => {
it("returns 404 for unknown routes", async () => {
const res = await request(app).get("/this-route-does-not-exist");
expect(res.status).toBe(404);
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
const { runLLMStream, checkProjectAccess } = vi.hoisted(() => ({
runLLMStream: vi.fn(),
checkProjectAccess: vi.fn(),
}));
function makeQuery() {
const result = {
data: { id: "chat-1", title: null, project_id: "p1" },
error: null,
};
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.single = vi.fn(() => Promise.resolve(result));
q.maybeSingle = vi.fn(() => Promise.resolve(result));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(result).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn(() => makeQuery()),
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));
vi.mock("../../lib/chat", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/chat")>();
return {
...actual,
buildProjectDocContext: vi.fn(async () => ({
docIndex: {},
docStore: new Map(),
folderPaths: new Map(),
})),
enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages),
buildWorkflowStore: vi.fn(async () => new Map()),
buildMessages: vi.fn(() => []),
runLLMStream: (...args: unknown[]) => runLLMStream(...args),
};
});
vi.mock("../../lib/userSettings", () => ({
getUserModelSettings: vi.fn(async () => ({
legal_research_us: false,
title_model: "test-model",
tabular_model: "test-model",
api_keys: {},
})),
getUserApiKeys: vi.fn(async () => ({})),
}));
vi.mock("../../lib/access", () => ({
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
listAccessibleProjectIds: vi.fn(async () => []),
}));
import { app } from "../../app";
const VALID_BODY = { messages: [{ role: "user", content: "hello" }] };
describe("POST /projects/:projectId/chat", () => {
beforeEach(() => {
vi.clearAllMocks();
runLLMStream.mockResolvedValue({
fullText: "",
events: [],
citations: [],
});
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
});
it("returns 404 and never streams when project access is denied", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/projects/p1/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
// The guard fires before any LLM stream.
expect(runLLMStream).not.toHaveBeenCalled();
});
it("streams SSE on the happy path with project access granted", async () => {
const res = await request(app)
.post("/projects/p1/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/event-stream");
expect(res.text).toContain('"type":"chat_id"');
expect(runLLMStream).toHaveBeenCalledTimes(1);
});
it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => {
runLLMStream.mockRejectedValue(new Error("upstream LLM failure"));
const res = await request(app)
.post("/projects/p1/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
expect(res.status).toBe(200);
expect(res.text).toContain('"type":"error"');
expect(res.text).toContain("[DONE]");
});
});

View file

@ -0,0 +1,415 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns we want to reconfigure per-test.
// ---------------------------------------------------------------------------
const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({
checkProjectAccess: vi.fn(),
deleteUserProjects: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach;
// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the
// per-table result, and rpc() resolves to a per-call result. Insert payloads
// are recorded so tests can assert on normalisation (lowercasing / dedupe).
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
rpc: QueryResult;
tables: Record<string, QueryResult>;
inserts: { table: string; payload: unknown }[];
};
function resetSupabaseState() {
supabaseState = {
rpc: { data: [], error: null },
tables: {},
inserts: [],
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.insert = vi.fn((payload: unknown) => {
supabaseState.inserts.push({ table, payload });
return q;
});
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));
// Every export of lib/access must be present — other routers (chat, documents,
// downloads, tabular) import from it at app load.
vi.mock("../../lib/access", () => ({
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
listAccessibleProjectIds: vi.fn(async () => []),
}));
// user router imports all four cleanup helpers at module load.
vi.mock("../../lib/userDataCleanup", () => ({
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
deleteAllUserChats: vi.fn(async () => {}),
deleteAllUserTabularReviews: vi.fn(async () => {}),
deleteUserAccountData: vi.fn(async () => {}),
}));
// Version-path enrichment hits the DB in real life; no-op it so the route
// responses are driven purely by the documents/projects table stubs.
vi.mock("../../lib/documentVersions", () => ({
attachActiveVersionPaths: vi.fn(async () => {}),
attachLatestVersionNumbers: vi.fn(async () => {}),
loadActiveVersion: vi.fn(async () => null),
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
describe("projects.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
deleteUserProjects.mockResolvedValue(1);
});
// ── GET /projects (overview) ──────────────────────────────────────────
describe("GET /projects", () => {
it("returns the overview rows from the RPC", async () => {
supabaseState.rpc = {
data: [{ id: "p1", name: "Alpha" }],
error: null,
};
const res = await request(app).get("/projects").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]);
});
it("returns 500 with detail when the RPC errors", async () => {
supabaseState.rpc = { data: null, error: { message: "boom" } };
const res = await request(app).get("/projects").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("boom");
});
});
// ── POST /projects (create) ───────────────────────────────────────────
describe("POST /projects", () => {
it("returns 400 when name is missing/blank", async () => {
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: " " });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("name is required");
});
it("returns 400 when sharing the project with yourself", async () => {
// The authed user's email is u1@test.local; supplying it (in any
// case) must be rejected.
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Beta", shared_with: ["U1@Test.Local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a project with yourself.",
);
});
it("creates the project (201) and normalises shared_with", async () => {
// Sharing requires each recipient to have a mirrored user_profiles
// row (findMissingUserEmails); seed both emails so validation
// passes and the create path proceeds.
supabaseState.tables.user_profiles = {
data: [{ email: "a@x.com" }, { email: "b@x.com" }],
error: null,
};
supabaseState.tables.projects = {
data: {
id: "p9",
name: "Gamma",
user_id: "u1",
shared_with: ["a@x.com", "b@x.com"],
},
error: null,
};
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({
name: " Gamma ",
shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "],
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "p9", documents: [] });
// The insert payload should be lowercased, deduped, trimmed and
// the name trimmed.
const insert = supabaseState.inserts.find(
(i) => i.table === "projects",
);
expect(insert?.payload).toMatchObject({
name: "Gamma",
shared_with: ["a@x.com", "b@x.com"],
});
});
it("returns 400 when a shared_with recipient is not a Mike user", async () => {
// No user_profiles rows seeded → findMissingUserEmails reports the
// recipient as unknown and the create is rejected before insert.
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Gamma", shared_with: ["ghost@x.com"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"ghost@x.com does not belong to a Mike user.",
);
expect(
supabaseState.inserts.find((i) => i.table === "projects"),
).toBeUndefined();
});
it("returns 500 when the insert errors", async () => {
supabaseState.tables.projects = {
data: null,
error: { message: "insert failed" },
};
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Delta" });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("insert failed");
});
});
// ── GET /projects/:projectId (detail, inline access) ──────────────────
describe("GET /projects/:projectId", () => {
it("returns 404 when the project does not exist", async () => {
supabaseState.tables.projects = { data: null, error: null };
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 404 when the caller is neither owner nor shared", async () => {
supabaseState.tables.projects = {
data: {
id: "p1",
user_id: "someone-else",
shared_with: ["other@x.com"],
},
error: null,
};
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("grants access to a shared member (is_owner false)", async () => {
supabaseState.tables.projects = {
data: {
id: "p1",
user_id: "someone-else",
shared_with: ["u1@test.local"],
},
error: null,
};
supabaseState.tables.documents = { data: [], error: null };
supabaseState.tables.project_subfolders = { data: [], error: null };
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: "p1", is_owner: false });
});
it("returns 200 with documents/folders/is_owner when owned", async () => {
supabaseState.tables.projects = {
data: { id: "p1", user_id: "u1", shared_with: null },
error: null,
};
supabaseState.tables.documents = {
data: [{ id: "d1", user_id: "u1" }],
error: null,
};
supabaseState.tables.project_subfolders = {
data: [{ id: "f1" }],
error: null,
};
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "p1",
is_owner: true,
documents: [{ id: "d1" }],
folders: [{ id: "f1" }],
});
});
});
// ── GET /projects/:projectId/documents (checkProjectAccess guard) ─────
describe("GET /projects/:projectId/documents", () => {
it("returns 404 when checkProjectAccess denies access", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/projects/p1/documents")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
});
it("returns 200 with documents when access is granted", async () => {
supabaseState.tables.documents = {
data: [{ id: "d1" }, { id: "d2" }],
error: null,
};
const res = await request(app)
.get("/projects/p1/documents")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]);
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
});
});
// ── PATCH /projects/:projectId (sharing normalisation) ────────────────
describe("PATCH /projects/:projectId", () => {
it("returns 400 when sharing the project with yourself", async () => {
const res = await request(app)
.patch("/projects/p1")
.set(...AUTH)
.send({ shared_with: ["u1@test.local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a project with yourself.",
);
});
it("returns 404 when the update matches no owned project", async () => {
supabaseState.tables.projects = { data: null, error: null };
const res = await request(app)
.patch("/projects/p1")
.set(...AUTH)
.send({ name: "Renamed" });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
});
// ── DELETE /projects/:projectId ───────────────────────────────────────
describe("DELETE /projects/:projectId", () => {
it("returns 404 when nothing was deleted", async () => {
deleteUserProjects.mockResolvedValue(0);
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 204 when the project is deleted", async () => {
deleteUserProjects.mockResolvedValue(1);
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(204);
// Signature is deleteUserProjects(db, userId, [projectId]).
expect(deleteUserProjects).toHaveBeenCalledWith(
expect.anything(),
"u1",
["p1"],
);
});
it("returns 500 when deletion throws", async () => {
deleteUserProjects.mockRejectedValue(new Error("cascade failed"));
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("cascade failed");
});
});
});

View file

@ -0,0 +1,146 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// Stack-level integration test: exercises the REAL Supabase stack (GoTrue auth +
// Postgres RLS) rather than mocks. This is the harness that makes pinning a fixed
// Supabase version set safe — it's what you re-run on every image bump to prove
// the auth↔API contract and the deny-all RLS firewall still hold. It also anchors
// the security model's central claim: RLS denies the user/anon path, and the API
// reaches data only via the service-role key.
//
// Gated: skipped unless a stack is provided (default CI unit run skips it).
// Locally: `supabase start`, then export the printed keys as:
// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY, SUPABASE_TEST_ANON_KEY
const url = process.env.SUPABASE_TEST_URL;
const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY;
const anonKey = process.env.SUPABASE_TEST_ANON_KEY;
const maybeDescribe =
url && serviceKey && anonKey ? describe : describe.skip;
// Every public table the app owns (backend/schema.sql + migrations). The
// anon/user path must never return rows from any of these (deny-all); a
// regression that ships a table without RLS — or with a permissive policy —
// trips the leak sweep below. A table missing from an older local stack
// returns an error (no rows), which never counts as a leak.
const PUBLIC_TABLES = [
"chat_messages", "chats", "courtlistener_citation_index",
"courtlistener_opinion_cluster_index", "document_edits",
"document_versions", "documents", "hidden_workflows", "library_folders",
"project_subfolders", "projects", "tabular_cells",
"tabular_review_chat_messages", "tabular_review_chats", "tabular_reviews",
"user_api_keys", "user_mcp_connector_tools", "user_mcp_connectors",
"user_mcp_oauth_states", "user_mcp_oauth_tokens",
"user_mcp_tool_audit_logs", "user_profiles",
"workflow_open_source_submissions", "workflow_shares", "workflows",
];
maybeDescribe("Supabase stack — auth contract + RLS deny-all firewall", () => {
const password = "StackTest1!";
const emailA = `stack-a-${Date.now()}@test.local`;
const emailB = `stack-b-${Date.now()}@test.local`;
let admin: SupabaseClient; // service-role: BYPASSRLS, the app's data path
let userA = "";
let userB = "";
let tokenA = "";
let projectId = "";
// A client acting as a signed-in end user (anon key + the user's JWT): this is
// the path RLS must fence off.
const asUser = (token: string) =>
createClient(url!, anonKey!, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: `Bearer ${token}` } },
});
beforeAll(async () => {
admin = createClient(url!, serviceKey!, {
auth: { persistSession: false, autoRefreshToken: false },
});
const a = await admin.auth.admin.createUser({
email: emailA, password, email_confirm: true,
});
const b = await admin.auth.admin.createUser({
email: emailB, password, email_confirm: true,
});
if (a.error || !a.data.user) throw a.error ?? new Error("no user A");
if (b.error || !b.data.user) throw b.error ?? new Error("no user B");
userA = a.data.user.id;
userB = b.data.user.id;
// Sign in as A to get a real access token (the token the API middleware
// validates via auth.getUser).
const signIn = await createClient(url!, anonKey!, {
auth: { persistSession: false, autoRefreshToken: false },
}).auth.signInWithPassword({ email: emailA, password });
if (signIn.error || !signIn.data.session) {
throw signIn.error ?? new Error("no session for A");
}
tokenA = signIn.data.session.access_token;
// Seed one row owned by A via the service role (the app's real write path).
const proj = await admin
.from("projects")
.insert({ user_id: userA, name: "Stack Test Project" })
.select("id")
.single();
if (proj.error || !proj.data) throw proj.error ?? new Error("no project");
projectId = proj.data.id;
});
afterAll(async () => {
if (projectId) await admin.from("projects").delete().eq("id", projectId);
if (userA) await admin.auth.admin.deleteUser(userA);
if (userB) await admin.auth.admin.deleteUser(userB);
});
it("auth contract: the access token resolves to its user (middleware path)", async () => {
const { data, error } = await admin.auth.getUser(tokenA);
expect(error).toBeNull();
expect(data.user?.id).toBe(userA);
expect(data.user?.email).toBe(emailA);
});
it("RLS: the service role sees seeded rows the owner cannot see via the user path", async () => {
// Service role (app data path) sees the project…
const svc = await admin
.from("projects").select("id").eq("id", projectId);
expect(svc.error).toBeNull();
expect(svc.data ?? []).toHaveLength(1);
// …but the owner, going through the user/anon path, sees zero rows —
// deny-all RLS is the firewall; the app must use the service role.
const owner = await asUser(tokenA)
.from("projects").select("id").eq("id", projectId);
expect(owner.data ?? []).toHaveLength(0);
// And the owner's profile (if any) is equally invisible to the user path.
const prof = await asUser(tokenA)
.from("user_profiles").select("user_id").eq("user_id", userA);
expect(prof.data ?? []).toHaveLength(0);
});
it("tenant isolation: user B cannot read user A's project via the user path", async () => {
const signInB = await createClient(url!, anonKey!, {
auth: { persistSession: false, autoRefreshToken: false },
}).auth.signInWithPassword({ email: emailB, password });
const tokenB = signInB.data.session!.access_token;
const cross = await asUser(tokenB)
.from("projects").select("id").eq("id", projectId);
expect(cross.data ?? []).toHaveLength(0);
});
it("leak sweep: no public table returns rows to the authenticated user path", async () => {
const client = asUser(tokenA);
const leaks: string[] = [];
for (const table of PUBLIC_TABLES) {
const { data } = await client.from(table).select("*").limit(1);
if ((data ?? []).length > 0) leaks.push(table);
}
// Any table returning rows to a normal user means RLS is missing or a
// policy is permissive — the exact regression this guards against.
expect(leaks).toEqual([]);
});
});

View file

@ -0,0 +1,713 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns reconfigured per-test. Access helpers + model settings are
// mocked so the tests drive review-access decisions, document-access filtering
// and the missing-API-key guard without touching real Supabase / LLM IO. The
// streaming endpoints (chat/generate) are only exercised up to their GUARDS —
// the SSE loop itself is never reached in these tests.
// ---------------------------------------------------------------------------
const {
ensureReviewAccess,
checkProjectAccess,
filterAccessibleDocumentIds,
getUserModelSettings,
loadActiveVersion,
} = vi.hoisted(() => ({
ensureReviewAccess: vi.fn(),
checkProjectAccess: vi.fn(),
filterAccessibleDocumentIds: vi.fn(),
getUserModelSettings: vi.fn(),
loadActiveVersion: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub (mirrors projects.routes.test). Each test seeds
// `supabaseState` in beforeEach; terminal query operations resolve to the
// per-table result, rpc() resolves to a per-call result. Insert payloads are
// recorded so tests can assert on what got persisted.
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
rpc: QueryResult;
tables: Record<string, QueryResult>;
inserts: { table: string; payload: unknown }[];
};
function resetSupabaseState() {
supabaseState = {
rpc: { data: [], error: null },
tables: {},
inserts: [],
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.insert = vi.fn((payload: unknown) => {
supabaseState.inserts.push({ table, payload });
return q;
});
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));
vi.mock("../../lib/access", () => ({
ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args),
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
filterAccessibleDocumentIds: (...args: unknown[]) =>
filterAccessibleDocumentIds(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
listAccessibleProjectIds: vi.fn(async () => []),
}));
vi.mock("../../lib/userSettings", () => ({
getUserModelSettings: (...args: unknown[]) => getUserModelSettings(...args),
getUserApiKeys: vi.fn(async () => ({})),
}));
// Version-path enrichment + active-version resolution hit the DB in real life;
// no-op them so route responses are driven purely by the table stubs.
vi.mock("../../lib/documentVersions", () => ({
attachActiveVersionPaths: vi.fn(async () => {}),
attachLatestVersionNumbers: vi.fn(async () => {}),
loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args),
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
describe("tabular.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
// Default: caller is the owner with full access.
ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true });
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
// Default: every requested doc is accessible (identity passthrough).
filterAccessibleDocumentIds.mockImplementation(
async (ids: string[]) => ids,
);
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: { claude: "sk-test" },
});
loadActiveVersion.mockResolvedValue(null);
});
// ── GET /tabular-review (overview) ────────────────────────────────────
describe("GET /tabular-review", () => {
it("returns the overview rows from the RPC", async () => {
supabaseState.rpc = {
data: [{ id: "r1", title: "Alpha" }],
error: null,
};
const res = await request(app).get("/tabular-review").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]);
});
it("returns 500 with detail when the RPC errors", async () => {
supabaseState.rpc = { data: null, error: { message: "boom" } };
const res = await request(app).get("/tabular-review").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("boom");
});
});
// ── POST /tabular-review (create) ─────────────────────────────────────
describe("POST /tabular-review", () => {
it("creates a review (201) and only persists accessible documents", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r9", title: "Gamma", document_ids: ["d1"] },
error: null,
};
// d2 is not accessible — it must be filtered out of the insert.
filterAccessibleDocumentIds.mockResolvedValue(["d1"]);
const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({
title: "Gamma",
document_ids: ["d1", "d2"],
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "r9" });
const reviewInsert = supabaseState.inserts.find(
(i) => i.table === "tabular_reviews",
);
expect(reviewInsert?.payload).toMatchObject({
document_ids: ["d1"],
});
// Cells are created for accessible docs × columns only (1 × 1).
const cellInsert = supabaseState.inserts.find(
(i) => i.table === "tabular_cells",
);
expect(cellInsert?.payload).toEqual([
{
review_id: "r9",
document_id: "d1",
column_index: 0,
status: "pending",
},
]);
});
it("returns 404 when project access is denied", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({
project_id: "p-nope",
document_ids: [],
columns_config: [],
});
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 500 when the review insert errors", async () => {
supabaseState.tables.tabular_reviews = {
data: null,
error: { message: "insert failed" },
};
const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({ document_ids: [], columns_config: [] });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("insert failed");
});
});
// ── GET /tabular-review/:reviewId (detail) ────────────────────────────
describe("GET /tabular-review/:reviewId", () => {
it("returns 404 when the review does not exist", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.get("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 200 with review/cells/documents + is_owner", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
document_ids: ["d1"],
columns_config: [],
},
error: null,
};
supabaseState.tables.tabular_cells = {
data: [
{
id: "c1",
document_id: "d1",
column_index: 0,
content: null,
status: "pending",
},
],
error: null,
};
supabaseState.tables.documents = {
data: [{ id: "d1", current_version_id: null }],
error: null,
};
const res = await request(app)
.get("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body.review).toMatchObject({ id: "r1", is_owner: true });
expect(res.body.cells).toHaveLength(1);
expect(res.body.documents).toEqual([
{ id: "d1", current_version_id: null },
]);
});
});
// ── PATCH /tabular-review/:reviewId ───────────────────────────────────
describe("PATCH /tabular-review/:reviewId", () => {
it("returns 400 when project_id is an invalid type", async () => {
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ project_id: 123 });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"project_id must be a non-empty string or null",
);
});
it("returns 400 when sharing the review with yourself", async () => {
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ shared_with: ["U1@Test.Local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a tabular review with yourself.",
);
});
it("returns 404 when the review does not exist", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ title: "Renamed" });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 403 when a non-owner edits columns_config", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: "p1" },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false });
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] });
expect(res.status).toBe(403);
expect(res.body.detail).toBe("Only the review owner can change columns");
});
});
// ── DELETE /tabular-review/:reviewId ──────────────────────────────────
describe("DELETE /tabular-review/:reviewId", () => {
it("returns 204 on success", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.delete("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(204);
});
it("returns 500 when the delete errors", async () => {
supabaseState.tables.tabular_reviews = {
data: null,
error: { message: "delete failed" },
};
const res = await request(app)
.delete("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("delete failed");
});
});
// ── POST /tabular-review/:reviewId/clear-cells ────────────────────────
describe("POST /tabular-review/:reviewId/clear-cells", () => {
it("returns 400 when document_ids is missing", async () => {
const res = await request(app)
.post("/tabular-review/r1/clear-cells")
.set(...AUTH)
.send({});
expect(res.status).toBe(400);
expect(res.body.detail).toBe("document_ids is required");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/clear-cells")
.set(...AUTH)
.send({ document_ids: ["d1"] });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 204 on success", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "u1", project_id: null },
error: null,
};
const res = await request(app)
.post("/tabular-review/r1/clear-cells")
.set(...AUTH)
.send({ document_ids: ["d1"] });
expect(res.status).toBe(204);
});
});
// ── POST /tabular-review/:reviewId/regenerate-cell ────────────────────
describe("POST /tabular-review/:reviewId/regenerate-cell", () => {
it("returns 400 when document_id / column_index are missing", async () => {
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({});
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"document_id and column_index are required",
);
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d1", column_index: 0 });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 400 when the column is not configured", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 5, name: "Other", prompt: "p" }],
},
error: null,
};
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d1", column_index: 0 });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("Column not found");
});
it("returns 404 when the document is not accessible", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
},
error: null,
};
filterAccessibleDocumentIds.mockResolvedValue([]);
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d-forbidden", column_index: 0 });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Document not found");
});
it("returns 422 with missing_api_key when the model key is absent", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
},
error: null,
};
supabaseState.tables.documents = {
data: { id: "d1", current_version_id: null },
error: null,
};
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: {},
});
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d1", column_index: 0 });
expect(res.status).toBe(422);
expect(res.body.code).toBe("missing_api_key");
expect(res.body.provider).toBe("claude");
});
});
// ── POST /tabular-review/:reviewId/generate (streaming GUARDS only) ───
describe("POST /tabular-review/:reviewId/generate", () => {
it("returns 404 when the review does not exist", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 400 when no columns are configured", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [],
},
error: null,
};
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(400);
expect(res.body.detail).toBe("No columns configured");
});
it("returns 422 missing_api_key before streaming when the key is absent", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
},
error: null,
};
supabaseState.tables.tabular_cells = { data: [], error: null };
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: {},
});
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(422);
expect(res.body.code).toBe("missing_api_key");
});
});
// ── POST /tabular-review/:reviewId/chat (streaming GUARDS only) ───────
describe("POST /tabular-review/:reviewId/chat", () => {
it("returns 400 when no user message is present", async () => {
const res = await request(app)
.post("/tabular-review/r1/chat")
.set(...AUTH)
.send({ messages: [{ role: "assistant", content: "hi" }] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("messages must include a user message");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/chat")
.set(...AUTH)
.send({ messages: [{ role: "user", content: "hello" }] });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 422 missing_api_key before streaming when the key is absent", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [],
},
error: null,
};
supabaseState.tables.tabular_cells = { data: [], error: null };
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: {},
});
const res = await request(app)
.post("/tabular-review/r1/chat")
.set(...AUTH)
.send({ messages: [{ role: "user", content: "hello" }] });
expect(res.status).toBe(422);
expect(res.body.code).toBe("missing_api_key");
});
});
// ── GET /tabular-review/:reviewId/chats ───────────────────────────────
describe("GET /tabular-review/:reviewId/chats", () => {
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/tabular-review/r1/chats")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns the chat list when access is granted", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "u1", project_id: null },
error: null,
};
supabaseState.tables.tabular_review_chats = {
data: [{ id: "chat-1", title: "T", user_id: "u1" }],
error: null,
};
const res = await request(app)
.get("/tabular-review/r1/chats")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([
{ id: "chat-1", title: "T", user_id: "u1" },
]);
});
});
});

View file

@ -0,0 +1,588 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns we reconfigure per-test. These cover the three security
// surfaces this suite baselines:
// - the MFA route guard (requireMfaIfEnrolled)
// - the API-key crypto boundary (userApiKeys)
// - the destructive data export / deletion helpers (userDataExport /
// userDataCleanup)
// Each is a vi.fn so we can both reconfigure behaviour and assert call args.
// ---------------------------------------------------------------------------
const {
requireMfaIfEnrolled,
getUserApiKeyStatus,
saveUserApiKey,
hasEnvApiKey,
normalizeApiKeyProvider,
deleteAllUserChats,
deleteAllUserTabularReviews,
deleteUserAccountData,
deleteUserProjects,
buildUserAccountExport,
buildUserChatsExport,
buildUserTabularReviewsExport,
} = vi.hoisted(() => ({
requireMfaIfEnrolled: vi.fn(),
getUserApiKeyStatus: vi.fn(),
saveUserApiKey: vi.fn(),
hasEnvApiKey: vi.fn(),
normalizeApiKeyProvider: vi.fn(),
deleteAllUserChats: vi.fn(),
deleteAllUserTabularReviews: vi.fn(),
deleteUserAccountData: vi.fn(),
deleteUserProjects: vi.fn(),
buildUserAccountExport: vi.fn(),
buildUserChatsExport: vi.fn(),
buildUserTabularReviewsExport: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub. The only route in this suite that reaches the
// DB directly is GET /user/profile (via loadProfile → selectProfile). Tests
// seed `supabaseState.tables.user_profiles`; terminal query ops resolve to the
// per-table result and auth.admin methods are stubbed where routes call them.
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
tables: Record<string, QueryResult>;
adminGetUserById: QueryResult;
adminDeleteUser: { error: unknown };
};
function resetSupabaseState() {
supabaseState = {
tables: {},
adminGetUserById: {
data: { user: { id: "u1", factors: [] } },
error: null,
},
adminDeleteUser: { error: null },
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert", "insert",
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (
resolve: (v: unknown) => unknown,
reject?: (e: unknown) => unknown,
) => Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
admin: {
getUserById: vi.fn(() =>
Promise.resolve(supabaseState.adminGetUserById),
),
deleteUser: vi.fn(() =>
Promise.resolve(supabaseState.adminDeleteUser),
),
},
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
// requireAuth always authenticates u1. requireMfaIfEnrolled is a reconfigurable
// guard so we can drive both the satisfied (next()) and rejected
// (403 mfa_verification_required) paths.
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
res.locals.token = "test-token";
next();
},
requireMfaIfEnrolled: (req: unknown, res: unknown, next: () => void) =>
requireMfaIfEnrolled(req, res, next),
}));
// API-key crypto boundary: the route must funnel writes through saveUserApiKey
// (which encrypts) and never echo plaintext — getUserApiKeyStatus returns
// presence-only booleans. getUserApiKeys must be exported too — lib/userSettings
// imports it at module load.
vi.mock("../../lib/userApiKeys", () => ({
getUserApiKeyStatus: (...args: unknown[]) => getUserApiKeyStatus(...args),
saveUserApiKey: (...args: unknown[]) => saveUserApiKey(...args),
hasEnvApiKey: (...args: unknown[]) => hasEnvApiKey(...args),
normalizeApiKeyProvider: (...args: unknown[]) =>
normalizeApiKeyProvider(...args),
getUserApiKeys: vi.fn(async () => ({})),
}));
vi.mock("../../lib/userDataCleanup", () => ({
deleteAllUserChats: (...args: unknown[]) => deleteAllUserChats(...args),
deleteAllUserTabularReviews: (...args: unknown[]) =>
deleteAllUserTabularReviews(...args),
deleteUserAccountData: (...args: unknown[]) =>
deleteUserAccountData(...args),
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
}));
vi.mock("../../lib/userDataExport", () => ({
buildUserAccountExport: (...args: unknown[]) =>
buildUserAccountExport(...args),
buildUserChatsExport: (...args: unknown[]) => buildUserChatsExport(...args),
buildUserTabularReviewsExport: (...args: unknown[]) =>
buildUserTabularReviewsExport(...args),
userExportFilename: (kind: string, userId: string) =>
`mike-${kind}-export-${userId.slice(0, 8)}.json`,
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
// A complete user_profiles row with credits_reset_date in the future so the
// monthly-reset branch in loadProfile is not triggered.
function profileRow(overrides: Record<string, unknown> = {}) {
return {
display_name: "Ada",
organisation: "Acme",
message_credits_used: 3,
credits_reset_date: "2999-01-01T00:00:00.000Z",
tier: "Pro",
title_model: null,
tabular_model: "gemini-3-flash-preview",
mfa_on_login: false,
legal_research_us: true,
...overrides,
};
}
const STATUS = { claude: true, openai: false, gemini: false, sources: {} };
// The exact 403 body the web client's MFA gate consumes (mirrors the real
// requireMfaIfEnrolled). Used by tests that simulate an unsatisfied factor.
function rejectMfa(_req: unknown, res: any) {
res.status(403).json({
code: "mfa_verification_required",
detail: "MFA verification required",
});
}
describe("user.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
// Default: MFA satisfied (guard passes through).
requireMfaIfEnrolled.mockImplementation(
(_req: unknown, _res: unknown, next: () => void) => next(),
);
getUserApiKeyStatus.mockResolvedValue(STATUS);
saveUserApiKey.mockResolvedValue(undefined);
hasEnvApiKey.mockReturnValue(false);
normalizeApiKeyProvider.mockImplementation((v: string) =>
["claude", "openai", "gemini"].includes(v) ? v : null,
);
deleteAllUserChats.mockResolvedValue(undefined);
deleteAllUserTabularReviews.mockResolvedValue(undefined);
deleteUserAccountData.mockResolvedValue(undefined);
deleteUserProjects.mockResolvedValue(undefined);
buildUserAccountExport.mockResolvedValue({ account: "data" });
buildUserChatsExport.mockResolvedValue({ chats: "data" });
buildUserTabularReviewsExport.mockResolvedValue({ reviews: "data" });
});
// ── GET /user/profile (MFA bootstrap path) ────────────────────────────
describe("GET /user/profile", () => {
it("returns the serialized profile plus apiKeyStatus", async () => {
supabaseState.tables.user_profiles = {
data: profileRow(),
error: null,
};
const res = await request(app).get("/user/profile").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
displayName: "Ada",
organisation: "Acme",
messageCreditsUsed: 3,
tier: "Pro",
legalResearchUs: true,
mfaOnLogin: false,
apiKeyStatus: STATUS,
});
// Presence-only key status — never plaintext.
expect(JSON.stringify(res.body)).not.toContain("sk-");
});
it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => {
// Even if the MFA factor were unsatisfied, profile must remain
// reachable so the client can render the verification gate.
requireMfaIfEnrolled.mockImplementation(rejectMfa);
supabaseState.tables.user_profiles = {
data: profileRow(),
error: null,
};
const res = await request(app).get("/user/profile").set(...AUTH);
expect(res.status).toBe(200);
expect(requireMfaIfEnrolled).not.toHaveBeenCalled();
});
it("returns 500 with detail when the profile load errors", async () => {
supabaseState.tables.user_profiles = {
data: null,
error: { message: "db down" },
};
const res = await request(app).get("/user/profile").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("db down");
});
});
// ── POST /user/profile (bootstrap upsert) ─────────────────────────────
describe("POST /user/profile", () => {
it("ensures the profile row and returns ok", async () => {
const res = await request(app).post("/user/profile").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(requireMfaIfEnrolled).not.toHaveBeenCalled();
});
});
// ── GET /user/api-keys (presence without plaintext) ───────────────────
describe("GET /user/api-keys", () => {
it("returns the boolean key-status map", async () => {
const res = await request(app).get("/user/api-keys").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual(STATUS);
expect(getUserApiKeyStatus).toHaveBeenCalledWith(
"u1",
expect.anything(),
);
});
});
// ── PUT /user/api-keys/:provider (crypto + MFA guard) ─────────────────
describe("PUT /user/api-keys/:provider", () => {
it("stores the key via the encryption helper and returns status", async () => {
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-secret-value" });
expect(res.status).toBe(200);
expect(res.body).toEqual(STATUS);
// The plaintext key must go through saveUserApiKey (the encryption
// boundary), keyed by provider + value, never persisted by the route.
expect(saveUserApiKey).toHaveBeenCalledWith(
"u1",
"claude",
"sk-secret-value",
expect.anything(),
);
});
it("deletes the key when api_key is omitted (null value)", async () => {
const res = await request(app)
.put("/user/api-keys/openai")
.set(...AUTH)
.send({});
expect(res.status).toBe(200);
expect(saveUserApiKey).toHaveBeenCalledWith(
"u1",
"openai",
null,
expect.anything(),
);
});
it("returns 400 for an unsupported provider", async () => {
const res = await request(app)
.put("/user/api-keys/bogus")
.set(...AUTH)
.send({ api_key: "x" });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("Unsupported provider");
expect(saveUserApiKey).not.toHaveBeenCalled();
});
it("returns 409 when the provider is configured by the server env", async () => {
hasEnvApiKey.mockReturnValue(true);
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-x" });
expect(res.status).toBe(409);
expect(saveUserApiKey).not.toHaveBeenCalled();
});
it("returns 500 when saving the key throws", async () => {
saveUserApiKey.mockRejectedValue(new Error("kms unavailable"));
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-x" });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("kms unavailable");
});
it("is rejected with 403 mfa_verification_required when MFA is unsatisfied", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-x" });
expect(res.status).toBe(403);
expect(res.body).toEqual({
code: "mfa_verification_required",
detail: "MFA verification required",
});
// Guarded: the crypto path is never reached.
expect(saveUserApiKey).not.toHaveBeenCalled();
});
});
// ── Data export endpoints (MFA-guarded, attachment headers) ───────────
describe("data export endpoints", () => {
it("GET /user/export returns the account export as a JSON attachment", async () => {
const res = await request(app).get("/user/export").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ account: "data" });
expect(res.headers["content-type"]).toContain("application/json");
expect(res.headers["content-disposition"]).toContain("attachment");
expect(res.headers["content-disposition"]).toContain(
"mike-account-export-u1.json",
);
expect(buildUserAccountExport).toHaveBeenCalledWith(
expect.anything(),
"u1",
"u1@test.local",
);
});
it("GET /user/chats/export returns the chats export", async () => {
const res = await request(app)
.get("/user/chats/export")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ chats: "data" });
expect(res.headers["content-disposition"]).toContain(
"mike-chats-export-u1.json",
);
expect(buildUserChatsExport).toHaveBeenCalledTimes(1);
});
it("GET /user/tabular-reviews/export returns the reviews export", async () => {
const res = await request(app)
.get("/user/tabular-reviews/export")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ reviews: "data" });
expect(res.headers["content-disposition"]).toContain(
"mike-tabular-reviews-export-u1.json",
);
expect(buildUserTabularReviewsExport).toHaveBeenCalledTimes(1);
});
it("GET /user/export returns 500 when the builder throws", async () => {
buildUserAccountExport.mockRejectedValue(new Error("export boom"));
const res = await request(app).get("/user/export").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("export boom");
});
it("GET /user/export is rejected when MFA is unsatisfied", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app).get("/user/export").set(...AUTH);
expect(res.status).toBe(403);
expect(res.body.code).toBe("mfa_verification_required");
expect(buildUserAccountExport).not.toHaveBeenCalled();
});
});
// ── Data deletion endpoints (MFA-guarded, cleanup helpers) ────────────
describe("data deletion endpoints", () => {
it("DELETE /user/chats invokes deleteAllUserChats and returns 204", async () => {
const res = await request(app).delete("/user/chats").set(...AUTH);
expect(res.status).toBe(204);
expect(deleteAllUserChats).toHaveBeenCalledWith(
expect.anything(),
"u1",
);
});
it("DELETE /user/projects invokes deleteUserProjects and returns 204", async () => {
const res = await request(app)
.delete("/user/projects")
.set(...AUTH);
expect(res.status).toBe(204);
expect(deleteUserProjects).toHaveBeenCalledWith(
expect.anything(),
"u1",
);
});
it("DELETE /user/tabular-reviews invokes the cleanup helper and returns 204", async () => {
const res = await request(app)
.delete("/user/tabular-reviews")
.set(...AUTH);
expect(res.status).toBe(204);
expect(deleteAllUserTabularReviews).toHaveBeenCalledWith(
expect.anything(),
"u1",
);
});
it("DELETE /user/account purges data then deletes the auth user (204)", async () => {
const res = await request(app).delete("/user/account").set(...AUTH);
expect(res.status).toBe(204);
// Account purge runs the cleanup helper with id + email.
expect(deleteUserAccountData).toHaveBeenCalledWith(
expect.anything(),
"u1",
"u1@test.local",
);
});
it("DELETE /user/account returns 500 when the auth-user delete errors", async () => {
supabaseState.adminDeleteUser = { error: { message: "auth boom" } };
const res = await request(app).delete("/user/account").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("auth boom");
});
it("DELETE /user/chats returns 500 when cleanup throws", async () => {
deleteAllUserChats.mockRejectedValue(new Error("cascade failed"));
const res = await request(app).delete("/user/chats").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("cascade failed");
});
it("DELETE /user/account is rejected when MFA is unsatisfied (no cleanup)", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app).delete("/user/account").set(...AUTH);
expect(res.status).toBe(403);
expect(res.body.code).toBe("mfa_verification_required");
expect(deleteUserAccountData).not.toHaveBeenCalled();
});
});
// ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ────────
describe("PATCH /user/security/mfa-login", () => {
it("returns 400 when enabling without a verified TOTP factor", async () => {
supabaseState.adminGetUserById = {
data: { user: { id: "u1", factors: [] } },
error: null,
};
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: true });
expect(res.status).toBe(400);
expect(res.body.detail).toContain("authenticator app");
});
it("enables MFA-on-login when a verified TOTP factor exists", async () => {
supabaseState.adminGetUserById = {
data: {
user: {
id: "u1",
factors: [
{ factor_type: "totp", status: "verified" },
],
},
},
error: null,
};
supabaseState.tables.user_profiles = {
data: profileRow({ mfa_on_login: true }),
error: null,
};
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mfaOnLogin: true });
});
it("returns 400 on a non-boolean enabled field", async () => {
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: "yes" });
expect(res.status).toBe(400);
});
it("is rejected with 403 when MFA is unsatisfied", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: false });
expect(res.status).toBe(403);
expect(res.body.code).toBe("mfa_verification_required");
});
});
});

161
backend/src/app.ts Normal file
View file

@ -0,0 +1,161 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { libraryRouter } from "./routes/library";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";
import { caseLawRouter } from "./routes/caseLaw";
export const app = express();
const isProduction = process.env.NODE_ENV === "production";
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function minutes(value: number): number {
return value * 60 * 1000;
}
function hours(value: number): number {
return minutes(value * 60);
}
function makeLimiter(options: {
windowMs: number;
max: number;
message?: string;
}) {
return rateLimit({
windowMs: options.windowMs,
max: options.max,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.method === "OPTIONS",
message: {
detail:
options.message ?? "Too many requests. Please try again later.",
},
});
}
const generalLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
});
const chatLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
message: "Too many chat requests. Please try again later.",
});
const chatCreateLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
});
const uploadLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
message: "Too many upload requests. Please try again later.",
});
const exportLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
message: "Too many export requests. Please try again later.",
});
const dataDeleteLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
message: "Too many data deletion requests. Please try again later.",
});
function jsonLimitForPath(path: string): string {
return "50mb";
}
app.disable("x-powered-by");
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: isProduction
? {
maxAge: 15552000,
includeSubDomains: true,
}
: false,
referrerPolicy: { policy: "no-referrer" },
}),
);
app.use(
cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
credentials: true,
}),
);
app.use(generalLimiter);
app.post("/chat", chatLimiter);
app.post("/projects/:projectId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/generate", chatLimiter);
app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/library/:kind/documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.put(
"/single-documents/:documentId/versions/:versionId/file",
uploadLimiter,
);
app.post("/projects/:projectId/documents", uploadLimiter);
app.get("/user/export", exportLimiter);
app.get("/user/chats/export", exportLimiter);
app.get("/user/tabular-reviews/export", exportLimiter);
app.delete("/user/account", dataDeleteLimiter);
app.delete("/user/chats", dataDeleteLimiter);
app.delete("/user/projects", dataDeleteLimiter);
app.delete("/user/tabular-reviews", dataDeleteLimiter);
app.use((req, res, next) =>
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
);
app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/library", libraryRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
app.use("/case-law", caseLawRouter);
app.get("/health", (_req, res) => res.json({ ok: true }));

View file

@ -1,125 +1,6 @@
import "dotenv/config"; import { app } from "./app";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";
const app = express();
const PORT = process.env.PORT ?? 3001; const PORT = process.env.PORT ?? 3001;
const isProduction = process.env.NODE_ENV === "production";
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function minutes(value: number): number {
return value * 60 * 1000;
}
function hours(value: number): number {
return minutes(value * 60);
}
function makeLimiter(options: {
windowMs: number;
max: number;
message?: string;
}) {
return rateLimit({
windowMs: options.windowMs,
max: options.max,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.method === "OPTIONS",
message: {
detail:
options.message ?? "Too many requests. Please try again later.",
},
});
}
const generalLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
});
const chatLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
message: "Too many chat requests. Please try again later.",
});
const chatCreateLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
});
const uploadLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
message: "Too many upload requests. Please try again later.",
});
app.disable("x-powered-by");
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
hsts: isProduction
? {
maxAge: 15552000,
includeSubDomains: true,
}
: false,
referrerPolicy: { policy: "no-referrer" },
}),
);
app.use(
cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
credentials: true,
}),
);
app.use(generalLimiter);
app.use(express.json({ limit: "50mb" }));
app.post("/chat", chatLimiter);
app.post("/projects/:projectId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/generate", chatLimiter);
app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.post("/projects/:projectId/documents", uploadLimiter);
app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Mike backend running on port ${PORT}`); console.log(`Mike backend running on port ${PORT}`);

View file

@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
checkProjectAccess,
ensureDocAccess,
ensureReviewAccess,
filterAccessibleDocumentIds,
listAccessibleProjectIds,
} from "../access";
type Row = Record<string, unknown>;
function makeDb(tables: Record<string, Row[]>) {
return {
from(table: string) {
let rows = [...(tables[table] ?? [])];
const query = {
select: () => query,
eq: (column: string, value: unknown) => {
rows = rows.filter((row) => row[column] === value);
return query;
},
neq: (column: string, value: unknown) => {
rows = rows.filter((row) => row[column] !== value);
return query;
},
in: (column: string, values: unknown[]) => {
rows = rows.filter((row) => values.includes(row[column]));
return query;
},
filter: (column: string, operator: string, value: string) => {
if (operator !== "cs") return query;
const expected = (JSON.parse(value) as string[]).map((item) =>
item.toLowerCase(),
);
rows = rows.filter((row) => {
const actual = row[column];
const normalizedActual = Array.isArray(actual)
? actual.map((item) => String(item).toLowerCase())
: [];
return (
Array.isArray(actual) &&
expected.every((item) => normalizedActual.includes(item))
);
});
return query;
},
single: async () => ({ data: rows[0] ?? null, error: null }),
then: (
resolve: (value: { data: Row[]; error: null }) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
};
return query;
},
} as any;
}
describe("access helpers", () => {
const db = makeDb({
projects: [
{ id: "own-project", user_id: "owner", shared_with: [] },
{
id: "shared-project",
user_id: "other-owner",
shared_with: ["Reviewer@Example.com"],
},
{ id: "private-project", user_id: "other-owner", shared_with: [] },
],
documents: [
{ id: "own-doc", user_id: "owner", project_id: null },
{
id: "shared-doc",
user_id: "other-owner",
project_id: "shared-project",
},
{
id: "private-doc",
user_id: "other-owner",
project_id: "private-project",
},
],
});
it("allows project owners", async () => {
await expect(
checkProjectAccess("own-project", "owner", "owner@example.com", db),
).resolves.toMatchObject({ ok: true, isOwner: true });
});
it("allows shared project access case-insensitively", async () => {
await expect(
checkProjectAccess(
"shared-project",
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: false });
});
it("denies private project access", async () => {
await expect(
checkProjectAccess(
"private-project",
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toEqual({ ok: false });
});
it("allows document owners and shared-project readers", async () => {
await expect(
ensureDocAccess(
{ user_id: "owner", project_id: null },
"owner",
"owner@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: true });
await expect(
ensureDocAccess(
{ user_id: "other-owner", project_id: "shared-project" },
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: false });
});
it("filters user-supplied document IDs to accessible documents only", async () => {
await expect(
filterAccessibleDocumentIds(
["own-doc", "shared-doc", "private-doc", "missing-doc"],
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toEqual(["shared-doc"]);
});
it("lists own and directly shared projects", async () => {
await expect(
listAccessibleProjectIds("owner", "reviewer@example.com", db),
).resolves.toEqual(expect.arrayContaining(["own-project", "shared-project"]));
});
it("allows direct review sharing without project access", async () => {
await expect(
ensureReviewAccess(
{
user_id: "other-owner",
project_id: null,
shared_with: ["Reviewer@Example.com"],
},
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: false });
});
});

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import {
resolveDoc,
resolveDocLabel,
type DocIndex,
type DocStore,
} from "../chat/types";
// ---------------------------------------------------------------------------
// resolveDoc
// ---------------------------------------------------------------------------
describe("resolveDoc", () => {
const index: DocIndex = {
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
};
it("returns the doc entry for a known label", () => {
expect(resolveDoc("doc-1", index)).toEqual({
document_id: "uuid-aaa",
filename: "contract.pdf",
});
});
it("returns undefined for an unknown label", () => {
expect(resolveDoc("doc-99", index)).toBeUndefined();
});
it("returns undefined for an empty string", () => {
expect(resolveDoc("", index)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// resolveDocLabel
// ---------------------------------------------------------------------------
describe("resolveDocLabel", () => {
const store: DocStore = new Map([
["doc-1", { storage_path: "path/a", file_type: "pdf", filename: "contract.pdf" }],
["doc-2", { storage_path: "path/b", file_type: "pdf", filename: "nda.pdf" }],
]);
const index: DocIndex = {
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
};
it("resolves by label when the label is in the store", () => {
expect(resolveDocLabel("doc-1", store, index)).toBe("doc-1");
});
it("resolves by filename when the filename matches a store entry", () => {
expect(resolveDocLabel("contract.pdf", store, index)).toBe("doc-1");
});
it("resolves by document UUID via the docIndex", () => {
expect(resolveDocLabel("uuid-bbb", store, index)).toBe("doc-2");
});
it("returns null when nothing matches", () => {
expect(resolveDocLabel("unknown-id", store, index)).toBeNull();
});
it("returns null when docIndex is omitted and only UUID matches", () => {
// Without the index there is no fallback for raw UUIDs.
expect(resolveDocLabel("uuid-aaa", store)).toBeNull();
});
it("prioritises exact label match over filename match", () => {
// If a label happens to equal a filename of a different doc,
// the label match wins.
const storeWithCrossMatch: DocStore = new Map([
["nda.pdf", { storage_path: "path/c", file_type: "pdf", filename: "contract.pdf" }],
]);
// "nda.pdf" is a label here, and it IS in the store, so it should
// be returned directly without the filename-fallback loop.
expect(resolveDocLabel("nda.pdf", storeWithCrossMatch)).toBe("nda.pdf");
});
});

View file

@ -0,0 +1,397 @@
import { describe, it, expect } from "vitest";
import {
parseCitations,
parseCitationsWithDiagnostics,
parsePartialCitationObjects,
createCitation,
CITATIONS_OPEN_TAG,
CITATIONS_CLOSE_TAG,
} from "../chat/citations";
import type { DocIndex } from "../chat/types";
function citationsBlock(json: string) {
return `Answer text.\n${CITATIONS_OPEN_TAG}\n${json}\n${CITATIONS_CLOSE_TAG}`;
}
// ---------------------------------------------------------------------------
// parseCitationsWithDiagnostics
// ---------------------------------------------------------------------------
describe("parseCitationsWithDiagnostics", () => {
it("reports no block when the tags are absent", () => {
const { citations, diagnostics } =
parseCitationsWithDiagnostics("plain answer");
expect(citations).toEqual([]);
expect(diagnostics).toEqual({ hasBlock: false, rawLength: 0, error: null });
});
it("reports a JSON parse error for malformed block content", () => {
const { citations, diagnostics } = parseCitationsWithDiagnostics(
citationsBlock("[{not json"),
);
expect(citations).toEqual([]);
expect(diagnostics.hasBlock).toBe(true);
expect(diagnostics.rawLength).toBeGreaterThan(0);
expect(diagnostics.error).toBeTruthy();
});
it("reports an error when the block JSON is not an array", () => {
const { citations, diagnostics } = parseCitationsWithDiagnostics(
citationsBlock('{"ref": 1}'),
);
expect(citations).toEqual([]);
expect(diagnostics.error).toBe("CITATIONS block JSON was not an array.");
});
});
// ---------------------------------------------------------------------------
// parseCitations — document citations
// ---------------------------------------------------------------------------
describe("parseCitations (document citations)", () => {
it("parses a minimal document citation", () => {
const [citation] = parseCitations(
citationsBlock(
'[{"ref": 1, "doc_id": "doc-1", "page": 3, "quote": "the term"}]',
),
);
expect(citation).toMatchObject({
kind: "document",
ref: 1,
doc_id: "doc-1",
page: 3,
quote: "the term",
});
expect(citation.quotes).toHaveLength(1);
});
it("derives ref from a [N] marker when ref is missing", () => {
const [citation] = parseCitations(
citationsBlock(
'[{"marker": "[7]", "doc_id": "doc-1", "page": 1, "quote": "q"}]',
),
);
expect(citation.ref).toBe(7);
});
it("drops entries without a usable ref or marker", () => {
expect(
parseCitations(
citationsBlock('[{"doc_id": "doc-1", "quote": "q", "marker": "nope"}]'),
),
).toEqual([]);
});
it("drops document entries without doc_id or quote", () => {
expect(
parseCitations(citationsBlock('[{"ref": 1, "doc_id": "doc-1"}]')),
).toEqual([]);
expect(
parseCitations(citationsBlock('[{"ref": 1, "quote": "q"}]')),
).toEqual([]);
});
it("drops non-object entries but keeps valid ones", () => {
const citations = parseCitations(
citationsBlock(
'[null, "junk", {"ref": 2, "doc_id": "doc-2", "page": 4, "quote": "kept"}]',
),
);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 2, doc_id: "doc-2" });
});
it("accepts a text field as a quote alias", () => {
const [citation] = parseCitations(
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 2, "text": "aliased"}]'),
);
expect(citation.kind).toBe("document");
expect((citation as { quote: string }).quote).toBe("aliased");
});
it("normalizes pages: numbers kept, ranges kept, junk becomes 1", () => {
const citations = parseCitations(
citationsBlock(
'[{"ref": 1, "doc_id": "d", "page": 5, "quote": "a"},' +
'{"ref": 2, "doc_id": "d", "page": "3-5", "quote": "b"},' +
'{"ref": 3, "doc_id": "d", "page": "12", "quote": "c"},' +
'{"ref": 4, "doc_id": "d", "page": "unknown", "quote": "d"}]',
),
);
expect(citations.map((c) => (c as { page: number | string }).page)).toEqual([
5,
"3-5",
12,
1,
]);
});
it("keeps at most 3 quotes and inherits top-level page/sheet/cell", () => {
const [citation] = parseCitations(
citationsBlock(
JSON.stringify([
{
ref: 1,
doc_id: "doc-1",
page: 9,
sheet: "Summary",
cell: "B7",
quotes: [
{ quote: "one" },
{ quote: "two", page: 2 },
{ quote: "three", sheet: "Detail", cell: "C1" },
{ quote: "four" },
],
},
]),
),
);
expect(citation.kind).toBe("document");
const doc = citation as {
page: number | string;
quotes: { page: number | string; quote: string; sheet?: string; cell?: string }[];
};
expect(doc.quotes).toHaveLength(3);
expect(doc.quotes[0]).toEqual({
page: 9,
quote: "one",
sheet: "Summary",
cell: "B7",
});
expect(doc.quotes[1].page).toBe(2);
expect(doc.quotes[2]).toMatchObject({ sheet: "Detail", cell: "C1" });
// Top-level fields mirror the first quote.
expect(doc.page).toBe(9);
});
it("skips quote rows without text", () => {
const [citation] = parseCitations(
citationsBlock(
'[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
' "quotes": [{"page": 2}, {"quote": " "}, {"quote": "real"}]}]',
),
);
expect((citation as { quotes: unknown[] }).quotes).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// parseCitations — case citations
// ---------------------------------------------------------------------------
describe("parseCitations (case citations)", () => {
it("parses a case citation from a numeric cluster_id", () => {
const [citation] = parseCitations(
citationsBlock('[{"ref": 1, "cluster_id": 12345, "quote": "held that"}]'),
);
expect(citation).toMatchObject({ kind: "case", ref: 1, cluster_id: 12345 });
expect((citation as { quotes: unknown[] }).quotes).toEqual([
{ opinionId: null, type: null, author: null, quote: "held that" },
]);
});
it("accepts clusterId camelCase and string cluster ids", () => {
const citations = parseCitations(
citationsBlock(
'[{"ref": 1, "clusterId": 7, "quote": "a"},' +
'{"ref": 2, "cluster_id": "42", "quote": "b"}]',
),
);
expect(citations.map((c) => (c as { cluster_id: number }).cluster_id)).toEqual([
7, 42,
]);
});
it("floors fractional cluster ids", () => {
const [citation] = parseCitations(
citationsBlock('[{"ref": 1, "cluster_id": 12.9, "quote": "q"}]'),
);
expect((citation as { cluster_id: number }).cluster_id).toBe(12);
});
it("treats non-positive cluster ids as document citations", () => {
// cluster_id 0 fails the > 0 check, so the entry needs a doc_id.
expect(
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 0, "quote": "q"}]')),
).toEqual([]);
});
it("normalizes structured case quotes with opinion metadata", () => {
const [citation] = parseCitations(
citationsBlock(
JSON.stringify([
{
ref: 3,
cluster_id: 99,
quotes: [
{
quote: "majority text",
opinion_id: 11.7,
type: "majority",
author: "Judge A",
},
{ text: "concurrence text", opinionId: 12 },
{ type: "no quote text, dropped" },
],
},
]),
),
);
expect((citation as { quotes: unknown[] }).quotes).toEqual([
{
opinionId: 11,
type: "majority",
author: "Judge A",
quote: "majority text",
},
{ opinionId: 12, type: null, author: null, quote: "concurrence text" },
]);
});
it("drops case citations with no quotes at all", () => {
expect(
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 5}]')),
).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// parsePartialCitationObjects
// ---------------------------------------------------------------------------
describe("parsePartialCitationObjects", () => {
it("returns [] when no array has started", () => {
expect(parsePartialCitationObjects("streaming <CITATIONS>")).toEqual([]);
});
it("extracts complete objects and ignores a trailing incomplete one", () => {
const partial =
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 2, "quote": "done"},' +
' {"ref": 2, "doc_id": "doc-2", "page": 3, "quote": "still stream';
const citations = parsePartialCitationObjects(partial);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 1, doc_id: "doc-1" });
});
it("handles braces and escaped quotes inside string values", () => {
const partial =
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
' "quote": "clause {a} says \\"stop\\""}';
const citations = parsePartialCitationObjects(partial);
expect(citations).toHaveLength(1);
expect((citations[0] as { quote: string }).quote).toBe(
'clause {a} says "stop"',
);
});
it("ignores content after the closing tag", () => {
const text =
'<CITATIONS>[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}]</CITATIONS>' +
'[{"ref": 9, "doc_id": "b", "page": 1, "quote": "after"}]';
const citations = parsePartialCitationObjects(text);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 1 });
});
it("stops at the array close bracket", () => {
const text =
'[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}] {"ref": 2, "doc_id": "b", "page": 1, "quote": "outside"}';
const citations = parsePartialCitationObjects(text);
expect(citations).toHaveLength(1);
});
it("skips malformed objects but keeps later valid ones", () => {
const text =
'[{"ref": bad}, {"ref": 2, "doc_id": "doc-2", "page": 1, "quote": "ok"}';
const citations = parsePartialCitationObjects(text);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 2 });
});
});
// ---------------------------------------------------------------------------
// createCitation
// ---------------------------------------------------------------------------
describe("createCitation", () => {
const docIndex: DocIndex = {
"doc-1": {
document_id: "uuid-aaa",
filename: "contract.pdf",
version_id: "ver-1",
version_number: 2,
},
};
it("builds a document citation payload from the doc index", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 4, "quote": "q"}]'),
);
expect(createCitation(parsed, docIndex)).toMatchObject({
type: "citation_data",
kind: "document",
ref: 1,
doc_id: "doc-1",
document_id: "uuid-aaa",
version_id: "ver-1",
version_number: 2,
filename: "contract.pdf",
page: 4,
quote: "q",
});
});
it("falls back to the raw doc_id as filename when unresolvable", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 1, "doc_id": "doc-9", "page": 1, "quote": "q"}]'),
);
const citation = createCitation(parsed, docIndex);
expect(citation).toMatchObject({
filename: "doc-9",
document_id: undefined,
version_id: null,
});
});
it("enriches a case citation from the cluster map", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
);
const cases = new Map([
[
55,
{
caseName: "Smith v. Jones",
citations: ["123 U.S. 456", "alt cite"],
url: "https://example.test/case",
pdfUrl: null,
dateFiled: "1990-01-02",
},
],
]);
expect(createCitation(parsed, docIndex, cases)).toMatchObject({
type: "citation_data",
kind: "case",
ref: 2,
cluster_id: 55,
case_name: "Smith v. Jones",
citation: "123 U.S. 456",
url: "https://example.test/case",
pdfUrl: null,
dateFiled: "1990-01-02",
});
});
it("nulls case metadata when the cluster map has no entry", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
);
expect(createCitation(parsed, docIndex)).toMatchObject({
case_name: null,
citation: null,
url: null,
pdfUrl: null,
dateFiled: null,
});
});
});

View file

@ -0,0 +1,315 @@
import { describe, it, expect } from "vitest";
import {
loadActiveVersion,
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../documentVersions";
type Row = Record<string, unknown>;
/**
* Read-only Supabase mock covering the query chains documentVersions uses:
* select/eq/in/is/not filters plus single() and awaiting the builder.
*/
function makeDb(tables: Record<string, Row[]>) {
return {
from(table: string) {
let rows = [...(tables[table] ?? [])];
const query: any = {
select: () => query,
eq: (column: string, value: unknown) => {
rows = rows.filter((row) => row[column] === value);
return query;
},
in: (column: string, values: unknown[]) => {
rows = rows.filter((row) => values.includes(row[column]));
return query;
},
is: (column: string, value: unknown) => {
rows = rows.filter((row) => (row[column] ?? null) === value);
return query;
},
not: (column: string, operator: string, value: unknown) => {
if (operator === "is" && value === null) {
rows = rows.filter((row) => row[column] != null);
}
return query;
},
single: async () => ({ data: rows[0] ?? null, error: null }),
then: (
resolve: (value: { data: Row[]; error: null }) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
};
return query;
},
} as any;
}
/** Shape of the mutable doc rows the attach* helpers annotate in place. */
type TestDoc = {
id: string;
current_version_id?: string | null;
latest_version_number?: number | null;
[k: string]: unknown;
};
const FULL_VERSION = {
id: "ver-1",
document_id: "doc-1",
storage_path: "documents/u/doc-1/source.pdf",
pdf_storage_path: "documents/u/doc-1/converted.pdf",
version_number: 3,
filename: "contract.pdf",
source: "upload",
file_type: "application/pdf",
size_bytes: 1024,
page_count: 12,
deleted_at: null,
};
// ---------------------------------------------------------------------------
// loadActiveVersion
// ---------------------------------------------------------------------------
describe("loadActiveVersion", () => {
it("resolves the document's current version by default", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [FULL_VERSION],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
id: "ver-1",
storage_path: "documents/u/doc-1/source.pdf",
pdf_storage_path: "documents/u/doc-1/converted.pdf",
version_number: 3,
filename: "contract.pdf",
source: "upload",
file_type: "application/pdf",
size_bytes: 1024,
page_count: 12,
});
});
it("prefers an explicit versionId over current_version_id", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [
FULL_VERSION,
{ ...FULL_VERSION, id: "ver-2", version_number: 2 },
],
});
const version = await loadActiveVersion("doc-1", db, "ver-2");
expect(version?.id).toBe("ver-2");
expect(version?.version_number).toBe(2);
});
it("returns null when neither versionId nor current_version_id exist", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: null }],
document_versions: [FULL_VERSION],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
});
it("returns null when the version belongs to a different document", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-other" }],
document_versions: [
{ ...FULL_VERSION, id: "ver-other", document_id: "doc-2" },
],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
// Also guards against a spoofed explicit versionId.
await expect(
loadActiveVersion("doc-1", db, "ver-other"),
).resolves.toBeNull();
});
it("returns null for soft-deleted versions", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
});
it("returns null when the version has no storage_path", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [{ ...FULL_VERSION, storage_path: null }],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
});
it("defaults optional metadata fields to null", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [
{
id: "ver-1",
document_id: "doc-1",
storage_path: "documents/u/doc-1/source.docx",
deleted_at: null,
},
],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
id: "ver-1",
storage_path: "documents/u/doc-1/source.docx",
pdf_storage_path: null,
version_number: null,
filename: null,
source: null,
file_type: null,
size_bytes: null,
page_count: null,
});
});
});
// ---------------------------------------------------------------------------
// attachActiveVersionPaths
// ---------------------------------------------------------------------------
describe("attachActiveVersionPaths", () => {
it("returns the same empty array untouched", async () => {
const db = makeDb({ document_versions: [] });
const docs: TestDoc[] = [];
await expect(attachActiveVersionPaths(db, docs)).resolves.toBe(docs);
});
it("nulls all fields when no document has a current version", async () => {
const db = makeDb({ document_versions: [FULL_VERSION] });
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: null },
]);
expect(doc).toMatchObject({
filename: "Untitled document",
storage_path: null,
pdf_storage_path: null,
file_type: null,
size_bytes: null,
page_count: null,
});
});
it("merges active-version metadata onto each row", async () => {
const db = makeDb({
document_versions: [
FULL_VERSION,
{
...FULL_VERSION,
id: "ver-2",
storage_path: "documents/u/doc-2/source.docx",
pdf_storage_path: null,
filename: "nda.docx",
version_number: 1,
},
],
});
const docs = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: "ver-1" },
{ id: "doc-2", current_version_id: "ver-2" },
{ id: "doc-3", current_version_id: null },
]);
expect(docs[0]).toMatchObject({
storage_path: "documents/u/doc-1/source.pdf",
pdf_storage_path: "documents/u/doc-1/converted.pdf",
active_version_number: 3,
filename: "contract.pdf",
file_type: "application/pdf",
size_bytes: 1024,
page_count: 12,
});
expect(docs[1]).toMatchObject({
storage_path: "documents/u/doc-2/source.docx",
pdf_storage_path: null,
active_version_number: 1,
filename: "nda.docx",
});
// Mixed list: the version-less doc still gets explicit nulls.
expect(docs[2]).toMatchObject({
storage_path: null,
filename: "Untitled document",
});
});
it("falls back to 'Untitled document' for blank filenames", async () => {
const db = makeDb({
document_versions: [{ ...FULL_VERSION, filename: " " }],
});
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: "ver-1" },
]);
expect(doc.filename).toBe("Untitled document");
});
it("ignores soft-deleted versions", async () => {
const db = makeDb({
document_versions: [
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
],
});
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: "ver-1" },
]);
expect(doc.storage_path).toBeNull();
expect(doc.filename).toBe("Untitled document");
});
});
// ---------------------------------------------------------------------------
// attachLatestVersionNumbers
// ---------------------------------------------------------------------------
describe("attachLatestVersionNumbers", () => {
const versionRow = (
document_id: string,
version_number: number | null,
overrides: Row = {},
) => ({
document_id,
version_number,
source: "assistant_edit",
deleted_at: null,
...overrides,
});
it("returns the same empty array untouched", async () => {
const db = makeDb({ document_versions: [] });
const docs: TestDoc[] = [];
await expect(attachLatestVersionNumbers(db, docs)).resolves.toBe(docs);
});
it("attaches the max assistant_edit version number per document", async () => {
const db = makeDb({
document_versions: [
versionRow("doc-1", 1),
versionRow("doc-1", 4),
versionRow("doc-1", 2),
versionRow("doc-2", 7),
],
});
const docs = await attachLatestVersionNumbers<TestDoc>(db, [
{ id: "doc-1" },
{ id: "doc-2" },
{ id: "doc-3" },
]);
expect(docs.map((d) => d.latest_version_number)).toEqual([4, 7, null]);
});
it("ignores non-assistant_edit and soft-deleted versions", async () => {
const db = makeDb({
document_versions: [
versionRow("doc-1", 9, { source: "upload" }),
versionRow("doc-1", 8, { deleted_at: "2026-01-01T00:00:00Z" }),
versionRow("doc-1", 2),
],
});
const docs = await attachLatestVersionNumbers<TestDoc>(db, [{ id: "doc-1" }]);
expect(docs[0].latest_version_number).toBe(2);
});
});

View file

@ -0,0 +1,109 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { signDownload, verifyDownload, buildDownloadUrl } from "../downloadTokens";
const SECRET = "test-secret-32-bytes-long-enough!!";
beforeAll(() => {
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
});
afterAll(() => {
delete process.env.DOWNLOAD_SIGNING_SECRET;
});
describe("signDownload", () => {
it("returns a two-part dot-separated token", () => {
const token = signDownload("documents/user/doc.pdf", "contract.pdf");
const parts = token.split(".");
expect(parts).toHaveLength(2);
expect(parts[0].length).toBeGreaterThan(0);
expect(parts[1].length).toBeGreaterThan(0);
});
it("produces different tokens for different paths", () => {
const t1 = signDownload("documents/a/file.pdf", "a.pdf");
const t2 = signDownload("documents/b/file.pdf", "b.pdf");
expect(t1).not.toBe(t2);
});
it("uses base64url characters only (no +, /, =)", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
expect(token).not.toMatch(/[+/=]/);
});
});
describe("verifyDownload", () => {
it("round-trips a valid token", () => {
const path = "documents/user123/doc456/source.pdf";
const filename = "Contract Final v2.pdf";
const token = signDownload(path, filename);
const result = verifyDownload(token);
expect(result).not.toBeNull();
expect(result!.path).toBe(path);
expect(result!.filename).toBe(filename);
});
it("returns null for a tampered payload", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
const [, sig] = token.split(".");
const fakePayload = Buffer.from(
JSON.stringify({ p: "documents/attacker/file.pdf", f: "file.pdf" }),
)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
expect(verifyDownload(`${fakePayload}.${sig}`)).toBeNull();
});
it("returns null for a tampered signature", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
const [enc] = token.split(".");
const fakeSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
expect(verifyDownload(`${enc}.${fakeSig}`)).toBeNull();
});
it("returns null for a token with too many parts", () => {
expect(verifyDownload("a.b.c")).toBeNull();
});
it("returns null for a token with too few parts", () => {
expect(verifyDownload("onlyonepart")).toBeNull();
});
it("returns null when payload JSON is missing required fields", () => {
const bad = Buffer.from(JSON.stringify({ x: 1 }))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
const sig = Buffer.alloc(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
expect(verifyDownload(`${bad}.${sig}`)).toBeNull();
});
it("returns null when signed with a different secret", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
process.env.DOWNLOAD_SIGNING_SECRET = "different-secret-value-!!";
const result = verifyDownload(token);
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
expect(result).toBeNull();
});
});
describe("buildDownloadUrl", () => {
it("returns a path starting with /download/", () => {
const url = buildDownloadUrl("documents/user/file.pdf", "file.pdf");
expect(url).toMatch(/^\/download\//);
});
it("embeds a verifiable token in the URL", () => {
const path = "documents/user/file.pdf";
const filename = "file.pdf";
const url = buildDownloadUrl(path, filename);
const token = url.replace("/download/", "");
const result = verifyDownload(token);
expect(result).not.toBeNull();
expect(result!.path).toBe(path);
expect(result!.filename).toBe(filename);
});
});

View file

@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
CLAUDE_MAIN_MODELS,
GEMINI_MAIN_MODELS,
OPENAI_MAIN_MODELS,
CLAUDE_MID_MODELS,
GEMINI_MID_MODELS,
OPENAI_MID_MODELS,
CLAUDE_LOW_MODELS,
GEMINI_LOW_MODELS,
OPENAI_LOW_MODELS,
DEFAULT_MAIN_MODEL,
DEFAULT_TITLE_MODEL,
DEFAULT_TABULAR_MODEL,
providerForModel,
resolveModel,
} from "../llm/models";
// ---------------------------------------------------------------------------
// providerForModel
// ---------------------------------------------------------------------------
describe("providerForModel", () => {
it("maps claude-* ids to the claude provider", () => {
for (const model of [...CLAUDE_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...CLAUDE_LOW_MODELS]) {
expect(providerForModel(model)).toBe("claude");
}
});
it("maps gemini-* ids to the gemini provider", () => {
for (const model of [...GEMINI_MAIN_MODELS, ...GEMINI_MID_MODELS, ...GEMINI_LOW_MODELS]) {
expect(providerForModel(model)).toBe("gemini");
}
});
it("maps gpt-* ids to the openai provider", () => {
for (const model of [...OPENAI_MAIN_MODELS, ...OPENAI_MID_MODELS, ...OPENAI_LOW_MODELS]) {
expect(providerForModel(model)).toBe("openai");
}
});
it("throws on an unknown model id", () => {
expect(() => providerForModel("llama-3")).toThrow(/Unknown model id/);
expect(() => providerForModel("")).toThrow(/Unknown model id/);
});
it("infers by prefix only, without validating against the catalog", () => {
// Documents current behavior: any claude-/gemini-/gpt- prefix is
// accepted even if the id is not a canonical model.
expect(providerForModel("claude-nonexistent")).toBe("claude");
expect(providerForModel("gpt-nonexistent")).toBe("openai");
});
});
// ---------------------------------------------------------------------------
// resolveModel
// ---------------------------------------------------------------------------
describe("resolveModel", () => {
it("returns a known model id unchanged", () => {
expect(resolveModel("claude-sonnet-4-6", DEFAULT_MAIN_MODEL)).toBe(
"claude-sonnet-4-6",
);
expect(resolveModel("gpt-5.4-lite", DEFAULT_TITLE_MODEL)).toBe(
"gpt-5.4-lite",
);
});
it("falls back for unknown model ids", () => {
expect(resolveModel("gpt-3.5-turbo", DEFAULT_MAIN_MODEL)).toBe(
DEFAULT_MAIN_MODEL,
);
});
it("falls back for null, undefined, and empty ids", () => {
expect(resolveModel(null, DEFAULT_MAIN_MODEL)).toBe(DEFAULT_MAIN_MODEL);
expect(resolveModel(undefined, DEFAULT_TABULAR_MODEL)).toBe(
DEFAULT_TABULAR_MODEL,
);
expect(resolveModel("", DEFAULT_TITLE_MODEL)).toBe(DEFAULT_TITLE_MODEL);
});
it("accepts models from every tier of the catalog", () => {
const catalog = [
...CLAUDE_MAIN_MODELS,
...GEMINI_MAIN_MODELS,
...OPENAI_MAIN_MODELS,
...CLAUDE_MID_MODELS,
...GEMINI_MID_MODELS,
...OPENAI_MID_MODELS,
...CLAUDE_LOW_MODELS,
...GEMINI_LOW_MODELS,
...OPENAI_LOW_MODELS,
];
for (const model of catalog) {
expect(resolveModel(model, "fallback-model")).toBe(model);
}
});
});
// ---------------------------------------------------------------------------
// Default model sanity
// ---------------------------------------------------------------------------
describe("default models", () => {
it("every default resolves to itself (defaults are in the catalog)", () => {
expect(resolveModel(DEFAULT_MAIN_MODEL, "x")).toBe(DEFAULT_MAIN_MODEL);
expect(resolveModel(DEFAULT_TITLE_MODEL, "x")).toBe(DEFAULT_TITLE_MODEL);
expect(resolveModel(DEFAULT_TABULAR_MODEL, "x")).toBe(
DEFAULT_TABULAR_MODEL,
);
});
it("every default has a resolvable provider", () => {
expect(providerForModel(DEFAULT_MAIN_MODEL)).toBe("gemini");
expect(providerForModel(DEFAULT_TITLE_MODEL)).toBe("gemini");
expect(providerForModel(DEFAULT_TABULAR_MODEL)).toBe("gemini");
});
});

View file

@ -0,0 +1,154 @@
import { describe, it, expect } from "vitest";
import {
redactSensitiveText,
safeErrorMessage,
safeErrorLog,
} from "../safeError";
// ---------------------------------------------------------------------------
// redactSensitiveText
// ---------------------------------------------------------------------------
describe("redactSensitiveText", () => {
it('redacts the OpenAI "Incorrect API key provided" message', () => {
expect(
redactSensitiveText(
"Incorrect API key provided: sk-proj-abc123def456ghi789.",
),
).toBe("Incorrect API key provided: [redacted].");
});
it("keeps the trailing period optional in the incorrect-key message", () => {
expect(
redactSensitiveText("Incorrect API key provided: badkey123"),
).toBe("Incorrect API key provided: [redacted]");
});
it("redacts secrets after api_key labels", () => {
expect(redactSensitiveText("api_key: mysecret123")).toBe(
"api_key: [redacted]",
);
expect(redactSensitiveText("api key = mysecret123")).toBe(
"api key = [redacted]",
);
});
it("redacts secrets after token/secret/authorization labels", () => {
expect(redactSensitiveText("token: abcdef123456")).toBe(
"token: [redacted]",
);
expect(redactSensitiveText("secret is abcdef123456")).toBe(
"secret is [redacted]",
);
expect(redactSensitiveText("authorization: abcdef123456")).toBe(
"authorization: [redacted]",
);
});
it("leaves short values after labels alone (below 6 chars)", () => {
expect(redactSensitiveText("token: abc")).toBe("token: abc");
});
it("redacts bare OpenAI-style sk- keys anywhere in the text", () => {
expect(
redactSensitiveText("request failed for sk-abc123def456ghi789 today"),
).toBe("request failed for [redacted] today");
});
it("redacts bare Anthropic-style sk-ant- keys", () => {
expect(
redactSensitiveText("used sk-ant-api03-abc123def456"),
).toBe("used [redacted]");
});
it("redacts bare Google AIza keys", () => {
expect(
redactSensitiveText("key AIzaSyA1234567890abcdefghij failed"),
).toBe("key [redacted] failed");
});
it("redacts multiple secrets in one string", () => {
const result = redactSensitiveText(
"first sk-abc123def456ghi789 then AIzaSyA1234567890abcdefghij",
);
expect(result).toBe("first [redacted] then [redacted]");
});
it("leaves ordinary text unchanged", () => {
expect(redactSensitiveText("Document not found")).toBe(
"Document not found",
);
});
});
// ---------------------------------------------------------------------------
// safeErrorMessage
// ---------------------------------------------------------------------------
describe("safeErrorMessage", () => {
it("uses the message of an Error instance", () => {
expect(safeErrorMessage(new Error("boom"))).toBe("boom");
});
it("redacts secrets inside an Error message", () => {
expect(
safeErrorMessage(new Error("bad key sk-abc123def456ghi789")),
).toBe("bad key [redacted]");
});
it("passes plain strings through (redacted)", () => {
expect(safeErrorMessage("token: abcdef123456")).toBe(
"token: [redacted]",
);
});
it("falls back for non-Error, non-string values", () => {
expect(safeErrorMessage(42)).toBe("Unexpected error");
expect(safeErrorMessage(null)).toBe("Unexpected error");
expect(safeErrorMessage({ message: "obj" })).toBe("Unexpected error");
});
it("falls back for an Error with an empty message", () => {
expect(safeErrorMessage(new Error(""))).toBe("Unexpected error");
});
it("honors a custom fallback", () => {
expect(safeErrorMessage(undefined, "Chat failed")).toBe("Chat failed");
});
});
// ---------------------------------------------------------------------------
// safeErrorLog
// ---------------------------------------------------------------------------
describe("safeErrorLog", () => {
it("captures name, message, and stack for an Error", () => {
const error = new Error("boom");
const log = safeErrorLog(error);
expect(log.name).toBe("Error");
expect(log.message).toBe("boom");
expect(log.stack).toContain("boom");
});
it("redacts secrets in the message and stack", () => {
const error = new Error("bad key sk-abc123def456ghi789");
const log = safeErrorLog(error);
expect(log.message).toBe("bad key [redacted]");
expect(log.stack).not.toContain("sk-abc123def456ghi789");
});
it("falls back to 'Unexpected error' for an empty Error message", () => {
expect(safeErrorLog(new Error("")).message).toBe("Unexpected error");
});
it("omits the stack when the Error has none", () => {
const error = new Error("boom");
error.stack = undefined;
expect(safeErrorLog(error).stack).toBeUndefined();
});
it("handles non-Error values with a null name and no stack", () => {
const log = safeErrorLog("plain failure");
expect(log).toEqual({ name: null, message: "plain failure" });
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import {
normalizeDownloadFilename,
sanitizeDispositionFilename,
encodeRFC5987,
buildContentDisposition,
storageKey,
pdfStorageKey,
generatedDocKey,
versionStorageKey,
} from "../storage";
describe("normalizeDownloadFilename", () => {
it("trims surrounding whitespace", () => {
expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf");
});
it("falls back to 'download' for empty string", () => {
expect(normalizeDownloadFilename("")).toBe("download");
expect(normalizeDownloadFilename(" ")).toBe("download");
});
it("replaces control characters with underscore", () => {
expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf");
expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf");
});
it("replaces forward and backward slashes with underscore", () => {
expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf");
expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf");
});
it("preserves normal filenames unchanged", () => {
expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe(
"Contract v2 (Final).pdf",
);
});
});
describe("sanitizeDispositionFilename", () => {
it("strips double-quote characters", () => {
expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf");
});
it("strips backslash characters", () => {
expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf");
});
it("strips non-ASCII characters", () => {
expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf");
});
it("still applies normalizeDownloadFilename rules first", () => {
expect(sanitizeDispositionFilename(" ")).toBe("download");
});
});
describe("encodeRFC5987", () => {
it("encodes spaces as %20", () => {
expect(encodeRFC5987("hello world")).toBe("hello%20world");
});
it("encodes single-quote as %27", () => {
expect(encodeRFC5987("it's")).toContain("%27");
});
it("encodes ( and ) as %28 and %29", () => {
const result = encodeRFC5987("a(b)c");
expect(result).toContain("%28");
expect(result).toContain("%29");
});
it("encodes * as %2A", () => {
expect(encodeRFC5987("a*b")).toContain("%2A");
});
it("leaves safe ASCII characters unencoded", () => {
expect(encodeRFC5987("file.pdf")).toBe("file.pdf");
});
});
describe("buildContentDisposition", () => {
it("produces an attachment header with ASCII filename", () => {
const header = buildContentDisposition("attachment", "contract.pdf");
expect(header).toMatch(/^attachment;/);
expect(header).toContain('filename="contract.pdf"');
expect(header).toContain("filename*=UTF-8''contract.pdf");
});
it("produces an inline header", () => {
const header = buildContentDisposition("inline", "preview.pdf");
expect(header).toMatch(/^inline;/);
});
it("encodes unicode filename in filename* param", () => {
const header = buildContentDisposition("attachment", "Ünïcödé.pdf");
expect(header).toContain("filename*=UTF-8''");
expect(header).not.toContain("Ü");
});
});
describe("storageKey", () => {
it("includes userId, docId, and correct extension", () => {
const key = storageKey("user1", "doc1", "contract.pdf");
expect(key).toBe("documents/user1/doc1/source.pdf");
});
it("falls back to .bin for extensions longer than 16 chars", () => {
const key = storageKey("user1", "doc1", "file.toolongextension1234");
expect(key).toBe("documents/user1/doc1/source.bin");
});
it("falls back to .bin when no extension", () => {
const key = storageKey("user1", "doc1", "noextension");
expect(key).toBe("documents/user1/doc1/source.bin");
});
});
describe("pdfStorageKey", () => {
it("places PDF in the correct path with stem", () => {
const key = pdfStorageKey("user1", "doc1", "contract");
expect(key).toBe("documents/user1/doc1/contract.pdf");
});
});
describe("generatedDocKey", () => {
it("uses generated/ prefix and .docx extension for docx files", () => {
const key = generatedDocKey("user1", "doc1", "output.docx");
expect(key).toBe("generated/user1/doc1/generated.docx");
});
it("falls back to .docx for extensions longer than 16 chars", () => {
const key = generatedDocKey("user1", "doc1", "output.toolongextension1234");
expect(key).toBe("generated/user1/doc1/generated.docx");
});
});
describe("versionStorageKey", () => {
it("includes userId, docId, versionSlug, and extension", () => {
const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf");
expect(key).toBe("documents/user1/doc1/versions/v2.pdf");
});
it("falls back to .bin for unknown extensions", () => {
const key = versionStorageKey("user1", "doc1", "v2", "file");
expect(key).toBe("documents/user1/doc1/versions/v2.bin");
});
});

View file

@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { normalizeApiKeyProvider, hasEnvApiKey } from "../userApiKeys";
describe("normalizeApiKeyProvider", () => {
it('returns "claude" for "claude"', () => {
expect(normalizeApiKeyProvider("claude")).toBe("claude");
});
it('returns "openai" for "openai"', () => {
expect(normalizeApiKeyProvider("openai")).toBe("openai");
});
it('returns "gemini" for "gemini"', () => {
expect(normalizeApiKeyProvider("gemini")).toBe("gemini");
});
it("returns null for unknown provider strings", () => {
expect(normalizeApiKeyProvider("unknown")).toBeNull();
expect(normalizeApiKeyProvider("")).toBeNull();
expect(normalizeApiKeyProvider("Claude")).toBeNull();
expect(normalizeApiKeyProvider("OPENAI")).toBeNull();
});
});
describe("hasEnvApiKey", () => {
const envVars = [
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
];
// Clear before AND after each test so keys exported in the developer's
// shell (or CI) can't leak into assertions.
beforeEach(() => {
for (const v of envVars) delete process.env[v];
});
afterEach(() => {
for (const v of envVars) delete process.env[v];
});
it("returns true for claude when ANTHROPIC_API_KEY is set", () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
expect(hasEnvApiKey("claude")).toBe(true);
});
it("returns true for claude when CLAUDE_API_KEY is set as fallback", () => {
process.env.CLAUDE_API_KEY = "sk-claude-test";
expect(hasEnvApiKey("claude")).toBe(true);
});
it("returns true for openai when OPENAI_API_KEY is set", () => {
process.env.OPENAI_API_KEY = "sk-openai-test";
expect(hasEnvApiKey("openai")).toBe(true);
});
it("returns true for gemini when GEMINI_API_KEY is set", () => {
process.env.GEMINI_API_KEY = "gemini-key-test";
expect(hasEnvApiKey("gemini")).toBe(true);
});
it("returns false when no env key is set for the provider", () => {
expect(hasEnvApiKey("claude")).toBe(false);
expect(hasEnvApiKey("openai")).toBe(false);
expect(hasEnvApiKey("gemini")).toBe(false);
});
it("ignores whitespace-only env values", () => {
process.env.ANTHROPIC_API_KEY = " ";
expect(hasEnvApiKey("claude")).toBe(false);
});
});

View file

@ -0,0 +1,432 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../storage", () => ({
deleteFile: vi.fn(async () => {}),
listFiles: vi.fn(async () => [] as string[]),
}));
import { deleteFile, listFiles } from "../storage";
import {
deleteAllUserChats,
deleteAllUserTabularReviews,
deleteUserProjects,
deleteUserAccountData,
} from "../userDataCleanup";
const deleteFileMock = vi.mocked(deleteFile);
const listFilesMock = vi.mocked(listFiles);
type Row = Record<string, unknown>;
/**
* Stateful Supabase mock: deletes and updates mutate `tables`, so tests can
* assert on exactly which rows survived a cleanup call. Supports the chains
* userDataCleanup uses (select/delete/update + eq/in/filter-cs) and can
* inject a delete error per table to exercise error propagation.
*/
function makeDb(
initialTables: Record<string, Row[]>,
options: { deleteErrors?: Record<string, string> } = {},
) {
const tables: Record<string, Row[]> = {};
for (const [name, rows] of Object.entries(initialTables)) {
tables[name] = rows.map((row) => ({ ...row }));
}
const db = {
from(table: string) {
const rowsOf = () => tables[table] ?? (tables[table] = []);
let predicate: (row: Row) => boolean = () => true;
let mode: "select" | "delete" | "update" = "select";
let patch: Row = {};
const narrow = (next: (row: Row) => boolean) => {
const prev = predicate;
predicate = (row) => prev(row) && next(row);
};
const query: any = {
select: () => query,
delete: () => {
mode = "delete";
return query;
},
update: (value: Row) => {
mode = "update";
patch = value;
return query;
},
eq: (column: string, value: unknown) => {
narrow((row) => row[column] === value);
return query;
},
in: (column: string, values: unknown[]) => {
narrow((row) => values.includes(row[column]));
return query;
},
filter: (column: string, operator: string, value: string) => {
if (operator !== "cs") return query;
const expected = (JSON.parse(value) as string[]).map((item) =>
item.toLowerCase(),
);
narrow((row) => {
const actual = row[column];
if (!Array.isArray(actual)) return false;
const normalized = actual.map((item) =>
String(item).toLowerCase(),
);
return expected.every((item) => normalized.includes(item));
});
return query;
},
then: (
resolve: (value: { data: Row[] | null; error: unknown }) => unknown,
reject?: (reason: unknown) => unknown,
) => {
let result: { data: Row[] | null; error: unknown };
if (mode === "delete") {
const message = options.deleteErrors?.[table];
if (message) {
result = { data: null, error: { message } };
} else {
tables[table] = rowsOf().filter((row) => !predicate(row));
result = { data: null, error: null };
}
} else if (mode === "update") {
for (const row of rowsOf().filter(predicate)) {
Object.assign(row, patch);
}
result = { data: null, error: null };
} else {
result = {
data: rowsOf().filter(predicate).map((row) => ({ ...row })),
error: null,
};
}
return Promise.resolve(result).then(resolve, reject);
},
};
return query;
},
};
return { db: db as any, tables };
}
const ids = (rows: Row[] | undefined) => (rows ?? []).map((row) => row.id);
beforeEach(() => {
deleteFileMock.mockClear();
deleteFileMock.mockResolvedValue(undefined as never);
listFilesMock.mockClear();
listFilesMock.mockResolvedValue([]);
});
// ---------------------------------------------------------------------------
// deleteAllUserChats
// ---------------------------------------------------------------------------
describe("deleteAllUserChats", () => {
it("deletes only the target user's assistant and tabular chats", async () => {
const { db, tables } = makeDb({
chats: [
{ id: "c1", user_id: "u1" },
{ id: "c2", user_id: "u2" },
],
tabular_review_chats: [
{ id: "tc1", user_id: "u1" },
{ id: "tc2", user_id: "u2" },
],
});
await deleteAllUserChats(db, "u1");
expect(ids(tables.chats)).toEqual(["c2"]);
expect(ids(tables.tabular_review_chats)).toEqual(["tc2"]);
});
it("surfaces delete failures with context", async () => {
const { db } = makeDb(
{ chats: [{ id: "c1", user_id: "u1" }], tabular_review_chats: [] },
{ deleteErrors: { chats: "boom" } },
);
await expect(deleteAllUserChats(db, "u1")).rejects.toThrow(
"Failed to delete assistant chats: boom",
);
});
});
// ---------------------------------------------------------------------------
// deleteAllUserTabularReviews
// ---------------------------------------------------------------------------
describe("deleteAllUserTabularReviews", () => {
const fixture = () =>
makeDb({
tabular_reviews: [
{ id: "r1", user_id: "u1" },
{ id: "r2", user_id: "u1" },
{ id: "r-other", user_id: "u2" },
],
tabular_review_chats: [
{ id: "rc1", review_id: "r1" },
{ id: "rc-other", review_id: "r-other" },
],
tabular_review_chat_messages: [
{ id: "rm1", chat_id: "rc1" },
{ id: "rm-other", chat_id: "rc-other" },
],
tabular_cells: [
{ id: "cell1", review_id: "r1" },
{ id: "cell-other", review_id: "r-other" },
],
});
it("cascades messages, chats, and cells before the reviews", async () => {
const { db, tables } = fixture();
await expect(deleteAllUserTabularReviews(db, "u1")).resolves.toBe(2);
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
});
it("returns 0 and deletes nothing for a user with no reviews", async () => {
const { db, tables } = fixture();
await expect(deleteAllUserTabularReviews(db, "u3")).resolves.toBe(0);
expect(tables.tabular_reviews).toHaveLength(3);
expect(tables.tabular_cells).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// deleteUserProjects
// ---------------------------------------------------------------------------
describe("deleteUserProjects", () => {
const fixture = () =>
makeDb({
projects: [
{ id: "p1", user_id: "u1" },
{ id: "p2", user_id: "u1" },
{ id: "p-other", user_id: "u2" },
],
documents: [
{ id: "d1", user_id: "u1", project_id: "p1" },
{ id: "d-loose", user_id: "u1", project_id: null },
{ id: "d-other", user_id: "u2", project_id: "p-other" },
],
document_versions: [
{
id: "v1",
document_id: "d1",
storage_path: "documents/u1/d1/source.pdf",
pdf_storage_path: "documents/u1/d1/converted.pdf",
},
{
id: "v-other",
document_id: "d-other",
storage_path: "documents/u2/d-other/source.pdf",
pdf_storage_path: null,
},
],
chats: [
{ id: "c1", project_id: "p1" },
{ id: "c-other", project_id: "p-other" },
],
chat_messages: [
{ id: "m1", chat_id: "c1" },
{ id: "m-other", chat_id: "c-other" },
],
tabular_reviews: [
{ id: "r1", project_id: "p1" },
{ id: "r-other", project_id: "p-other" },
],
tabular_review_chats: [
{ id: "rc1", review_id: "r1" },
{ id: "rc-other", review_id: "r-other" },
],
tabular_review_chat_messages: [
{ id: "rm1", chat_id: "rc1" },
{ id: "rm-other", chat_id: "rc-other" },
],
tabular_cells: [
{ id: "cell1", review_id: "r1" },
{ id: "cell-other", review_id: "r-other" },
],
project_subfolders: [
{ id: "f1", project_id: "p1" },
{ id: "f-other", project_id: "p-other" },
],
});
it("cascades project contents and storage files for owned projects", async () => {
const { db, tables } = fixture();
await expect(deleteUserProjects(db, "u1")).resolves.toBe(2);
expect(ids(tables.projects)).toEqual(["p-other"]);
expect(ids(tables.documents)).toEqual(["d-loose", "d-other"]);
expect(ids(tables.chats)).toEqual(["c-other"]);
expect(ids(tables.chat_messages)).toEqual(["m-other"]);
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
expect(ids(tables.project_subfolders)).toEqual(["f-other"]);
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
expect(deletedPaths.sort()).toEqual([
"documents/u1/d1/converted.pdf",
"documents/u1/d1/source.pdf",
]);
});
it("restricts deletion to the requested owned projects", async () => {
const { db, tables } = fixture();
// p-other belongs to u2, so requesting it must not delete anything of theirs.
await expect(
deleteUserProjects(db, "u1", ["p2", "p-other", "p2"]),
).resolves.toBe(1);
expect(ids(tables.projects)).toEqual(["p1", "p-other"]);
expect(ids(tables.documents)).toEqual(["d1", "d-loose", "d-other"]);
});
it("returns 0 for an explicitly empty project list", async () => {
const { db, tables } = fixture();
await expect(deleteUserProjects(db, "u1", [])).resolves.toBe(0);
expect(tables.projects).toHaveLength(3);
});
it("returns 0 when the user owns no projects", async () => {
const { db, tables } = fixture();
await expect(deleteUserProjects(db, "u3")).resolves.toBe(0);
expect(tables.projects).toHaveLength(3);
expect(deleteFileMock).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// deleteUserAccountData
// ---------------------------------------------------------------------------
describe("deleteUserAccountData", () => {
const fixture = () =>
makeDb({
projects: [
{ id: "p1", user_id: "u1", shared_with: [] },
{
id: "p-other",
user_id: "u2",
shared_with: ["u1@example.com", " U1@Example.com ", "keep@example.com"],
},
],
tabular_reviews: [
{ id: "r1", user_id: "u1", shared_with: [] },
{ id: "r-other", user_id: "u2", shared_with: ["u1@example.com"] },
],
documents: [
{ id: "d1", user_id: "u1", project_id: null },
// Guest doc uploaded by another user into u1's project: deleted too.
{ id: "d-guest", user_id: "u2", project_id: "p1" },
{ id: "d-other", user_id: "u2", project_id: "p-other" },
],
document_versions: [
{
id: "v1",
document_id: "d1",
storage_path: "documents/u1/d1/source.pdf",
pdf_storage_path: "documents/u1/d1/converted.pdf",
},
{
id: "v-guest",
document_id: "d-guest",
storage_path: "documents/u2/d-guest/source.docx",
pdf_storage_path: null,
},
{
id: "v-other",
document_id: "d-other",
storage_path: "documents/u2/d-other/source.pdf",
pdf_storage_path: null,
},
],
chats: [
{ id: "c1", user_id: "u1" },
{ id: "c-other", user_id: "u2" },
],
tabular_review_chats: [{ id: "rc1", user_id: "u1" }],
project_subfolders: [{ id: "f1", user_id: "u1" }],
hidden_workflows: [{ id: "h1", user_id: "u1" }],
workflow_open_source_submissions: [
{ id: "s1", submitted_by_user_id: "u1" },
],
workflow_shares: [
{ id: "ws-by", shared_by_user_id: "u1", shared_with_email: "x@y.z" },
{
id: "ws-to",
shared_by_user_id: "u2",
shared_with_email: "u1@example.com",
},
{
id: "ws-keep",
shared_by_user_id: "u2",
shared_with_email: "keep@example.com",
},
],
workflows: [
{ id: "w1", user_id: "u1" },
{ id: "w-other", user_id: "u2" },
],
});
it("removes the user's rows, files, and share references everywhere", async () => {
const { db, tables } = fixture();
listFilesMock.mockResolvedValue(["documents/u1/orphan.bin"]);
await deleteUserAccountData(db, "u1", " U1@Example.COM ");
// Owned docs and guest docs inside owned projects are gone.
expect(ids(tables.documents)).toEqual(["d-other"]);
expect(ids(tables.projects)).toEqual(["p-other"]);
expect(ids(tables.chats)).toEqual(["c-other"]);
expect(tables.tabular_review_chats).toEqual([]);
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
expect(tables.project_subfolders).toEqual([]);
expect(tables.hidden_workflows).toEqual([]);
expect(tables.workflow_open_source_submissions).toEqual([]);
expect(ids(tables.workflows)).toEqual(["w-other"]);
// Shares by the user and shares to the user's email are both removed.
expect(ids(tables.workflow_shares)).toEqual(["ws-keep"]);
// The email is scrubbed from other users' shared_with lists
// (case-insensitively), preserving other collaborators.
expect(tables.projects[0].shared_with).toEqual(["keep@example.com"]);
expect(tables.tabular_reviews[0].shared_with).toEqual([]);
// Version files for deleted docs plus orphans under the user's prefix.
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
expect(deletedPaths.sort()).toEqual([
"documents/u1/d1/converted.pdf",
"documents/u1/d1/source.pdf",
"documents/u1/orphan.bin",
"documents/u2/d-guest/source.docx",
]);
expect(listFilesMock).toHaveBeenCalledWith("documents/u1/");
});
it("treats storage prefix cleanup as best-effort", async () => {
const { db, tables } = fixture();
listFilesMock.mockRejectedValue(new Error("storage unavailable"));
await expect(
deleteUserAccountData(db, "u1", "u1@example.com"),
).resolves.toBeUndefined();
expect(ids(tables.documents)).toEqual(["d-other"]);
});
it("skips shared_with scrubbing when no email is known", async () => {
const { db, tables } = fixture();
await deleteUserAccountData(db, "u1", null);
// Rows referencing the email by value are left in place...
expect(tables.projects.find((row) => row.id === "p-other")?.shared_with)
.toContain("u1@example.com");
expect(ids(tables.workflow_shares)).toEqual(["ws-to", "ws-keep"]);
// ...but the user's own data is still deleted.
expect(ids(tables.documents)).toEqual(["d-other"]);
expect(tables.workflows.map((row) => row.id)).toEqual(["w-other"]);
});
});

View file

@ -0,0 +1,267 @@
import { describe, it, expect } from "vitest";
import {
normalizeEmail,
normalizeDisplayName,
loadProfileUsersByEmail,
findProfileUserByEmail,
findMissingUserEmails,
syncProfileEmail,
} from "../userLookup";
type Row = Record<string, unknown>;
/**
* Minimal user_profiles-shaped Supabase mock. Supports the query chains
* userLookup uses (select/eq/in/not + single-row readers) plus insert and
* update so syncProfileEmail can be exercised end to end.
*/
function makeDb(initialRows: Row[]) {
const tables: Record<string, Row[]> = {
user_profiles: initialRows.map((row) => ({ ...row })),
};
return {
tables,
from(table: string) {
const all = () => tables[table] ?? [];
let predicate: (row: Row) => boolean = () => true;
let mode: "select" | "insert" | "update" = "select";
let pendingRow: Row = {};
const narrow = (next: (row: Row) => boolean) => {
const prev = predicate;
predicate = (row) => prev(row) && next(row);
};
const query: any = {
select: () => query,
insert: (row: Row) => {
mode = "insert";
pendingRow = row;
return query;
},
update: (patch: Row) => {
mode = "update";
pendingRow = patch;
return query;
},
eq: (column: string, value: unknown) => {
narrow((row) => row[column] === value);
return query;
},
in: (column: string, values: unknown[]) => {
narrow((row) => values.includes(row[column]));
return query;
},
not: (column: string, operator: string, value: unknown) => {
if (operator === "is" && value === null) {
narrow((row) => row[column] != null);
}
return query;
},
maybeSingle: async () => ({
data: all().filter(predicate)[0] ?? null,
error: null,
}),
then: (
resolve: (value: { data: Row[] | null; error: null }) => unknown,
reject?: (reason: unknown) => unknown,
) => {
if (mode === "insert") {
all().push({ ...pendingRow });
return Promise.resolve({ data: null, error: null }).then(
resolve,
reject,
);
}
if (mode === "update") {
for (const row of all().filter(predicate)) {
Object.assign(row, pendingRow);
}
return Promise.resolve({ data: null, error: null }).then(
resolve,
reject,
);
}
return Promise.resolve({
data: all().filter(predicate),
error: null,
}).then(resolve, reject);
},
};
return query;
},
};
}
// ---------------------------------------------------------------------------
// normalizeEmail / normalizeDisplayName
// ---------------------------------------------------------------------------
describe("normalizeEmail", () => {
it("trims and lowercases", () => {
expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
});
it("returns empty string for non-strings", () => {
expect(normalizeEmail(null)).toBe("");
expect(normalizeEmail(undefined)).toBe("");
expect(normalizeEmail(42)).toBe("");
});
});
describe("normalizeDisplayName", () => {
it("trims usable names", () => {
expect(normalizeDisplayName(" Ada Lovelace ")).toBe("Ada Lovelace");
});
it("returns null for empty or non-string values", () => {
expect(normalizeDisplayName(" ")).toBeNull();
expect(normalizeDisplayName("")).toBeNull();
expect(normalizeDisplayName(null)).toBeNull();
expect(normalizeDisplayName(7)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// loadProfileUsersByEmail
// ---------------------------------------------------------------------------
describe("loadProfileUsersByEmail", () => {
it("indexes profiles by normalized email and by id", async () => {
const db = makeDb([
{ user_id: "u1", email: "Alice@Example.com", display_name: " Alice " },
{ user_id: "u2", email: "bob@example.com", display_name: null },
]);
const { userByEmail, userById } = await loadProfileUsersByEmail(
db as any,
);
expect(userByEmail.get("alice@example.com")).toEqual({
id: "u1",
email: "alice@example.com",
display_name: "Alice",
});
expect(userById.get("u2")).toEqual({
id: "u2",
email: "bob@example.com",
display_name: null,
});
expect(userByEmail.size).toBe(2);
});
it("skips rows whose email normalizes to empty", async () => {
const db = makeDb([
{ user_id: "u1", email: " ", display_name: null },
{ user_id: "u2", email: "ok@example.com", display_name: null },
]);
const { userByEmail } = await loadProfileUsersByEmail(db as any);
expect(userByEmail.size).toBe(1);
expect(userByEmail.has("ok@example.com")).toBe(true);
});
});
// ---------------------------------------------------------------------------
// findProfileUserByEmail
// ---------------------------------------------------------------------------
describe("findProfileUserByEmail", () => {
const rows = [
{ user_id: "u1", email: "alice@example.com", display_name: "Alice" },
];
it("finds a profile by normalized email", async () => {
const db = makeDb(rows);
await expect(
findProfileUserByEmail(db as any, " ALICE@example.com "),
).resolves.toEqual({
id: "u1",
email: "alice@example.com",
display_name: "Alice",
});
});
it("returns null when no profile matches", async () => {
const db = makeDb(rows);
await expect(
findProfileUserByEmail(db as any, "missing@example.com"),
).resolves.toBeNull();
});
it("returns null without querying for empty input", async () => {
const db = makeDb(rows);
await expect(findProfileUserByEmail(db as any, " ")).resolves.toBeNull();
});
});
// ---------------------------------------------------------------------------
// findMissingUserEmails
// ---------------------------------------------------------------------------
describe("findMissingUserEmails", () => {
const db = makeDb([
{ user_id: "u1", email: "alice@example.com", display_name: null },
]);
it("returns only emails with no matching profile", async () => {
await expect(
findMissingUserEmails(db as any, [
"Alice@Example.com",
"carol@example.com",
]),
).resolves.toEqual(["carol@example.com"]);
});
it("dedupes and drops empty entries before querying", async () => {
await expect(
findMissingUserEmails(db as any, [
"carol@example.com",
" CAROL@example.com ",
"",
" ",
]),
).resolves.toEqual(["carol@example.com"]);
});
it("returns [] for an empty input list", async () => {
await expect(findMissingUserEmails(db as any, [])).resolves.toEqual([]);
});
});
// ---------------------------------------------------------------------------
// syncProfileEmail
// ---------------------------------------------------------------------------
describe("syncProfileEmail", () => {
it("inserts a profile row when none exists", async () => {
const db = makeDb([]);
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
expect(result).toBeNull();
expect(db.tables.user_profiles).toEqual([
{ user_id: "u1", email: "new@example.com" },
]);
});
it("is a no-op when the stored email already matches (case-insensitive)", async () => {
const db = makeDb([
{ user_id: "u1", email: "Same@Example.com", display_name: null },
]);
const result = await syncProfileEmail(db as any, "u1", "same@example.com");
expect(result).toBeNull();
expect(db.tables.user_profiles[0].email).toBe("Same@Example.com");
});
it("updates the stored email when it changed", async () => {
const db = makeDb([
{ user_id: "u1", email: "old@example.com", display_name: null },
]);
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
expect(result).toBeNull();
expect(db.tables.user_profiles[0].email).toBe("new@example.com");
expect(db.tables.user_profiles[0].updated_at).toEqual(expect.any(String));
});
it("returns null without touching the table for missing inputs", async () => {
const db = makeDb([]);
await expect(syncProfileEmail(db as any, "", "a@b.com")).resolves.toBeNull();
await expect(syncProfileEmail(db as any, "u1", null)).resolves.toBeNull();
await expect(syncProfileEmail(db as any, "u1", " ")).resolves.toBeNull();
expect(db.tables.user_profiles).toEqual([]);
});
});

View file

@ -1,76 +0,0 @@
export const BUILTIN_WORKFLOWS: { id: string; title: string; prompt_md: string }[] = [
{
id: "builtin-cp-checklist",
title: "Generate CP Checklist",
prompt_md:
"## Generate Conditions Precedent Checklist\n\n" +
"Review the uploaded credit agreement or financing document and generate a comprehensive " +
"Conditions Precedent (CP) checklist.\n\n" +
"You MUST use the generate_docx tool to produce the checklist as a downloadable Word document. " +
"You MUST pass landscape: true to the generate_docx tool — the document must be in landscape orientation. " +
"Do not display the checklist inline — generate the .docx file and provide the download link.\n\n" +
"Structure the document as follows:\n" +
"- For each category of conditions (e.g. Corporate, Financial, Legal, Security), add a section with a heading\n" +
"- Under each category heading, include a table with exactly these four columns in this order:\n" +
" 1. Index — sequential number within the category (1, 2, 3…)\n" +
" 2. Clause Number — the clause or schedule reference from the agreement\n" +
" 3. Clause — a concise description of the condition precedent\n" +
" 4. Status — leave blank (empty string) for the user to fill in\n\n" +
"Use the table field in the section object (not content) for each category's rows.\n\n" +
"Before finalizing, double-check that every table is formatted correctly: each table must have exactly the four columns above in the same order, headers must match exactly (Index, Clause Number, Clause, Status), every row must have the same number of cells as the headers, the Index column must be sequential starting from 1 within each category, and no cells should contain stray markdown, newlines, or placeholder text (use an empty string for Status).",
},
{
id: "builtin-credit-summary",
title: "Credit Agreement Summary",
prompt_md:
"## Credit Agreement Summary\n\n" +
"Review the uploaded credit agreement and produce a comprehensive legal summary covering the following topics. " +
"For each section, identify the key provisions, quote the relevant clause or schedule references, and flag any unusual, onerous, or non-market terms.\n\n" +
"1. **Lenders** — All lenders or members of the lender syndicate, including their full legal name and role (e.g. mandated lead arranger, original lender, agent bank)\n" +
"2. **Borrowers** — All borrowers, including their full legal name and jurisdiction of incorporation\n" +
"3. **Guarantors** — All guarantors, including their full legal name and the scope of their guarantee obligation\n" +
"4. **Other Parties** — Any other material parties (e.g. facility agent, security agent, hedge counterparties, issuing bank) and their roles\n" +
"5. **Date of Agreement** — Date of the credit agreement\n" +
"6. **Facilities** — Each facility available (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C), the facility type, tranche name, and any key structural features\n" +
"7. **Amount** — Total committed amount across all facilities, the currency, and breakdown by tranche if applicable\n" +
"8. **Purpose** — Stated purpose for which borrowings may be used and any restrictions on use of proceeds\n" +
"9. **Interest** — Applicable reference rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured\n" +
"10. **Commitment Fee** — Commitment or utilisation fees, the applicable rate, how they are calculated, and the basis (e.g. undrawn commitment, average utilisation)\n" +
"11. **Repayment Schedule** — Repayment profile for each facility, whether by scheduled instalments or bullet repayment, and the repayment dates and amounts\n" +
"12. **Maturity** — Final maturity date for each facility\n" +
"13. **Security** — Each class of security granted or required (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken\n" +
"14. **Guarantees** — Guarantee obligations, the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test)\n" +
"15. **Financial Covenants** — Each financial covenant, the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, testing frequency, and any equity cure rights\n" +
"16. **Events of Default** — Each event of default, noting any grace periods, materiality thresholds, or cross-default provisions\n" +
"17. **Assignment** — Restrictions or permissions on assignment or transfer (e.g. white/blacklists, borrower consent for lender transfers; restrictions on borrower assignment)\n" +
"18. **Change of Control** — What constitutes a change of control, what obligations it triggers (e.g. mandatory prepayment, cancellation, lender consent), and any cure period\n" +
"19. **Prepayment Fee** — Any prepayment fees, make-whole premiums, or soft-call protections, the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposals)\n" +
"20. **Governing Law** — Governing law of the agreement\n" +
"21. **Dispute Resolution** — Whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions\n\n" +
"Deliver the summary inline in your chat response — do NOT call generate_docx. Only produce a downloadable Word document if the user explicitly asks for one.",
},
{
id: "builtin-sha-summary",
title: "Shareholder Agreement Summary",
prompt_md:
"## Shareholder Agreement Summary\n\n" +
"Review the uploaded shareholder agreement and produce a comprehensive legal summary covering the following topics. " +
"For each section, identify the key provisions, quote the relevant clause references, and flag any unusual, onerous, or market-standard deviations.\n\n" +
"1. **Parties & Shareholdings** — Full legal names, roles, share classes held, and percentage interests (on a fully diluted basis if stated)\n" +
"2. **Share Classes & Rights** — For each class: voting rights, dividend rights, liquidation preference, conversion or redemption features\n" +
"3. **Board Composition & Governance** — Board size, director appointment rights (and the shareholding thresholds required to maintain them), quorum, and casting vote\n" +
"4. **Reserved Matters** — Decisions requiring a special majority, unanimity, or a specific shareholder's consent; note the threshold and whose consent is required for each\n" +
"5. **Pre-emption on New Shares** — Who holds pre-emption rights, procedure, timeline, and any carve-outs (e.g. employee option schemes)\n" +
"6. **Transfer Restrictions** — Lock-up periods, prohibited transfers, permitted transfers (e.g. to affiliates), and any board or shareholder approval requirements\n" +
"7. **Right of First Refusal / Pre-emption on Transfer** — Trigger, procedure, pricing mechanics, and any exceptions\n" +
"8. **Drag-Along Rights** — Who holds the right, threshold to trigger, conditions (e.g. minimum price, independent valuation), and minority protections\n" +
"9. **Tag-Along Rights** — Who holds the right, triggering threshold, exercise procedure, and price terms\n" +
"10. **Anti-Dilution Protections** — Type (full ratchet, weighted average), trigger events, calculation mechanics, and exceptions\n" +
"11. **Dividend Policy** — Any obligation or target to pay dividends, preferential dividend rights, and restrictions on distributions\n" +
"12. **Exit & Liquidity** — Agreed exit routes (trade sale, IPO, drag sale), timelines, and liquidation preferences on exit\n" +
"13. **Deadlock** — Deadlock definition, escalation and resolution mechanisms (e.g. Russian roulette, put/call options), and consequences if unresolved\n" +
"14. **Non-Compete & Non-Solicitation** — Who is bound, scope of activities and geography, duration, and carve-outs\n" +
"15. **Governing Law & Dispute Resolution** — Applicable law, forum, arbitration or litigation, and any mandatory escalation steps\n\n" +
"Generate the summary as a downloadable Word document.",
},
];

View file

@ -0,0 +1,310 @@
import { type DocIndex, resolveDoc } from "./types";
// ---------------------------------------------------------------------------
// Internal citation parse types
// ---------------------------------------------------------------------------
type DocumentQuote = {
page: number | string;
quote: string;
// Spreadsheet sources are located by cell instead of page: `sheet` is the
// worksheet name and `cell` is an A1 address or range (e.g. "B7" or "B7:C9").
sheet?: string;
cell?: string;
};
type ParsedDocumentCitation = {
kind: "document";
ref: number;
doc_id: string;
page: number | string;
quote: string;
sheet?: string;
cell?: string;
quotes: DocumentQuote[];
};
type ParsedCaseCitation = {
kind: "case";
ref: number;
cluster_id: number;
quotes: {
opinionId: number | null;
type: string | null;
author: string | null;
quote: string;
}[];
};
type ParsedCitation = ParsedDocumentCitation | ParsedCaseCitation;
function normalizeCitation(raw: unknown): ParsedCitation | null {
if (!raw || typeof raw !== "object") return null;
const c = raw as Record<string, unknown>;
const markerRef =
typeof c.marker === "string"
? Number(c.marker.match(/^\[(\d+)\]$/)?.[1])
: NaN;
const ref =
typeof c.ref === "number"
? c.ref
: Number.isFinite(markerRef)
? markerRef
: null;
if (typeof ref !== "number") return null;
const quote = typeof c.quote === "string" ? c.quote : c.text;
const rawClusterId =
typeof c.cluster_id === "number"
? c.cluster_id
: typeof c.clusterId === "number"
? c.clusterId
: typeof c.cluster_id === "string"
? Number.parseInt(c.cluster_id, 10)
: typeof c.clusterId === "string"
? Number.parseInt(c.clusterId, 10)
: NaN;
if (Number.isFinite(rawClusterId) && rawClusterId > 0) {
const quotes = normalizeCaseCitationQuotes(c);
if (!quotes.length) {
if (typeof quote !== "string" || !quote) return null;
quotes.push({ opinionId: null, type: null, author: null, quote });
}
return { kind: "case", ref, cluster_id: Math.floor(rawClusterId), quotes };
}
if (typeof c.doc_id !== "string") return null;
const quotes = normalizeDocumentCitationQuotes(c);
if (!quotes.length) {
if (typeof quote !== "string" || !quote) return null;
quotes.push({
page: normalizeCitationPage(c.page),
quote,
...normalizeCellLocator(c),
});
}
return {
kind: "document",
ref,
doc_id: c.doc_id,
page: quotes[0].page,
quote: quotes[0].quote,
sheet: quotes[0].sheet,
cell: quotes[0].cell,
quotes,
};
}
/** Pull an optional spreadsheet `{sheet, cell}` locator off a raw object. */
function normalizeCellLocator(
c: Record<string, unknown>,
): { sheet?: string; cell?: string } {
const out: { sheet?: string; cell?: string } = {};
if (typeof c.sheet === "string" && c.sheet.trim()) out.sheet = c.sheet.trim();
if (typeof c.cell === "string" && c.cell.trim()) out.cell = c.cell.trim();
return out;
}
function normalizeCitationPage(value: unknown): number | string {
if (typeof value === "number") {
return value;
} else if (typeof value === "string" && /^\d+\s*-\s*\d+$/.test(value)) {
return value;
} else {
const n = parseInt(String(value ?? ""), 10);
if (!Number.isFinite(n)) return 1;
return n;
}
}
function normalizeDocumentCitationQuotes(
c: Record<string, unknown>,
): DocumentQuote[] {
if (!Array.isArray(c.quotes)) return [];
return c.quotes
.slice(0, 3)
.map((raw): DocumentQuote | null => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const text = typeof row.quote === "string" ? row.quote : row.text;
if (typeof text !== "string" || !text.trim()) return null;
// Fall back to the top-level sheet/cell so a citation can set them once.
return {
page: normalizeCitationPage(row.page ?? c.page),
quote: text,
...normalizeCellLocator({
sheet: row.sheet ?? c.sheet,
cell: row.cell ?? c.cell,
}),
};
})
.filter((quote): quote is DocumentQuote => !!quote);
}
function normalizeCaseCitationQuotes(c: Record<string, unknown>) {
if (!Array.isArray(c.quotes)) return [];
return c.quotes
.slice(0, 3)
.map((raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const text = typeof row.quote === "string" ? row.quote : row.text;
if (typeof text !== "string" || !text.trim()) return null;
const opinionId =
typeof row.opinion_id === "number" && Number.isFinite(row.opinion_id)
? Math.floor(row.opinion_id)
: typeof row.opinionId === "number" && Number.isFinite(row.opinionId)
? Math.floor(row.opinionId)
: null;
return {
opinionId,
type: typeof row.type === "string" ? row.type : null,
author: typeof row.author === "string" ? row.author : null,
quote: text,
};
})
.filter(
(quote): quote is {
opinionId: number | null;
type: string | null;
author: string | null;
quote: string;
} => !!quote,
);
}
// ---------------------------------------------------------------------------
// Citation block constants and parsers
// ---------------------------------------------------------------------------
export const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/;
export const CITATIONS_OPEN_TAG = "<CITATIONS>";
export const CITATIONS_CLOSE_TAG = "</CITATIONS>";
type CitationParseDiagnostics = {
hasBlock: boolean;
rawLength: number;
error: string | null;
};
export function parseCitationsWithDiagnostics(text: string): {
citations: ParsedCitation[];
diagnostics: CitationParseDiagnostics;
} {
const match = text.match(CITATIONS_BLOCK_RE);
if (!match) {
return { citations: [], diagnostics: { hasBlock: false, rawLength: 0, error: null } };
}
const raw = match[1] ?? "";
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return {
citations: [],
diagnostics: { hasBlock: true, rawLength: raw.length, error: "CITATIONS block JSON was not an array." },
};
}
return {
citations: parsed.map(normalizeCitation).filter((c): c is ParsedCitation => c !== null),
diagnostics: { hasBlock: true, rawLength: raw.length, error: null },
};
} catch (error) {
return {
citations: [],
diagnostics: {
hasBlock: true,
rawLength: raw.length,
error: error instanceof Error ? error.message : String(error),
},
};
}
}
export function parseCitations(text: string): ParsedCitation[] {
return parseCitationsWithDiagnostics(text).citations;
}
export function parsePartialCitationObjects(text: string): ParsedCitation[] {
const beforeClose = text.split(CITATIONS_CLOSE_TAG)[0] ?? text;
const arrayStart = beforeClose.indexOf("[");
if (arrayStart < 0) return [];
const parsed: ParsedCitation[] = [];
let inString = false;
let escaped = false;
let depth = 0;
let objectStart = -1;
for (let i = arrayStart + 1; i < beforeClose.length; i += 1) {
const char = beforeClose[i];
if (escaped) { escaped = false; continue; }
if (char === "\\") { escaped = inString; continue; }
if (char === '"') { inString = !inString; continue; }
if (inString) continue;
if (char === "{") {
if (depth === 0) objectStart = i;
depth += 1;
} else if (char === "}") {
if (depth === 0) continue;
depth -= 1;
if (depth === 0 && objectStart >= 0) {
try {
const raw = JSON.parse(beforeClose.slice(objectStart, i + 1));
const citation = normalizeCitation(raw);
if (citation) parsed.push(citation);
} catch { /* ignore incomplete/malformed partial object */ }
objectStart = -1;
}
} else if (char === "]" && depth === 0) {
break;
}
}
return parsed;
}
type CasesByClusterId = Map<number, {
caseName: string | null;
citations: string[];
url: string | null;
pdfUrl: string | null;
dateFiled: string | null;
}>;
export function createCitation(
citation: ParsedCitation,
docIndex: DocIndex,
casesByClusterId?: CasesByClusterId,
) {
if (citation.kind === "case") {
const caseRecord = casesByClusterId?.get(citation.cluster_id);
return {
type: "citation_data",
kind: "case",
ref: citation.ref,
cluster_id: citation.cluster_id,
case_name: caseRecord?.caseName ?? null,
citation: caseRecord?.citations[0] ?? null,
url: caseRecord?.url ?? null,
pdfUrl: caseRecord?.pdfUrl ?? null,
dateFiled: caseRecord?.dateFiled ?? null,
quotes: citation.quotes,
};
}
const docInfo = resolveDoc(citation.doc_id, docIndex);
return {
type: "citation_data",
kind: "document",
ref: citation.ref,
doc_id: citation.doc_id,
document_id: docInfo?.document_id,
version_id: docInfo?.version_id ?? null,
version_number: docInfo?.version_number ?? null,
filename: docInfo?.filename ?? citation.doc_id,
page: citation.page,
quote: citation.quote,
sheet: citation.sheet,
cell: citation.cell,
quotes: citation.quotes,
};
}

View file

@ -0,0 +1,583 @@
import { createServerSupabase } from "../supabase";
import {
attachActiveVersionPaths,
} from "../documentVersions";
import {
type DocStore,
type DocIndex,
type WorkflowStore,
type ChatMessage,
type AskInputsResponseRequest,
type AskInputResponseItem,
devLog,
} from "./types";
import { buildSystemPrompt } from "./prompts";
import { parseCitations, createCitation } from "./citations";
import type { AssistantEvent } from "./streaming";
export async function enrichWithPriorEvents(
messages: ChatMessage[],
chatId: string | null | undefined,
db: ReturnType<typeof createServerSupabase>,
docIndex: DocIndex,
): Promise<ChatMessage[]> {
if (!chatId) return messages;
const { data: rows } = await db
.from("chat_messages")
.select("content, created_at")
.eq("chat_id", chatId)
.eq("role", "assistant")
.order("created_at", { ascending: false })
.limit(1);
const lastRow = rows?.[0] as { content?: unknown } | undefined;
const content = lastRow?.content;
if (!Array.isArray(content)) return messages;
const slugByDocumentId = new Map<string, string>();
for (const [slug, info] of Object.entries(docIndex)) {
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
}
const refFor = (documentId: unknown, filename: unknown) => {
const slug =
typeof documentId === "string"
? slugByDocumentId.get(documentId)
: undefined;
return slug ? `${slug} ("${filename}")` : `"${filename}"`;
};
const lines: string[] = [];
for (const ev of content as Record<string, unknown>[]) {
if (ev?.type === "doc_created") {
lines.push(`- generated_document → ${refFor(ev.document_id, ev.filename)}`);
} else if (ev?.type === "doc_edited") {
lines.push(`- edit_document → ${refFor(ev.document_id, ev.filename)}`);
} else if (ev?.type === "doc_read") {
lines.push(`- read_document → ${refFor(ev.document_id, ev.filename)}`);
} else if (ev?.type === "doc_replicated") {
// The model needs to know what each copy resolved to so it
// can call edit_document / read_document on them. Emit one
// line per copy, all attributed back to the same source.
const srcLabel =
typeof ev.filename === "string" ? `"${ev.filename}"` : "";
const copies = Array.isArray(ev.copies)
? (ev.copies as {
new_filename?: unknown;
document_id?: unknown;
}[])
: [];
for (const c of copies) {
const ref = refFor(c.document_id, c.new_filename);
lines.push(
srcLabel
? `- replicate_document → ${ref} (copy of ${srcLabel})`
: `- replicate_document → ${ref}`,
);
}
} else if (ev?.type === "workflow_applied") {
lines.push(`- applied workflow: "${ev.title}"`);
} else if (ev?.type === "ask_inputs") {
const count = Array.isArray(ev.items) ? ev.items.length : 0;
lines.push(`- asked user for ${count} input${count === 1 ? "" : "s"}`);
} else if (ev?.type === "ask_inputs_response") {
const responses = Array.isArray(ev.responses) ? ev.responses : [];
for (const response of responses) {
if (!response || typeof response !== "object") continue;
const row = response as Record<string, unknown>;
if (row.skipped) {
lines.push("- user skipped an input");
} else if (row.kind === "choice" && typeof row.answer === "string") {
lines.push(`- user answered: "${row.answer}"`);
} else if (
row.kind === "documents" &&
Array.isArray(row.filenames)
) {
lines.push(
`- user attached documents: ${row.filenames.join(", ") || "none"}`,
);
}
}
}
}
if (lines.length === 0) return messages;
const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`;
// Find the index of the last assistant message and attach the
// summary there only.
let lastAssistantIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIdx = i;
break;
}
}
if (lastAssistantIdx < 0) return messages;
const enriched = messages.slice();
const target = enriched[lastAssistantIdx];
enriched[lastAssistantIdx] = {
...target,
content: (target.content ?? "") + summary,
};
return enriched;
}
export function buildMessages(
messages: ChatMessage[],
docAvailability: {
doc_id: string;
filename: string;
folder_path?: string;
}[],
systemPromptExtra?: string,
docIndex?: DocIndex,
includeResearchTools = true,
) {
const formatted: unknown[] = [];
let systemContent = buildSystemPrompt(includeResearchTools);
if (systemPromptExtra) {
systemContent += `\n\n${systemPromptExtra.trim()}`;
}
if (docAvailability.length) {
systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n";
for (const doc of docAvailability) {
const label = doc.folder_path
? `${doc.folder_path} / ${doc.filename}`
: doc.filename;
systemContent += `- ${doc.doc_id}: ${label}\n`;
}
systemContent +=
"\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) once at the start of every response that involves a document's content, even if you have read it in a previous turn. Within the same response, do not call read_document or fetch_documents again for a document/version that has already been read; use the prior tool result, find_in_document for targeted checks, or proceed to the next required tool. Failure to read once per turn will result in hallucinated or stale content.\n---\n";
}
formatted.push({ role: "system", content: systemContent });
// Map document_id (UUID) → current-turn doc_id slug, so when we
// inline a user attachment we hand the model the same handle it
// would use to call read_document / fetch_documents.
const slugByDocumentId = new Map<string, string>();
if (docIndex) {
for (const [slug, info] of Object.entries(docIndex)) {
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
}
}
for (const msg of messages) {
let content = msg.content ?? "";
if (msg.role === "user" && msg.workflow) {
content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`;
}
if (msg.role === "user" && msg.files?.length) {
const lines = msg.files.map((f) => {
const slug = f.document_id
? slugByDocumentId.get(f.document_id)
: undefined;
return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`;
});
content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`;
}
formatted.push({ role: msg.role, content });
}
return formatted;
}
export function extractCitations(
fullText: string,
docIndex: DocIndex,
_events?: ({ type: string } & Record<string, unknown>[]) | unknown[],
): unknown[] {
return parseCitations(fullText).map((c) =>
createCitation(c, docIndex),
);
}
export function stripTransientAssistantEvents(events: AssistantEvent[]) {
return events.filter((event) => event.type !== "case_opinions");
}
function cleanAskInputResponseId(value: unknown) {
const id = typeof value === "string" ? value.trim() : "";
return id.slice(0, 80);
}
export function parseAskInputsResponsePayload(
value: unknown,
): AskInputsResponseRequest | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
const rawResponses = Array.isArray(row.responses) ? row.responses : [];
const responses = rawResponses
.map((item): AskInputResponseItem | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const current = item as Record<string, unknown>;
const id = cleanAskInputResponseId(current.id);
const kind = current.kind;
const skipped = current.skipped === true;
if (!id || (kind !== "choice" && kind !== "documents")) return null;
if (kind === "choice") {
const question =
typeof current.question === "string"
? current.question.trim().slice(0, 500)
: "";
const answer =
typeof current.answer === "string"
? current.answer.trim().slice(0, 1000)
: "";
if (!question || (!answer && !skipped)) return null;
return {
id,
kind,
question,
...(answer ? { answer } : {}),
...(skipped ? { skipped: true } : {}),
};
}
const rawFilenames = Array.isArray(current.filenames)
? current.filenames
: [];
const filenames = rawFilenames
.filter((f): f is string => typeof f === "string")
.map((f) => f.trim())
.filter(Boolean)
.slice(0, 50);
return {
id,
kind,
filenames,
...(skipped ? { skipped: true } : {}),
};
})
.filter((item): item is AskInputResponseItem => !!item)
.slice(0, 20);
return responses.length > 0 ? { responses } : null;
}
export async function appendAskInputsResponseToLastAssistantMessage(
db: ReturnType<typeof createServerSupabase>,
chatId: string,
response: AskInputsResponseRequest,
) {
await appendAssistantEventsToLastAssistantMessage(db, chatId, [
{
type: "ask_inputs_response" as const,
responses: response.responses,
},
]);
}
export async function appendAssistantEventsToLastAssistantMessage(
db: ReturnType<typeof createServerSupabase>,
chatId: string,
events: AssistantEvent[],
citations?: unknown[],
) {
if (events.length === 0 && (!citations || citations.length === 0)) {
return;
}
const { data: rows, error: selectError } = await db
.from("chat_messages")
.select("id, content, citations")
.eq("chat_id", chatId)
.eq("role", "assistant")
.order("created_at", { ascending: false })
.limit(1);
if (selectError || !rows?.[0]) {
if (selectError) {
console.error(
"[assistant-events] failed to load assistant message",
selectError,
);
}
return;
}
const row = rows[0] as {
id: string;
content: unknown;
citations?: unknown;
};
const existing = Array.isArray(row.content)
? row.content
: [];
const next = [...existing, ...events];
const existingCitations = Array.isArray(row.citations)
? row.citations
: [];
const nextCitations =
citations && citations.length > 0
? [...existingCitations, ...citations]
: existingCitations;
const { error: updateError } = await db
.from("chat_messages")
.update({
content: next.length ? next : null,
citations: nextCitations.length ? nextCitations : null,
})
.eq("id", row.id);
if (updateError) {
console.error(
"[assistant-events] failed to update assistant message",
updateError,
);
}
}
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
return [...events, { type: "content" as const, text: "Cancelled by user." }];
}
export function buildCancelledAssistantMessage(args: {
fullText: string;
events: AssistantEvent[];
buildCitations: (fullText: string, events: AssistantEvent[]) => unknown[];
}) {
const events = appendCancelledAssistantEvent(
stripTransientAssistantEvents(args.events),
);
return {
events,
citations: args.buildCitations(args.fullText, events),
};
}
// ---------------------------------------------------------------------------
// Document context builder (from message file attachments)
// ---------------------------------------------------------------------------
export async function buildDocContext(
messages: ChatMessage[],
userId: string,
db: ReturnType<typeof createServerSupabase>,
chatId?: string | null,
): Promise<{ docIndex: DocIndex; docStore: DocStore }> {
const docIndex: DocIndex = {};
const docStore: DocStore = new Map();
const documentIds = new Set<string>();
for (const m of messages) {
for (const f of m.files ?? []) {
if (f.document_id) documentIds.add(f.document_id);
}
}
// Also pull in document_ids from prior assistant events in this chat —
// generated docs (generate_docx) and tracked-change edits (edit_document)
// aren't attached to user messages as files, so they only live in the
// assistant's `doc_created` / `doc_edited` events. Without this sweep
// the model loses access to generated docs after the turn that created
// them, and can't call edit_document / read_document on them.
if (chatId) {
const { data: rows } = await db
.from("chat_messages")
.select("content")
.eq("chat_id", chatId)
.eq("role", "assistant");
for (const row of rows ?? []) {
const content = (row as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const ev of content as Record<string, unknown>[]) {
if (
(ev?.type === "doc_created" || ev?.type === "doc_edited") &&
typeof ev.document_id === "string"
) {
documentIds.add(ev.document_id);
}
}
}
}
const ids = [...documentIds];
if (ids.length > 0) {
const { data: docs } = await db
.from("documents")
.select("id, current_version_id, status")
.in("id", ids)
.eq("user_id", userId)
.eq("status", "ready");
const docList = (docs ?? []) as unknown as {
id: string;
filename?: string | null;
file_type?: string | null;
current_version_id?: string | null;
active_version_number?: number | null;
storage_path?: string | null;
}[];
await attachActiveVersionPaths(db, docList);
for (let i = 0; i < docList.length; i++) {
const doc = docList[i];
if (!doc.storage_path) continue;
const docLabel = `doc-${i}`;
const filename = doc.filename?.trim() || "Untitled document";
docIndex[docLabel] = {
document_id: doc.id,
filename,
version_id: doc.current_version_id ?? null,
version_number: doc.active_version_number ?? null,
};
docStore.set(docLabel, {
storage_path: doc.storage_path,
file_type: doc.file_type ?? "",
filename,
});
}
}
devLog(
"[buildDocContext] available docs:",
Object.entries(docIndex).map(([label, info]) => ({
label,
filename: info.filename,
document_id: info.document_id,
})),
);
return { docIndex, docStore };
}
export async function buildProjectDocContext(
projectId: string,
_userId: string,
db: ReturnType<typeof createServerSupabase>,
): Promise<{
docIndex: DocIndex;
docStore: DocStore;
folderPaths: Map<string, string>;
}> {
const docIndex: DocIndex = {};
const docStore: DocStore = new Map();
const [{ data: docs }, { data: folders }] = await Promise.all([
db
.from("documents")
.select("id, current_version_id, status, folder_id")
.eq("project_id", projectId)
.eq("status", "ready")
.order("created_at", { ascending: true }),
db
.from("project_subfolders")
.select("id, name, parent_folder_id")
.eq("project_id", projectId),
]);
const docList = (docs ?? []) as unknown as {
id: string;
filename?: string | null;
file_type?: string | null;
current_version_id?: string | null;
active_version_number?: number | null;
folder_id?: string | null;
storage_path?: string | null;
}[];
await attachActiveVersionPaths(db, docList);
// Build folder id → full path map
const folderMap = new Map<
string,
{ name: string; parent_folder_id: string | null }
>();
for (const f of folders ?? [])
folderMap.set(f.id, {
name: f.name,
parent_folder_id: f.parent_folder_id,
});
function resolvePath(folderId: string | null): string {
if (!folderId) return "";
const parts: string[] = [];
let cur: string | null = folderId;
while (cur) {
const f = folderMap.get(cur);
if (!f) break;
parts.unshift(f.name);
cur = f.parent_folder_id;
}
return parts.join(" / ");
}
const folderPaths = new Map<string, string>(); // doc label → folder path
for (let i = 0; i < docList.length; i++) {
const doc = docList[i];
if (!doc.storage_path) continue;
const docLabel = `doc-${i}`;
const filename = doc.filename?.trim() || "Untitled document";
docIndex[docLabel] = {
document_id: doc.id,
filename,
version_id: doc.current_version_id ?? null,
version_number: doc.active_version_number ?? null,
};
docStore.set(docLabel, {
storage_path: doc.storage_path,
file_type: doc.file_type ?? "",
filename,
});
const path = resolvePath(doc.folder_id ?? null);
if (path) folderPaths.set(docLabel, path);
}
devLog(
"[buildProjectDocContext] available docs:",
Object.entries(docIndex).map(([label, info]) => ({
label,
filename: info.filename,
document_id: info.document_id,
folder: folderPaths.get(label) ?? null,
})),
);
return { docIndex, docStore, folderPaths };
}
export async function buildWorkflowStore(
userId: string,
userEmail: string | null | undefined,
db: ReturnType<typeof createServerSupabase>,
): Promise<WorkflowStore> {
const { SYSTEM_ASSISTANT_WORKFLOWS } = await import("../systemWorkflows");
const store: WorkflowStore = new Map();
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
// Seed system workflows first.
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
store.set(wf.id, { title: wf.title, skill_md: wf.skill_md });
}
// Then overlay user-owned assistant workflows.
const { data: workflows } = await db
.from("workflows")
.select("id, title, prompt_md")
.eq("user_id", userId)
.eq("type", "assistant");
for (const wf of workflows ?? []) {
if (wf.prompt_md) {
store.set(wf.id, { title: wf.title, skill_md: wf.prompt_md });
}
}
// Shared assistant workflows must also be readable by workflow tools.
if (normalizedUserEmail) {
const { data: shares } = await db
.from("workflow_shares")
.select("workflow_id")
.eq("shared_with_email", normalizedUserEmail);
const sharedIds = [
...new Set((shares ?? []).map((share) => share.workflow_id)),
];
if (sharedIds.length > 0) {
const { data: sharedWorkflows } = await db
.from("workflows")
.select("id, title, prompt_md")
.in("id", sharedIds)
.eq("type", "assistant");
for (const wf of sharedWorkflows ?? []) {
if (wf.prompt_md) {
store.set(wf.id, {
title: wf.title,
skill_md: wf.prompt_md,
});
}
}
}
}
return store;
}

View file

@ -0,0 +1,8 @@
export * from "./types";
export * from "./prompts";
export * from "./tools/toolSchemas";
export * from "./citations";
export * from "./tools/documentOps";
export * from "./tools/toolDispatcher";
export * from "./streaming";
export * from "./contextBuilders";

View file

@ -0,0 +1,88 @@
import { COURTLISTENER_SYSTEM_PROMPT } from "./tools/courtlistenerTools";
const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are Mike, an AI legal assistant for lawyers and legal professionals. Help analyze documents, answer legal questions, and draft legal documents.
CORE RULES:
- Be precise, professional, and evidence-aware.
- Do not fabricate document content.
- Use at most 10 tool-use rounds per response. Batch independent tool calls and leave room for the final answer.
- Read each relevant document/version at most once per response. After read_document or fetch_documents returns a document's full text, do not call either tool again for that same document/version in the same response; use the prior result, call find_in_document for targeted checks, or proceed to the next required tool.
- If the user selects a workflow with [Workflow: <title> (id: <id>)], immediately call read_workflow with that id and follow the workflow before doing anything else.
- If you need the user to choose between options, clarify a missing premise, or attach one or more documents before you can continue, call ask_inputs with all needed choice and document-upload items in a single tool call. For document-upload items, include a document_types array with short labels for the specific categories of documents you need. After asking, do not continue the substantive task until the user responds in a later message.
DOCUMENT CITATIONS:
Use document citations only for verbatim evidence from uploaded or generated documents.
In prose, put sequential markers [1], [2], etc. exactly where the cited claim appears. Assign citation refs in first-appearance order and increment by exactly 1 each time: [1], [2], [3], never [1], [2], [3], [4], [5], [8], [9]. The marker number is the citation "ref" value, not a page, footnote, section, clause, or document number.
At the very end of the response, append:
<CITATIONS>
[
{"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 3, "quote": "exact verbatim text"}]},
{"ref": 2, "doc_id": "doc-1", "quotes": [{"page": "41-42", "quote": "text before page break [[PAGE_BREAK]] text after page break"}]}
]
</CITATIONS>
Citation rules:
- Every [N] marker must have exactly one matching entry with "ref": N.
- Citation refs must be contiguous with no skipped numbers. If the response uses N citations, the refs must be exactly 1 through N, and the <CITATIONS> array should list them in that order.
- Bracketed numbers like [1] are only citation annotation markers. Do not add brackets to section, clause, schedule, exhibit, paragraph, or list numbering.
- "doc_id" must be the exact chat-local label you were given, such as "doc-0". Never use a filename or document UUID in "doc_id".
- Use one citation entry per marker. If one marker needs several passages, use "quotes" with 1 quote by default and at most 3.
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote".
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
- Omit the <CITATIONS> block when there are no citations.
DOCX GENERATION:
- If the user asks you to create or draft a document, call generate_docx and provide the downloadable Word document rather than only displaying text inline.
- If the user asks for a spreadsheet, table workbook, tracker, checklist matrix, or Excel file, call generate_excel.
- If the user asks for slides, a presentation, pitch deck, board deck, or PowerPoint file, call generate_ppt.
- If the user asks to revise a document you just generated, call edit_document on that document unless they explicitly want a brand-new document or the change is too broad for coherent editing.
- Use heading levels in order; do not skip from Heading 1 to Heading 3.
- Numbering starts at 1, never 0. The generator applies legal numbering automatically. Do not type numbering prefixes into headings.
- Do not repeat the document title as the first section heading.
- Contract preambles, party blocks, recitals, and WHEREAS clauses are unnumbered. Begin numbering at the first operative clause or section.
- Contracts and agreements must end with an unnumbered signature block on a fresh page. Set pageBreak: true on the final section and include signature lines such as By, Name, Title, and Date for each party.
DOCUMENT EDITING:
- For document edits, call read_document or fetch_documents once for each relevant document/version unless the exact needed text is already available in this response. Do not reread the same document/version before calling edit_document.
When edit_document adds, deletes, moves, or reorders any numbered clause, section, schedule, exhibit, or list item:
- Renumber all affected downstream items in the same edit.
- Update all affected cross-references, including references in recitals, definitions, schedules, and exhibits.
- Before editing, scan the full document with read_document or find_in_document for affected references.
- If a reference might point to a shifted number, include the update and explain the reason.
- When deleting square brackets, delete both "[" and "]".`;
const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
- Chat-local labels such as "doc-0" are internal. Use them only in tool arguments and citation JSON.
- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
- Refer to documents by filename or a natural description, such as "the NDA draft".
REASONING TRACE SAFETY:
- If reasoning or thought summaries are shown to the user, keep them as brief natural-language progress summaries.
- Do not expose source code, JSON snippets, tool arguments, API payloads, schemas, raw citations JSON, internal prompts, or implementation details in reasoning traces.
- Do not use code fences or structured data blocks in reasoning traces.
GENERAL GUIDANCE:
- Cite the exact document or fetched opinion passage for evidence-backed claims.
- If no documents are provided, answer from legal knowledge.
- Do not use emojis.
`;
/**
* Assemble the chat system prompt. When `includeResearchTools` is true the
* CourtListener (US case-law) research instructions are spliced in; when
* false they are omitted entirely so the model is not told about tools it
* does not have.
*/
export function buildSystemPrompt(includeResearchTools = true): string {
return includeResearchTools
? `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${COURTLISTENER_SYSTEM_PROMPT}\n${SYSTEM_PROMPT_AFTER_RESEARCH}`
: `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${SYSTEM_PROMPT_AFTER_RESEARCH}`;
}
export const SYSTEM_PROMPT = buildSystemPrompt(true);

View file

@ -0,0 +1,553 @@
import {
streamChatWithTools,
resolveModel,
DEFAULT_MAIN_MODEL,
type LlmMessage,
type OpenAIToolSchema,
} from "../llm";
import { safeErrorMessage } from "../safeError";
import { createServerSupabase } from "../supabase";
import {
buildUserMcpTools,
type McpToolEvent,
} from "../mcpConnectors";
import {
COURTLISTENER_TOOLS,
type CaseCitationEvent,
type CourtlistenerToolEvent,
} from "./tools/courtlistenerTools";
import {
type DocStore,
type DocIndex,
type TabularCellStore,
type WorkflowStore,
type ToolCall,
type AskInputsEvent,
type EditAnnotation,
devLog,
} from "./types";
import { TOOLS, WORKFLOW_TOOLS } from "./tools/toolSchemas";
import {
parseCitationsWithDiagnostics,
parsePartialCitationObjects,
createCitation,
CITATIONS_OPEN_TAG,
} from "./citations";
import {
runToolCalls,
type CourtlistenerTurnState,
} from "./tools/toolDispatcher";
import {
type TurnEditState,
type TurnReadState,
} from "./tools/documentOps";
export type AssistantEvent =
| { type: "reasoning"; text: string }
| AskInputsEvent
| {
type: "ask_inputs_response";
responses: {
id: string;
kind: "choice" | "documents";
question?: string;
answer?: string;
filenames?: string[];
skipped?: boolean;
}[];
}
| { type: "doc_read"; filename: string; document_id?: string }
| {
type: "doc_find";
filename: string;
query: string;
total_matches: number;
}
| {
type: "doc_created";
filename: string;
download_url: string;
document_id?: string;
version_id?: string;
version_number?: number | null;
}
| { type: "doc_download"; filename: string; download_url: string }
| {
type: "doc_replicated";
/** Source document being copied. */
filename: string;
count: number;
copies: {
new_filename: string;
document_id: string;
version_id: string;
}[];
}
| { type: "workflow_applied"; workflow_id: string; title: string }
| {
type: "doc_edited";
filename: string;
document_id: string;
version_id: string;
/** Per-document monotonic Vn; null if backend couldn't determine it. */
version_number: number | null;
download_url: string;
annotations: EditAnnotation[];
}
| CaseCitationEvent
| CourtlistenerToolEvent
| McpToolEvent
| { type: "case_opinions"; cluster_id: number; case: unknown }
| { type: "content"; text: string }
| { type: "error"; message: string };
export class AssistantStreamError extends Error {
fullText: string;
events: AssistantEvent[];
constructor(message: string, fullText: string, events: AssistantEvent[]) {
super(message);
this.name = "AssistantStreamError";
this.fullText = fullText;
this.events = events;
}
}
export class AssistantStreamAbortError extends AssistantStreamError {
constructor(fullText: string, events: AssistantEvent[]) {
super("Stream aborted.", fullText, events);
this.name = "AbortError";
}
}
class AssistantStreamAskInputsPause extends Error {
constructor() {
super("Waiting for user input.");
this.name = "AssistantStreamAskInputsPause";
}
}
export function isAbortError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const record = error as { name?: unknown; message?: unknown };
return (
record.name === "AbortError" || record.message === "Stream aborted."
);
}
function throwIfAborted(signal?: AbortSignal) {
if (!signal?.aborted) return;
const err = new Error("Stream aborted.");
err.name = "AbortError";
throw err;
}
export async function runLLMStream(params: {
apiMessages: unknown[];
docStore: DocStore;
docIndex: DocIndex;
userId: string;
db: ReturnType<typeof createServerSupabase>;
write: (s: string) => void;
extraTools?: unknown[];
includeResearchTools?: boolean;
workflowStore?: WorkflowStore;
tabularStore?: TabularCellStore;
buildCitations?: (fullText: string) => unknown[];
model?: string;
apiKeys?: import("../llm").UserApiKeys;
signal?: AbortSignal;
/**
* If set, generate_docx will attach created docs to this project so
* they appear in the project sidebar. Leave null for general chats
* generated docs still get persisted, but as standalone documents.
*/
projectId?: string | null;
}): Promise<{
fullText: string;
events: AssistantEvent[];
citations: unknown[];
}> {
const {
apiMessages,
docStore,
docIndex,
userId,
db,
write,
extraTools,
includeResearchTools = true,
workflowStore,
tabularStore,
buildCitations,
model,
apiKeys,
signal,
projectId,
} = params;
const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : [];
const mcpTools = await buildUserMcpTools(userId, db);
const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS];
const activeTools = extraTools?.length
? [...baseTools, ...mcpTools, ...extraTools]
: [...baseTools, ...mcpTools];
// Extract system prompt; pass remaining turns to the adapter as
// plain user/assistant messages.
const rawMsgs = apiMessages as { role: string; content: string | null }[];
const systemPrompt =
rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : "";
const chatMessages: LlmMessage[] = rawMsgs
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role === "assistant" ? "assistant" : "user",
content: m.content ?? "",
}));
const events: AssistantEvent[] = [];
// One assistant turn produces at most one document_versions row per
// edited doc. `runToolCalls` fires once per tool-call batch; the model
// may emit multiple batches in a single turn, so this map persists
// across batches to let subsequent edit_document calls overwrite the
// turn's existing version instead of creating a new one.
const turnEditState: TurnEditState = new Map();
// Suppress repeated full-document reads for the same document/version in
// one assistant response. The guard is invalidated when edit_document
// changes that document so a post-edit verification read can still happen.
const turnReadState: TurnReadState = new Map();
const courtlistenerTurnState: CourtlistenerTurnState = {
casesByClusterId: new Map(),
};
let fullText = "";
let iterText = "";
let iterVisibleText = "";
let iterReasoning = "";
let visibleTailBuffer = "";
let citationsOpenSeen = false;
let streamingCitationsBuffer = "";
let streamedCitationCount = 0;
const emitCitationStreamSnapshot = (
status: "started" | "partial",
citations: unknown[],
) => {
if (buildCitations) return;
write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`);
};
const streamHiddenCitationContent = (delta: string) => {
if (buildCitations || !delta) return;
streamingCitationsBuffer += delta;
const partial = parsePartialCitationObjects(streamingCitationsBuffer);
if (partial.length <= streamedCitationCount) return;
streamedCitationCount = partial.length;
const citations = partial.map((c) =>
createCitation(
c,
docIndex,
courtlistenerTurnState.casesByClusterId,
),
);
emitCitationStreamSnapshot("partial", citations);
};
const streamVisibleContent = (delta: string) => {
if (!delta) return;
if (citationsOpenSeen) {
streamHiddenCitationContent(delta);
return;
}
const combined = visibleTailBuffer + delta;
const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG);
if (markerIdx >= 0) {
const visible = combined.slice(0, markerIdx);
if (visible) {
iterVisibleText += visible;
write(
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
);
}
visibleTailBuffer = "";
citationsOpenSeen = true;
streamingCitationsBuffer = "";
streamedCitationCount = 0;
emitCitationStreamSnapshot("started", []);
streamHiddenCitationContent(
combined.slice(markerIdx + CITATIONS_OPEN_TAG.length),
);
return;
}
const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length);
const visible = combined.slice(0, combined.length - keep);
visibleTailBuffer = combined.slice(combined.length - keep);
if (visible) {
iterVisibleText += visible;
write(
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
);
}
};
const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
const emit = opts.emit ?? true;
if (citationsOpenSeen || !visibleTailBuffer) {
visibleTailBuffer = "";
return;
}
iterVisibleText += visibleTailBuffer;
if (emit) {
write(
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
);
}
visibleTailBuffer = "";
};
const flushText = (opts: { emit?: boolean } = {}) => {
if (!iterText) return;
fullText += iterText;
flushVisibleTail(opts);
if (iterVisibleText) {
events.push({ type: "content", text: iterVisibleText });
}
iterText = "";
iterVisibleText = "";
visibleTailBuffer = "";
citationsOpenSeen = false;
streamingCitationsBuffer = "";
streamedCitationCount = 0;
};
const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
flushText(opts);
if (iterReasoning) {
events.push({ type: "reasoning", text: iterReasoning });
iterReasoning = "";
}
};
const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
try {
throwIfAborted(signal);
await streamChatWithTools({
model: selectedModel,
systemPrompt,
messages: chatMessages,
tools: activeTools as OpenAIToolSchema[],
maxIterations: 10,
apiKeys,
enableThinking: true,
abortSignal: signal,
callbacks: {
onContentDelta: (delta) => {
iterText += delta;
streamVisibleContent(delta);
},
onReasoningDelta: (delta) => {
iterReasoning += delta;
write(
`data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`,
);
},
onReasoningBlockEnd: () => {
if (!iterReasoning) return;
events.push({ type: "reasoning", text: iterReasoning });
write(`data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`);
iterReasoning = "";
},
// Fires after Claude's turn ends with stop_reason=tool_use, before
// the tool actually runs. Flushes any buffered assistant text so
// it's emitted in chronological order, then signals the client so
// it can open a fresh PreResponseWrapper (shows "Working…") while
// the tool executes — avoids the dead gap between message_stop
// and the first tool-specific event.
onToolCallStart: (call) => {
flushText();
write(
`data: ${JSON.stringify({
type: "tool_call_start",
name: call.name,
})}\n\n`,
);
},
},
runTools: async (calls) => {
throwIfAborted(signal);
// Emit any text the model produced before this tool turn so the
// UI sees it before the tool results stream in.
flushText();
const toolCalls: ToolCall[] = calls.map((c) => ({
id: c.id,
function: {
name: c.name,
arguments: JSON.stringify(c.input),
},
}));
const {
toolResults,
docsRead,
docsFound,
docsCreated,
docsReplicated,
workflowsApplied,
docsEdited,
askInputsEvents,
courtlistenerEvents,
caseCitationEvents,
mcpEvents,
} = await runToolCalls(
toolCalls,
docStore,
userId,
db,
write,
workflowStore,
tabularStore,
docIndex,
turnEditState,
turnReadState,
projectId,
courtlistenerTurnState,
apiKeys,
);
throwIfAborted(signal);
for (const r of docsRead) {
events.push({
type: "doc_read",
filename: r.filename,
document_id: r.document_id,
});
}
for (const f of docsFound) {
events.push({
type: "doc_find",
filename: f.filename,
query: f.query,
total_matches: f.total_matches,
});
}
for (const dl of docsCreated) {
events.push({
type: "doc_created",
filename: dl.filename,
download_url: dl.download_url,
document_id: dl.document_id,
version_id: dl.version_id,
version_number: dl.version_number ?? null,
});
}
for (const r of docsReplicated) {
events.push({
type: "doc_replicated",
filename: r.filename,
count: r.count,
copies: r.copies,
});
}
for (const wf of workflowsApplied) {
events.push({
type: "workflow_applied",
workflow_id: wf.workflow_id,
title: wf.title,
});
}
for (const e of docsEdited) {
events.push({
type: "doc_edited",
filename: e.filename,
document_id: e.document_id,
version_id: e.version_id,
version_number: e.version_number,
download_url: e.download_url,
annotations: e.annotations,
});
}
for (const askInputsEvent of askInputsEvents) {
write(`data: ${JSON.stringify(askInputsEvent)}\n\n`);
events.push(askInputsEvent);
}
for (const event of courtlistenerEvents) {
events.push(event);
}
for (const event of mcpEvents) {
events.push(event);
}
for (const event of caseCitationEvents) {
events.push(event);
}
if (askInputsEvents.length > 0) {
throw new AssistantStreamAskInputsPause();
}
// Index alignment would break if any tool branch skips its
// push (unhandled tool name, disabled store, guard failure).
// Each tool_result already carries its tool_call_id, so key off
// that directly — and fall back to an error result for any
// tool_use that didn't produce one, so Claude's next request
// has a tool_result for every tool_use it sent.
const resultByCallId = new Map<string, string>();
for (const r of toolResults) {
const row = r as { tool_call_id: string; content?: unknown };
resultByCallId.set(row.tool_call_id, String(row.content ?? ""));
}
return toolCalls.map((c) => ({
tool_use_id: c.id,
content:
resultByCallId.get(c.id) ??
JSON.stringify({
error: `Tool '${c.function.name}' is not available.`,
}),
}));
},
});
} catch (err) {
if (err instanceof AssistantStreamAskInputsPause) {
// The ask_inputs event has already been emitted and persisted in `events`.
// Stop this assistant turn here so the model does not add redundant
// prose telling the user to answer the picker or attach documents.
} else if (isAbortError(err)) {
flushPartialTurn({ emit: false });
throw new AssistantStreamAbortError(fullText, events);
} else {
flushPartialTurn();
const message = safeErrorMessage(err, "Stream error");
events.push({ type: "error", message });
throw new AssistantStreamError(message, fullText, events);
}
}
flushText();
// Parse and emit citations from <CITATIONS> block
const { citations: parsedCitations, diagnostics: citationDiagnostics } =
parseCitationsWithDiagnostics(fullText);
const citations = buildCitations
? buildCitations(fullText)
: parsedCitations.map((c) =>
createCitation(
c,
docIndex,
courtlistenerTurnState.casesByClusterId,
),
);
devLog("[chat/stream] final citations", {
hasCitationsBlock: citationDiagnostics.hasBlock,
citationsBlockLength: citationDiagnostics.rawLength,
parseError: citationDiagnostics.error,
parsedCitationCount: parsedCitations.length,
emittedCitationCount: citations.length,
usedCustomCitationBuilder: !!buildCitations,
});
write(
`data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
);
write("data: [DONE]\n\n");
return { fullText, events, citations };
}

View file

@ -0,0 +1,197 @@
export type CourtlistenerToolEvent =
| {
type: "courtlistener_search_case_law";
query: string;
result_count: number;
error?: string;
}
| {
type: "courtlistener_get_cases";
cluster_ids: number[];
case_count: number;
opinion_count: number;
cases?: {
cluster_id: number;
case_name: string | null;
citation: string | null;
dateFiled?: string | null;
url?: string | null;
}[];
error?: string;
}
| {
type: "courtlistener_find_in_case";
cluster_id: number | null;
query: string;
total_matches: number;
case_name?: string | null;
citation?: string | null;
searches?: {
cluster_id: number | null;
query: string;
total_matches: number;
case_name?: string | null;
citation?: string | null;
error?: string;
}[];
error?: string;
}
| {
type: "courtlistener_read_case";
cluster_id: number | null;
case_name?: string | null;
citation?: string | null;
opinion_count: number;
error?: string;
}
| {
type: "courtlistener_verify_citations";
citation_count: number;
match_count: number;
error?: string;
};
export type CaseCitationEvent = {
type: "case_citation";
cluster_id: number | null;
case_name: string | null;
citation: string | null;
url: string;
pdfUrl?: string | null;
dateFiled?: string | null;
};
export const COURTLISTENER_TOOL_NAMES = {
searchCaseLaw: "courtlistener_search_case_law",
getCases: "courtlistener_get_cases",
findInCase: "courtlistener_find_in_case",
readCase: "courtlistener_read_case",
verifyCitations: "courtlistener_verify_citations",
} as const;
export const COURTLISTENER_SYSTEM_PROMPT = `US CASE LAW RESEARCH:
Use CourtListener when answering US-law questions that require case law.
Workflow:
1. If you have reporter citations, verify them with courtlistener_verify_citations using only clean citations: {"citations":["467 U.S. 837","323 U.S. 134"]}. Never pass case names to this tool.
2. Fetch matched clusters with courtlistener_get_cases.
3. Get cite-worthy text from the fetched cases with courtlistener_find_in_case. Use short 1-3 word searches, maximum 3 searches per assistant turn.
4. If snippets are not enough, read only the necessary opinion(s) with courtlistener_read_case. For multi-opinion cases, choose the specific opinion_id/opinionIds needed; do not read all opinions by default.
Citation rules:
- Final case citations must be based on opinion text or passage snippets supplied in this turn. Do not cite cases based only on memory, metadata, search results, citationLinks, or verification results.
- If you mention a CourtListener case as legal support in the final answer, cite it with both: (a) the clickable markdown link returned in citationLinks, and (b) an inline [N] marker. Include the clickable case link only the first time you cite that case; later references to the same case should use the existing inline [N] marker without repeating the link unless clarity requires it.
- Assign new annotation refs in first-use order as much as possible: [1], then [2], then [3]. Reuse an existing ref when citing the same case/passage again, even if that means a later sentence cites [3] and then [1] again.
- The final <CITATIONS> block must include one matching case entry for each [N] case marker: {"ref": N, "cluster_id": 123, "quotes": [{"opinion_id": 456, "quote": "exact verbatim opinion text"}]}.
- Do not use doc_id, page, top-level quote, case_name, or citation fields in case entries.
- If you have not obtained opinion text or snippets for a useful case, fetch/read it before citing it, or say you could not read it and do not rely on it.
Limits:
- If any CourtListener call returns a rate-limit/throttling/429 error, stop all CourtListener calls for that turn and answer using only information already available.`;
export const COURTLISTENER_TOOLS = [
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.getCases,
description:
"Fetch and cache one or more CourtListener case clusters and their opinions by cluster ID. This returns metadata/counts only, not full opinion text. After this, call courtlistener_find_in_case for targeted passages or courtlistener_read_case if broader full-case context is needed.",
parameters: {
type: "object",
properties: {
clusterIds: {
type: "array",
items: { type: "integer" },
description:
"CourtListener cluster IDs from courtlistener_verify_citations or other case metadata already present in the conversation.",
},
},
required: ["clusterIds"],
},
},
},
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.findInCase,
description:
"Search within an already-fetched CourtListener case cluster for specific keyword(s) or phrases. Returns matches with surrounding opinion context. Call courtlistener_get_cases first; this tool does not fetch cases. Use no more than 3 calls to this tool in a single assistant turn.",
parameters: {
type: "object",
properties: {
clusterId: {
type: "integer",
description:
"CourtListener cluster ID previously fetched with courtlistener_get_cases.",
},
query: {
type: "string",
description:
"Short term to search for, 1-3 words long and likely to appear exactly as written in the opinion text. Matching is case-insensitive and collapses whitespace.",
},
max_results: {
type: "integer",
description:
"Maximum number of matches to return. Default 20.",
},
context_chars: {
type: "integer",
description:
"Characters of surrounding context to include on each side of each match. Default 160.",
},
},
required: ["clusterId", "query"],
},
},
},
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.readCase,
description:
"Read selected opinion text from an already-fetched CourtListener case cluster in this turn's cache. Use after courtlistener_find_in_case if snippets are insufficient. If the case has multiple opinions, pass only the opinionId/opinionIds needed. Call courtlistener_get_cases first; this tool does not fetch cases.",
parameters: {
type: "object",
properties: {
clusterId: {
type: "integer",
description:
"CourtListener cluster ID previously fetched with courtlistener_get_cases.",
},
opinionId: {
type: "integer",
description:
"Specific opinion ID to read. Use when one opinion is enough.",
},
opinionIds: {
type: "array",
items: { type: "integer" },
description:
"Specific opinion IDs to read. Use the smallest set needed; do not read all opinions unless the question requires it.",
},
},
required: ["clusterId"],
},
},
},
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.verifyCitations,
description:
"Verify legal case citations using CourtListener's citation lookup. Accepts only an array of clean reporter citations, not case names. Example: {\"citations\":[\"467 U.S. 837\",\"323 U.S. 134\"]}. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.",
parameters: {
type: "object",
properties: {
citations: {
type: "array",
items: { type: "string" },
description:
"Required list of clean reporter citations only. Put each reporter citation in its own array item, e.g. [\"467 U.S. 837\", \"323 U.S. 134\"]. Do not include case names. Up to 250 items.",
},
},
required: ["citations"],
},
},
},
];

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,471 @@
export const PROJECT_EXTRA_TOOLS = [
{
type: "function",
function: {
name: "list_documents",
description:
"List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "fetch_documents",
description:
"Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once. In one response, fetch each document/version at most once; after it has been fetched, use the prior tool result or find_in_document for targeted checks.",
parameters: {
type: "object",
properties: {
doc_ids: {
type: "array",
items: { type: "string" },
description:
"Array of document IDs to read (e.g. ['doc-0', 'doc-2'])",
},
},
required: ["doc_ids"],
},
},
},
{
type: "function",
function: {
name: "replicate_document",
description:
"Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "ID of the source document to copy (e.g. 'doc-0').",
},
count: {
type: "integer",
description:
"How many copies to create. Defaults to 1. Maximum 20.",
minimum: 1,
maximum: 20,
},
new_filename: {
type: "string",
description:
"Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.",
},
},
required: ["doc_id"],
},
},
},
];
export const TABULAR_TOOLS = [
{
type: "function",
function: {
name: "read_table_cells",
description:
"Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.",
parameters: {
type: "object",
properties: {
col_indices: {
type: "array",
items: { type: "integer" },
description:
"0-based column indices to read (e.g. [0, 2]). Omit to read all columns.",
},
row_indices: {
type: "array",
items: { type: "integer" },
description:
"0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.",
},
},
},
},
},
];
export const WORKFLOW_TOOLS = [
{
type: "function",
function: {
name: "list_workflows",
description:
"List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "read_workflow",
description:
"Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.",
parameters: {
type: "object",
properties: {
workflow_id: {
type: "string",
description: "The workflow ID to read",
},
},
required: ["workflow_id"],
},
},
},
];
export const TOOLS = [
{
type: "function",
function: {
name: "ask_inputs",
description:
"Ask the user for one or more decisions, clarifications, or document uploads before continuing. Use this when guessing would materially affect the answer or when required documents have not been attached. Put all needed questions and document requests in one items array. After calling ask_inputs, do not continue the substantive task until the user responds in a later message.",
parameters: {
type: "object",
properties: {
items: {
type: "array",
minItems: 1,
maxItems: 12,
description:
"The list of user inputs needed before continuing. Use choice items for decisions/clarifications and documents items for required uploads.",
items: {
type: "object",
properties: {
id: {
type: "string",
description:
"Stable short ID for this input, unique within this tool call.",
},
kind: {
type: "string",
enum: ["choice", "documents"],
},
question: {
type: "string",
description:
"For choice items only: the concise question to show to the user.",
},
options: {
type: "array",
description:
"For choice items only: selectable choices to show. Each choice has a single user-facing value, which is also sent back if selected.",
minItems: 1,
maxItems: 8,
items: {
type: "object",
properties: {
value: {
type: "string",
description: "The user-facing choice text.",
},
},
required: ["value"],
},
},
allow_other: {
type: "boolean",
description:
"For choice items only: whether to show an Other option with a text field. Defaults to true.",
},
other_label: {
type: "string",
description:
"For choice items only: label for the free-text option. Defaults to Other.",
},
document_types: {
type: "array",
description:
"For documents items only: readable labels for the types of documents you need the user to attach.",
minItems: 1,
maxItems: 8,
items: {
type: "string",
},
},
response_prefix: {
type: "string",
description:
"Optional prefix the UI should include when sending this response back as the next message.",
},
},
required: ["id", "kind"],
},
},
},
required: ["items"],
},
},
},
{
type: "function",
function: {
name: "read_document",
description:
"Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, citing from, or editing a document, but call it at most once per document/version in a single response. After this returns, use the prior tool result or find_in_document for targeted checks instead of reading the same document/version again.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "The document ID to read (e.g. 'doc-0', 'doc-1')",
},
},
required: ["doc_id"],
},
},
},
{
type: "function",
function: {
name: "find_in_document",
description:
"Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "The document ID to search (e.g. 'doc-0').",
},
query: {
type: "string",
description:
"The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.",
},
max_results: {
type: "integer",
description:
"Maximum number of matches to return (default 20). Use a smaller value for common terms.",
},
context_chars: {
type: "integer",
description:
"Characters of surrounding context to include on each side of a match (default 80).",
},
},
required: ["doc_id", "query"],
},
},
},
{
type: "function",
function: {
name: "generate_docx",
description:
"Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Document title (used as filename and heading)",
},
landscape: {
type: "boolean",
description:
"Set to true for landscape page orientation. Default is portrait.",
},
sections: {
type: "array",
description:
"List of document sections. Each section may contain a heading, prose content, or a table.",
items: {
type: "object",
properties: {
heading: {
type: "string",
description: "Optional section heading",
},
level: {
type: "integer",
description: "Heading level: 1, 2, or 3",
},
content: {
type: "string",
description:
"Prose text content (paragraphs separated by double newlines)",
},
pageBreak: {
type: "boolean",
description:
"Set to true to start this section on a new page. Use for contract signature pages.",
},
table: {
type: "object",
description: "Optional table to render in this section",
properties: {
headers: {
type: "array",
items: { type: "string" },
description: "Column header labels",
},
rows: {
type: "array",
items: {
type: "array",
items: { type: "string" },
},
description:
"Array of rows, each row is an array of cell strings matching the headers order",
},
},
required: ["headers", "rows"],
},
},
},
},
},
required: ["title", "sections"],
},
},
},
{
type: "function",
function: {
name: "generate_excel",
description:
"Generate an Excel (.xlsx) workbook from structured sheet data. Use this when the user asks for a spreadsheet, tracker, matrix, checklist, schedule, or Excel file. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Workbook title, used as the filename.",
},
sheets: {
type: "array",
description:
"Workbook sheets. Each sheet has a name, columns, and rows. Row values should follow the columns order.",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Sheet tab name. Keep it short.",
},
columns: {
type: "array",
items: { type: "string" },
description: "Column header labels.",
},
rows: {
type: "array",
items: {
type: "array",
items: { type: "string" },
},
description:
"Array of rows, each row an array of cell strings matching the columns order.",
},
},
required: ["name", "columns", "rows"],
},
},
},
required: ["title", "sheets"],
},
},
},
{
type: "function",
function: {
name: "generate_ppt",
description:
"Generate a PowerPoint (.pptx) presentation from structured slides. Use this when the user asks for slides, a deck, presentation, or PowerPoint file. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Presentation title, used as the filename.",
},
slides: {
type: "array",
description:
"Slides in order. Each slide may have a title, bullets, and optional speaker notes.",
items: {
type: "object",
properties: {
title: {
type: "string",
description: "Slide title.",
},
bullets: {
type: "array",
items: { type: "string" },
description:
"Main bullet points for the slide. Keep each bullet concise.",
},
notes: {
type: "string",
description:
"Optional speaker notes. Included as text on a notes slide placeholder is not supported; use only for generation context.",
},
},
required: ["title", "bullets"],
},
},
},
required: ["title", "slides"],
},
},
},
{
type: "function",
function: {
name: "edit_document",
description:
"Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first unless this same document/version has already been read in the current response. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "Document slug (e.g. 'doc-0').",
},
edits: {
type: "array",
description: "List of precise substitutions.",
items: {
type: "object",
properties: {
find: {
type: "string",
description:
"Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).",
},
replace: {
type: "string",
description:
"Replacement text. Empty string = pure deletion.",
},
context_before: {
type: "string",
description:
"~40 chars immediately preceding `find`, used to disambiguate.",
},
context_after: {
type: "string",
description: "~40 chars immediately following `find`.",
},
reason: {
type: "string",
description:
"Short explanation shown to the user on the card.",
},
},
required: ["find", "replace", "context_before", "context_after"],
},
},
},
required: ["doc_id", "edits"],
},
},
},
];

View file

@ -0,0 +1,153 @@
import path from "path";
export const STANDARD_FONT_DATA_URL = (() => {
try {
const pkgPath = require.resolve("pdfjs-dist/package.json");
return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep;
} catch {
return undefined;
}
})();
const isDev = process.env.NODE_ENV !== "production";
export const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
// ---------------------------------------------------------------------------
// Core types
// ---------------------------------------------------------------------------
export type DocStore = Map<
string,
{ storage_path: string; file_type: string; filename: string }
>;
export type WorkflowStore = Map<string, { title: string; skill_md: string }>;
export type DocIndex = Record<
string,
{
document_id: string;
filename: string;
version_id?: string | null;
version_number?: number | null;
}
>;
export type TabularCellStore = {
columns: { index: number; name: string }[];
documents: { id: string; filename: string }[];
/** key: `${colIndex}:${docId}` */
cells: Map<
string,
{ summary: string; flag?: string; reasoning?: string } | null
>;
};
export type ToolCall = {
id: string;
function: { name: string; arguments: string };
};
export type ChatMessage = {
role: string;
content: string | null;
files?: { filename: string; document_id?: string }[];
workflow?: { id: string; title: string };
};
// ---------------------------------------------------------------------------
// Doc resolution helpers (used by citations + documentOps)
// ---------------------------------------------------------------------------
export function resolveDoc(rawId: string, docIndex: DocIndex) {
return docIndex[rawId];
}
/**
* Resolve whatever identifier the model passed (`doc-N` slug, filename, or
* document UUID) back to a chat-local doc label.
*/
export function resolveDocLabel(
rawId: string,
docStore: DocStore,
docIndex?: DocIndex,
): string | null {
if (docStore.has(rawId)) return rawId;
for (const [label, info] of docStore.entries()) {
if (info.filename === rawId) return label;
}
if (docIndex) {
for (const [label, info] of Object.entries(docIndex)) {
if (info.document_id === rawId) return label;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Event / annotation types (shared between toolDispatcher and streaming)
// ---------------------------------------------------------------------------
export type AskInputOption = {
value: string;
};
export type AskInputItem =
| {
id: string;
kind: "choice";
question: string;
options: AskInputOption[];
allow_other: boolean;
other_label: string;
response_prefix?: string;
}
| {
id: string;
kind: "documents";
document_types: string[];
response_prefix?: string;
};
export type AskInputsEvent = {
type: "ask_inputs";
items: AskInputItem[];
};
export type AskInputResponseItem =
| {
id: string;
kind: "choice";
question: string;
answer?: string;
skipped?: boolean;
}
| {
id: string;
kind: "documents";
filenames: string[];
skipped?: boolean;
};
export type AskInputsResponseRequest = {
responses: AskInputResponseItem[];
};
export type EditAnnotation = {
kind: "edit";
edit_id: string;
document_id: string;
version_id: string;
version_number?: number | null;
change_id: string;
del_w_id?: string;
ins_w_id?: string;
deleted_text: string;
inserted_text: string;
context_before: string;
context_after: string;
reason?: string;
status: "pending" | "accepted" | "rejected";
};

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,81 @@
import JSZip from "jszip"; import JSZip from "jszip";
import fs from "node:fs";
import path from "node:path";
let _convert: let _convert:
| ((buf: Buffer, ext: string, filter: undefined) => Promise<Buffer>) | ((buf: Buffer, ext: string, filter: undefined) => Promise<Buffer>)
| null = null; | null = null;
let _sofficeBinaryPaths: string[] | null = null;
function executablePath(filePath: string) {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function resolveSofficeBinaryPaths(): string[] {
if (_sofficeBinaryPaths) return _sofficeBinaryPaths;
const candidates = new Set<string>();
for (const envName of [
"SOFFICE_BINARY_PATH",
"LIBREOFFICE_BINARY_PATH",
"LIBRE_OFFICE_EXE",
]) {
const value = process.env[envName]?.trim();
if (value) candidates.add(value);
}
const pathDirs = (process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean);
for (const dir of pathDirs) {
candidates.add(path.join(dir, "soffice"));
candidates.add(path.join(dir, "libreoffice"));
}
for (const filePath of [
"/usr/bin/libreoffice",
"/usr/bin/soffice",
"/snap/bin/libreoffice",
"/opt/libreoffice/program/soffice",
"/opt/libreoffice7.6/program/soffice",
]) {
candidates.add(filePath);
}
_sofficeBinaryPaths = [...candidates].filter(executablePath);
return _sofficeBinaryPaths;
}
async function getConvert() { async function getConvert() {
if (!_convert) { if (!_convert) {
const libre = await import("libreoffice-convert"); const libre = await import("libreoffice-convert");
const convert = libre.default.convert.bind(libre.default) as ( const convertWithOptions = libre.default.convertWithOptions.bind(
libre.default,
) as (
buf: Buffer, buf: Buffer,
ext: string, ext: string,
filter: undefined, filter: undefined,
options: { sofficeBinaryPaths?: string[] },
callback?: (err: Error | null, result: Buffer) => void, callback?: (err: Error | null, result: Buffer) => void,
) => Promise<Buffer> | void; ) => Promise<Buffer> | void;
_convert = (buf, ext, filter) => _convert = (buf, ext, filter) =>
new Promise<Buffer>((resolve, reject) => { new Promise<Buffer>((resolve, reject) => {
try { try {
const maybePromise = convert(buf, ext, filter, (err, result) => { const maybePromise = convertWithOptions(
buf,
ext,
filter,
{ sofficeBinaryPaths: resolveSofficeBinaryPaths() },
(err, result) => {
if (err) reject(err); if (err) reject(err);
else resolve(result); else resolve(result);
}); },
);
if (maybePromise && typeof maybePromise.then === "function") { if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.then(resolve, reject); maybePromise.then(resolve, reject);
} }
@ -67,6 +123,11 @@ export async function normalizeDocxZipPaths(buffer: Buffer): Promise<Buffer> {
* Throws if LibreOffice is not installed or conversion fails. * Throws if LibreOffice is not installed or conversion fails.
*/ */
export async function docxToPdf(buffer: Buffer): Promise<Buffer> { export async function docxToPdf(buffer: Buffer): Promise<Buffer> {
if (resolveSofficeBinaryPaths().length === 0) {
throw new Error(
"LibreOffice/soffice binary was not found. Ensure Railway uses backend/nixpacks.toml or set SOFFICE_BINARY_PATH/LIBREOFFICE_BINARY_PATH.",
);
}
const convert = await getConvert(); const convert = await getConvert();
const normalized = await normalizeDocxZipPaths(buffer); const normalized = await normalizeDocxZipPaths(buffer);
return convert(normalized, ".pdf", undefined); return convert(normalized, ".pdf", undefined);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
export const ALLOWED_DOCUMENT_TYPES = new Set([
"pdf",
"docx",
"doc",
"xlsx",
"xlsm",
"xls",
"pptx",
"ppt",
]);
export const ALLOWED_DOCUMENT_TYPES_LABEL =
"pdf, docx, doc, xlsx, xlsm, xls, pptx, ppt";
const WORD_TYPES = new Set(["docx", "doc"]);
const SPREADSHEET_TYPES = new Set(["xlsx", "xlsm", "xls"]);
const PRESENTATION_TYPES = new Set(["pptx", "ppt"]);
export function isWordDocumentType(fileType: string | null | undefined) {
return WORD_TYPES.has((fileType ?? "").toLowerCase());
}
export function isSpreadsheetDocumentType(fileType: string | null | undefined) {
return SPREADSHEET_TYPES.has((fileType ?? "").toLowerCase());
}
export function isPresentationDocumentType(fileType: string | null | undefined) {
return PRESENTATION_TYPES.has((fileType ?? "").toLowerCase());
}
export function shouldConvertToPdf(fileType: string | null | undefined) {
const normalized = (fileType ?? "").toLowerCase();
// Spreadsheets are intentionally excluded: they are rendered natively as a
// grid in the frontend (Fortune-sheet) from the raw file bytes rather than a
// PDF rendition, which clipped wide/large sheets.
return (
isWordDocumentType(normalized) || isPresentationDocumentType(normalized)
);
}
export function contentTypeForDocumentType(fileType: string | null | undefined) {
switch ((fileType ?? "").toLowerCase()) {
case "pdf":
return "application/pdf";
case "docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case "xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case "xlsm":
return "application/vnd.ms-excel.sheet.macroEnabled.12";
case "xls":
return "application/vnd.ms-excel";
case "pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
case "ppt":
return "application/vnd.ms-powerpoint";
default:
return "application/octet-stream";
}
}

View file

@ -9,6 +9,8 @@ interface DocRow {
} }
interface VersionPathRow extends DocRow { interface VersionPathRow extends DocRow {
/** API/client alias for document_versions.filename of the active version. */
filename?: string | null;
/** Set from document_versions.storage_path of the active version. */ /** Set from document_versions.storage_path of the active version. */
storage_path?: string | null; storage_path?: string | null;
/** Set from document_versions.pdf_storage_path of the active version. */ /** Set from document_versions.pdf_storage_path of the active version. */
@ -16,6 +18,10 @@ interface VersionPathRow extends DocRow {
current_version_id?: string | null; current_version_id?: string | null;
/** Set from document_versions.version_number of the active version. */ /** Set from document_versions.version_number of the active version. */
active_version_number?: number | null; active_version_number?: number | null;
/** Active-version file metadata. */
file_type?: string | null;
size_bytes?: number | null;
page_count?: number | null;
} }
export interface ActiveVersion { export interface ActiveVersion {
@ -23,8 +29,11 @@ export interface ActiveVersion {
storage_path: string; storage_path: string;
pdf_storage_path: string | null; pdf_storage_path: string | null;
version_number: number | null; version_number: number | null;
display_name: string | null; filename: string | null;
source: string | null; source: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
} }
/** /**
@ -54,9 +63,10 @@ export async function loadActiveVersion(
const { data: v } = await db const { data: v } = await db
.from("document_versions") .from("document_versions")
.select( .select(
"id, document_id, storage_path, pdf_storage_path, version_number, display_name, source", "id, document_id, storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
) )
.eq("id", targetVersionId) .eq("id", targetVersionId)
.is("deleted_at", null)
.single(); .single();
if (!v || v.document_id !== documentId || !v.storage_path) return null; if (!v || v.document_id !== documentId || !v.storage_path) return null;
return { return {
@ -64,8 +74,11 @@ export async function loadActiveVersion(
storage_path: v.storage_path as string, storage_path: v.storage_path as string,
pdf_storage_path: (v.pdf_storage_path as string | null) ?? null, pdf_storage_path: (v.pdf_storage_path as string | null) ?? null,
version_number: (v.version_number as number | null) ?? null, version_number: (v.version_number as number | null) ?? null,
display_name: (v.display_name as string | null) ?? null, filename: (v.filename as string | null) ?? null,
source: (v.source as string | null) ?? null, source: (v.source as string | null) ?? null,
file_type: (v.file_type as string | null) ?? null,
size_bytes: (v.size_bytes as number | null) ?? null,
page_count: (v.page_count as number | null) ?? null,
}; };
} }
@ -85,21 +98,32 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
.filter((id): id is string => typeof id === "string"); .filter((id): id is string => typeof id === "string");
if (versionIds.length === 0) { if (versionIds.length === 0) {
for (const d of docs) { for (const d of docs) {
d.filename = "Untitled document";
d.storage_path = null; d.storage_path = null;
d.pdf_storage_path = null; d.pdf_storage_path = null;
d.file_type = null;
d.size_bytes = null;
d.page_count = null;
} }
return docs; return docs;
} }
const { data: rows } = await db const { data: rows } = await db
.from("document_versions") .from("document_versions")
.select("id, storage_path, pdf_storage_path, version_number") .select(
.in("id", versionIds); "id, storage_path, pdf_storage_path, version_number, filename, file_type, size_bytes, page_count",
)
.in("id", versionIds)
.is("deleted_at", null);
const byId = new Map< const byId = new Map<
string, string,
{ {
storage_path: string | null; storage_path: string | null;
pdf_storage_path: string | null; pdf_storage_path: string | null;
version_number: number | null; version_number: number | null;
filename: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
} }
>(); >();
for (const r of (rows ?? []) as { for (const r of (rows ?? []) as {
@ -107,11 +131,19 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
storage_path: string | null; storage_path: string | null;
pdf_storage_path: string | null; pdf_storage_path: string | null;
version_number: number | null; version_number: number | null;
filename: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
}[]) { }[]) {
byId.set(r.id, { byId.set(r.id, {
storage_path: r.storage_path ?? null, storage_path: r.storage_path ?? null,
pdf_storage_path: r.pdf_storage_path ?? null, pdf_storage_path: r.pdf_storage_path ?? null,
version_number: r.version_number ?? null, version_number: r.version_number ?? null,
filename: r.filename ?? null,
file_type: r.file_type ?? null,
size_bytes: r.size_bytes ?? null,
page_count: r.page_count ?? null,
}); });
} }
for (const d of docs) { for (const d of docs) {
@ -119,6 +151,10 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
d.storage_path = v?.storage_path ?? null; d.storage_path = v?.storage_path ?? null;
d.pdf_storage_path = v?.pdf_storage_path ?? null; d.pdf_storage_path = v?.pdf_storage_path ?? null;
d.active_version_number = v?.version_number ?? null; d.active_version_number = v?.version_number ?? null;
d.filename = v?.filename?.trim() || "Untitled document";
d.file_type = v?.file_type ?? null;
d.size_bytes = v?.size_bytes ?? null;
d.page_count = v?.page_count ?? null;
} }
return docs; return docs;
} }
@ -140,6 +176,7 @@ export async function attachLatestVersionNumbers<T extends DocRow>(
.select("document_id, version_number") .select("document_id, version_number")
.in("document_id", ids) .in("document_id", ids)
.eq("source", "assistant_edit") .eq("source", "assistant_edit")
.is("deleted_at", null)
.not("version_number", "is", null); .not("version_number", "is", null);
const latestByDoc = new Map<string, number>(); const latestByDoc = new Map<string, number>();

View file

@ -7,6 +7,7 @@ import type {
NormalizedToolResult, NormalizedToolResult,
} from "./types"; } from "./types";
import { toClaudeTools } from "./tools"; import { toClaudeTools } from "./tools";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
type ContentBlock = type ContentBlock =
| { type: "text"; text: string } | { type: "text"; text: string }
@ -41,6 +42,65 @@ function toNativeMessages(
return messages.map((m) => ({ role: m.role, content: m.content })); return messages.map((m) => ({ role: m.role, content: m.content }));
} }
function claudeErrorMessage(error: unknown): string {
const parsedObject = claudeStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (error instanceof Error && error.message) {
const parsed = parseClaudeErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Claude error:")
? error.message
: `Claude error: ${error.message}`;
}
const parsed = parseClaudeErrorPayload(String(error));
if (parsed) return parsed;
return `Claude error: ${String(error)}`;
}
function parseClaudeErrorPayload(value: string): string | null {
const trimmed = value.trim();
const jsonStart = trimmed.indexOf("{");
if (jsonStart < 0) return null;
const jsonEnd = trimmed.lastIndexOf("}");
if (jsonEnd <= jsonStart) return null;
const payload = trimmed.slice(jsonStart, jsonEnd + 1);
try {
const parsed = JSON.parse(payload) as unknown;
return claudeStreamFailureMessage(parsed);
} catch {
return null;
}
}
function claudeStreamFailureMessage(event: unknown): string | null {
if (!event || typeof event !== "object") return null;
const record = event as Record<string, unknown>;
const error = record.error;
if (record.type !== "error" || !error || typeof error !== "object") {
return null;
}
const err = error as Record<string, unknown>;
const type =
typeof err.type === "string" && err.type.trim() ? err.type.trim() : null;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Claude stream failed.";
return type
? `Claude error (${type}): ${message}`
: `Claude error: ${message}`;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
export async function streamClaude( export async function streamClaude(
params: StreamChatParams, params: StreamChatParams,
): Promise<StreamChatResult> { ): Promise<StreamChatResult> {
@ -59,8 +119,14 @@ export async function streamClaude(
const messages: NativeMessage[] = toNativeMessages(params.messages); const messages: NativeMessage[] = toNativeMessages(params.messages);
let fullText = ""; let fullText = "";
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "claude",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) { for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const stream = anthropic.messages.stream({ const stream = anthropic.messages.stream({
model, model,
system: systemPrompt, system: systemPrompt,
@ -82,6 +148,45 @@ export async function streamClaude(
}); });
let sawThinking = false; let sawThinking = false;
let streamFailureMessage: string | null = null;
const abortStream = () => stream.abort();
params.abortSignal?.addEventListener("abort", abortStream, {
once: true,
});
stream.on("streamEvent", (event) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "streamEvent",
payload: event,
});
rawStreamRecorder?.record({
iteration: iter,
label: "streamEvent",
payload: event,
});
const failureMessage = claudeStreamFailureMessage(event);
if (failureMessage) {
streamFailureMessage = failureMessage;
stream.abort();
}
});
stream.on("error", (error) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "error",
payload: error,
});
rawStreamRecorder?.record({
iteration: iter,
label: "error",
payload: error,
});
});
stream.on("text", (delta) => { stream.on("text", (delta) => {
callbacks.onContentDelta?.(delta); callbacks.onContentDelta?.(delta);
@ -93,8 +198,18 @@ export async function streamClaude(
}); });
} }
const final = await stream.finalMessage(); let final: Awaited<ReturnType<typeof stream.finalMessage>>;
try {
final = await stream.finalMessage();
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
if (streamFailureMessage) throw new Error(streamFailureMessage);
throw new Error(claudeErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", abortStream);
}
if (sawThinking) callbacks.onReasoningBlockEnd?.(); if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
const stopReason = final.stop_reason; const stopReason = final.stop_reason;
const assistantBlocks = final.content as ContentBlock[]; const assistantBlocks = final.content as ContentBlock[];
@ -126,6 +241,7 @@ export async function streamClaude(
} }
const results = await runTools(toolCalls); const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Record the assistant turn (preserving the original content blocks, // Record the assistant turn (preserving the original content blocks,
// which Claude requires on the follow-up) and the user turn that // which Claude requires on the follow-up) and the user turn that
@ -141,7 +257,12 @@ export async function streamClaude(
}); });
} }
await rawStreamRecorder?.flush("completed");
return { fullText }; return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
} }
export async function completeClaudeText(params: { export async function completeClaudeText(params: {
@ -152,12 +273,17 @@ export async function completeClaudeText(params: {
apiKeys?: { claude?: string | null }; apiKeys?: { claude?: string | null };
}): Promise<string> { }): Promise<string> {
const anthropic = client(params.apiKeys?.claude); const anthropic = client(params.apiKeys?.claude);
const resp = await anthropic.messages.create({ let resp: Awaited<ReturnType<typeof anthropic.messages.create>>;
try {
resp = await anthropic.messages.create({
model: params.model, model: params.model,
max_tokens: params.maxTokens ?? 512, max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt, system: params.systemPrompt,
messages: [{ role: "user", content: params.user }], messages: [{ role: "user", content: params.user }],
}); });
} catch (error) {
throw new Error(claudeErrorMessage(error));
}
const text = resp.content const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text") .filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text) .map((b) => b.text)

View file

@ -5,6 +5,7 @@ import type {
NormalizedToolCall, NormalizedToolCall,
} from "./types"; } from "./types";
import { toGeminiTools } from "./tools"; import { toGeminiTools } from "./tools";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
type GeminiPart = { type GeminiPart = {
text?: string; text?: string;
@ -42,26 +43,149 @@ function client(override?: string | null): GoogleGenAI {
return new GoogleGenAI({ apiKey: apiKey(override) }); return new GoogleGenAI({ apiKey: apiKey(override) });
} }
function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent[] { function toNativeContents(
messages: StreamChatParams["messages"],
): GeminiContent[] {
return messages.map((m) => ({ return messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user", role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }], parts: [{ text: m.content }],
})); }));
} }
function geminiErrorMessage(error: unknown): string {
const parsedObject = geminiStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (typeof error === "string") {
const parsed = parseGeminiErrorPayload(error);
if (parsed) return parsed;
return error.startsWith("Gemini error:") ? error : `Gemini error: ${error}`;
}
if (error instanceof Error && error.message) {
const parsed = parseGeminiErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Gemini error:")
? error.message
: `Gemini error: ${error.message}`;
}
return `Gemini error: ${String(error)}`;
}
function parseGeminiErrorPayload(value: string): string | null {
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
return geminiStreamFailureMessage(parsed);
} catch {
return null;
}
}
function geminiStreamFailureMessage(chunk: unknown): string | null {
if (!chunk || typeof chunk !== "object") return null;
const record = chunk as Record<string, unknown>;
const error = record.error;
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
const nested =
typeof err.message === "string"
? parseGeminiErrorPayload(err.message)
: null;
if (nested) return nested;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Gemini stream failed.";
const code =
typeof err.code === "string" && err.code.trim()
? err.code.trim()
: typeof err.code === "number" && Number.isFinite(err.code)
? String(err.code)
: typeof err.status === "string" && err.status.trim()
? err.status.trim()
: null;
return code
? `Gemini error (${code}): ${message}`
: `Gemini error: ${message}`;
}
const promptFeedback = record.promptFeedback;
if (promptFeedback && typeof promptFeedback === "object") {
const feedback = promptFeedback as Record<string, unknown>;
const blockReason =
typeof feedback.blockReason === "string" ? feedback.blockReason : null;
if (blockReason) {
const detail =
typeof feedback.blockReasonMessage === "string" &&
feedback.blockReasonMessage.trim()
? feedback.blockReasonMessage.trim()
: "The Gemini response was blocked.";
return `Gemini error (${blockReason}): ${detail}`;
}
}
const candidates = Array.isArray(record.candidates)
? (record.candidates as Record<string, unknown>[])
: [];
const finishReason =
typeof candidates[0]?.finishReason === "string"
? candidates[0].finishReason
: null;
const errorFinishReasons = new Set([
"SAFETY",
"RECITATION",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"MALFORMED_FUNCTION_CALL",
"OTHER",
]);
if (finishReason && errorFinishReasons.has(finishReason)) {
return `Gemini error (${finishReason}): The Gemini stream ended with an error finish reason.`;
}
return null;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
export async function streamGemini( export async function streamGemini(
params: StreamChatParams, params: StreamChatParams,
): Promise<StreamChatResult> { ): Promise<StreamChatResult> {
const { model, systemPrompt, tools = [], callbacks = {}, runTools, apiKeys, enableThinking } = params; const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10; const maxIter = params.maxIterations ?? 10;
const ai = client(apiKeys?.gemini); const ai = client(apiKeys?.gemini);
const functionDeclarations = toGeminiTools(tools); const functionDeclarations = toGeminiTools(tools);
const contents: GeminiContent[] = toNativeContents(params.messages); const contents: GeminiContent[] = toNativeContents(params.messages);
let fullText = ""; let fullText = "";
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "gemini",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) { for (let iter = 0; iter < maxIter; iter++) {
const stream = await ai.models.generateContentStream({ throwIfAborted(params.abortSignal);
let stream: AsyncIterable<unknown>;
try {
stream = await ai.models.generateContentStream({
model, model,
contents: contents as never, contents: contents as never,
config: { config: {
@ -78,14 +202,48 @@ export async function streamGemini(
: { thinkingBudget: 0 }, : { thinkingBudget: 0 },
}, },
}); });
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
// Per-iteration accumulators. // Per-iteration accumulators.
const textParts: string[] = []; const textParts: string[] = [];
const callParts: GeminiPart[] = []; const callParts: GeminiPart[] = [];
const toolCalls: NormalizedToolCall[] = []; const toolCalls: NormalizedToolCall[] = [];
let sawThinking = false; let sawThinking = false;
const iterator = stream[Symbol.asyncIterator]();
let rejectAbort: ((reason?: unknown) => void) | null = null;
const abortPromise = new Promise<never>((_, reject) => {
rejectAbort = reject;
});
const onAbort = () => rejectAbort?.(abortError());
params.abortSignal?.addEventListener("abort", onAbort, {
once: true,
});
try {
while (true) {
throwIfAborted(params.abortSignal);
const { value: chunk, done } = await Promise.race([
iterator.next(),
abortPromise,
]);
if (done) break;
logRawLlmStream({
provider: "gemini",
model,
iteration: iter,
label: "chunk",
payload: chunk,
});
rawStreamRecorder?.record({
iteration: iter,
label: "chunk",
payload: chunk,
});
const failureMessage = geminiStreamFailureMessage(chunk);
if (failureMessage) throw new Error(failureMessage);
for await (const chunk of stream) {
const parts = const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] }) (chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? []; .candidates?.[0]?.content?.parts ?? [];
@ -105,7 +263,9 @@ export async function streamGemini(
// so it can be echoed verbatim in the replay turn. // so it can be echoed verbatim in the replay turn.
callParts.push(part); callParts.push(part);
const call: NormalizedToolCall = { const call: NormalizedToolCall = {
id: part.functionCall.id ?? `${part.functionCall.name}-${toolCalls.length}`, id:
part.functionCall.id ??
`${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name, name: part.functionCall.name,
input: part.functionCall.args ?? {}, input: part.functionCall.args ?? {},
}; };
@ -114,8 +274,18 @@ export async function streamGemini(
} }
} }
} }
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
throw new Error(geminiErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.abortSignal?.aborted) {
await iterator.return?.();
}
}
if (sawThinking) callbacks.onReasoningBlockEnd?.(); if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
fullText += textParts.join(""); fullText += textParts.join("");
@ -124,6 +294,7 @@ export async function streamGemini(
} }
const results = await runTools(toolCalls); const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Append the model's turn (text + functionCall parts, in that order) // Append the model's turn (text + functionCall parts, in that order)
// and the matching functionResponse turn. // and the matching functionResponse turn.
@ -149,7 +320,12 @@ export async function streamGemini(
}); });
} }
await rawStreamRecorder?.flush("completed");
return { fullText }; return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
} }
export async function completeGeminiText(params: { export async function completeGeminiText(params: {
@ -159,12 +335,17 @@ export async function completeGeminiText(params: {
apiKeys?: { gemini?: string | null }; apiKeys?: { gemini?: string | null };
}): Promise<string> { }): Promise<string> {
const ai = client(params.apiKeys?.gemini); const ai = client(params.apiKeys?.gemini);
const resp = await ai.models.generateContent({ let resp: Awaited<ReturnType<typeof ai.models.generateContent>>;
try {
resp = await ai.models.generateContent({
model: params.model, model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }], contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt config: params.systemPrompt
? { systemInstruction: params.systemPrompt } ? { systemInstruction: params.systemPrompt }
: undefined, : undefined,
}); });
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
return resp.text ?? ""; return resp.text ?? "";
} }

View file

@ -4,23 +4,29 @@ import type { Provider } from "./types";
// Canonical model IDs // Canonical model IDs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main-chat tier (top-end) — user picks one of these per message. // Main-chat tier (top-end) — user picks one of these per message.
export const CLAUDE_MAIN_MODELS = ["claude-opus-4-7", "claude-sonnet-4-6"] as const; export const CLAUDE_MAIN_MODELS = [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-sonnet-4-6",
] as const;
export const GEMINI_MAIN_MODELS = [ export const GEMINI_MAIN_MODELS = [
"gemini-3.5-flash",
"gemini-3.1-pro-preview", "gemini-3.1-pro-preview",
"gemini-3-flash-preview", "gemini-3-flash-preview",
] as const; ] as const;
export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4-mini"] as const; export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4"] as const;
// Mid-tier (used for tabular review) — user picks one in account settings. // Mid-tier (used for tabular review) — user picks one in account settings.
export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const;
export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const; export const GEMINI_MID_MODELS = ["gemini-3.5-flash", "gemini-3-flash-preview"] as const;
export const OPENAI_MID_MODELS = ["gpt-5.4-mini"] as const; export const OPENAI_MID_MODELS = ["gpt-5.4"] as const;
// Low-tier (used for title generation, lightweight extractions) — user picks // Low-tier (used for title generation, lightweight extractions) — user picks
// one in account settings. // one in account settings.
export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const;
export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const;
export const OPENAI_LOW_MODELS = ["gpt-5.4-nano"] as const; export const OPENAI_LOW_MODELS = ["gpt-5.4-lite"] as const;
export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview";
export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview";

View file

@ -6,9 +6,21 @@ import type {
StreamChatParams, StreamChatParams,
StreamChatResult, StreamChatResult,
} from "./types"; } from "./types";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
const MAX_OUTPUT_TOKENS = 16384; const MAX_OUTPUT_TOKENS = 16384;
const COURTLISTENER_CITATION_REMINDER_TOOL_NAMES = new Set([
"courtlistener_find_in_case",
"courtlistener_read_case",
]);
const COURTLISTENER_CITATION_REMINDER = `COURTLISTENER CITATION REMINDER:
If your final answer relies on any CourtListener case, every such case reference must have BOTH a clickable markdown case link and an inline [N] marker.
Include the clickable case link only the first time you cite that case; later references to the same case should reuse the existing inline [N] marker without repeating the link unless clarity requires it.
Assign new refs in first-use order as much as possible: [1], then [2], then [3]. Reuse an existing ref when citing the same case/passage again, even if that means a later sentence cites [3] and then [1] again.
End the response with a <CITATIONS> block containing one matching case entry per [N] marker:
{"ref": N, "cluster_id": 123, "quotes": [{"opinion_id": 456, "quote": "exact verbatim opinion text"}]}.
Do not use doc_id, page, top-level quote, case_name, or citation fields for CourtListener case entries.`;
type ResponseInputItem = type ResponseInputItem =
| { role: "user" | "assistant"; content: string } | { role: "user" | "assistant"; content: string }
@ -31,7 +43,13 @@ type ResponseFunctionCallItem = {
type ResponseStreamEvent = { type ResponseStreamEvent = {
type?: string; type?: string;
delta?: string; delta?: string;
response?: { id?: string; output_text?: string }; response?: {
id?: string;
output_text?: string;
status?: string;
error?: { code?: string; message?: string } | null;
};
error?: { code?: string; message?: string } | null;
item?: ResponseFunctionCallItem; item?: ResponseFunctionCallItem;
}; };
@ -104,6 +122,45 @@ function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall {
}; };
} }
function openAIStreamFailureMessage(event: ResponseStreamEvent): string | null {
const error = event.response?.error ?? event.error ?? null;
const failed =
event.type === "response.failed" ||
event.response?.status === "failed" ||
!!error;
if (!failed) return null;
const message =
typeof error?.message === "string" && error.message.trim()
? error.message.trim()
: "OpenAI response failed.";
const code =
typeof error?.code === "string" && error.code.trim()
? error.code.trim()
: null;
return code ? `OpenAI error (${code}): ${message}` : message;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
function responseInstructions(systemPrompt: string, includeReminder: boolean) {
return includeReminder
? `${systemPrompt}\n\n${COURTLISTENER_CITATION_REMINDER}`
: systemPrompt;
}
function shouldAppendCourtlistenerCitationReminder(call: NormalizedToolCall) {
return COURTLISTENER_CITATION_REMINDER_TOOL_NAMES.has(call.name);
}
async function createResponse(params: { async function createResponse(params: {
model: string; model: string;
input: ResponseInputItem[]; input: ResponseInputItem[];
@ -114,6 +171,7 @@ async function createResponse(params: {
previousResponseId?: string; previousResponseId?: string;
reasoningSummary?: boolean; reasoningSummary?: boolean;
apiKey: string; apiKey: string;
signal?: AbortSignal;
}): Promise<Response> { }): Promise<Response> {
const response = await fetch(OPENAI_RESPONSES_URL, { const response = await fetch(OPENAI_RESPONSES_URL, {
method: "POST", method: "POST",
@ -129,10 +187,9 @@ async function createResponse(params: {
stream: params.stream, stream: params.stream,
max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS, max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS,
previous_response_id: params.previousResponseId, previous_response_id: params.previousResponseId,
reasoning: params.reasoningSummary reasoning: params.reasoningSummary ? { summary: "auto" } : undefined,
? { summary: "auto" }
: undefined,
}), }),
signal: params.signal,
}); });
if (!response.ok) { if (!response.ok) {
@ -165,18 +222,28 @@ export async function streamOpenAI(
let input = toResponseInput(params.messages); let input = toResponseInput(params.messages);
let previousResponseId: string | undefined; let previousResponseId: string | undefined;
let fullText = ""; let fullText = "";
const hasTools = responseTools.length > 0; let needsCourtlistenerCitationReminder = false;
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "openai",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) { for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const response = await createResponse({ const response = await createResponse({
model, model,
instructions: iter === 0 ? systemPrompt : undefined, instructions: responseInstructions(
systemPrompt,
needsCourtlistenerCitationReminder,
),
input, input,
tools: responseTools, tools: responseTools,
stream: true, stream: true,
previousResponseId, previousResponseId,
reasoningSummary: !!enableThinking, reasoningSummary: !!enableThinking,
apiKey: key, apiKey: key,
signal: params.abortSignal,
}); });
if (!response.body) throw new Error("OpenAI response had no body"); if (!response.body) throw new Error("OpenAI response had no body");
@ -185,18 +252,49 @@ export async function streamOpenAI(
const toolCalls: NormalizedToolCall[] = []; const toolCalls: NormalizedToolCall[] = [];
const startedToolCallIds = new Set<string>(); const startedToolCallIds = new Set<string>();
let buffer = ""; let buffer = "";
let pendingText = "";
let sawReasoning = false; let sawReasoning = false;
while (true) { while (true) {
throwIfAborted(params.abortSignal);
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); const decoded = decoder.decode(value, { stream: true });
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_chunk",
payload: decoded,
});
rawStreamRecorder?.record({
iteration: iter,
label: "sse_chunk",
payload: decoded,
});
buffer += decoded;
const extracted = extractSseJson(buffer); const extracted = extractSseJson(buffer);
buffer = extracted.rest; buffer = extracted.rest;
for (const event of extracted.events as ResponseStreamEvent[]) { for (const event of extracted.events as ResponseStreamEvent[]) {
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_event",
payload: event,
});
rawStreamRecorder?.record({
iteration: iter,
label: "sse_event",
payload: event,
});
const failureMessage = openAIStreamFailureMessage(event);
if (failureMessage) {
throw new Error(failureMessage);
}
if (event.response?.id) { if (event.response?.id) {
previousResponseId = event.response.id; previousResponseId = event.response.id;
} }
@ -213,13 +311,9 @@ export async function streamOpenAI(
event.type === "response.output_text.delta" && event.type === "response.output_text.delta" &&
typeof event.delta === "string" typeof event.delta === "string"
) { ) {
if (hasTools) {
pendingText += event.delta;
} else {
fullText += event.delta; fullText += event.delta;
callbacks.onContentDelta?.(event.delta); callbacks.onContentDelta?.(event.delta);
} }
}
if ( if (
event.type === "response.output_item.added" && event.type === "response.output_item.added" &&
@ -244,16 +338,18 @@ export async function streamOpenAI(
} }
if (sawReasoning) callbacks.onReasoningBlockEnd?.(); if (sawReasoning) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
if (!toolCalls.length || !runTools) { if (!toolCalls.length || !runTools) {
if (pendingText) {
fullText += pendingText;
callbacks.onContentDelta?.(pendingText);
}
break; break;
} }
if (toolCalls.some(shouldAppendCourtlistenerCitationReminder)) {
needsCourtlistenerCitationReminder = true;
}
const results = await runTools(toolCalls); const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
input = results.map((result) => ({ input = results.map((result) => ({
type: "function_call_output", type: "function_call_output",
call_id: result.tool_use_id, call_id: result.tool_use_id,
@ -261,7 +357,12 @@ export async function streamOpenAI(
})); }));
} }
await rawStreamRecorder?.flush("completed");
return { fullText }; return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
} }
export async function completeOpenAIText(params: { export async function completeOpenAIText(params: {

View file

@ -0,0 +1,170 @@
import { randomUUID } from "crypto";
import { mkdir, open } from "fs/promises";
import type { FileHandle } from "fs/promises";
import path from "path";
type RawStreamEntry = {
timestamp: string;
iteration: number;
label: string;
payload: unknown;
};
function rawStreamLogDir(): string | null {
return process.env.RAW_LLM_STREAM_LOG_DIR?.trim() || null;
}
function safeFilePart(value: string) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
}
function stringifyJson(value: unknown) {
const seen = new WeakSet<object>();
return JSON.stringify(value, (_key, innerValue: unknown) => {
if (typeof innerValue === "bigint") return innerValue.toString();
if (innerValue instanceof Error) {
return {
name: innerValue.name,
message: innerValue.message,
stack: innerValue.stack,
};
}
if (innerValue && typeof innerValue === "object") {
if (seen.has(innerValue)) return "[Circular]";
seen.add(innerValue);
}
return innerValue;
});
}
export function logRawLlmStream(args: {
provider: string;
model: string;
iteration: number;
label: string;
payload: unknown;
}) {
if (process.env.LOG_RAW_LLM_STREAM !== "true") return;
console.log(
`[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`,
);
console.dir(args.payload, { depth: null, maxArrayLength: null });
}
export function createRawLlmStreamRecorder(args: {
provider: string;
model: string;
}) {
const dir = rawStreamLogDir();
if (!dir) return null;
const logDir = dir;
const startedAt = new Date();
const id = randomUUID();
const filename = [
safeFilePart(args.provider),
safeFilePart(args.model),
startedAt.toISOString().replace(/[:.]/g, "-"),
id,
].join("-");
const filePath = path.join(logDir, `${filename}.raw-llm-stream.json`);
let fileHandle: FileHandle | null = null;
let writeChain: Promise<void> = Promise.resolve();
let writeError: unknown = null;
let wroteEntry = false;
let finalized = false;
async function ensureOpen() {
if (fileHandle) return fileHandle;
await mkdir(logDir, { recursive: true });
fileHandle = await open(filePath, "w");
const header = {
id,
provider: args.provider,
model: args.model,
startedAt: startedAt.toISOString(),
};
await fileHandle.write(`${stringifyJson(header)?.slice(0, -1)},"entries":[`);
return fileHandle;
}
function queueWrite(action: () => Promise<void>) {
writeChain = writeChain
.then(action)
.catch((error) => {
writeError = error;
console.error("[raw-llm-stream] failed to write log file", {
filePath,
error: error instanceof Error ? error.message : String(error),
});
});
}
return {
record(entry: Omit<RawStreamEntry, "timestamp">) {
if (finalized) return;
const rawEntry = {
timestamp: new Date().toISOString(),
...entry,
};
queueWrite(async () => {
const handle = await ensureOpen();
const serialized =
stringifyJson(rawEntry) ??
stringifyJson({
timestamp: rawEntry.timestamp,
iteration: rawEntry.iteration,
label: rawEntry.label,
payload: "[Unserializable payload]",
});
await handle.write(`${wroteEntry ? "," : ""}${serialized}`);
wroteEntry = true;
});
},
async flush(status: "completed" | "error", error?: unknown) {
if (finalized) return;
finalized = true;
const errorPayload =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error
? { message: String(error) }
: undefined;
const footer = {
finishedAt: new Date().toISOString(),
status,
error: errorPayload,
};
try {
await writeChain;
const handle = await ensureOpen();
await handle.write(`],${stringifyJson(footer)?.slice(1)}\n`);
} catch (writeError) {
console.error("[raw-llm-stream] failed to write log file", {
filePath,
error:
writeError instanceof Error
? writeError.message
: String(writeError),
});
} finally {
if (fileHandle) {
await fileHandle.close().catch(() => {});
fileHandle = null;
}
if (writeError) {
console.error("[raw-llm-stream] log file may be incomplete", {
filePath,
});
}
}
},
};
}

Some files were not shown because too many files have changed in this diff Show more