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..030845aa3 --- /dev/null +++ b/backend/src/lib/llm/ollama.ts @@ -0,0 +1,205 @@ +// 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(/\/$/, ""); +} + +// 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. + 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", ...authHeaders() }, + 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..3c3c36c0b --- /dev/null +++ b/backend/src/routes/models.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { authHeaders } from "../lib/llm/ollama"; + +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`, { 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) => ({ + 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..fde092dd8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,231 @@ +# 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: + # 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 + 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" + # 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: ${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 + # 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" + # 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 + # 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: + # 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: + - 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: + # 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" + restart: unless-stopped + + # --- S3-compatible object storage (RustFS, MinIO alternative). + storage: + image: rustfs/rustfs:latest + ports: + # 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 + 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: + - "${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+. + # + # 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=${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=${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: + # Dokploy's own dashboard occupies 3000 — set FRONTEND_PORT there. + - "${FRONTEND_PORT:-3000}:3000" + depends_on: + - backend + restart: unless-stopped + +volumes: + db_data: + storage_data: 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. 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 $$;