Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
PORT=3001
FRONTEND_URL=http://localhost:3000

# HMAC key used to sign /download/:token URLs. Required at startup.
# Generate with: openssl rand -hex 32
# Use a dedicated secret distinct from SUPABASE_SECRET_KEY.
DOWNLOAD_SIGNING_SECRET=replace-with-a-random-32-byte-hex-string
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=your-supabase-service-role-key
# Required for cross-tenant test suite (npm run test:cross-tenant) to sign test
# users into anon-key sessions and obtain real JWTs. Optional for runtime.
SUPABASE_ANON_KEY=

DOWNLOAD_SIGNING_SECRET=your-random-signing-secret-min-32-chars

R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your-r2-access-key
Expand All @@ -15,6 +15,25 @@ R2_BUCKET_NAME=mike

GEMINI_API_KEY=your-gemini-key
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key

# Optional — when set, enables raw LLM stream console logging (debug only; remove in production)
LLM_STREAM_DEBUG=

OPENROUTER_API_KEY=your-openrouter-key
RESEND_API_KEY=your-resend-key
USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret

# Migration runner — Supabase direct connection, NOT the pgBouncer pooler.
# Format: postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgres
DATABASE_URL=

# LLM rate limiting (per-user, applies to all LLM-spending routes)
RATE_LIMIT_WINDOW_MS=60000 # Sliding window in milliseconds (default: 60000 = 1 minute)
RATE_LIMIT_MAX=20 # Max LLM requests per user per window (default: 20)

# CLEAN-05 — at-rest encryption of user LLM API keys (AES-256-GCM)
# Generate with: openssl rand -hex 32
HUGO_MASTER_KEY=

# CLEAN-44 — HMAC secret for account-restore tokens (30-day soft-delete window)
# Generate with: openssl rand -base64 48
HUGO_RESTORE_TOKEN_SECRET=
19 changes: 19 additions & 0 deletions backend/migrations/0000_baseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Baseline migration marker.
*
* The schema has already been applied via backend/migrations/000_one_shot_schema.sql.
* This file's only purpose is to give node-pg-migrate a tracked starting point;
* up/down are intentionally no-ops. Future schema changes ship as new
* timestamped migration files in this directory.
*/
import type { MigrationBuilder } from "node-pg-migrate";

export const shorthands = undefined;

export const up = (_pgm: MigrationBuilder): void => {
// No-op: baseline tracks the post-one-shot schema state.
};

export const down = (_pgm: MigrationBuilder): void => {
// No-op: baseline cannot be rolled back.
};
42 changes: 42 additions & 0 deletions backend/migrations/0001_auth_user_lookup_rpcs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { MigrationBuilder } from "node-pg-migrate";

export async function up(pgm: MigrationBuilder): Promise<void> {
pgm.sql(`
create or replace function public.get_auth_user_by_email(p_email text)
returns table (id uuid, email text)
language sql
security definer
set search_path = ''
as $$
select u.id, u.email
from auth.users u
where lower(u.email) = lower(p_email)
limit 1;
$$;

revoke all on function public.get_auth_user_by_email(text) from public, anon, authenticated;
grant execute on function public.get_auth_user_by_email(text) to service_role;

create or replace function public.get_auth_user_by_id(p_id uuid)
returns table (id uuid, email text)
language sql
security definer
set search_path = ''
as $$
select u.id, u.email
from auth.users u
where u.id = p_id
limit 1;
$$;

revoke all on function public.get_auth_user_by_id(uuid) from public, anon, authenticated;
grant execute on function public.get_auth_user_by_id(uuid) to service_role;
`);
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.sql(`
drop function if exists public.get_auth_user_by_email(text);
drop function if exists public.get_auth_user_by_id(uuid);
`);
}
16 changes: 16 additions & 0 deletions backend/migrations/0002_pdf_conversion_status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { MigrationBuilder } from "node-pg-migrate";

export async function up(pgm: MigrationBuilder): Promise<void> {
pgm.addColumns("documents", {
pdf_conversion_status: {
type: "text",
notNull: true,
default: "ok",
check: "pdf_conversion_status IN ('pending', 'ok', 'failed')",
},
});
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.dropColumns("documents", ["pdf_conversion_status"]);
}
115 changes: 115 additions & 0 deletions backend/migrations/0003_uuid_fk_billing_cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { MigrationBuilder } from "node-pg-migrate";

