Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
575b41d
fix(security): fail fast when download HMAC secret is missing
Metbcy May 3, 2026
e573204
fix(security): remove persisted raw Claude stream log
fayerman-source May 4, 2026
6c488bd
fix(projects): validate folder ownership before folder mutations
fayerman-source May 4, 2026
8a05fe2
fix(chat): require project access on chat creation endpoint
ryanmcdonough Apr 30, 2026
1cdbd33
feat: Add OpenRouter as third LLM provider
CharlesFTP May 1, 2026
f4d8045
chore(self-host): apply incremental migrations after the one-shot schema
Lef-F May 8, 2026
284890d
feat(mcp): add user-configurable Connectors with OAuth 2.1
ZachLaik May 8, 2026
11da4b9
chore(self-host): wire OPENROUTER_API_KEY, BACKEND_PUBLIC_URL, DOWNLO…
Lef-F May 8, 2026
4f988f8
fix(security): add RLS policies to projects, chats, and chat_messages
ryanmcdonough May 1, 2026
9979566
fix(security): harden data access, document uploads, and secret handling
kveton May 7, 2026
9f40245
chore(self-host): renumber security migration + fix incremental migra…
Lef-F May 8, 2026
c5e6141
chore(self-host): generate and pass DOWNLOAD_SIGNING_SECRET + USER_AP…
Lef-F May 8, 2026
597a219
chore(self-host): use --legacy-peer-deps for backend npm ci
Lef-F May 8, 2026
c5741fc
fix(security): require DOWNLOAD_SIGNING_SECRET for MCP OAuth state to…
Lef-F May 8, 2026
d5e0f74
fix(security): include user_mcp_servers in PostgREST lockdown
Lef-F May 8, 2026
da0bc6d
fix(security): SSRF guard on MCP server URLs + clear creds on URL change
Lef-F May 8, 2026
31c1817
fix(mcp): cap tool output bytes + structured ok/truncated result
Lef-F May 8, 2026
908f8e1
fix(security): lowercase emails on shared_with lookups + jsonb form i…
Lef-F May 8, 2026
3d46ed4
fix(llm): three OpenRouter bugs (debug log, tool-call id, model slugs)
Lef-F May 8, 2026
2740fa8
fix(upload): scan first 1024 bytes for PDF + drop JSZip private-API z…
Lef-F May 8, 2026
a96867e
fix(frontend): 3 post-merge bugs (auth race, openrouter tabular, prov…
Lef-F May 8, 2026
ca110ef
refactor(backend): consolidate handleDocumentUpload to a single home
Lef-F May 8, 2026
5355113
refactor(frontend): extract apiKeysFromProfile helper to remove 4-fol…
Lef-F May 8, 2026
701535b
refactor(backend): centralize provider-key encrypt/decrypt mapping
Lef-F May 8, 2026
693f133
refactor(backend): use native Buffer base64url everywhere
Lef-F May 8, 2026
c4ff092
feat(mcp): encrypt headers + oauth_tokens at rest with lazy upgrade
Lef-F May 8, 2026
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,30 @@ JWT_SECRET= # set by generate-secrets.sh
SUPABASE_PUBLISHABLE_KEY= # set by generate-secrets.sh (anon JWT)
SUPABASE_SECRET_KEY= # set by generate-secrets.sh (service_role JWT)

# --- Backend secrets ---------------------------------------------------------
DOWNLOAD_SIGNING_SECRET= # set by generate-secrets.sh; HMAC for /download/:token
USER_API_KEYS_ENCRYPTION_KEY= # set by generate-secrets.sh; encrypts user-stored LLM keys at rest

# --- GoTrue (laptop defaults; flip to false + add SMTP for real email) -------
GOTRUE_MAILER_AUTOCONFIRM=true
GOTRUE_DISABLE_SIGNUP=false

# --- LLM providers (set at least one) ----------------------------------------
ANTHROPIC_API_KEY=
GEMINI_API_KEY=
OPENROUTER_API_KEY=

# --- MCP Connectors (optional) -----------------------------------------------
# Externally-reachable backend URL used by MCP OAuth 2.1 callbacks. The
# default works for laptop use; OAuth-based MCP servers will only complete
# the callback if this URL is reachable from the third-party MCP server
# (i.e. you've exposed Mike to the public internet over TLS).
BACKEND_PUBLIC_URL=http://localhost:80/backend
# Allow MCP server URLs that point at private/loopback IPs and single-label
# hostnames. Required for any laptop dev where you run an MCP server on
# localhost or as a docker service alongside Mike. In production-style
# deployments (real domain, public traffic), leave unset to default-deny.
MCP_ALLOW_PRIVATE_HOSTS=true

# --- Garage ------------------------------------------------------------------
GARAGE_RPC_SECRET= # set by generate-secrets.sh
Expand Down
13 changes: 13 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
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

# Externally-reachable backend URL. Used to build the OAuth callback URL for
# MCP connectors. Defaults to http://localhost:${PORT} when unset.
BACKEND_PUBLIC_URL=http://localhost:3001
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=your-supabase-service-role-key

# Symmetric key used to encrypt user-supplied LLM API keys at rest.
# Required at startup. Generate with: openssl rand -hex 32
USER_API_KEYS_ENCRYPTION_KEY=replace-with-a-random-32-byte-hex-string

R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
Expand Down
251 changes: 244 additions & 7 deletions backend/migrations/000_one_shot_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ create table if not exists public.user_profiles (
tabular_model text not null default 'gemini-3-flash-preview',
claude_api_key text,
gemini_api_key text,
openrouter_api_key text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Expand All @@ -30,14 +31,7 @@ create index if not exists idx_user_profiles_user
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
Expand Down Expand Up @@ -82,6 +76,36 @@ create index if not exists idx_projects_user
create index if not exists projects_shared_with_idx
on public.projects using gin (shared_with);

alter table public.projects enable row level security;

drop policy if exists projects_select_owner_or_shared on public.projects;
create policy projects_select_owner_or_shared
on public.projects for select
using (
user_id = auth.uid()::text
or exists (
select 1
from jsonb_array_elements_text(coalesce(shared_with, '[]'::jsonb)) as member(email)
where lower(member.email) = lower(coalesce(auth.jwt()->>'email', ''))
)
);

drop policy if exists projects_insert_owner_only on public.projects;
create policy projects_insert_owner_only
on public.projects for insert
with check (user_id = auth.uid()::text);

drop policy if exists projects_update_owner_only on public.projects;
create policy projects_update_owner_only
on public.projects for update
using (user_id = auth.uid()::text)
with check (user_id = auth.uid()::text);

drop policy if exists projects_delete_owner_only on public.projects;
create policy projects_delete_owner_only
on public.projects for delete
using (user_id = auth.uid()::text);

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,
Expand Down Expand Up @@ -242,6 +266,65 @@ create index if not exists idx_chats_user
create index if not exists idx_chats_project
on public.chats(project_id);

alter table public.chats enable row level security;

drop policy if exists chats_select_owner_or_project_member on public.chats;
create policy chats_select_owner_or_project_member
on public.chats for select
using (
user_id = auth.uid()::text
or (
project_id is not null
and exists (
select 1
from public.projects p
where p.id = chats.project_id
and (
p.user_id = auth.uid()::text
or exists (
select 1
from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email)
where lower(member.email) = lower(coalesce(auth.jwt()->>'email', ''))
)
)
)
)
);

drop policy if exists chats_insert_user_and_project_access on public.chats;
create policy chats_insert_user_and_project_access
on public.chats for insert
with check (
user_id = auth.uid()::text
and (
project_id is null
or exists (
select 1
from public.projects p
where p.id = chats.project_id
and (
p.user_id = auth.uid()::text
or exists (
select 1
from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email)
where lower(member.email) = lower(coalesce(auth.jwt()->>'email', ''))
)
)
)
)
);

