Compare commits

...

2 Commits

Author SHA1 Message Date
tes d27fd02866 Add third NATS user 'external-apps' for external application connections
Unrestricted admin (same shape as admin/volcanic-agents), password
delivered via Coolify env var NATS_EXTERNAL_APPS_PASSWORD. Doc updated
in both places that enumerate users. Plan file included for the record.
2026-08-26 16:22:45 +00:00
tes 84ed07fdec Add design doc for NATS external-apps user 2026-08-26 16:05:44 +00:00
4 changed files with 521 additions and 3 deletions
+3 -2
View File
@@ -15,10 +15,11 @@ Coolify's non-secret app config (project/server UUIDs, name, domain) lives in `d
## Connection details (for agents managing this NATS) ## Connection details (for agents managing this NATS)
There are two users, both admin (unrestricted publish/subscribe): There are three users, all admin (unrestricted publish/subscribe):
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application. - `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token). - `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
- `external-apps` — env var `NATS_EXTERNAL_APPS_PASSWORD` on the Coolify application. Intended for third-party / external application connections. Password-style credential (random string, framed as a password rather than a bearer token). Unrestricted for now; scope down if we ever need to isolate external callers from internal traffic.
### Local credentials cache ### Local credentials cache
@@ -110,7 +111,7 @@ asyncio.run(main())
- `8080` — WebSocket listener, `no_tls: true`. The only port in `expose:`. Traefik terminates TLS and forwards `ws://nats:8080` from `wss://nats.tes.gd`. - `8080` — WebSocket listener, `no_tls: true`. The only port in `expose:`. Traefik terminates TLS and forwards `ws://nats:8080` from `wss://nats.tes.gd`.
- `8222` — HTTP monitoring (`/healthz`, `/varz`, `/jsz`), used by the compose healthcheck; not routed publicly. - `8222` — HTTP monitoring (`/healthz`, `/varz`, `/jsz`), used by the compose healthcheck; not routed publicly.
- **JetStream**: enabled, persisted to the named volume `nats-data` mounted at `/data`. Limits: 256MB memory / 4GB file. Bump `max_file_store` in `nats-server.conf` if you need more. - **JetStream**: enabled, persisted to the named volume `nats-data` mounted at `/data`. Limits: 256MB memory / 4GB file. Bump `max_file_store` in `nats-server.conf` if you need more.
- **Auth**: two users in `authorization.users` — `admin` (password from `NATS_PASSWORD`) and `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token). Both are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode. - **Auth**: three users in `authorization.users` — `admin` (password from `NATS_PASSWORD`), `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token), and `external-apps` (password from `NATS_EXTERNAL_APPS_PASSWORD`, for third-party callers). All three are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
## Managing / redeploying ## Managing / redeploying
+2 -1
View File
@@ -40,7 +40,8 @@ configs:
authorization { authorization {
users = [ users = [
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" }, { user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" } { user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" },
{ user: "external-apps", password: "${NATS_EXTERNAL_APPS_PASSWORD}" }
] ]
} }
@@ -0,0 +1,456 @@
# NATS `external-apps` User Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a third NATS user, `external-apps`, so third-party applications can connect to `wss://nats.tes.gd` with their own unrestricted credential.
**Architecture:** One-line addition to `authorization.users` in the inline `nats-conf` config inside `docker-compose.yml`. Credential value is a random password held in the Coolify env var `NATS_EXTERNAL_APPS_PASSWORD` and substituted at compose parse time. Deployment is triggered fire-and-forget via the Coolify v4 REST API. Local `credentials.local.json` and `CLAUDE.md` are updated to reflect the new user.
**Tech Stack:** Docker Compose 2.x, NATS 2.10 (WebSocket listener), Coolify v4 REST API, `curl` + `jq` for API calls, Node + `nats.ws` for auth verification (installed on demand).
---
## File Structure
| Path | Change | Purpose |
|---|---|---|
| `docker-compose.yml` | Modify (append 1 line inside `authorization.users`) | Register the new user in the NATS server config |
| `CLAUDE.md` | Modify (2 sections: "Connection details" intro, "Auth" bullet) | Keep the project doc in sync with the live config |
| `credentials.local.json` | Modify (add a third `users` entry) | Local cache of the new credential (gitignored) |
| `deploy.json` | Read-only | Source of `app.uuid` for the deploy API call |
| Coolify env vars | External change via API | Where the actual secret lives; source of truth |
Both the compose file and `CLAUDE.md` are committed. `credentials.local.json` is gitignored and stays local.
---
## Prerequisites
- `COOLIFY_URL` and `COOLIFY_KEY` env vars are set in the shell.
- Working directory is the repo root (`/app/data/home/nats_expert`).
- Local `deploy.json` is present (already confirmed).
---
### Task 1: Generate password and set the Coolify env var
**Files:**
- Read: `deploy.json` (for `app.uuid`)
- External: Coolify env vars on the `nats` application
This must happen **before** the compose change is pushed. If compose substitutes an empty string for `NATS_EXTERNAL_APPS_PASSWORD` at parse time, the user `external-apps` ends up with `password: ""` and any caller sending an empty password would be authenticated.
- [ ] **Step 1: Generate a 32-byte hex password and hold it in a shell variable**
```bash
NEW_PW=$(openssl rand -hex 32)
echo "generated: length=${#NEW_PW}" # sanity check only — do NOT echo $NEW_PW
```
Expected: `generated: length=64`
- [ ] **Step 2: Confirm the app UUID from deploy.json**
```bash
APP_UUID=$(jq -r '.app.uuid' deploy.json)
echo "app: $APP_UUID"
```
Expected: `app: pic1i4nuchjk810eblk2mv6a` (or whatever is in `deploy.json`).
- [ ] **Step 3: Check the env var is not already set (avoid clobbering)**
```bash
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
| jq -r '.[] | .key' | grep -c '^NATS_EXTERNAL_APPS_PASSWORD$'
```
Expected: `0`. If it prints `1`, stop and check with the user — someone already set it and the value will be overwritten below.
- [ ] **Step 4: POST the new env var to Coolify**
```bash
curl -sS -X POST "$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg v "$NEW_PW" '{key:"NATS_EXTERNAL_APPS_PASSWORD", value:$v, is_preview:false, is_build_time:false, is_literal:true}')"
```
Expected: JSON response with a `uuid` field for the new env var. No error.
- [ ] **Step 5: Read the env back to confirm it landed**
```bash
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
| jq -r '.[] | select(.key=="NATS_EXTERNAL_APPS_PASSWORD") | "\(.key)=<length=\(.value|length)>"'
```
Expected: `NATS_EXTERNAL_APPS_PASSWORD=<length=64>`. Do **not** print the value.
- [ ] **Step 6: Save the password to a shell file for later steps in this session**
Do this so the value survives if the shell forgets `NEW_PW` between steps.
```bash
umask 077
printf '%s\n' "$NEW_PW" > /tmp/nats-external-apps.pw
```
No commit — this file is temporary and lives outside the repo.
---
### Task 2: Add the user to `docker-compose.yml`
**Files:**
- Modify: `docker-compose.yml` (inside `configs.nats-conf.content`, the `authorization.users` array)
- [ ] **Step 1: Read the current file to confirm the anchor line**
```bash
grep -n 'volcanic-agents' docker-compose.yml
```
Expected: one hit like `43: { user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" }`.
- [ ] **Step 2: Apply the edit**
Use the Edit tool. Replace:
```
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" }
```
with:
```
{ user: "${NATS_USER}", password: "${NATS_PASSWORD}" },
{ user: "volcanic-agents", password: "${NATS_VOLCANIC_AGENTS_TOKEN}" },
{ user: "external-apps", password: "${NATS_EXTERNAL_APPS_PASSWORD}" }
```
Only the last entry lacks a trailing comma; make sure `volcanic-agents` now has one and `external-apps` doesn't.
- [ ] **Step 3: Verify the edit locally**
```bash
grep -n 'external-apps\|volcanic-agents\|NATS_USER' docker-compose.yml
```
Expected: three lines showing all three users in order.
- [ ] **Step 4: Validate the compose file parses**
```bash
docker compose -f docker-compose.yml config >/dev/null && echo OK
```
Expected: `OK` (may print warnings about the empty env var — that's fine, we're not deploying locally). If `docker` is not on this machine, skip this step; the Coolify deploy will fail loudly if the file is malformed.
---
### Task 3: Update `CLAUDE.md`
**Files:**
- Modify: `CLAUDE.md` (two sections)
- [ ] **Step 1: Update the "Connection details" intro list**
Find the block that starts with `There are two users, both admin`. Replace it:
Old:
```
There are two users, both admin (unrestricted publish/subscribe):
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
```
New:
```
There are three users, all admin (unrestricted publish/subscribe):
- `admin` — env vars `NATS_USER` / `NATS_PASSWORD` on the Coolify application.
- `volcanic-agents` — env var `NATS_VOLCANIC_AGENTS_TOKEN` on the Coolify application. Semantically a bearer token; on the wire it goes through NATS's `password` field (NATS has no per-user `token` field — that's only valid at the top of `authorization` as a single global token).
- `external-apps` — env var `NATS_EXTERNAL_APPS_PASSWORD` on the Coolify application. Intended for third-party / external application connections. Password-style credential (random string, framed as a password rather than a bearer token). Unrestricted for now; scope down if we ever need to isolate external callers from internal traffic.
```
- [ ] **Step 2: Update the "Auth" bullet under "What's inside the server"**
Find the bullet starting with `**Auth**: two users in \`authorization.users\``. Replace:
Old:
```
- **Auth**: two users in `authorization.users` — `admin` (password from `NATS_PASSWORD`) and `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token). Both are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
```
New:
```
- **Auth**: three users in `authorization.users` — `admin` (password from `NATS_PASSWORD`), `volcanic-agents` (password from `NATS_VOLCANIC_AGENTS_TOKEN`, semantically a bearer token), and `external-apps` (password from `NATS_EXTERNAL_APPS_PASSWORD`, for third-party callers). All three are unrestricted (no `permissions` block → admin). Substitution happens at Docker Compose parse time (the config lives inline in `docker-compose.yml` under `configs.nats-conf.content`), so NATS itself sees a static config. No accounts, no operator/JWT mode.
```
- [ ] **Step 3: Verify both edits landed**
```bash
grep -n 'external-apps' CLAUDE.md
```
Expected: two hits, one in each of the sections above.
---
### Task 4: Commit compose + doc changes
- [ ] **Step 1: Stage and review**
```bash
git add docker-compose.yml CLAUDE.md
git status
git diff --cached
```
Expected: only those two files are staged; the diff shows the new user in compose and the two doc updates.
- [ ] **Step 2: Commit**
```bash
git commit -m "$(cat <<'EOF'
Add third NATS user 'external-apps' for external application connections
Unrestricted admin (same shape as admin/volcanic-agents), password
delivered via Coolify env var NATS_EXTERNAL_APPS_PASSWORD.
EOF
)"
```
Expected: one new commit on `main`.
- [ ] **Step 3: Push**
```bash
git push 2>&1 | sed -E "s|${GIT_KEY:-NEVER_MATCH}|***|g; s|${GIT_USER:-NEVER_MATCH}|***|g"
```
Expected: `main -> main`, no errors.
---
### Task 5: Trigger the Coolify deploy (fire-and-forget)
**Files:**
- Read: `deploy.json` (for `app.uuid`)
Per global instructions: trigger and stop. Do not poll build progress. The API call returning a deployment uuid is the success signal.
- [ ] **Step 1: POST the deploy**
```bash
APP_UUID=$(jq -r '.app.uuid' deploy.json)
curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID" \
-H "Authorization: Bearer $COOLIFY_KEY" | jq .
```
Expected: JSON with a `deployments` array containing an object with `deployment_uuid`. Record the uuid in the session but do **not** poll it.
---
### Task 6: Update `credentials.local.json`
**Files:**
- Modify: `credentials.local.json` (add third `users` entry)
Kept separate from the commit so no secret is ever staged, even accidentally.
- [ ] **Step 1: Add the new entry**
Use the Edit tool. Replace:
```
"volcanic-agents": {
"user": "volcanic-agents",
"password": "8f53ceb8b7bb13f3828d0b9c7cda77c1ba21d2a2031203712eecb3372cad2ae4",
"note": "wire-level user/password (NATS has no per-user token field); the password is an opaque random token stored in Coolify env NATS_VOLCANIC_AGENTS_TOKEN. Admin (unrestricted)."
}
}
}
```
with (substituting the real password read from `/tmp/nats-external-apps.pw`):
```
"volcanic-agents": {
"user": "volcanic-agents",
"password": "8f53ceb8b7bb13f3828d0b9c7cda77c1ba21d2a2031203712eecb3372cad2ae4",
"note": "wire-level user/password (NATS has no per-user token field); the password is an opaque random token stored in Coolify env NATS_VOLCANIC_AGENTS_TOKEN. Admin (unrestricted)."
},
"external-apps": {
"user": "external-apps",
"password": "<paste the value from /tmp/nats-external-apps.pw>",
"note": "user/password; unrestricted permissions. Intended for third-party / external application connections. Stored in Coolify env NATS_EXTERNAL_APPS_PASSWORD."
}
}
}
```
Read the real password with `cat /tmp/nats-external-apps.pw` and paste it in place of the placeholder before saving.
- [ ] **Step 2: Confirm the file is valid JSON**
```bash
jq '.users | keys' credentials.local.json
```
Expected: `["admin","external-apps","volcanic-agents"]`.
- [ ] **Step 3: Confirm the file is still gitignored**
```bash
git check-ignore -v credentials.local.json && echo IGNORED
```
Expected: prints the ignore rule and then `IGNORED`. If it does **not** print `IGNORED`, stop and fix `.gitignore` before doing anything else.
- [ ] **Step 4: Clean up the temp password file**
```bash
shred -u /tmp/nats-external-apps.pw 2>/dev/null || rm -f /tmp/nats-external-apps.pw
```
---
### Task 7: Verify auth works over WebSocket
The `nats` CLI is not installed on this workstation and `python3 -c "import nats"` fails. Use a one-shot Node script with `nats.ws` in a scratch directory.
Give the previous deploy time to roll — the compose parse step re-executes with the new env var, and until then the container is running the old config without `external-apps`.
- [ ] **Step 1: Wait for the new container to be healthy**
Poll the container status until it's `running:healthy` (not `restarting:unknown` or the old `running:healthy` from the previous config). This is not the same as polling the build; the app-status endpoint is cheap.
```bash
APP_UUID=$(jq -r '.app.uuid' deploy.json)
until curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/api/v1/applications/$APP_UUID" \
| jq -er '.status | test("running:healthy")' >/dev/null; do
sleep 10
done
echo "healthy"
```
Expected: eventually prints `healthy`. If it never does within a few minutes, fall back to the "Gotchas" workflow in `CLAUDE.md` (check `docker logs` on the Coolify host — the config parser will have logged the reason if it failed).
- [ ] **Step 2: Set up a scratch Node project and install `nats.ws`**
```bash
mkdir -p /tmp/nats-verify && cd /tmp/nats-verify
npm init -y >/dev/null
npm install nats --no-save --silent
```
Expected: `nats` installed under `/tmp/nats-verify/node_modules`. The Node `nats` package speaks WebSocket transports natively when passed a `wss://` URL.
- [ ] **Step 3: Write the verification script**
Create `/tmp/nats-verify/verify.mjs`:
```javascript
import { connect } from "nats";
const pw = process.env.NATS_EXTERNAL_APPS_PASSWORD;
if (!pw) { console.error("missing NATS_EXTERNAL_APPS_PASSWORD"); process.exit(2); }
const nc = await connect({
servers: "wss://nats.tes.gd",
user: "external-apps",
pass: pw,
});
const info = nc.info;
console.log("connected: server_id=" + info.server_id + " version=" + info.version);
const sub = nc.subscribe("verify.>");
nc.publish("verify.ping", new TextEncoder().encode("hello"));
for await (const m of sub) {
console.log("got: subject=" + m.subject + " data=" + new TextDecoder().decode(m.data));
break;
}
await nc.drain();
```
- [ ] **Step 4: Run the verification with the credential from Coolify**
Fetch the value straight from Coolify (source of truth) — do not rely on any local file:
```bash
APP_UUID=$(jq -r '.app.uuid' /app/data/home/nats_expert/deploy.json)
export NATS_EXTERNAL_APPS_PASSWORD=$(
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
| jq -r '.[] | select(.key=="NATS_EXTERNAL_APPS_PASSWORD") | .value'
)
[ -n "$NATS_EXTERNAL_APPS_PASSWORD" ] && echo "have credential" || { echo "no credential"; exit 1; }
node /tmp/nats-verify/verify.mjs
unset NATS_EXTERNAL_APPS_PASSWORD
```
Expected:
```
have credential
connected: server_id=… version=2.10.…
got: subject=verify.ping data=hello
```
If the connection fails with an authorization error, the compose parse substituted an empty string or the wrong value — re-run Task 1 step 5 and confirm the env var is present with a 64-char value, then re-trigger the deploy (Task 5).
- [ ] **Step 5: Cross-check that the OLD users still work**
We must not have accidentally rewritten the `admin` or `volcanic-agents` entries. Run the same script twice more, once per user, reading their values the same way:
```bash
for var in NATS_PASSWORD NATS_VOLCANIC_AGENTS_TOKEN; do
case $var in
NATS_PASSWORD) user=$(jq -r '.env.NATS_USER' /app/data/home/nats_expert/deploy.json) ;;
NATS_VOLCANIC_AGENTS_TOKEN) user=volcanic-agents ;;
esac
pw=$(curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/api/v1/applications/$APP_UUID/envs" \
| jq -r --arg k "$var" '.[] | select(.key==$k) | .value')
NATS_USER="$user" NATS_EXTERNAL_APPS_PASSWORD="$pw" node -e '
import("nats").then(async ({connect}) => {
const nc = await connect({servers:"wss://nats.tes.gd", user:process.env.NATS_USER, pass:process.env.NATS_EXTERNAL_APPS_PASSWORD});
console.log("ok: " + process.env.NATS_USER);
await nc.drain();
}).catch(e => { console.error("FAIL " + process.env.NATS_USER + ": " + e.message); process.exit(1); });
'
done
```
Expected:
```
ok: admin
ok: volcanic-agents
```
- [ ] **Step 6: Clean up the scratch dir**
```bash
rm -rf /tmp/nats-verify
```
---
## Done criteria
- The `nats` app on Coolify has env var `NATS_EXTERNAL_APPS_PASSWORD` set to a 64-hex-char value.
- The `main` branch has one new commit that adds the `external-apps` line to `docker-compose.yml` and updates `CLAUDE.md`.
- The new container is `running:healthy` on Coolify.
- Verification script connects as `external-apps` and round-trips a message.
- The pre-existing `admin` and `volcanic-agents` users still connect successfully.
- Local `credentials.local.json` contains the new entry and is still gitignored.
- No temp password file remains on disk.
@@ -0,0 +1,60 @@
# Design: NATS `external-apps` user
## Goal
Add a third NATS user, `external-apps`, so third-party / external applications can connect to `wss://nats.tes.gd` with their own credential instead of borrowing the `admin` or `volcanic-agents` account.
## Scope decisions
- **Permissions:** unrestricted. Same shape as the existing `admin` and `volcanic-agents` users — no `permissions` block, so full publish/subscribe on any subject, JetStream management included. Scoped permissions are explicitly out of scope for this change; if we later need to lock down external callers, that's a follow-up.
- **Credential style:** password (not opaque bearer token). Env var is `NATS_EXTERNAL_APPS_PASSWORD`, following the `NATS_PASSWORD` convention used by `admin` rather than the `..._TOKEN` convention used by `volcanic-agents`.
- **Wire protocol / transport:** unchanged. The user connects the same way as the others (WebSockets at `wss://nats.tes.gd`, standard user+password auth on the wire).
## Change surface
1. **`docker-compose.yml`** — inside `configs.nats-conf.content`, append one entry to `authorization.users`:
```
{ user: "external-apps", password: "${NATS_EXTERNAL_APPS_PASSWORD}" }
```
Substitution happens at Docker Compose parse time, so NATS sees a static config on disk.
2. **Coolify env var** — add `NATS_EXTERNAL_APPS_PASSWORD` on the `nats` application (project `Volcanic MFEs`, app UUID from local `deploy.json`). Value: a fresh random secret (e.g. `openssl rand -hex 32`).
3. **`credentials.local.json`** — add a third entry under `users`:
```json
"external-apps": { "user": "external-apps", "password": "…", "note": "…" }
```
Mirrors the Coolify env value. File is gitignored.
4. **`CLAUDE.md`** — update the two spots that enumerate users so the doc stays in sync:
- The "Connection details (for agents managing this NATS)" intro list.
- The "Auth" bullet under "What's inside the server".
Mention that `external-apps` is the intended account for third-party callers, unrestricted for now.
5. **Deploy** — commit the compose + doc changes on `main`, then fire-and-forget `POST /api/v1/deploy?uuid=<app-uuid>` on Coolify. Do not poll.
## Ordering constraint
Set the Coolify env var **before** pushing the compose change. Otherwise the compose parser substitutes an empty string, the config lands as `password: ""`, and until the next deploy anyone who connects with empty credentials for user `external-apps` is authenticated. Order:
1. PATCH the env var into Coolify.
2. Commit + push the compose + doc changes.
3. Trigger the deploy.
## Verification
After the deploy triggers, connect from a workstation with the `nats` CLI as the new user and confirm auth works:
```
nats --server=wss://nats.tes.gd \
--user=external-apps --password="$NATS_EXTERNAL_APPS_PASSWORD" \
server info
```
A `Server ID` response = success. An auth error = the env var didn't land or the substitution failed; check `docker logs` on the Coolify host per the existing "Gotchas" section of `CLAUDE.md`.
## Out of scope
- Scoped/restricted permissions for external callers (deliberate — noted above).
- Rotating the existing `admin` or `volcanic-agents` credentials.
- Adding a UI or self-service flow for provisioning further users.