From 906440efbe59908d565c7cdb9e7954b430d23b22 Mon Sep 17 00:00:00 2001 From: Thijs de Zoete Date: Mon, 29 Jun 2026 11:56:34 +0200 Subject: [PATCH 1/6] Add self-hosted Docker stack with local Supabase, storage, email, and Ollama Bring the whole app up with a single `docker compose up` and no external accounts. Self-contained stack: - Embedded Supabase (Postgres + GoTrue auth + PostgREST + an nginx gateway), with the schema auto-loaded on first boot and auth emails auto-confirmable. - RustFS for S3-compatible object storage, replacing Cloudflare R2. - Mailpit catches auth emails (signup/confirmation) in a local inbox. - Production-style image builds for the frontend and backend. Local models via Ollama: - New provider speaking Ollama's OpenAI-compatible chat API, including the agentic tool-call loop and a graceful fallback for models that don't support tools. - Models are detected dynamically (GET /models/ollama) and appear in every model picker (chat, title, tabular review) under a "Local" group, with no API key required. Any `ollama/` id is valid by prefix. - Manual "Refresh" button on the API Keys page re-detects installed models and re-checks key status, pushing updates live to the pickers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 11 + README.md | 73 ++++++ backend/.dockerignore | 5 + backend/Dockerfile | 17 ++ backend/src/app.ts | 2 + backend/src/lib/llm/index.ts | 3 + backend/src/lib/llm/models.ts | 5 +- backend/src/lib/llm/ollama.ts | 198 +++++++++++++++++ backend/src/lib/llm/types.ts | 2 +- backend/src/routes/models.ts | 25 +++ backend/src/routes/tabular.ts | 1 + docker-compose.yml | 209 ++++++++++++++++++ frontend/.dockerignore | 8 + frontend/Dockerfile | 20 ++ .../src/app/(pages)/account/api-keys/page.tsx | 35 ++- .../src/app/(pages)/account/models/page.tsx | 9 +- .../app/components/assistant/ModelToggle.tsx | 12 +- frontend/src/app/hooks/useOllamaModels.ts | 49 ++++ frontend/src/app/hooks/useSelectedModel.ts | 8 +- frontend/src/app/lib/mikeApi.ts | 13 ++ frontend/src/app/lib/modelAvailability.ts | 6 +- supabase/gateway.conf | 52 +++++ supabase/roles.sql | 12 + 23 files changed, 758 insertions(+), 17 deletions(-) create mode 100644 .env.example create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/src/lib/llm/ollama.ts create mode 100644 backend/src/routes/models.ts create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/src/app/hooks/useOllamaModels.ts create mode 100644 supabase/gateway.conf create mode 100644 supabase/roles.sql diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..97ee09e37 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Root env for docker-compose: frontend build-time vars (baked into the bundle). +# Backend secrets live in backend/.env (see backend/.env.example). +# +# Defaults below are the stable demo keys printed by `supabase start`. If your +# local `supabase status` shows different values, paste them here. + +NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 +NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 + +# Local Supabase anon key (well-known CLI default): +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 diff --git a/README.md b/README.md index 66bdc7b90..d76ff66b9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,79 @@ It has a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloud Website: [mikeoss.com](https://mikeoss.com) +## Docker (local, account-free) + +Run the whole stack in containers with **one command** and no external +accounts. `docker-compose.yml` embeds everything: Supabase (Postgres + Auth + +data API + a gateway), RustFS for S3-compatible object storage, and the +frontend/backend. The schema loads itself on first boot. + +The only thing you must supply is **at least one model provider key** +(Anthropic, Gemini, or OpenAI — these are external APIs and cannot be +self-hosted). + +```bash +# 1. Env files +cp .env.example .env # frontend build vars (local demo keys prefilled) +cp backend/.env.example backend/.env + +# 2. In backend/.env set: +# - one of ANTHROPIC_API_KEY / GEMINI_API_KEY / OPENAI_API_KEY +# - DOWNLOAD_SIGNING_SECRET and USER_API_KEYS_ENCRYPTION_SECRET (openssl rand -hex 32) +# SUPABASE_URL / SUPABASE_SECRET_KEY / R2_* are set by docker-compose, leave them. + +# 3. Up +docker compose up --build +``` + +Open `http://localhost:3000` and sign up. Other endpoints: Postgres +`localhost:54322`, Supabase API `localhost:54321`, storage console +`localhost:9001` (`rustfsadmin` / `rustfsadmin`), and the **Mailpit inbox** at +`localhost:8025`. + +### What happens when you register + +1. You sign up with email + password at `localhost:3000`. +2. The Auth service creates an **unconfirmed** user and emails a confirmation + link. No mail leaves your machine — it is caught by Mailpit. +3. Open the Mailpit inbox at **http://localhost:8025**, open the "Confirm Your + Email" message, and click the link. That confirms the account and redirects + back to the app. +4. Now you can log in. + +To skip the email step entirely (instant signup), set +`GOTRUE_MAILER_AUTOCONFIRM: "true"` on the `auth` service in +`docker-compose.yml` and `docker compose up -d --force-recreate auth`. + +> Mailpit only catches **auth** emails (signup, password reset). Other app email +> (via Resend) still needs a real `RESEND_API_KEY` and is not routed to Mailpit. + +### Local models via Ollama + +[Ollama](https://ollama.com) models are detected **dynamically** — whatever you +have installed (`ollama list`) shows up in every model picker under a **Local** +group, with no API key. The backend reaches Ollama on the host at +`http://host.docker.internal:11434/v1` (override with `OLLAMA_BASE_URL`) and +exposes the live list at `GET /models/ollama`. + +Just pull a model and it appears after a refresh: + +```bash +ollama pull qwen3.6 +``` + +Notes: +- Models that support tool-calling can drive the full assistant; ones that + don't (e.g. `phi3:mini`) still work for plain chat — the backend retries + without tools automatically. +- Quality and speed depend on the local model; large models are noticeably + slower for tabular review (which runs the model across many cells). + +The Supabase JWT secret and the anon/`service_role` keys baked into +`docker-compose.yml` / `.env.example` are the well-known Supabase **local demo** +values — convenient for localhost, but regenerate them before exposing this +anywhere. + ## Contents - `frontend/` - Next.js application diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 000000000..0ebf8c613 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.* +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..b424d6b55 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +# ponytail: single stage. LibreOffice dwarfs everything, multi-stage saves little. +FROM node:22-slim + +# libreoffice-convert shells out to soffice for doc conversion (see src/lib/convert.ts) +RUN apt-get update \ + && apt-get install -y --no-install-recommends libreoffice \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +ENV NODE_ENV=production +EXPOSE 3001 +CMD ["npm", "start"] diff --git a/backend/src/app.ts b/backend/src/app.ts index 19cb5c3a0..54e5e1cb2 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,6 +11,7 @@ import { libraryRouter } from "./routes/library"; import { tabularRouter } from "./routes/tabular"; import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; +import { modelsRouter } from "./routes/models"; import { downloadsRouter } from "./routes/downloads"; import { caseLawRouter } from "./routes/caseLaw"; @@ -147,6 +148,7 @@ app.use((req, res, next) => ); app.use("/chat", chatRouter); +app.use("/models", modelsRouter); app.use("/projects", projectsRouter); app.use("/projects/:projectId/chat", projectChatRouter); app.use("/single-documents", documentsRouter); diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 4b5e97936..ad83bda0e 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,6 +1,7 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; import { streamOpenAI, completeOpenAIText } from "./openai"; +import { streamOllama, completeOllamaText } from "./ollama"; import { providerForModel } from "./models"; import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types"; @@ -13,6 +14,7 @@ export async function streamChatWithTools( const provider = providerForModel(params.model); if (provider === "claude") return streamClaude(params); if (provider === "openai") return streamOpenAI(params); + if (provider === "ollama") return streamOllama(params); return streamGemini(params); } @@ -26,5 +28,6 @@ export async function completeText(params: { const provider = providerForModel(params.model); if (provider === "claude") return completeClaudeText(params); if (provider === "openai") return completeOpenAIText(params); + if (provider === "ollama") return completeOllamaText(params); return completeGeminiText(params); } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 16b7bb3db..3652d2f48 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -16,6 +16,8 @@ export const GEMINI_MAIN_MODELS = [ "gemini-3-flash-preview", ] as const; export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4"] as const; +// Ollama models are detected dynamically (see GET /models/ollama). Any id of +// the form "ollama/" is valid — see providerForModel / resolveModel. // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; @@ -49,6 +51,7 @@ const ALL_MODELS = new Set([ // --------------------------------------------------------------------------- export function providerForModel(model: string): Provider { + if (model.startsWith("ollama")) return "ollama"; if (model.startsWith("claude")) return "claude"; if (model.startsWith("gemini")) return "gemini"; if (model.startsWith("gpt-")) return "openai"; @@ -56,6 +59,6 @@ export function providerForModel(model: string): Provider { } export function resolveModel(id: string | null | undefined, fallback: string): string { - if (id && ALL_MODELS.has(id)) return id; + if (id && (ALL_MODELS.has(id) || id.startsWith("ollama/"))) return id; return fallback; } diff --git a/backend/src/lib/llm/ollama.ts b/backend/src/lib/llm/ollama.ts new file mode 100644 index 000000000..6ccb2c3fd --- /dev/null +++ b/backend/src/lib/llm/ollama.ts @@ -0,0 +1,198 @@ +// Ollama provider — talks to Ollama's OpenAI-compatible /v1/chat/completions. +// No API key (local). Base URL + model are overridable per-instance: +// OLLAMA_BASE_URL (default http://localhost:11434/v1) +// OLLAMA_MODEL (default: the tag after "ollama/" in the model id) +import type { + StreamChatParams, + StreamChatResult, + NormalizedToolCall, + LlmMessage, + OpenAIToolSchema, +} from "./types"; + +function baseUrl(): string { + return (process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434/v1").replace(/\/$/, ""); +} + +function modelName(modelId: string): string { + // The id's tag (e.g. "ollama/qwen3.6:latest" -> "qwen3.6:latest") wins; the + // OLLAMA_MODEL env is only a fallback for a bare "ollama" id. + const tag = modelId.replace(/^ollama\/?/, ""); + return tag || process.env.OLLAMA_MODEL?.trim() || "qwen3.6"; +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw new Error("Request aborted"); +} + +// Chat-completions message shape (superset of LlmMessage with tool roles). +type ChatMessage = + | { role: "system" | "user" | "assistant"; content: string } + | { + role: "assistant"; + content: string; + tool_calls: { + id: string; + type: "function"; + function: { name: string; arguments: string }; + }[]; + } + | { role: "tool"; tool_call_id: string; content: string }; + +function initialMessages(systemPrompt: string, messages: LlmMessage[]): ChatMessage[] { + const out: ChatMessage[] = []; + if (systemPrompt.trim()) out.push({ role: "system", content: systemPrompt }); + for (const m of messages) out.push({ role: m.role, content: m.content }); + return out; +} + +// Accumulates streamed tool-call deltas (id/name arrive once, arguments stream). +type PartialToolCall = { id: string; name: string; arguments: string }; + +async function postChat( + body: unknown, + signal: AbortSignal | undefined, +): Promise { + const response = await fetch(`${baseUrl()}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Ollama request failed (${response.status}): ${text || response.statusText}`, + ); + } + return response; +} + +export async function streamOllama( + params: StreamChatParams, +): Promise { + const { model, systemPrompt, tools = [], callbacks = {}, runTools } = params; + const maxIter = params.maxIterations ?? 10; + const messages = initialMessages(systemPrompt, params.messages); + let fullText = ""; + // Some small local models reject the `tools` param. Drop it and carry on + // (the model just can't call tools) rather than failing the whole chat. + let useTools = tools.length > 0; + + for (let iter = 0; iter < maxIter; iter++) { + throwIfAborted(params.abortSignal); + const sendBody = () => ({ + model: modelName(model), + messages, + tools: useTools ? tools : undefined, + stream: true, + }); + let response: Response; + try { + response = await postChat(sendBody(), params.abortSignal); + } catch (err) { + if (useTools && /does not support tools/i.test(String((err as Error)?.message))) { + useTools = false; + response = await postChat(sendBody(), params.abortSignal); + } else { + throw err; + } + } + if (!response.body) throw new Error("Ollama response had no body"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const partials = new Map(); + let assistantText = ""; + let buffer = ""; + + while (true) { + throwIfAborted(params.abortSignal); + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE: events are newline-delimited "data: {json}" lines. + let nl: number; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") continue; + + const delta = JSON.parse(data)?.choices?.[0]?.delta; + if (!delta) continue; + + if (typeof delta.content === "string" && delta.content) { + assistantText += delta.content; + fullText += delta.content; + callbacks.onContentDelta?.(delta.content); + } + for (const tc of delta.tool_calls ?? []) { + const idx = tc.index ?? 0; + const acc = partials.get(idx) ?? { id: "", name: "", arguments: "" }; + if (tc.id) acc.id = tc.id; + if (tc.function?.name) acc.name = tc.function.name; + if (tc.function?.arguments) acc.arguments += tc.function.arguments; + partials.set(idx, acc); + } + } + } + + const toolCalls: NormalizedToolCall[] = [...partials.values()].map((p) => { + let input: Record = {}; + try { + input = p.arguments ? JSON.parse(p.arguments) : {}; + } catch { + input = {}; + } + return { id: p.id || p.name, name: p.name, input }; + }); + + if (!toolCalls.length || !runTools) break; + + // Echo the assistant turn (with tool_calls) then feed tool results back. + messages.push({ + role: "assistant", + content: assistantText, + tool_calls: toolCalls.map((c) => ({ + id: c.id, + type: "function", + function: { name: c.name, arguments: JSON.stringify(c.input) }, + })), + }); + for (const call of toolCalls) callbacks.onToolCallStart?.(call); + + throwIfAborted(params.abortSignal); + const results = await runTools(toolCalls); + for (const r of results) { + messages.push({ role: "tool", tool_call_id: r.tool_use_id, content: r.content }); + } + } + + return { fullText }; +} + +export async function completeOllamaText(params: { + model: string; + systemPrompt?: string; + user: string; + maxTokens?: number; +}): Promise { + const response = await postChat( + { + model: modelName(params.model), + messages: initialMessages(params.systemPrompt ?? "", [ + { role: "user", content: params.user }, + ]), + max_tokens: params.maxTokens ?? 512, + stream: false, + }, + undefined, + ); + const json = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + return json.choices?.[0]?.message?.content ?? ""; +} diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index 6a9f18acf..b63a36933 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -2,7 +2,7 @@ // Callers always speak OpenAI-style tools + { role, content } messages; each // provider translates internally. -export type Provider = "claude" | "gemini" | "openai"; +export type Provider = "claude" | "gemini" | "openai" | "ollama"; export type OpenAIToolSchema = { type: "function"; diff --git a/backend/src/routes/models.ts b/backend/src/routes/models.ts new file mode 100644 index 000000000..b0de3a88f --- /dev/null +++ b/backend/src/routes/models.ts @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; + +export const modelsRouter = Router(); + +// Live list of locally installed Ollama models, shaped like the frontend's +// ModelOption. Returns [] when Ollama is unreachable so the app still works. +modelsRouter.get("/ollama", requireAuth, async (_req, res) => { + const base = ( + process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434/v1" + ).replace(/\/$/, ""); + try { + const r = await fetch(`${base}/models`); + if (!r.ok) return void res.json({ models: [] }); + const data = (await r.json()) as { data?: { id: string }[] }; + const models = (data.data ?? []).map((m) => ({ + id: `ollama/${m.id}`, + label: `${m.id} (local)`, + group: "Local", + })); + res.json({ models }); + } catch { + res.json({ models: [] }); + } +}); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 27c28c3d4..035022d67 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -78,6 +78,7 @@ function providerLabel(provider: Provider): string { function missingModelApiKey(model: string, apiKeys: UserApiKeys) { const provider = providerForModel(model); + if (provider === "ollama") return null; // local, no key if (apiKeys[provider]?.trim()) return null; return { provider, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..cbedea8b8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,209 @@ +# MikeOSS — full local stack, one `docker compose up`. No external accounts. +# Supabase is embedded (db + auth + rest + gateway); storage is RustFS. +# The only thing you must supply is one model API key in backend/.env. +# +# JWT secret + anon/service_role keys below are the well-known Supabase local +# demo values — fine for localhost, NOT for anything exposed. The keys are JWTs +# signed with the secret; don't change one without regenerating the others. + +services: + # --- Supabase: Postgres (ships base roles, the `auth` schema, auth.uid, etc.) + db: + image: supabase/postgres:17.6.1.136 + ports: + - "54322:5432" # psql from host if needed + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long + JWT_EXP: 3600 + volumes: + - db_data:/var/lib/postgresql/data + - ./supabase/roles.sql:/docker-entrypoint-initdb.d/zz-roles.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + # --- Supabase: Auth (GoTrue). Auto-runs its migrations to build auth.users. + auth: + image: supabase/gotrue:v2.189.0 + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: "9999" + API_EXTERNAL_URL: http://localhost:54321 + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:postgres@db:5432/postgres + GOTRUE_SITE_URL: http://localhost:3000 + GOTRUE_URI_ALLOW_LIST: "*" + GOTRUE_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long + GOTRUE_JWT_EXP: "3600" + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_EXTERNAL_PHONE_ENABLED: "false" + # Send a real confirmation email (caught by Mailpit) instead of auto-confirming. + GOTRUE_MAILER_AUTOCONFIRM: "false" + GOTRUE_SMTP_HOST: mailpit + GOTRUE_SMTP_PORT: "1025" + # No SMTP_USER/PASS: Mailpit needs no auth, and GoTrue refuses to send + # credentials over a plaintext (non-TLS) connection ("unencrypted connection"). + GOTRUE_SMTP_ADMIN_EMAIL: admin@mike.local + GOTRUE_SMTP_SENDER_NAME: Mike + # Confirmation/recovery links resolve to the gateway, which strips /auth/v1. + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + depends_on: + db: + condition: service_healthy + mailpit: + condition: service_started + restart: unless-stopped + + # --- Supabase: data API (PostgREST). Backend queries tables through this. + rest: + image: postgrest/postgrest:v14.12 + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:postgres@db:5432/postgres + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long + restart: unless-stopped + + # --- Supabase: gateway. supabase-js hits :54321/{auth,rest}/v1/* through here. + gateway: + image: nginx:alpine + ports: + - "54321:8000" + volumes: + - ./supabase/gateway.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - auth + - rest + restart: unless-stopped + + # --- One-shot: load schema.sql once auth.users exists; grant service_role. + db-init: + image: supabase/postgres:17.6.1.136 + depends_on: + db: + condition: service_healthy + auth: + condition: service_started + environment: + DB: postgres://postgres:postgres@db:5432/postgres + volumes: + - ./backend/schema.sql:/schema.sql:ro + entrypoint: ["bash", "-c"] + command: + - | + until psql "$$DB" -tAc "select to_regclass('auth.users')" | grep -q auth; do + echo "waiting for auth schema..."; sleep 2; + done + if psql "$$DB" -tAc "select to_regclass('public.user_profiles')" | grep -q user_profiles; then + echo "schema already applied, skipping"; + else + psql "$$DB" -v ON_ERROR_STOP=1 -f /schema.sql; + fi + psql "$$DB" -c "grant usage on schema public to service_role; + grant all privileges on all tables in schema public to service_role; + grant all privileges on all sequences in schema public to service_role;" + restart: "no" + + # --- Local SMTP catcher. Auth emails land in its web inbox (no real sending). + mailpit: + image: axllent/mailpit:latest + ports: + - "8025:8025" # web inbox + - "1025:1025" # SMTP + environment: + MP_SMTP_AUTH_ACCEPT_ANY: "1" + MP_SMTP_AUTH_ALLOW_INSECURE: "1" + restart: unless-stopped + + # --- S3-compatible object storage (RustFS, MinIO alternative). + storage: + image: rustfs/rustfs:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + RUSTFS_VOLUMES: /data + RUSTFS_ADDRESS: 0.0.0.0:9000 + RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001 + RUSTFS_CONSOLE_ENABLE: "true" + RUSTFS_ACCESS_KEY: rustfsadmin + RUSTFS_SECRET_KEY: rustfsadmin + volumes: + - storage_data:/data + restart: unless-stopped + + createbucket: + image: amazon/aws-cli + depends_on: + - storage + environment: + AWS_ACCESS_KEY_ID: rustfsadmin + AWS_SECRET_ACCESS_KEY: rustfsadmin + AWS_DEFAULT_REGION: us-east-1 + entrypoint: /bin/sh + command: > + -c "until aws --endpoint-url http://storage:9000 s3 ls; do echo 'waiting for storage'; sleep 2; done; + aws --endpoint-url http://storage:9000 s3 mb s3://mike || true" + restart: "no" + + backend: + build: ./backend + ports: + - "3001:3001" + env_file: ./backend/.env # model key(s) + the two generated *_SECRET values + environment: + # Topology + local Supabase wiring (override .env placeholders). + PORT: 3001 + FRONTEND_URL: http://localhost:3000 + SUPABASE_URL: http://gateway:8000 + SUPABASE_SECRET_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + R2_ENDPOINT_URL: http://storage:9000 + R2_ACCESS_KEY_ID: rustfsadmin + R2_SECRET_ACCESS_KEY: rustfsadmin + R2_BUCKET_NAME: mike + # Local Ollama running on the host. Override OLLAMA_MODEL to change the tag. + OLLAMA_BASE_URL: http://host.docker.internal:11434/v1 + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + db-init: + condition: service_completed_successfully + createbucket: + condition: service_completed_successfully + auth: + condition: service_started + rest: + condition: service_started + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:54321} + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY} + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3001} + ports: + - "3000:3000" + depends_on: + - backend + restart: unless-stopped + +volumes: + db_data: + storage_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 000000000..8e0cc21ff --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.open-next +out +build +.env +.env.* +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..a444e4ace --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-slim + +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . + +# NEXT_PUBLIC_* are inlined at build time, so they must be present during `next build`. +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY +ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY \ + NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +RUN npm run build + +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/frontend/src/app/(pages)/account/api-keys/page.tsx b/frontend/src/app/(pages)/account/api-keys/page.tsx index f9da3d36f..f306049fc 100644 --- a/frontend/src/app/(pages)/account/api-keys/page.tsx +++ b/frontend/src/app/(pages)/account/api-keys/page.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, RefreshCw } from "lucide-react"; import { Input } from "@/app/components/ui/input"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { refreshOllamaModels } from "@/app/hooks/useOllamaModels"; import { MfaVerificationPopup, needsMfaVerification, @@ -49,13 +50,37 @@ const OTHER_API_KEY_FIELDS = [ ] as const; export default function ApiKeysPage() { - const { profile, updateApiKey } = useUserProfile(); + const { profile, updateApiKey, reloadProfile } = useUserProfile(); + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await Promise.all([reloadProfile(), refreshOllamaModels()]); + } finally { + setRefreshing(false); + } + }; return (
-

