mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Compare commits
88 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0ff4404b3 | ||
|
|
b2dbb39c62 | ||
|
|
9399fce86d | ||
|
|
7460b065e5 | ||
|
|
e25c2637ec | ||
|
|
6aed350a3c | ||
|
|
71a7aba337 | ||
|
|
2ec89a7c52 | ||
|
|
7d2ba4b87f | ||
|
|
73b9cd1692 | ||
|
|
39dc609f95 | ||
|
|
948e9bdd4d | ||
|
|
eda088e143 | ||
|
|
8a34a6457f | ||
|
|
36cddb2566 | ||
|
|
0843e2909a | ||
|
|
2053ca15c8 | ||
|
|
0740f656e6 | ||
|
|
45a4f7508c | ||
|
|
a29e67deac | ||
|
|
c139acc3c6 | ||
|
|
15b7b4c925 | ||
|
|
b20109ac6c | ||
|
|
4039b94980 | ||
|
|
dafac6b0a4 | ||
|
|
fa21ac8fd7 | ||
|
|
0ed5050b21 | ||
|
|
9eb63ef3fd | ||
|
|
81590ea137 | ||
|
|
f5abbac42c | ||
|
|
1d61634a37 | ||
|
|
f0b90ab3b4 | ||
|
|
cfdcda6d2c | ||
|
|
e32daad5a4 | ||
|
|
82dcaefc43 | ||
|
|
a5fe6d6e04 | ||
|
|
93f921be90 | ||
|
|
457d4a18a4 | ||
|
|
9a1277ba99 | ||
|
|
01dfcfe0d4 | ||
|
|
3132e04ac0 | ||
|
|
8a2dc05181 | ||
|
|
1fa0554ea5 | ||
|
|
a211b92f38 | ||
|
|
444d1d38e4 | ||
|
|
3a10943200 | ||
|
|
15c96b0dd4 | ||
|
|
f32a194b33 | ||
|
|
44e868eb42 | ||
|
|
d39f5806e5 | ||
|
|
4f3384334a | ||
|
|
f656e14ef7 | ||
|
|
2bbb628891 | ||
|
|
ea48cdedd5 | ||
|
|
2f24cae407 | ||
|
|
b685e86028 | ||
|
|
4ba4d53c38 | ||
|
|
9749d601fa | ||
|
|
aed8c42e94 | ||
|
|
b4ba274264 | ||
|
|
4290104cd0 | ||
|
|
87e55d6046 | ||
|
|
9e7046d4aa | ||
|
|
08d996781a | ||
|
|
a2368a7479 | ||
|
|
2e8eafc78e | ||
|
|
f39f175273 | ||
|
|
56c6051f90 | ||
|
|
91d0c2a089 | ||
|
|
469ee4adec | ||
|
|
af5691e773 | ||
|
|
0ac2744a8e | ||
|
|
a84c1cc113 | ||
|
|
dbbf19697e | ||
|
|
029181b2ff | ||
|
|
625bca4d84 | ||
|
|
e261d2e4bd | ||
|
|
f40c25d07f | ||
|
|
bef75b082d | ||
|
|
adc2cf2370 | ||
|
|
1f191fea59 | ||
|
|
e5a3d6f222 | ||
|
|
7f5dd217d7 | ||
|
|
ba6f771144 | ||
|
|
fce2f2d941 | ||
|
|
7062a30039 | ||
|
|
eb4414092e | ||
|
|
d9690965b5 |
358 changed files with 117360 additions and 0 deletions
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal 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
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
112
.github/workflows/ci.yml
vendored
Normal 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
19
.gitignore
vendored
Normal 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
75
CONTRIBUTING.md
Normal 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
661
LICENSE
Normal 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
162
README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
# Mike
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
23
backend/.env.example
Normal 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
8
backend/.gitignore
vendored
Normal 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
889
backend/bun.lock
Normal 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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/migrations/20260419_tabular_chat_jsonb.sql
Normal file
32
backend/migrations/20260419_tabular_chat_jsonb.sql
Normal 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;
|
||||||
52
backend/migrations/20260421_01_docx_editing.sql
Normal file
52
backend/migrations/20260421_01_docx_editing.sql
Normal 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);
|
||||||
9
backend/migrations/20260421_02_user_api_keys.sql
Normal file
9
backend/migrations/20260421_02_user_api_keys.sql
Normal 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;
|
||||||
10
backend/migrations/20260423_01_docx_editing_wids.sql
Normal file
10
backend/migrations/20260423_01_docx_editing_wids.sql
Normal 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;
|
||||||
32
backend/migrations/20260423_02_docx_version_number.sql
Normal file
32
backend/migrations/20260423_02_docx_version_number.sql
Normal 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);
|
||||||
35
backend/migrations/20260424_01_docx_version_display_name.sql
Normal file
35
backend/migrations/20260424_01_docx_version_display_name.sql
Normal 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;
|
||||||
|
|
@ -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 $$;
|
||||||
81
backend/migrations/20260427_01_move_storage_to_versions.sql
Normal file
81
backend/migrations/20260427_01_move_storage_to_versions.sql
Normal 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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
28
backend/migrations/20260428_workflow_shares_unique.sql
Normal file
28
backend/migrations/20260428_workflow_shares_unique.sql
Normal 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 $$;
|
||||||
25
backend/migrations/20260502_secure_user_api_keys.sql
Normal file
25
backend/migrations/20260502_secure_user_api_keys.sql
Normal 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.
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 $$;
|
||||||
26
backend/migrations/20260511_contact_messages.sql
Normal file
26
backend/migrations/20260511_contact_messages.sql
Normal 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;
|
||||||
36
backend/migrations/20260513_projects_shared_with_jsonb.sql
Normal file
36
backend/migrations/20260513_projects_shared_with_jsonb.sql
Normal 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);
|
||||||
12
backend/migrations/20260517_tabular_review_document_ids.sql
Normal file
12
backend/migrations/20260517_tabular_review_document_ids.sql
Normal 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;
|
||||||
41
backend/migrations/20260523_courtlistener_bulk_indexes.sql
Normal file
41
backend/migrations/20260523_courtlistener_bulk_indexes.sql
Normal 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;
|
||||||
|
|
@ -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'));
|
||||||
|
|
@ -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'));
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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) <> '';
|
||||||
|
|
@ -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 $$;
|
||||||
27
backend/migrations/20260602_04_drop_documents_filename.sql
Normal file
27
backend/migrations/20260602_04_drop_documents_filename.sql
Normal 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 $$;
|
||||||
12
backend/migrations/20260603_drop_structure_tree.sql
Normal file
12
backend/migrations/20260603_drop_structure_tree.sql
Normal 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;
|
||||||
192
backend/migrations/20260606_oss_schema_diff.sql
Normal file
192
backend/migrations/20260606_oss_schema_diff.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
39
backend/migrations/20260613_01_chats_overview_rpc.sql
Normal file
39
backend/migrations/20260613_01_chats_overview_rpc.sql
Normal 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;
|
||||||
|
$$;
|
||||||
81
backend/migrations/20260613_02_projects_overview_rpc.sql
Normal file
81
backend/migrations/20260613_02_projects_overview_rpc.sql
Normal 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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
92
backend/migrations/20260613_04_user_mcp_connectors.sql
Normal file
92
backend/migrations/20260613_04_user_mcp_connectors.sql
Normal 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;
|
||||||
90
backend/migrations/20260613_05_workflows_overview_rpc.sql
Normal file
90
backend/migrations/20260613_05_workflows_overview_rpc.sql
Normal 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;
|
||||||
|
$$;
|
||||||
42
backend/migrations/20260615_01_mcp_connector_oauth.sql
Normal file
42
backend/migrations/20260615_01_mcp_connector_oauth.sql
Normal 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;
|
||||||
120
backend/migrations/20260625_01_workflow_metadata.sql
Normal file
120
backend/migrations/20260625_01_workflow_metadata.sql
Normal 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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
41
backend/migrations/20260703_01_user_profile_email.sql
Normal file
41
backend/migrations/20260703_01_user_profile_email.sql
Normal 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;
|
||||||
|
$$;
|
||||||
84
backend/migrations/20260703_02_project_practice.sql
Normal file
84
backend/migrations/20260703_02_project_practice.sql
Normal 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;
|
||||||
|
$$;
|
||||||
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal file
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal 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 $$;
|
||||||
71
backend/migrations/20260710_01_library_documents.sql
Normal file
71
backend/migrations/20260710_01_library_documents.sql
Normal 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;
|
||||||
12
backend/migrations/20260716_01_chat_messages_workflow.sql
Normal file
12
backend/migrations/20260716_01_chat_messages_workflow.sql
Normal 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
2
backend/nixpacks.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[phases.setup]
|
||||||
|
nixPkgs = ["...", "libreoffice"]
|
||||||
7299
backend/package-lock.json
generated
Normal file
7299
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
51
backend/package.json
Normal file
51
backend/package.json
Normal 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
898
backend/schema.sql
Normal 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
63
backend/scripts/test-stack.sh
Executable 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 \
|
||||||
|
"$@"
|
||||||
97
backend/src/__tests__/integration/access.supabase.test.ts
Normal file
97
backend/src/__tests__/integration/access.supabase.test.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal file
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal file
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
80
backend/src/__tests__/integration/health.test.ts
Normal file
80
backend/src/__tests__/integration/health.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal file
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal file
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal file
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal file
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal 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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
588
backend/src/__tests__/integration/user.routes.test.ts
Normal file
588
backend/src/__tests__/integration/user.routes.test.ts
Normal 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
161
backend/src/app.ts
Normal 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
7
backend/src/index.ts
Normal 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}`);
|
||||||
|
});
|
||||||
163
backend/src/lib/__tests__/access.test.ts
Normal file
163
backend/src/lib/__tests__/access.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal file
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
397
backend/src/lib/__tests__/citations.test.ts
Normal file
397
backend/src/lib/__tests__/citations.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
315
backend/src/lib/__tests__/documentVersions.test.ts
Normal file
315
backend/src/lib/__tests__/documentVersions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
109
backend/src/lib/__tests__/downloadTokens.test.ts
Normal file
109
backend/src/lib/__tests__/downloadTokens.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
backend/src/lib/__tests__/llmModels.test.ts
Normal file
119
backend/src/lib/__tests__/llmModels.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
154
backend/src/lib/__tests__/safeError.test.ts
Normal file
154
backend/src/lib/__tests__/safeError.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
149
backend/src/lib/__tests__/storage.test.ts
Normal file
149
backend/src/lib/__tests__/storage.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal file
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
432
backend/src/lib/__tests__/userDataCleanup.test.ts
Normal file
432
backend/src/lib/__tests__/userDataCleanup.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
backend/src/lib/__tests__/userLookup.test.ts
Normal file
267
backend/src/lib/__tests__/userLookup.test.ts
Normal 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
188
backend/src/lib/access.ts
Normal 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];
|
||||||
|
}
|
||||||
310
backend/src/lib/chat/citations.ts
Normal file
310
backend/src/lib/chat/citations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
583
backend/src/lib/chat/contextBuilders.ts
Normal file
583
backend/src/lib/chat/contextBuilders.ts
Normal 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;
|
||||||
|
}
|
||||||
8
backend/src/lib/chat/index.ts
Normal file
8
backend/src/lib/chat/index.ts
Normal 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";
|
||||||
88
backend/src/lib/chat/prompts.ts
Normal file
88
backend/src/lib/chat/prompts.ts
Normal 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);
|
||||||
553
backend/src/lib/chat/streaming.ts
Normal file
553
backend/src/lib/chat/streaming.ts
Normal 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 };
|
||||||
|
}
|
||||||
197
backend/src/lib/chat/tools/courtlistenerTools.ts
Normal file
197
backend/src/lib/chat/tools/courtlistenerTools.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
1813
backend/src/lib/chat/tools/documentOps.ts
Normal file
1813
backend/src/lib/chat/tools/documentOps.ts
Normal file
File diff suppressed because it is too large
Load diff
1896
backend/src/lib/chat/tools/toolDispatcher.ts
Normal file
1896
backend/src/lib/chat/tools/toolDispatcher.ts
Normal file
File diff suppressed because it is too large
Load diff
471
backend/src/lib/chat/tools/toolSchemas.ts
Normal file
471
backend/src/lib/chat/tools/toolSchemas.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
153
backend/src/lib/chat/types.ts
Normal file
153
backend/src/lib/chat/types.ts
Normal 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
138
backend/src/lib/convert.ts
Normal 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`;
|
||||||
|
}
|
||||||
1189
backend/src/lib/courtlistener.ts
Normal file
1189
backend/src/lib/courtlistener.ts
Normal file
File diff suppressed because it is too large
Load diff
60
backend/src/lib/documentTypes.ts
Normal file
60
backend/src/lib/documentTypes.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
196
backend/src/lib/documentVersions.ts
Normal file
196
backend/src/lib/documentVersions.ts
Normal 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;
|
||||||
|
}
|
||||||
1178
backend/src/lib/docxTrackedChanges.ts
Normal file
1178
backend/src/lib/docxTrackedChanges.ts
Normal file
File diff suppressed because it is too large
Load diff
81
backend/src/lib/downloadTokens.ts
Normal file
81
backend/src/lib/downloadTokens.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
295
backend/src/lib/llm/claude.ts
Normal file
295
backend/src/lib/llm/claude.ts
Normal 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 };
|
||||||
351
backend/src/lib/llm/gemini.ts
Normal file
351
backend/src/lib/llm/gemini.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue