PIP is an internal legal AI assistant for Piper Alderman. It lets fee earners upload legal documents, chat about them with AI models, propose and accept tracked-change edits, run reusable workflows, and extract structured data into spreadsheet-style tabular reviews.
Forked from the upstream "Mike" project and re-architected for internal self-hosting at PA.
- Frontend Next.js 16, React 19, Tailwind 4, Auth.js v5
- Backend Express + TypeScript on Node 20
- Database PostgreSQL 16 (plain
pg; no ORM, no Supabase) - Auth Auth.js (Credentials + Microsoft Entra OIDC), HS256 JWT shared with the backend
- Storage local filesystem with AES-256-GCM at rest (default), S3-compatible (R2 / MinIO) as opt-in
- AI Anthropic, Gemini, OpenAI as external providers; local Ollama (or any OpenAI-compatible endpoint) for sovereign matters, with an organisation switch to disable external providers entirely
- Deploy Docker Compose with host-mounted persistent volumes, Caddy reverse proxy, nightly pg_dump sidecar
The full architecture map is at
docs/developer/01-architecture.md.
PIP/
├── Instructions.md project conventions and decisions log
├── CHANGELOG.md running record of every change
├── docs/ audience-grouped documentation
│ ├── developer/ architecture, schema, migrations, auth
│ ├── security/ threat model and cryptography
│ ├── operator/ deployment and ops (Phase 7+)
│ ├── admin/ admin-facing guides (Phase 4+)
│ └── user/ end-user guides
├── backend/ Express + pg
│ ├── migrations/ SQL migrations applied in order
│ └── src/
└── frontend/ Next.js + Auth.js
The guided installer takes a clean Ubuntu Server 22.04+ or Debian 12+ from nothing to a running PIP deployment in one command:
sudo apt update && sudo apt install -y git
sudo git clone <repo-url> /opt/pip
cd /opt/pip
sudo bash install.shThe installer:
- Detects the OS and installs
whiptail,curl,openssl,gnupg,ufw, then Docker Engine + the Compose plugin. - Runs a TUI wizard collecting:
- Public hostname (any DNS name or LAN address works)
- TLS mode: Caddy internal CA (self-signed, default, works without DNS), Let's Encrypt (public DNS required), or HTTP only (LAN testing).
- Bootstrap admin email (first user signing up with this email becomes admin automatically).
- Data root (default
/srv/pip). - Postgres password (auto-generated by default).
- Email (Resend) - skipable.
- External AI policy and per-provider keys.
- Microsoft Entra OIDC - skipable.
- Ollama mode: bundled (run Ollama as a container in this stack), remote (point at an existing Ollama server URL), or none. The bundled service is gated by a Docker Compose profile so a remote / none install never starts the local container.
- Ollama starter model (bundled mode only).
- Generates
AUTH_SECRET,USER_API_KEYS_ENCRYPTION_SECRET,DOWNLOAD_SIGNING_SECRET, andSTORAGE_ENCRYPTION_KEYautomatically. - Provisions
${DATA_ROOT}/{postgres,storage,ollama,caddy,backups}with the right ownership for the backend container (uid 10001) and the backup sidecar (uid 70). - Writes
.env.compose(mode 0600), the runtime${DATA_ROOT}/caddy/Caddyfile, and a secrets backup at${DATA_ROOT}/secrets-backup.txt(mode 0400). Copy the secrets backup off the server - losingSTORAGE_ENCRYPTION_KEYmakes every uploaded document permanently unreadable. - Opens UFW ports 80/443 if UFW is active.
- Builds the backend + frontend images, brings up the stack, waits
for
/ready, optionally pulls the chosen Ollama model.
Idempotent. Re-run sudo bash install.sh at any time to change
domain, TLS mode, AI providers, or any other setting.
Upgrade. After a git pull or any code change:
sudo bash update.shManual one-shot backup. The compose stack runs a nightly
pg_dump sidecar; for an ad-hoc dump:
sudo bash bin/backup-now.shHealth check / control panel. One command prints service status, container resource usage (CPU, memory, network I/O), host disk usage per data subdir, public-URL probe, and (when run interactively) drops into a menu for restart / log / reset-admin actions:
sudo bash bin/pip-status.sh # status + interactive menu
sudo bash bin/pip-status.sh --status # one-shot, no menu
sudo bash bin/pip-status.sh --restart backend
sudo bash bin/pip-status.sh --restart-all
sudo bash bin/pip-status.sh --watch # refresh every 2 secondsReset the bootstrap admin password. Locked out, or rotating after first sign-in:
sudo bash bin/pip-reset-admin.sh # interactive
sudo bash bin/pip-reset-admin.sh --generate # auto-generate, print once
sudo bash bin/pip-reset-admin.sh --password '<your-new-12+-char-password>'The script hashes the new password via the backend container's
bcrypt (cost 12, same as the application), UPDATEs the bootstrap
user's password_hash, clears any pending reset tokens, mirrors the
new value into ${DATA_ROOT}/secrets-backup.txt + .env.compose,
and writes a user.password.reset audit event.
Nuke a test deployment. Tears down the stack, deletes
${DATA_ROOT} (postgres, storage, ollama, caddy, backups, and the
secrets backup), removes .env.compose, and optionally drops the
built images. Irreversible. Intended for test hosts and
pre-go-live rebuilds, not for production:
sudo bash bin/pip-uninstall.sh # interactive, prompts before each destructive step
sudo bash bin/pip-uninstall.sh --yes # non-interactive (scripted runs)
sudo bash bin/pip-uninstall.sh --keep-data # tear down containers but keep ${DATA_ROOT}
sudo bash bin/pip-uninstall.sh --keep-images # keep built backend/frontend images
sudo bash bin/pip-uninstall.sh --prune # also `docker system prune -af --volumes`After running, sudo bash install.sh does a fresh end-to-end
install.
The compose services are postgres, backend, frontend, ollama
(local AI), caddy (reverse proxy + TLS), and backup. Every
persistent volume mounts under ${DATA_ROOT} on the host so
containers can be replaced without losing data. The backend
entrypoint runs pending SQL migrations on every boot.
If you'd rather run the apps directly on your laptop without Docker:
- Node.js 20 or newer
- npm
- git
- PostgreSQL 16 with a database PIP can own
- A Resend API key (for password-reset email; optional in development)
- LibreOffice on the host PATH if you want DOCX -> PDF conversion
- Optional: an S3-compatible bucket (R2 or MinIO) if you prefer object storage to the AES-encrypted local filesystem driver
createdb pip
createuser pip --pwprompt
psql -c "GRANT ALL PRIVILEGES ON DATABASE pip TO pip; ALTER ROLE pip BYPASSRLS;"BYPASSRLS is required because every backend-owned table has
FORCE ROW LEVEL SECURITY enabled as a defence-in-depth measure. The
application is the access-control boundary. See
docs/developer/08-migrations.md.
Create backend/.env:
PORT=3001
FRONTEND_URL=http://localhost:3000
DATABASE_URL=postgres://pip:your-password@localhost:5432/pip
# Generate secrets with: openssl rand -hex 32
AUTH_SECRET=... # shared with frontend
USER_API_KEYS_ENCRYPTION_SECRET=... # required
DOWNLOAD_SIGNING_SECRET=... # required
# Optional: bootstraps the admin role on this email at first sign-in
BOOTSTRAP_ADMIN_EMAIL=you@piperalderman.com.au
# Model providers (only those you intend to use)
ANTHROPIC_API_KEY=...
GEMINI_API_KEY=...
OPENAI_API_KEY=...
# Outbound email (password reset). Required in production.
RESEND_API_KEY=...
# RESEND_FROM="PIP <no-reply@piperalderman.com.au>"
# Storage. The default driver writes AES-256-GCM encrypted files to
# STORAGE_LOCAL_PATH; STORAGE_ENCRYPTION_KEY is required (32+ chars).
STORAGE_DRIVER=local
STORAGE_LOCAL_PATH=./local-storage
STORAGE_ENCRYPTION_KEY=...
# Opt-in S3-compatible mode (R2 / MinIO) instead of the local driver
# STORAGE_DRIVER=s3
# R2_ENDPOINT_URL=...
# R2_ACCESS_KEY_ID=...
# R2_SECRET_ACCESS_KEY=...
# R2_BUCKET_NAME=pip
# Optional Entra OIDC (omit to use Credentials only)
# AUTH_MICROSOFT_ENTRA_ID_ID=...
# AUTH_MICROSOFT_ENTRA_ID_SECRET=...
# AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=...Create frontend/.env.local:
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
AUTH_SECRET=... # same value as backend
# AUTH_URL=http://localhost:3000 # required in production
# Entra (same three values as backend; both sides need them)
# AUTH_MICROSOFT_ENTRA_ID_ID=...
# AUTH_MICROSOFT_ENTRA_ID_SECRET=...
# AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=...The frontend env file holds no privileged secrets other than
AUTH_SECRET, which is also held by the backend.
npm install --prefix backend
npm install --prefix frontend
npm run migrate --prefix backendThe migration runner applies every file in backend/migrations/
in order and records progress in a pip_migrations tracking table.
See docs/developer/08-migrations.md.
npm run dev --prefix backend
npm run dev --prefix frontendOpen http://localhost:3000, register the BOOTSTRAP_ADMIN_EMAIL
account, complete the onboarding wizard, and you're in.
- The first user with the email matching
BOOTSTRAP_ADMIN_EMAILis promoted to admin on registration. Any other email matching the organisation'sallowed_email_domainspolicy (defaultpiperalderman.com.au) signs up as a regular user. - The onboarding wizard collects display name, office, jurisdictions, practice services, sectors, and an AI-disclaimer acknowledgement.
- The default org system prompt is preconfigured with AU English, AGLC4 citation conventions, and hallucination guards. Admins can edit this in the admin AI Policy panel (Phase 4).
- Account > Custom Instructions accepts additive personal notes that the assistant follows on top of the organisation's instructions.
AUTH_SECRET is required on boot. Generate a 32-byte hex value
with openssl rand -hex 32 and put the same value in both backend/.env
and frontend/.env.local.
USER_API_KEYS_ENCRYPTION_SECRET is required and must be at least 32 characters. Same fix; this protects user-supplied model provider
keys at rest.
Migrations apply but queries return zero rows. The Postgres role
PIP connects with must have BYPASSRLS (RLS is enabled defence-in-
depth on every table). Run
ALTER ROLE pip BYPASSRLS; as a superuser.
Password-reset email never arrives. In development without
RESEND_API_KEY, the email body is written to the backend log;
copy the link from stdout. In production set RESEND_API_KEY.
DOC or DOCX conversion fails. Install LibreOffice locally and
restart the backend so soffice is on the process path. From Phase 7
this runs in a sandboxed converter container.
The model picker shows a missing-key warning. Add a key for that
provider in Account > Models & API Keys, or configure it in
backend/.env and restart.
npm run build --prefix backend
npm run build --prefix frontend
npm run lint --prefix frontendThe phased build plan. Phases are tackled in order; items move from "Outstanding" to "Done" as each phase completes.
Foundation docs, threat model, PR template, audit-finding fixes that don't need architectural change: dead-secret removal, JSON DoS cap, LibreOffice timeout, DOCX zip/XML bomb guards, CORS allowlist, strict CSP, multer 2.x, generic error responses, production log redaction.
Replaced "Mike" everywhere (UI copy, system prompts, defaults, page
titles, file names) and ran an AU English copy sweep at the same
time. Brand tokens (pip-red, pip-navy) wired into the Tailwind
theme. Placeholder logo pending the official PA asset.
The largest single change. All of:
- pg connection pool and in-house SQL migration runner with a
pip_migrationstracking table; no ORM. - Full Phase 2 schema: 19 application tables defined entirely by
migrations. Every
user_idis auuidforeign key topublic.userswithON DELETE CASCADE. Row-level security enabled (forced) on every backend-owned table. org_settingsseeded with the canonical PA org system prompt (AU English, AGLC4 citations, no-fabrication and flag-uncertainty guards, AI reminder), 10 AU jurisdictions, the 17 PA services and 16 PA sectors frompiperalderman.com.au, thepiperalderman.com.auemail-domain allowlist, and a 7-year retention default.audit_eventsappend-only audit trail withON DELETE SET NULLon the actor so history survives user deletion.- Auth.js v5 on the frontend with two providers wired:
- Credentials (email + bcrypt at cost 12, minimum 12-char passwords).
- Microsoft Entra ID OIDC, enabled when the three
AUTH_MICROSOFT_ENTRA_ID_*env vars are set. Conflict and domain-allowlist rejection both 401.
- HS256 JWT shared between frontend and backend via
AUTH_SECRET. Auth.js session cookie stays httpOnly; the browser obtains a five-minute backend bearer viaGET /api/session-token. - Backend
requireAuthmiddleware verifies the JWT viajose. NewrequireAdminchecksrole. BOOTSTRAP_ADMIN_EMAILenv auto-promotes the first match.- Onboarding wizard (display name, office, jurisdictions, services, sectors, AI-disclaimer acknowledgement). Mandatory after first sign-in; middleware redirects until completed.
- Account > Custom Instructions panel with a 4096-character cap. Additive only; never overrides the organisation's instructions.
- Password reset flow via Resend with a one-hour single-use HMAC token (SHA-256 of the raw value stored).
- Every backend route migrated off the Supabase JS client onto
plain
pgwith parameterised SQL.@supabase/supabase-jsuninstalled from both apps. - Account deletion enforces re-auth (password for Credentials, email-typing for Entra-only) and tears down storage objects before the cascade runs.
Audit findings closed: C3, H1, H2, H3, H4 (timeout), H5, H7, M1, M3, M4, M5, M6, L1, L5.
Workspaces sit above users and below the organisation. Every user
gets an auto-provisioned Personal workspace via the
ensure_personal_workspace trigger; the partial unique index pins
each user to exactly one. Owner / admin / member role ladder with
last-owner protection, Personal-workspace protection, and an
email-to-user-id invite path. AI policy fields
(instructions, allow_external_models, retention_days) on the
workspace override the org defaults when set.
Every workspace-scoped resource (projects, chats,
tabular_reviews, workflows) carries workspace_id. Chats and
reviews inherit the parent project's workspace when project-scoped.
GET /projects?workspace_id= scopes both own and shared branches.
The legacy projects.shared_with and tabular_reviews.shared_with
JSONB email lists are replaced by project_members and
review_members joined on user_id. Backfill loops migrated every
existing email to its matching active user; unmatched emails are
dropped and reported as NOTICE. The legacy JSONB columns were
later dropped in migration 0018 once the members tables had
proven stable in production paths.
Backend admin API at /admin/*, gated by requireAdmin:
GET / PATCH / DELETE /admin/users(last-admin protection, self-mutation blocked).GET / PATCH /admin/org-settings(typed validation per field, metadata recorded in audit).GET /admin/audit(paginated, optional action and user_id filters). Frontend admin pages land separately; the API is complete.
New StorageDriver selector keyed on STORAGE_DRIVER:
local(default for Docker): writes files to${STORAGE_LOCAL_PATH}with AES-256-GCM. Each file on disk is[12-byte IV][16-byte GCM tag][ciphertext]; tampering is detected on read. Path-traversal defence: keys are resolved against the configured root and refused if they escape.s3: existing R2 / MinIO behaviour, opt-in via the R2 env vars.
STORAGE_ENCRYPTION_KEY is required at runtime for the local
driver (minimum 32 chars). The download-token format gains userId
and exp claims; /download/:token refuses tokens that belong to
a different user or have passed their expiry (default 24 h, tunable
via DOWNLOAD_TOKEN_TTL_SECONDS). Closes audit finding C1.
New local provider in the LLM adapter (local/<model-id> routes
to any OpenAI-compatible Chat Completions endpoint, default
http://ollama:11434/v1). completeText and streamChatWithTools
both dispatch. Tool support was added in Phase 9.
EXTERNAL_AI_DISABLED=true (env) refuses every dispatch to
Anthropic, Gemini, and OpenAI at the runtime layer. Local models
always pass. The same gate also honours
org_settings.allow_external_models and the per-workspace override.
Multi-stage Dockerfiles for backend and frontend. Next.js builds
with output: 'standalone'. docker-compose.yml services:
postgres, backend, frontend, ollama, caddy (TLS + reverse proxy),
backup (nightly pg_dump + retention sweep). Every persistent
volume mounts under ${DATA_ROOT}. Backend entrypoint runs
migrations on boot. /health and /ready endpoints are in place
(the latter pings Postgres). Closes audit finding H4
(LibreOffice sandboxing follow-up still parked, see below).
Per-user rate limits: chat, chat-create, and upload limiters now
key on res.locals.userId so one staff member's burst doesn't
drain the bucket for the whole firm sitting behind a corporate NAT.
Pre-auth requests still fall back to IP. Closes audit finding
H6.
structure_tree titles extracted from uploaded PDFs and DOCX are
sanitised (control chars stripped, angle brackets escaped, length
capped) before storage and render. Closes audit finding M2.
Per-user durable facts ("I work in commercial litigation, NSW")
injected into the system prompt between user custom instructions
and the chat message. Migration 0022_user_memories.sql. Soft cap
of 50 entries per user enforced inside a single SELECT FOR UPDATE
transaction so concurrent saves can't bypass the LRU eviction.
LLM tool add_memory(content) for model-driven capture with an
in-chat confirmation when a memory is saved. Account > Memories tab
supports review, edit, pin, delete, export. org_settings.allow_memory
master switch; default off until reviewed. Audit events for create,
update, delete, clear-all.
Shipped alongside the layered system prompt in
backend/src/lib/promptAssembly.ts. Compose order: org prompt,
workspace override, user custom instructions, memory block,
operational footer. org_settings.use_layered_prompt is the
emergency rollback to the legacy hardcoded assembly.
Admin-gated retrieval from the open web. Pluggable provider
interface; default is Brave Search (clean ToS, single env var),
with SearXNG self-hosted as the sovereign alternative (Docker
Compose profile web-search). Migrations 0023_web_search.sql
and 0025_web_search_credentials.sql; admin-saved API keys are
encrypted at rest with STORAGE_ENCRYPTION_KEY.
LLM tools web_search(query) and fetch_url(url) with a
24-hour fetch cache, size and timeout caps, content-type allowlist,
and an SSRF-safe undici fetcher that resolves the host, validates
the IP isn't in a private range, and pins the connection to that
exact IP. Per-chat toggle and an external-AI-style banner when
active. Audit events for every search and fetch.
org_settings.allow_web_search master switch; per-workspace
override. Domain blocklist as the primary filter, seeded with paste
sites, unmoderated forums, and *.onion. Optional allowlist for
tightly scoped configurations. Citations rendered with the existing
cite-button.
RBAC on top of the Phase 3 workspace model. Migration
0024_groups.sql: groups, group_members, capability set in
permissions, group_permissions. project_members and
review_members extended to accept either a user id or a group id.
Auto-managed system groups ("All members", "Admins") maintained
by Postgres triggers that read a session-local GUC
(pip.system_group_sync) so legitimate sync writes pass while
direct UI mutations of a system group are refused.
Capability resolver computes effective permissions in a single CTE
query (workspace role, group memberships, direct grants) and caches
per request. Admin UI: group CRUD, member management, capability
editor. Migration 0026_owner_locked_caps.sql introduces the
owner_locked column for capabilities only an owner may grant
(e.g. admin.policy.write), enforced via a trigger reading the
session-local pip.actor_role GUC.
Consumer routes migrated from inline role checks to req.can(),
returning 403 with { code: 'forbidden', capability } on refusal.
Hot-path indexes added in migration 0027_phase11_indexes.sql.
Two post-Phase-11 sweeps. Tier A (6f9836a, 730a7e3):
hot-path indexes, owner-locked capabilities, consumer route
migration, SIGTERM handling, migration advisory lock, citation
memoisation, Caddy security headers, update.sh waits for
/ready, bin/pip-acceptance.sh (21-probe end-to-end check).
Tier B (d4f70b0, 1e3cb08, 00b993b): shared /me
context, React.memo on chat, permissionsByWorkspace N+1
collapsed, formatApiError + ApiError class, empty-state
copy, aria-labels, modal focus trap (useFocusTrap), form
labels, bulk accept/reject two-step confirm, loading skeletons,
tabular pending-cell spinner.
Each phase has a complete design doc under docs/developer/.
Pick up with the same pre-implementation audit pattern Phases
9-11 used.
New chat mode with up to three models running in parallel against
the same message history. Backend fan-out, per-model rate-limit
accounting and cost capture, split-pane UI. Admin policy caps
parallelism. Design: docs/developer/phase-12-multi-model.md.
The biggest single quality lift on the roadmap. pgvector,
document_chunks and document_embeddings, embedding worker on
upload, hybrid vector + tsvector search, local embeddings via
Ollama by default. Reuses the existing cite-button per chunk.
Design: docs/developer/phase-13-vector-rag.md.
UX layer on top of Phase 13: # autocomplete to scope retrieval to
a named collection, project, or document. Depends on Phase 13.
Design: docs/developer/phase-14-knowledge-collections.md.
Real but not on the critical path. Pick up when adjacent work is
in scope. Tracked in docs/developer/00-roadmap.md.
- Structured logging with request-id correlation. Replace
the printf-style
backend/src/lib/logger.tswith apino-style emitter; addreq.id = uuid()middleware so an SSE error can be traced back to its chat. - Refactor
backend/src/lib/chatTools.ts(4053 LOC) intochatTools/{memory,webSearch,documents,workflows}.ts. Pure code hygiene; no behaviour change. - Prometheus
/metricsendpoint. Retention counts, web-search rate-limit hits, chat token usage are log lines today. - ProjectsOverview virtualisation (
react-window). Only matters above ~500 projects per user.
- Workflow audit. The upstream ships 60+ built-in workflows; most are US-centric. A future workstream replaces them with AU and PA-relevant content, authored with practice-area-lead input.
- LibreOffice sandboxing. Today
sofficeruns in the backend container under a non-root user with a hard timeout. A separatepip-convertercontainer with stricter limits is a follow-up. - Automated tests. The codebase currently has no test suite. Adding one is a real gap for a legal-sector tool but is out of scope for the MVP cut.
- Voice and dictation. Local-Whisper-only dictation may return as a small phase if user demand materialises after Phase 14. External STT/TTS rejected.
- Image generation, user-authored plugins, anonymous chat.
All considered and rejected. See
docs/developer/00-roadmap.md.
AGPL-3.0-only. PIP is intended for internal Piper Alderman
deployment. Upstream attribution is preserved in LICENSE and
NOTICE.