# 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)="' ``` Expected: `NATS_EXTERNAL_APPS_PASSWORD=`. 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": "", "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.