Multi-tenant API platform built on OpenResty Nginx/Lua/PostgreSQL (OLP Stack). Lua runs directly inside Nginx workers — no CGI overhead, Unix-socket-grade performance.
- OpsAPI (Backend) — Lapis/Lua API server with JWT auth, RBAC, multi-tenancy
- OpsAPI Dashboard — Next.js admin dashboard (port 8039)
- OpsAPI Node — Node.js service for file uploads (optional)
The server is published on Docker Hub as bwalia/opsapi, so you don't need this repository to run it. Run an OpsAPI server has a ready docker-compose.yml, the setup commands (tables, first admin and workspace) and a production checklist. The rest of this page is for developing OpsAPI itself.
- Docker Desktop — Download and install from https://www.docker.com/products/docker-desktop/
- After installing, open Docker Desktop and wait until it shows "Docker Desktop is running" (green icon in the system tray/menu bar)
- Docker Compose is included with Docker Desktop
- Git — Download from https://git-scm.com/downloads
git clone https://github.com/bwalia/opsapi.git
cd opsapicp lapis/.sample.env lapis/.envThis copies a pre-configured environment file with working defaults. No editing needed — it works out of the box for local development.
chmod +x start.sh./start.sh -e local -n -j allWhat this does:
-e local— sets up for local development (http://127.0.0.1:4010)-n— skips git operations (stash/pull) since you just cloned-j all— creates all database tables
The first run takes 3-5 minutes as Docker downloads images and builds containers. Subsequent runs are much faster.
Note: Near the end, the script updates your /etc/hosts file and will ask for your computer password (sudo). This is safe — it just adds 127.0.0.1 opsapi-dev.local so you can access the API at http://opsapi-dev.local:4010.
Once the script finishes, open these URLs in your browser:
| Service | URL |
|---|---|
| Backend API | http://127.0.0.1:4010/health |
| Dashboard | http://127.0.0.1:8039 |
| API Docs (Swagger) | http://127.0.0.1:4010/swagger |
You should see the health check return {"status":"ok"} and the dashboard load a login page.
Use these credentials on the Dashboard (http://127.0.0.1:8039) or for API calls:
Email: admin@opsapi.com
Password: Admin@123
These are the defaults created by start.sh. You can customise them — see Custom Admin Credentials below.
| Service | URL | Login |
|---|---|---|
| Backend API | http://127.0.0.1:4010 | See above |
| Dashboard | http://127.0.0.1:8039 | See above |
| Swagger Docs | http://127.0.0.1:4010/swagger | No login needed |
| Adminer (DB browser) | http://127.0.0.1:7779 | System: PostgreSQL, Server: postgres, User: pguser, Password: pgpassword, Database: opsapi |
| MinIO Console (files) | http://127.0.0.1:9001 | User: minioadmin, Password: minioadmin123 |
| Grafana (monitoring) | http://127.0.0.1:3011 | User: admin, Password: admin |
| Prometheus (metrics) | http://127.0.0.1:9090 | No login needed |
| Gatus (health status) | http://127.0.0.1:8888 | No login needed |
"Permission denied" when running start.sh:
chmod +x start.sh"Cannot connect to the Docker daemon": Open Docker Desktop and wait for it to fully start (green icon).
Port already in use (e.g. port 4010, 5439, 9000):
Another application is using that port. Stop it, or change the port in lapis/docker-compose.yml.
Script asks for password:
This is your computer password (sudo) — it's adding a hostname entry to /etc/hosts. This is normal and safe.
./start.sh -e local -n -j all \
-A admin@mycompany.com -W MySecurePassword123If you're working on a specific project, use its project code to only create the tables you need:
# UK Tax Return project
./start.sh -e local -n -j tax_copilot
# E-commerce project
./start.sh -e local -n -j ecommerce# Stop all containers
cd lapis && docker compose down
# Restart (fast — no rebuild)
cd lapis && docker compose up -d
# Restart with rebuild (after code changes)
./start.sh -e local -n -j all
# Full reset — wipes database and starts fresh
./start.sh -e local -n -j all -rThe start.sh script handles the full setup: environment config, Docker build, migrations, namespace seeding, and /etc/hosts entry.
| Flag | Description | Default |
|---|---|---|
-e, --env ENV |
Target environment (local/dev/test/acc/prod/remote or any custom name) |
Interactive prompt |
-d, --domain DOMAIN |
Apex domain | wslcrm.com |
-P, --protocol PROTO |
API protocol (http/https) |
http for local, https for others |
-j, --project CODE |
Project code for conditional migrations | all |
-A, --admin-email EMAIL |
Super admin email for namespace setup | admin@opsapi.com |
-W, --admin-password PWD |
Super admin password | Admin@123 |
-N, --namespace-name NAME |
Custom namespace name | Derived from project code |
-S, --namespace-slug SLUG |
Custom namespace slug | Derived from project code |
-s, --stash y/n |
Git stash option | Interactive prompt |
-p, --pull y/n |
Git pull option | Interactive prompt |
-a, --auto |
Auto mode: stash=y, pull=y (no prompts) | — |
-n, --no-git |
Skip all git operations | — |
-r, --reset-db |
Reset database (removes Docker volumes — destructive) | false |
-c, --check-env |
Only check/update .env, don't start containers |
— |
-C, --ci |
CI/CD mode: uses docker-compose.ci.yml (no dev volume mounts) |
— |
-h, --help |
Show help | — |
| Code | Description |
|---|---|
all |
All features (default, backward compatible) |
tax_copilot |
UK Tax Return AI Agent (core + tax tables) |
ecommerce |
E-commerce platform (core + stores, products, orders) |
collaboration |
Chat + Kanban + Services |
hospital |
Hospital CRM |
billing |
Billing & Entitlements: sell your apps on flat-tier plans, entitlement checks, licence keys (docs/BILLING_ENTITLEMENTS.md, SDK @opsapi/client/billing) |
core_only |
Just authentication tables |
Preset:
| Environment | API URL |
|---|---|
local |
http://127.0.0.1:4010 |
dev |
https://dev-api.{domain} |
test |
https://test-api.{domain} |
acc |
https://acc-api.{domain} |
prod |
https://api.{domain} |
remote |
https://remote-api.{domain} |
Custom: Any name generates https://{name}-api.{domain} (e.g. -e staging → https://staging-api.wslcrm.com).
# Local dev, no git ops, all features
./start.sh -e local -n
# Local dev, tax project, fresh database
./start.sh -e local -n -j tax_copilot -r
# Local dev, custom admin + namespace
./start.sh -e local -n -j tax_copilot \
-A admin@mycompany.com -W SecurePass123 \
-N "My Company" -S my-company
# Dev environment, auto git (stash + pull)
./start.sh -e dev -a -j all
# Custom domain
./start.sh -e dev -d myapp.com -a
# Just check .env URLs (don't start containers)
./start.sh -c -e dev
# CI/CD deployment
./start.sh -e remote -n -C -j all
# Full reset — wipes database
./start.sh -e local -n -r- Selects environment and configures API URLs in
lapis/.env - Optionally stashes/pulls git changes
- Creates required directories (
lapis/logs,lapis/pgdata,lapis/keycloak_data) - Builds and starts Docker containers (
docker compose up --build -d) - Waits for PostgreSQL and OpsAPI to be healthy
- Runs database migrations (
lapis migrate) with project code - Runs namespace setup script (creates admin user, namespace, default roles, modules)
- Adds
opsapi-dev.localto/etc/hosts(requires sudo)
Need an API OpsAPI doesn't have? Write a plugin instead of forking. A plugin is a folder of Lua files that gets the same auth, multi-tenancy, RBAC, migrations and Swagger docs as the built-in modules. It can also have generated or custom dashboard pages, react to business events (invoice paid, deal won, …), run scheduled jobs, and ask each workspace for its own settings.
docker exec opsapi opsapi plugin:new helpdesk
docker exec opsapi opsapi make:resource helpdesk ticket title:string:required status:string
docker exec opsapi opsapi migrate
# → /api/v2/helpdesk/tickets (list/show/create/update/delete, tenant-scoped)
# and a Tickets page in the dashboard sidebar (/dashboard/plugins/helpdesk/tickets)
docker exec opsapi opsapi make:page helpdesk overview "Support overview" # your own dashboard page
docker exec opsapi opsapi make:listener helpdesk invoice.paid # react to business events
docker exec opsapi opsapi make:job helpdesk close_stale 1h # run every hour, per workspaceEach workspace turns installed plugins on or off and fills in their settings under Workspace → Plugins. Off is a hard switch: the plugin's API answers 404 there, and its pages, events and jobs stop for that workspace.
With ./start.sh -e local the API reloads itself when you save a file: there's no restart step. Full guide: PLUGINS.md. Example: projects/helpdesk.
Calling OpsAPI from your own app? @opsapi/client is a typed TypeScript client covering every endpoint, your plugins included. It handles sign-in with 2FA, workspaces, retries, pagination and webhook verification.
npm install @opsapi/clientimport { createClient } from '@opsapi/client';
const opsapi = createClient({ baseUrl: 'https://api.example.com', token: process.env.OPSAPI_KEY, namespace: 'acme' });
const { data } = await opsapi.GET('/api/v2/customers', { params: { query: { page: 1 } } });Its README covers running your own server, sign-in, API keys, errors, pagination, typed plugins, webhooks and troubleshooting.
Tenants who just need integrations don't need a plugin. Under Dashboard → Webhooks each workspace can send its events (invoice paid, lead created, …) to its own URLs, signed and retried. See WEBHOOKS.md.
For technical managers: OpsAPI gives every app you run one shared sign-in (email and password with 2FA, Google or Apple) and one user directory with roles per workspace, on infrastructure you host yourself, so there's no per-user identity bill and no separate login system to build for each app.
To sign users in, your app sends the browser to https://<opsapi-host>/auth/google?frontend_url=https://<your-app>&from=/<path> (or /auth/apple). OpsAPI handles the Google or Apple round trip, creates the user on first sign-in and adds them to a workspace (the project_code query parameter, else SIGNUP_NAMESPACE_SLUG), then redirects to https://<your-app>/auth/callback?token=<JWT>&redirect=/<path>. Your /auth/callback reads token, calls GET /auth/me with Authorization: Bearer <token> to get the user, and starts its own session. Don't verify the JWT yourself: it's signed with the server's JWT_SECRET_KEY, which must never leave OpsAPI. Mobile apps skip the redirect and post the Google or Apple ID token to POST /auth/oauth/validate; email-and-password sign-in is POST /auth/login, then POST /auth/2fa/verify when the account has 2FA.
The token is only sent to origins the server trusts. frontend_url must match CORS_ALLOWED_DOMAINS (a domain and all its subdomains), CORS_ALLOWED_ORIGINS or PASSWORD_RESET_ALLOWED_ORIGINS; any other value falls back to FRONTEND_URL. Register https://<opsapi-host>/auth/google/callback as a redirect URI on your Google OAuth client and set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and GOOGLE_REDIRECT_URI. Calls back to the API carry the user's token plus an X-Namespace-Slug header, so roles and data stay scoped to that workspace. OpsAPI is not an OpenID Connect provider: there are no client registrations or /authorize and /token endpoints, so a stock OIDC client such as Spring Security's oauth2Login() can't use it directly. A worked example on Kubernetes is in devops/demo/README.md.
All environment variables are in lapis/.env. The .sample.env file has working defaults for local dev.
| Variable | Description | Local Default |
|---|---|---|
POSTGRES_HOST |
PostgreSQL host | 172.71.0.10 |
POSTGRES_PORT |
PostgreSQL port | 5432 |
POSTGRES_USER |
Database user | pguser |
POSTGRES_PASSWORD |
Database password | pgpassword |
POSTGRES_DB |
Database name | opsapi |
JWT_SECRET_KEY |
JWT signing secret | Set in .sample.env |
OPENSSL_SECRET_KEY |
AES-128 encryption key (32 hex chars) | Set in .sample.env |
OPENSSL_SECRET_IV |
AES-128 encryption IV (32 hex chars) | Set in .sample.env |
MINIO_ENDPOINT |
MinIO S3 endpoint | http://172.71.0.17:9000 |
MINIO_ACCESS_KEY |
MinIO access key | minioadmin |
MINIO_SECRET_KEY |
MinIO secret key | minioadmin123 |
MINIO_BUCKET |
Default bucket | opsapi |
NEXT_PUBLIC_API_URL |
API URL (auto-set by start.sh) |
http://127.0.0.1:4010 |
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Google OAuth credentials |
GOOGLE_REDIRECT_URI |
Google OAuth callback (auto-set by start.sh) |
APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY |
Sign in with Apple key (ES256 client_secret) |
APPLE_CLIENT_ID |
Apple Services ID (web audience + token client_id) |
APPLE_BUNDLE_ID |
iOS bundle ID (native identity-token audience) |
APPLE_REDIRECT_URI |
Apple web callback (/auth/apple/callback) |
KEYCLOAK_* |
Keycloak SSO configuration |
STRIPE_SECRET_KEY / STRIPE_PUBLISHABLE_KEY / STRIPE_WEBHOOK_SECRET |
Stripe payments |
CORS_ALLOWED_DOMAINS |
Comma-separated domains (allows subdomains + any port) |
CORS_ALLOWED_ORIGINS |
Comma-separated explicit origin URLs |
NODE_API_URL |
Node.js service URL |
OPSAPI_SSL_VERIFY |
SSL verification for external calls (true/false) |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD |
SMTP server for outbound email (password reset, invitations, OTP) |
SMTP_FROM_EMAIL / SMTP_FROM_NAME |
Default sender for outbound email |
APP_NAME |
Display name used in email subject lines (default OpsAPI) |
FRONTEND_URL |
Bootstrap-only: seeds namespaces.allowed_redirect_origins on first run of migration 489. Auto-set by start.sh. After migration, the source of truth is the DB column — see Password Reset Flow. |
OPSAPI_WEBHOOKS_ALLOW_PRIVATE |
true lets workspace webhooks call http:// and private addresses. Local development only. See WEBHOOKS.md. |
OPSAPI_VAULT_ALLOW_PRIVATE |
true lets the secret vault reach a HashiCorp Vault, Azure Key Vault or Kubernetes API on a private network or over http://. Off by default: vault URLs must be public https://. Set it when your Vault runs inside the cluster. |
PASSWORD_RESET_ALLOWED_ORIGINS |
Bootstrap-only: comma-separated extra origins for the same migration 489 bootstrap. Used when one tenant has multiple frontends (e.g. staging + prod). |
Every dashboard page has an Ask AI button (⌘J). The assistant there knows that page: it explains how to use it and does the work on it (create, update, delete) through the same API routes, with the user's own permissions in the current workspace. Each page area keeps its own conversation, so timesheet work never mixes with project work. Deletes only run after the user presses Confirm.
One model setting for every AI feature: the page assistant, chat agent, tax transaction classification, bookkeeping AI (categorise, VAT, questions) and bank-statement extraction all go through lapis/lib/agent/llm.lua. The model is chosen by env/secrets only, so switching provider needs no code change:
| Variable | Description |
|---|---|
AI_PROVIDER |
ollama (default), anthropic, or openai. openai works with any OpenAI-compatible API: OpenAI, OpenRouter, Groq, Together, Mistral, vLLM, LM Studio. |
AI_MODEL |
Model id. If unset: OLLAMA_MODEL / ANTHROPIC_MODEL / OPENAI_MODEL, then qwen3.8:latest / claude-opus-5-5 / gpt-4.1. |
AI_API_KEY |
Provider key. If unset: OLLAMA_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY. |
AI_BASE_URL |
Optional endpoint. If unset: OLLAMA_URL or the provider's API. |
AI_VISION_PROVIDER / AI_VISION_MODEL |
Optional: the model that reads statement images and PDFs. It must be able to see images (Claude, gpt-4.1, or minicpm-v on Ollama). Until AI_PROVIDER is set, this stays on Claude (ANTHROPIC_VISION_MODEL) when an Anthropic key exists. |
AI_FALLBACK_PROVIDER / AI_FALLBACK_MODEL |
Where a request goes when the main provider fails (timeout, outage, rate limit, bad key). Default: Ollama, with OLLAMA_MODEL on OLLAMA_URL / OLLAMA_API_KEY. none turns it off. While the main provider is down, requests go straight to the fallback for 60 s, and the dashboard footer shows the fallback model in amber. |
AI_FALLBACK_CHAIN |
Optional ordered chain of fallback hops tried after the main provider, e.g. bedrock,azure,vertex,vast1,vast2. Replaces AI_FALLBACK_PROVIDER when set (none = no fallback). Built-in hops bedrock (AWS Bedrock Converse: AWS_*, BEDROCK_MODEL), azure (AZURE_OPENAI_*), vertex (Google Vertex AI: GCP_CREDENTIALS_JSON, GCP_PROJECT, GCP_LOCATION, VERTEX_MODEL) and anthropic / openai / gemini / ollama read their standard env; any other name (e.g. two vast.ai hosts) is defined in the AI_HOPS JSON env. Each hop has its own 60 s circuit breaker; unconfigured hops are skipped. AI_FALLBACK_CHAIN_BY_FEATURE sets per-task chains. Platform admins see the chain at GET /api/v2/admin/ai-chain. Full reference: the header of lapis/lib/agent/llm.lua and lapis/.sample.env. |
AI_USER_DAILY_TOKEN_LIMIT |
Optional: how many tokens one user may use per UTC day, across every AI feature. Past it, AI requests are refused with a clear message until midnight UTC. Unset = no limit. |
For example, to run production on Claude, set AI_PROVIDER=anthropic, AI_MODEL=claude-opus-5-5 and AI_API_KEY in the Vault secret, then redeploy. Every AI feature moves together, and Ollama stays the automatic fallback. The dashboard footer shows the live model and its response time. The diy stack's LLM_PROVIDER setting is deliberately not read, so it can't change OpsAPI's model. Embeddings for search stay on OLLAMA_EMBED_MODEL (384-dim), because the stored vectors depend on it.
Usage metering: every model call is recorded in ai_usage: the user, workspace, feature (assistant, tax classification, statement reading, bookkeeping, health check), model, tokens in and out, response time and whether it worked. Counts only, never prompts or replies. One assistant message counts as one request, however many model calls it took. Workspace owners and admins see it under Activity → AI usage (GET /api/v2/namespace/ai-usage?days=30). Platform admins can switch to All workspaces (GET /api/v2/admin/ai-usage). Search embeddings aren't counted (they run on the small local embedder).
What each page's assistant knows and may call lives in lapis/lib/agent/knowledge/<page>.md: a short guide plus that page's endpoints. Only the endpoints written there can be called. To give a new page an assistant, add one file. lapis/spec/page-assistant_spec.lua checks every file.
Note: NEXT_PUBLIC_API_URL is a build-time variable for the Next.js dashboard. If changed after initial build, rebuild with:
cd lapis && docker compose build --no-cache dashboard && docker compose up -d dashboard# API error logs
docker exec -i opsapi tail -50 /var/log/nginx/error.log
# Container logs
docker logs opsapi
docker logs opsapi-postgres-dev-dbcd lapis && docker compose restart lapisdocker exec -e "PROJECT_CODE=all" -it opsapi lapis migrateMigrating is safe to repeat. Each run also repairs schema drift from older releases: column defaults that were stored with extra quotes (a status of 'active' instead of active, including rows that already took the bad value) and columns that older databases are missing.
docker exec -i opsapi curl -s -X POST 'http://127.0.0.1/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"admin@opsapi.com","password":"Admin@123"}'TOKEN=$(docker exec -i opsapi curl -s -X POST 'http://127.0.0.1/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"admin@opsapi.com","password":"Admin@123"}' | jq -r '.token')
docker exec -i opsapi curl -s "http://127.0.0.1/api/v2/users" -H "Authorization: Bearer $TOKEN"docker exec -i opsapi-postgres-dev-db psql -U pguser -d opsapi -c "\dt"cd lapis && docker compose build --no-cache dashboard && docker compose up -d dashboard./start.sh -e local -n -rOr manually:
cd lapis && docker compose down --volumes && docker compose up --build -d
sleep 15
docker exec -e "PROJECT_CODE=all" -it opsapi lapis migrate- Swagger UI: http://127.0.0.1:4010/swagger
- OpenAPI JSON: http://127.0.0.1:4010/openapi.json
| Status | Meaning |
|---|---|
400 |
Malformed request: a missing X-Namespace-* header, or a value of the wrong type (a non-UUID id, text where a number belongs). |
401 |
Missing or invalid credentials. |
403 |
Signed in, but your role in this workspace doesn't allow it (or you aren't a member). |
404 |
Doesn't exist, belongs to another workspace, or the module isn't deployed (PROJECT_CODE). |
409 |
Conflict: a duplicate value, or the record is still referenced by others. |
422 |
Validation failed: a required field is missing, a value is out of range, or a referenced record doesn't exist. |
503 |
An integration this endpoint needs (Stripe, Google, MinIO, HMRC, …) isn't configured on this server. |
500 |
A bug on our side. The body never contains SQL or internal details; check the API logs. |
Errors carry a human message and, where it helps, a machine-readable code and context:
{ "error": "A record with this value already exists.", "code": "CONFLICT_409",
"context": { "reason": "duplicate", "field": "email" } }Newer endpoints nest the same fields: { "error": { "code", "message", "context" } }. Some validation errors add details, a map of field → message. @opsapi/client reads both shapes into OpsApiError.
| Endpoint | Description |
|---|---|
GET /health |
Health check |
GET /swagger |
API documentation |
GET /metrics |
Prometheus metrics |
POST /auth/login |
Login (returns JWT) |
POST /api/v2/register |
User registration |
POST /auth/forgot-password |
Request a password-reset email — see Password Reset Flow |
POST /auth/reset-password |
Consume a reset token and set a new password |
OpsAPI ships a production-grade password reset flow that scales to multi-tenant SaaS without per-environment code or env-var changes once a tenant is configured.
┌─────────┐ 1. POST /auth/forgot-password ┌─────────┐
│ Browser │ ─────{email}─────────────────────────────► │ OpsAPI │
└─────────┘ └─────────┘
│
2. Look up user → namespace
→ allow-list of origins
│
3. Pick primary
(= first entry in list)
│
4. Generate 32-byte CSPRNG
token, store SHA-256(token)
│
5. Send email with link:
${primary}/reset-password
?token=${plaintext_token}
│
┌─────────┐ User clicks email link ◄──────────┘
│ Browser │ ─────────────► /reset-password?token=xxxxx
│ │ │
│ │ 6. POST /auth/reset-password │
│ │ ─────{token, new_password}────────────────► OpsAPI
└─────────┘ │
7. Atomic UPDATE..RETURNING
(single-use, race-free)
│
8. bcrypt-hash new password,
store, revoke ALL refresh
tokens for the user
│
9. 200 OK
The reset-link destination is the namespace primary — the first
entry in namespaces.allowed_redirect_origins. Admins set it through
the admin UI; OpsAPI does not honour any caller-supplied redirect_url
when picking the destination. This means:
- Admin-controlled, not caller-controlled. Changing where reset
emails land is a UI action, not a code change. Phishing attempts that
POST forgot-password with
redirect_url: "https://evil.com"cannot redirect the email link to the attacker's site. - Predictable for tenants. Whatever the admin marks as primary in the UI is exactly where every reset email points — no surprise echo-back to whichever frontend the request originated from.
Origin lookup priority:
-
namespaces.allowed_redirect_origins[1](primary, multi-tenant SaaS source of truth) —TEXT[]column populated per tenant. Position 1 is authoritative; subsequent entries are reserved for future use cases (e.g. OAuth callback aliases) and do not affect password-reset routing. -
PASSWORD_RESET_ALLOWED_ORIGINS+FRONTEND_URLenv vars (bootstrap fallback) — used only when the user's namespace has an empty/NULL column. Migration 489 uses these env vars to populate the column on first run, so the env vars become inert once any namespace is bootstrapped. -
http://localhost— last-resort default, for dev only. A WARN log fires if production hits this path.
For each new SaaS tenant / new environment / new frontend domain, set the namespace's allow-list. Three ways depending on your workflow:
A — start.sh auto-bootstrap (existing pattern):
Set FRONTEND_URL (and optionally PASSWORD_RESET_ALLOWED_ORIGINS) in
the environment. On first migration run, all namespaces with empty
allow-lists are populated from these env vars.
FRONTEND_URL=https://app.example.com \
PASSWORD_RESET_ALLOWED_ORIGINS=https://staging.example.com,https://acc.example.com \
./start.sh prodB — Direct SQL (any time after migration runs):
UPDATE namespaces
SET allowed_redirect_origins = ARRAY[
'https://app.example.com', -- primary (first entry)
'https://staging.example.com',
'https://acc.example.com'
]
WHERE slug = 'tax-copilot';C — Admin UI (/admin/settings → "Frontend URLs" tab):
Platform admins manage the per-namespace list with no DB access:
add/remove URLs, drag the primary to position 1, save. The first
entry is automatically used as the primary destination for reset
emails. Backed by GET/PUT /api/v2/admin/namespaces/:uuid/redirect-origins.
| Property | Implementation |
|---|---|
| Cryptographic randomness | 32 bytes from resty.random.bytes(N, true) — bytes_strict so a low-entropy pool fails loud rather than issuing a weak token |
| Token storage | SHA-256(token) hex; the plaintext token only ever lives in the email link, never persisted, never logged |
| Single-use | Atomic UPDATE ... RETURNING with used_at IS NULL AND expires_at > NOW() — no race window between validate and consume |
| Token TTL | 30 minutes (industry standard for reset links) |
| Enumeration safety | /forgot-password always returns 200 with the same response body, regardless of whether the email exists |
| Rate limit | /forgot-password 5/hour per IP, /reset-password 10/min per IP — token itself is the strong gate, rate limit is anti-abuse |
| Refresh-token revocation | On successful reset, every refresh_tokens row for the user is revoked — kicks attackers out of any stolen sessions |
| Multi-pending-token cleanup | Re-requesting forgot-password invalidates earlier unconsumed tokens; consuming a token invalidates siblings |
| Phishing-domain protection | Email destination is the admin-configured namespace primary; redirect_url from the client is ignored, so an attacker cannot steer the email link to a phishing domain |
# 1. Confirm the column exists and is populated
docker exec opsapi-postgres-dev-db psql -U pguser -d opsapi-diytaxreturn \
-c "SELECT slug, allowed_redirect_origins FROM namespaces;"
# 2. Trigger forgot-password — destination is always the namespace primary,
# regardless of any redirect_url the caller sends
curl -X POST http://localhost/opsapi/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"admin@taxreturn.uk"}'
# 3. Even when an attacker sends a phishing redirect_url, the email link
# still points at the configured primary (server ignores redirect_url)
curl -X POST http://localhost/opsapi/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"admin@taxreturn.uk","redirect_url":"https://attacker.com"}'
# 4. Confirm by inspecting the latest reset-link in the email log /
# queue — it should be ${primary}/reset-password?token=... where
# ${primary} matches allowed_redirect_origins[1] from step 1.
# 5. Synthesise a token for QA (skips the actual email send)
docker exec opsapi-postgres-dev-db psql -U pguser -d opsapi-diytaxreturn -c "
DELETE FROM password_reset_tokens WHERE user_id = 4;
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
VALUES (4, encode(digest('local_qa_token_42chars_for_testing_only', 'sha256'), 'hex'),
NOW() + INTERVAL '30 minutes', NOW());
"
# 6. Consume it
curl -X POST http://localhost/opsapi/auth/reset-password \
-H "Content-Type: application/json" \
-d '{"token":"local_qa_token_42chars_for_testing_only","new_password":"NewPass123!"}'- Deploy this branch — migration 489 runs automatically on container start.
- If
FRONTEND_URL(and optionallyPASSWORD_RESET_ALLOWED_ORIGINS) is set, the migration auto-bootstraps every namespace's allow-list from those env vars. You're done. - Otherwise, populate manually via the SQL in option B above.
- Verify with the curl smoke-tests above.
- Subsequent additions (new staging URL, domain rename) use option B — no opsapi restart, no env-var change required.
| File | Role |
|---|---|
lapis/migrations/tax-copilot-system.lua |
Phase 487 — password_reset_tokens table |
lapis/migrations.lua |
Phase 489 — namespaces.allowed_redirect_origins column + bootstrap |
lapis/helper/password-reset.lua |
Token lifecycle (create / validate-and-consume / revoke) |
lapis/queries/NamespaceQueries.lua |
getAllowedRedirectOrigins() helper |
lapis/routes/auth.lua |
/auth/forgot-password and /auth/reset-password route handlers |
lapis/views/emails/password_reset.etlua |
Email template |
┌──────────────────────────────────────────────────────────────┐
│ Docker Network (172.71.0.0/16) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ OpsAPI │ │ Dashboard │ │ PostgreSQL │ │
│ │ (Lapis) │ │ (Next.js) │ │ (pgvector) │ │
│ │ 172.71.0.12 │ │ 172.71.0.19 │ │ 172.71.0.10 │ │
│ │ :4010 │ │ :8039 │ │ :5439 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Redis │ │ MinIO │ │ Grafana │ │
│ │ 172.71.0.13 │ │ 172.71.0.17 │ │ 172.71.0.16 │ │
│ │ :6373 │ │ :9000/:9001 │ │ :3011 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Prometheus │ │ Adminer │ │ Gatus │ │
│ │ 172.71.0.15 │ │ │ │ 172.71.0.18 │ │
│ │ :9090 │ │ :7779 │ │ :8888 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
A merge to main builds the image, cuts the next release tag (1.0.x) and deploys int automatically (API and dashboard). Prod is promoted from the workstation Ring Promoter. There, apps Workstation OpsAPI (the API) and Workstation OpsAPI UI (the dashboard) each have an int and a prod ring. To ship a release, seed each app's prod ring with the release tag that int runs, e.g. 1.0.184. Prod seeding asks for the prod password.
- API: Ring Promoter runs Build and Deploy OpsAPI to K3s with
TARGET_ENV=prod,DEPLOYMENT_TYPE=deployandIMAGE_TAG=<release>, the same image int ran (releases are also taggedbwalia/opsapi:<release>). The workflow runs migrations, rolls out, and then Ring Promoter checks thathttps://opsapi.workstation.co.uk/readyreports thatversion. - Dashboard: Ring Promoter runs Deploy Workstation Dashboard with
TARGET_ENV=prodandVERSION=<release>, which builds the UI from exactly that tag for prod (its API URL is baked in).
You can still run both workflows by hand from the Actions tab with the same inputs.
Deploy using Docker Compose on a self-hosted runner.
Trigger: Actions → Deploy OpsAPI via Docker Compose (Self-Hosted) → Run workflow
| Option | Description | Default |
|---|---|---|
TARGET_ENV |
Preset environment | remote |
CUSTOM_ENV |
Custom environment name (overrides TARGET_ENV) | — |
PROTOCOL |
API protocol | https |
RESET_DB |
Reset database | false |
RUN_MIGRATIONS |
Run migrations after deploy | true |
PULL_LATEST |
Pull latest code | true |
RUNNER_LABEL |
Self-hosted runner label | self-hosted |
ENV_FILE_CONTENT |
Base64-encoded .env content |
— |
SLACK_WEBHOOK_URL |
Slack webhook for notifications | — |
# 1. Create sealed secret from env vars
kubeseal --format=yaml < secret.yaml > sealed-secret.yaml
# 2. Deploy with Helm
helm upgrade --install opsapi ./devops/helm-charts/opsapi \
-f ./devops/helm-charts/opsapi/values-<namespace>.yaml \
--set image.repository=bwalia/opsapi \
--set image.tag=latest \
--namespace <namespace> --create-namespaceEach values-<env>.yaml MUST set frontendUrl to the canonical frontend
origin for that environment. The deployment template injects it as
FRONTEND_URL, which migration 489 uses to bootstrap
namespaces.allowed_redirect_origins on first run AND the runtime
fallback uses if any namespace's column is empty.
# values-acc.yaml example
frontendUrl: "https://acc.diytaxreturn.co.uk"
# optional comma-separated extras for multi-frontend tenants:
# passwordResetAllowedOrigins: "https://acc-alt.diytaxreturn.co.uk"Without this, password reset emails will contain http://localhost
links — a WARN log fires in production. See Password Reset Flow.
After first deploy, verify the bootstrap landed:
kubectl -n <namespace> exec deploy/diytaxreturn-lapis -- \
psql $DATABASE_URL -c \
"SELECT slug, allowed_redirect_origins FROM namespaces;"# 1. Create sealed secret
cat node/opsapi-node/.env | kubectl create secret generic node-app-env \
--dry-run=client --from-file=.env=/dev/stdin -o json \
| kubeseal --format yaml --namespace <namespace>
# 2. Deploy with Helm
helm upgrade --install opsapi-node ./devops/helm-charts/opsapi-node \
-f ./devops/helm-charts/opsapi-node/values-<namespace>.yaml \
--set image.repository=bwalia/opsapi-node \
--set image.tag=latest \
--namespace <namespace> --create-namespace- Go to Google Cloud Console
- Create/select a project → Enable Google+ API
- Create OAuth 2.0 credentials
- Add authorized redirect URI:
http://127.0.0.1:4010/auth/google/callback - Update
lapis/.env:
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://127.0.0.1:4010/auth/google/callback