export async function up(pgm: MigrationBuilder): Promise<void> {
// 1. Drop indexes covering user_id columns (Postgres requires this before ALTER COLUMN TYPE)
pgm.sql("DROP INDEX IF EXISTS public.idx_projects_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_documents_user_project");
pgm.sql("DROP INDEX IF EXISTS public.idx_workflows_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_hidden_workflows_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_chats_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_tabular_reviews_user");
pgm.sql("DROP INDEX IF EXISTS public.tabular_review_chats_user_idx");

// 2. Drop composite UNIQUE on hidden_workflows (Postgres blocks type change of constrained column — Pitfall 4)
pgm.sql("ALTER TABLE public.hidden_workflows DROP CONSTRAINT IF EXISTS hidden_workflows_user_id_workflow_id_key");

// 3. Alter NOT NULL user_id columns to uuid + add FK CASCADE
const notNullTables = [
"projects",
"project_subfolders",
"documents",
"chats",
"tabular_reviews",
"tabular_review_chats",
"hidden_workflows",
];
for (const t of notNullTables) {
pgm.sql(`ALTER TABLE public.${t} ALTER COLUMN user_id TYPE uuid USING user_id::uuid`);
pgm.sql(`ALTER TABLE public.${t} ADD CONSTRAINT ${t}_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE`);
}

// 4. workflows.user_id is NULLABLE (Pitfall 3) — keep nullable, FK MATCH SIMPLE allows NULL
pgm.sql("ALTER TABLE public.workflows ALTER COLUMN user_id TYPE uuid USING user_id::uuid");
pgm.sql("ALTER TABLE public.workflows ADD CONSTRAINT workflows_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE");

// 5. workflow_shares.shared_by_user_id (NOT NULL)
pgm.sql("ALTER TABLE public.workflow_shares ALTER COLUMN shared_by_user_id TYPE uuid USING shared_by_user_id::uuid");
pgm.sql("ALTER TABLE public.workflow_shares ADD CONSTRAINT workflow_shares_shared_by_user_id_fkey FOREIGN KEY (shared_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE");

// 6. Re-add hidden_workflows composite UNIQUE
pgm.sql("ALTER TABLE public.hidden_workflows ADD CONSTRAINT hidden_workflows_user_id_workflow_id_key UNIQUE (user_id, workflow_id)");

// 7. Re-create dropped indexes
pgm.sql("CREATE INDEX IF NOT EXISTS idx_projects_user ON public.projects(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_documents_user_project ON public.documents(user_id, project_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_workflows_user ON public.workflows(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_hidden_workflows_user ON public.hidden_workflows(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_chats_user ON public.chats(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_tabular_reviews_user ON public.tabular_reviews(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS tabular_review_chats_user_idx ON public.tabular_review_chats(user_id)");

// 8. UNIQUE constraint on document_versions (backs CLEAN-08 retry pattern)
pgm.sql("ALTER TABLE public.document_versions ADD CONSTRAINT document_versions_doc_version_unique UNIQUE (document_id, version_number)");

// 9. Drop dead billing columns (CLEAN-48 — out of scope per REQUIREMENTS.md)
pgm.sql("ALTER TABLE public.user_profiles DROP COLUMN IF EXISTS tier");
pgm.sql("ALTER TABLE public.user_profiles DROP COLUMN IF EXISTS message_credits_used");
pgm.sql("ALTER TABLE public.user_profiles DROP COLUMN IF EXISTS credits_reset_date");
}

