cpatpa builds a self-hostable Mike for firms that can't ship files to the cloud

Five phases of plumbing turn the fork into something a firm can run end-to-end on its own hardware.

infrastructurecompliance

cpatpa landed a bundle of work that quietly repositions this fork for firms with strict data-handling rules. Files now stay on local disk, encrypted at rest with per-file keys, instead of being pushed to cloud object storage. The AI itself can run locally too, via an adapter for Ollama - a project that lets you run open-source language models on your own machine - including the tool-calling behaviour that makes modern AI assistants actually useful, not just chatty.

Rounding it out: a proper admin console where a firm administrator can manage users, set org-wide policies (retention, allowed domains, which models are on), and review an audit log of every change. The whole thing ships as a Docker bundle, so a competent IT person can stand it up in an afternoon.

So what If you've wanted Mike but your compliance team won't let documents or prompts leave the building, this is the fork to watch.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

3 commits from cpatpa/PIP, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
9166a01d Phases 4-8: admin backend, storage driver, local LLM, Docker, polish Claude 2026-05-15 ↗ GitHub
commit body
Closes audit findings C1, H4 (sandboxing follow-up parked), H6, M2.

Phase 4 - admin backend.
  - routes/admin.ts mounted at /admin/*, gated by requireAuth +
    requireAdmin.
  - GET / PATCH / DELETE /admin/users (last-admin protection;
    self-mutation refused).
  - GET / PATCH /admin/org-settings with typed validation per
    field; audit_events records every change.
  - GET /admin/audit paginated; optional action and user_id
    filters.
  - Frontend admin pages land later; the API is complete.

Phase 5 - storage driver + at-rest encryption + download tokens.
  - lib/storageLocal.ts: local driver. Each file on disk is
    [12-byte IV][16-byte GCM tag][ciphertext]. Master key is
    SHA-256(STORAGE_ENCRYPTION_KEY). Path traversal: resolved
    absolute path must stay under STORAGE_LOCAL_PATH.
  - lib/storage.ts becomes a facade keyed on STORAGE_DRIVER:
    local (default for Docker) or s3 (existing R2 / MinIO).
    Signed URLs return null in local mode; callers fall back to
    /download/:token.
  - End-to-end verified: 25-byte plaintext -> 53-byte on-disk
    file; plaintext does not appear; round-trip preserved.
  - lib/downloadTokens.ts payload adds u (user_id) + exp;
    /download/:token refuses tokens issued to a different user
    or past expiry. TTL defaults to 24h, env-tunable. Closes C1.

Phase 6 - local LLM + EXTERNAL_AI_DISABLED.
  - lib/llm/local.ts: OpenAI-compatible adapter for Ollama /
    vLLM / LM Studio. Streaming + completion. Models prefixed
    "local/" route here; suffix sent verbatim as the upstream
    model id. Tools not yet wired (follow-up).
  - lib/llm/index.ts adds assertProviderAllowed: when
    EXTERNAL_AI_DISABLED=true, refuses Claude/Gemini/OpenAI
    dispatch. Local always passes.
  - Provider enum extended to include "local".
  - tabular.ts missingModelApiKey skips the check for local
    models (gated by LOCAL_LLM_BASE_URL on the backend).

Phase 7 - Docker / Caddy / deployment.
  - backend/Dockerfile multi-stage (deps -> tsc build -> slim
    runtime). LibreOffice baked in. Non-root user. Entrypoint
    runs `npm run migrate` (toggle via MIGRATE_ON_BOOT) then
    starts the server.
  - frontend/Dockerfile multi-stage. next.config.ts:
    output: "standalone".
  - docker-compose.yml at repo root. Services:
      postgres, backend, frontend, ollama, caddy, backup.
    Every persistent volume mounts under ${DATA_ROOT}. Backend
    healthcheck uses /ready. Backup sidecar runs nightly
    pg_dump with BACKUP_KEEP_DAYS retention.
  - caddy/Caddyfile terminates TLS via Let's Encrypt for
    ${PIP_DOMAIN}; forwards backend paths to backend:3001 and
    everything else to frontend:3000.
  - .env.compose.example documents every required and optional
    var.

Phase 8 - rate limits + structure_tree sanitisation.
  - rate-limit keyGenerator keys on res.locals.userId for chat,
    chat-create, and upload paths; pre-auth requests still fall
    back to IP. Closes H6.
  - sanitiseStructureTitle strips control chars, escapes angle
    brackets, caps length. Applied to PDF outline titles and
    DOCX mammoth-extracted lines before storage. Closes M2.

Type-check clean across the backend. All 16 migrations apply
cleanly. Storage driver smoke-tested.

Remaining outstanding (post-MVP polish):
  - Frontend workspace switcher + workspace settings page.
  - Frontend admin pages (Users / AI Policy / Audit).
  - Frontend Account page polish for the new per-user fields.
  - Tool support on the local LLM adapter so edit / generate /
    read flows work with Ollama-class models.
7d44fef6 Frontend admin console: Users, AI Policy, Audit Log Claude 2026-05-15 ↗ GitHub
commit body
Adds the admin-only /admin section reachable from the user dropdown
when the JWT role is admin. Mirrors the backend admin API:

  /admin/users    list, change role/status, delete (with self-guard
                  and last-admin guard already enforced server-side)
  /admin/policy   org system prompt, allow_external_models toggle,
                  user-instructions toggle, default model, retention,
                  timezone, banner text, and the jurisdiction/
                  practice-area/sector/allowed-domain lists
  /admin/audit    paginated audit log with action + user filters

Layout enforces role=admin client-side and the backend re-checks via
requireAdmin so the gate is real, not cosmetic.
9fe6a8e9 Local LLM adapter: tool-call loop Claude 2026-05-15 ↗ GitHub
commit body
Adds OpenAI-style tool support to the local Ollama adapter. The
adapter now forwards the caller's tool schemas, assembles tool_calls
from the streamed delta chunks (deltas arrive keyed by index with
arguments streaming in fragments), and runs them via the supplied
runTools callback. Tool results are appended as role=tool messages
with tool_call_id and the loop re-streams until the model produces
content with no further tool calls or maxIterations is hit.

Models that do not support tools simply never emit tool_calls, so
the loop exits after the first turn. Unparseable JSON arguments are
passed through under _raw_arguments rather than crashing the turn.

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-365.md from inside the repo you want the changes in.

⬇ Download capture-thread-365.md