Compare commits

...

88 commits
v0.1.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
cosimoastrada
d39f5806e5
Merge pull request #148 from willchen96/document-ui-tabular-updates
Update document UI, tabular reviews, and storage caching
2026-05-18 00:25:08 +08:00
willchen96
4f3384334a Update document UI, tabular reviews, and storage caching 2026-05-18 00:21:40 +08:00
willchen96
f656e14ef7 docs: add security reporting guidance 2026-05-17 01:57:19 +08:00
cosimoastrada
2bbb628891
Merge pull request #146 from willchen96/require-download-signing-secret
fix: require dedicated download signing secret
2026-05-17 01:06:53 +08:00
willchen96
ea48cdedd5 fix: require dedicated download signing secret 2026-05-17 01:05:01 +08:00
cosimoastrada
2f24cae407
Merge pull request #139 from willchen96/add-contributing-guide
docs: add contributing guide
2026-05-16 01:48:03 +08:00
willchen96
b685e86028 docs: add contributing guide 2026-05-16 01:47:22 +08:00
cosimoastrada
4ba4d53c38
Merge pull request #138 from willchen96/supabase-env-cleanup
fix: enforce SUPABASE_URL and SUPABASE_SECRET_KEY presence in server-side client; remove unused supabase-server.ts file
2026-05-16 01:16:51 +08:00
willchen96
9749d601fa fix: enforce SUPABASE_URL and SUPABASE_SECRET_KEY presence in server-side client; remove unused supabase-server.ts file 2026-05-16 01:16:05 +08:00
cosimoastrada
aed8c42e94
Merge pull request #137 from willchen96/require-user-api-key-secret
fix: update encryption key retrieval to use only USER_API_KEYS_ENCRYPTION_SECRET
2026-05-16 00:55:57 +08:00
willchen96
b4ba274264 fix: update encryption key retrieval to use only USER_API_KEYS_ENCRYPTION_SECRET; remove supabase secret key fallback 2026-05-16 00:53:14 +08:00
cosimoastrada
4290104cd0
Merge pull request #136 from willchen96/prevent-self-sharing
feat: prevent users from sharing projects and reviews with themselves
2026-05-16 00:06:40 +08:00
willchen96
87e55d6046 feat: prevent users from sharing projects and reviews with themselves 2026-05-16 00:05:16 +08:00
cosimoastrada
9e7046d4aa
Merge pull request #132 from willchen96/project-page-deployment-fixes
refactor: enhance error handling and streamline API key management in LLM modules
2026-05-14 23:57:23 +08:00
willchen96
08d996781a feat: enhance workflow sharing by preventing users from sharing with themselves and normalizing email inputs 2026-05-14 23:29:08 +08:00
willchen96
a2368a7479 refactor: enhance error handling and streamline API key management in LLM modules 2026-05-14 23:20:28 +08:00
cosimoastrada
2e8eafc78e
Merge pull request #64 from willchen96/project-page-deployment-fixes
Sync deployment and project page fixes
2026-05-13 02:38:45 +08:00
willchen96
f39f175273 Sync deployment and project page fixes 2026-05-13 02:32:26 +08:00
cosimoastrada
56c6051f90
Merge pull request #62 from willchen96/sync/next-opennext-cloudflare
chore: update Next and Cloudflare dependencies
2026-05-12 13:42:55 +08:00
willchen96
91d0c2a089 chore: update Next and Cloudflare dependencies 2026-05-12 13:40:01 +08:00
cosimoastrada
469ee4adec
Merge pull request #56 from willchen96/legal-download-updates
Update OSS setup docs and remove app legal pages
2026-05-11 03:20:04 +08:00
willchen96
af5691e773 Update OSS setup docs and remove app legal pages 2026-05-11 03:15:34 +08:00
cosimoastrada
0ac2744a8e
Merge pull request #21 from Metbcy/fix/download-secret-fail-fast
fix(security): fail fast when download HMAC secret is missing (closes #7)
2026-05-11 02:20:27 +08:00
willchen96
a84c1cc113 docs: improve setup guidance and env examples 2026-05-10 22:36:29 +08:00
cosimoastrada
dbbf19697e
Merge pull request #51 from aaronjmars/security/tabular-document-idor
fix(security): scope tabular-review document_ids by access (CWE-639)
2026-05-10 21:03:38 +08:00
cosimoastrada
029181b2ff
Merge pull request #52 from willchen96/sync/jsonb-shared-with-s3-path-style
fix: handle JSONB shared_with filters and path-style S3
2026-05-10 20:23:13 +08:00
willchen96
625bca4d84 fix: handle JSONB shared_with filters and path-style S3 2026-05-10 20:19:30 +08:00
Aeon (aaronjmars)
e261d2e4bd fix(security): scope tabular-review document_ids by access (CWE-639)
The tabular-review routes accept user-supplied document_ids in
request bodies (POST /tabular-review, PATCH /:reviewId) and stale
cell rows on byte-fetching paths (POST /:reviewId/regenerate-cell,
POST /:reviewId/generate). None of those paths checked whether the
caller can read those documents — a free-account attacker could plant
foreign UUIDs into their own review and have the server fetch the
bytes from R2 + run an LLM extraction over them, returning verbatim
text via the standard review GET.

Adds filterAccessibleDocumentIds(documentIds, userId, userEmail, db)
next to the existing access helpers (owner-of-doc OR project member),
and applies it at the four entry points:

- POST /tabular-review               drop unauthorised on insert
- PATCH /:reviewId                   drop newly-added unauthorised; keep
                                     already-attached cells so non-owner
                                     collaborators don't accidentally
                                     orphan rows they can't directly
                                     access
- POST /:reviewId/regenerate-cell    refuse byte fetch when caller has
                                     no access to the underlying doc
- POST /:reviewId/generate           filter docIds before parallel LLM
                                     fetch (defense-in-depth for legacy
                                     cells planted before this fix)

Fails closed silently rather than 403'ing so legacy clients that pass
stale ids don't error out the whole review.

Detected by Aeon + manual review.
Severity: high
CWE-639 (Authorization Bypass Through User-Controlled Key)
2026-05-10 04:50:21 +00:00
cosimoastrada
f40c25d07f
Merge pull request #48 from willchen96/sync/openai-model-support
feat: add OpenAI model support and harden OSS security defaults
2026-05-09 15:07:47 +08:00
willchen96
bef75b082d feat: add OpenAI model support and harden OSS security defaults 2026-05-09 14:55:51 +08:00
cosimoastrada
adc2cf2370
Merge pull request #31 from fayerman-source/codex/safe-local-testing-guide
docs: add safe local testing guide
2026-05-08 23:05:15 +08:00
cosimoastrada
1f191fea59
Merge branch 'main' into codex/safe-local-testing-guide 2026-05-08 23:05:03 +08:00
cosimoastrada
e5a3d6f222
Merge pull request #28 from fayerman-source/codex/validate-project-folders
fix(projects): validate folder ownership before folder mutations
2026-05-08 22:55:15 +08:00
cosimoastrada
7f5dd217d7
Merge pull request #46 from willchen96/sync/security-profile-chat-headers
Sync security and backend profile updates
2026-05-08 21:12:04 +08:00
willchen96
ba6f771144 Sync security and backend profile updates 2026-05-08 20:45:16 +08:00
Eli Fayerman
fce2f2d941 docs: add safe local testing guide 2026-05-04 20:09:05 -04:00
Eli Fayerman
7062a30039 fix project folder boundary checks 2026-05-04 11:59:04 -04:00
Metbcy
eb4414092e fix(security): fail fast when download HMAC secret is missing
Resolves the issue where getSecret() silently fell back to the literal
string "dev-secret" when neither DOWNLOAD_SIGNING_SECRET nor
SUPABASE_SECRET_KEY was set. Because the codebase is public, that
fallback let anyone forge valid /download/:token signatures against a
mis-configured deployment.

- Throw at first call instead of returning the hardcoded string, with a
  message pointing the operator at `openssl rand -hex 32`.
- Document DOWNLOAD_SIGNING_SECRET in backend/.env.example so deployers
  following the README know to set it (and that it should be distinct
  from SUPABASE_SECRET_KEY).

Closes #7
2026-05-03 00:12:44 +00:00
willchen96
d9690965b5 Add local repo contents 2026-04-29 19:49:06 +02:00
358 changed files with 117360 additions and 0 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

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
node_modules
.next
dist
out
build
.open-next
.env
.env.*
!.env.example
!.env.local.example
*.log
*.raw-llm-stream.json
*.tsbuildinfo
next-env.d.ts
.DS_Store
.vercel
coverage

75
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,75 @@
# Contributing
Thanks for helping improve Mike. Please keep contributions small, focused, and easy to review.
## Guidelines
- Prefer targeted edits over broad refactors.
- Keep each PR focused on one bug, feature, or cleanup.
- Update docs or env examples when changing setup, config, or user-facing behavior.
- Please do not propose local-hosting refactors for the main app, such as local LLMs, local databases, or local filesystem storage. Those ideas are better suited to a future fully local version of the project.
- Do not commit secrets, API keys, private documents, or local `.env` files.
## Before Opening a PR
- Run the relevant build or test command for the area you changed.
- Check `git diff` and remove unrelated changes.
- Write a concise Markdown PR description with:
- summary
- changes
- why
- 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
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.
## Local Development
Backend:
```bash
npm run build --prefix backend
```
Frontend:
```bash
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.

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

162
README.md Normal file
View file

@ -0,0 +1,162 @@
# Mike
![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)
## Contents
- `frontend/` - Next.js application
- `backend/` - Express API, Supabase access, document processing, and database schema
- `backend/schema.sql` - Supabase schema for fresh databases
- `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
- Node.js 20 or newer
- npm
- git
- A Supabase project
- A Cloudflare R2 bucket, MinIO bucket, or another S3-compatible bucket
- 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
## Database Setup
For a new Supabase database, open the Supabase SQL editor and run:
```sql
-- copy and run the contents of:
-- backend/schema.sql
```
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. 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
Create local env files:
```bash
touch backend/.env
touch frontend/.env.local
```
Create `backend/.env`:
```bash
PORT=3001
FRONTEND_URL=http://localhost:3000
DOWNLOAD_SIGNING_SECRET=replace-with-a-random-32-byte-hex-string
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=your-supabase-service-role-key
R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=mike
GEMINI_API_KEY=your-gemini-key
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key
RESEND_API_KEY=your-resend-key
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`:
```bash
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-supabase-anon-key
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.
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 each app package:
```bash
npm install --prefix backend
npm install --prefix frontend
```
## Run Locally
Start the backend:
```bash
npm run dev --prefix backend
```
Start the main app:
```bash
npm run dev --prefix frontend
```
Open `http://localhost:3000`.
## First Run
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.
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
**Sign-up confirmation email never arrives.** Confirmation emails are sent by Supabase Auth, not by Mike. For local development, the simplest fix is to disable email confirmation in **Supabase > Authentication > Providers > Email**. For production, configure custom SMTP in Supabase; the built-in mailer is heavily rate-limited and may be restricted on newer projects.
**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.
## Useful Checks
```bash
npm run build --prefix backend
npm run build --prefix frontend
npm run lint --prefix frontend
```

23
backend/.env.example Normal file
View file

@ -0,0 +1,23 @@
PORT=3001
FRONTEND_URL=http://localhost:3000
# HMAC key used to sign /download/:token URLs. Required at startup.
# Generate with: openssl rand -hex 32
# Use a dedicated secret distinct from SUPABASE_SECRET_KEY.
DOWNLOAD_SIGNING_SECRET=replace-with-a-random-32-byte-hex-string
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=your-supabase-service-role-key
R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=mike
GEMINI_API_KEY=your-gemini-key
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key
RESEND_API_KEY=your-resend-key
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

8
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
dist
.env*
!.env.example
*.log
*.raw-llm-stream.json
logs/
.DS_Store

889
backend/bun.lock Normal file
View file

@ -0,0 +1,889 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "mike-backend",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@google/genai": "^1.50.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.49.4",
"cors": "^2.8.5",
"docx": "^9.5.0",
"dotenv": "^17.4.1",
"express": "^4.21.2",
"express-rate-limit": "^8.5.1",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.7.1",
"helmet": "^8.1.0",
"jszip": "^3.10.1",
"libreoffice-convert": "^1.6.0",
"mammoth": "^1.9.0",
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76",
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.1",
"prettier": "^3.8.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1026.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-node": "^3.972.30", "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", "@aws-sdk/middleware-expect-continue": "^3.972.9", "@aws-sdk/middleware-flexible-checksums": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-location-constraint": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/middleware-ssec": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.16", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/eventstream-serde-browser": "^4.2.13", "@smithy/eventstream-serde-config-resolver": "^4.3.13", "@smithy/eventstream-serde-node": "^4.2.13", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-blob-browser": "^4.2.14", "@smithy/hash-node": "^4.2.13", "@smithy/hash-stream-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/md5-js": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.15", "tslib": "^2.6.2" } }, "sha512-tMP+s641FLSXdJazvYvuf38F7suWWv+wagTvShykPTffuFpBj5J9f7Rw0eKsauBcsjPSntiwBz9Gm0Tlh+cKfQ=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.6", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-login": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/token-providers": "3.1026.0", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/crc64-nvme": "^3.972.6", "@aws-sdk/types": "^3.973.7", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-retry": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.19", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/config-resolver": "^4.4.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1026.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "^3.996.16", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-format-url": "^3.972.9", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-PBVt/zb4YsJMcyB/HbGmID4RP00dTkdQGkNQiw1i6oXQ/U8hnPEI8+IvTKR4+5YEQ8Cq4QmtIV0mzv070L+oOg=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1026.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-endpoints": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.15", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.17", "", { "dependencies": { "@smithy/types": "^4.14.0", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@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-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-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.97", "", { "os": "linux", "cpu": "arm" }, "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.97", "", { "os": "linux", "cpu": "none" }, "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ=="],
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ=="],
"@smithy/core": ["@smithy/core@3.23.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.13", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.16", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.14", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.29", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-serde": "^4.2.17", "@smithy/node-config-provider": "^4.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.0", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/service-error-classification": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.17", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.13", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.2", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0" } }, "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.8", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.13", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.12.9", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-stack": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ=="],
"@smithy/types": ["@smithy/types@4.14.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.13", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.45", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.49", "", { "dependencies": { "@smithy/config-resolver": "^4.4.14", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.4", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow=="],
"@smithy/util-retry": ["@smithy/util-retry@4.3.0", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.22", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.15", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg=="],
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
"@supabase/auth-js": ["@supabase/auth-js@2.102.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-2uH2WB0H98TOGDtaFWhxIcR42Dro/VB7VDZanz/4bVJsqioIue1m3TUqu3xciDm2W9r+1LXQvYNsYbQfWmD+uQ=="],
"@supabase/functions-js": ["@supabase/functions-js@2.102.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-UcrcKTPnAIo+Yp9Jjq9XXwFbsmgRYY637mwka9ZjmTIWcX/xr1pote4OVvaGQycVY1KTiQgjMvpC0Q0yJhRq3w=="],
"@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.102.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-InLvXKAYf8BIqiv9jWOYudWB3rU8A9uMbcip5BQ5sLLNPrbO1Ekkr79OvlhZBgMNSppxVyC7wPPGzLxMcTZhlA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.102.1", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-h2fCumib/v6u7XMwSPgxnpfimjX4xCEayUHrxWLC7UurfQjUZJ0pmJDgm6yj80DnUerxuulRghwm5zXYysFG/Q=="],
"@supabase/storage-js": ["@supabase/storage-js@2.102.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-eCL9T4Xpe40nmKlkUJ7Zq/hk34db1xPiT0WL3Iv5MbJqHuCAe5TxhV8Rjqd6DNZrzjtfYObZtYl9jKJaHrivqw=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.102.1", "", { "dependencies": { "@supabase/auth-js": "2.102.1", "@supabase/functions-js": "2.102.1", "@supabase/postgrest-js": "2.102.1", "@supabase/realtime-js": "2.102.1", "@supabase/storage-js": "2.102.1" } }, "sha512-bChxPVeLDnYN9M2d/u4fXsvylwSQG5grAl+HN8f+ZD9a9PuVU+Ru+xGmEsk+b9Iz3rJC9ZQnQUJYQ28fApdWYA=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/multer": ["@types/multer@1.4.13", "", { "dependencies": { "@types/express": "*" } }, "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw=="],
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"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=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"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=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
"docx": ["docx@9.6.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="],
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"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-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=="],
"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-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-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=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"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=="],
"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=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"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=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"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=="],
"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-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=="],
"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=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"libreoffice-convert": ["libreoffice-convert@1.8.1", "", { "dependencies": { "async": "^3.2.3", "tmp": "^0.2.1" } }, "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": "bin/mammoth" }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"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=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": "bin/cmd.js" }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"multer": ["multer@1.4.5-lts.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", "concat-stream": "^1.5.2", "mkdirp": "^0.5.4", "object-assign": "^4.1.1", "type-is": "^1.6.4", "xtend": "^4.0.0" } }, "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A=="],
"nanoid": ["nanoid@5.1.7", "", { "bin": "bin/nanoid.js" }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"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=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"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=="],
"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=="],
"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=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"protobufjs": ["protobufjs@7.5.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"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-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="],
"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=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"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=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"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-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"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=="],
"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-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"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/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/util/@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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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/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/util/@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-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@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=="],
"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=="],
"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/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=="],
"@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;

