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.
17 KiB
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_URLandCOOLIFY_KEYenv vars are set in the shell.- Working directory is the repo root (
/app/data/home/nats_expert). - Local
deploy.jsonis present (already confirmed).
Task 1: Generate password and set the Coolify env var
Files:
- Read:
deploy.json(forapp.uuid) - External: Coolify env vars on the
natsapplication
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
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
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)
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
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
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.
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(insideconfigs.nats-conf.content, theauthorization.usersarray) -
Step 1: Read the current file to confirm the anchor line
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
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
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
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
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
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
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(forapp.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
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 thirdusersentry)
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
jq '.users | keys' credentials.local.json
Expected: ["admin","external-apps","volcanic-agents"].
- Step 3: Confirm the file is still gitignored
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
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.
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
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:
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:
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:
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
rm -rf /tmp/nats-verify
Done criteria
- The
natsapp on Coolify has env varNATS_EXTERNAL_APPS_PASSWORDset to a 64-hex-char value. - The
mainbranch has one new commit that adds theexternal-appsline todocker-compose.ymland updatesCLAUDE.md. - The new container is
running:healthyon Coolify. - Verification script connects as
external-appsand round-trips a message. - The pre-existing
adminandvolcanic-agentsusers still connect successfully. - Local
credentials.local.jsoncontains the new entry and is still gitignored. - No temp password file remains on disk.