export async function down(pgm: MigrationBuilder): Promise<void> {
// 1. Restore billing columns
pgm.sql("ALTER TABLE public.user_profiles ADD COLUMN IF NOT EXISTS tier text NOT NULL DEFAULT 'Free'");
pgm.sql("ALTER TABLE public.user_profiles ADD COLUMN IF NOT EXISTS message_credits_used integer NOT NULL DEFAULT 0");
pgm.sql("ALTER TABLE public.user_profiles ADD COLUMN IF NOT EXISTS credits_reset_date timestamptz NOT NULL DEFAULT (now() + interval '30 days')");

// 2. Drop UNIQUE on document_versions
pgm.sql("ALTER TABLE public.document_versions DROP CONSTRAINT IF EXISTS document_versions_doc_version_unique");

// 3. Drop re-created indexes
pgm.sql("DROP INDEX IF EXISTS public.idx_projects_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_documents_user_project");
pgm.sql("DROP INDEX IF EXISTS public.idx_workflows_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_hidden_workflows_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_chats_user");
pgm.sql("DROP INDEX IF EXISTS public.idx_tabular_reviews_user");
pgm.sql("DROP INDEX IF EXISTS public.tabular_review_chats_user_idx");

// 4. Drop hidden_workflows composite UNIQUE constraint (recreated as text-based below)
pgm.sql("ALTER TABLE public.hidden_workflows DROP CONSTRAINT IF EXISTS hidden_workflows_user_id_workflow_id_key");

// 5. Drop FK constraints and revert uuid columns back to text (not null tables)
const notNullTables = [
"projects",
"project_subfolders",
"documents",
"chats",
"tabular_reviews",
"tabular_review_chats",
"hidden_workflows",
];
for (const t of notNullTables) {
pgm.sql(`ALTER TABLE public.${t} DROP CONSTRAINT IF EXISTS ${t}_user_id_fkey`);
pgm.sql(`ALTER TABLE public.${t} ALTER COLUMN user_id TYPE text USING user_id::text`);
}

// 6. Drop FK and revert workflows.user_id (nullable)
pgm.sql("ALTER TABLE public.workflows DROP CONSTRAINT IF EXISTS workflows_user_id_fkey");
pgm.sql("ALTER TABLE public.workflows ALTER COLUMN user_id TYPE text USING user_id::text");

// 7. Drop FK and revert workflow_shares.shared_by_user_id
pgm.sql("ALTER TABLE public.workflow_shares DROP CONSTRAINT IF EXISTS workflow_shares_shared_by_user_id_fkey");
pgm.sql("ALTER TABLE public.workflow_shares ALTER COLUMN shared_by_user_id TYPE text USING shared_by_user_id::text");

// 8. Re-add hidden_workflows composite UNIQUE (now text-based)
pgm.sql("ALTER TABLE public.hidden_workflows ADD CONSTRAINT hidden_workflows_user_id_workflow_id_key UNIQUE (user_id, workflow_id)");

// 9. Restore indexes
pgm.sql("CREATE INDEX IF NOT EXISTS idx_projects_user ON public.projects(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_documents_user_project ON public.documents(user_id, project_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_workflows_user ON public.workflows(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_hidden_workflows_user ON public.hidden_workflows(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_chats_user ON public.chats(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS idx_tabular_reviews_user ON public.tabular_reviews(user_id)");
pgm.sql("CREATE INDEX IF NOT EXISTS tabular_review_chats_user_idx ON public.tabular_review_chats(user_id)");
}
30 changes: 30 additions & 0 deletions backend/migrations/0004_select_review_doc_counts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { MigrationBuilder } from "node-pg-migrate";

export async function up(pgm: MigrationBuilder): Promise<void> {
pgm.sql(`
-- CLEAN-28: server-side aggregation for tabular review document counts.
-- EXPLAIN confirms idx_tabular_cells_review (review_id, document_id, column_index)
-- covers the (review_id, document_id) prefix — no new index needed.
CREATE OR REPLACE FUNCTION public.select_review_doc_counts(review_ids uuid[])
RETURNS TABLE (review_id uuid, doc_count bigint)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT review_id, count(DISTINCT document_id) AS doc_count
FROM public.tabular_cells
WHERE review_id = ANY(review_ids)
GROUP BY review_id;
$$;

REVOKE ALL ON FUNCTION public.select_review_doc_counts(uuid[]) FROM public, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.select_review_doc_counts(uuid[]) TO service_role;
`);
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.sql(`
DROP FUNCTION IF EXISTS public.select_review_doc_counts(uuid[]);
`);
}
Loading