2
backend/nixpacks.toml Normal file
View file

@ -0,0 +1,2 @@
[phases.setup]
nixPkgs = ["...", "libreoffice"]

7299
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

51
backend/package.json Normal file
View file

@ -0,0 +1,51 @@
{
"name": "mike-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:stack": "bash scripts/test-stack.sh",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@google/genai": "^1.50.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.49.4",
"cors": "^2.8.5",
"docx": "^9.5.0",
"dotenv": "^17.4.1",
"express": "^4.21.2",
"express-rate-limit": "^8.5.1",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.7.1",
"helmet": "^8.1.0",
"jszip": "^3.10.1",
"libreoffice-convert": "^1.6.0",
"mammoth": "^1.9.0",
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.1",
"@types/supertest": "^7.2.1",
"@vitest/coverage-v8": "^4.1.9",
"prettier": "^3.8.1",
"supertest": "^7.2.2",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^4.1.9"
},
"license": "AGPL-3.0-only"
}

898
backend/schema.sql Normal file
View file

@ -0,0 +1,898 @@
-- Mike Supabase schema
-- Use this for a fresh Supabase database. Existing deployments should instead
-- apply the dated incremental migration files in backend/migrations that are
-- newer than the version of Mike they currently have deployed.
create extension if not exists "pgcrypto";
-- ---------------------------------------------------------------------------
-- User profiles
-- ---------------------------------------------------------------------------
create table if not exists public.user_profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null unique references auth.users(id) on delete cascade,
email text,
display_name text,
organisation text,
tier text not null default 'Free',
message_credits_used integer not null default 0,
credits_reset_date timestamptz not null default (now() + interval '30 days'),
title_model text,
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(),
updated_at timestamptz not null default now()
);
create index if not exists idx_user_profiles_user
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()
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;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
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;
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
-- ---------------------------------------------------------------------------
create table if not exists public.projects (
id uuid primary key default gen_random_uuid(),
user_id text not null,
name text not null,
cm_number text,
practice text,
visibility text not null default 'private',
shared_with jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_projects_user
on public.projects(user_id);
create index if not exists projects_shared_with_idx
on public.projects using gin (shared_with);
create table if not exists public.project_subfolders (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
user_id text not null,
name text not null,
parent_folder_id uuid references public.project_subfolders(id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_project_subfolders_project
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 (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
user_id text not null,
status text not null default 'pending',
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(),
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
on public.documents(user_id, project_id);
create index if not exists idx_documents_project_folder
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 (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
storage_path text,
pdf_storage_path text,
source text not null default 'upload',
version_number integer,
filename text,
file_type text,
size_bytes integer,
page_count integer,
deleted_at timestamptz,
deleted_by uuid,
created_at timestamptz not null default now(),
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
]))
);
create index if not exists document_versions_document_id_idx
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
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
add column if not exists current_version_id uuid
references public.document_versions(id) on delete set null;
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,
version_id uuid not null references public.document_versions(id) on delete cascade,
change_id text not null,
del_w_id text,
ins_w_id text,
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);
-- ---------------------------------------------------------------------------
-- Workflows
-- ---------------------------------------------------------------------------
create table if not exists public.workflows (
id uuid primary key default gen_random_uuid(),
user_id text,
title text not null,
type text not null,
prompt_md text,
columns_config jsonb,
language text default 'English',
practice text default 'General Transactions',
jurisdictions text[] default array['General']::text[],
created_at timestamptz not null default now()
);
create index if not exists idx_workflows_user
on public.workflows(user_id);
create table if not exists public.hidden_workflows (
id uuid primary key default gen_random_uuid(),
user_id text not null,
workflow_id text not null,
created_at timestamptz not null default now(),
unique(user_id, workflow_id)
);
create index if not exists idx_hidden_workflows_user
on public.hidden_workflows(user_id);
create table if not exists public.workflow_shares (
id uuid primary key default gen_random_uuid(),
workflow_id uuid not null references public.workflows(id) on delete cascade,
shared_by_user_id text not null,
shared_with_email text not null,
allow_edit boolean not null default false,
created_at timestamptz not null default now(),
constraint workflow_shares_workflow_email_unique
unique(workflow_id, shared_with_email)
);
create index if not exists workflow_shares_workflow_id_idx
on public.workflow_shares(workflow_id);
create index if not exists workflow_shares_email_idx
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
-- ---------------------------------------------------------------------------
create table if not exists public.chats (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
user_id text not null,
title text,
created_at timestamptz not null default now()
);
create index if not exists idx_chats_user
on public.chats(user_id);
create index if not exists idx_chats_project
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 (
id uuid primary key default gen_random_uuid(),
chat_id uuid not null references public.chats(id) on delete cascade,
role text not null,
content jsonb,
files jsonb,
workflow jsonb,
citations jsonb,
created_at timestamptz not null default now()
);
create index if not exists idx_chat_messages_chat
on public.chat_messages(chat_id);
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'document_edits_chat_message_id_fkey'
and conrelid = 'public.document_edits'::regclass
) then
alter table public.document_edits
add constraint document_edits_chat_message_id_fkey
foreign key (chat_message_id)
references public.chat_messages(id)
on delete set null;
end if;
end;
$$;
-- ---------------------------------------------------------------------------
-- Tabular reviews
-- ---------------------------------------------------------------------------
create table if not exists public.tabular_reviews (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
user_id text not null,
title text,
columns_config jsonb,
document_ids jsonb,
workflow_id uuid references public.workflows(id) on delete set null,
practice text,
shared_with jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_tabular_reviews_user
on public.tabular_reviews(user_id);
create index if not exists idx_tabular_reviews_project
on public.tabular_reviews(project_id);
create index if not exists tabular_reviews_shared_with_idx
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 (
id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
document_id uuid not null references public.documents(id) on delete cascade,
column_index integer not null,
content text,
citations jsonb,
status text not null default 'pending',
created_at timestamptz not null default now()
);
create index if not exists idx_tabular_cells_review
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 (
id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
user_id text not null,
title text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists tabular_review_chats_review_idx
on public.tabular_review_chats(review_id, updated_at desc);
create index if not exists tabular_review_chats_user_idx
on public.tabular_review_chats(user_id);
create table if not exists public.tabular_review_chat_messages (
id uuid primary key default gen_random_uuid(),
chat_id uuid not null references public.tabular_review_chats(id) on delete cascade,
role text not null,
content jsonb,
annotations jsonb,
created_at timestamptz not null default now()
);
create index if not exists tabular_review_chat_messages_chat_idx
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
-- ---------------------------------------------------------------------------
--
-- The frontend uses Supabase directly only for authentication. Application
-- data access goes through the backend API with the service role after the
-- backend verifies the user's JWT. Do not grant the browser anon/authenticated
-- roles direct table privileges for backend-owned data.
revoke all on public.user_profiles from anon, authenticated;
revoke all on public.projects 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.document_versions from anon, authenticated;
revoke all on public.document_edits from anon, authenticated;
revoke all on public.workflows from anon, authenticated;
revoke all on public.hidden_workflows from anon, authenticated;
revoke all on public.workflow_shares from anon, authenticated;
revoke all on public.chats from anon, authenticated;
revoke all on public.chat_messages from anon, authenticated;
revoke all on public.tabular_reviews from anon, authenticated;
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_chat_messages 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 }));