drop policy if exists chats_update_owner_only on public.chats;
create policy chats_update_owner_only
on public.chats for update
using (user_id = auth.uid()::text)
with check (user_id = auth.uid()::text);

drop policy if exists chats_delete_owner_only on public.chats;
create policy chats_delete_owner_only
on public.chats for delete
using (user_id = auth.uid()::text);

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,
Expand All @@ -255,6 +338,68 @@ create table if not exists public.chat_messages (
create index if not exists idx_chat_messages_chat
on public.chat_messages(chat_id);

alter table public.chat_messages enable row level security;

drop policy if exists chat_messages_select_by_chat_access on public.chat_messages;
create policy chat_messages_select_by_chat_access
on public.chat_messages for select
using (
exists (
select 1
from public.chats c
where c.id = chat_messages.chat_id
and (
c.user_id = auth.uid()::text
or (
c.project_id is not null
and exists (
select 1
from public.projects p
where p.id = c.project_id
and (
p.user_id = auth.uid()::text
or exists (
select 1
from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email)
where lower(member.email) = lower(coalesce(auth.jwt()->>'email', ''))
)
)
)
)
)
)
);

drop policy if exists chat_messages_insert_by_chat_access on public.chat_messages;
create policy chat_messages_insert_by_chat_access
on public.chat_messages for insert
with check (
exists (
select 1
from public.chats c
where c.id = chat_messages.chat_id
and (
c.user_id = auth.uid()::text
or (
c.project_id is not null
and exists (
select 1
from public.projects p
where p.id = c.project_id
and (
p.user_id = auth.uid()::text
or exists (
select 1
from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email)
where lower(member.email) = lower(coalesce(auth.jwt()->>'email', ''))
)
)
)
)
)
)
);

