diff --git a/README.md b/README.md
index ea57c42..632abfd 100644
--- a/README.md
+++ b/README.md
@@ -404,36 +404,6 @@ Redis and are preserved by the volume.
---
-## Running a component individually
-
-> The integrated stack (`./setup.sh`) is the supported path. The per-project
-> commands below are for the advanced case of running one component on its own
-> (separate host, no orchestrator) — you then manage secrets from the
-> `config/*-secrets.js` file only (no shared OpenBao) and do the OIDC/LDAP wiring
-> by hand. See [docs/standalone.md](docs/standalone.md).
-
-Each submodule builds and runs on its own:
-
-- **SSO Manager alone**:
- ```bash
- cd sso-manager-node
- mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
- docker compose up -d --build
- ```
- See its [DEPLOYMENT.md](sso-manager-node/DEPLOYMENT.md).
-
-- **Proxy alone** (pointing at any external SSO + LDAP via a mounted
- `secrets.js`):
- ```bash
- cd proxy
- mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
- docker compose up -d --build
- ```
- See its [DEPLOYMENT.md](proxy/DEPLOYMENT.md).
-
-No cross-repo file edits are needed at runtime — the unified stack is pure
-composition (one compose file + one bootstrap script).
-
---
## How the first-run wiring works
diff --git a/docs/_config.yml b/docs/_config.yml
index d99df5a..8e129ea 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -28,9 +28,15 @@ nav:
- title: Secrets
page: /secrets.html
icon: fa-key
- - title: Standalone
- page: /standalone.html
- icon: fa-puzzle-piece
+ - title: SSO Manager
+ page: /sso/
+ icon: fa-users
+ - title: Proxy
+ page: /proxy/
+ icon: fa-shield-halved
+ - title: Jump Host
+ page: /jump-host/
+ icon: fa-terminal
- title: Changelog
url: https://github.com/theta42/theta-suite/blob/master/CHANGELOG.md
icon: fa-list
diff --git a/docs/index.md b/docs/index.md
index a71fb4e..a8d1cce 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -76,7 +76,7 @@ cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run
any time to converge the stack to `./config/`. For the full config reference,
-architecture, and running each project standalone, see the
+architecture, see the
**[GitHub repository](https://github.com/theta42/theta-suite)**.
## Related projects
diff --git a/docs/jump-host/README.md b/docs/jump-host/README.md
new file mode 100644
index 0000000..fdcfe03
--- /dev/null
+++ b/docs/jump-host/README.md
@@ -0,0 +1,24 @@
+# Documentation
+
+This directory is the GitHub Pages documentation site for the Jump Host project.
+
+**Live site:** https://theta42.github.io/jump-host/
+
+## Pages
+
+- `index.md` — overview and quick start
+- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP
+- `architecture.md` — how auth, access resolution, key injection, and bridging work
+- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL
+
+## Local preview
+
+```bash
+gem install jekyll bundler
+cd docs && jekyll serve
+# http://localhost:4000/jump-host/
+```
+
+## Updating
+
+Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically.
diff --git a/docs/jump-host/_layouts/default.html b/docs/jump-host/_layouts/default.html
new file mode 100644
index 0000000..132c524
--- /dev/null
+++ b/docs/jump-host/_layouts/default.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/jump-host/architecture.md b/docs/jump-host/architecture.md
new file mode 100644
index 0000000..c5dfbf1
--- /dev/null
+++ b/docs/jump-host/architecture.md
@@ -0,0 +1,125 @@
+---
+layout: default
+title: Architecture
+description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model.
+---
+
+# Architecture
+
+The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2)
+as both an SSH **server** and **client**) with two faces: the SSH front door
+(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its
+own — identity, authorization, and onward credentials all come from the shared
+directory.
+
+```
+ ┌────────────────────── jump host ──────────────────────┐
+ ssh │ ssh2 Server (:2222) │ ssh2 Client
+ ─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream
+ user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the
+ │ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user)
+ │ 4. bridge channels ◀───────────────────────────────▶ │
+ │ web UI/API (:3002) ──▶ audit + metrics (redis) │
+ └───────────────────────────────────────────────────────┘
+```
+
+## 1. Inbound authentication
+
+When a user connects, the jump host authenticates them against LDAP:
+
+- **Public key** — it looks up the user's `sshPublicKey` values in the directory
+ and matches the offered key (handling ssh2's probe-then-sign two-phase
+ publickey auth). The jump host's *own* injected key (identified by its comment
+ marker) is deliberately excluded from this match — only the jump host may hold
+ that private key, so accepting it inbound would be a bypass.
+- **Password** — an LDAP simple bind as the user's DN. Policy is configurable:
+ `off` (keys only — recommended for a public host), `local` (passwords only
+ from loopback/RFC1918 clients, keys-only from the internet), or `all`.
+
+Every attempt — success or failure, with method and reason — is audited.
+
+## 2. Access & target resolution
+
+The hosts a user may reach are computed from the directory, not a local list:
+
+1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
+2. For each group, the SSO's
+ `GET /api/discovery/resources?group=` (authenticated with an API token),
+ unioned and filtered to `kind: host`.
+
+Each host's dial address is `metadata.ip` (or the hostname from
+`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
+briefly per user and shared by both the grammar path and the TUI picker.
+
+Target matching tries, in order: exact slug → `host_`-prefixed slug → display
+name → IP → address hostname. A raw IP that isn't an accessible directory host
+is refused unless explicitly allowed.
+
+> The directory auto-creates `_access` / `_admin` groups for every
+> host and service (see the SSO's
+> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html)
+> docs), which is exactly what this authorization reads.
+
+## 3. Per-user key injection {#per-user-key-injection}
+
+The jump host holds **one** keypair. To connect downstream *as the user*
+without asking them for anything, it must present a key the downstream `sshd`
+will accept for that user. Downstream hosts (joined via
+[ldap-client](https://github.com/theta42/ldap-client)) serve authorized keys
+straight from LDAP via `AuthorizedKeysCommand`. So on a user's first connection,
+the jump host appends its own public key to that user's `sshPublicKey` attribute
+in LDAP — comment-marked so it's recognizable — then connects downstream with
+its private key.
+
+- Idempotent: the key is added once; a redis flag skips the LDAP round-trip
+ afterwards.
+- The jump host's bind account therefore needs **write access to the
+ `sshPublicKey` attribute** on user entries (an OpenLDAP ACL — see the README).
+ In the bundled theta-env deployment this is handled for you.
+- Because the marker key is excluded from inbound auth (step 1), it grants only
+ the jump host's onward path, never inbound impersonation.
+
+## 4. Bridging
+
+Once the upstream connection is ready, the jump host splices SSH channels
+between the two connections:
+
+- **shell / exec** — piped both ways, with window-change and exit-status
+ forwarded.
+- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes;
+ no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work
+ unchanged.
+- Channel requests that arrive before the upstream is ready are buffered and
+ replayed, so nothing is dropped during the connect.
+- The downstream host key's SHA256 fingerprint is recorded in the audit event
+ (trust-on-use in v1).
+
+Byte counts per direction are tallied cheaply for the audit record.
+
+## Web UI, API & audit
+
+An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
+look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
+local anti-lockout admin (`auth.adminUsers`), with admin access gated by
+`auth.adminGroups`. It exposes:
+
+- `GET /health` — open; `{status, activeSessions, version}`
+- `GET /api/sessions` — active sessions
+- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
+- `GET /api/metrics` — counters (total, failures, top users/hosts)
+
+Audit events and counters live in redis. Each event captures: user, auth method,
+mode (grammar/picker), target slug/address/port, channel type, client IP,
+success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
+
+## Where it sits in the stack
+
+- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
+ OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
+ jump host reads.
+- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
+ downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
+ connects into.
+- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
+ under TLS.
+- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
diff --git a/docs/jump-host/assets/css/style.css b/docs/jump-host/assets/css/style.css
new file mode 100644
index 0000000..e24a5de
--- /dev/null
+++ b/docs/jump-host/assets/css/style.css
@@ -0,0 +1,116 @@
+/* theta42 docs site — shares the in-app dark navbar/footer + card look
+ (Bootstrap 5 + Font Awesome, same as the running apps) rather than a
+ generic Jekyll theme. */
+
+body {
+ background-color: #f4f5f6;
+}
+
+.navbar-brand img {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
+}
+
+.navbar-nav .nav-link.active {
+ color: #fff;
+ font-weight: 600;
+}
+
+/* Markdown content typography, scoped to the card body so it doesn't leak
+ into the nav/footer. */
+.site-content h1:first-child {
+ margin-top: 0;
+}
+
+.site-content h1,
+.site-content h2,
+.site-content h3 {
+ font-weight: 700;
+}
+
+.site-content h2 {
+ margin-top: 2.5rem;
+ padding-bottom: .4rem;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.site-content h3 {
+ margin-top: 1.75rem;
+}
+
+.site-content a {
+ color: #a3671f;
+ text-decoration-color: rgba(163, 103, 31, .35);
+}
+
+.site-content a:hover {
+ color: #8a5a16;
+}
+
+.site-content pre {
+ background-color: #212529;
+ color: #f8f9fa;
+ padding: 1rem 1.25rem;
+ border-radius: .375rem;
+ overflow-x: auto;
+}
+
+.site-content code {
+ color: #a3671f;
+ background-color: #f4f0e8;
+ padding: .15em .4em;
+ border-radius: .25rem;
+ font-size: .875em;
+}
+
+.site-content pre code {
+ color: inherit;
+ background: none;
+ padding: 0;
+}
+
+.site-content table {
+ display: block;
+ overflow-x: auto;
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.25rem 0;
+}
+
+.site-content table th,
+.site-content table td {
+ border: 1px solid #dee2e6;
+ padding: .5rem .75rem;
+ text-align: left;
+}
+
+.site-content table th {
+ background-color: #f8f9fa;
+}
+
+.site-content blockquote {
+ border-left: 4px solid #C59341;
+ padding: .5rem 1rem;
+ margin: 1.25rem 0;
+ background-color: #f8f6f1;
+ color: #495057;
+}
+
+.site-content img {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Screenshot grids in the markdown use width="49%" inline attrs for a
+ two-up desktop layout -- stack them on narrow screens instead of
+ squeezing to illegibility. */
+@media (max-width: 576px) {
+ .site-content img[width] {
+ width: 100% !important;
+ margin-bottom: .75rem;
+ }
+}
+
+.site-content hr {
+ margin: 2rem 0;
+ border-top: 1px solid #e9ecef;
+}
diff --git a/docs/jump-host/assets/img/favicon.svg b/docs/jump-host/assets/img/favicon.svg
new file mode 100644
index 0000000..35e1881
--- /dev/null
+++ b/docs/jump-host/assets/img/favicon.svg
@@ -0,0 +1,17 @@
+
diff --git a/docs/jump-host/assets/img/theta42.svg b/docs/jump-host/assets/img/theta42.svg
new file mode 100644
index 0000000..e598305
--- /dev/null
+++ b/docs/jump-host/assets/img/theta42.svg
@@ -0,0 +1,51 @@
+
diff --git a/docs/jump-host/connecting.md b/docs/jump-host/connecting.md
new file mode 100644
index 0000000..fc2ccbb
--- /dev/null
+++ b/docs/jump-host/connecting.md
@@ -0,0 +1,102 @@
+---
+layout: default
+title: Connecting
+description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get.
+---
+
+# Connecting
+
+You reach a downstream host two ways: name the target in your username, or log
+in plain and pick it from a menu. Either way you authenticate **once**, to the
+jump host, with your directory credentials.
+
+## The username grammar
+
+```
+{uid}_-_{target}
+```
+
+- `{uid}` — your directory username.
+- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP).
+- `{target}` — the host to reach: a directory **slug** (`host_web01` or just
+ `web01`), the host's **display name**, its **IP**, or the hostname in its
+ directory `address`.
+
+```bash
+ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional)
+ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach)
+```
+
+If the target matches a host your directory groups grant, you're bridged
+straight to its `sshd` — same as if you'd SSH'd directly, but through the
+audited jump host.
+
+## SFTP / WinSCP / scp
+
+Because the whole route is encoded in the username, file transfer tools that
+only take one connection string work with no extra configuration:
+
+```bash
+sftp -P 2222 alice_-_web01@jump.example.com
+scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/
+```
+
+**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name
+to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations
+(browse, upload, download, rename) work normally.
+
+## The interactive picker
+
+Log in with just your username and you get a TUI list of every host you can
+reach:
+
+```bash
+ssh alice@jump.example.com
+```
+
+- **↑ / ↓** move the selection
+- **type** to filter the list incrementally
+- **Enter** connect to the highlighted host
+- **number keys** jump straight to that row
+- **q** or **Ctrl-C** to quit
+
+Pick a host and you're bridged into it. The picker only ever lists hosts your
+directory access allows — it doubles as "what can I reach from here?"
+
+## What you can reach
+
+The set of hosts is computed per login: your LDAP group memberships intersected
+with the SSO directory's hosts (via the `host__access` groups the
+directory auto-creates for each machine). To get access to a new host, an admin
+adds you to that host's access group in the SSO — nothing on the jump host
+changes.
+
+Targets that don't resolve to a host you're allowed to reach are refused (and
+audited). Raw IPs that aren't a known directory host are denied by default.
+
+
+## Authentication
+
+The jump host authenticates **you** against the directory:
+
+- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your
+ normal SSH key; the client picks it automatically.
+- **Password** — your directory password (LDAP bind). Password auth is often
+ restricted to local networks or disabled entirely on a public jump host
+ (keys-only) — check with your operator.
+
+You never manage a separate credential for the downstream host: the jump host
+handles onward authentication for you (see
+[Architecture](architecture.html#per-user-key-injection)).
+
+## First connection to a host
+
+The very first time you reach a given downstream host, the jump host provisions
+its access key for you behind the scenes. If that first attempt races the
+directory's key-cache refresh you may see a brief
+
+```
+jump-host: first-time key propagation, retrying…
+```
+
+and it reconnects automatically. Subsequent connections are immediate.
diff --git a/docs/jump-host/images/audit.png b/docs/jump-host/images/audit.png
new file mode 100644
index 0000000..997974d
Binary files /dev/null and b/docs/jump-host/images/audit.png differ
diff --git a/docs/jump-host/images/dashboard.png b/docs/jump-host/images/dashboard.png
new file mode 100644
index 0000000..8caa276
Binary files /dev/null and b/docs/jump-host/images/dashboard.png differ
diff --git a/docs/jump-host/images/login.png b/docs/jump-host/images/login.png
new file mode 100644
index 0000000..5881ac9
Binary files /dev/null and b/docs/jump-host/images/login.png differ
diff --git a/docs/jump-host/images/sessions.png b/docs/jump-host/images/sessions.png
new file mode 100644
index 0000000..e52511c
Binary files /dev/null and b/docs/jump-host/images/sessions.png differ
diff --git a/docs/jump-host/index.md b/docs/jump-host/index.md
new file mode 100644
index 0000000..58f720f
--- /dev/null
+++ b/docs/jump-host/index.md
@@ -0,0 +1,116 @@
+---
+layout: default
+title: Home
+description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to.
+---
+
+# Jump Host
+
+An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
+stack. Users SSH into **one** public host and land on any downstream host
+they're entitled to — authenticated against the shared LDAP directory,
+authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
+inventory graph, and audited end to end.
+
+No per-host accounts, no distributing keys, no VPN. The same people who log in
+to your SSO are the people who can reach your machines — and only the machines
+their directory groups grant.
+
+Part of the theta42 self-hosted identity stack, alongside
+[SSO Manager](https://theta42.github.io/sso-manager-node/) and
+[Proxy](https://theta42.github.io/proxy/), composable with one command via
+[theta-env](https://theta42.github.io/theta-env/).
+
+## Screenshots
+
+
+
+
+
+
+*(click any screenshot to view full size)*
+
+
+
+## Two ways to connect
+
+**Direct (WinSCP/SFTP-friendly):**
+
+```bash
+ssh alice_-_web01@jump.example.com
+sftp -P 2222 alice_-_web01@jump.example.com
+```
+
+The username grammar is `{uid}_-_{target}` — `target` is a directory host slug
+(with or without the `host_` prefix), a bare hostname, or an IP. One username
+string, no interactive step, so it works cleanly in WinSCP and scripts.
+
+**Interactive picker:**
+
+```bash
+ssh alice@jump.example.com
+```
+
+A plain login shows a TUI list of the hosts you can reach; arrow-key or type to
+filter, Enter to connect.
+
+See **[Connecting](connecting.html)** for the full usage guide.
+
+## Why a jump host (and why this one)
+
+A bastion/jump host is the standard way to give SSH access to internal machines
+through a single audited entry point. What's usually painful is *authorization*
+and *credentials*: who may reach which host, and how the bastion authenticates
+onward without you copying keys everywhere.
+
+This jump host answers both from your directory:
+
+- **Authorization is your directory graph.** The hosts you can reach are the
+ union of your LDAP groups × the SSO's inventory (the `host__access`
+ groups the directory already auto-creates). Add someone to a group; they can
+ reach the host. No bastion-side allow-list to maintain.
+- **Onward auth is automatic.** The jump host holds one key and injects its
+ public half into your `sshPublicKey` on first use, then connects downstream
+ **as you**. Downstream hosts already serve keys from LDAP (via
+ [ldap-client](https://github.com/theta42/ldap-client)'s
+ `AuthorizedKeysCommand`), so nothing downstream needs configuring.
+
+## Features
+
+- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
+ SFTP included (WinSCP works)
+- **Interactive TUI host picker** on plain login, scoped to your access
+- **LDAP inbound auth** — public key or password (keys-only policy recommended
+ for a public host)
+- **Directory-driven access** — reachable hosts come from the SSO inventory, not
+ a static list
+- **Per-user key injection** — no downstream changes, no key distribution
+- **Shell, exec, and SFTP** bridging
+- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
+ audit log, per-user/per-host counters
+- **Full audit trail** — who, target, method, result, bytes, duration, and the
+ downstream host-key fingerprint
+
+- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
+ installer, or bundled in theta-env
+
+## Get it
+
+```bash
+git clone https://github.com/theta42/jump-host.git
+cd jump-host
+cp secrets.js.example config/jump-secrets.js # then edit it
+docker compose up -d --build
+```
+
+For bare-metal and the bundled theta-env
+option, see **[Installation](installation.html)**.
+
+## Related projects
+
+- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
+ directory + OIDC provider + the inventory graph this jump host reads.
+- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
+ same identity; fronts this jump host's web UI.
+- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
+ jump host included, with one command.
diff --git a/docs/proxy/README.md b/docs/proxy/README.md
new file mode 100644
index 0000000..8203639
--- /dev/null
+++ b/docs/proxy/README.md
@@ -0,0 +1,39 @@
+# Documentation
+
+This directory contains the GitHub Pages documentation site for the Proxy project.
+
+**Live site:** https://theta42.github.io/proxy/
+
+## Pages
+
+- `index.md` - Home page with project overview
+- `installation.md` - Installation and setup guide
+- `api.md` - Complete API reference
+- `architecture.md` - System architecture and design
+- `contributing.md` - Development and contribution guide
+
+## Local Preview
+
+To preview the site locally:
+
+```bash
+# Install Jekyll (one-time setup)
+gem install jekyll bundler
+
+# Run local server
+cd docs
+jekyll serve
+
+# View at http://localhost:4000/proxy/
+```
+
+## Theme
+
+The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`.
+
+## Updating Documentation
+
+1. Edit markdown files in this directory
+2. Commit and push to master branch
+3. GitHub Pages automatically rebuilds (may take 1-2 minutes)
+4. Changes visible at https://theta42.github.io/proxy/
diff --git a/docs/proxy/_layouts/default.html b/docs/proxy/_layouts/default.html
new file mode 100644
index 0000000..132c524
--- /dev/null
+++ b/docs/proxy/_layouts/default.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+ {% seo title=false %}
+ {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/proxy/api.md b/docs/proxy/api.md
new file mode 100755
index 0000000..a497b71
--- /dev/null
+++ b/docs/proxy/api.md
@@ -0,0 +1,972 @@
+---
+layout: default
+title: API Reference
+description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions.
+---
+
+# API Documentation
+
+[← Back to Home](index.html)
+
+All API endpoints require authentication unless otherwise noted. Three
+authentication methods are supported:
+
+- **`auth-token` header** — a browser-session token from `POST /api/auth/login`
+ or the OIDC flow (below).
+- **`Authorization: Bearer ` header** — a self-service API token (PAT,
+ see [API Tokens](#api-tokens)), for scripts/CI without a browser session.
+- **OIDC (browser)** — if the proxy is configured as an OIDC client of an SSO
+ (`app_oidc__*` / `conf.oidc`, see [DEPLOYMENT.md](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)),
+ users can log in via `GET /api/auth/oidc/start` instead of posting a
+ username/password.
+
+The proxy can also be configured as a **direct LDAP client** (`app_ldap__*` /
+`conf.ldap`) for looking up/validating users, independent of the OIDC flow —
+see DEPLOYMENT.md for the full configuration reference.
+
+Authenticated requests also carry **RBAC** (role-based access control):
+global admins can manage everything; other users are scoped to `viewer` or
+`manager` rights on specific domains via [Permissions](#permissions) and
+[Groups](#groups).
+
+Base URL: `https://your-proxy-host.com/api`
+
+---
+
+## Authentication
+
+### Login
+
+**POST** `/api/auth/login`
+
+Authenticate a user and receive an auth token.
+
+```bash
+curl -H "Content-Type: application/json" \
+ -X POST \
+ -d '{"username": "myuser", "password": "mypassword"}' \
+ https://proxy-host.com/api/auth/login
+```
+
+**Responses:**
+- `200` `{"login": true, "token": "027d3964-7d81-4462-a6f9-2c1f9b40b4be", "message": "myuser logged in!"}`
+- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}`
+
+### Logout
+
+**ALL** `/api/auth/logout`
+
+Invalidate the current auth token.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X POST \
+ https://proxy-host.com/api/auth/logout
+```
+
+**Responses:**
+- `200` `{"message": "Bye"}`
+
+### OIDC Login (start)
+
+**GET** `/api/auth/oidc/start`
+
+Begin the OIDC authorization-code flow: creates a PKCE + state challenge and
+redirects the browser to the configured SSO's authorize endpoint. Only
+available when `conf.oidc.enabled` is true.
+
+**Query Parameters:**
+- `redirect` - Internal path to return to after login (optional; sanitized to same-origin)
+
+```bash
+curl -i "https://proxy-host.com/api/auth/oidc/start?redirect=/hosts"
+```
+
+**Responses:**
+- `302` Redirect to the SSO's authorization endpoint
+- `404` `{"name": "OidcDisabled", "message": "OIDC login is not enabled."}`
+
+### OIDC Callback
+
+**GET** `/api/auth/oidc/callback`
+
+Redirect target for the SSO after login. Validates the one-time `state`,
+exchanges the authorization `code` for tokens, reads identity from the
+userinfo endpoint, establishes a session, and redirects the browser back to
+the login page with the app's own `auth-token` in a URL fragment.
+
+**Query Parameters:**
+- `code` (required) - Authorization code from the SSO
+- `state` (required) - State value from the `start` step
+
+```bash
+# Not called directly — the SSO redirects the browser here after login.
+```
+
+**Responses:**
+- `302` Redirect to `/login#token=...&redirect=...`
+- `400` `{"name": "OidcCallbackInvalid", "message": "Missing code or state."}` or expired/unknown state
+
+---
+
+## API Tokens
+
+Self-service personal access tokens (PATs) for scripting/CI without a browser
+session. Every endpoint is owner-scoped: a user only sees/manages tokens they
+created. Mounted at `/api/api-token`.
+
+### List API Tokens
+
+**GET** `/api/api-token`
+
+List the current user's API tokens.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/api-token
+```
+
+**Responses:**
+- `200` `{"results": [{"id": "...", "name": "ci", ...}, ...]}`
+
+### Create API Token
+
+**POST** `/api/api-token`
+
+Create a new API token. The raw token string is only returned once, at
+creation.
+
+**Parameters:**
+- `name` (required) - Display name
+- `description` (optional)
+- `expires_in_days` (optional) - `0` or omitted means no expiry
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"name": "ci", "expires_in_days": 90}' \
+ https://proxy-host.com/api/api-token
+```
+
+**Responses:**
+- `200` `{"results": {...}, "token": "prx__", "message": "API token 'ci' created. Save it now — it will not be shown again."}`
+
+### Get API Token
+
+**GET** `/api/api-token/:id`
+
+Get a token's metadata (not the raw secret, which is never stored/returned again).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/api-token/
+```
+
+**Responses:**
+- `200` `{"results": {...}}`
+- `403` Not your token
+
+### Update API Token
+
+**PUT** `/api/api-token/:id`
+
+Update a token's name/description/expiry.
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X PUT \
+ -d '{"name": "ci-updated"}' \
+ https://proxy-host.com/api/api-token/
+```
+
+**Responses:**
+- `200` `{"results": {...}, "message": "API token 'ci-updated' updated."}`
+
+### Delete (Revoke) API Token
+
+**DELETE** `/api/api-token/:id`
+
+Revoke a token immediately.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/api-token/
+```
+
+**Responses:**
+- `200` `{"id": "", "message": "API token 'ci' revoked."}`
+
+### Rotate API Token
+
+**POST** `/api/api-token/:id/rotate`
+
+Issue a new secret for an existing token (same id, new raw value shown once).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X POST \
+ https://proxy-host.com/api/api-token//rotate
+```
+
+**Responses:**
+- `200` `{"token": "prx__", "message": "API token 'ci' rotated. Save it — it will not be shown again."}`
+
+---
+
+## Users
+
+All user endpoints require authentication. `GET /me` and `PUT /password`
+(self-service) work for any authenticated user; everything else (listing,
+creating, deleting users, resetting another user's password) requires global
+admin.
+
+### List Users
+
+**GET** `/api/user`
+
+Get list of all users. Admin only.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/user
+```
+
+**Query Parameters:**
+- `detail` - Include full user details (optional)
+
+**Responses:**
+- `200` `{"results": ["user1", "user2"]}`
+- `200` `{"results": [{"username": "user1", ...}, ...]}` (with `?detail=true`)
+- `403` Not an admin
+
+### Get Current User
+
+**GET** `/api/user/me`
+
+Get the currently authenticated user's identity and effective RBAC rights
+(drives the web UI's nav/button gating).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/user/me
+```
+
+**Responses:**
+- `200` `{"username": "myuser", "groups": [...], "localGroups": [...], "externalGroups": [...], "isAdmin": false, "global": null, "domains": {...}}`
+
+### Create User
+
+**POST** `/api/user`
+
+Create a new local user. Admin only.
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"username": "newuser", "password": "newpassword"}' \
+ https://proxy-host.com/api/user
+```
+
+**Responses:**
+- `200` User created successfully
+- `403` Not an admin
+- `409` Username already exists
+- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error (also returned for weak passwords)
+
+### Delete User
+
+**DELETE** `/api/user/:username`
+
+Delete a user account. Admin only.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/user/olduser
+```
+
+**Responses:**
+- `200` `{"username": "olduser", "results": ...}`
+- `403` Not an admin
+- `404` User not found
+
+### Change Password (Self)
+
+**PUT** `/api/user/password`
+
+Change the password for the currently authenticated user.
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X PUT \
+ -d '{"password": "newpassword"}' \
+ https://proxy-host.com/api/user/password
+```
+
+**Responses:**
+- `200` `{"results": ...}` Password changed successfully
+- `422` Weak password rejected by the password policy
+
+### Change Password (Other User)
+
+**PUT** `/api/user/password/:username`
+
+Change the password for another user. Admin only.
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X PUT \
+ -d '{"password": "newpassword"}' \
+ https://proxy-host.com/api/user/password/otheruser
+```
+
+**Responses:**
+- `200` `{"results": ...}` Password changed successfully
+- `403` Not an admin
+- `404` User not found
+
+---
+
+## Permissions
+
+RBAC: grants a `viewer` or `manager` role to a user or group, either globally
+or scoped to one domain. Global-admin-only. Mounted at `/api/permission`.
+
+### List Permissions
+
+**GET** `/api/permission`
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/permission
+```
+
+**Responses:**
+- `200` `{"results": [{"id": "...", "subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com", ...}, ...]}`
+
+### List Permission Subjects
+
+**GET** `/api/permission/subjects`
+
+Autocomplete source for the "Subject" field: known usernames plus known group
+names (local groups, groups already used in permissions, and groups from
+`conf.auth.adminGroups` / `conf.auth.groupRoleMap`).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/permission/subjects
+```
+
+**Responses:**
+- `200` `{"users": ["alice", "bob"], "groups": ["ops", "sre"]}`
+
+### Create Permission
+
+**POST** `/api/permission`
+
+Grant a role to a subject.
+
+**Parameters:**
+- `subjectType` (required) - `user` or `group`
+- `subject` (required) - username or group name
+- `role` (required) - `viewer` or `manager`
+- `scope` (required) - `global` or `domain`
+- `domain` (required if `scope` is `domain`)
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com"}' \
+ https://proxy-host.com/api/permission
+```
+
+**Responses:**
+- `200` `{"message": "Granted manager to user \"alice\" on example.com.", ...}`
+- `422` Validation error
+
+### Delete Permission
+
+**DELETE** `/api/permission/:id`
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/permission/
+```
+
+**Responses:**
+- `200` `{"message": "Permission removed."}`
+
+---
+
+## Groups
+
+Local groups (independent of any SSO/LDAP groups) used as subjects for
+permission grants. Global-admin-only. Mounted at `/api/group`.
+
+### List Groups
+
+**GET** `/api/group`
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/group
+```
+
+**Responses:**
+- `200` `{"results": [{"name": "ops", "members": ["alice", "bob"], ...}, ...]}`
+
+### Create Group
+
+**POST** `/api/group`
+
+**Parameters:**
+- `name` (required)
+- `members` (optional) - array of usernames
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"name": "ops", "members": ["alice"]}' \
+ https://proxy-host.com/api/group
+```
+
+**Responses:**
+- `200` `{"message": "Group \"ops\" created.", ...}`
+
+### Delete Group
+
+**DELETE** `/api/group/:name`
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/group/ops
+```
+
+**Responses:**
+- `200` `{"message": "Group \"ops\" removed."}`
+
+### Add Group Member
+
+**POST** `/api/group/:name/members`
+
+**Parameters:**
+- `username` (required)
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"username": "bob"}' \
+ https://proxy-host.com/api/group/ops/members
+```
+
+**Responses:**
+- `200` `{"message": "Added \"bob\" to \"ops\".", ...}`
+
+### Remove Group Member
+
+**DELETE** `/api/group/:name/members/:username`
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/group/ops/members/bob
+```
+
+**Responses:**
+- `200` `{"message": "Removed \"bob\" from \"ops\".", ...}`
+
+---
+
+## Hosts
+
+Manage proxy host configurations.
+
+### List Hosts
+
+**GET** `/api/host`
+
+Get list of all configured hosts.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/host
+```
+
+**Query Parameters:**
+- `detail` - Include full host details (optional)
+
+**Responses:**
+- `200` `{"results": ["example.com", "*.wildcard.com"]}`
+- `200` `{"results": [{"host": "example.com", "ip": "192.168.1.10", ...}, ...]}` (with `?detail=true`)
+
+### Get Host
+
+**GET** `/api/host/:host`
+
+Get configuration for a specific host.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/host/example.com
+```
+
+**Responses:**
+- `200` `{"item": "example.com", "results": {"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, ...}}`
+- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
+
+### Lookup Host
+
+**GET** `/api/host/lookup/:domain`
+
+Test the host lookup algorithm (supports wildcard matching).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/host/lookup/sub.example.com
+```
+
+**Responses:**
+- `200` `{"string": "sub.example.com", "results": {"host": "*.example.com", ...}}`
+- `200` `{"string": "sub.example.com", "results": null}` (no match)
+
+### Get Lookup Tree
+
+**GET** `/api/host/lookupobj`
+
+Get the internal lookup tree structure (for debugging).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/host/lookupobj
+```
+
+**Responses:**
+- `200` `{"results": {"com": {"example": {...}}}}`
+
+### Create Host
+
+**POST** `/api/host`
+
+Add a new host configuration.
+
+**Parameters:**
+- `host` (required) - Domain name (e.g., `example.com`, `*.example.com`)
+- `ip` (required) - Target IP address or FQDN
+- `targetPort` (required) - Target port number (1-65535)
+- `forcessl` (optional) - Force HTTPS redirect (default: true)
+- `targetssl` (optional) - Use HTTPS to backend (default: false)
+- `challengeType` (optional) - For wildcards: `DNS-01-wildcard` or `wildcardChild`
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, "forcessl": true, "targetssl": false}' \
+ https://proxy-host.com/api/host
+```
+
+**Responses:**
+- `200` `{"message": "\"example.com\" added.", "host": "example.com", ...}`
+- `409` `{"name": "HostNameUsed", "message": "Host already exists"}`
+- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error
+
+### Update Host
+
+**PUT** `/api/host/:host`
+
+Update an existing host configuration.
+
+**Parameters:** Same as Create Host (all optional)
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X PUT \
+ -d '{"ip": "192.168.1.20", "targetPort": 9000}' \
+ https://proxy-host.com/api/host/example.com
+```
+
+**Responses:**
+- `200` `{"message": "\"example.com\" updated.", ...}`
+- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
+- `422` Validation error
+
+### Delete Host
+
+**DELETE** `/api/host/:host`
+
+Remove a host configuration.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/host/example.com
+```
+
+**Responses:**
+- `200` `{"message": "example.com deleted", ...}`
+- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
+
+### Clear Host Cache
+
+**DELETE** `/api/host/cache`
+
+Remove all cached wildcard-subdomain host lookups. Cache entries are created on
+demand when a wildcard host serves a subdomain; clearing them forces the next
+request for each subdomain to be resolved fresh through the lookup tree.
+Admin only.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/host/cache
+```
+
+**Responses:**
+- `200` `{"message": "Cleared 3 cached hosts.", "count": 3}`
+
+### Renew Wildcard Certificate
+
+**PUT** `/api/host/:host/renew`
+
+Manually trigger wildcard certificate renewal.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X PUT \
+ https://proxy-host.com/api/host/*.example.com/renew
+```
+
+**Responses:**
+- `200` `{"message": "Requesting wildcard cert for *.example.com"}`
+- `404` Host not found
+
+---
+
+## DNS Providers
+
+Manage DNS provider integrations for wildcard SSL certificates.
+
+### List DNS Providers
+
+**GET** `/api/dns`
+
+Get list of configured DNS providers.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/dns
+```
+
+**Query Parameters:**
+- `detail` - Include full provider details (optional)
+
+**Responses:**
+- `200` `{"results": ["provider-id-1", "provider-id-2"]}`
+
+### List Available Provider Types
+
+**OPTIONS** `/api/dns`
+
+Get list of supported DNS provider types and their configuration requirements.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X OPTIONS \
+ https://proxy-host.com/api/dns
+```
+
+**Responses:**
+- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
+
+### Create DNS Provider
+
+**POST** `/api/dns`
+
+Configure a new DNS provider.
+
+**Cloudflare:**
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"name": "My Cloudflare", "dnsProvider": "Cloudflare", "token": "your-api-token"}' \
+ https://proxy-host.com/api/dns
+```
+
+**DigitalOcean:**
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"name": "My DO", "dnsProvider": "DigitalOcean", "token": "your-api-token"}' \
+ https://proxy-host.com/api/dns
+```
+
+**PorkBun:**
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"name": "My PorkBun", "dnsProvider": "PorkBun", "apiKey": "pk_xxx", "secretApiKey": "sk_xxx"}' \
+ https://proxy-host.com/api/dns
+```
+
+**DuckDNS (free):**
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \
+ https://proxy-host.com/api/dns
+```
+
+`subdomains` is a comma-separated list of the subdomains you've registered at
+[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
+`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
+DuckDNS only supports one A/AAAA record and one TXT record per domain (no
+arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
+
+**Responses:**
+- `200` `{"message": "\"provider-id\" added.", ...}`
+- `422` Validation error or invalid API credentials
+
+### Get DNS Provider
+
+**GET** `/api/dns/:id`
+
+Get a specific DNS provider configuration.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/dns/provider-id
+```
+
+**Responses:**
+- `200` `{"item": "provider-id", "results": {...}}`
+- `404` Provider not found
+
+### Update DNS Provider
+
+**PUT** `/api/dns/:id`
+
+Update DNS provider configuration.
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X PUT \
+ -d '{"name": "Updated Name"}' \
+ https://proxy-host.com/api/dns/provider-id
+```
+
+**Responses:**
+- `200` `{"message": "\"provider-id\" updated.", ...}`
+- `404` Provider not found
+
+### Delete DNS Provider
+
+**DELETE** `/api/dns/:id`
+
+Remove a DNS provider and all associated domains.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/dns/provider-id
+```
+
+**Responses:**
+- `200` `{"message": "provider-id deleted", ...}`
+- `404` Provider not found
+
+### List Domains
+
+**GET** `/api/dns/domain`
+
+List all domains from all configured providers.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/dns/domain
+```
+
+**Query Parameters:**
+- `detail` - Include full domain details (optional)
+
+**Responses:**
+- `200` `{"results": ["example.com", "test.com"]}`
+
+### Get Domain
+
+**GET** `/api/dns/domain/:domain`
+
+Get details for a specific domain.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/dns/domain/example.com
+```
+
+**Responses:**
+- `200` `{"results": [{"domain": "example.com", "zoneId": "...", ...}]}`
+- `404` Domain not found
+
+### Refresh Domains
+
+**POST** `/api/dns/domain/refresh/:providerId`
+
+Refresh the domain list from a DNS provider's API.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X POST \
+ https://proxy-host.com/api/dns/domain/refresh/provider-id
+```
+
+**Responses:**
+- `200` `{"results": ...}` Updated domain list
+- `404` Provider not found
+
+### Dynamic DNS
+
+A-records kept automatically pointed at this box's public (WAN) IP. All
+`/api/dns/dynamic*` routes are viewer/manager scoped to the record's domain
+(via [Permissions](#permissions)), not admin-only like the rest of `/api/dns`.
+
+#### Get Current Public IP
+
+**GET** `/api/dns/dynamic/ip`
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/dns/dynamic/ip
+```
+
+**Responses:**
+- `200` `{"ip": "203.0.113.5"}`
+
+#### List Dynamic Records
+
+**GET** `/api/dns/dynamic`
+
+Lists records the caller may view (their own/granted domains, or all for admins).
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/dns/dynamic
+```
+
+**Responses:**
+- `200` `{"results": [{"id": "...", "domain": "example.com", "name": "home", "last_status": "ok", ...}, ...]}`
+
+#### Create Dynamic Record
+
+**POST** `/api/dns/dynamic`
+
+Requires `manager` rights on the target domain. Applies the record immediately
+against the current public IP (best-effort — failures are recorded in
+`last_status` and retried by the scheduler).
+
+**Parameters:**
+- `domain` (required)
+- `name` (required) - sub-label, or `@` for the apex
+
+```bash
+curl -H "Content-Type: application/json" \
+ -H "auth-token: your-token-here" \
+ -X POST \
+ -d '{"domain": "example.com", "name": "home"}' \
+ https://proxy-host.com/api/dns/dynamic
+```
+
+**Responses:**
+- `200` `{"message": "\"home.example.com\" added.", ...}`
+- `403` Missing `manager` rights on the domain
+- `422` Validation error
+
+#### Refresh Dynamic Record
+
+**POST** `/api/dns/dynamic/:id/refresh`
+
+Force an immediate refresh of one record against the current public IP.
+Requires `manager` rights on the record's domain.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X POST \
+ https://proxy-host.com/api/dns/dynamic//refresh
+```
+
+**Responses:**
+- `200` `{"message": "Refreshed \"home.example.com\".", "result": {...}}`
+- `403` Missing `manager` rights on the domain
+
+#### Delete Dynamic Record
+
+**DELETE** `/api/dns/dynamic/:id`
+
+Stop managing a record. Requires `manager` rights on the record's domain.
+Leaves the provider's A record in place at its last value.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ -X DELETE \
+ https://proxy-host.com/api/dns/dynamic/
+```
+
+**Responses:**
+- `200` `{"message": "home.example.com removed.", ...}`
+- `403` Missing `manager` rights on the domain
+
+---
+
+## Certificates
+
+Retrieve SSL certificate information.
+
+### Get Certificate
+
+**GET** `/api/cert/:host`
+
+Get the SSL certificate for a host.
+
+```bash
+curl -H "auth-token: your-token-here" \
+ https://proxy-host.com/api/cert/example.com
+```
+
+**Responses:**
+- `200` Certificate data including `cert_pem`, `fullchain_pem`, `privkey_pem`, expiry information
+- `404` Certificate not found
+
+---
+
+## Error Responses
+
+All endpoints may return the following error responses:
+
+- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` - Authentication required or invalid
+- `404` `{"name": "NotFound", "message": "..."}` - Resource not found
+- `422` `{"name": "ObjectValidateError", "message": [...], "keys": [...]}` - Validation errors
+- `500` Internal server error
+
+## Notes
+
+- All timestamps are in milliseconds since epoch
+- Authenticated endpoints accept either the `auth-token` header (browser
+ session / OIDC login) or an `Authorization: Bearer ` API token
+- Host names support wildcards: `*` (single level) and `**` (multi-level)
+- DNS providers are validated on creation - invalid API credentials will be rejected
+- Wildcard certificates are automatically renewed 30 days before expiration
diff --git a/docs/proxy/architecture.md b/docs/proxy/architecture.md
new file mode 100644
index 0000000..29f7a04
--- /dev/null
+++ b/docs/proxy/architecture.md
@@ -0,0 +1,291 @@
+---
+layout: default
+title: Architecture
+description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
+---
+
+# Architecture
+
+[← Back to Home](index.html)
+
+> Looking for a plainer explanation of hosts, HTTPS, or the local
+> permission model instead of internals? See
+> [Hosts & HTTPS](concepts-hosts.html) and
+> [Users, Groups & Permissions](concepts-access.html).
+
+## System Overview
+
+The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Internet │
+└─────────────────────────┬────────────────────────────────────┘
+ │ HTTPS/HTTP
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ OpenResty/Nginx │
+│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
+│ │ SSL Termination│ │ Host Routing │ │ Request Proxying│ │
+│ │ (lua-resty- │ │ (targetinfo. │ │ │ │
+│ │ auto-ssl) │ │ lua) │ │ │ │
+│ └────────────────┘ └──────┬───────┘ └─────────────────┘ │
+└────────────┬──────────────────┼───────────────────────────┬──┘
+ │ │ │
+ Let's Encrypt 1. Check Redis FIRST Backend
+ HTTP-01 2. Unix Socket (fallback) Services
+ │ │ │
+ ▼ ▼ ▼
+┌──────────────────────┐ ┌──────────────────────────────────┐
+│ Redis │ │ Node.js Application │
+│ (Primary Cache) │ │ ┌──────────────┐ ┌─────────┐ │
+│ - Host configs ◄────┼──┼──┤ Services │ │ Routes │ │
+│ - User accounts │ │ │ - host_lookup│ │ - /api/*│ │
+│ - SSL certs │ │ │ - scheduler │ │ │ │
+│ - Auth tokens │ │ └──────────────┘ └─────────┘ │
+└──────────────────────┘ └─────────┬────────────────────────┘
+ │
+ ▼
+ ┌──────────────────────┐
+ │ DNS Providers │
+ │ - Cloudflare │
+ │ - DigitalOcean │
+ │ - PorkBun │
+ │ - DuckDNS (free) │
+ │ (DNS-01 challenges) │
+ └──────────────────────┘
+```
+
+## Component Details
+
+### OpenResty/Nginx (Frontend)
+
+**Responsibilities:**
+- Accept incoming HTTP/HTTPS requests
+- SSL termination using lua-resty-auto-ssl
+- Host-based routing decisions (Redis-first lookup)
+- Proxy requests to backend services
+
+**Key Features:**
+- HTTP-01 ACME challenge handling for automatic SSL
+- Redis-first host lookup with Node.js fallback via Unix socket
+- High-performance event-driven architecture
+- Support for WebSocket connections
+- Continues serving cached hosts even if Node.js is down
+
+**Configuration Files:**
+- `/etc/openresty/nginx.conf` - Main configuration
+- `/etc/openresty/autossl.conf` - Let's Encrypt integration
+- `/etc/openresty/sites-enabled/000-proxy` - Proxy configuration
+- `/usr/local/openresty/lualib/targetinfo.lua` - Host lookup module
+
+### Node.js Application (Backend)
+
+**Responsibilities:**
+- API for host/user/DNS management
+- Wildcard SSL certificate orchestration
+- Host lookup tree maintenance
+- User authentication and authorization
+
+**Directory Structure:**
+```
+nodejs/
+├── bin/www # Application entry point
+├── conf/ # Configuration (base.js, environment overlays, secrets.js)
+├── controller/ # App-level wiring (pubsub, startup)
+├── migrations/ # One-off Redis data migration scripts
+├── models/ # Data models
+│ ├── host.js # Host configuration and lookup
+│ ├── auth.js # Authentication logic
+│ ├── user.js # User management
+│ └── dns_provider/ # DNS provider implementations
+├── routes/ # API endpoints
+│ ├── host.js # Host CRUD operations
+│ ├── dns.js # DNS provider management
+│ ├── user.js # User management
+│ ├── auth.js # Authentication (login + OIDC)
+│ ├── permission.js # RBAC permission management
+│ ├── group.js # Local group management
+│ └── api_token.js # Self-service API (PAT) tokens
+├── services/ # Background services
+│ ├── host_lookup.js # Unix socket server
+│ └── host_scheduler.js # Cert renewal scheduler
+├── middleware/ # Express middleware
+│ └── auth.js # Authentication middleware
+└── utils/ # Utility modules
+ └── unix_socket_json.js # Unix socket server
+```
+
+### Redis (Data Store)
+
+**ORM:** [model-redis](https://www.npmjs.com/package/model-redis) - A lightweight Redis ORM for Node.js with schema validation, relationships, and automatic key management.
+
+**Stored Data:**
+- Host configurations (domain, IP, port, SSL settings)
+- User accounts and hashed passwords
+- Authentication tokens
+- SSL certificates (for wildcard domains)
+- DNS provider credentials
+- Domain-to-provider mappings
+
+**Key Prefixes:**
+```
+proxy_Host_ # Host configuration
+proxy_User_ # User account
+proxy_AuthToken_ # Auth tokens
+proxy_DnsProvider_ # DNS provider
+proxy_Domain_ # Domain info
+:latest # SSL certificate cache
+```
+
+## Request Flow
+
+### Standard HTTP/HTTPS Request
+
+1. **Client** sends HTTPS request to `app.example.com`
+2. **OpenResty** receives request, terminates SSL
+3. **Lua script** (`targetinfo.lua`) queries **Redis first** for host config
+4. If **found in Redis**, jump to step 7 (Node.js not involved)
+5. If **not in Redis**, Lua queries Node.js via Unix socket as fallback
+6. **Node.js** performs host lookup (supports wildcards), caches result in Redis
+7. **OpenResty** proxies request to backend service using target IP and port
+8. **Response** proxied back to client
+
+**Resilience**: If Node.js goes down, all hosts already cached in Redis continue to work. Only new/uncached hosts will fail until Node.js recovers.
+
+### Wildcard SSL Certificate Request
+
+1. **User** creates wildcard host (`*.example.com`) via API
+2. **Node.js** validates domain has DNS provider configured
+3. **Let's Encrypt** DNS-01 challenge initiated
+4. **DNS provider** API creates TXT record (`_acme-challenge.example.com`)
+5. **Let's Encrypt** validates TXT record
+6. **Certificate** generated and stored in Redis
+7. **DNS provider** cleans up TXT record
+8. **Background scheduler** monitors expiration, renews 30 days before expiry
+
+## Host Lookup Algorithm
+
+The lookup tree enables sophisticated domain matching:
+
+```
+Input: "api.v1.example.com"
+
+Tree Structure:
+{
+ "com": {
+ "example": {
+ "*": { // Matches api.example.com
+ "#record": {...}
+ },
+ "v1": {
+ "api": { // Matches api.v1.example.com (exact)
+ "#record": {...}
+ }
+ }
+ }
+ }
+}
+
+Priority: Exact > Single wildcard (*) > Double wildcard (**)
+```
+
+**Wildcard Types:**
+- `example.com` - Exact match only
+- `*.example.com` - Matches `sub.example.com` (single level)
+- `**.example.com` - Matches any depth (`sub.deep.example.com`)
+- `api.*.example.com` - Matches `api.v1.example.com`, `api.v2.example.com`
+
+## Security Architecture
+
+### Authentication Flow
+
+1. User sends credentials to `/api/auth/login`
+2. Credentials validated against stored hash (bcrypt)
+3. Token generated and stored in Redis with TTL
+4. Token returned to client
+5. Subsequent requests include token in `auth-token` header
+6. Middleware validates token before processing request
+
+### SSL Certificate Security
+
+- **Private keys** stored only in Redis (memory/disk based on config)
+- **Fallback certificates** used when SNI unavailable
+- **Let's Encrypt** rate limiting respected
+- **DNS provider credentials** marked as `isPrivate` (not returned in API)
+
+### Unix Socket Communication
+
+- Socket file: `/var/run/proxy_lookup.socket`
+- Permissions: `777` (container-safe, single-use deployment)
+- Protocol: JSON over Unix stream socket
+- Buffer handling: Accumulates partial messages until complete JSON
+
+## Performance Optimizations
+
+### Caching Strategy
+
+The system uses a multi-tier caching approach:
+
+1. **Redis (L1 Cache)** - OpenResty checks Redis FIRST for every request
+ - Primary host configuration storage
+ - Survives Node.js restarts/failures
+ - Shared across all OpenResty workers
+
+2. **Node.js Lookup Tree (L2 Cache)** - In-memory host lookup with wildcard matching
+ - Only queried when Redis has no entry
+ - Rebuilt automatically when hosts change
+ - Supports complex wildcard resolution
+
+3. **Wildcard Parent Caching** - Resolved wildcard matches stored back to Redis
+ - Subsequent requests to `api.example.com` hit Redis directly
+ - No repeated wildcard resolution needed
+
+### Unix Socket vs HTTP API
+
+Unix socket chosen over HTTP for host lookups:
+- **Lower latency** - No TCP overhead
+- **Higher throughput** - No HTTP parsing
+- **Simpler** - Direct JSON communication
+- **Secure** - Filesystem permissions, no network exposure
+
+## Scalability Considerations
+
+### Current Architecture
+
+- **Single instance** - OpenResty + Node.js + Redis on one server
+- **Vertical scaling** - Add CPU/RAM as needed
+- **Limitations** - Unix socket ties OpenResty to Node.js on same host
+
+### Future Scaling Options
+
+- **Redis cluster** - Distribute data storage
+- **Multiple OpenResty instances** - Load balance incoming requests
+- **Stateless Node.js** - Run multiple API instances
+- **Replace Unix socket** - Use TCP/HTTP for cross-host communication
+- **Separate cert management** - Dedicated service for wildcard SSL
+
+## Monitoring and Observability
+
+### Logs
+
+- **OpenResty**: `/var/log/nginx/access.log`, `/var/log/nginx/error.log`
+- **Node.js**: `journalctl -u proxy.service`
+- **Redis**: `redis-cli MONITOR`
+
+### Health Checks
+
+- Node.js API: `curl http://localhost:3000/api/host`
+- Redis: `redis-cli PING`
+- OpenResty: `systemctl status openresty`
+- Unix socket: `ls -la /var/run/proxy_lookup.socket`
+
+### Metrics to Monitor
+
+- Request rate and response times
+- SSL certificate expiration dates
+- Redis memory usage
+- Host lookup cache hit rate
+- Background service execution times
+
+[← Back to Home](index.html)
diff --git a/docs/proxy/assets/css/style.css b/docs/proxy/assets/css/style.css
new file mode 100644
index 0000000..e24a5de
--- /dev/null
+++ b/docs/proxy/assets/css/style.css
@@ -0,0 +1,116 @@
+/* theta42 docs site — shares the in-app dark navbar/footer + card look
+ (Bootstrap 5 + Font Awesome, same as the running apps) rather than a
+ generic Jekyll theme. */
+
+body {
+ background-color: #f4f5f6;
+}
+
+.navbar-brand img {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
+}
+
+.navbar-nav .nav-link.active {
+ color: #fff;
+ font-weight: 600;
+}
+
+/* Markdown content typography, scoped to the card body so it doesn't leak
+ into the nav/footer. */
+.site-content h1:first-child {
+ margin-top: 0;
+}
+
+.site-content h1,
+.site-content h2,
+.site-content h3 {
+ font-weight: 700;
+}
+
+.site-content h2 {
+ margin-top: 2.5rem;
+ padding-bottom: .4rem;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.site-content h3 {
+ margin-top: 1.75rem;
+}
+
+.site-content a {
+ color: #a3671f;
+ text-decoration-color: rgba(163, 103, 31, .35);
+}
+
+.site-content a:hover {
+ color: #8a5a16;
+}
+
+.site-content pre {
+ background-color: #212529;
+ color: #f8f9fa;
+ padding: 1rem 1.25rem;
+ border-radius: .375rem;
+ overflow-x: auto;
+}
+
+.site-content code {
+ color: #a3671f;
+ background-color: #f4f0e8;
+ padding: .15em .4em;
+ border-radius: .25rem;
+ font-size: .875em;
+}
+
+.site-content pre code {
+ color: inherit;
+ background: none;
+ padding: 0;
+}
+
+.site-content table {
+ display: block;
+ overflow-x: auto;
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.25rem 0;
+}
+
+.site-content table th,
+.site-content table td {
+ border: 1px solid #dee2e6;
+ padding: .5rem .75rem;
+ text-align: left;
+}
+
+.site-content table th {
+ background-color: #f8f9fa;
+}
+
+.site-content blockquote {
+ border-left: 4px solid #C59341;
+ padding: .5rem 1rem;
+ margin: 1.25rem 0;
+ background-color: #f8f6f1;
+ color: #495057;
+}
+
+.site-content img {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Screenshot grids in the markdown use width="49%" inline attrs for a
+ two-up desktop layout -- stack them on narrow screens instead of
+ squeezing to illegibility. */
+@media (max-width: 576px) {
+ .site-content img[width] {
+ width: 100% !important;
+ margin-bottom: .75rem;
+ }
+}
+
+.site-content hr {
+ margin: 2rem 0;
+ border-top: 1px solid #e9ecef;
+}
diff --git a/docs/proxy/assets/img/favicon.svg b/docs/proxy/assets/img/favicon.svg
new file mode 100644
index 0000000..35e1881
--- /dev/null
+++ b/docs/proxy/assets/img/favicon.svg
@@ -0,0 +1,17 @@
+
diff --git a/docs/proxy/assets/img/theta42.svg b/docs/proxy/assets/img/theta42.svg
new file mode 100644
index 0000000..e598305
--- /dev/null
+++ b/docs/proxy/assets/img/theta42.svg
@@ -0,0 +1,51 @@
+
diff --git a/docs/proxy/concepts-access.md b/docs/proxy/concepts-access.md
new file mode 100644
index 0000000..76c9aa8
--- /dev/null
+++ b/docs/proxy/concepts-access.md
@@ -0,0 +1,75 @@
+---
+layout: default
+title: Users, Groups & Permissions
+description: A plain-language guide to local admin accounts, groups, and the domain-scoped permission model in theta42/proxy.
+---
+
+# Users, Groups & Permissions
+
+This page explains, in plain language, who can manage what in this app. For
+the deeper system-design detail, see [Architecture](architecture.html).
+
+## Two different ways to log in
+
+Most people who use apps you've proxied through this app never see this
+app's own login at all — they use whatever authentication you set up on
+the *individual host* (basic auth, or single sign-on through your SSO
+Manager). This page is about a different, smaller group: the people who
+manage the proxy itself — adding hosts, registering DNS providers, and so
+on.
+
+There are two ways someone gets into the proxy's own management UI:
+
+- **A local account**, created on the **Users** page — a username and
+ password specific to this app.
+- **Single sign-on**, if you've connected this proxy to an SSO Manager (or
+ another OIDC provider) — the same login your other connected apps use.
+
+Either way, once logged in, what they're actually *allowed to do* here is
+controlled by permissions, described below.
+
+## Groups
+
+A **group** here is just a named list of local usernames, used to grant
+the same permission to several people at once instead of one at a time.
+If you're using SSO instead of local accounts, group membership normally
+comes from your identity provider instead — local groups exist mainly for
+the local-account case.
+
+## Permissions: scope + role
+
+Each **permission** entry grants one subject (a user or a group) one
+**role**, at one **scope** — the two are independent choices:
+
+**Scope** — *where* the role applies:
+
+- **Domain** — only hosts under one specific domain (e.g. someone can
+ manage everything under `example.com`, but can't see or touch a
+ completely different domain you also proxy).
+- **Global** — everywhere, across every domain this proxy manages.
+
+**Role** — *what* they can do within that scope:
+
+- **Viewer** — read-only. Can see hosts and their settings, but not
+ change anything.
+- **Manager** — full control over hosts (create, edit, delete) within
+ that scope.
+- **Admin** — same host control as Manager, **plus**, but *only when
+ granted at Global scope*, the ability to manage other people's
+ permissions, DNS providers, and local user accounts. An Admin role
+ granted at Domain scope instead of Global behaves exactly like Manager
+ for that one domain — it does not unlock those extra admin-only pages.
+
+In practice: give someone **Manager** on just the domain(s) they're
+responsible for to delegate day-to-day host management without handing
+them the keys to everything. Reserve **Global Admin** for people who
+should be able to change anything, anywhere, including who else has
+access.
+
+## Want more detail?
+
+This page doesn't cover the exact permission-checking implementation or
+how SSO group membership maps into this system internally — for that, see
+[Architecture](architecture.html).
+
+[← Back to Home](index.html)
diff --git a/docs/proxy/concepts-api-tokens.md b/docs/proxy/concepts-api-tokens.md
new file mode 100644
index 0000000..5efe7d4
--- /dev/null
+++ b/docs/proxy/concepts-api-tokens.md
@@ -0,0 +1,60 @@
+---
+layout: default
+title: API Tokens
+description: A plain-language guide to personal access tokens in theta42/proxy.
+---
+
+# API Tokens
+
+This page explains what an API token is and when you'd want one. For the
+full list of API endpoints a token can call, see the
+[API reference](api.html).
+
+## What's an API token, in plain terms?
+
+Normally, you interact with this app by logging in through a web browser.
+An **API token** (also called a personal access token, or PAT) is an
+alternative way in — a long, random string that a script, a scheduled job,
+or another program can use instead of a username and password, to act on
+your behalf without a human typing a login in each time.
+
+If you've ever set up a script to talk to GitHub, GitLab, or a similar
+service using a "token" instead of your real password, this is the same
+idea.
+
+## When would you actually need one?
+
+Most people never need to create one of these — you'll only want a token
+if you're automating something, for example:
+
+- A script that registers or updates hosts automatically (say, spinning up
+ a new service and wanting the proxy entry created for it without a
+ manual step).
+- A monitoring or backup job that checks this app's health via its API.
+- A configuration-management tool that keeps your host list in sync with
+ something else.
+
+If you're not doing any of that, you don't need an API token — just log in
+normally through the web UI.
+
+## How it works
+
+Create a token from your Profile page, give it a name so you remember what
+it's for later, and optionally an expiry. You'll be shown the token's
+value **exactly once** — copy it somewhere safe immediately, because it
+can't be viewed again afterward (only revoked or rotated). Whatever script
+or tool you're using it with sends it along with each request, the same
+way a browser sends your login session.
+
+A token acts **as you**, with **your** [permissions](concepts-access.html)
+— if you're only a Manager on one domain, a token you create can't touch
+any other domain either. If you ever suspect a token has leaked (ended up
+somewhere it shouldn't have, like a public script or log file), revoke it
+immediately from your Profile page; it stops working right away.
+
+## Want more detail?
+
+This page doesn't attempt to list every API endpoint or show request/
+response examples — for that, see the full [API reference](api.html).
+
+[← Back to Home](index.html)
diff --git a/docs/proxy/concepts-dns.md b/docs/proxy/concepts-dns.md
new file mode 100644
index 0000000..23e0d62
--- /dev/null
+++ b/docs/proxy/concepts-dns.md
@@ -0,0 +1,49 @@
+---
+layout: default
+title: DNS Providers
+description: A plain-language guide to why theta42/proxy needs a DNS provider, and only for wildcard certificates.
+---
+
+# DNS Providers
+
+This page explains, in plain language, what a "DNS provider" is for in this
+app and when you actually need one. For setup steps, see
+[Installation](installation.html).
+
+## Do you need this at all?
+
+**Only if you want a [wildcard host](concepts-hosts.html)** (something like
+`*.example.com` covering every subdomain with one certificate). A normal,
+single-name host doesn't need a DNS provider configured at all — skip this
+page entirely if that's all you're setting up.
+
+## Why a wildcard cert needs this extra step
+
+To prove you actually own `example.com` before issuing a certificate that
+covers *every* possible subdomain of it, Let's Encrypt needs to see a
+specific, temporary DNS record appear on that domain — something only the
+real owner of the domain could add. A normal single-host certificate
+doesn't need this because it can prove ownership a simpler way (by
+responding to a web request instead).
+
+So: to get a wildcard certificate, this app needs to be able to add (and
+later remove) that one temporary DNS record on your domain automatically,
+which means it needs your domain registrar or DNS host's API credentials —
+that's what registering a **DNS provider** here does.
+
+## What you're actually giving it access to
+
+A DNS provider entry only needs enough access to add/remove TXT records —
+it's not given your registrar account's full login, and it can't do
+anything to your domain besides that one narrow task (and, for some
+providers, keeping a dynamic A record updated if you use that feature
+separately). Check your specific provider's page in the
+[Installation guide](installation.html) for exactly what kind of
+credential to generate and how narrowly you can scope it.
+
+## Want more detail?
+
+For exact setup steps per provider (Cloudflare, DigitalOcean, Porkbun,
+DuckDNS, etc.), see [Installation](installation.html).
+
+[← Back to Home](index.html)
diff --git a/docs/proxy/concepts-hosts.md b/docs/proxy/concepts-hosts.md
new file mode 100644
index 0000000..504d2e1
--- /dev/null
+++ b/docs/proxy/concepts-hosts.md
@@ -0,0 +1,81 @@
+---
+layout: default
+title: Hosts & HTTPS
+description: A plain-language guide to hosts, HTTPS certificates, and wildcards in theta42/proxy.
+---
+
+# Hosts & HTTPS
+
+This page explains, in plain language, what a "host" is and how this app
+gets you working HTTPS without you having to think about certificates. For
+the deeper system-design detail, see [Architecture](architecture.html); for
+step-by-step setup, see [Installation](installation.html).
+
+## What's a "host"?
+
+A **host** is one entry telling the proxy: "when someone requests *this*
+public address, send them to *that* server." For example: requests for
+`photos.example.com` get sent to the little box in your closet running your
+photo app on port 8080. Each app or service you want to reach from outside
+your network — a home automation dashboard, a media server, this proxy's
+own management UI — gets its own host entry.
+
+Two settings on a host are easy to mix up:
+
+- **Incoming host name** — the public address people type in their
+ browser (`photos.example.com`).
+- **Target IP/port** — where the proxy actually sends the request behind
+ the scenes (`10.0.0.5:8080`, or a hostname like `photo-server`).
+
+Everything else on the host form (traffic limits, access rules,
+authentication) is optional — a bare host with just those two fields
+already works.
+
+## HTTPS certificates: mostly automatic
+
+Every public website needs an HTTPS certificate so browsers show the lock
+icon instead of a scary warning. This app gets one for you automatically
+from [Let's Encrypt](https://letsencrypt.org) the first time a host is
+actually requested — you don't manually request, install, or renew
+anything for a normal host. This happens behind the scenes using a method
+called **HTTP-01**, and it's the default for every new host.
+
+## Wildcards: one certificate for a whole family of hosts
+
+Sometimes you want *every* subdomain under one name to work — `app1.`,
+`app2.`, `anything.example.com` — without registering each one by hand and
+waiting for its own certificate. That's what a **wildcard** host does: a
+single host entry named `*.example.com` gets one certificate that covers
+the whole family at once. Setting one up needs one extra piece of
+information the automatic method above doesn't need — see
+[DNS Providers](concepts-dns.html) for why.
+
+Once a wildcard exists, you have two ways to actually use it:
+
+- **Register nothing else, and turn on "Match any subdomain"** on the
+ wildcard host itself — *any* subdomain that doesn't already have its own
+ entry gets automatically routed to the wildcard's target the first time
+ it's requested. Convenient, but it means literal typos and random scan
+ traffic get routed too, not just the subdomains you meant to use.
+- **Register each subdomain as its own host, as a "Parent Wildcard"
+ child** — more setup, but each subdomain can point at a different
+ target/server while still reusing the one wildcard certificate instead
+ of getting its own. This is the recommended default and is what
+ "Match only subdomains defined here" (the host form's default) does.
+
+You'll see the **"Parent Wildcard"** option light up automatically on the
+host form whenever the name you're entering already has a matching
+wildcard available to reuse — including the wildcard's own bare base
+domain (e.g. `example.com` itself, not just `something.example.com`).
+
+## Load Balancing
+
+If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
+
+## Want more detail?
+
+This page skips the system-internals (Redis, OpenResty, the lookup service)
+and the exact install steps. For those, see
+[Architecture](architecture.html) and [Installation](installation.html).
+
+[← Back to Home](index.html)
diff --git a/docs/proxy/contributing.md b/docs/proxy/contributing.md
new file mode 100644
index 0000000..08aa80f
--- /dev/null
+++ b/docs/proxy/contributing.md
@@ -0,0 +1,344 @@
+---
+layout: default
+title: Contributing
+description: How to contribute to the proxy — dev setup, tests, and code conventions.
+---
+
+# Contributing Guide
+
+[← Back to Home](index.html)
+
+Thank you for considering contributing to the Proxy project! This guide will help you get started.
+
+## Development Setup
+
+### Prerequisites
+
+- Node.js 18+ (18.x, 20.x, or 22.x recommended)
+- Redis server
+- Git
+
+### Local Development
+
+1. **Clone the repository**
+ ```bash
+ git clone https://github.com/theta42/proxy.git
+ cd proxy/nodejs
+ ```
+
+2. **Install dependencies**
+ ```bash
+ npm install
+ ```
+
+3. **Start Redis** (if not already running)
+ ```bash
+ redis-server
+ ```
+
+4. **Run in development mode**
+ ```bash
+ npm run dev
+ ```
+
+ This starts the Node.js API with nodemon for auto-reload on file changes.
+
+5. **Access the API**
+ - API: `http://localhost:3000/api`
+ - Web UI: `http://localhost:3000`
+
+## Testing
+
+The project uses Node.js built-in test runner (requires Node 18+).
+
+### Running Tests
+
+```bash
+# Run all tests
+npm test
+
+# Run only unit tests
+npm run test:unit
+
+# Run only integration tests
+npm run test:integration
+
+# Watch mode for development
+npm run test:watch
+```
+
+### Test Structure
+
+```
+test/
+├── unit/ # Unit tests for isolated components
+│ ├── basicauth.test.js
+│ ├── callback_queue.test.js
+│ ├── dynamic_record.test.js
+│ ├── host_features.test.js
+│ ├── host_lookup.test.js
+│ ├── hostname_validate.test.js
+│ ├── host_sso.test.js
+│ ├── oidc.test.js
+│ ├── password_policy.test.js
+│ ├── roles.test.js
+│ ├── safe_redirect.test.js
+│ ├── unix_socket.test.js
+│ └── wildcard_matchany.test.js
+├── integration/ # Integration tests
+│ └── dns_provider.test.js
+└── helpers/ # Test utilities
+ └── dns_provider_contract.js
+```
+
+### Writing Tests
+
+We test **custom logic**, not third-party libraries:
+
+**DO test:**
+- Host lookup algorithm
+- Socket buffering logic
+- DNS provider contracts
+- Custom utility functions
+
+**DON'T test:**
+- Express.js routing
+- Redis ORM
+- External DNS APIs (use mocks instead)
+
+### Adding DNS Provider Tests
+
+When adding a new DNS provider, you **must** add contract tests:
+
+```javascript
+describe('NewProvider Provider', () => {
+ const NewProvider = require('../../models/dns_provider/newprovider');
+
+ test('should meet DNS provider contract', () => {
+ const mockCredentials = {api_key: 'mock-key'};
+ const instance = validateDnsProviderContract(NewProvider, mockCredentials);
+ assert.ok(instance);
+ });
+
+ test('should have valid method signatures', () => {
+ const instance = new NewProvider({api_key: 'mock'});
+ validateMethodSignatures(instance);
+ });
+
+ test('should validate key mapping', () => {
+ const instance = new NewProvider({api_key: 'mock'});
+ validateKeyMapping(instance);
+ });
+
+ test('should validate type checking', () => {
+ const instance = new NewProvider({api_key: 'mock'});
+ validateTypeChecking(instance);
+ });
+});
+```
+
+See `test/integration/dns_provider.test.js` for examples.
+
+## Code Style
+
+### General Guidelines
+
+- Use strict mode: `'use strict';`
+- Use tabs for indentation
+- Clear, descriptive variable names
+- Comment complex logic
+- No trailing whitespace
+
+### File Organization
+
+```javascript
+'use strict';
+
+// 1. Node.js built-ins
+const fs = require('fs');
+const path = require('path');
+
+// 2. Third-party modules
+const express = require('express');
+const redis = require('redis');
+
+// 3. Local modules
+const {Host} = require('./models');
+const middleware = require('./middleware/auth');
+
+// 4. Code...
+```
+
+### Naming Conventions
+
+- Classes: `PascalCase`
+- Functions: `camelCase`
+- Constants: `UPPER_SNAKE_CASE`
+- Private methods: `__privateMethod` (double underscore prefix)
+
+## Project Structure
+
+Understanding the codebase:
+
+```
+nodejs/
+├── conf/ # Configuration (base.js, environment overlays, secrets.js)
+├── controller/ # App-level wiring (pubsub, startup)
+├── migrations/ # One-off Redis data migration scripts
+├── models/ # Data models (Host, User, DNS providers)
+├── routes/ # API route handlers
+├── services/ # Background services (lookup, scheduler)
+├── middleware/ # Express middleware
+├── utils/ # Utility functions
+├── public/ # Static web assets
+├── views/ # EJS templates
+└── test/ # Test suite
+```
+
+## Pull Request Process
+
+### Before Submitting
+
+1. **Run tests** - Ensure all tests pass
+ ```bash
+ npm test
+ ```
+
+2. **Test locally** - Verify your changes work
+ ```bash
+ npm run dev
+ ```
+
+3. **Update documentation** - Keep docs in sync with code changes
+
+4. **Commit messages** - Use clear, descriptive messages
+ ```
+ Add DNS provider for Route53
+
+ - Implement Route53 DNS API client
+ - Add contract tests for Route53
+ - Update documentation with Route53 setup
+ ```
+
+### Submitting a PR
+
+1. **Fork the repository**
+
+2. **Create a feature branch**
+ ```bash
+ git checkout -b feature/my-new-feature
+ ```
+
+3. **Make your changes**
+
+4. **Commit your changes**
+ ```bash
+ git add .
+ git commit -m "Description of changes"
+ ```
+
+5. **Push to your fork**
+ ```bash
+ git push origin feature/my-new-feature
+ ```
+
+6. **Open a Pull Request** on GitHub
+
+### PR Requirements
+
+- All tests must pass (CI/CD runs automatically)
+- Tests run on Node.js 18.x, 20.x, and 22.x
+- No merge conflicts with `master`
+- Code follows project conventions
+- New features include tests
+- Documentation updated if needed
+
+### CI/CD Process
+
+When you open a PR:
+1. GitHub Actions automatically runs tests
+2. Tests execute on multiple Node.js versions
+3. PR cannot be merged until all checks pass
+4. Review from maintainers
+5. Merge to master
+
+## Data Models
+
+The project uses [model-redis](https://www.npmjs.com/package/model-redis) as the ORM for Redis data storage. All models extend the `Table` class and use a declarative schema via `_keyMap`.
+
+**Example Model:**
+```javascript
+const Table = require('../utils/redis_model');
+
+class Host extends Table {
+ static _key = 'host'; // Primary key field
+ static _keyMap = {
+ 'host': {isRequired: true, type: 'string', min: 3, max: 500},
+ 'ip': {isRequired: true, type: 'string', min: 3, max: 500},
+ 'targetPort': {isRequired: true, type: 'number', min: 0, max: 65535},
+ 'forcessl': {default: true, type: 'boolean'},
+ 'created_on': {default: () => Date.now(), type: 'number'}
+ };
+}
+```
+
+**Learn more:** [model-redis documentation](https://www.npmjs.com/package/model-redis)
+
+## Adding Features
+
+### Adding a DNS Provider
+
+1. **Create provider file** in `models/dns_provider/yourprovider.js`
+
+2. **Extend DnsApi base class**
+ ```javascript
+ const {DnsApi} = require('./common');
+
+ class YourProvider extends DnsApi {
+ static _keyMap = {
+ api_key: {isRequired: true, type: 'string', isPrivate: true}
+ };
+
+ // Implement required methods
+ async listDomains() { }
+ async getRecords(domain, options) { }
+ async createRecord(domain, options) { }
+ async deleteRecords(domain, options) { }
+ }
+ ```
+
+3. **Add to provider list** in `models/dns_provider.js`
+
+4. **Add contract tests** in `test/integration/dns_provider.test.js`
+
+5. **Test your provider**
+ ```bash
+ npm run test:integration
+ ```
+
+### Adding API Endpoints
+
+1. **Add route** in appropriate file (`routes/`)
+2. **Update API documentation** (`nodejs/api.md` and `docs/api.md` — keep them in sync)
+3. **Test the endpoint** manually and add integration tests if needed
+
+## Getting Help
+
+- **Questions?** Open a [GitHub Discussion](https://github.com/theta42/proxy/discussions)
+- **Bug reports** Use [GitHub Issues](https://github.com/theta42/proxy/issues)
+- **Security issues** Email maintainers directly (see package.json)
+
+## Code of Conduct
+
+- Be respectful and inclusive
+- Focus on constructive feedback
+- Help others learn and grow
+- Follow the project's technical direction
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the MIT License.
+
+---
+
+[← Back to Home](index.html) | [View on GitHub](https://github.com/theta42/proxy)
diff --git a/docs/proxy/images/host-auth-basic.png b/docs/proxy/images/host-auth-basic.png
new file mode 100644
index 0000000..f4d8026
Binary files /dev/null and b/docs/proxy/images/host-auth-basic.png differ
diff --git a/docs/proxy/images/host-auth-sso.png b/docs/proxy/images/host-auth-sso.png
new file mode 100644
index 0000000..cccb8ec
Binary files /dev/null and b/docs/proxy/images/host-auth-sso.png differ
diff --git a/docs/proxy/images/hosts.png b/docs/proxy/images/hosts.png
new file mode 100644
index 0000000..cb6cf08
Binary files /dev/null and b/docs/proxy/images/hosts.png differ
diff --git a/docs/proxy/images/load-balancing.png b/docs/proxy/images/load-balancing.png
new file mode 100644
index 0000000..d21f2b9
Binary files /dev/null and b/docs/proxy/images/load-balancing.png differ
diff --git a/docs/proxy/index.md b/docs/proxy/index.md
new file mode 100644
index 0000000..72458b1
--- /dev/null
+++ b/docs/proxy/index.md
@@ -0,0 +1,78 @@
+---
+layout: default
+title: Home
+description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
+---
+
+# Proxy
+
+A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a
+management API and web GUI. It puts any of your apps behind single sign-on
+(OIDC) and can also look users up directly in LDAP — so the same people who
+log in to your SSO are the people allowed to reach your proxied apps.
+
+Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname,
+and per-host access control tied to your identity provider — managed from a
+web UI or a REST API, with no downtime on config changes.
+
+Part of the theta42 self-hosted identity stack, alongside
+[SSO Manager](https://theta42.github.io/sso-manager-node/) and
+[theta-env](https://theta42.github.io/theta-env/) (the two composed with one
+command).
+
+## Screenshots
+
+
+
+
+Basic auth and SSO are mutually exclusive per host, with per-user password
+management once basic auth is enabled:
+
+
+
+*(click any screenshot to view full size)*
+
+## Why this over the alternatives
+
+Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
+auto-HTTPS. This one is built around identity: it is both an **OIDC client**
+of an SSO provider (for browser login) **and** a direct **LDAP client** (for
+user lookups and per-host access control), so access decisions come from your
+real user directory, not a static allow-list or a separate auth proxy bolted
+on top. The trade-off is that it expects an OIDC/LDAP identity source to point
+at — it is not an auth server on its own. Pair it with
+[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP +
+OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider +
+LDAP directory you already run.
+
+## Features
+
+- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
+- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
+- Dynamic host routing with wildcard domain matching (`*`, `**`)
+- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
+- **OIDC login** and **direct LDAP lookups**, independently of each other
+- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
+ it's never ambiguous which one gated a request)
+- **Role-based access control** — global admins, local groups, and
+ per-domain permissions (viewer/manager)
+- Self-service API tokens for scripting/CI without a browser session
+- Web UI and a full REST API
+
+## Get it
+
+```bash
+git clone https://github.com/theta42/proxy.git
+cd proxy && docker compose up -d --build
+```
+
+For the full set of install options (Docker, bare-metal, or as part of the combined
+SSO + proxy stack), configuration reference, and API docs, see the
+**[GitHub repository](https://github.com/theta42/proxy)**.
+
+## Related projects
+
+- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
+ provider + LDAP directory this proxy is designed to sit in front of.
+- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and
+ SSO Manager together with one command.
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 7491ac0..633bbfc 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -183,7 +183,6 @@ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
under **API Tokens** in each UI, mint a personal access token and use it as
`Authorization: Bearer sso_…` (SSO) or `prx_…` (proxy). A token authenticates as
its creator with their permissions. See each submodule's DEPLOYMENT.
-- See [Architecture](architecture.html) for how it all fits together, and
- [Standalone](standalone.html) to run either project on its own.
+- See [Architecture](architecture.html) for how it all fits together.
[← Back to Home](index.html)
\ No newline at end of file
diff --git a/docs/sso/_layouts/default.html b/docs/sso/_layouts/default.html
new file mode 100644
index 0000000..39af120
--- /dev/null
+++ b/docs/sso/_layouts/default.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+ {% seo title=false %}
+ {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/sso/agents.md b/docs/sso/agents.md
new file mode 100644
index 0000000..44e6768
--- /dev/null
+++ b/docs/sso/agents.md
@@ -0,0 +1,88 @@
+---
+layout: default
+title: Discovery Agents
+nav_order: 5
+---
+
+# Discovery Agents
+
+The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
+
+## Writing a Custom Agent
+
+Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
+
+A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
+
+### Agent Skeleton
+
+```javascript
+// nodejs/agents/discovery/my_custom_agent.js
+module.exports = {
+ discover: async (config) => {
+ const { url, apiKey } = config; // Provided by your configuration
+
+ const resources = [];
+ const edges = [];
+
+ // 1. Fetch your data from an API
+ // const data = await fetch(...);
+
+ // 2. Map data to Resources
+ resources.push({
+ kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
+ name: 'My Switch',
+ slug: 'my-switch-01',
+ metadata: {
+ make: 'Vendor',
+ model: 'Model X',
+ interfaces: [
+ { mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
+ ]
+ }
+ });
+
+ // 3. Map relations to Edges (optional)
+ edges.push({
+ parentSlug: 'my-switch-01',
+ childSlug: 'some-connected-client-slug',
+ relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
+ });
+
+ return { resources, edges };
+ }
+};
+```
+
+## Configuration
+
+Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
+
+```javascript
+module.exports = {
+ // ... existing config ...
+ discovery: {
+ agents: {
+ my_custom_agent: {
+ enabled: true,
+ cron: '*/30 * * * *', // Run every 30 minutes
+ url: 'https://api.example.com',
+ apiKey: 'secret-key'
+ },
+ nmap: {
+ enabled: true,
+ cron: '0 * * * *',
+ targetRange: '192.168.1.0/24'
+ }
+ }
+ }
+};
+```
+
+## The Reconciliation Engine
+
+When your agent returns its graph, the Reconciliation Engine takes over:
+1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
+2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
+3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
+4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
diff --git a/docs/sso/assets/css/style.css b/docs/sso/assets/css/style.css
new file mode 100644
index 0000000..e24a5de
--- /dev/null
+++ b/docs/sso/assets/css/style.css
@@ -0,0 +1,116 @@
+/* theta42 docs site — shares the in-app dark navbar/footer + card look
+ (Bootstrap 5 + Font Awesome, same as the running apps) rather than a
+ generic Jekyll theme. */
+
+body {
+ background-color: #f4f5f6;
+}
+
+.navbar-brand img {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
+}
+
+.navbar-nav .nav-link.active {
+ color: #fff;
+ font-weight: 600;
+}
+
+/* Markdown content typography, scoped to the card body so it doesn't leak
+ into the nav/footer. */
+.site-content h1:first-child {
+ margin-top: 0;
+}
+
+.site-content h1,
+.site-content h2,
+.site-content h3 {
+ font-weight: 700;
+}
+
+.site-content h2 {
+ margin-top: 2.5rem;
+ padding-bottom: .4rem;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.site-content h3 {
+ margin-top: 1.75rem;
+}
+
+.site-content a {
+ color: #a3671f;
+ text-decoration-color: rgba(163, 103, 31, .35);
+}
+
+.site-content a:hover {
+ color: #8a5a16;
+}
+
+.site-content pre {
+ background-color: #212529;
+ color: #f8f9fa;
+ padding: 1rem 1.25rem;
+ border-radius: .375rem;
+ overflow-x: auto;
+}
+
+.site-content code {
+ color: #a3671f;
+ background-color: #f4f0e8;
+ padding: .15em .4em;
+ border-radius: .25rem;
+ font-size: .875em;
+}
+
+.site-content pre code {
+ color: inherit;
+ background: none;
+ padding: 0;
+}
+
+.site-content table {
+ display: block;
+ overflow-x: auto;
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.25rem 0;
+}
+
+.site-content table th,
+.site-content table td {
+ border: 1px solid #dee2e6;
+ padding: .5rem .75rem;
+ text-align: left;
+}
+
+.site-content table th {
+ background-color: #f8f9fa;
+}
+
+.site-content blockquote {
+ border-left: 4px solid #C59341;
+ padding: .5rem 1rem;
+ margin: 1.25rem 0;
+ background-color: #f8f6f1;
+ color: #495057;
+}
+
+.site-content img {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Screenshot grids in the markdown use width="49%" inline attrs for a
+ two-up desktop layout -- stack them on narrow screens instead of
+ squeezing to illegibility. */
+@media (max-width: 576px) {
+ .site-content img[width] {
+ width: 100% !important;
+ margin-bottom: .75rem;
+ }
+}
+
+.site-content hr {
+ margin: 2rem 0;
+ border-top: 1px solid #e9ecef;
+}
diff --git a/docs/sso/assets/img/theta42.svg b/docs/sso/assets/img/theta42.svg
new file mode 100644
index 0000000..e598305
--- /dev/null
+++ b/docs/sso/assets/img/theta42.svg
@@ -0,0 +1,51 @@
+
diff --git a/docs/sso/concepts-accounts.md b/docs/sso/concepts-accounts.md
new file mode 100644
index 0000000..e782ab5
--- /dev/null
+++ b/docs/sso/concepts-accounts.md
@@ -0,0 +1,125 @@
+---
+layout: default
+title: Accounts, Groups & Managers
+description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
+---
+
+# Accounts, Groups & Managers
+
+This page explains the concepts behind the Users and Groups pages in plain
+language. If you want the technical schema/attribute-level detail instead,
+see the [LDAP reference](ldap.html).
+
+## What's an account?
+
+Every person (or app) that can sign in through this SSO Manager has an
+**account** — a username, a display name, maybe an email address, and a
+password (or, for service accounts, no password at all — see below).
+Accounts live in the directory this app manages, and any other app you've
+connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
+same accounts instead of keeping its own separate list of users and
+passwords.
+
+## Two kinds of account: people and service accounts
+
+Most accounts belong to an actual person — check **Users → People** to see
+them. But sometimes you need an account for something that *isn't* a
+person: a media server, a backup script, a bind account another app uses to
+look people up. These are **service accounts**, listed separately under
+**Users → Service Accounts**, and they're different from a person's account
+in two ways that matter:
+
+- **No email required.** A service account doesn't need a mailbox, so the
+ form doesn't ask for one.
+- **A password is optional.** If you leave it blank, nobody can log in as
+ that account — which is exactly what you want for something that only
+ ever gets used programmatically (a script authenticating with an API
+ token, or another app binding with a fixed, separately-configured
+ password you set yourself). Only give it a password if the account
+ genuinely needs to log in or bind somewhere as itself.
+
+Aside from those two differences, a service account is a completely normal
+account under the hood — it can belong to groups, have a manager, and so
+on, just like anyone else's.
+
+## Groups: who can do what
+
+A **group** is just a named list of accounts, used to control access. This
+app has a handful of built-in groups that grant admin powers (e.g. only
+people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
+pages at all), but you can also make your own groups for any app you
+connect — say, a group listing everyone who should be allowed into your
+photo server. Once a group exists, add or remove members from the
+**Groups** page, and point the other app's "who's allowed in" setting at
+that group's name.
+
+### Groups inside groups
+
+A group can contain another group, not just people — the *Nested* tab on any
+group card. Everyone in the inner group counts as a member of the outer one,
+however many levels deep it goes.
+
+This is mostly a way to stop repeating yourself. Make one `developers` group,
+nest it into the handful of things developers should reach, and adding a new
+developer to that one group grants all of them at once — instead of adding them
+to each individually and slowly drifting out of sync. The app already does this
+for itself: super admins are nested into every resource's admin group, and each
+admin group into its access group, so "can administer it" always implies "can
+use it".
+
+Two things it won't let you do: put a group inside itself (directly or round a
+longer loop), and empty a group completely — every group must keep at least one
+member.
+
+A note if you also manage the directory by hand: a group's member list shows
+what is *directly* listed on it. Someone who gets in through a nested group is
+a real member but won't appear there — the **Nested** tab shows what is nested,
+and the API's `effective` view lists everyone who actually gets in.
+
+## Every account's personal group
+
+Separately from the groups above, every single account — person or
+service account — automatically gets its own small, personal group when
+it's created, named after the account itself. Most of the time you'll
+never think about this; it exists so that, on a Linux system connected to
+this directory, each account "owns" its own files by default the same way
+a normal Unix user account would.
+
+Occasionally you'll want to share that ownership with someone else — for
+example, letting a second account also have write access to files a
+service account owns. That's what the **"Members of ``'s group"**
+section on a profile page is for: add another account there, and the
+underlying Linux permissions treat them as if they belong to that same
+personal group too.
+
+## What's a "manager"?
+
+Every account has one or more **managers** — the people allowed to edit
+that account's profile (phone number, SSH key, home directory, and so on)
+without needing full admin rights. By default, whoever created an account
+(the admin who added it, or whoever sent the invite) becomes its first
+manager, but you can add or remove managers later from the account's Edit
+form.
+
+This is useful for service accounts especially: if a service account
+belongs to a particular project or person, make them its manager so they
+can maintain it — rotate its SSH key, adjust its description — without
+needing to be a full SSO administrator.
+
+## Inviting someone vs. adding them yourself
+
+From the Users page you can either fill in someone's details yourself
+("Add new user"), or send them an **invite** — an email (or a link you copy
+and send however you like) that lets them pick their own username and
+password. Either way, the resulting account is identical; invites are just
+a convenience so you don't have to know someone's preferred username or
+handle their password directly.
+
+## Want more detail?
+
+This page deliberately leaves out LDAP schema names, attribute types, and
+protocol-level detail. If you're connecting a third-party app directly to
+the LDAP directory, or you just want to know exactly what's stored where,
+see the [LDAP reference](ldap.html).
+
+[← Back to Home](index.html)
diff --git a/docs/sso/concepts-api-tokens.md b/docs/sso/concepts-api-tokens.md
new file mode 100644
index 0000000..36eeb9d
--- /dev/null
+++ b/docs/sso/concepts-api-tokens.md
@@ -0,0 +1,59 @@
+---
+layout: default
+title: API Tokens
+description: A plain-language guide to personal access tokens in SSO Manager.
+---
+
+# API Tokens
+
+This page explains what an API token is and when you'd want one. For the
+full list of API endpoints a token can call, see the
+[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
+
+## What's an API token, in plain terms?
+
+Normally, you interact with this app by logging in through a web browser.
+An **API token** (also called a personal access token, or PAT) is an
+alternative way in — a long, random string that a script, a scheduled job,
+or another program can use instead of a username and password, to act on
+your behalf without a human typing a login in each time.
+
+If you've ever set up a script to talk to GitHub, GitLab, or a similar
+service using a "token" instead of your real password, this is the same
+idea.
+
+## When would you actually need one?
+
+Most people never need to create one of these — you'll only want a token
+if you're automating something, for example:
+
+- A script that syncs users or groups from somewhere else into this SSO
+ Manager on a schedule.
+- A backup or monitoring job that checks this app's health via its API.
+- A CI/CD pipeline that needs to register or update an OAuth client
+ automatically.
+
+If you're not doing any of that, you don't need an API token — just log in
+normally through the web UI.
+
+## How it works
+
+Create a token from your Profile page, give it a name so you remember what
+it's for later, and optionally an expiry. You'll be shown the token's
+value **exactly once** — copy it somewhere safe immediately, because it
+can't be viewed again afterward (only revoked or rotated). Whatever script
+or tool you're using it with sends it along with each request, the same
+way a browser sends your login session.
+
+A token acts **as you**, with **your** permissions — if you're not an
+admin, a token you create can't do admin-only things either. If you ever
+suspect a token has leaked (ended up somewhere it shouldn't have, like a
+public script or log file), revoke it immediately from your Profile page;
+it stops working right away.
+
+## Want more detail?
+
+This page doesn't attempt to list every API endpoint or show request/
+response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
+
+[← Back to Home](index.html)
diff --git a/docs/sso/concepts-oauth-apps.md b/docs/sso/concepts-oauth-apps.md
new file mode 100644
index 0000000..5479056
--- /dev/null
+++ b/docs/sso/concepts-oauth-apps.md
@@ -0,0 +1,79 @@
+---
+layout: default
+title: Connecting Apps (Single Sign-On)
+description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
+---
+
+# Connecting Apps (Single Sign-On)
+
+This page explains, in plain language, what happens when you "connect" an
+app to your SSO Manager so people can log into it with their existing
+account. For the technical endpoint/token detail, see the
+[OAuth reference](oauth.html).
+
+## What does "single sign-on" actually mean?
+
+Instead of every app you run having its own separate list of usernames and
+passwords, they all check with this SSO Manager instead. You log in once,
+here, and any connected app trusts that login — no separate password to
+remember or manage for each one. If you ever need to lock someone out
+everywhere at once, you do it in one place (deactivate their account here)
+instead of hunting down every app individually.
+
+The technology behind this is called **OAuth 2.0** and **OpenID Connect
+(OIDC)** — you'll see both names used, often together, referring to the
+same thing. You don't need to understand the protocol to use this page;
+what matters practically is the handful of concepts below.
+
+## What's a "client"?
+
+Every app you connect is registered here as a **client** — a single entry
+in the Directory representing that one app. Registering a client
+gives you a **Client ID** and **Client Secret**: think of these like a
+username and password, but for the *app itself* rather than for a person.
+You paste them into the other app's own "Single Sign-On" or "OIDC" setup
+screen, along with the discovery URL shown at the top of this page, and
+that app is now able to ask this SSO Manager to authenticate people on its
+behalf.
+
+**Treat the Client Secret like a password** — anyone who has it can
+impersonate that app when talking to your SSO Manager. If you ever suspect
+it's leaked, rotate it from the client's card.
+
+## What are "scopes"?
+
+**Scopes** control what information a connected app is allowed to ask for
+about the person logging in — their username, email, group memberships,
+and so on. Most apps tell you exactly which scopes they need in their own
+setup instructions; when in doubt, the default set (`openid`, `profile`,
+`email`, `groups`) covers what nearly every app expects.
+
+## "Restrict to Groups"
+
+By default, *any* account with an SSO Manager login can sign into a
+connected app. If that's not what you want — say, a home automation
+dashboard that only certain family members should reach — set **Restrict
+to Groups** on that client to one of your [groups](concepts-accounts.html).
+Only members of that group will be allowed to log into that particular
+app; everyone else gets turned away at the login step, even though their
+SSO Manager account still works everywhere else.
+
+## Redirect URIs
+
+A **Redirect URI** is the exact web address the connected app wants people
+sent back to once they've logged in here — it's a security measure so an
+attacker can't trick the login flow into redirecting somewhere else. The
+app's own setup instructions will tell you this value; copy it in exactly
+as given. If the app is reachable via more than one hostname (for example,
+because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
+this field supports wildcard patterns — see the inline help under the
+field itself for the exact syntax.
+
+## Want more detail?
+
+This page intentionally skips the protocol-level detail (exact endpoint
+URLs, token formats, claim names). If you're troubleshooting a connection
+or building something against the API directly, see the
+[OAuth reference](oauth.html).
+
+[← Back to Home](index.html)
diff --git a/docs/sso/configuration.md b/docs/sso/configuration.md
new file mode 100644
index 0000000..1e6c36c
--- /dev/null
+++ b/docs/sso/configuration.md
@@ -0,0 +1,104 @@
+---
+layout: default
+title: Configuration
+description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
+---
+
+# Configuration
+
+[← Back to Home](index.html)
+
+The app loads configuration via
+[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which
+deep-merges, in order (later wins):
+
+1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`,
+ `localhost`, `SSO Manager`).
+2. `conf/.js` — optional, environment-specific.
+3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
+4. **`app_*` environment variables** — the highest-precedence layer.
+
+Any env var whose name starts with `app_` overrides the merged config. The rest
+of the name splits on **double-underscore** (`__`) into a nested path. Values are
+`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
+raw strings otherwise.
+
+## Examples
+
+| Env var | Sets | Type |
+|---------|------|------|
+| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string |
+| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string |
+| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
+| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
+| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
+| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
+| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
+| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
+| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
+| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
+| `app_smtp__secure=false` | `conf.smtp.secure` | boolean |
+| `app_smtp__host=smtp.example.com` | `conf.smtp.host` | string |
+| `app_name=My SSO` | `conf.name` | string |
+| `app_redis__host=redis.local` | `conf.redis.host` | string (external Redis) |
+
+## The `app_*` env layer requires conf >= 1.1.0
+
+The `app_*` environment-variable override layer was added in
+`@simpleworkjs/conf` **1.1.0**. On 1.0.0 the app ignores all `app_*` vars and only
+reads `base.js` / `.js` / `secrets.js`. The Docker image will not honor
+`app_*` env on 1.0.0. Refresh the lock from the `nodejs/` directory:
+
+```bash
+cd nodejs && npm install @simpleworkjs/conf@^1.1.0
+```
+
+## Inspecting the merged config
+
+From the `nodejs/` directory:
+
+```bash
+node -e "console.log(require('@simpleworkjs/conf').ldap)"
+node -e "console.log(require('@simpleworkjs/conf').oauth)"
+node -e "console.log(require('@simpleworkjs/conf'))" # everything
+```
+
+Or, inside the running container:
+
+```bash
+docker compose exec sso-manager node -e "console.log(require('@simpleworkjs/conf').ldap)"
+```
+
+`app_*` env vars override `secrets.js`, which overrides `base.js` — if a value
+isn't what you expect, check those layers in that order.
+
+## Migrating an existing instance to the generic defaults
+
+The committed `nodejs/conf/base.js` ships **generic** defaults
+(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried
+Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth
+issuer). If you run an existing instance off this repo:
+
+- Move per-deployment, non-secret values (bind DN, user/group bases, SMTP
+ host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored
+ `conf/secrets.js`, **or** set them as `app_*` env vars.
+- Secret values (LDAP bind password, SMTP password, JWT secret) already belong
+ in `secrets.js`.
+
+## Troubleshooting `app_*` env vars
+
+### `app_*` vars seem to do nothing
+
+You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+ (above).
+
+### LDAP operations 401 / "Invalid Credentials"
+
+Check the merged LDAP config the app actually sees:
+
+```bash
+cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
+```
+
+Confirm `url` / `bindDN` / `bindPassword` / `userBase` match your directory.
+
+[← Back to Home](index.html)
\ No newline at end of file
diff --git a/docs/sso/directory.md b/docs/sso/directory.md
new file mode 100644
index 0000000..1287c6c
--- /dev/null
+++ b/docs/sso/directory.md
@@ -0,0 +1,139 @@
+---
+layout: default
+title: Directory Management
+description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
+---
+
+# Directory Management
+
+The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
+
+## Architecture
+
+The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
+
+There are three primary **Kinds** of resources you can define:
+- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
+- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
+- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
+- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
+
+By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
+
+## Automatic LDAP Group Creation
+
+When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
+
+1. `_access` (Member level access)
+2. `_admin` (Owner level access)
+
+For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
+
+## Resource Metadata
+
+Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
+
+### Common Metadata
+- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
+- **IP Address**: The internal IP address of the resource.
+- **MAC Address**: The hardware address of the primary interface.
+- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
+- **Production Environment**: A boolean toggle indicating if the resource is in production.
+
+### Host Metadata
+- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
+- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
+- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
+
+### Service Metadata
+- **Internal Port**: The local port the service binds to (e.g. `8080`).
+- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
+- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
+- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
+- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
+- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
+- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
+
+### Who sees which metadata
+
+Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
+
+- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
+- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
+- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
+
+Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
+
+## Catalog & access requests
+
+The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
+
+- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh _-_@`; otherwise they fall back to a direct `ssh @`.
+- **Discover More** — everything else in the directory, with a **Request access** button.
+- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
+
+A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
+
+Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
+
+## Navigating the UI
+
+The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
+
+
+
+## Slug conventions
+
+Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
+
+- **Sites**: `site_` — e.g. `site_local`, `site_us-east`
+- **Hosts**: `host_` — e.g. `host_pve1`, `host_web01`
+- **Services/apps**: a plain slug or `app_` — e.g. `sso-manager`, `app_emby`
+
+The auto-created LDAP groups derive from the slug (`_access` / `_admin`), so keep slugs stable once access groups are in use.
+
+## Automatic registration
+
+You don't have to build the graph by hand — the theta42 tooling registers itself:
+
+### The stack itself (theta-env)
+
+[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
+
+- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
+- the **host** the stack runs on (`host_`), with IP, MAC address, OS, and kernel collected from the machine
+- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
+- the proxy's auto-registered **OAuth client**, linked under its service
+
+The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
+
+### Linux hosts (ldap-client)
+
+The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
+
+## Consumers of the directory
+
+The inventory graph isn't just documentation — other components read it to make decisions:
+
+- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=`), then bridges them in. The `host_` slugs and `host__access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
+
+Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
+
+## API
+
+All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
+
+- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
+- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
+- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
+- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
+- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
+- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
+
+Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
+
+- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}`
+- `GET /api/access-requests/mine` — the caller's own history
+- `GET /api/access-requests` — pending requests the caller may decide
+- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
+- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
diff --git a/docs/sso/images/dashboard.png b/docs/sso/images/dashboard.png
new file mode 100644
index 0000000..18b4a81
Binary files /dev/null and b/docs/sso/images/dashboard.png differ
diff --git a/docs/sso/images/directory.png b/docs/sso/images/directory.png
new file mode 100644
index 0000000..9b1a4b3
Binary files /dev/null and b/docs/sso/images/directory.png differ
diff --git a/docs/sso/images/groups.png b/docs/sso/images/groups.png
new file mode 100644
index 0000000..ffcb461
Binary files /dev/null and b/docs/sso/images/groups.png differ
diff --git a/docs/sso/images/oauth-clients.png b/docs/sso/images/oauth-clients.png
new file mode 100644
index 0000000..e8790d5
Binary files /dev/null and b/docs/sso/images/oauth-clients.png differ
diff --git a/docs/sso/images/sites.png b/docs/sso/images/sites.png
new file mode 100644
index 0000000..797e8e8
Binary files /dev/null and b/docs/sso/images/sites.png differ
diff --git a/docs/sso/images/users.png b/docs/sso/images/users.png
new file mode 100644
index 0000000..60be54a
Binary files /dev/null and b/docs/sso/images/users.png differ
diff --git a/docs/sso/index.md b/docs/sso/index.md
new file mode 100644
index 0000000..6296519
--- /dev/null
+++ b/docs/sso/index.md
@@ -0,0 +1,85 @@
+---
+layout: default
+title: Home
+description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
+---
+
+# SSO Manager
+
+A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
+and a web management UI — for home labs and small businesses that want their
+own identity provider instead of a hosted one.
+
+One place to manage your users and groups, one login (OIDC) your modern apps
+can use, and one LDAP directory your older or odder apps can bind to directly.
+Everything runs on your own hardware; no phone-home, no hosted control plane,
+no per-user pricing.
+
+Part of the theta42 self-hosted identity stack, alongside
+[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy)
+and [theta-env](https://theta42.github.io/theta-env/) (the two composed with
+one command).
+
+## Screenshots
+
+
+
+
+
+
+
+*(click any screenshot to view full size)*
+
+## Why this over the alternatives
+
+Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
+LDAP is either a paid feature, a federation target you have to run
+separately, or absent. If your stack already has apps that speak LDAP
+directly — or you just want one real directory as the source of truth — you
+end up running *two* identity systems and keeping them in sync.
+
+SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
+apps and LDAP apps read from the same users and groups. The trade-off is
+scope: it's intentionally small and self-hosted, not an enterprise IAM suite.
+If you want a lightweight, self-contained identity provider with a real LDAP
+backend, that's the niche.
+
+## Features
+
+- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
+ tokens; standard discovery document at `/.well-known/openid-configuration`.
+- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public
+ keys, and sudo roles, with `memberOf` + referential-integrity overlays.
+- **Web management UI** — users, groups, and OAuth clients from a browser;
+ invite and password-reset flows over email; self-service profile + API
+ tokens.
+- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
+ via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
+ directory.
+- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
+ run the pieces separately via `app_*` env config.
+- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
+- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
+
+## Get it
+
+```bash
+git clone https://github.com/theta42/sso-manager-node.git
+cd sso-manager-node
+cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env
+docker compose up -d --build
+```
+
+For the full set of install options
+(Docker, bare-metal, or as part of the combined SSO + proxy stack), the
+`app_*` env reference, and the OAuth/LDAP internals, see the
+**[GitHub repository](https://github.com/theta42/sso-manager-node)**.
+
+## Related projects
+
+- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
+ reverse proxy, designed to sit in front of this SSO.
+- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
+ uses this SSO's directory to decide who may reach which machine.
+- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
+ Manager and the proxy together with one command.
diff --git a/docs/sso/ldap.md b/docs/sso/ldap.md
new file mode 100644
index 0000000..6ef41a0
--- /dev/null
+++ b/docs/sso/ldap.md
@@ -0,0 +1,432 @@
+---
+layout: default
+title: LDAP
+description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
+---
+
+# LDAP Directory
+
+[← Back to Home](index.html)
+
+> Looking for a plainer explanation of accounts, groups, and managers
+> instead of schema/attribute detail? See
+> [Accounts, Groups & Managers](concepts-accounts.html).
+
+SSO Manager runs an OpenLDAP directory holding your users and groups. The app
+authenticates against it over `localhost:389` (inside the all-in-one container)
+and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
+directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
+theta42/proxy, etc.
+
+## Directory layout
+
+```
+dc=yourdomain,dc=com
+├── ou=people users (inetOrgPerson + posixAccount + …)
+├── ou=groups groups (groupOfNames)
+└── ou=policies password policies (pwdPolicy)
+ └── cn=ppolicy default policy
+```
+
+### Users
+
+User entries are `cn=,ou=people,` and carry the objectClasses:
+
+- `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs.
+- `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's
+ `userFilter` is `(objectClass=posixAccount)`, so a user is "a real account"
+ iff it has `posixAccount`.
+- `ldapPublicKey` — SSH public keys (`sshPublicKey`).
+- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
+- `theta42Person` (custom auxiliary; `dateOfBirth`).
+
+Every user (person or service account) also carries a `manager` attribute
+(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
+the people who created/administer that account. Set automatically to the
+creator's DN on signup (whoever an admin was logged in as, or whoever sent
+the invite), and reassignable later from the account's Edit form. Anyone
+listed as a `manager` can edit that account (same fields an admin can:
+mobile, description, SSH key, date of birth, home directory, login shell,
+and the manager list itself) without needing `app_sso_admin`.
+
+Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
+verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
+canonical hasher; if you provision users out-of-band, hash passwords the same
+way or use `slappasswd -h '{SSHA512}'`.
+
+### Groups
+
+Groups are `cn=,ou=groups,` (`groupOfNames`) with a `member`
+attribute listing member DNs. The `memberOf` overlay populates reverse
+membership (`memberOf` on the user); `refint` keeps it consistent on
+add/remove.
+
+Note that `groupOfNames` requires **at least one member**, which has two
+consequences worth knowing: whoever creates a group is automatically seeded
+into it, and removing the last member (user *or* nested group) is refused with
+a 409 rather than leaving an invalid entry behind.
+
+### Nested groups
+
+A `member` DN may be another group's, not just a user's — that is how nesting
+is stored, with no extra schema. Everyone in the nested group is a member of
+the outer one, at any depth. Manage it on the **Groups** page under each
+group's *Nested* tab, or via the API:
+
+```
+PUT /api/group/:group/nested/:child nest :child inside :group
+DELETE /api/group/:group/nested/:child un-nest
+GET /api/group/:group/effective direct users, nested groups, and the
+ full transitive set of users
+```
+
+Cycles are refused (409) rather than truncated — a loop makes "who is in this
+group" unanswerable. Two standing relationships are wired automatically: the
+cross-app `app_super_admin` is nested into every resource's `_admin`
+group, and each `_admin` into its `_access` group, so administering
+something implies being able to use it.
+
+**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
+can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
+direct membership only. The bundled slapd is therefore built from source with
+the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
+via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
+closure itself — same answers, more queries. Either way, **never read `memberOf`
+directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
+which is correct in both modes.
+
+### Personal groups
+
+Every user (person or service account) also gets a **personal Unix group**
+at creation — `cn=,ou=groups,`, `objectClass: posixGroup` (RFC
+2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
+different schema than the `groupOfNames` groups above — its membership
+attribute is `memberUid` (a bare username, not a DN), and unlike
+`groupOfNames` it's valid with zero members. It's excluded from the
+`/groups` page (which filters on `objectClass=groupOfNames`) and managed
+instead from the owning user's own profile page ("Members of ``'s
+group", admin-only) — add other accounts as supplementary members, e.g. to
+share write access to files owned by this group.
+
+The SSO seeds these groups automatically (entrypoint / `install.sh`):
+
+| Group | Grants |
+|-------|--------|
+| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
+| `app_sso_admin` | full admin (users, groups, settings) |
+| `app_sso_oauth_admin` | OAuth client management |
+| `app_sso_invite` | invitation management |
+| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
+
+## TLS (LDAPS / StartTLS)
+
+The bundled slapd generates a **self-signed cert** on first start (CN =
+`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on:
+
+- `ldaps:///` — **636**, TLS (the port to expose for direct-LDAP clients).
+- `ldap:///` — **389**, plain + StartTLS (not mapped to the host by default).
+
+The cert lives on the `ldap-certs` volume so it persists across container
+recreation.
+
+### Trusting the self-signed cert
+
+Copy it out and add it to the client's CA store:
+
+```bash
+docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
+```
+
+…or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy
+sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect).
+
+### Using your own cert
+
+Replace the `ldap-certs` named volume with a bind mount containing your own
+`ldap.crt` + `ldap.key`:
+
+```yaml
+volumes:
+ - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
+```
+
+The entrypoint leaves existing certs untouched (idempotent).
+
+## Choosing the LDAPS hostname
+
+The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
+default it derives that URL from the public OAuth issuer (e.g.
+`https://sso.example.com` → `ldaps://sso.example.com:636`). That is convenient,
+but it implies LDAP clients reach your directory through the same public
+hostname — which usually means port-forwarding 636 through your router.
+
+**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
+have no rate limiting and are a brute-force target. Instead, use one of these
+internal-only patterns and set `conf.ldap.ldapsHost` (or
+`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
+
+### 1. Same Docker / local network host (best for apps on this machine)
+
+If the LDAP client runs on the same Docker network as the SSO Manager (for
+example, the bundled `theta-env` stack), use the internal service name:
+
+```
+ldaps://sso-manager:636
+```
+
+In `conf/secrets.js`:
+
+```javascript
+ldap: {
+ ldapsHost: 'sso-manager',
+ ldapsPort: 636,
+}
+```
+
+The proxy in theta-env already uses this internally. The bundled slapd cert
+includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
+so hostname verification works without extra setup.
+
+### 2. LAN host behind your router (best for separate home-lan machines)
+
+Create an internal-only DNS record — e.g. `ldap.internal.example.com` →
+`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
+or generate a cert whose SAN/CN matches that internal name:
+
+- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
+ public domain and can complete DNS-01 challenge; the record itself can stay
+ private/routable only inside your LAN.
+- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
+ `ldap.internal.example.com`, and distribute the CA cert to clients.
+- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
+ the generated `ldap.crt` to each client and trust it.
+
+In `conf/secrets.js`:
+
+```javascript
+ldap: {
+ ldapsHost: 'ldap.internal.example.com',
+ ldapsPort: 636,
+}
+```
+
+The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
+
+### 3. Public hostname (acceptable only behind a VPN/firewall)
+
+If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
+etc.) or a tightly locked-down firewall rule. In that case the public hostname
+may be appropriate, but the LDAPS port should still not be reachable from the
+open internet.
+
+### Why not just use the LDAP server's IP address?
+
+TLS clients verify the server name against the certificate. Connecting to
+`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
+will fail hostname verification unless you disable cert checks — which removes
+most of the security benefit of LDAPS. Always use a hostname that matches the
+cert.
+
+## Service accounts
+
+A service account is a normal `posixAccount` for something that isn't a
+person: a media manager, a torrent client, a service like Emby, or a
+read-only bind account an app uses to look users up — anything that needs a
+real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
+group for write access (e.g. a `stuff_manager` group granting write rights
+to a media library). There's only one kind — every account, person or
+service, is a real `posixAccount` with a UID.
+
+Create one from the **Users → Service Accounts** tab's "Add new user" form
+with **This is a service account** checked — it skips the birthday/
+Terms-of-Service fields a real person's account needs and asks for just an
+account name. It's flagged (via membership in the `app_sso_service_account`
+group) so it's listed separately from real people and excluded from "all
+users" notification broadcasts.
+
+Email and password are both optional for a service account:
+
+- No `mail` is set unless you give it one (it never needs a mailbox).
+- Leaving the password blank is fine — no `userPassword` attribute is set at
+ all, and an entry with no `userPassword` simply can't bind with any
+ password (standard LDAP simple-bind behavior). Only set a password if the
+ account actually needs to authenticate as itself (e.g. a bind-only account
+ an app uses to look users up).
+
+theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
+against LDAP (independent of this app), and the proxy binds as it — that
+account won't show up in the Service Accounts tab since it isn't managed
+through this app, but it keeps working unchanged.
+
+Either way: don't reuse the admin DN, and give a service account only the
+group memberships and `manager`s it actually needs.
+
+Example bind test (a service account with a password set):
+
+```bash
+ldapsearch -x -H ldaps://sso.example.com:636 \
+ -D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \
+ -b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail
+```
+
+## Connecting a 3rd-party app or container
+
+Most self-hosted apps with an "LDAP authentication" settings page — Gitea,
+Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via
+`LDAP_*` env vars, all ask for the same handful of values. These are the
+`conf.ldap` values from [Configuration](configuration.html), applied to
+*your* domain:
+
+| Field the app asks for | Value |
+|---|---|
+| Host / URL | `ldaps://:636` (preferred), or `ldap://:389` + StartTLS |
+| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,` (see above) |
+| Bind password | that service account's password |
+| User search base | `ou=people,` |
+| User search filter | `(objectClass=posixAccount)` |
+| Username attribute | `uid` |
+| Email attribute | `mail` |
+| Group search base | `ou=groups,` |
+| Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) |
+| TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use |
+
+### Worked example: Gitea
+
+Gitea's **Admin → Authentication Sources → Add Authentication Source** (type
+LDAP, "Bind DN/Password") maps directly:
+
+- Security Protocol: `LDAPS`
+- Host / Port: your SSO host / `636`
+- Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com`
+- Bind Password: the service account's password
+- User Search Base: `ou=people,dc=yourdomain,dc=com`
+- User Filter: `(&(objectClass=posixAccount)(uid=%s))`
+- Username Attribute: `uid`
+- E-mail Attribute: `mail`
+
+Other apps with an LDAP settings UI follow the same shape — the field names
+above are the constants; only the base DN and hostname change per deployment.
+
+### Generic Docker container (`LDAP_*` env vars)
+
+For images that take a flat env-var LDAP config (there's no single standard,
+but most look like this):
+
+```yaml
+environment:
+ LDAP_URL: ldaps://sso.example.com:636
+ LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com
+ LDAP_BIND_PASSWORD:
+ LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com
+ LDAP_USER_FILTER: (objectClass=posixAccount)
+ LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com
+```
+
+Check the specific image's docs for its actual variable names — the values
+you plug in are still the ones from the table above.
+
+### Full Linux host auth (SSH, sudo, login) instead of a single app
+
+If you want a *host* (not just one app) to authenticate logins, SSH keys, and
+sudo against this LDAP directory — not just one application — that's a
+different integration (SSSD + PAM + NSS, not a single bind). See
+[theta42/ldap-client](https://github.com/theta42/ldap-client): a script that
+configures SSSD on Ubuntu/Debian hosts against this directory, including
+group-based access control and SSH public key retrieval from LDAP.
+
+## Modules + overlays (external LDAP servers)
+
+If you point the app at your own LDAP server instead of the bundled slapd, it
+needs:
+
+- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
+ `ppolicy`, `memberof`, `refint`.
+- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
+ released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
+ unreleased), so the bundled image builds slapd from a pinned upstream commit.
+ Without it the app resolves nesting itself and everything still works — leave
+ `ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
+ configure:
+
+ ```
+ overlay nestgroup
+ nestgroup-base ou=groups,
+ nestgroup-flags member-filter memberof-filter memberof-values
+ ```
+
+ Flags are **space-separated**; the comma form the man page's `{a, b, c}`
+ notation suggests is rejected. `member-values` is deliberately omitted — it
+ expands the `member` attribute when reading a group, which destroys the
+ distinction between "listed here" and "reachable through a nested group", and
+ the raw values are then unrecoverable. Transitive answers come from the filter
+ flags and from `GET /api/group/:group/effective`.
+
+ One more consequence of building from master: it ships **LMDB 1.0.0**, whose
+ on-disk format is mutually unreadable with the 0.9.x in 2.6.x
+ (`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
+ is a `slapcat` → `slapadd` reload, not a restart.
+- **Custom schema:** the `theta42Person` auxiliary objectClass with
+ `dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
+- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
+ a default `pwdPolicy` at `cn=ppolicy,ou=policies,`.
+- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
+ and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
+ also nests it into the first three).
+
+`ops/ldap-setup.sh -p ` configures all of the above
+idempotently against a running slapd (auto-detects the database holding your
+base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's
+active/inactive toggle depends on).
+
+## Backups and restore
+
+`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention)
+— see the *Backups and restore* section of
+`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the
+hood, useful if you want just the directory without Redis/config.
+
+**Backup** (while slapd is running):
+
+```bash
+docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
+ -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
+```
+
+Store the `.ldif` off the host — it contains every user's password hash.
+
+**Restore** into a stopped directory. The SSO image uses a static `slapd.conf`
+(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`:
+
+```bash
+docker compose stop sso-manager
+docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
+ 'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
+ < ldap-backup-.ldif
+docker compose start sso-manager
+```
+
+Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`.
+
+Redis state (OAuth clients, tokens) and `./config/` secrets are backed up
+separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the
+full (LDAP + Redis + secrets) runbook.
+
+## Troubleshooting
+
+### `503 OpenLDAP ppolicy overlay is not configured`
+
+The ppolicy overlay isn't attached to the database holding your users, so the
+active/inactive toggle can't set `pwdAccountLockedTime`:
+
+```bash
+sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
+```
+
+### LDAP connection refused
+
+```bash
+docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
+systemctl status slapd # bare metal
+```
+
+[← Back to Home](index.html)
\ No newline at end of file
diff --git a/docs/sso/oauth.md b/docs/sso/oauth.md
new file mode 100644
index 0000000..230a784
--- /dev/null
+++ b/docs/sso/oauth.md
@@ -0,0 +1,111 @@
+---
+layout: default
+title: OAuth / OIDC
+description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
+---
+
+# OAuth 2.0 / OpenID Connect
+
+[← Back to Home](index.html)
+
+> Looking for a plainer explanation of clients/scopes/redirect URIs instead
+> of endpoint-level detail? See
+> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
+
+SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
+access, refresh, and ID tokens that your apps can consume to authenticate
+users and authorize API calls. It also runs a full OpenLDAP directory, so it
+can be both your SSO and your user directory at once.
+
+## Discovery
+
+The provider publishes a standards-compliant discovery document:
+
+```
+GET https:///.well-known/openid-configuration
+```
+
+It advertises the `issuer`, `authorization_endpoint`, `token_endpoint`,
+`userinfo_endpoint`, `end_session_endpoint`, supported scopes, and token
+lifetimes. OIDC clients (e.g. the theta42/proxy) can read their endpoint URLs
+from here rather than configuring each one.
+
+The `issuer` advertised is `conf.oauth.issuer` — set it to the **browser-facing**
+HTTPS URL the SSO is served at (e.g. `https://sso.example.com`), either in
+`conf/secrets.js` or via `app_oauth__issuer` / `OAUTH_ISSUER`.
+
+## OAuth clients
+
+An OAuth client represents an app that authenticates against the SSO. Each has:
+
+- `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is
+ shown once** when the client is created or rotated — save it immediately).
+- `name`, `description`, `created_by` (the admin uid that created it).
+- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may
+ use `*` (one hostname label) / `**` (any number of labels) as a wildcard —
+ e.g. `https://*.example.com/__proxy_auth/callback` covers every host
+ theta42/proxy fronts under `example.com`, so you don't have to register
+ each proxied host's callback individually.
+- `scopes` — requested scopes (default `openid profile email groups`).
+- `allowed_groups` — restrict the client to members of specific SSO groups
+ (empty = any valid user).
+- `token_lifetime` — `access_token` / `refresh_token` lifetimes (seconds).
+
+### Managing clients
+
+Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
+
+| Action | How to do it |
+|--------|--------------|
+| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
+| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
+| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
+| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
+
+> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
+
+
+
+## Scopes
+
+| Scope | Claims / access |
+|-------|-----------------|
+| `openid` | OIDC ID token + discovery |
+| `profile` | `preferred_username`, display name, etc. |
+| `email` | the user's `mail` |
+| `groups` | the user's group memberships (the `groups` claim) |
+
+The `groups` claim is what relying parties (e.g. the proxy's
+`app_auth__adminGroups`) use to map group membership to roles.
+
+## Token lifetimes
+
+Defaults (overridable per-client via `token_lifetime`, or globally via
+`app_oauth__token_lifetime__access_token` /
+`app_oauth__token_lifetime__refresh_token`):
+
+- access token: 3600s (1 hour)
+- refresh token: 2592000s (30 days)
+
+## Admin gating
+
+SSO admin actions are gated by LDAP group membership (checked via the group's
+`member` list, not `memberOf` on the user):
+
+- `app_sso_admin` — full admin (users, groups, settings).
+- `app_sso_oauth_admin` — OAuth client management.
+- `app_sso_invite` — invitation management.
+
+The bootstrap in [theta-env](https://github.com/theta42/theta-env) creates your
+first admin and adds them to `app_sso_admin` + `app_sso_oauth_admin`
+automatically.
+
+## JWT signing
+
+Tokens are signed with `conf.oauth.jwtSecret` (`app_oauth__jwtSecret` /
+`JWT_SECRET`). **Persist this secret** — if it changes, every issued token
+stops validating. The all-in-one Docker image auto-generates one if none is set,
+but that generated value does not survive container recreation unless you
+persist it (set `JWT_SECRET` in your `.env`).
+
+[← Back to Home](index.html)
\ No newline at end of file
diff --git a/docs/sso/replication.md b/docs/sso/replication.md
new file mode 100644
index 0000000..06955ad
--- /dev/null
+++ b/docs/sso/replication.md
@@ -0,0 +1,55 @@
+---
+layout: default
+title: Geo-Location Scaling (Replication)
+---
+
+# Geo-Location Scaling (Replication)
+
+SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
+
+## Why and when to use this?
+- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
+- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
+- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
+
+By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
+
+## How it works
+
+In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
+- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
+- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
+- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
+
+## Configuration
+
+To enable replication, you must pass two environment variables to the `sso-manager` container:
+
+1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
+2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
+
+### Example using `theta-env` / Docker Compose
+
+**Site 1 (`setup.env` or `docker-compose.yml`)**
+```env
+LDAP_SERVER_ID=1
+LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
+```
+
+**Site 2 (`setup.env` or `docker-compose.yml`)**
+```env
+LDAP_SERVER_ID=2
+LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
+```
+
+**Site 3 (`setup.env` or `docker-compose.yml`)**
+```env
+LDAP_SERVER_ID=3
+LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
+```
+
+Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
+
+## User Locations
+
+When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
diff --git a/docs/sso/vault.md b/docs/sso/vault.md
new file mode 100644
index 0000000..a301682
--- /dev/null
+++ b/docs/sso/vault.md
@@ -0,0 +1,45 @@
+---
+layout: default
+title: Secrets Vault
+nav_order: 6
+---
+
+# Secrets Vault
+
+SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
+
+The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
+
+## Architecture
+
+The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
+
+When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
+
+## Accessing the Vault
+
+The SSO Manager Vault can be accessed in two ways:
+
+1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
+2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
+
+### API Example
+
+To read secrets from the default key-value store, issue a `GET` request to:
+`/api/vault/v1/secret/data/sso-manager/conf`
+
+Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
+
+## Namespaces and Paths
+
+Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
+
+## Plugin Integration
+
+Plugin instances store their per-instance secrets in OpenBao at
+`secret/plugins//conf` (configured, loaded/unloaded, and run from
+the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
+in-process, so the SSO Manager reads/writes those secrets server-side through
+the `sso-broker` token; the admin UI only ever sees masked values, and external
+apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
+consistently enforced instead of hardcoding them.
diff --git a/docs/standalone.md b/docs/standalone.md
deleted file mode 100644
index 71a0d3b..0000000
--- a/docs/standalone.md
+++ /dev/null
@@ -1,145 +0,0 @@
----
-layout: default
-title: Standalone
-description: Running a component individually, without theta-suite's orchestration — an advanced path; the integrated stack is the supported one.
----
-
-# Running a component individually
-
-[← Back to Home](index.html)
-
-> **The integrated stack is the supported path.** `./setup.sh` wiring all four
-> components together around a shared OpenBao secrets store is what's tested and
-> released. The steps below are for the advanced case where you want to run one
-> component on its own — a separate host, a different network, or without the
-> orchestrator. Running standalone means managing secrets from the
-> `config/*-secrets.js` file only (no shared OpenBao) and doing the OIDC/LDAP
-> wiring by hand.
-
-The submodules in this repo are normal clones; you can also clone them directly
-from GitHub. Each component builds and runs on its own.
-
----
-
-## SSO Manager alone
-
-The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis:
-
-```bash
-git clone https://github.com/theta42/sso-manager-node.git
-cd sso-manager-node
-mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
-docker compose up -d --build
-```
-
-The entrypoint points the `CONF_SECRETS` env var at `config/sso-secrets.js` so
-`@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and
-the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass
-**no `app_*` env** — env beats the secrets file, so `app_*` would silently
-override your file.
-
-- Web UI: `http://localhost:3001`
-- Health: `http://localhost:3001/health`
-- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
-- LDAPS: `ldaps://:636`
-
-Requires `@simpleworkjs/conf` >= 1.2.0. Full reference:
-[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
-
-### Bare metal
-
-```bash
-sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' -n 'Your Org' -o 3001
-sudo systemctl enable --now sso-manager
-```
-
-Idempotent — re-run to update. See the SSO Manager
-[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html).
-
----
-
-## Proxy alone
-
-The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis:
-
-```bash
-git clone https://github.com/theta42/proxy.git
-cd proxy
-mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
-docker compose up -d --build
-```
-
-The entrypoint points the `CONF_SECRETS` env var at `config/proxy-secrets.js`
-so `@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints +
-`clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and
-`auth` (admin groups/users). Pass **no `app_*` env** — env beats the secrets
-file, so `app_*` would silently override your file.
-
-- Proxy (public, auto-SSL): `https:///`
-- Mgmt UI / API: `http://127.0.0.1:3000/`
-- Health: `http://127.0.0.1:3000/health`
-
-Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
-[proxy deployment docs](https://theta42.github.io/proxy/docker.html).
-
-### The `auth.adminUsers` anti-lockout account
-
-Both `setup.sh` and `config.example/proxy-secrets.js.example` write
-`auth.adminUsers: ['proxyadmin2']` into `proxy-secrets.js`. This is a
-**local, config-driven admin bypass** — the proxy grants full admin rights to
-any logged-in OIDC user whose username (the `preferred_username` claim from
-the SSO) matches an entry in `auth.adminUsers`, regardless of their LDAP group
-membership (see `proxy/nodejs/utils/roles.js`, `resolveEffective()`). It exists
-so an operator can't lock themselves out of the proxy mgmt UI if the SSO's
-`app_sso_admin` group is ever misconfigured, deleted, or otherwise broken.
-
-It is **not** derived from any `setup.env` value, and it does **not** create a
-user by itself — the name is only a username match. To actually use the
-bypass, create a user with uid `proxyadmin2` in the SSO (it does not need to
-be in `app_sso_admin` or any other group) and log in through the proxy as that
-user.
-
-To change or disable it, edit `auth.adminUsers` directly in
-`./config/proxy-secrets.js` after the first `./setup.sh` run (re-running
-`setup.sh` will not overwrite an existing `proxy-secrets.js`):
-
-- **Rename** it to a less guessable username: `adminUsers: ['your-break-glass-uid']`.
-- **Add more** anti-lockout accounts: `adminUsers: ['proxyadmin2', 'another-admin']`.
-- **Disable** it entirely: `adminUsers: []` (global admin then comes only from
- `auth.adminGroups` membership — make sure at least one real admin group is
- reachable before doing this).
-
-### Bare metal
-
-```bash
-wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
-```
-
-See the proxy
-[Docker guide](https://theta42.github.io/proxy/docker.html) /
-[installation guide](https://theta42.github.io/proxy/installation.html).
-
----
-
-## Wiring components together by hand
-
-If you have a specific reason to run the components on separate hosts instead
-of through `./setup.sh` (and accept that you lose the shared OpenBao secrets
-store), the four wiring steps are documented in both projects' deployment
-guides:
-
-1. One Docker network (or reachable hostnames) so the proxy can reach the SSO
- internally for token/userinfo + LDAPS.
-2. Set the SSO's `oauth.issuer` (in its `secrets.js`) to the browser-facing HTTPS
- URL the proxy serves the SSO at.
-3. Register the proxy as an OIDC client in the SSO, with `redirectUri` matching
- the proxy's callback; put the resulting `clientId`/`clientSecret` in the
- proxy's `secrets.js`.
-4. Point the proxy's `ldap.url` at the SSO's LDAPS + create a dedicated
- `cn=ldapclient` service account; set the same password as `bindPassword`.
-
-`./setup.sh` exists to do all of this for you — and to add the OpenBao secrets
-store, jump host, and ldap-client on top. Unless you need the components on
-separate hosts, prefer the integrated stack.
-
-[← Back to Home](index.html)
\ No newline at end of file