7
backend/src/index.ts Normal file
View file

@ -0,0 +1,7 @@
import { app } from "./app";
const PORT = process.env.PORT ?? 3001;
app.listen(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([]);
});
});

188
backend/src/lib/access.ts Normal file
View file

@ -0,0 +1,188 @@
/**
* Project / document access helpers.
*
* Sharing makes the previous "scope by user_id" pattern incorrect a doc
* can belong to user A's project that A has shared with B's email, and B
* must still be able to read/edit it. These helpers centralize the
* "owner OR shared project member" check so every route uses the same
* logic instead of re-implementing the join.
*
* Returned `isOwner` lets callers gate operations that should stay
* owner-only (delete, rename, member management).
*/
import type { createServerSupabase } from "./supabase";
type Db = ReturnType<typeof createServerSupabase>;
export type ProjectAccess =
| {
ok: true;
isOwner: boolean;
project: {
id: string;
user_id: string;
shared_with: string[] | null;
};
}
| { ok: false };
export async function checkProjectAccess(
projectId: string,
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<ProjectAccess> {
const { data: project } = await db
.from("projects")
.select("id, user_id, shared_with")
.eq("id", projectId)
.single();
if (!project) return { ok: false };
const proj = project as {
id: string;
user_id: string;
shared_with: string[] | null;
};
if (proj.user_id === userId) {
return { ok: true, isOwner: true, project: proj };
}
const sharedWith = Array.isArray(proj.shared_with) ? proj.shared_with : [];
const email = (userEmail ?? "").toLowerCase();
if (
email &&
sharedWith.some((e) => (e ?? "").toLowerCase() === email)
) {
return { ok: true, isOwner: false, project: proj };
}
return { ok: false };
}
/**
* Check whether the current user can access a document the caller has
* already loaded (saves a round-trip vs. having the helper re-fetch).
* Owner-of-doc passes immediately; otherwise we fall through to a
* project-membership check via `shared_with`.
*/
export async function ensureDocAccess(
doc: { user_id: string; project_id: string | null },
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<{ ok: true; isOwner: boolean } | { ok: false }> {
if (doc.user_id === userId) return { ok: true, isOwner: true };
if (!doc.project_id) return { ok: false };
const access = await checkProjectAccess(
doc.project_id,
userId,
userEmail,
db,
);
if (access.ok) return { ok: true, isOwner: false };
return { ok: false };
}
/**
* Same shape as `ensureDocAccess`, for tabular_reviews. A review can be
* shared in two ways:
* 1. Indirectly if `project_id` is set, everyone with project access
* can read/operate on it.
* 2. Directly `tabular_reviews.shared_with` is a per-review email list
* so standalone reviews (project_id null) can also be shared.
* The owner (review.user_id) always has access.
*/
export async function ensureReviewAccess(
review: {
user_id: string;
project_id: string | null;
shared_with?: string[] | null;
},
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<{ ok: true; isOwner: boolean } | { ok: false }> {
if (review.user_id === userId) return { ok: true, isOwner: true };
const email = (userEmail ?? "").toLowerCase();
if (email && Array.isArray(review.shared_with)) {
if (review.shared_with.some((e) => (e ?? "").toLowerCase() === email)) {
return { ok: true, isOwner: false };
}
}
if (!review.project_id) return { ok: false };
const access = await checkProjectAccess(
review.project_id,
userId,
userEmail,
db,
);
if (access.ok) return { ok: true, isOwner: false };
return { ok: false };
}
/**
* Filter user-supplied document IDs down to documents the caller can read.
*
* Tabular review routes accept document IDs from request bodies. Without this
* check, a caller with access to any review could attach arbitrary document
* UUIDs and later cause /generate or /regenerate-cell to extract those bytes.
*/
export async function filterAccessibleDocumentIds(
documentIds: string[],
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<string[]> {
if (documentIds.length === 0) return [];
const { data: docs } = await db
.from("documents")
.select("id, user_id, project_id")
.in("id", documentIds);
const rows = (docs ?? []) as {
id: string;
user_id: string;
project_id: string | null;
}[];
if (rows.length === 0) return [];
const accessibleProjectIds = new Set(
await listAccessibleProjectIds(userId, userEmail, db),
);
const allowed: string[] = [];
for (const doc of rows) {
if (doc.user_id === userId) {
allowed.push(doc.id);
} else if (
doc.project_id &&
accessibleProjectIds.has(doc.project_id)
) {
allowed.push(doc.id);
}
}
return allowed;
}
/**
* Returns the set of project IDs the user can access own projects plus
* any project where their email is in `shared_with`. Used to scope chat
* lists and similar collection queries.
*/
export async function listAccessibleProjectIds(
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<string[]> {
const [{ data: own }, { data: shared }] = await Promise.all([
db.from("projects").select("id").eq("user_id", userId),
userEmail
? db
.from("projects")
.select("id")
.filter("shared_with", "cs", JSON.stringify([userEmail]))
.neq("user_id", userId)
: Promise.resolve({ data: [] as { id: string }[] }),
]);
const ids = new Set<string>();
for (const p of (own ?? []) as { id: string }[]) ids.add(p.id);
for (const p of (shared ?? []) as { id: string }[]) ids.add(p.id);
return [...ids];
}

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";
};

138
backend/src/lib/convert.ts Normal file
View file

@ -0,0 +1,138 @@
import JSZip from "jszip";
import fs from "node:fs";
import path from "node:path";
let _convert:
| ((buf: Buffer, ext: string, filter: undefined) => Promise<Buffer>)
| 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() {
if (!_convert) {
const libre = await import("libreoffice-convert");
const convertWithOptions = libre.default.convertWithOptions.bind(
libre.default,
) as (
buf: Buffer,
ext: string,
filter: undefined,
options: { sofficeBinaryPaths?: string[] },
callback?: (err: Error | null, result: Buffer) => void,
) => Promise<Buffer> | void;
_convert = (buf, ext, filter) =>
new Promise<Buffer>((resolve, reject) => {
try {
const maybePromise = convertWithOptions(
buf,
ext,
filter,
{ sofficeBinaryPaths: resolveSofficeBinaryPaths() },
(err, result) => {
if (err) reject(err);
else resolve(result);
},
);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.then(resolve, reject);
}
} catch (err) {
reject(err);
}
});
}
return _convert;
}
/**
* Some older Windows/Word archives store .docx entries with backslash
* separators (e.g. `word\document.xml`). Mammoth and LibreOffice both look
* up entries by exact string and miss those files, producing empty output
* or conversion failures. Rewrite any such entries to the canonical
* forward-slash form before handing the buffer off.
*/
export async function normalizeDocxZipPaths(buffer: Buffer): Promise<Buffer> {
let zip: JSZip;
try {
zip = await JSZip.loadAsync(buffer);
} catch {
return buffer;
}
const renames: [string, string][] = [];
zip.forEach((relativePath) => {
if (relativePath.includes("\\")) {
renames.push([relativePath, relativePath.replace(/\\/g, "/")]);
}
});
if (renames.length === 0) return buffer;
for (const [oldPath, newPath] of renames) {
const entry = zip.file(oldPath);
if (!entry) continue;
const content = await entry.async("nodebuffer");
zip.remove(oldPath);
zip.file(newPath, content);
}
return zip.generateAsync({ type: "nodebuffer" });
}
/**
* Convert a DOCX/DOC buffer to PDF using LibreOffice.
* Throws if LibreOffice is not installed or conversion fails.
*/
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 normalized = await normalizeDocxZipPaths(buffer);
return convert(normalized, ".pdf", undefined);
}
export function convertedPdfKey(userId: string, docId: string): string {
return `converted-pdfs/${userId}/${docId}.pdf`;
}

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

@ -0,0 +1,196 @@
import type { createServerSupabase } from "./supabase";
type Supa = ReturnType<typeof createServerSupabase>;
interface DocRow {
id: string;
latest_version_number?: number | null;
[k: string]: unknown;
}
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. */
storage_path?: string | null;
/** Set from document_versions.pdf_storage_path of the active version. */
pdf_storage_path?: string | null;
current_version_id?: string | null;
/** Set from document_versions.version_number of the active version. */
active_version_number?: number | null;
/** Active-version file metadata. */
file_type?: string | null;
size_bytes?: number | null;
page_count?: number | null;
}
export interface ActiveVersion {
id: string;
storage_path: string;
pdf_storage_path: string | null;
version_number: number | null;
filename: string | null;
source: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
}
/**
* Resolve storage paths for a document. Prefers the version pointed to by
* `versionId` (if it belongs to this document); else falls back to
* `documents.current_version_id`. Returns null if no usable version exists.
*
* After the storage_path/pdf_storage_path columns moved off `documents`,
* every read-from-storage path goes through here.
*/
export async function loadActiveVersion(
documentId: string,
db: Supa,
versionId?: string | null,
): Promise<ActiveVersion | null> {
const { data: doc } = await db
.from("documents")
.select("current_version_id")
.eq("id", documentId)
.single();
const targetVersionId =
(typeof versionId === "string" && versionId) ||
(doc?.current_version_id as string | undefined) ||
null;
if (!targetVersionId) return null;
const { data: v } = await db
.from("document_versions")
.select(
"id, document_id, storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
)
.eq("id", targetVersionId)
.is("deleted_at", null)
.single();
if (!v || v.document_id !== documentId || !v.storage_path) return null;
return {
id: v.id as string,
storage_path: v.storage_path as string,
pdf_storage_path: (v.pdf_storage_path as string | null) ?? null,
version_number: (v.version_number as number | null) ?? null,
filename: (v.filename 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,
};
}
/**
* For a list of documents, look up the active version for each and merge
* `storage_path` + `pdf_storage_path` onto the row. One round-trip total
* regardless of list size. Documents with no current_version_id retain
* null paths.
*/
export async function attachActiveVersionPaths<T extends VersionPathRow>(
db: Supa,
docs: T[],
): Promise<T[]> {
if (docs.length === 0) return docs;
const versionIds = docs
.map((d) => d.current_version_id)
.filter((id): id is string => typeof id === "string");
if (versionIds.length === 0) {
for (const d of docs) {
d.filename = "Untitled document";
d.storage_path = null;
d.pdf_storage_path = null;
d.file_type = null;
d.size_bytes = null;
d.page_count = null;
}
return docs;
}
const { data: rows } = await db
.from("document_versions")
.select(
"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<
string,
{
storage_path: string | null;
pdf_storage_path: string | 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 {
id: string;
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
filename: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
}[]) {
byId.set(r.id, {
storage_path: r.storage_path ?? null,
pdf_storage_path: r.pdf_storage_path ?? 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) {
const v = d.current_version_id ? byId.get(d.current_version_id) : null;
d.storage_path = v?.storage_path ?? null;
d.pdf_storage_path = v?.pdf_storage_path ?? 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;
}
/**
* Given a list of document rows, attach `latest_version_number` the
* max `version_number` across all assistant_edit rows for that doc, or
* null if none. Mutates rows in place and returns the same reference.
* One extra query regardless of list size.
*/
export async function attachLatestVersionNumbers<T extends DocRow>(
db: Supa,
docs: T[],
): Promise<T[]> {
if (docs.length === 0) return docs;
const ids = docs.map((d) => d.id);
const { data: rows } = await db
.from("document_versions")
.select("document_id, version_number")
.in("document_id", ids)
.eq("source", "assistant_edit")
.is("deleted_at", null)
.not("version_number", "is", null);
const latestByDoc = new Map<string, number>();
for (const r of (rows ?? []) as {
document_id: string;
version_number: number | null;
}[]) {
if (r.version_number == null) continue;
const prev = latestByDoc.get(r.document_id) ?? 0;
if (r.version_number > prev)
latestByDoc.set(r.document_id, r.version_number);
}
for (const d of docs) {
d.latest_version_number = latestByDoc.get(d.id) ?? null;
}
return docs;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
import crypto from "crypto";
/**
* HMAC-signed, non-expiring download tokens.
*
* The token encodes the R2 storage path + filename; the backend route
* `/download/:token` validates the signature and streams the file. This
* gives persistent links safe to store in chat history without signed-URL
* expiry or R2 CORS headaches.
*/
function getSecret(): string {
const secret = process.env.DOWNLOAD_SIGNING_SECRET;
if (!secret) {
throw new Error(
"DOWNLOAD_SIGNING_SECRET must be set. " +
"Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.",
);
}
return secret;
}
function b64urlEncode(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function b64urlDecode(s: string): Buffer {
let t = s.replace(/-/g, "+").replace(/_/g, "/");
while (t.length % 4) t += "=";
return Buffer.from(t, "base64");
}
function timingSafeEqStr(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export function signDownload(path: string, filename: string): string {
const payload = JSON.stringify({ p: path, f: filename });
const enc = b64urlEncode(Buffer.from(payload, "utf8"));
const sig = crypto
.createHmac("sha256", getSecret())
.update(enc)
.digest();
return `${enc}.${b64urlEncode(sig)}`;
}
export function verifyDownload(
token: string,
): { path: string; filename: string } | null {
const parts = token.split(".");
if (parts.length !== 2) return null;
const [enc, sigEnc] = parts;
const expected = crypto
.createHmac("sha256", getSecret())
.update(enc)
.digest();
if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null;
try {
const parsed = JSON.parse(b64urlDecode(enc).toString("utf8")) as {
p: string;
f: string;
};
if (!parsed?.p || !parsed?.f) return null;
return { path: parsed.p, filename: parsed.f };
} catch {
return null;
}
}
/**
* Returns a relative download URL (e.g. "/download/abc.def"). The frontend
* prefixes it with NEXT_PUBLIC_API_BASE_URL when rendering `<a href=…>`.
*/
export function buildDownloadUrl(path: string, filename: string): string {
return `/download/${signDownload(path, filename)}`;
}

View file

@ -0,0 +1,295 @@
import Anthropic from "@anthropic-ai/sdk";
import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages";
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
NormalizedToolResult,
} from "./types";
import { toClaudeTools } from "./tools";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: string; [key: string]: unknown };
type NativeMessage = {
role: "user" | "assistant";
content: string | ContentBlock[];
};
const MAX_TOKENS = 16384;
function apiKey(override?: string | null): string {
const key = override?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Anthropic API key is not configured. Set ANTHROPIC_API_KEY or add a user Anthropic key.",
);
}
return key;
}
function client(override?: string | null): Anthropic {
const apiKeyValue = apiKey(override);
return new Anthropic({ apiKey: apiKeyValue });
}
function toNativeMessages(
messages: StreamChatParams["messages"],
): NativeMessage[] {
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(
params: StreamChatParams,
): Promise<StreamChatResult> {
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const anthropic = client(apiKeys?.claude);
const claudeTools = toClaudeTools(tools);
const messages: NativeMessage[] = toNativeMessages(params.messages);
let fullText = "";
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "claude",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const stream = anthropic.messages.stream({
model,
system: systemPrompt,
messages: messages as Anthropic.MessageParam[],
tools: claudeTools.length
? (claudeTools as unknown as Tool[])
: undefined,
max_tokens: MAX_TOKENS,
// Claude 4.x models require `thinking.type: "adaptive"` and
// drive effort via `output_config.effort` rather than a fixed
// token budget. We only opt in when the caller requested it.
...(enableThinking
? ({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
} as unknown as Record<string, unknown>)
: {}),
// Extended thinking requires temperature to be default (omitted).
});
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) => {
callbacks.onContentDelta?.(delta);
});
if (enableThinking) {
stream.on("thinking", (delta) => {
sawThinking = true;
callbacks.onReasoningDelta?.(delta);
});
}
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?.();
throwIfAborted(params.abortSignal);
const stopReason = final.stop_reason;
const assistantBlocks = final.content as ContentBlock[];
// Extract text content and tool_use calls from the final assistant
// message so we can accumulate text and drive the tool-call loop.
const toolCalls: NormalizedToolCall[] = [];
for (const block of assistantBlocks) {
if (block.type === "text") {
const txt = (block as { text: string }).text;
if (typeof txt === "string") fullText += txt;
} else if (block.type === "tool_use") {
const tu = block as {
id: string;
name: string;
input: unknown;
};
const call: NormalizedToolCall = {
id: tu.id,
name: tu.name,
input: (tu.input as Record<string, unknown>) ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
if (stopReason !== "tool_use" || !toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Record the assistant turn (preserving the original content blocks,
// which Claude requires on the follow-up) and the user turn that
// carries the tool_result blocks.
messages.push({ role: "assistant", content: assistantBlocks });
messages.push({
role: "user",
content: results.map((r) => ({
type: "tool_result",
tool_use_id: r.tool_use_id,
content: r.content,
})),
});
}
await rawStreamRecorder?.flush("completed");
return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
}
export async function completeClaudeText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { claude?: string | null };
}): Promise<string> {
const anthropic = client(params.apiKeys?.claude);
let resp: Awaited<ReturnType<typeof anthropic.messages.create>>;
try {
resp = await anthropic.messages.create({
model: params.model,
max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt,
messages: [{ role: "user", content: params.user }],
});
} catch (error) {
throw new Error(claudeErrorMessage(error));
}
const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return text;
}
// Helper re-export for callers wanting to hand normalized results back in.
export type { NormalizedToolResult };

View file

@ -0,0 +1,351 @@
import { GoogleGenAI } from "@google/genai";
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
} from "./types";
import { toGeminiTools } from "./tools";
import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog";
type GeminiPart = {
text?: string;
// Set by Gemini when the text content is a thought summary rather than
// final-answer prose. Requires `thinkingConfig.includeThoughts: true`.
thought?: boolean;
functionCall?: { id?: string; name: string; args?: Record<string, unknown> };
functionResponse?: {
id?: string;
name: string;
response: Record<string, unknown>;
};
// Gemini 3 returns a thoughtSignature on parts that contain reasoning or
// a functionCall. It must be echoed back verbatim on the same part when
// we replay the model's turn, or the API rejects the next call.
thoughtSignature?: string;
};
type GeminiContent = {
role: "user" | "model";
parts: GeminiPart[];
};
function apiKey(override?: string | null): string {
const key = override?.trim() || process.env.GEMINI_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Gemini API key is not configured. Set GEMINI_API_KEY or add a user Gemini key.",
);
}
return key;
}
function client(override?: string | null): GoogleGenAI {
return new GoogleGenAI({ apiKey: apiKey(override) });
}
function toNativeContents(
messages: StreamChatParams["messages"],
): GeminiContent[] {
return messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
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(
params: StreamChatParams,
): Promise<StreamChatResult> {
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const ai = client(apiKeys?.gemini);
const functionDeclarations = toGeminiTools(tools);
const contents: GeminiContent[] = toNativeContents(params.messages);
let fullText = "";
const rawStreamRecorder = createRawLlmStreamRecorder({
provider: "gemini",
model,
});
try {
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
let stream: AsyncIterable<unknown>;
try {
stream = await ai.models.generateContentStream({
model,
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
// When enabled, ask Gemini to surface thought summaries.
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
},
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
// Per-iteration accumulators.
const textParts: string[] = [];
const callParts: GeminiPart[] = [];
const toolCalls: NormalizedToolCall[] = [];
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);
const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if (part.thought) {
sawThinking = true;
callbacks.onReasoningDelta?.(part.text);
} else {
textParts.push(part.text);
callbacks.onContentDelta?.(part.text);
}
}
if (part.functionCall) {
// Preserve the whole part (including thoughtSignature)
// so it can be echoed verbatim in the replay turn.
callParts.push(part);
const call: NormalizedToolCall = {
id:
part.functionCall.id ??
`${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name,
input: part.functionCall.args ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
}
} 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?.();
throwIfAborted(params.abortSignal);
fullText += textParts.join("");
if (!toolCalls.length || !runTools) {
break;
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Append the model's turn (text + functionCall parts, in that order)
// and the matching functionResponse turn.
const modelParts: GeminiPart[] = [];
if (textParts.length) modelParts.push({ text: textParts.join("") });
for (const cp of callParts) modelParts.push(cp);
contents.push({ role: "model", parts: modelParts });
contents.push({
role: "user",
parts: results.map((r) => {
const match = toolCalls.find((c) => c.id === r.tool_use_id);
return {
functionResponse: {
...(r.tool_use_id && !r.tool_use_id.startsWith(match?.name ?? "")
? { id: r.tool_use_id }
: {}),
name: match?.name ?? "tool",
response: { output: r.content },
},
};
}),
});
}
await rawStreamRecorder?.flush("completed");
return { fullText };
} catch (error) {
await rawStreamRecorder?.flush("error", error);
throw error;
}
}
export async function completeGeminiText(params: {
model: string;
systemPrompt?: string;
user: string;
apiKeys?: { gemini?: string | null };
}): Promise<string> {
const ai = client(params.apiKeys?.gemini);
let resp: Awaited<ReturnType<typeof ai.models.generateContent>>;
try {
resp = await ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
return resp.text ?? "";
}

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