From d27fd02866b36d96477b4b19afcdc6f7085c7323 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 26 Aug 2026 16:22:45 +0000 Subject: [PATCH] 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. --- CLAUDE.md | 5 +- docker-compose.yml | 3 +- .../2026-08-26-nats-external-apps-user.md | 456 ++++++++++++++++++ 3 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-26-nats-external-apps-user.md diff --git a/CLAUDE.md b/CLAUDE.md index 20754d0..b577d09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) -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. - `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 @@ -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`. - `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. -- **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 diff --git a/docker-compose.yml b/docker-compose.yml index a12d9ea..2fdcf5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,8 @@ configs: authorization { users = [ { 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}" } ] } diff --git a/docs/superpowers/plans/2026-08-26-nats-external-apps-user.md b/docs/superpowers/plans/2026-08-26-nats-external-apps-user.md new file mode 100644 index 0000000..251ca63 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-nats-external-apps-user.md @@ -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)="' +``` + +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.