- API Keys -

+
+

+ API Keys +

+ +

You must provide your own API keys for the app to work or add your API keys into the .env file if you are running your own diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 6133c6efd..f3ca85ccc 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -28,11 +28,13 @@ import { accountGlassInputClassName, } from "../accountStyles"; import { AccountSection } from "../AccountSection"; +import { useOllamaModels } from "@/app/hooks/useOllamaModels"; type ModelPreferenceField = "titleModel" | "tabularModel"; export default function ModelPreferencesPage() { const { profile, updateModelPreference } = useUserProfile(); + const ollamaModels = useOllamaModels(); const [savingField, setSavingField] = useState( null, ); @@ -95,7 +97,7 @@ export default function ModelPreferencesPage() { profile?.titleModel ?? "gemini-3.1-flash-lite-preview" } - options={SETTINGS_MODELS} + options={[...SETTINGS_MODELS, ...ollamaModels]} apiKeys={profile?.apiKeys} isSaving={savingField === "titleModel"} isSaved={savedField === "titleModel"} @@ -117,7 +119,7 @@ export default function ModelPreferencesPage() { profile?.tabularModel ?? "gemini-3-flash-preview" } - options={MODELS} + options={[...MODELS, ...ollamaModels]} apiKeys={profile?.apiKeys} isSaving={savingField === "tabularModel"} isSaved={savedField === "tabularModel"} @@ -147,10 +149,11 @@ function ModelPreferenceDropdown({ const [isOpen, setIsOpen] = useState(false); const selected = options.find((m) => m.id === value); const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true; - const groups: ("Anthropic" | "Google" | "OpenAI")[] = [ + const groups: ModelOption["group"][] = [ "Anthropic", "Google", "OpenAI", + "Local", ]; return ( diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index abd20b9b5..05c2c6e85 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -14,11 +14,12 @@ import { } from "@/app/components/ui/liquid-dropdown"; import { isModelAvailable } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; +import { useOllamaModels } from "@/app/hooks/useOllamaModels"; export interface ModelOption { id: string; label: string; - group: "Anthropic" | "Google" | "OpenAI"; + group: "Anthropic" | "Google" | "OpenAI" | "Local"; } export const MODELS: ModelOption[] = [ @@ -31,6 +32,7 @@ export const MODELS: ModelOption[] = [ { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, { id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" }, { id: "gpt-5.4", label: "GPT-5.4", group: "OpenAI" }, + // Local (Ollama) models are appended dynamically — see useOllamaModels. ]; export const SETTINGS_MODELS: ModelOption[] = [ @@ -48,7 +50,7 @@ export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"]; +const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI", "Local"]; const itemClassName = "rounded-xl px-2.5 py-1.5 text-gray-700 focus:bg-app-surface-hover focus:text-gray-900 data-[highlighted]:bg-app-surface-hover data-[highlighted]:text-gray-900"; @@ -60,7 +62,9 @@ interface Props { export function ModelToggle({ value, onChange, apiKeys }: Props) { const [isOpen, setIsOpen] = useState(false); - const selected = MODELS.find((m) => m.id === value); + const ollamaModels = useOllamaModels(); + const models = [...MODELS, ...ollamaModels]; + const selected = models.find((m) => m.id === value); const selectedLabel = selected?.label ?? "Model"; const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) @@ -93,7 +97,7 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { align="end" > {GROUP_ORDER.map((group, gi) => { - const items = MODELS.filter((m) => m.group === group); + const items = models.filter((m) => m.group === group); if (items.length === 0) return null; return (

diff --git a/frontend/src/app/hooks/useOllamaModels.ts b/frontend/src/app/hooks/useOllamaModels.ts new file mode 100644 index 000000000..93199a180 --- /dev/null +++ b/frontend/src/app/hooks/useOllamaModels.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { getOllamaModels, type OllamaModelOption } from "@/app/lib/mikeApi"; + +// Module-level store so every picker shares one fetch and a refresh propagates +// to all of them. Empty list if Ollama is unreachable — the app works without it. +let cache: OllamaModelOption[] | null = null; +let inflight: Promise | null = null; +const listeners = new Set<() => void>(); + +function load(force = false): Promise { + if (force) { + cache = null; + inflight = null; + } + if (cache) return Promise.resolve(cache); + if (!inflight) { + inflight = getOllamaModels() + .then((m) => { + cache = m; + listeners.forEach((l) => l()); + return m; + }) + .catch(() => { + inflight = null; // don't poison cache — retry on next load + return []; + }); + } + return inflight; +} + +// Clear the cache and refetch; mounted pickers update automatically. +export function refreshOllamaModels(): Promise { + return load(true); +} + +export function useOllamaModels(): OllamaModelOption[] { + const [models, setModels] = useState(cache ?? []); + + useEffect(() => { + const update = () => setModels(cache ?? []); + listeners.add(update); + void load().then(update); + return () => { + listeners.delete(update); + }; + }, []); + + return models; +} diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 5b5530e55..eaba65b93 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -5,10 +5,14 @@ import { ALLOWED_MODEL_IDS, DEFAULT_MODEL_ID } from "../components/assistant/Mod const STORAGE_KEY = "mike.selectedModel"; +function isAllowed(id: string): boolean { + return ALLOWED_MODEL_IDS.has(id) || id.startsWith("ollama/"); +} + function readStored(): string { if (typeof window === "undefined") return DEFAULT_MODEL_ID; const raw = window.localStorage.getItem(STORAGE_KEY); - if (raw && ALLOWED_MODEL_IDS.has(raw)) return raw; + if (raw && isAllowed(raw)) return raw; return DEFAULT_MODEL_ID; } @@ -21,7 +25,7 @@ export function useSelectedModel(): [string, (id: string) => void] { }, []); const setModel = useCallback((id: string) => { - const next = ALLOWED_MODEL_IDS.has(id) ? id : DEFAULT_MODEL_ID; + const next = isAllowed(id) ? id : DEFAULT_MODEL_ID; setModelState(next); if (typeof window !== "undefined") { window.localStorage.setItem(STORAGE_KEY, next); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 6dbaa1e6e..7e7bbde6e 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -299,6 +299,19 @@ export async function getApiKeyStatus(): Promise { return apiRequest("/user/api-keys"); } +export interface OllamaModelOption { + id: string; + label: string; + group: "Local"; +} + +export async function getOllamaModels(): Promise { + const { models } = await apiRequest<{ models: OllamaModelOption[] }>( + "/models/ollama", + ); + return models; +} + export async function saveApiKey( provider: ApiKeyProvider, apiKey: string | null, diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index fb0f09abf..b39d1d6f1 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,9 +1,10 @@ import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle"; import type { ApiKeyState } from "@/app/lib/mikeApi"; -export type ModelProvider = "claude" | "gemini" | "openai"; +export type ModelProvider = "claude" | "gemini" | "openai" | "ollama"; export function getModelProvider(modelId: string): ModelProvider | null { + if (modelId.startsWith("ollama/")) return "ollama"; // dynamic, not in the static list const model = SETTINGS_MODELS.find((m) => m.id === modelId); if (!model) return null; return modelGroupToProvider(model.group); @@ -22,12 +23,14 @@ export function isProviderAvailable( provider: ModelProvider, apiKeys: ApiKeyState, ): boolean { + if (provider === "ollama") return true; // local, no key needed return !!apiKeys[provider]?.configured; } export function providerLabel(provider: ModelProvider): string { if (provider === "claude") return "Anthropic (Claude)"; if (provider === "openai") return "OpenAI"; + if (provider === "ollama") return "Local (Ollama)"; return "Google (Gemini)"; } @@ -36,5 +39,6 @@ export function modelGroupToProvider( ): ModelProvider { if (group === "Anthropic") return "claude"; if (group === "OpenAI") return "openai"; + if (group === "Local") return "ollama"; return "gemini"; } diff --git a/supabase/gateway.conf b/supabase/gateway.conf new file mode 100644 index 000000000..3e88dce5c --- /dev/null +++ b/supabase/gateway.conf @@ -0,0 +1,52 @@ +# Minimal Supabase gateway: route the two API prefixes supabase-js uses, and +# handle CORS here (the browser calls this from the frontend's origin). +# Upstreams resolve at request time via Docker's DNS (127.0.0.11) so recreating +# the auth/rest containers doesn't leave nginx pinned to a stale IP. +# +# Note: nginx add_header in a location does NOT apply to a response returned +# from inside an `if`, so the preflight branch repeats the CORS headers. + +server { + listen 8000; + server_name _; + client_max_body_size 50m; + resolver 127.0.0.11 ipv6=off valid=10s; + + location /auth/v1/ { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + add_header Access-Control-Max-Age 86400 always; + add_header Content-Length 0; + return 204; + } + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + + set $auth_upstream auth:9999; + rewrite ^/auth/v1/(.*)$ /$1 break; + proxy_pass http://$auth_upstream; + } + + location /rest/v1/ { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers $http_access_control_request_headers always; + add_header Access-Control-Max-Age 86400 always; + add_header Content-Length 0; + return 204; + } + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + + set $rest_upstream rest:3000; + rewrite ^/rest/v1/(.*)$ /$1 break; + proxy_pass http://$rest_upstream; + } +} diff --git a/supabase/roles.sql b/supabase/roles.sql new file mode 100644 index 000000000..b70b422b9 --- /dev/null +++ b/supabase/roles.sql @@ -0,0 +1,12 @@ +-- Runs once on first DB init (after the supabase/postgres image creates its +-- base roles). Guarantees GoTrue and PostgREST can log in with POSTGRES_PASSWORD. +-- ponytail: only forces the two login passwords; everything else the image sets up. +do $$ +begin + if exists (select from pg_roles where rolname = 'supabase_auth_admin') then + alter role supabase_auth_admin with login password 'postgres'; + end if; + if exists (select from pg_roles where rolname = 'authenticator') then + alter role authenticator with login password 'postgres'; + end if; +end $$; From 04cdbe9ab3d63bb6904b665ae85315b04222668b Mon Sep 17 00:00:00 2001 From: Thijs de Zoete Date: Mon, 29 Jun 2026 23:03:39 +0200 Subject: [PATCH 2/6] docs: add design spec for legal workflows (C&D, contract triage, MSA review) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-29-legal-workflows-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-legal-workflows-design.md diff --git a/docs/superpowers/specs/2026-06-29-legal-workflows-design.md b/docs/superpowers/specs/2026-06-29-legal-workflows-design.md new file mode 100644 index 000000000..1365682d9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-legal-workflows-design.md @@ -0,0 +1,155 @@ +# Legal Workflows — Design Spec + +Date: 2026-06-29 + +## Goal + +Add three curated assistant workflows: a cease-and-desist letter drafter, a +contract-triage router, and a Master Services Agreement review. + +## Architecture + +Workflows are titled prompts (`{ id, title, prompt_md }`). Built-ins are +code-defined in `backend/src/lib/builtinWorkflows.ts`, seeded into the runtime +`WorkflowStore` by `buildWorkflowStore` (`lib/chatTools.ts`), and exposed to the +assistant via the `list_workflows` and `read_workflow` tools. No DB or schema +change is involved. + +These three live in a **new file** `backend/src/lib/legalWorkflows.ts` +exporting `LEGAL_WORKFLOWS: { id: string; title: string; prompt_md: string }[]`. +`builtinWorkflows.ts` imports and spreads it into `BUILTIN_WORKFLOWS`, so the +single existing seed path picks them up automatically. + +```ts +// builtinWorkflows.ts +import { LEGAL_WORKFLOWS } from "./legalWorkflows"; +export const BUILTIN_WORKFLOWS = [ ...existing, ...LEGAL_WORKFLOWS ]; +``` + +No other code changes. Chaining (workflow #2) works because the assistant can +call `list_workflows` then `read_workflow` mid-run to load and follow another +workflow's prompt. + +## Workflow 1 — `builtin-cease-and-desist` + +Title: **Draft Cease & Desist (IP infringement)** + +Behaviour: gather the essentials (asking for anything not already supplied or +found in uploaded documents), then draft a formal C&D letter as a downloadable +`.docx`. + +Draft `prompt_md`: + +> ## Draft a Cease & Desist Letter (IP infringement) +> +> Help the user draft a formal cease-and-desist letter to a party copying their +> product or intellectual property. +> +> First, make sure you have the information below. Use anything available in +> uploaded documents or the conversation; for anything missing, ask the user in +> a single consolidated list and wait for their reply before drafting: +> - Rights holder: full legal name and address of the party sending the letter. +> - The IP/rights being infringed: copyright, trademark, and/or trade dress — +> and any registration numbers or first-use/priority dates if available. +> - The original product or work being copied (short description). +> - The infringer: name and address/contact if known. +> - Where the infringement appears: marketplace/site names and specific URLs or +> listing IDs. +> - How they are infringing (a concrete description of the copying). +> - Desired remedies (e.g. stop sales, remove listings, destroy inventory, +> written confirmation) and a compliance deadline (e.g. 10 business days). +> +> Then use the generate_docx tool to produce the letter as a downloadable Word +> document. Structure it as a formal business letter: sender block, date, +> recipient block, "RE:" line, body paragraphs (identify the rights and the +> ownership, describe the infringing conduct with the specifics gathered, +> demand the listed remedies by the stated deadline, and reserve all rights and +> remedies), and a signature block. +> +> Keep the tone firm and professional. Do not assert facts the user has not +> provided. Begin the document with a short italicised note: "Draft for review — +> not legal advice. Have qualified counsel review before sending." Do not give a +> legal opinion on the strength of the claim. + +## Workflow 2 — `builtin-contract-triage` + +Title: **Review Contract (auto-detect type)** + +Behaviour: detect the contract type, then load and follow the matching review +workflow; fall back to a generic review for unrecognised types. + +Draft `prompt_md`: + +> ## Review Contract — detect type and run the right review +> +> 1. Read the uploaded contract and identify its type (e.g. Master Services +> Agreement, Shareholder Agreement, Credit/Facility Agreement, NDA, SaaS +> subscription, employment agreement, etc.). State the detected type in one +> line. +> 2. Call list_workflows and look for a review workflow matching the detected +> type: +> - Master Services Agreement → "Master Services Agreement Review" +> - Shareholder Agreement → "Shareholder Agreement Summary" +> - Credit or Facility Agreement → "Credit Agreement Summary" +> If a match exists, call read_workflow with its id and follow those +> instructions exactly for this document. +> 3. If no specific review workflow matches the detected type, do a general +> contract review instead: parties and roles, term and renewal, key +> obligations of each party, fees/payment, liability and indemnities, +> termination rights and effects, governing law and dispute resolution, and a +> list of any unusual, onerous, or non-market terms with clause references. +> +> Always ground findings in the document and cite clause or section references. + +## Workflow 3 — `builtin-msa-review` + +Title: **Master Services Agreement Review** + +Behaviour: structured summary like the existing Credit/SHA reviews, delivered +inline (docx only if the user asks). + +Draft `prompt_md`: + +> ## Master Services Agreement Review +> +> Review the uploaded Master Services Agreement and produce a comprehensive +> legal summary covering the topics below. For each, identify the key +> provisions, quote the relevant clause or schedule references, and flag any +> unusual, onerous, or non-market terms. +> +> 1. **Parties** — full legal names, roles (customer/supplier), and jurisdictions. +> 2. **Structure** — how the MSA relates to SOWs/Order Forms; order of precedence. +> 3. **Term & Renewal** — initial term, renewal mechanism, and notice periods. +> 4. **Services & SOW mechanics** — how services are scoped, changed, and accepted. +> 5. **Fees & Payment** — pricing model, invoicing, payment terms, late fees, +> expenses, and any rate-increase mechanism. +> 6. **Intellectual Property** — ownership of pre-existing IP and deliverables, +> and any licences granted. +> 7. **Confidentiality** — scope, exclusions, and duration. +> 8. **Data Protection & Security** — data processing obligations, security +> standards, breach notification, and any DPA reference. +> 9. **Warranties** — service warranties and disclaimers. +> 10. **Indemnities** — who indemnifies whom and for what (e.g. IP, data breach). +> 11. **Limitation of Liability** — liability caps, the cap amount/formula, and +> carve-outs (e.g. confidentiality, IP, data breach, indemnities). +> 12. **Insurance** — required coverage types and amounts. +> 13. **Termination** — termination for cause/convenience, cure periods, and the +> effects of termination (transition, return/destruction of data). +> 14. **Governing Law & Dispute Resolution** — governing law, forum, and whether +> litigation or arbitration. +> +> Deliver the summary inline in your chat response. Only produce a downloadable +> Word document with generate_docx if the user explicitly asks for one. + +## Out of scope / non-goals + +- No new tools, routes, DB tables, or frontend changes. +- No jurisdiction-specific legal logic beyond what the prompts request from the user. +- Workflows are drafting/review aids, not legal advice (stated in-letter for #1). + +## Test plan + +- `list_workflows` (via the assistant) returns the three new titles. +- Each runs end-to-end: #1 asks for missing details then emits a `.docx`; #2 + detects an MSA and follows the MSA review; #3 produces the inline summary. +- Backend `npm run build` (tsc) passes. From 16fdcf96e0e0bccecb659bcb1332220b4e8d8cbc Mon Sep 17 00:00:00 2001 From: Thijs de Zoete Date: Mon, 20 Jul 2026 11:27:28 +0200 Subject: [PATCH 3/6] fix(compose): make env files optional so hosted deploys work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env_file: ./backend/.env was a hard failure when the file is absent, which is always the case on Dokploy and similar platforms that inject env vars rather than committing a .env. Both backend/.env and the compose-root .env are now optional sources, root last so hosted values win. Secrets deliberately stay out of environment:, which overrides env_file — a bare name unset on the host silently blanks out the value the file supplied. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index cbedea8b8..e81c86297 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -165,19 +165,29 @@ services: build: ./backend ports: - "3001:3001" - env_file: ./backend/.env # model key(s) + the two generated *_SECRET values + # ponytail: both optional — local dev keeps secrets in backend/.env, hosted + # deploys (Dokploy et al) write them to the compose-root .env. Whichever + # exists wins; root .env is last so it takes precedence. Needs Compose 2.24+. + # + # Secrets must NOT be listed in `environment:` below — that section overrides + # env_file, so a bare name unset on the host blanks out the file's value. + env_file: + - path: ./backend/.env + required: false + - path: .env + required: false environment: # Topology + local Supabase wiring (override .env placeholders). - PORT: 3001 - FRONTEND_URL: http://localhost:3000 - SUPABASE_URL: http://gateway:8000 - SUPABASE_SECRET_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU - R2_ENDPOINT_URL: http://storage:9000 - R2_ACCESS_KEY_ID: rustfsadmin - R2_SECRET_ACCESS_KEY: rustfsadmin - R2_BUCKET_NAME: mike + - PORT=3001 + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} + - SUPABASE_URL=http://gateway:8000 + - SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY:-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU} + - R2_ENDPOINT_URL=http://storage:9000 + - R2_ACCESS_KEY_ID=rustfsadmin + - R2_SECRET_ACCESS_KEY=rustfsadmin + - R2_BUCKET_NAME=mike # Local Ollama running on the host. Override OLLAMA_MODEL to change the tag. - OLLAMA_BASE_URL: http://host.docker.internal:11434/v1 + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434/v1} extra_hosts: - "host.docker.internal:host-gateway" depends_on: From 079013966ebfaf0eb4a23a2fef3e16f702214cf3 Mon Sep 17 00:00:00 2001 From: Thijs de Zoete Date: Mon, 20 Jul 2026 12:58:25 +0200 Subject: [PATCH 4/6] fix(compose): make published ports configurable, bind infra to loopback Fixed host ports assume a dev machine. On Dokploy, 3000 is the dashboard's, so the frontend fails with 'port is already allocated'. Each published port now takes an env override with the current value as default, so local dev is unchanged. Infra ports (postgres, mailpit, storage) now bind 127.0.0.1 instead of 0.0.0.0. They were reachable from the internet on a public host while still carrying default credentials, and the mailpit inbox exposes password-reset links. Compose service-to-service networking is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e81c86297..63249c159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,10 @@ services: db: image: supabase/postgres:17.6.1.136 ports: - - "54322:5432" # psql from host if needed + # ponytail: infra ports bind loopback only — reachable from the host for + # debugging, never from the internet. Compose networking is unaffected, so + # the other services still reach these by service name. + - "127.0.0.1:${DB_PORT:-54322}:5432" # psql from host if needed environment: POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres @@ -83,7 +86,8 @@ services: gateway: image: nginx:alpine ports: - - "54321:8000" + # Browser-facing (supabase-js talks to it directly), so not loopback-bound. + - "${GATEWAY_PORT:-54321}:8000" volumes: - ./supabase/gateway.conf:/etc/nginx/conf.d/default.conf:ro depends_on: @@ -123,8 +127,9 @@ services: mailpit: image: axllent/mailpit:latest ports: - - "8025:8025" # web inbox - - "1025:1025" # SMTP + # Loopback only: the inbox exposes auth + password-reset links. + - "127.0.0.1:${MAILPIT_PORT:-8025}:8025" # web inbox + - "127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025" # SMTP environment: MP_SMTP_AUTH_ACCEPT_ANY: "1" MP_SMTP_AUTH_ALLOW_INSECURE: "1" @@ -134,8 +139,9 @@ services: storage: image: rustfs/rustfs:latest ports: - - "9000:9000" - - "9001:9001" + # Loopback only: creds below are the default admin pair. + - "127.0.0.1:${STORAGE_PORT:-9000}:9000" + - "127.0.0.1:${STORAGE_CONSOLE_PORT:-9001}:9001" environment: RUSTFS_VOLUMES: /data RUSTFS_ADDRESS: 0.0.0.0:9000 @@ -164,7 +170,7 @@ services: backend: build: ./backend ports: - - "3001:3001" + - "${BACKEND_PORT:-3001}:3001" # ponytail: both optional — local dev keeps secrets in backend/.env, hosted # deploys (Dokploy et al) write them to the compose-root .env. Whichever # exists wins; root .env is last so it takes precedence. Needs Compose 2.24+. @@ -209,7 +215,8 @@ services: NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY} NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3001} ports: - - "3000:3000" + # Dokploy's own dashboard occupies 3000 — set FRONTEND_PORT there. + - "${FRONTEND_PORT:-3000}:3000" depends_on: - backend restart: unless-stopped From f4ac3196307b957fe44bc9802d7ecbcb0c695383 Mon Sep 17 00:00:00 2001 From: Thijs de Zoete Date: Mon, 20 Jul 2026 13:31:01 +0200 Subject: [PATCH 5/6] fix(compose): make auth URLs and signup policy configurable GoTrue's API_EXTERNAL_URL and SITE_URL were hardcoded to localhost, so confirmation links pointed at the user's own machine and signup could never be completed on a hosted deploy. Both now derive from the public URLs. MAILER_AUTOCONFIRM and DISABLE_SIGNUP are configurable too: a host with no real SMTP needs autoconfirm on to register at all, and open registration should be closable once the operator has an account. Defaults unchanged, so local dev still exercises the real email flow via Mailpit. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 63249c159..fde092dd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,21 +36,26 @@ services: environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: "9999" - API_EXTERNAL_URL: http://localhost:54321 + # Must be the browser-reachable gateway URL — confirmation links are built + # from it, so a localhost value makes signup uncompletable off-machine. + API_EXTERNAL_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:54321} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:postgres@db:5432/postgres - GOTRUE_SITE_URL: http://localhost:3000 + GOTRUE_SITE_URL: ${FRONTEND_URL:-http://localhost:3000} GOTRUE_URI_ALLOW_LIST: "*" GOTRUE_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long GOTRUE_JWT_EXP: "3600" GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_DISABLE_SIGNUP: "false" + # Open registration by default (localhost). On a public host, create your + # account then set GOTRUE_DISABLE_SIGNUP=true to pull the ladder up. + GOTRUE_DISABLE_SIGNUP: ${GOTRUE_DISABLE_SIGNUP:-false} GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_EXTERNAL_PHONE_ENABLED: "false" - # Send a real confirmation email (caught by Mailpit) instead of auto-confirming. - GOTRUE_MAILER_AUTOCONFIRM: "false" + # Locally, send a real confirmation email (caught by Mailpit). Set to true + # where no SMTP is wired up, or signup can never be completed. + GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM:-false} GOTRUE_SMTP_HOST: mailpit GOTRUE_SMTP_PORT: "1025" # No SMTP_USER/PASS: Mailpit needs no auth, and GoTrue refuses to send From 7c04e1a954f3584ef9a2b2650853cdb3df0f5675 Mon Sep 17 00:00:00 2001 From: Thijs de Zoete Date: Tue, 21 Jul 2026 17:00:51 +0200 Subject: [PATCH 6/6] feat(llm): optional bearer auth for the local/OpenAI-compatible provider Bare Ollama needs no auth, but OpenAI-compatible servers behind a gateway (vLLM --api-key, Open WebUI, LiteLLM) require a bearer token. OLLAMA_API_KEY, when set, is sent on both model discovery and chat completions; absent, the provider behaves exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/lib/llm/ollama.ts | 9 ++++++++- backend/src/routes/models.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/lib/llm/ollama.ts b/backend/src/lib/llm/ollama.ts index 6ccb2c3fd..030845aa3 100644 --- a/backend/src/lib/llm/ollama.ts +++ b/backend/src/lib/llm/ollama.ts @@ -14,6 +14,13 @@ function baseUrl(): string { return (process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434/v1").replace(/\/$/, ""); } +// Optional bearer auth — bare Ollama needs none, but OpenAI-compatible servers +// behind a gateway (vLLM --api-key, Open WebUI, LiteLLM) require it. +export function authHeaders(): Record { + const key = process.env.OLLAMA_API_KEY?.trim(); + return key ? { Authorization: `Bearer ${key}` } : {}; +} + function modelName(modelId: string): string { // The id's tag (e.g. "ollama/qwen3.6:latest" -> "qwen3.6:latest") wins; the // OLLAMA_MODEL env is only a fallback for a bare "ollama" id. @@ -55,7 +62,7 @@ async function postChat( ): Promise { const response = await fetch(`${baseUrl()}/chat/completions`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body), signal, }); diff --git a/backend/src/routes/models.ts b/backend/src/routes/models.ts index b0de3a88f..3c3c36c0b 100644 --- a/backend/src/routes/models.ts +++ b/backend/src/routes/models.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; +import { authHeaders } from "../lib/llm/ollama"; export const modelsRouter = Router(); @@ -10,7 +11,7 @@ modelsRouter.get("/ollama", requireAuth, async (_req, res) => { process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434/v1" ).replace(/\/$/, ""); try { - const r = await fetch(`${base}/models`); + const r = await fetch(`${base}/models`, { headers: authHeaders() }); if (!r.ok) return void res.json({ models: [] }); const data = (await r.json()) as { data?: { id: string }[] }; const models = (data.data ?? []).map((m) => ({