diff --git a/README.md b/README.md index 37cd725..aa673cb 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Open-source release containing the Mike frontend and backend. ## Contents - `frontend/` - Next.js application -- `backend/` - Express API, Supabase access, document processing, and migrations -- `backend/migrations/000_one_shot_schema.sql` - one-shot Supabase schema for fresh databases +- `backend/` - Express API, Supabase access, document processing, and database schema +- `backend/schema.sql` - Supabase schema for fresh databases ## Setup @@ -24,7 +24,7 @@ cp backend/.env.example backend/.env cp frontend/.env.local.example frontend/.env.local ``` -Run `backend/migrations/000_one_shot_schema.sql` in the Supabase SQL editor for a fresh database. +Run `backend/schema.sql` in the Supabase SQL editor for a fresh database. Start the backend: diff --git a/backend/.gitignore b/backend/.gitignore index bcbd2ce..12bb4bb 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,5 @@ node_modules dist .env* -!.env.example *.log .DS_Store diff --git a/backend/bun.lock b/backend/bun.lock index eb64b68..90061e1 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -14,8 +14,10 @@ "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", @@ -471,6 +473,8 @@ "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@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -517,6 +521,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="], + "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=="], @@ -533,6 +539,8 @@ "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=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql deleted file mode 100644 index 80d563a..0000000 --- a/backend/migrations/000_one_shot_schema.sql +++ /dev/null @@ -1,340 +0,0 @@ --- Mike one-shot Supabase schema --- Based on supabase-migration.sql plus the later backend/migrations/*.sql files. --- Use this for a fresh Supabase database. Existing deployments should continue --- to apply the incremental migration files instead. - -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, - 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'), - tabular_model text not null default 'gemini-3-flash-preview', - claude_api_key text, - gemini_api_key text, - 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); - -alter table public.user_profiles enable row level security; - -drop policy if exists "Users can view their own profile" on public.user_profiles; -create policy "Users can view their own profile" - on public.user_profiles for select - using (auth.uid() = user_id); - -drop policy if exists "Users can update their own profile" on public.user_profiles; -create policy "Users can update their own profile" - on public.user_profiles for update - using (auth.uid() = user_id); - -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) - values (new.id) - on conflict (user_id) do nothing; - 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(); - --- --------------------------------------------------------------------------- --- 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, - 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.documents ( - id uuid primary key default gen_random_uuid(), - project_id uuid references public.projects(id) on delete cascade, - user_id text not null, - filename text not null, - file_type text, - size_bytes integer not null default 0, - page_count integer, - structure_tree jsonb, - status text not null default 'pending', - folder_id uuid references public.project_subfolders(id) on delete set null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -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 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 not null, - pdf_storage_path text, - source text not null default 'upload', - version_number integer, - display_name text, - 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_doc_vnum_idx - on public.document_versions(document_id, version_number); - -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, - practice text, - is_system boolean not null default false, - 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); - --- --------------------------------------------------------------------------- --- 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 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, - annotations 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, - 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 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 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); diff --git a/backend/package-lock.json b/backend/package-lock.json index 86f8238..effa2ad 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "mike-backend", "version": "1.0.0", - "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.787.0", @@ -18,8 +17,10 @@ "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", @@ -3351,6 +3352,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3650,6 +3669,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -3774,6 +3802,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index 50dfb58..8451ab8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,6 @@ { "name": "mike-backend", "version": "1.0.0", - "license": "AGPL-3.0-only", "private": true, "scripts": { "dev": "tsx watch src/index.ts", @@ -18,8 +17,10 @@ "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", @@ -35,5 +36,6 @@ "prettier": "^3.8.1", "tsx": "^4.19.3", "typescript": "^5.8.3" - } + }, + "license": "AGPL-3.0-only" } diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..cf72870 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,1046 @@ +-- Mike Supabase schema +-- Based on supabase-migration.sql plus the later backend/migrations/*.sql files. +-- Use this for a fresh Supabase database. Existing deployments should continue +-- to apply the incremental migration files instead. + +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, + 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'), + tabular_model text not null default 'gemini-3-flash-preview', + 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); + +alter table public.user_profiles enable row level security; + +drop policy if exists "Users can view their own profile" on public.user_profiles; +create policy "Users can view their own profile" + on public.user_profiles for select + using (auth.uid() = user_id); + +drop policy if exists "Users can update their own profile" on public.user_profiles; +create policy "Users can update their own profile" + on public.user_profiles for update + using (auth.uid() = user_id); + +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) + values (new.id) + on conflict (user_id) do nothing; + 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')), + 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; + +-- --------------------------------------------------------------------------- +-- 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, + 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.documents ( + id uuid primary key default gen_random_uuid(), + project_id uuid references public.projects(id) on delete cascade, + user_id text not null, + filename text not null, + file_type text, + size_bytes integer not null default 0, + page_count integer, + structure_tree jsonb, + status text not null default 'pending', + folder_id uuid references public.project_subfolders(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +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 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 not null, + pdf_storage_path text, + source text not null default 'upload', + version_number integer, + display_name text, + 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_doc_vnum_idx + on public.document_versions(document_id, version_number); + +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, + practice text, + is_system boolean not null default false, + 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); + +-- --------------------------------------------------------------------------- +-- 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 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, + annotations 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, + 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 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 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); + +-- --------------------------------------------------------------------------- +-- Row-level security +-- --------------------------------------------------------------------------- + +create or replace function public.current_user_id_text() +returns text +language sql +stable +set search_path = public, auth +as $$ + select auth.uid()::text; +$$; + +create or replace function public.current_user_email() +returns text +language sql +stable +set search_path = public, auth +as $$ + select lower(coalesce(auth.jwt() ->> 'email', '')); +$$; + +create or replace function public.email_is_shared(shared_with jsonb) +returns boolean +language sql +stable +set search_path = public +as $$ + select public.current_user_email() <> '' + and exists ( + select 1 + from jsonb_array_elements_text(coalesce(shared_with, '[]'::jsonb)) as emails(email) + where lower(emails.email) = public.current_user_email() + ); +$$; + +create or replace function public.project_is_accessible(target_project_id uuid) +returns boolean +language sql +stable +security definer +set search_path = public, auth +as $$ + select exists ( + select 1 + from public.projects p + where p.id = target_project_id + and ( + p.user_id = public.current_user_id_text() + or public.email_is_shared(p.shared_with) + ) + ); +$$; + +create or replace function public.review_is_accessible(target_review_id uuid) +returns boolean +language sql +stable +security definer +set search_path = public, auth +as $$ + select exists ( + select 1 + from public.tabular_reviews r + where r.id = target_review_id + and ( + r.user_id = public.current_user_id_text() + or public.email_is_shared(r.shared_with) + or ( + r.project_id is not null + and public.project_is_accessible(r.project_id) + ) + ) + ); +$$; + +create or replace function public.workflow_can_view(target_workflow_id uuid) +returns boolean +language sql +stable +security definer +set search_path = public, auth +as $$ + select exists ( + select 1 + from public.workflows w + where w.id = target_workflow_id + and ( + w.is_system + or w.user_id = public.current_user_id_text() + or exists ( + select 1 + from public.workflow_shares s + where s.workflow_id = w.id + and s.shared_with_email = public.current_user_email() + ) + ) + ); +$$; + +create or replace function public.workflow_can_edit(target_workflow_id uuid) +returns boolean +language sql +stable +security definer +set search_path = public, auth +as $$ + select exists ( + select 1 + from public.workflows w + where w.id = target_workflow_id + and ( + w.user_id = public.current_user_id_text() + or exists ( + select 1 + from public.workflow_shares s + where s.workflow_id = w.id + and s.shared_with_email = public.current_user_email() + and s.allow_edit + ) + ) + ); +$$; + +alter table public.user_profiles enable row level security; +alter table public.user_api_keys enable row level security; +alter table public.projects enable row level security; +alter table public.project_subfolders enable row level security; +alter table public.documents enable row level security; +alter table public.document_versions enable row level security; +alter table public.document_edits enable row level security; +alter table public.workflows enable row level security; +alter table public.hidden_workflows enable row level security; +alter table public.workflow_shares enable row level security; +alter table public.chats enable row level security; +alter table public.chat_messages enable row level security; +alter table public.tabular_reviews enable row level security; +alter table public.tabular_cells enable row level security; +alter table public.tabular_review_chats enable row level security; +alter table public.tabular_review_chat_messages enable row level security; + +drop policy if exists "Users can insert their own profile" on public.user_profiles; +create policy "Users can insert their own profile" + on public.user_profiles for insert + with check (auth.uid() = user_id); + +drop policy if exists "Users can view their own profile" on public.user_profiles; +create policy "Users can view their own profile" + on public.user_profiles for select + using (auth.uid() = user_id); + +drop policy if exists "Users can update their own profile" on public.user_profiles; +create policy "Users can update their own profile" + on public.user_profiles for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +-- user_api_keys is intentionally service-role only. The browser can only see +-- key status through backend routes, never encrypted key material. + +drop policy if exists "Users can view accessible projects" on public.projects; +create policy "Users can view accessible projects" + on public.projects for select + using ( + user_id = public.current_user_id_text() + or public.email_is_shared(shared_with) + ); + +drop policy if exists "Users can insert their own projects" on public.projects; +create policy "Users can insert their own projects" + on public.projects for insert + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Owners can update projects" on public.projects; +create policy "Owners can update projects" + on public.projects for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Owners can delete projects" on public.projects; +create policy "Owners can delete projects" + on public.projects for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible project folders" on public.project_subfolders; +create policy "Users can view accessible project folders" + on public.project_subfolders for select + using ( + user_id = public.current_user_id_text() + or public.project_is_accessible(project_id) + ); + +drop policy if exists "Users can insert their own project folders" on public.project_subfolders; +create policy "Users can insert their own project folders" + on public.project_subfolders for insert + with check ( + user_id = public.current_user_id_text() + and public.project_is_accessible(project_id) + ); + +drop policy if exists "Owners can update project folders" on public.project_subfolders; +create policy "Owners can update project folders" + on public.project_subfolders for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Owners can delete project folders" on public.project_subfolders; +create policy "Owners can delete project folders" + on public.project_subfolders for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible documents" on public.documents; +create policy "Users can view accessible documents" + on public.documents for select + using ( + user_id = public.current_user_id_text() + or ( + project_id is not null + and public.project_is_accessible(project_id) + ) + ); + +drop policy if exists "Users can insert their own documents" on public.documents; +create policy "Users can insert their own documents" + on public.documents for insert + with check ( + user_id = public.current_user_id_text() + and ( + project_id is null + or public.project_is_accessible(project_id) + ) + ); + +drop policy if exists "Owners can update documents" on public.documents; +create policy "Owners can update documents" + on public.documents for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Owners can delete documents" on public.documents; +create policy "Owners can delete documents" + on public.documents for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible document versions" on public.document_versions; +create policy "Users can view accessible document versions" + on public.document_versions for select + using ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and ( + d.user_id = public.current_user_id_text() + or ( + d.project_id is not null + and public.project_is_accessible(d.project_id) + ) + ) + ) + ); + +drop policy if exists "Document owners can insert versions" on public.document_versions; +create policy "Document owners can insert versions" + on public.document_versions for insert + with check ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Document owners can update versions" on public.document_versions; +create policy "Document owners can update versions" + on public.document_versions for update + using ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ) + with check ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Document owners can delete versions" on public.document_versions; +create policy "Document owners can delete versions" + on public.document_versions for delete + using ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Users can view accessible document edits" on public.document_edits; +create policy "Users can view accessible document edits" + on public.document_edits for select + using ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and ( + d.user_id = public.current_user_id_text() + or ( + d.project_id is not null + and public.project_is_accessible(d.project_id) + ) + ) + ) + ); + +drop policy if exists "Document owners can insert edits" on public.document_edits; +create policy "Document owners can insert edits" + on public.document_edits for insert + with check ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Document owners can update edits" on public.document_edits; +create policy "Document owners can update edits" + on public.document_edits for update + using ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ) + with check ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Document owners can delete edits" on public.document_edits; +create policy "Document owners can delete edits" + on public.document_edits for delete + using ( + exists ( + select 1 + from public.documents d + where d.id = document_id + and d.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Users can view accessible workflows" on public.workflows; +create policy "Users can view accessible workflows" + on public.workflows for select + using (public.workflow_can_view(id)); + +drop policy if exists "Users can insert their own workflows" on public.workflows; +create policy "Users can insert their own workflows" + on public.workflows for insert + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Workflow owners can update workflows" on public.workflows; +create policy "Workflow owners can update workflows" + on public.workflows for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Workflow owners can delete workflows" on public.workflows; +create policy "Workflow owners can delete workflows" + on public.workflows for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can manage their hidden workflows" on public.hidden_workflows; +create policy "Users can manage their hidden workflows" + on public.hidden_workflows for all + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view relevant workflow shares" on public.workflow_shares; +create policy "Users can view relevant workflow shares" + on public.workflow_shares for select + using ( + shared_by_user_id = public.current_user_id_text() + or shared_with_email = public.current_user_email() + ); + +drop policy if exists "Workflow owners can insert shares" on public.workflow_shares; +create policy "Workflow owners can insert shares" + on public.workflow_shares for insert + with check ( + shared_by_user_id = public.current_user_id_text() + and exists ( + select 1 + from public.workflows w + where w.id = workflow_id + and w.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Workflow owners can update shares" on public.workflow_shares; +create policy "Workflow owners can update shares" + on public.workflow_shares for update + using (shared_by_user_id = public.current_user_id_text()) + with check (shared_by_user_id = public.current_user_id_text()); + +drop policy if exists "Workflow owners can delete shares" on public.workflow_shares; +create policy "Workflow owners can delete shares" + on public.workflow_shares for delete + using (shared_by_user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible chats" on public.chats; +create policy "Users can view accessible chats" + on public.chats for select + using ( + user_id = public.current_user_id_text() + or ( + project_id is not null + and public.project_is_accessible(project_id) + ) + ); + +drop policy if exists "Users can insert their own chats" on public.chats; +create policy "Users can insert their own chats" + on public.chats for insert + with check ( + user_id = public.current_user_id_text() + and ( + project_id is null + or public.project_is_accessible(project_id) + ) + ); + +drop policy if exists "Chat owners can update chats" on public.chats; +create policy "Chat owners can update chats" + on public.chats for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Chat owners can delete chats" on public.chats; +create policy "Chat owners can delete chats" + on public.chats for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible chat messages" on public.chat_messages; +create policy "Users can view accessible chat messages" + on public.chat_messages for select + using ( + exists ( + select 1 + from public.chats c + where c.id = chat_id + and ( + c.user_id = public.current_user_id_text() + or ( + c.project_id is not null + and public.project_is_accessible(c.project_id) + ) + ) + ) + ); + +drop policy if exists "Chat owners can insert messages" on public.chat_messages; +create policy "Chat owners can insert messages" + on public.chat_messages for insert + with check ( + exists ( + select 1 + from public.chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Chat owners can update messages" on public.chat_messages; +create policy "Chat owners can update messages" + on public.chat_messages for update + using ( + exists ( + select 1 + from public.chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ) + with check ( + exists ( + select 1 + from public.chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Chat owners can delete messages" on public.chat_messages; +create policy "Chat owners can delete messages" + on public.chat_messages for delete + using ( + exists ( + select 1 + from public.chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Users can view accessible tabular reviews" on public.tabular_reviews; +create policy "Users can view accessible tabular reviews" + on public.tabular_reviews for select + using ( + user_id = public.current_user_id_text() + or public.email_is_shared(shared_with) + or ( + project_id is not null + and public.project_is_accessible(project_id) + ) + ); + +drop policy if exists "Users can insert their own tabular reviews" on public.tabular_reviews; +create policy "Users can insert their own tabular reviews" + on public.tabular_reviews for insert + with check ( + user_id = public.current_user_id_text() + and ( + project_id is null + or public.project_is_accessible(project_id) + ) + ); + +drop policy if exists "Review owners can update tabular reviews" on public.tabular_reviews; +create policy "Review owners can update tabular reviews" + on public.tabular_reviews for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Review owners can delete tabular reviews" on public.tabular_reviews; +create policy "Review owners can delete tabular reviews" + on public.tabular_reviews for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible tabular cells" on public.tabular_cells; +create policy "Users can view accessible tabular cells" + on public.tabular_cells for select + using (public.review_is_accessible(review_id)); + +drop policy if exists "Review owners can insert tabular cells" on public.tabular_cells; +create policy "Review owners can insert tabular cells" + on public.tabular_cells for insert + with check ( + exists ( + select 1 + from public.tabular_reviews r + where r.id = review_id + and r.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Review owners can update tabular cells" on public.tabular_cells; +create policy "Review owners can update tabular cells" + on public.tabular_cells for update + using ( + exists ( + select 1 + from public.tabular_reviews r + where r.id = review_id + and r.user_id = public.current_user_id_text() + ) + ) + with check ( + exists ( + select 1 + from public.tabular_reviews r + where r.id = review_id + and r.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Review owners can delete tabular cells" on public.tabular_cells; +create policy "Review owners can delete tabular cells" + on public.tabular_cells for delete + using ( + exists ( + select 1 + from public.tabular_reviews r + where r.id = review_id + and r.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Users can view accessible tabular review chats" on public.tabular_review_chats; +create policy "Users can view accessible tabular review chats" + on public.tabular_review_chats for select + using ( + user_id = public.current_user_id_text() + or public.review_is_accessible(review_id) + ); + +drop policy if exists "Users can insert their own tabular review chats" on public.tabular_review_chats; +create policy "Users can insert their own tabular review chats" + on public.tabular_review_chats for insert + with check ( + user_id = public.current_user_id_text() + and public.review_is_accessible(review_id) + ); + +drop policy if exists "Tabular chat owners can update chats" on public.tabular_review_chats; +create policy "Tabular chat owners can update chats" + on public.tabular_review_chats for update + using (user_id = public.current_user_id_text()) + with check (user_id = public.current_user_id_text()); + +drop policy if exists "Tabular chat owners can delete chats" on public.tabular_review_chats; +create policy "Tabular chat owners can delete chats" + on public.tabular_review_chats for delete + using (user_id = public.current_user_id_text()); + +drop policy if exists "Users can view accessible tabular chat messages" on public.tabular_review_chat_messages; +create policy "Users can view accessible tabular chat messages" + on public.tabular_review_chat_messages for select + using ( + exists ( + select 1 + from public.tabular_review_chats c + where c.id = chat_id + and ( + c.user_id = public.current_user_id_text() + or public.review_is_accessible(c.review_id) + ) + ) + ); + +drop policy if exists "Tabular chat owners can insert messages" on public.tabular_review_chat_messages; +create policy "Tabular chat owners can insert messages" + on public.tabular_review_chat_messages for insert + with check ( + exists ( + select 1 + from public.tabular_review_chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Tabular chat owners can update messages" on public.tabular_review_chat_messages; +create policy "Tabular chat owners can update messages" + on public.tabular_review_chat_messages for update + using ( + exists ( + select 1 + from public.tabular_review_chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ) + with check ( + exists ( + select 1 + from public.tabular_review_chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ); + +drop policy if exists "Tabular chat owners can delete messages" on public.tabular_review_chat_messages; +create policy "Tabular chat owners can delete messages" + on public.tabular_review_chat_messages for delete + using ( + exists ( + select 1 + from public.tabular_review_chats c + where c.id = chat_id + and c.user_id = public.current_user_id_text() + ) + ); diff --git a/backend/src/index.ts b/backend/src/index.ts index 0e99fff..07b3b84 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,8 @@ 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"; @@ -12,6 +14,79 @@ import { downloadsRouter } from "./routes/downloads"; const app = express(); const PORT = process.env.PORT ?? 3001; +const isProduction = process.env.NODE_ENV === "production"; + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function minutes(value: number): number { + return value * 60 * 1000; +} + +function hours(value: number): number { + return minutes(value * 60); +} + +function makeLimiter(options: { + windowMs: number; + max: number; + message?: string; +}) { + return rateLimit({ + windowMs: options.windowMs, + max: options.max, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.method === "OPTIONS", + message: { + detail: + options.message ?? "Too many requests. Please try again later.", + }, + }); +} + +const generalLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_GENERAL_MAX", 300), +}); + +const chatLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_MAX", 30), + message: "Too many chat requests. Please try again later.", +}); + +const chatCreateLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), +}); + +const uploadLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), + message: "Too many upload requests. Please try again later.", +}); + +app.disable("x-powered-by"); +app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); + +app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + hsts: isProduction + ? { + maxAge: 15552000, + includeSubDomains: true, + } + : false, + referrerPolicy: { policy: "no-referrer" }, + }), +); app.use( cors({ @@ -20,8 +95,20 @@ app.use( }), ); +app.use(generalLimiter); + app.use(express.json({ limit: "50mb" })); +app.post("/chat", chatLimiter); +app.post("/projects/:projectId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/generate", chatLimiter); +app.post("/chat/create", chatCreateLimiter); +app.post("/chat/:chatId/generate-title", chatCreateLimiter); +app.post("/single-documents", uploadLimiter); +app.post("/single-documents/:documentId/versions", uploadLimiter); +app.post("/projects/:projectId/documents", uploadLimiter); + app.use("/chat", chatRouter); app.use("/projects", projectsRouter); app.use("/projects/:projectId/chat", projectChatRouter); diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index c3ab243..d2a2491 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -13,7 +13,10 @@ import { type EditInput, } from "./docxTrackedChanges"; import { buildDownloadUrl } from "./downloadTokens"; -import { attachActiveVersionPaths, loadActiveVersion } from "./documentVersions"; +import { + attachActiveVersionPaths, + loadActiveVersion, +} from "./documentVersions"; import { streamChatWithTools, resolveModel, @@ -56,7 +59,10 @@ export type TabularCellStore = { columns: { index: number; name: string }[]; documents: { id: string; filename: string }[]; /** key: `${colIndex}:${docId}` */ - cells: Map; + cells: Map< + string, + { summary: string; flag?: string; reasoning?: string } | null + >; }; export type ToolCall = { @@ -320,25 +326,43 @@ export const TOOLS = [ properties: { title: { type: "string", - description: "Document title (used as filename and heading)", + description: + "Document title (used as filename and heading)", }, landscape: { type: "boolean", - description: "Set to true for landscape page orientation. Default is portrait.", + 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.", + 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." }, + 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", + description: + "Optional table to render in this section", properties: { headers: { type: "array", @@ -351,7 +375,8 @@ export const TOOLS = [ type: "array", items: { type: "string" }, }, - description: "Array of rows, each row is an array of cell strings matching the headers order", + description: + "Array of rows, each row is an array of cell strings matching the headers order", }, }, required: ["headers", "rows"], @@ -390,22 +415,31 @@ export const TOOLS = [ }, replace: { type: "string", - description: "Replacement text. Empty string = pure deletion.", + description: + "Replacement text. Empty string = pure deletion.", }, context_before: { type: "string", - description: "~40 chars immediately preceding `find`, used to disambiguate.", + description: + "~40 chars immediately preceding `find`, used to disambiguate.", }, context_after: { type: "string", - description: "~40 chars immediately following `find`.", + description: + "~40 chars immediately following `find`.", }, reason: { type: "string", - description: "Short explanation shown to the user on the card.", + description: + "Short explanation shown to the user on the card.", }, }, - required: ["find", "replace", "context_before", "context_after"], + required: [ + "find", + "replace", + "context_before", + "context_after", + ], }, }, }, @@ -578,7 +612,11 @@ export async function enrichWithPriorEvents( export function buildMessages( messages: ChatMessage[], - docAvailability: { doc_id: string; filename: string; folder_path?: string }[], + docAvailability: { + doc_id: string; + filename: string; + folder_path?: string; + }[], systemPromptExtra?: string, docIndex?: DocIndex, ) { @@ -592,7 +630,9 @@ export function buildMessages( 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; + const label = doc.folder_path + ? `${doc.folder_path} / ${doc.filename}` + : doc.filename; systemContent += `- ${doc.doc_id}: ${label}\n`; } systemContent += @@ -620,9 +660,7 @@ export function buildMessages( const slug = f.document_id ? slugByDocumentId.get(f.document_id) : undefined; - return slug - ? `- ${slug}: ${f.filename}` - : `- ${f.filename}`; + 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}`; } @@ -676,30 +714,50 @@ export async function generateDocx( ) { try { const { - Document, Paragraph, HeadingLevel, Packer, - Table, TableRow, TableCell, WidthType, BorderStyle, - TextRun, AlignmentType, PageOrientation, PageBreak, + Document, + Paragraph, + HeadingLevel, + Packer, + Table, + TableRow, + TableCell, + WidthType, + BorderStyle, + TextRun, + AlignmentType, + PageOrientation, + PageBreak, } = await import("docx"); const FONT = "Times New Roman"; const SIZE = 22; // 11pt in half-points - type DocChild = InstanceType | InstanceType; + type DocChild = + | InstanceType + | InstanceType; const children: DocChild[] = []; children.push( new Paragraph({ heading: HeadingLevel.TITLE, spacing: { after: 200 }, alignment: AlignmentType.CENTER, - children: [new TextRun({ text: title.toUpperCase(), color: "000000", font: FONT, size: SIZE, bold: true })], + children: [ + new TextRun({ + text: title.toUpperCase(), + color: "000000", + font: FONT, + size: SIZE, + bold: true, + }), + ], }), ); const cellBorder = { - top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, - right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, + right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }, }; const headingLevels = [ @@ -718,9 +776,7 @@ export async function generateDocx( table?: { headers: string[]; rows: string[][] }; }[]) { if (section.pageBreak) { - children.push( - new Paragraph({ children: [new PageBreak()] }), - ); + children.push(new Paragraph({ children: [new PageBreak()] })); } if (section.heading) { const idx = Math.min((section.level ?? 1) - 1, 3); @@ -732,7 +788,15 @@ export async function generateDocx( new Paragraph({ heading: headingLevels[idx], spacing: { after: 160 }, - children: [new TextRun({ text: headingText, color: "000000", font: FONT, size: SIZE, bold: true })], + children: [ + new TextRun({ + text: headingText, + color: "000000", + font: FONT, + size: SIZE, + bold: true, + }), + ], }), ); } @@ -751,7 +815,14 @@ export async function generateDocx( shading: { fill: "F2F2F2" }, children: [ new Paragraph({ - children: [new TextRun({ text: h, bold: true, font: FONT, size: SIZE })], + children: [ + new TextRun({ + text: h, + bold: true, + font: FONT, + size: SIZE, + }), + ], alignment: AlignmentType.LEFT, }), ], @@ -784,7 +855,13 @@ export async function generateDocx( borders: cellBorder, children: [ new Paragraph({ - children: [new TextRun({ text: cell, font: FONT, size: SIZE })], + children: [ + new TextRun({ + text: cell, + font: FONT, + size: SIZE, + }), + ], }), ], }), @@ -810,14 +887,26 @@ export async function generateDocx( new Paragraph({ bullet: { level: 0 }, spacing: { after: 120 }, - children: [new TextRun({ text: bulletMatch[1], font: FONT, size: SIZE })], + children: [ + new TextRun({ + text: bulletMatch[1], + font: FONT, + size: SIZE, + }), + ], }), ); } else { children.push( new Paragraph({ spacing: { after: 120 }, - children: [new TextRun({ text: trimmed, font: FONT, size: SIZE })], + children: [ + new TextRun({ + text: trimmed, + font: FONT, + size: SIZE, + }), + ], }), ); } @@ -829,7 +918,9 @@ export async function generateDocx( ? { page: { size: { orientation: PageOrientation.LANDSCAPE } } } : {}; - const doc = new Document({ sections: [{ properties: pageSetup, children }] }); + const doc = new Document({ + sections: [{ properties: pageSetup, children }], + }); const buf = await Packer.toBuffer(doc); const docId = crypto.randomUUID().replace(/-/g, ""); const safeTitle = @@ -973,11 +1064,11 @@ export async function runEditDocument(params: { const current = await loadCurrentVersionBytes(documentId, db); if (!current) return { ok: false, error: "Could not load document bytes." }; - const { bytes: editedBytes, changes, errors } = await applyTrackedEdits( - current.bytes, - edits, - { author: "Mike" }, - ); + const { + bytes: editedBytes, + changes, + errors, + } = await applyTrackedEdits(current.bytes, edits, { author: "Mike" }); if (changes.length === 0) { return { @@ -1028,7 +1119,8 @@ export async function runEditDocument(params: { .order("version_number", { ascending: false, nullsFirst: false }) .limit(1) .maybeSingle(); - nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1; + nextVersionNumber = + ((maxRow?.version_number as number | null) ?? 1) + 1; // Inherit the display name from the most recent prior version so // user-applied renames carry forward through further edits. Falls @@ -1081,7 +1173,9 @@ export async function runEditDocument(params: { const { data: insertedEdits, error: editsErr } = await db .from("document_edits") .insert(editRows) - .select("id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after"); + .select( + "id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after", + ); if (editsErr || !insertedEdits) { return { ok: false, error: "Failed to record edits." }; @@ -1092,25 +1186,34 @@ export async function runEditDocument(params: { .update({ current_version_id: versionRowId }) .eq("id", documentId); - const annotations: EditAnnotation[] = insertedEdits.map((r: { id: string; change_id: string; deleted_text: string; inserted_text: string; context_before: string | null; context_after: string | null }) => { - const src = changes.find((c) => c.id === r.change_id); - return { - kind: "edit", - edit_id: r.id, - document_id: documentId, - version_id: versionRowId, - version_number: nextVersionNumber, - change_id: r.change_id, - del_w_id: src?.delId, - ins_w_id: src?.insId, - deleted_text: r.deleted_text ?? "", - inserted_text: r.inserted_text ?? "", - context_before: r.context_before ?? "", - context_after: r.context_after ?? "", - reason: src?.reason, - status: "pending", - }; - }); + const annotations: EditAnnotation[] = insertedEdits.map( + (r: { + id: string; + change_id: string; + deleted_text: string; + inserted_text: string; + context_before: string | null; + context_after: string | null; + }) => { + const src = changes.find((c) => c.id === r.change_id); + return { + kind: "edit", + edit_id: r.id, + document_id: documentId, + version_id: versionRowId, + version_number: nextVersionNumber, + change_id: r.change_id, + del_w_id: src?.delId, + ins_w_id: src?.insId, + deleted_text: r.deleted_text ?? "", + inserted_text: r.inserted_text ?? "", + context_before: r.context_before ?? "", + context_after: r.context_after ?? "", + reason: src?.reason, + status: "pending", + }; + }, + ); // Persistent, non-expiring permalink. The backend streams fresh bytes // on each request, so this URL stays valid as long as the file exists. @@ -1216,9 +1319,7 @@ async function readDocumentContent( { const head = Buffer.from(raw).subarray(0, 8); const hex = head.toString("hex"); - const ascii = head - .toString("binary") - .replace(/[^\x20-\x7e]/g, "."); + const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, "."); console.log( `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`, ); @@ -1273,7 +1374,9 @@ async function readDocumentContent( err, ); if (emitEvents) - write(`data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`); + write( + `data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`, + ); return "Document could not be read."; } } @@ -1385,7 +1488,10 @@ async function findInDocumentContent(params: { const { norm, origIdx } = normalizeWithMap(text); const needle = normalizeQuery(query); if (!needle) { - return JSON.stringify({ ok: false, error: "Empty query after normalization." }); + return JSON.stringify({ + ok: false, + error: "Empty query after normalization.", + }); } type Hit = { @@ -1528,19 +1634,30 @@ export async function runToolCalls( const rawDocId = args.doc_id as string; const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; - const content = await readDocumentContent(docId, docStore, write, docIndex, db); + const content = await readDocumentContent( + docId, + docStore, + write, + docIndex, + db, + ); const filename = docStore.get(docId)?.filename; const documentId = docIndex?.[docId]?.document_id; if (filename) docsRead.push({ filename, document_id: documentId }); toolResults.push({ role: "tool", tool_call_id: tc.id, content }); - } else if (tc.function.name === "find_in_document") { const rawDocId = args.doc_id as string; const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId; const query = (args.query as string) ?? ""; - const maxResults = typeof args.max_results === "number" ? args.max_results : undefined; - const contextChars = typeof args.context_chars === "number" ? args.context_chars : undefined; + const maxResults = + typeof args.max_results === "number" + ? args.max_results + : undefined; + const contextChars = + typeof args.context_chars === "number" + ? args.context_chars + : undefined; const content = await findInDocumentContent({ docLabel: docId, query, @@ -1569,7 +1686,6 @@ export async function runToolCalls( }); } toolResults.push({ role: "tool", tool_call_id: tc.id, content }); - } else if (tc.function.name === "list_documents") { const list = Array.from(docStore.entries()).map( ([doc_id, info]) => ({ @@ -1583,7 +1699,6 @@ export async function runToolCalls( tool_call_id: tc.id, content: JSON.stringify(list), }); - } else if (tc.function.name === "fetch_documents") { const rawDocIds = (args.doc_ids as string[]) ?? []; const docIds = rawDocIds.map( @@ -1591,7 +1706,13 @@ export async function runToolCalls( ); const parts: string[] = []; for (const docId of docIds) { - const content = await readDocumentContent(docId, docStore, write, docIndex, db); + const content = await readDocumentContent( + docId, + docStore, + write, + docIndex, + db, + ); const filename = docStore.get(docId)?.filename ?? docId; parts.push(`--- ${filename} (${docId}) ---\n${content}`); if (docStore.get(docId)) { @@ -1604,18 +1725,25 @@ export async function runToolCalls( tool_call_id: tc.id, content: parts.join("\n\n"), }); - } else if (tc.function.name === "list_workflows") { const list = workflowStore - ? Array.from(workflowStore.entries()).map(([id, w]) => ({ id, title: w.title })) + ? Array.from(workflowStore.entries()).map(([id, w]) => ({ + id, + title: w.title, + })) : []; - toolResults.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(list) }); - + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: JSON.stringify(list), + }); } else if (tc.function.name === "read_workflow") { const wfId = args.workflow_id as string; const wf = workflowStore?.get(wfId); if (wf) { - write(`data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`); + write( + `data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`, + ); workflowsApplied.push({ workflow_id: wfId, title: wf.title }); } toolResults.push({ @@ -1623,7 +1751,6 @@ export async function runToolCalls( tool_call_id: tc.id, content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`, }); - } else if (tc.function.name === "read_table_cells" && tabularStore) { const colIndices = args.col_indices as number[] | undefined; const rowIndices = args.row_indices as number[] | undefined; @@ -1632,23 +1759,36 @@ export async function runToolCalls( ? tabularStore.columns.filter((_, i) => colIndices.includes(i)) : tabularStore.columns; const filteredDocs = rowIndices?.length - ? tabularStore.documents.filter((_, i) => rowIndices.includes(i)) + ? tabularStore.documents.filter((_, i) => + rowIndices.includes(i), + ) : tabularStore.documents; const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`; - write(`data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`); + write( + `data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`, + ); const lines: string[] = []; for (const col of filteredCols) { - const colPos = tabularStore.columns.findIndex((c) => c.index === col.index); + const colPos = tabularStore.columns.findIndex( + (c) => c.index === col.index, + ); for (const doc of filteredDocs) { - const rowPos = tabularStore.documents.findIndex((d) => d.id === doc.id); - const cell = tabularStore.cells.get(`${col.index}:${doc.id}`); - lines.push(`[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`); + const rowPos = tabularStore.documents.findIndex( + (d) => d.id === doc.id, + ); + const cell = tabularStore.cells.get( + `${col.index}:${doc.id}`, + ); + lines.push( + `[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`, + ); if (cell?.summary) { lines.push(`Summary: ${cell.summary}`); if (cell.flag) lines.push(`Flag: ${cell.flag}`); - if (cell.reasoning) lines.push(`Reasoning: ${cell.reasoning}`); + if (cell.reasoning) + lines.push(`Reasoning: ${cell.reasoning}`); } else { lines.push(`(not yet generated)`); } @@ -1656,14 +1796,15 @@ export async function runToolCalls( } } - write(`data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`); + write( + `data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`, + ); docsRead.push({ filename: label }); toolResults.push({ role: "tool", tool_call_id: tc.id, content: lines.join("\n") || "No cells found.", }); - } else if (tc.function.name === "edit_document" && docIndex) { const rawDocId = args.doc_id as string; const editsRaw = args.edits as unknown[] | undefined; @@ -1707,10 +1848,7 @@ export async function runToolCalls( tool_call_id: tc.id, content: JSON.stringify({ error: err }), }); - } else if ( - !Array.isArray(editsRaw) || - editsRaw.length === 0 - ) { + } else if (!Array.isArray(editsRaw) || editsRaw.length === 0) { const err = "edits array is required and must not be empty."; emitEditError(docInfo.filename, indexed.document_id, err); toolResults.push({ @@ -1733,15 +1871,15 @@ export async function runToolCalls( filename: docInfo.filename, })}\n\n`, ); - const edits: EditInput[] = (editsRaw as Record[]).map( - (e) => ({ - find: String(e.find ?? ""), - replace: String(e.replace ?? ""), - context_before: String(e.context_before ?? ""), - context_after: String(e.context_after ?? ""), - reason: e.reason ? String(e.reason) : undefined, - }), - ); + const edits: EditInput[] = ( + editsRaw as Record[] + ).map((e) => ({ + find: String(e.find ?? ""), + replace: String(e.replace ?? ""), + context_before: String(e.context_before ?? ""), + context_after: String(e.context_after ?? ""), + reason: e.reason ? String(e.reason) : undefined, + })); const reuseVersion = turnEditState?.get(indexed.document_id); const result = await runEditDocument({ documentId: indexed.document_id, @@ -1824,7 +1962,6 @@ export async function runToolCalls( }); } } - } else if (tc.function.name === "replicate_document" && docIndex) { const rawDocId = args.doc_id as string; const requestedFilename = @@ -1933,7 +2070,11 @@ export async function runToolCalls( .from("documents") .insert(docRows) .select("id, filename"); - if (docErr || !insertedDocs || insertedDocs.length === 0) { + if ( + docErr || + !insertedDocs || + insertedDocs.length === 0 + ) { fail( `Failed to record replicated documents: ${docErr?.message ?? "unknown"}`, ); @@ -2008,7 +2149,10 @@ export async function runToolCalls( `Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`, ); } else { - const versionByDocId = new Map(); + const versionByDocId = new Map< + string, + string + >(); for (const v of insertedVersions as { id: string; document_id: string; @@ -2119,13 +2263,21 @@ export async function runToolCalls( fail(`replicate_document failed: ${String(e)}`); } } - } else if (tc.function.name === "generate_docx") { const title = args.title as string; - const landscape = !!(args.landscape); - console.log(`[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`); - const previewFilename = `${(title.replace(/[^a-zA-Z0-9 _-]/g, "").trim().slice(0, 64) || "document")}.docx`; - write(`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`); + const landscape = !!args.landscape; + console.log( + `[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`, + ); + const previewFilename = `${ + title + .replace(/[^a-zA-Z0-9 _-]/g, "") + .trim() + .slice(0, 64) || "document" + }.docx`; + write( + `data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`, + ); const result = await generateDocx( title, args.sections as unknown[], @@ -2137,10 +2289,15 @@ export async function runToolCalls( if ("filename" in result && "download_url" in result) { const dlFilename = result.filename as string; const dlUrl = result.download_url as string; - const documentId = (result as { document_id?: string }).document_id; - const versionId = (result as { version_id?: string }).version_id; - const versionNumber = (result as { version_number?: number }).version_number ?? null; - const storagePath = (result as { storage_path?: string }).storage_path; + const documentId = (result as { document_id?: string }) + .document_id; + const versionId = (result as { version_id?: string }) + .version_id; + const versionNumber = + (result as { version_number?: number }).version_number ?? + null; + const storagePath = (result as { storage_path?: string }) + .storage_path; // Register the generated doc in the chat context so // edit_document (and read_document / find_in_document) @@ -2181,14 +2338,19 @@ export async function runToolCalls( version_number: versionNumber, }); } else { - write(`data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`); + write( + `data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`, + ); } // Surface the chat-local doc label in the tool result so the // model can pass it as `doc_id` to edit_document / read_document // / find_in_document in the same turn. Without this the model // only sees the DB UUID, which isn't valid as a doc_id anchor. const toolResultPayload = newDocLabel - ? { ...(result as Record), doc_id: newDocLabel } + ? { + ...(result as Record), + doc_id: newDocLabel, + } : result; toolResults.push({ role: "tool", @@ -2313,7 +2475,21 @@ export async function runLLMStream(params: { */ projectId?: string | null; }): Promise<{ fullText: string; events: AssistantEvent[] }> { - const { apiMessages, docStore, docIndex, userId, db, write, extraTools, workflowStore, tabularStore, buildCitations, model, apiKeys, projectId } = params; + const { + apiMessages, + docStore, + docIndex, + userId, + db, + write, + extraTools, + workflowStore, + tabularStore, + buildCitations, + model, + apiKeys, + projectId, + } = params; const activeTools = extraTools?.length ? [...TOOLS, ...WORKFLOW_TOOLS, ...extraTools] : [...TOOLS, ...WORKFLOW_TOOLS]; @@ -2323,14 +2499,6 @@ export async function runLLMStream(params: { const rawMsgs = apiMessages as { role: string; content: string | null }[]; const systemPrompt = rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : ""; - console.log( - "[runLLMStream] system prompt:\n" + - "─".repeat(80) + - "\n" + - systemPrompt + - "\n" + - "─".repeat(80), - ); const chatMessages: LlmMessage[] = rawMsgs .filter((m) => m.role !== "system") .map((m) => ({ @@ -2473,17 +2641,17 @@ export async function runLLMStream(params: { workflowsApplied, docsEdited, } = await runToolCalls( - toolCalls, - docStore, - userId, - db, - write, - workflowStore, - tabularStore, - docIndex, - turnEditState, - projectId, - ); + toolCalls, + docStore, + userId, + db, + write, + workflowStore, + tabularStore, + docIndex, + turnEditState, + projectId, + ); for (const r of docsRead) { events.push({ type: "doc_read", @@ -2589,7 +2757,7 @@ export async function runLLMStream(params: { export function extractAnnotations( fullText: string, docIndex: DocIndex, - events?: { type: string } & Record[] | unknown[], + events?: ({ type: string } & Record[]) | unknown[], ): unknown[] { const out: unknown[] = parseCitations(fullText).map((c) => { const docInfo = resolveDoc(c.doc_id, docIndex); @@ -2606,9 +2774,13 @@ export function extractAnnotations( }; }); if (Array.isArray(events)) { - for (const ev of events as { type?: string; annotations?: EditAnnotation[] }[]) { + for (const ev of events as { + type?: string; + annotations?: EditAnnotation[]; + }[]) { if (ev?.type === "doc_edited" && Array.isArray(ev.annotations)) { - for (const a of ev.annotations) out.push({ ...a, type: "edit_data" }); + for (const a of ev.annotations) + out.push({ ...a, type: "edit_data" }); } } } @@ -2652,8 +2824,7 @@ export async function buildDocContext( if (!Array.isArray(content)) continue; for (const ev of content as Record[]) { if ( - (ev?.type === "doc_created" || - ev?.type === "doc_edited") && + (ev?.type === "doc_created" || ev?.type === "doc_edited") && typeof ev.document_id === "string" ) { documentIds.add(ev.document_id); @@ -2713,17 +2884,25 @@ export async function buildProjectDocContext( projectId: string, _userId: string, db: ReturnType, -): Promise<{ docIndex: DocIndex; docStore: DocStore; folderPaths: Map }> { +): Promise<{ + docIndex: DocIndex; + docStore: DocStore; + folderPaths: Map; +}> { const docIndex: DocIndex = {}; const docStore: DocStore = new Map(); const [{ data: docs }, { data: folders }] = await Promise.all([ - db.from("documents") - .select("id, filename, file_type, current_version_id, status, folder_id") + db + .from("documents") + .select( + "id, filename, file_type, current_version_id, status, folder_id", + ) .eq("project_id", projectId) .eq("status", "ready") .order("created_at", { ascending: true }), - db.from("project_subfolders") + db + .from("project_subfolders") .select("id, name, parent_folder_id") .eq("project_id", projectId), ]); @@ -2739,8 +2918,15 @@ export async function buildProjectDocContext( await attachActiveVersionPaths(db, docList); // Build folder id → full path map - const folderMap = new Map(); - for (const f of folders ?? []) folderMap.set(f.id, { name: f.name, parent_folder_id: f.parent_folder_id }); + 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 ""; @@ -2820,7 +3006,9 @@ export async function buildWorkflowStore( .from("workflow_shares") .select("workflow_id") .eq("shared_with_email", normalizedUserEmail); - const sharedIds = [...new Set((shares ?? []).map((share) => share.workflow_id))]; + const sharedIds = [ + ...new Set((shares ?? []).map((share) => share.workflow_id)), + ]; if (sharedIds.length > 0) { const { data: sharedWorkflows } = await db .from("workflows") @@ -2829,7 +3017,10 @@ export async function buildWorkflowStore( .eq("type", "assistant"); for (const wf of sharedWorkflows ?? []) { if (wf.prompt_md) { - store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md }); + store.set(wf.id, { + title: wf.title, + prompt_md: wf.prompt_md, + }); } } } diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts index 9ed625e..0ecef37 100644 --- a/backend/src/lib/llm/claude.ts +++ b/backend/src/lib/llm/claude.ts @@ -1,7 +1,5 @@ import Anthropic from "@anthropic-ai/sdk"; import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages"; -import * as fs from "fs"; -import * as path from "path"; import type { StreamChatParams, StreamChatResult, @@ -10,11 +8,6 @@ import type { } from "./types"; import { toClaudeTools } from "./tools"; -const RAW_STREAM_LOG_PATH = path.resolve( - process.cwd(), - "claude-raw-stream.log", -); - type ContentBlock = | { type: "text"; text: string } | { type: "tool_use"; id: string; name: string; input: unknown } @@ -80,12 +73,6 @@ export async function streamClaude( let sawThinking = false; - stream.on("streamEvent", (event) => { - const line = JSON.stringify(event); - console.log("[claude raw stream]", line); - fs.appendFile(RAW_STREAM_LOG_PATH, line + "\n", () => {}); - }); - stream.on("text", (delta) => { callbacks.onContentDelta?.(delta); }); diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index ee43d61..57e62d7 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -77,7 +77,6 @@ export async function streamGemini( let sawThinking = false; for await (const chunk of stream) { - console.log("[gemini stream chunk]", JSON.stringify(chunk, null, 2)); const parts = (chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] }) .candidates?.[0]?.content?.parts ?? []; diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index 82e7f23..6b4f749 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -122,7 +122,9 @@ export function normalizeDownloadFilename(name: string): string { } export function sanitizeDispositionFilename(name: string): string { - return normalizeDownloadFilename(name).replace(/["\\]/g, "_"); + return normalizeDownloadFilename(name) + .replace(/["\\]/g, "_") + .replace(/[^\x20-\x7E]/g, "_"); } export function encodeRFC5987(str: string): string { diff --git a/backend/src/lib/userApiKeys.ts b/backend/src/lib/userApiKeys.ts new file mode 100644 index 0000000..51b26bd --- /dev/null +++ b/backend/src/lib/userApiKeys.ts @@ -0,0 +1,143 @@ +import crypto from "crypto"; +import { createServerSupabase } from "./supabase"; +import type { UserApiKeys } from "./llm"; + +type Db = ReturnType; +export type ApiKeyProvider = "claude" | "gemini"; + +type EncryptedKeyRow = { + provider: ApiKeyProvider; + encrypted_key: string; + iv: string; + auth_tag: string; +}; + +const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini"]; + +function encryptionKey(): Buffer { + const secret = + process.env.USER_API_KEYS_ENCRYPTION_SECRET || + process.env.API_KEYS_ENCRYPTION_SECRET || + process.env.SUPABASE_SECRET_KEY; + if (!secret) { + throw new Error("API key encryption secret is not configured"); + } + return crypto.createHash("sha256").update(secret).digest(); +} + +function encrypt(value: string): Omit { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv); + const encrypted = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final(), + ]); + return { + encrypted_key: encrypted.toString("base64"), + iv: iv.toString("base64"), + auth_tag: cipher.getAuthTag().toString("base64"), + }; +} + +function decrypt(row: EncryptedKeyRow): string | null { + try { + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + encryptionKey(), + Buffer.from(row.iv, "base64"), + ); + decipher.setAuthTag(Buffer.from(row.auth_tag, "base64")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(row.encrypted_key, "base64")), + decipher.final(), + ]); + return decrypted.toString("utf8"); + } catch (err) { + console.error("[user-api-keys] failed to decrypt stored key", { + provider: row.provider, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +function isProvider(value: string): value is ApiKeyProvider { + return (PROVIDERS as string[]).includes(value); +} + +export function normalizeApiKeyProvider(value: string): ApiKeyProvider | null { + return isProvider(value) ? value : null; +} + +export async function getUserApiKeyStatus( + userId: string, + db: Db = createServerSupabase(), +): Promise> { + const status: Record = { + claude: false, + gemini: false, + }; + + const { data, error } = await db + .from("user_api_keys") + .select("provider") + .eq("user_id", userId); + if (error) throw error; + + for (const row of data ?? []) { + const provider = normalizeApiKeyProvider(String(row.provider)); + if (provider) status[provider] = true; + } + + return status; +} + +export async function getUserApiKeys( + userId: string, + db: Db = createServerSupabase(), +): Promise { + const apiKeys: UserApiKeys = { claude: null, gemini: null }; + + const { data, error } = await db + .from("user_api_keys") + .select("provider, encrypted_key, iv, auth_tag") + .eq("user_id", userId); + if (error) throw error; + + for (const row of (data ?? []) as EncryptedKeyRow[]) { + const provider = normalizeApiKeyProvider(row.provider); + if (!provider) continue; + apiKeys[provider] = decrypt(row); + } + + return apiKeys; +} + +export async function saveUserApiKey( + userId: string, + provider: ApiKeyProvider, + value: string | null, + db: Db = createServerSupabase(), +): Promise { + const normalized = value?.trim() || null; + if (!normalized) { + const { error } = await db + .from("user_api_keys") + .delete() + .eq("user_id", userId) + .eq("provider", provider); + if (error) throw error; + return; + } + + const { error } = await db.from("user_api_keys").upsert( + { + user_id: userId, + provider, + ...encrypt(normalized), + updated_at: new Date().toISOString(), + }, + { onConflict: "user_id,provider" }, + ); + if (error) throw error; +} diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index c798b63..2476060 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -5,6 +5,7 @@ import { DEFAULT_TABULAR_MODEL, type UserApiKeys, } from "./llm"; +import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys"; export type UserModelSettings = { title_model: string; @@ -29,14 +30,10 @@ export async function getUserModelSettings( const client = db ?? createServerSupabase(); const { data } = await client .from("user_profiles") - .select("tabular_model, claude_api_key, gemini_api_key") + .select("tabular_model") .eq("user_id", userId) .single(); - - const api_keys: UserApiKeys = { - claude: data?.claude_api_key ?? null, - gemini: data?.gemini_api_key ?? null, - }; + const api_keys = await getStoredUserApiKeys(userId, client); return { title_model: resolveTitleModel(api_keys), @@ -50,13 +47,5 @@ export async function getUserApiKeys( db?: ReturnType, ): Promise { const client = db ?? createServerSupabase(); - const { data } = await client - .from("user_profiles") - .select("claude_api_key, gemini_api_key") - .eq("user_id", userId) - .single(); - return { - claude: data?.claude_api_key ?? null, - gemini: data?.gemini_api_key ?? null, - }; + return getStoredUserApiKeys(userId, client); } diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index b56c293..206148f 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -16,6 +16,118 @@ import { checkProjectAccess } from "../lib/access"; export const chatRouter = Router(); +type Db = ReturnType; + +type AccessibleChat = { + id: string; + title: string | null; + user_id: string; + project_id: string | null; +} & Record; + +function parseOptionalProjectId(value: unknown): + | { ok: true; provided: boolean; projectId: string | null } + | { ok: false; detail: string } { + if (value === undefined) + return { ok: true, provided: false, projectId: null }; + if (value === null) return { ok: true, provided: true, projectId: null }; + if (typeof value !== "string" || !value.trim()) { + return { + ok: false, + detail: "project_id must be a non-empty string or null", + }; + } + return { ok: true, provided: true, projectId: value.trim() }; +} + +function parseOptionalChatId(value: unknown): + | { ok: true; chatId: string | null } + | { ok: false; detail: string } { + if (value === undefined || value === null) return { ok: true, chatId: null }; + if (typeof value !== "string" || !value.trim()) { + return { ok: false, detail: "chat_id must be a non-empty string" }; + } + return { ok: true, chatId: value.trim() }; +} + +function parseChatMessages(value: unknown): + | { ok: true; messages: ChatMessage[] } + | { ok: false; detail: string } { + if (!Array.isArray(value) || value.length === 0) { + return { ok: false, detail: "messages must be a non-empty array" }; + } + + for (const message of value) { + if (!message || typeof message !== "object" || Array.isArray(message)) { + return { ok: false, detail: "messages must contain objects" }; + } + const row = message as Record; + if (typeof row.role !== "string") { + return { ok: false, detail: "message.role must be a string" }; + } + if (row.content !== null && typeof row.content !== "string") { + return { + ok: false, + detail: "message.content must be a string or null", + }; + } + } + + return { ok: true, messages: value as ChatMessage[] }; +} + +function parseOptionalModel(value: unknown): + | { ok: true; model: string | undefined } + | { ok: false; detail: string } { + if (value === undefined) return { ok: true, model: undefined }; + if (typeof value !== "string" || !value.trim()) { + return { ok: false, detail: "model must be a non-empty string" }; + } + return { ok: true, model: value.trim() }; +} + +async function validateAccessibleProjectId( + projectId: string | null, + userId: string, + userEmail: string | null | undefined, + db: Db, +): Promise<{ ok: true } | { ok: false; status: number; detail: string }> { + if (!projectId) return { ok: true }; + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) + return { ok: false, status: 404, detail: "Project not found" }; + return { ok: true }; +} + +async function getAccessibleChat( + chatId: string, + userId: string, + userEmail: string | null | undefined, + db: Db, +): Promise { + const { data: chat, error } = await db + .from("chats") + .select("*") + .eq("id", chatId) + .maybeSingle(); + if (error || !chat) return null; + + const row = chat as AccessibleChat; + if (row.user_id === userId) return row; + + if (row.project_id) { + const access = await checkProjectAccess( + row.project_id, + userId, + userEmail, + db, + ); + if (access.ok) return row; + } + + return null; +} + // GET /chat // Visible chats = the user's own chats + every chat under a project the // user owns (so a project owner sees all collaborator chats in their @@ -52,11 +164,27 @@ chatRouter.get("/", requireAuth, async (req, res) => { // POST /chat/create chatRouter.post("/create", requireAuth, async (req, res) => { const userId = res.locals.userId as string; - const projectId: string | null = req.body.project_id ?? null; + const userEmail = res.locals.userEmail as string | undefined; + const parsedProjectId = parseOptionalProjectId(req.body?.project_id); + if (!parsedProjectId.ok) { + return void res.status(400).json({ detail: parsedProjectId.detail }); + } + const projectId = parsedProjectId.projectId; const db = createServerSupabase(); + const projectAccess = await validateAccessibleProjectId( + projectId, + userId, + userEmail, + db, + ); + if (!projectAccess.ok) + return void res + .status(projectAccess.status) + .json({ detail: projectAccess.detail }); + const { data, error } = await db .from("chats") - .insert({ user_id: userId, project_id: projectId ?? undefined }) + .insert({ user_id: userId, project_id: projectId ?? null }) .select("id") .single(); @@ -71,25 +199,8 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => { const { chatId } = req.params; const db = createServerSupabase(); - const { data: chat, error } = await db - .from("chats") - .select("*") - .eq("id", chatId) - .single(); - if (error || !chat) - return void res.status(404).json({ detail: "Chat not found" }); - // Owner of the chat OR a member of the chat's project can view it. - let canView = chat.user_id === userId; - if (!canView && chat.project_id) { - const access = await checkProjectAccess( - chat.project_id, - userId, - userEmail, - db, - ); - canView = access.ok; - } - if (!canView) + const chat = await getAccessibleChat(chatId, userId, userEmail, db); + if (!chat) return void res.status(404).json({ detail: "Chat not found" }); const { data: messages } = await db @@ -261,30 +372,14 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { chatId } = req.params; - const message: string = (req.body.message ?? "").trim(); + const message = + typeof req.body?.message === "string" ? req.body.message.trim() : ""; if (!message) return void res.status(400).json({ detail: "message is required" }); const db = createServerSupabase(); - const { data: chat, error } = await db - .from("chats") - .select("id, user_id, project_id") - .eq("id", chatId) - .single(); - - if (error || !chat) - return void res.status(404).json({ detail: "Chat not found" }); - let canTitle = chat.user_id === userId; - if (!canTitle && chat.project_id) { - const access = await checkProjectAccess( - chat.project_id, - userId, - userEmail, - db, - ); - canTitle = access.ok; - } - if (!canTitle) + const chat = await getAccessibleChat(chatId, userId, userEmail, db); + if (!chat) return void res.status(404).json({ detail: "Chat not found" }); try { @@ -303,8 +398,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { await db .from("chats") .update({ title }) - .eq("id", chatId) - .eq("user_id", userId); + .eq("id", chatId); res.json({ title }); } catch (err) { @@ -316,12 +410,31 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { // POST /chat — streaming chatRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; - const { messages, chat_id, project_id, model } = req.body as { - messages: ChatMessage[]; - chat_id?: string; - project_id?: string; - model?: string; - }; + const body = + req.body && typeof req.body === "object" && !Array.isArray(req.body) + ? (req.body as Record) + : {}; + const parsedMessages = parseChatMessages(body.messages); + if (!parsedMessages.ok) { + return void res.status(400).json({ detail: parsedMessages.detail }); + } + const parsedChatId = parseOptionalChatId(body.chat_id); + if (!parsedChatId.ok) { + return void res.status(400).json({ detail: parsedChatId.detail }); + } + const parsedProjectId = parseOptionalProjectId(body.project_id); + if (!parsedProjectId.ok) { + return void res.status(400).json({ detail: parsedProjectId.detail }); + } + const parsedModel = parseOptionalModel(body.model); + if (!parsedModel.ok) { + return void res.status(400).json({ detail: parsedModel.detail }); + } + + const messages = parsedMessages.messages; + const chat_id = parsedChatId.chatId; + const project_id = parsedProjectId.projectId; + const model = parsedModel.model; console.log("[chat/stream] incoming request", { userId, @@ -335,46 +448,43 @@ chatRouter.post("/", requireAuth, async (req, res) => { const db = createServerSupabase(); let chatId = chat_id ?? null; let chatTitle: string | null = null; + let resolvedProjectId: string | null = parsedProjectId.projectId; if (chatId) { - // Either chat owner OR a member of the chat's project can post. - const { data: existing } = await db - .from("chats") - .select("id, title, user_id, project_id") - .eq("id", chatId) - .single(); - let canUse = !!existing && existing.user_id === userId; - if (!canUse && existing?.project_id) { - const access = await checkProjectAccess( - existing.project_id, - userId, - userEmail, - db, - ); - canUse = access.ok; + const existing = await getAccessibleChat(chatId, userId, userEmail, db); + if (!existing) + return void res.status(404).json({ detail: "Chat not found" }); + + const existingProjectId = existing.project_id ?? null; + if ( + parsedProjectId.provided && + parsedProjectId.projectId !== existingProjectId + ) { + return void res + .status(400) + .json({ detail: "project_id does not match chat" }); } - if (!canUse || !existing) chatId = null; - else chatTitle = existing.title; + resolvedProjectId = existingProjectId; + chatTitle = existing.title; } if (!chatId) { // If creating a chat tied to a project, the user must have access // to the project (own or shared). - if (project_id) { - const access = await checkProjectAccess( - project_id, - userId, - userEmail, - db, - ); - if (!access.ok) - return void res - .status(404) - .json({ detail: "Project not found" }); - } + const projectAccess = await validateAccessibleProjectId( + resolvedProjectId, + userId, + userEmail, + db, + ); + if (!projectAccess.ok) + return void res + .status(projectAccess.status) + .json({ detail: projectAccess.detail }); + const { data: newChat, error } = await db .from("chats") - .insert({ user_id: userId, project_id: project_id ?? null }) + .insert({ user_id: userId, project_id: resolvedProjectId }) .select("id, title") .single(); if (error || !newChat) { @@ -449,7 +559,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { workflowStore, model, apiKeys, - projectId: project_id ?? null, + projectId: resolvedProjectId, }); console.log("[chat/stream] LLM stream finished", { diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index aeddd3a..f0674ed 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,23 +1,250 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm"; +import { + getUserApiKeyStatus, + normalizeApiKeyProvider, + saveUserApiKey, +} from "../lib/userApiKeys"; export const userRouter = Router(); -// POST /user/profile -userRouter.post("/profile", requireAuth, async (req, res) => { - const userId = res.locals.userId as string; - const db = createServerSupabase(); +const MONTHLY_CREDIT_LIMIT = 999999; + +type UserProfileRow = { + display_name: string | null; + organisation: string | null; + message_credits_used: number; + credits_reset_date: string; + tier: string; + tabular_model: string; +}; + +function serializeProfile( + row: UserProfileRow, + apiKeyStatus?: { claude: boolean; gemini: boolean }, +) { + const creditsUsed = row.message_credits_used ?? 0; + return { + displayName: row.display_name, + organisation: row.organisation, + messageCreditsUsed: creditsUsed, + creditsResetDate: row.credits_reset_date, + creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0), + tier: row.tier || "Free", + tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL), + ...(apiKeyStatus ? { apiKeyStatus } : {}), + }; +} + +function validateProfilePayload(body: unknown): + | { + ok: true; + update: { + display_name?: string | null; + organisation?: string | null; + tabular_model?: string; + updated_at: string; + }; + } + | { ok: false; detail: string } { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, detail: "Expected a JSON object" }; + } + + const raw = body as Record; + const allowedFields = new Set([ + "displayName", + "organisation", + "tabularModel", + ]); + const invalidField = Object.keys(raw).find((key) => !allowedFields.has(key)); + if (invalidField) { + return { ok: false, detail: `Unsupported profile field: ${invalidField}` }; + } + + const update: { + display_name?: string | null; + organisation?: string | null; + tabular_model?: string; + updated_at: string; + } = { updated_at: new Date().toISOString() }; + + if ("displayName" in raw) { + if (raw.displayName !== null && typeof raw.displayName !== "string") { + return { ok: false, detail: "displayName must be a string or null" }; + } + update.display_name = raw.displayName?.trim() || null; + } + + if ("organisation" in raw) { + if (raw.organisation !== null && typeof raw.organisation !== "string") { + return { ok: false, detail: "organisation must be a string or null" }; + } + update.organisation = raw.organisation?.trim() || null; + } + + if ("tabularModel" in raw) { + if (typeof raw.tabularModel !== "string") { + return { ok: false, detail: "tabularModel must be a string" }; + } + const resolved = resolveModel(raw.tabularModel, ""); + if (!resolved) { + return { ok: false, detail: "Unsupported tabularModel" }; + } + update.tabular_model = resolved; + } + + return { ok: true, update }; +} + +async function ensureProfileRow( + db: ReturnType, + userId: string, +) { const { error } = await db .from("user_profiles") .upsert( { user_id: userId }, { onConflict: "user_id", ignoreDuplicates: true }, ); + return error; +} + +async function loadProfile( + db: ReturnType, + userId: string, + options: { repairMissing?: boolean } = {}, +) { + let { data, error } = await db + .from("user_profiles") + .select( + "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", + ) + .eq("user_id", userId) + .maybeSingle(); + + if (error) return { data: null, error }; + if (!data) { + if (!options.repairMissing) { + return { data: null, error: new Error("Profile not found") }; + } + + const ensureError = await ensureProfileRow(db, userId); + if (ensureError) return { data: null, error: ensureError }; + + const created = await db + .from("user_profiles") + .select( + "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", + ) + .eq("user_id", userId) + .single(); + if (created.error) return { data: null, error: created.error }; + data = created.data; + } + + let row = data as UserProfileRow; + if (row.credits_reset_date && new Date() > new Date(row.credits_reset_date)) { + const creditsResetDate = new Date(); + creditsResetDate.setDate(creditsResetDate.getDate() + 30); + const { data: resetData, error: resetError } = await db + .from("user_profiles") + .update({ + message_credits_used: 0, + credits_reset_date: creditsResetDate.toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId) + .select( + "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", + ) + .single(); + + if (resetError) return { data: null, error: resetError }; + row = resetData as UserProfileRow; + } + + return { data: serializeProfile(row), error: null }; +} + +// POST /user/profile +userRouter.post("/profile", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const error = await ensureProfileRow(db, userId); if (error) return void res.status(500).json({ detail: error.message }); res.json({ ok: true }); }); +// GET /user/profile +userRouter.get("/profile", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const { data, error } = await loadProfile(db, userId, { + repairMissing: true, + }); + if (error) return void res.status(500).json({ detail: error.message }); + const apiKeyStatus = await getUserApiKeyStatus(userId, db); + res.json({ ...data, apiKeyStatus }); +}); + +// PATCH /user/profile +userRouter.patch("/profile", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const parsed = validateProfilePayload(req.body); + if (!parsed.ok) return void res.status(400).json({ detail: parsed.detail }); + + const db = createServerSupabase(); + const ensureError = await ensureProfileRow(db, userId); + if (ensureError) + return void res.status(500).json({ detail: ensureError.message }); + + const { error: updateError } = await db + .from("user_profiles") + .update(parsed.update) + .eq("user_id", userId); + if (updateError) + return void res.status(500).json({ detail: updateError.message }); + + const { data, error } = await loadProfile(db, userId); + if (error) return void res.status(500).json({ detail: error.message }); + const apiKeyStatus = await getUserApiKeyStatus(userId, db); + res.json({ ...data, apiKeyStatus }); +}); + +// GET /user/api-keys +userRouter.get("/api-keys", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const status = await getUserApiKeyStatus(userId, db); + res.json(status); +}); + +// PUT /user/api-keys/:provider +userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const provider = normalizeApiKeyProvider(req.params.provider); + if (!provider) + return void res.status(400).json({ detail: "Unsupported provider" }); + + const apiKey = + typeof req.body?.api_key === "string" ? req.body.api_key : null; + const db = createServerSupabase(); + try { + await saveUserApiKey(userId, provider, apiKey, db); + const status = await getUserApiKeyStatus(userId, db); + res.json(status); + } catch (err) { + console.error("[user/api-keys] save failed", { + provider, + error: err instanceof Error ? err.message : String(err), + }); + res.status(500).json({ detail: "Failed to save API key" }); + } +}); + // DELETE /user/account userRouter.delete("/account", requireAuth, async (_req, res) => { const userId = res.locals.userId as string; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 00bc0c5..a4b3abf 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,6 +10,7 @@ "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, + "types": ["node", "express", "cors", "multer"], "paths": { "@/*": ["./src/*"] } diff --git a/frontend/bun.lock b/frontend/bun.lock index 0ffcaed..90f252b 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "frontend-app", + "name": "mike", "dependencies": { "@aws-sdk/client-s3": "^3.1025.0", "@aws-sdk/s3-request-presigner": "^3.1025.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5782999..decbac1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "mike", "version": "0.1.0", - "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.1025.0", "@aws-sdk/s3-request-presigner": "^3.1025.0", diff --git a/frontend/package.json b/frontend/package.json index 520d74d..7c1725e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,6 @@ { "name": "mike", "version": "0.1.0", - "license": "AGPL-3.0-only", "private": true, "scripts": { "dev": "next dev", @@ -69,5 +68,6 @@ "tw-animate-css": "^1.4.0", "typescript": "^5", "wrangler": "^4.51.0" - } + }, + "license": "AGPL-3.0-only" } diff --git a/frontend/public/link-image.jpg b/frontend/public/link-image.jpg new file mode 100644 index 0000000..796213e Binary files /dev/null and b/frontend/public/link-image.jpg differ diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index cf3720e..153de3e 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -74,18 +74,20 @@ export default function ModelsAndApiKeysPage() { updateApiKey("claude", value.trim() || null) } + onRemove={() => updateApiKey("claude", null)} /> updateApiKey("gemini", value.trim() || null) } + onRemove={() => updateApiKey("gemini", null)} /> @@ -183,30 +185,33 @@ function TabularModelDropdown({ function ApiKeyField({ label, placeholder, - initialValue, + hasSavedKey, onSave, + onRemove, }: { label: string; placeholder: string; - initialValue: string; + hasSavedKey: boolean; onSave: (value: string) => Promise; + onRemove: () => Promise; }) { - const [value, setValue] = useState(initialValue); + const [value, setValue] = useState(""); const [reveal, setReveal] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saved, setSaved] = useState(false); useEffect(() => { - setValue(initialValue); - }, [initialValue]); + setValue(""); + }, [hasSavedKey]); - const dirty = value !== initialValue; + const dirty = value.trim().length > 0; const handleSave = async () => { setIsSaving(true); const ok = await onSave(value); setIsSaving(false); if (ok) { + setValue(""); setSaved(true); setTimeout(() => setSaved(false), 2000); } else { @@ -214,16 +219,28 @@ function ApiKeyField({ } }; + const handleRemove = async () => { + setIsSaving(true); + const ok = await onRemove(); + setIsSaving(false); + if (!ok) alert(`Failed to remove ${label}.`); + }; + return (
+ {hasSavedKey && ( +

+ A key is saved. Paste a new key to replace it. +

+ )}
setValue(e.target.value)} - placeholder={placeholder} + placeholder={hasSavedKey ? "Saved key hidden" : placeholder} className="pr-10" autoComplete="off" spellCheck={false} @@ -257,6 +274,16 @@ function ApiKeyField({ "Save" )} + {hasSavedKey && ( + + )}
); diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx new file mode 100644 index 0000000..7f38730 --- /dev/null +++ b/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { ProjectPage } from "@/app/components/projects/ProjectPage"; + +interface Props { + params: Promise<{ id: string }>; +} + +export default function ProjectAssistantPage({ params }: Props) { + const { id } = use(params); + return ; +} diff --git a/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx b/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx new file mode 100644 index 0000000..54b185d --- /dev/null +++ b/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { ProjectPage } from "@/app/components/projects/ProjectPage"; + +interface Props { + params: Promise<{ id: string }>; +} + +export default function ProjectTabularReviewsPage({ params }: Props) { + const { id } = use(params); + return ; +} diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index 7f56192..4c79c68 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -67,10 +67,12 @@ export const ChatInput = forwardRef(function ChatInput( } | null>(null); const [model, setModel] = useSelectedModel(); const { profile } = useUserProfile(); - const apiKeys = { - claudeApiKey: profile?.claudeApiKey ?? null, - geminiApiKey: profile?.geminiApiKey ?? null, - }; + const apiKeys = profile + ? { + claudeApiKey: profile?.claudeApiKey ?? null, + geminiApiKey: profile?.geminiApiKey ?? null, + } + : undefined; const textareaRef = useRef(null); const [docSelectorOpen, setDocSelectorOpen] = useState(false); const [workflowModalOpen, setWorkflowModalOpen] = useState(false); @@ -116,7 +118,7 @@ export const ChatInput = forwardRef(function ChatInput( const handleSubmit = () => { const query = value.trim(); if (!query || isLoading) return; - if (!isModelAvailable(model, apiKeys)) { + if (apiKeys && !isModelAvailable(model, apiKeys)) { setApiKeyModalProvider(getModelProvider(model)); return; } diff --git a/frontend/src/app/components/projects/ProjectPage.tsx b/frontend/src/app/components/projects/ProjectPage.tsx index a2f7f5a..e82128d 100644 --- a/frontend/src/app/components/projects/ProjectPage.tsx +++ b/frontend/src/app/components/projects/ProjectPage.tsx @@ -66,6 +66,7 @@ import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; interface Props { projectId: string; + initialTab?: Tab; } type Tab = "documents" | "assistant" | "reviews"; @@ -271,7 +272,7 @@ function DocVersionHistory({ ); } -export function ProjectPage({ projectId }: Props) { +export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const [project, setProject] = useState(null); const [folders, setFolders] = useState([]); const [chats, setChats] = useState([]); @@ -282,7 +283,7 @@ export function ProjectPage({ projectId }: Props) { const tab: Tab = tabParam === "assistant" || tabParam === "reviews" ? tabParam - : "documents"; + : initialTab; const [addDocsOpen, setAddDocsOpen] = useState(false); const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 3522df3..86e3caa 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -447,6 +447,7 @@ function TRChatInput({ model, onModelChange, apiKeys, + onHeightChange, }: { isLoading: boolean; onSubmit: (value: string) => void; @@ -454,10 +455,42 @@ function TRChatInput({ model: string; onModelChange: (id: string) => void; apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }; + onHeightChange: (height: number) => void; }) { const [value, setValue] = useState(""); + const rootRef = useRef(null); const textareaRef = useRef(null); + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const notify = () => { + onHeightChange(root.getBoundingClientRect().height); + }; + notify(); + + const observer = new ResizeObserver(notify); + observer.observe(root); + window.addEventListener("resize", notify); + return () => { + observer.disconnect(); + window.removeEventListener("resize", notify); + }; + }, [onHeightChange]); + + function resizeTextarea(el: HTMLTextAreaElement) { + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 192)}px`; + el.style.overflowY = el.scrollHeight > 192 ? "auto" : "hidden"; + } + + function resetTextarea() { + if (!textareaRef.current) return; + textareaRef.current.style.height = "auto"; + textareaRef.current.style.overflowY = "hidden"; + } + function handleAction() { if (isLoading) { onCancel(); @@ -466,13 +499,16 @@ function TRChatInput({ const trimmed = value.trim(); if (!trimmed) return; setValue(""); - if (textareaRef.current) textareaRef.current.style.height = "auto"; + resetTextarea(); onSubmit(trimmed); } return ( -
-
+
+