Files
NATS_Expert/docs/superpowers/plans/2026-08-26-nats-external-apps-user.md
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

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_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
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 (inside configs.nats-conf.content, the authorization.users array)

  • 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 (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
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
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 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.