Playbook: Auto-rotate DISCORD_BOT_TOKEN via Playwright (Discord Dev Portal UI)

RETIRED — 2026-08-07 (#5868)

DISCORD_BOT_TOKEN has been retired, not rotated. The morning-of rotation (#5775, closing out exposure incident #5282) lost the new token value to a $env: cross-tool-call scope bug (same class as the prior false-expiry incident) before it was durably written to SOPS — see #5868 for the full timeline. The old exposed token was already dead (403). Before requesting a third manual Discord reset, usage was re-audited and the credential was found to have no live consumers:

  • n8n (bms-4) discordBotApi credential (id mOjEebvriQM) — referenced by 0 of 70 workflows (46 active), confirmed via a workflow_entity query.
  • The Alertmanager → Discord-bot-poll bridge described below was replaced by a direct Alertmanager → n8n webhook → GitHub comment bridge (#1960); no live alert route depends on the bot token anymore.
  • The only remaining reference, scripts/discord-provisioning.py, is an on-demand manual tool, not a running service.

Decision (user-confirmed): retire the key instead of re-rotating it. DISCORD_BOT_TOKEN has been removed entirely from secrets/monitoring.env.sops (not left empty) and the DISCORD_BOT_TOKEN GH Secret was deleted. The orphaned n8n discordBotApi credential row is pending cleanup on bms-4 (SSH/Docker credential-store operation) — see #5868 for status. scripts/discord-provisioning.py is kept as-is; its bot-token REST path is simply unavailable until/unless someone re-provisions a fresh token in the future — an accepted tradeoff, not a bug.

This entire playbook below is now historical reference only. Do not follow it to “restore” DISCORD_BOT_TOKEN — if the bot-token REST path is needed again, treat it as bootstrapping a brand-new credential (new Discord application/bot or a fresh token on p24-infra-admin), not a rotation of a next_due schedule. There is no rotation schedule for this key anymore.

Service: Discord Developer Portal (https://discord.com/developers/applications) Secret: DISCORD_BOT_TOKEN Application: p24-infra-admin (App ID 1501737509896323072) SOPS file: secrets/monitoring.env.sops GH Secret: DISCORD_BOT_TOKEN in radieu/p24-infra Rotation frequency: 90 days (or immediately on suspected exposure) N/A — retired 2026-08-07, see banner above Last rotated: 2026-07-03 Next due: 2026-10-03 retired, not due

Canonical playbook for DISCORD_BOT_TOKEN. This supersedes the older discord-bot-token-rotation.md by adding the GH Secret rotation step, the /api/v10/applications/@me verification, n8n restart steps, and the mandatory browser-close security step. Structured to mirror telegram-bot-token-rotation.md for consistency.


Why a playbook (not a one-click API rotation)

DISCORD_BOT_TOKEN cannot be rotated via the Discord REST API — Discord exposes no endpoint to reset a bot token. The only reset path is the Developer Portal web UI (Bot → Reset Token). That UI flow is fully automatable with Playwright MCP tools, which is what Option A below does. Option B is the manual fallback for when the browser session is unavailable.


Trigger conditions

Rotate when any of these is true:

TriggerSource
Scheduled 90-day rotation reachedcredential-rotation.yml (Mon 06:00 UTC) opens a human-action issue when next_due passes
Token suspected exposed (chat leak, log leak, committed in plaintext)Follow docs/playbooks/static-api-key-incident-rotation.md first, then this playbook
Bot compromised / unexpected messages observed in DiscordImmediate rotation

What uses this credential

ConsumerHowEffect if missing
n8n workflows on bms-4HTTP Request / Discord Bot nodes posting to #p24-issues and #errors channelsBot messages fail; webhook fallback may still work
Monitoring alert automationDiscord bot posts structured alert embeds to alert channelsAlert embeds stop; only raw webhook messages remain

Note: Discord webhook URLs (P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL, etc.) are separate from the bot token. Webhooks do not require the bot token and are unaffected by this rotation. Only Bot-API consumers break when the token is reset.


Where stored

LocationKey nameHow to update
secrets/monitoring.env.sopsDISCORD_BOT_TOKENSOPS write pattern (below)
GH Secret (radieu/p24-infra)DISCORD_BOT_TOKENgh secret set DISCORD_BOT_TOKEN
vps-i1 deployed env/opt/p24-infra/monitoring/.envAuto-synced by secrets-sync.yml on merge to dev/main
n8n credential on bms-4Discord Bot API credentialUpdate via n8n UI after SOPS change (old token invalid instantly) — see n8n section below

Login credentials

  • URL: https://discord.com/developers/applications
  • Email: radieu@gmail.com
  • Password: stored in discord_radieu_password key in secrets/monitoring.env.sops
    • Safe extraction (never print the value): (sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Select-String "^discord_radieu_password=").ToString().Split('=',2)[1]

Automation status

Playwright-automatable — Discord Developer Portal bot-token reset is accessible via web UI. Resetting the token instantly invalidates the old token (see Regression risks). If the Chrome profile connected to Playwright MCP has an active Discord login, no human interaction is needed. If the session is expired, re-login may prompt 2FA/OTP — a Tier 3 blocker that requires human action (escalate, see below).

Dispatch weight: heavy — Playwright tasks require a browser binary (≥300 MB Chromium). All Discord bot rotation jobs must be dispatched with job_type: "heavy" so the queue router sends them to a server with Playwright installed (bms-4 or Windows dev).


Regression risks

  • Resetting the token invalidates the old token instantly. Any n8n workflow or container using the old credential breaks until the credential object is updated in the n8n UI and the deployed env is re-synced. Schedule rotation during low-traffic windows.
  • Expired browser session: if the Chrome profile is not logged in to Discord, Playwright Option A fails at the login gate (requires human 2FA/OTP). Fall back to Option B.

Rotation steps

Option A — Playwright agent (preferred)

You are rotating DISCORD_BOT_TOKEN for the Discord bot (p24-infra-admin) used by p24-infra automation.

IMPORTANT: Never display any secret value in your response. Reference key names only.
If a command output contains a secret value, summarise the result without quoting the value.

1. Read login credentials safely (never print values):
   $env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
   $env:DISCORD_PASS = (sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops |
       Select-String "^discord_radieu_password=").ToString().Split('=',2)[1].Trim()

2. Use Playwright MCP to navigate to https://discord.com/login
   - Email: radieu@gmail.com
   - Password: $env:DISCORD_PASS (pass as fill value, never log it)
   - If 2FA is prompted, check .env.local for DISCORD_TOTP_SECRET; if absent, abort and use Option B
   - Navigate to https://discord.com/developers/applications
   - Open the "p24-infra-admin" application (App ID 1501737509896323072)
   - Click Bot (left sidebar)
   - Click "Reset Token" -> confirm in the dialog
   - Copy the revealed new token into $env:NEW_TOKEN (never print)
   $env:DISCORD_PASS = ""

3. Update secrets/monitoring.env.sops (LF-only, WriteAllText pattern — see docs/playbooks/sops-windows-crlf.md):
   $plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
   $temp = "$PWD\secrets\monitoring-edit.env.sops"
   $updated = $plain -replace "^DISCORD_BOT_TOKEN=.*", "DISCORD_BOT_TOKEN=$env:NEW_TOKEN"
   [System.IO.File]::WriteAllText($temp, ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
   $enc = sops --encrypt --input-type dotenv --output-type dotenv $temp
   [System.IO.File]::WriteAllText("$PWD\secrets\monitoring.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
   # Canary — MANDATORY before git add:
   sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Out-Null
   if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit. See docs/playbooks/sops-windows-crlf.md" }
   [System.IO.File]::Delete($temp)

4. Update the GH Secret (CI/CD copy) — value piped, never echoed:
   $env:NEW_TOKEN | gh secret set DISCORD_BOT_TOKEN --repo radieu/p24-infra
   $env:NEW_TOKEN = ""; $env:SOPS_AGE_KEY_FILE = ""

5. Close the browser session (SECURITY — do not leave the Reset Token page open):
   # Playwright: close the Discord Developer Portal tab / browser context
   await page.close();

6. Commit and PR targeting main:
   git checkout -b fix/rotate-discord-bot-token origin/main
   git add secrets/monitoring.env.sops
   git commit -m "chore: rotate DISCORD_BOT_TOKEN (scheduled 90d rotation)"
   git push -u origin fix/rotate-discord-bot-token
   gh pr create --base main --title "chore: rotate DISCORD_BOT_TOKEN"
   gh pr merge --merge --delete-branch

7. Update the n8n Discord credential on bms-4 (old token now invalid):
   Navigate to https://n8n.bms-4.infra.zintegrowana.online/credentials
   -> Find the Discord Bot API credential -> Edit -> update the Bot Token field -> Save

8. Restart any n8n workers / containers still holding the old token (see "n8n / container restart" below).

9. Append a row to docs/secrets-rotation-log.md (see "Rotation log" below).

Browser-close is mandatory. Per the issue #1691 security note: leaving the Developer Portal open after a token reset is a risk — the reset-token page may still be visible or accessible in browser history. Always run await page.close(); (or navigate to about:blank) as the final Playwright action before reporting back.

Option B — Manual (fallback when browser session unavailable)

Step 1 — Log in to Discord Developer Portal
  URL: https://discord.com/developers/applications
  Email: radieu@gmail.com
  Password: see discord_radieu_password in secrets/monitoring.env.sops
  Complete 2FA if prompted

Step 2 — Reset bot token
  -> Select the "p24-infra-admin" application (App ID 1501737509896323072)
  -> Bot -> Reset Token -> confirm
  -> Copy the new token (shown once; long string containing two periods)

Step 3 — Store in .env.local then hand off to Claude:
  Add to d:\code_2026\p24-infra\.env.local:
    DISCORD_BOT_TOKEN_NEW=<value>
  (Use [System.IO.File]::WriteAllText to preserve LF; never display the value.)
  Tell Claude "Discord bot token updated in .env.local" — Claude handles SOPS + GH Secret + PR.

Step 4 — Close the Developer Portal tab once the value is captured (security).

Option C — bms-4 heavy worker (headless Playwright, autonomous)

Prerequisite check:

  • bms-4 has Playwright 1.40.0 at /usr/local/bin/playwright with Chromium binaries at ~/.cache/ms-playwright/chromium-1228/ — ready to use, no install needed.
  • The rotation script scripts/rotate/discord-bot-token.js must be present in the p24-infra repo clone on bms-4 at /opt/p24-infra/scripts/rotate/ (available after the PR that introduced it is merged to main).
  • discord_radieu_password must be available to the worker. It lives in secrets/monitoring.env.sops which bms-4 workers can decrypt via the age key at /home/claude-runner/.age/p24-infra-keys.txt.

Job dispatch:

POST https://p24-meta-dispatcher.radieu.workers.dev/queue-issue
{ "issue_number": <N>, "repo": "radieu/p24-infra", "job_type": "heavy" }

Playwright rotation issues MUST use job_type: "heavy" — the dispatcher routes them to bms-4 (the only server with Playwright + Chromium installed). Light workers lack the browser binary.

Worker instructions (include in GH issue body):

@ai-worker: PLAYWRIGHT_HEAVY rotation task.

1. Confirm Playwright is available:
   playwright --version  # expected: 1.40.0
   ls ~/.cache/ms-playwright/chromium-1228/  # must exist

2. Load discord password from SOPS:
   SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt
   export DISCORD_PASS=$(sops --decrypt --input-type dotenv --output-type dotenv \
     /opt/p24-infra/secrets/monitoring.env.sops | grep "^discord_radieu_password=" | cut -d= -f2-)

3. Run the rotation script:
   cd /opt/p24-infra
   TOKEN_OUT_FILE=/tmp/discord-token-$$ DISCORD_PASS="$DISCORD_PASS" \
     node scripts/rotate/discord-bot-token.js
   unset DISCORD_PASS

4. Update SOPS with new token (use python3 sops-edit pattern):
   NEW_TOKEN=$(cat /tmp/discord-token-$$); rm /tmp/discord-token-$$
   # Write Python edit script to /tmp/discord_update.py then run it
   # (see docs/playbooks/sops-edit-operations.md §Linux pattern)

5. Commit + PR + update rotation log.
   # Do NOT hardcode token value — read from SOPS in step 4 via python3

Self-healing note: If discord-bot-token.js breaks (Discord changes their portal UI), the script captures a debug screenshot to /tmp/discord-debug-*.png. SSH to bms-4, inspect the screenshots, then spawn a Playwright MCP session (Windows dev) to rebuild the selector. The script is designed to fail fast with screenshots rather than silently hang.


n8n Discord credential update

After rotation, the n8n Discord Bot API credential must be updated immediately — the old token is invalid from the moment it is reset.

n8n API key prerequisite

⚠️ Known issue (2026-07-03): BMS4_N8N_API_KEY in SOPS is an expired JWT session token (not a static API key). All /api/v1/ calls return HTTP 401. Fix before next rotation:

  1. Log in to n8n UI → Settings → n8n API → delete old key → create new key (starts with n8n_api_)
  2. Run /role-secret-manager → update BMS4_N8N_API_KEY in n8n-bms4.env.sops AND N8N_BMS4_API_KEY in monitoring.env.sops with the new static key
  3. Track in issue #2659

Update via n8n REST API (when API key is valid)

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$bms4 = sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4.env.sops
$env:N8N_KEY = ($bms4 | Where-Object { $_ -match "^BMS4_N8N_API_KEY=" }).Split("=",2)[1].Trim()
$env:N8N_HOST = ($bms4 | Where-Object { $_ -match "^BMS4_N8N_HOST=" }).Split("=",2)[1].Trim()
$mon = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
$env:DISCORD_TOKEN = ($mon | Where-Object { $_ -match "^DISCORD_BOT_TOKEN=" }).Split("=",2)[1].Trim()
$bms4 = $null; $mon = $null
 
# Find Discord credential
$creds = Invoke-RestMethod "$env:N8N_HOST/api/v1/credentials" `
    -Headers @{ "X-N8N-API-KEY" = $env:N8N_KEY } -Method GET
$discordId = ($creds.data | Where-Object { $_.type -like "*discord*" } | Select-Object -First 1).id
Write-Host "Discord cred id: $discordId"
 
# Update credential (PATCH)
$body = @{ data = @{ botToken = $env:DISCORD_TOKEN } } | ConvertTo-Json -Depth 3
Invoke-RestMethod "$env:N8N_HOST/api/v1/credentials/$discordId" `
    -Headers @{ "X-N8N-API-KEY" = $env:N8N_KEY; "Content-Type" = "application/json" } `
    -Method PATCH -Body $body
$env:N8N_KEY = ""; $env:DISCORD_TOKEN = ""

Update via n8n UI (manual fallback)

1. Navigate to https://n8n.bms-4.infra.zintegrowana.online
2. Log in (admin credentials)
3. Left sidebar → Credentials
4. Find "Discord Bot API" credential → Edit
5. Update the Bot Token field with the new value from secrets/monitoring.env.sops
6. Save
7. Restart n8n workers (see container restart below)

n8n / container restart

After the new token is live in SOPS + the deployed env, restart consumers holding the old token:

# n8n workers on bms-4 (queue mode — restart all workers + main):
ssh root@54.36.123.110 "cd /opt/p24-infra/bms-4 && docker compose restart bms-4-n8n-1 bms-4-n8n-worker-1-1 bms-4-n8n-worker-2-1 bms-4-n8n-worker-3-1"
 
# Any monitoring container on vps-i1 that reads DISCORD_BOT_TOKEN (only after secrets-sync ran):
ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose up -d --no-deps <service>"

Verification

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$line = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops |
    Select-String "^DISCORD_BOT_TOKEN="
$val = $line.ToString().Split('=',2)[1]
Write-Host "DISCORD_BOT_TOKEN length: $($val.Length)"   # expected: > 50, never print the value
 
# Quick API check — confirms the bot application identity, sends no messages:
$env:T = $val
$r = Invoke-RestMethod "https://discord.com/api/v10/applications/@me" `
    -Headers @{ Authorization = "Bot $env:T" }
Write-Host "Discord application: $($r.name) (id $($r.id))"   # expected: p24-infra-admin / 1501737509896323072
$env:T = ""; $env:SOPS_AGE_KEY_FILE = ""

A 200 with the expected application name confirms the new token is valid. A 401 Unauthorized means the token is wrong or was not saved correctly — re-check the SOPS write and canary.


Rotation log

Append a row to docs/secrets-rotation-log.md (newest first) once the rotation is executed:

| <YYYY-MM-DD> | DISCORD_BOT_TOKEN | scheduled 90d rotation (or: incident #NNNN) | claude (Playwright) | SOPS monitoring.env.sops + GH Secret (this PR) |

Also bump Last rotated / Next due at the top of this playbook.


Escalation

SymptomAction
Discord login requires 2FA code and .env.local has no DISCORD_TOTP_SECRETCreate a human-action GH issue in radieu/p24-infra (title: 🔴 [Discord] DISCORD_BOT_TOKEN rotation blocked — Dev Portal session expired, manual 2FA required); user runs Option B and provides OTP
Developer Portal shows no applicationsConfirm you are logged in as radieu@gmail.com, not a different account
”Reset Token” button is greyed outBot may require privileged intents to be confirmed — check Bot settings
/api/v10/applications/@me returns 401 after mergeToken not saved correctly — re-run SOPS write + canary, re-verify
vps-i1 / n8n automation still fails after mergeCheck secrets-sync.yml ran; update the n8n Discord credential; restart affected containers (above)
n8n REST API returns 401 when updating Discord credentialBMS4_N8N_API_KEY is likely an expired JWT — see issue #2659 and the “n8n API key prerequisite” section above
Playwright script discord-bot-token.js fails with no screenshotsCheck if script exists in /opt/p24-infra/scripts/rotate/ — PR with script must be merged to main first

On any unrecoverable failure during an automated run, also send a Discord alert via P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (color red) and open a GH issue — per the Error Notification Standard in CLAUDE.md.


Prevention

  • Discord bot tokens do not expire but can be compromised; rotate on the 90-day schedule.
  • The credential-rotation.yml GH Actions workflow (Monday 06:00 UTC) checks next_due and opens a human-action issue when due.
  • Keep the Discord login session active in the Playwright Chrome profile to enable Option A.
  • After rotation, update Last rotated and Next due at the top of this playbook.

References


Audit Log — Log to infra_operations

After this operation completes, log it to the infra_operations audit table.

Python (Linux server — bms-4, vps-i1, vps-h1, or similar):

import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
 
log_op(
    actor="claude",  # "radieu" for manual human ops, "claude" for agent
    op_type="credential_rotation",
    resource="DISCORD_BOT_TOKEN",
    result="success",  # "success" | "failed" | "skipped"
    detail="Scheduled rotation — Discord bot token regenerated via Discord Developer Portal and SOPS updated",
    env="bms-4",
    gh_issue=2730,
)

PowerShell (Windows dev machine):

$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'DISCORD_BOT_TOKEN', 'success', 'Scheduled rotation — Discord bot token regenerated via Discord Developer Portal and SOPS updated', 'bms-4')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''