do $$
begin
if not exists (
Expand All @@ -272,6 +417,56 @@ begin
end;
$$;

-- ---------------------------------------------------------------------------
-- User MCP servers
-- ---------------------------------------------------------------------------

create table if not exists public.user_mcp_servers (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
slug text not null,
name text not null,
url text not null,
headers jsonb not null default '{}'::jsonb,
enabled boolean not null default true,
last_error text,
auth_type text not null default 'headers'
check (auth_type in ('headers', 'oauth')),
oauth_metadata jsonb,
oauth_tokens jsonb,
oauth_code_verifier text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint user_mcp_servers_slug_format
check (slug ~ '^[a-z0-9_-]{1,24}$'),
unique (user_id, slug)
);

create index if not exists idx_user_mcp_servers_user
on public.user_mcp_servers(user_id, enabled);

alter table public.user_mcp_servers enable row level security;

drop policy if exists "Users can view their own MCP servers" on public.user_mcp_servers;
create policy "Users can view their own MCP servers"
on public.user_mcp_servers for select
using (auth.uid() = user_id);

drop policy if exists "Users can insert their own MCP servers" on public.user_mcp_servers;
create policy "Users can insert their own MCP servers"
on public.user_mcp_servers for insert
with check (auth.uid() = user_id);

drop policy if exists "Users can update their own MCP servers" on public.user_mcp_servers;
create policy "Users can update their own MCP servers"
on public.user_mcp_servers for update
using (auth.uid() = user_id);

drop policy if exists "Users can delete their own MCP servers" on public.user_mcp_servers;
create policy "Users can delete their own MCP servers"
on public.user_mcp_servers for delete
using (auth.uid() = user_id);

-- ---------------------------------------------------------------------------
-- Tabular reviews
-- ---------------------------------------------------------------------------
Expand Down Expand Up @@ -338,3 +533,45 @@ create table if not exists public.tabular_review_chat_messages (

create index if not exists tabular_review_chat_messages_chat_idx
on public.tabular_review_chat_messages(chat_id, created_at);

-- ---------------------------------------------------------------------------
-- Security posture
-- ---------------------------------------------------------------------------
-- App data is accessed through backend service-role routes. Keep RLS enabled
-- without direct anon/authenticated policies so browser clients cannot read or
-- write raw tables such as user profiles, document metadata, or API keys.

alter table public.user_profiles 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;

revoke all on public.user_profiles from anon, authenticated;
revoke all on public.projects from anon, authenticated;
revoke all on public.project_subfolders from anon, authenticated;
revoke all on public.documents from anon, authenticated;
revoke all on public.document_versions from anon, authenticated;
revoke all on public.document_edits from anon, authenticated;
revoke all on public.workflows from anon, authenticated;
revoke all on public.hidden_workflows from anon, authenticated;
revoke all on public.workflow_shares from anon, authenticated;
revoke all on public.chats from anon, authenticated;
revoke all on public.chat_messages from anon, authenticated;
revoke all on public.tabular_reviews from anon, authenticated;
revoke all on public.tabular_cells from anon, authenticated;
revoke all on public.tabular_review_chats from anon, authenticated;
revoke all on public.tabular_review_chat_messages from anon, authenticated;
-- user_mcp_servers carries OAuth tokens and Authorization headers; it
-- absolutely must not be reachable via PostgREST under anon/authenticated.
revoke all on public.user_mcp_servers from anon, authenticated;
5 changes: 5 additions & 0 deletions backend/migrations/001_add_openrouter_api_key.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Add OpenRouter API key column to user_profiles
-- Run this migration in your Supabase SQL Editor

alter table public.user_profiles
add column if not exists openrouter_api_key text;
Loading