AI legal platform for in-house and outside counsel: chat over your documents, run tabular extractions across whole sets, draft documents with tracked changes, and route everything through a single fine-tuned legal-tuned model (Olava-001).
Repo history: this codebase started life as
willchen96/mike(an open-source legal-AI scaffold), was forked and rebranded Mike → Finch → Olava. References to the original "Mike" name remain in internal type aliases (MikeMessage,MikeChat, etc.) and the GitHub repo slug. Renaming those is a tracked-but-deferred chore — see Known follow-ups.
- What's in here
- Architecture
- Tech stack
- Project layout
- Local development
- Production deployment
- Domain & email setup (
tryolava.ai) - Auth, RLS, and the email-domain whitelist
- Security hardening
- Models & the Olava-001 backend
- Storage
- Common gotchas
- Testing changes
- Known follow-ups
- License
A two-process app with a managed-services back-end:
frontend/— Next.js 16 app (React 19, App Router). Auth UI, chat, tabular review with editable cells and a 3-pane doc viewer, workflows, account settings.backend/— Express 4 API. Document ingest, LibreOffice DOC/DOCX→PDF conversion, model dispatch, tracked-change DOCX generation, Supabase JWT verification on every request.backend/migrations/— One-shot Supabase schema (000_one_shot_schema.sql) plus an idempotent email-domain whitelist trigger (001_email_domain_whitelist.sql). Apply by pasting into the Supabase SQL editor.assets/— Brand assets (theONIT_Mark_Dark.svglogo lives here and is also copied intofrontend/public/onit-mark-dark.svg).supabase/— Reserved for local Supabase CLI configs (used during early local-dev experimentation; the production setup is Supabase Cloud).
There is no monorepo tooling (no Turborepo, no workspaces). Each side has its own package.json and is deployed independently.
┌────────────────────────────────┐
Browser ─HTTPS─► │ Vercel ─ Next.js frontend │
│ www.tryolava.ai │
└──────────┬─────────────────────┘
│ fetch
▼
┌────────────────────────────────┐
│ Railway ─ Express backend │
│ api.tryolava.ai │
│ (LibreOffice via Nixpacks) │
└──────┬───────────┬─────────┬───┘
│ │ │
Supabase JWT S3 SDK OpenAI-
+ RLS (Storage) compatible
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌─────────────┐
│ Supabase │ │Supabase│ │ vLLM/RunPod │
│ Postgres │ │Storage │ │ Olava-001 │
│ + Auth │ │ (S3) │ │ (Qwen+LoRA) │
└──────────┘ └────────┘ └─────────────┘
▲
│ SMTP (auth emails)
│
┌──────────────┐
│ Resend │
│ mail.tryolava.ai
└──────────────┘
Why these choices:
- Supabase for DB + Auth + Storage — one provider, RLS keeps multi-tenant queries safe without writing per-route auth.
- Vercel for the frontend — Next.js 16's native target, preview deploys per branch.
- Railway for the backend — needs a long-lived process and LibreOffice (for DOC/DOCX → PDF). Railway's Nixpacks reads
backend/nixpacks.tomland installslibreofficeas a system package automatically. - Resend for transactional auth emails — straightforward DKIM+SPF setup; SMTP creds plug straight into Supabase.
- vLLM on RunPod for the model — running a fine-tuned LoRA over a 35B base requires a GPU; this is the cheapest "always-on" option that supports OpenAI-compatible streaming + tool calls.
| Layer | Tech |
|---|---|
| Frontend | Next.js 16 · React 19 · TailwindCSS 4 · Tiptap (rich text) · pdf.js · TypeScript 5 |
| Backend | Express 4 · TypeScript 5 (tsx watch in dev) · Multer (uploads) · libreoffice-convert · jszip · fast-xml-parser |
| Database | Postgres 15 (Supabase) with RLS on every user-owned table |
| Auth | Supabase Auth (email/password, email confirmation toggleable) |
| Storage | Supabase Storage via S3 protocol (@aws-sdk/client-s3, path-style) |
| LLM | Olava-001 — Qwen/Qwen3.6-35B-A3B + olava-extract LoRA, served on vLLM with --enable-auto-tool-choice --tool-call-parser hermes |
Resend SMTP, sender noreply@mail.tryolava.ai |
|
| CI/CD | Auto-deploy on git push origin main (both Vercel + Railway watch the repo) |
mike/ (repo slug; product name is Olava)
├── frontend/
│ ├── src/app/
│ │ ├── (pages)/ protected pages (assistant, projects, tabular-reviews, workflows, account)
│ │ ├── login/, signup/ public auth pages
│ │ ├── components/ feature components grouped by area (assistant/, tabular/, workflows/, …)
│ │ ├── contexts/ AuthContext, UserProfileContext, ChatHistoryContext
│ │ ├── lib/ modelAvailability, supabase clients, fetch wrappers
│ │ └── layout.tsx, page.tsx
│ ├── public/ onit-mark-dark.svg, favicons
│ ├── .npmrc `legacy-peer-deps=true` (Vercel install needs this — see Common gotchas)
│ └── package.json
│
├── backend/
│ ├── src/
│ │ ├── index.ts Express bootstrap, multi-origin CORS
│ │ ├── routes/ chat, projects, projectChat, documents, tabular, workflows, user, downloads
│ │ ├── lib/
│ │ │ ├── llm/ provider dispatch (claude.ts, gemini.ts, olava.ts, models.ts, types.ts)
│ │ │ ├── chatTools.ts, docxTrackedChanges.ts, convert.ts, …
│ │ │ ├── storage.ts S3 SDK against Supabase Storage / R2 / MinIO
│ │ │ └── supabase.ts service-role client
│ │ └── middleware/auth.ts Supabase JWT verification
│ ├── migrations/
│ │ ├── 000_one_shot_schema.sql tables + RLS + handle_new_user trigger
│ │ └── 001_email_domain_whitelist.sql signup domain restriction
│ ├── nixpacks.toml `aptPkgs = ["libreoffice"]`
│ └── package.json engines.node >= 20
│
├── assets/ ONIT_Mark_Dark.svg, source assets
├── supabase/ (legacy local-dev configs)
└── README.md you are here
Tested on macOS 14 / Node 20+. Bun also works but the deploy targets npm.
- Node 20+
- A Supabase project (free tier is fine) or the Supabase CLI for fully-local dev
- An S3-compatible bucket (Supabase Storage, MinIO locally, or Cloudflare R2)
- LibreOffice installed locally (
brew install --cask libreofficeon macOS) — needed for DOCX→PDF - Olava credentials: a vLLM endpoint URL + bearer token (or any OpenAI-compatible model server)
git clone https://github.com/nwhitehouse/mike.git
cd mike
npm install --prefix backend
npm install --prefix frontend
cp backend/.env.example backend/.env
cp frontend/.env.local.example frontend/.env.localOpen backend/.env and fill in Supabase + storage + Olava values. The frontend only needs NEXT_PUBLIC_* vars and the Supabase service-role key (used only in server components).
In the Supabase SQL editor, run both migrations in order:
backend/migrations/000_one_shot_schema.sqlbackend/migrations/001_email_domain_whitelist.sql
npm run dev --prefix backend # → http://localhost:3001 (or PORT in .env)
npm run dev --prefix frontend # → http://localhost:9000The frontend dev port is 9000 (not 3000) because the project ran a parallel local stack experiment with MinIO + Supabase CLI on adjacent ports. Production uses :3000-equivalent Vercel routing, no conflict.
npm run build --prefix backend # tsc emit
npm run build --prefix frontend # next build
npm run lint --prefix frontend # eslint
curl http://localhost:3001/health # → {"ok":true}
curl http://localhost:3001/user/server-keys # → {"claude":…,"gemini":…,"olava":true}The whole stack is wired for hands-off auto-deploy on git push origin main. Each service watches its own subdirectory of the repo.
- Root Directory:
frontend - Framework: Next.js (auto-detected)
- Install command: default (Vercel reads
.npmrcforlegacy-peer-deps=true) - Build command: default (
next build) - Environment Variables:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY(anon)SUPABASE_SECRET_KEY(service role — server-only)NEXT_PUBLIC_API_BASE_URL=https://api.tryolava.ai
Vercel team settings to watch:
- "Require Verified Commits" must be off (or per-project override) — otherwise Claude/CI commits get silently rejected from the deploy queue.
- Vulnerable-package blocking — Vercel's security scanner refuses to deploy known-bad versions of Next.js (and friends). Bump promptly when warnings appear; don't try to override.
- Root Directory:
backend - Builder: Nixpacks (default). Reads
backend/nixpacks.tomlfor the LibreOffice apt layer. - Start command:
npm run start - Generated domain routed at
api.tryolava.aivia custom domain. - Environment Variables (subset — full list in
backend/.env.example):PORT=3001(Railway also injects this, but explicit is fine)FRONTEND_URL=https://www.tryolava.aiSUPABASE_URL,SUPABASE_SECRET_KEYR2_ENDPOINT_URL,R2_REGION,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET_NAME(these are S3-protocol vars; against Supabase Storage point athttps://<project>.storage.supabase.co/storage/v1/s3and setR2_REGION=<your-region>, e.g.us-west-2)OLAVA_BASE_URL,OLAVA_AUTH_TOKEN,OLAVA_ENABLE_TOOLS=true,OLAVA_MAX_TOKENS=128000RESEND_API_KEY(only if backend triggers transactional emails — Supabase handles signup/auth emails directly)
Important Nixpacks notes:
- Use
aptPkgs(additive), notnixPkgs(replaces Node detection and breaksnpm ci). - The backend
package.jsondeclares"engines": { "node": ">=20" }. Railway dropped Node 18 from the auto-detect pool; without this pin builds fail.
- One project. The free tier handles the legal-team demo comfortably.
- Authentication → URL Configuration must list every host the app is served from:
- Site URL:
https://www.tryolava.ai - Redirect URLs:
https://www.tryolava.ai/**,https://*.vercel.app/**,http://localhost:9000/**
- Site URL:
- Authentication → SMTP Settings uses Resend (see Domain & email setup).
- Storage uses the S3 API. Backend hits it via
@aws-sdk/client-s3withforcePathStyle: true.
- Domain verified at
mail.tryolava.ai(DKIM CNAMEs + SPF TXT in GoDaddy DNS). - Supabase SMTP config:
- Sender email:
noreply@mail.tryolava.ai(must align with the DKIM-signed subdomain or DMARC fails) - Host:
smtp.resend.com· Port:465 - Username:
resend(literal) · Password: yourRESEND_API_KEYvalue (re_…)
- Sender email:
GoDaddy DNS (one-time):
| Host | Type | Value | Purpose |
|---|---|---|---|
@ |
A | 76.76.21.21 (Vercel) |
apex points at frontend |
www |
CNAME | cname.vercel-dns.com |
www points at frontend |
api |
CNAME | <your>.up.railway.app |
backend |
mail |
(Resend gives you 3 CNAMEs + 1 TXT) | … | DKIM + SPF for transactional email |
_dmarc |
TXT | v=DMARC1; p=none; aspf=r; adkim=r; rua=mailto:you@onit.com |
apex DMARC, relaxed alignment so subdomain DKIM aligns |
After DNS settles (10–30 min), in Vercel claim tryolava.ai + www.tryolava.ai; in Railway claim api.tryolava.ai; in Resend confirm all rows are green; in Supabase update Site URL + redirects.
- User submits email + password on
/signup. - Frontend pre-flight check: email domain must be one of
onit.com,mccarthyfinch.com,k1.com. If not, instant red error. supabase.auth.signUp({ email, password, options: { emailRedirectTo: window.location.origin } }).- Hard enforcement kicks in: the
enforce_email_domain_whitelisttrigger onauth.users(from migration001_…) re-checks the domain server-side and raises if it's not in the list. This applies to every signup path — frontend, Supabase JS client, admin API, dashboard "Add user" button. - The
handle_new_usertrigger (in migration000_…) creates auser_profilesrow with the auth user's id. - If email confirmation is on: Supabase emails the user a confirmation link via Resend. Caveat: corporate email gateways (Mimecast in particular) may quarantine the confirmation email until an admin whitelists
mail.tryolava.aiand Resend's IPs. For the legal-team demo, email confirmation is currently off in Supabase to avoid this — the domain whitelist alone is sufficient for the use case. - Frontend gets the session and redirects to
/assistant.
Edit both of these and re-run the SQL:
backend/migrations/001_email_domain_whitelist.sql—allowed_domainsarray (the source of truth)frontend/src/app/signup/page.tsx—ALLOWED_EMAIL_DOMAINS(the UX hint)
The SQL file is CREATE OR REPLACE … DROP TRIGGER IF EXISTS … — safe to re-run.
Apply the Supabase migrations in order:
backend/migrations/000_one_shot_schema.sqlbackend/migrations/001_email_domain_whitelist.sqlbackend/migrations/002_enable_rls_tenant_tables.sql
The backend runs with the service-role key, so route handlers still perform
app-layer authorization after resolving the caller from the Supabase JWT in
middleware/auth.ts. RLS is the defense-in-depth layer for direct Supabase
client access and missed app-layer checks. The hardening migration covers
projects, folders, documents, versions, edits, chats, workflows, workflow
shares, tabular reviews, cells, and tabular chat messages.
The current security hardening rollout is documented in SECURITY_HARDENING.md. It covers:
- removed model-content/raw-stream logging;
- required
DOWNLOAD_SIGNING_SECRET; - tightened project/document ID authorization checks;
- RLS migration rollout;
- stricter CORS,
helmet, JSON limits, and upload concurrency limits; - frontend markdown/DOCX render sanitization;
- dependency audit status and remaining moderate advisory rationale.
For dependency audit details, see SECURITY_AUDIT.md.
The product currently exposes a single model: Olava-001 (model id olava-extract).
- Base:
Qwen/Qwen3.6-35B-A3B - LoRA:
olava-extract— fine-tuned for legal extraction + drafting tasks - Server: vLLM on RunPod, OpenAI-compatible endpoint,
--enable-auto-tool-choice --tool-call-parser hermes - Reasoning fields: this is a reasoning model. Streaming responses include
delta.reasoning/delta.reasoning_contentchunks before any visible content —backend/src/lib/llm/olava.tsaccumulates reasoning separately and strips inline<think>...</think>blocks. - Tool-call format: the LoRA emits a non-Hermes custom format:
vLLM's hermes parser doesn't catch this. We work around it: when tools are enabled, we use the non-streaming endpoint and either consume
<tool_call> <function=NAME> <parameter=KEY> VALUE </parameter> </function> </tool_call>message.tool_calls(when vLLM does parse it) or fall back to parsing the custom markup frommessage.contentviaparseCustomToolCall()inolava.ts.
The backend/src/lib/llm/{claude,gemini}.ts modules and the provider switch in lib/llm/index.ts are still present but unreachable from the UI. A defensive coercion in streamChatWithTools() and completeText() rewrites any non-Olava model id to DEFAULT_MAIN_MODEL, so stale localStorage or DB rows referencing gemini-3-flash-preview etc. don't break chat. Removing these modules is a future cleanup — not done in this pass to keep the diff small.
If you want Anthropic + Google back as user-selectable models:
- Add their entries to
MODELSinfrontend/src/app/components/assistant/ModelToggle.tsx. - Re-add the API-key input UI to
frontend/src/app/(pages)/account/models/page.tsx. - Re-add
updateApiKey()tofrontend/src/contexts/UserProfileContext.tsx(deleted in the Olava-only pass). - Remove the
coerceToOlavawrapper inbackend/src/lib/llm/index.ts. - Set
ANTHROPIC_API_KEY/GEMINI_API_KEYenv vars on Railway.
The codebase uses S3-compatible storage via the AWS SDK (@aws-sdk/client-s3) with forcePathStyle: true. Three setups have been verified:
| Setup | R2_ENDPOINT_URL |
R2_REGION |
|---|---|---|
| Cloudflare R2 | https://<account>.r2.cloudflarestorage.com |
auto |
| Supabase Storage (production) | https://<project>.storage.supabase.co/storage/v1/s3 |
a real region, e.g. us-west-2 |
| MinIO (local dev) | http://localhost:9100 |
auto |
Bucket name is R2_BUCKET_NAME (default mike). Object keys are namespaced as documents/<userId>/<docId>/<filename> and generated/<userId>/<docId>/<filename> — see lib/storage.ts.
- CORS rejecting a frontend host — production CORS is explicit. Set
FRONTEND_URLto the canonical frontend origin and add any extra known origins throughADDITIONAL_CORS_ORIGINS. Localhost defaults are only allowed outside production. - Mimecast killing signup emails — see Auth, RLS, and the email-domain whitelist. The current workaround is to leave email confirmation off; long-term ask the corporate IT admin to whitelist
mail.tryolava.ai. - Vercel "Require Verified Commits" — silently rejects deploys from unsigned commits even though the GitHub status shows green. Disable per-project or sign commits.
- Vercel vulnerable-Next.js block — bump
nextpromptly when Vercel surfaces a security advisory; don't try to bypass. - Nixpacks dropped Node 18 —
backend/package.jsonpinsengines.node >= 20; if you remove that, Railway falls back to Node 16/18 detection and crashes on modern syntax. legacy-peer-deps— Vercel's npm install needs this for thenext@16+@opennextjs/cloudflarepeer mismatch. Set infrontend/.npmrc.- Olava 400 "auto tool choice requires --enable-auto-tool-choice" — vLLM was started without that flag. Either (a) restart vLLM with
--enable-auto-tool-choice --tool-call-parser hermes, or (b) setOLAVA_ENABLE_TOOLS=falseto disable tools entirely. - Stale
localStoragemodel id — old sessions may havemike.selectedModel = "gemini-3-flash-preview"saved. The backend'scoerceToOlava()wrapper handles this transparently; users don't need to clear storage.
The backend now has a small security regression suite. The general verification path is:
npm --prefix backend test— security regression checks.npm --prefix backend run build— TypeScript compiles cleanly.npm --prefix frontend run build— Next.js production build.npm --prefix frontend exec tsc -- --noEmit --pretty false— frontend type check.npm --prefix backend audit --audit-level=moderateandnpm --prefix frontend audit --audit-level=moderate.- Run both dev servers, sign up with a fresh email in incognito, upload a small PDF, run an extraction, ask the assistant to draft an NDA, generate a tracked-change DOCX.
- Smoke-test on the Vercel preview URL before promoting to production.
npm run lint --prefix frontend is not currently a clean release gate because
the repo has existing lint debt; see Known follow-ups.
For UI changes, start the dev server and click through the actual feature in a browser before claiming success. The TypeScript compiler proves syntax, not feature behavior.
A backlog for whoever picks this up:
- Type rename — internal type aliases (
MikeMessage,MikeChat,MikeProject,MikeDocument,MikeCitationAnnotation,MikeEditAnnotation) and drag-and-drop MIME types (application/mike-doc,application/mike-folder) still use the original "Mike" name. Cosmetic refactor; no functional impact. - Repo rename — the GitHub repo is still
nwhitehouse/mike. Renaming requires updating Vercel + Railway "GitHub repo" settings to track the new slug. - Bucket rename —
R2_BUCKET_NAME=mikein production. Renaming the bucket requires a data migration of every storage key. - Strip dead Anthropic/Gemini code paths —
backend/src/lib/llm/{claude,gemini}.tsand the dispatch inllm/index.tsare unreachable from the UI. Safe to delete in one PR; do it when you're confident multi-provider isn't coming back. - Custom domain rollout — the production custom domain (
tryolava.ai) is wired up. If it ever gets retired, remove thetryolava.aiallowance from the CORS origin function inbackend/src/index.tsand updateFRONTEND_URLon Railway. - Frontend lint debt —
npm run lint --prefix frontendcurrently fails on existing repo-wide issues. Clean this up so lint can become a required release gate. - Live RLS tests — backend tests cover static migration/policy presence and app-layer helpers, but there is not yet a live Supabase JWT test using owner/shared/unrelated users.
- Broader E2E coverage — add Playwright or equivalent coverage for the chat happy path, tabular extraction, upload limits, and cross-tenant denial flows.
- Email deliverability for corporate gateways — long-term, Olava emails should be sent from a domain that already has Mimecast reputation (e.g. an Onit subdomain), not from a freshly-registered
tryolava.aisubdomain. - Observability — no error tracking (Sentry/Datadog/etc.) and no structured logs. Backend logs are
console.logstrings.
AGPL-3.0-only. Inherited from the upstream willchen96/mike repo. Any deployment of a modified version must make the corresponding source available under the same terms.