From c2a0cc7c217337961324fa4d269b062625533881 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:03:54 +0000 Subject: [PATCH 1/6] fix: require dedicated Discord gateway secret outside local dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop Discord gateway↔API auth from falling back to ENCRYPTION_KEY in production-like environments, wire a dedicated secret into deploy templates/installer, and cover the new contract with tests. --- .agents/skills/mock-discord-testing/SKILL.md | 4 +- .env.production.example | 4 + .../handlers/discord/__tests__/auth.test.ts | 115 ++++++++++++++++++ apps/api/src/handlers/discord/auth.ts | 45 ++++++- apps/discord-gateway/src/config.test.ts | 25 +++- apps/discord-gateway/src/config.ts | 34 ++++-- apps/discord-gateway/src/service.ts | 2 +- apps/docs/environment-variables.mdx | 2 +- .../docs/providers/communications/discord.mdx | 9 +- deploy/README.md | 2 + deploy/ci/validate-deployment-artifacts.mjs | 33 +++++ deploy/coolify/docker-compose.yaml | 2 + deploy/fly/README.md | 4 +- deploy/install.sh | 6 + deploy/railway/README.md | 2 + deploy/railway/template.yaml | 4 + .../evals/run-discord-scenario.ts | 7 +- .../communication/scripts/run-mock-discord.ts | 8 +- render.yaml | 4 + 19 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/handlers/discord/__tests__/auth.test.ts diff --git a/.agents/skills/mock-discord-testing/SKILL.md b/.agents/skills/mock-discord-testing/SKILL.md index d7276065..5c26260c 100644 --- a/.agents/skills/mock-discord-testing/SKILL.md +++ b/.agents/skills/mock-discord-testing/SKILL.md @@ -22,7 +22,7 @@ Discord continuity is inferred from **task thread → active cloud job**: root-c | Mock state endpoint | `http://127.0.0.1:3014/mock/state` | | Mock event injection endpoint | `http://127.0.0.1:3014/mock/events` | | Example scenario | `packages/communication/scripts/mock-discord.example.json` | -| Gateway secret source | `R_DISCORD_GATEWAY_SECRET`, falling back to `ENCRYPTION_KEY` | +| Gateway secret source | `R_DISCORD_GATEWAY_SECRET` (local-dev-only fallback: `ENCRYPTION_KEY`) | | Mock bot identity | `RoomoteBot` (id `100000000000000001`) | | Mock application id | `200000000000000001` | | Mock guild id | `300000000000000001` | @@ -57,7 +57,7 @@ The API resolves Discord credentials from real env vars first (`resolveDiscordRu ```bash R_DISCORD_BOT_TOKEN=mock-discord-token # must match the harness botToken (default: mock-discord-token) DISCORD_API_BASE_URL=http://127.0.0.1:3014/api/v10 # reroutes ALL outbound Discord REST calls to the harness -# R_DISCORD_GATEWAY_SECRET is optional; the events endpoint falls back to ENCRYPTION_KEY +# Prefer R_DISCORD_GATEWAY_SECRET. Local-dev only may fall back to ENCRYPTION_KEY. ``` The harness reads the same gateway secret via dotenvx, so the `x-roomote-discord-gateway-secret` header matches automatically. Without any secret configured the events endpoint returns 503; with a mismatched secret it returns 401. diff --git a/.env.production.example b/.env.production.example index 69761d12..0c889b07 100644 --- a/.env.production.example +++ b/.env.production.example @@ -42,6 +42,10 @@ PREVIEW_AUTH_PUBLIC_KEY= # R_AUTO_GENERATE_KEYS=true # Random single-line secrets generated by the operator. ENCRYPTION_KEY= +# Shared internal secret for Discord-gateway→API event delivery (API + BullMQ). +# Distinct from ENCRYPTION_KEY. Required outside local development when Discord +# is enabled. The installer and Railway/Render/Coolify templates generate it. +# R_DISCORD_GATEWAY_SECRET= ARTIFACT_SIGNING_KEY= DASHBOARD_PASSWORD= # Optional dedicated secret for signing sign-in session cookies (>=32 chars). diff --git a/apps/api/src/handlers/discord/__tests__/auth.test.ts b/apps/api/src/handlers/discord/__tests__/auth.test.ts new file mode 100644 index 00000000..1fd5c18d --- /dev/null +++ b/apps/api/src/handlers/discord/__tests__/auth.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const envMock = vi.hoisted(() => ({ + APP_ENV: 'production' as string | undefined, + NODE_ENV: 'test' as string | undefined, + ENCRYPTION_KEY: 'production-encryption-key-that-is-long-enough', + R_DISCORD_GATEWAY_SECRET: undefined as string | undefined, +})); + +vi.mock('@roomote/env', () => ({ + Env: envMock, +})); + +import { verifyDiscordGatewaySecret } from '../auth'; + +describe('verifyDiscordGatewaySecret', () => { + const previousEnv = { + R_DISCORD_GATEWAY_SECRET: process.env.R_DISCORD_GATEWAY_SECRET, + R_APP_ENV: process.env.R_APP_ENV, + APP_ENV: process.env.APP_ENV, + NODE_ENV: process.env.NODE_ENV, + ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, + }; + + beforeEach(() => { + envMock.APP_ENV = 'production'; + envMock.NODE_ENV = 'test'; + envMock.ENCRYPTION_KEY = 'production-encryption-key-that-is-long-enough'; + envMock.R_DISCORD_GATEWAY_SECRET = undefined; + delete process.env.R_DISCORD_GATEWAY_SECRET; + delete process.env.R_APP_ENV; + delete process.env.APP_ENV; + delete process.env.ENCRYPTION_KEY; + process.env.NODE_ENV = 'test'; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('accepts the dedicated gateway secret', () => { + process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; + + expect(verifyDiscordGatewaySecret('gateway-secret')).toBeNull(); + }); + + it('rejects a wrong dedicated gateway secret', () => { + process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; + + expect(verifyDiscordGatewaySecret('wrong-secret')).toEqual({ + error: 'discord_gateway_unauthorized', + status: 401, + }); + }); + + it('does not accept ENCRYPTION_KEY in production-like config', () => { + envMock.APP_ENV = 'production'; + envMock.ENCRYPTION_KEY = 'production-encryption-key-that-is-long-enough'; + process.env.R_APP_ENV = 'production'; + + expect( + verifyDiscordGatewaySecret( + 'production-encryption-key-that-is-long-enough', + ), + ).toEqual({ + error: 'discord_gateway_secret_not_configured', + status: 503, + }); + }); + + it('does not accept ENCRYPTION_KEY in preview', () => { + envMock.APP_ENV = 'preview'; + process.env.R_APP_ENV = 'preview'; + + expect( + verifyDiscordGatewaySecret( + 'production-encryption-key-that-is-long-enough', + ), + ).toEqual({ + error: 'discord_gateway_secret_not_configured', + status: 503, + }); + }); + + it('falls back to ENCRYPTION_KEY only in local development', () => { + envMock.APP_ENV = 'development'; + envMock.ENCRYPTION_KEY = 'local-encryption-key-that-is-long-enough'; + process.env.R_APP_ENV = 'development'; + + expect( + verifyDiscordGatewaySecret('local-encryption-key-that-is-long-enough'), + ).toBeNull(); + }); + + it('prefers the dedicated secret over ENCRYPTION_KEY in development', () => { + envMock.APP_ENV = 'development'; + envMock.ENCRYPTION_KEY = 'local-encryption-key-that-is-long-enough'; + process.env.R_APP_ENV = 'development'; + process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; + + expect(verifyDiscordGatewaySecret('gateway-secret')).toBeNull(); + expect( + verifyDiscordGatewaySecret('local-encryption-key-that-is-long-enough'), + ).toEqual({ + error: 'discord_gateway_unauthorized', + status: 401, + }); + }); +}); diff --git a/apps/api/src/handlers/discord/auth.ts b/apps/api/src/handlers/discord/auth.ts index bdbfef1f..8d98fd3f 100644 --- a/apps/api/src/handlers/discord/auth.ts +++ b/apps/api/src/handlers/discord/auth.ts @@ -12,10 +12,44 @@ type DiscordGatewayAuthError = { status: 503 | 401; }; -function configuredGatewaySecret(): string | null { - const value = - process.env.R_DISCORD_GATEWAY_SECRET?.trim() || Env.ENCRYPTION_KEY?.trim(); - return value || null; +/** + * ENCRYPTION_KEY fallback is local-dev only. Production and preview must set a + * dedicated `R_DISCORD_GATEWAY_SECRET` so a leaked transport header cannot + * unlock the vault that encrypts stored secrets. + */ +function allowsEncryptionKeyFallback( + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + const appEnv = ( + processEnv.R_APP_ENV || + processEnv.APP_ENV || + Env.APP_ENV + )?.trim(); + if (appEnv === 'development') { + return true; + } + if (appEnv === 'production' || appEnv === 'preview') { + return false; + } + const nodeEnv = (processEnv.NODE_ENV || Env.NODE_ENV)?.trim(); + return nodeEnv === 'development'; +} + +function configuredGatewaySecret( + processEnv: NodeJS.ProcessEnv = process.env, +): string | null { + const dedicated = processEnv.R_DISCORD_GATEWAY_SECRET?.trim(); + if (dedicated) { + return dedicated; + } + + if (!allowsEncryptionKeyFallback(processEnv)) { + return null; + } + + return ( + Env.ENCRYPTION_KEY?.trim() || processEnv.ENCRYPTION_KEY?.trim() || null + ); } function secretsMatch(expected: string, received: string): boolean { @@ -29,8 +63,9 @@ function secretsMatch(expected: string, received: string): boolean { export function verifyDiscordGatewaySecret( received: string | undefined, + processEnv: NodeJS.ProcessEnv = process.env, ): DiscordGatewayAuthError | null { - const expected = configuredGatewaySecret(); + const expected = configuredGatewaySecret(processEnv); if (!expected) { return { error: 'discord_gateway_secret_not_configured', diff --git a/apps/discord-gateway/src/config.test.ts b/apps/discord-gateway/src/config.test.ts index 4f1d1ecd..cac2a9af 100644 --- a/apps/discord-gateway/src/config.test.ts +++ b/apps/discord-gateway/src/config.test.ts @@ -17,12 +17,33 @@ describe('resolveDiscordGatewayConfig', () => { }); }); - it('uses the shared encryption key for rollout compatibility', () => { + it('uses ENCRYPTION_KEY only as a local-development fallback', () => { expect( - resolveDiscordGatewayConfig({ ENCRYPTION_KEY: 'shared-key' }).apiSecret, + resolveDiscordGatewayConfig({ + ENCRYPTION_KEY: 'shared-key', + R_APP_ENV: 'development', + }).apiSecret, ).toBe('shared-key'); }); + it('does not accept ENCRYPTION_KEY under production-like config', () => { + expect( + resolveDiscordGatewayConfig({ + ENCRYPTION_KEY: 'shared-key', + R_APP_ENV: 'production', + }).apiSecret, + ).toBeNull(); + }); + + it('does not accept ENCRYPTION_KEY under preview config', () => { + expect( + resolveDiscordGatewayConfig({ + ENCRYPTION_KEY: 'shared-key', + R_APP_ENV: 'preview', + }).apiSecret, + ).toBeNull(); + }); + it('supports bounded delivery and login retry tuning', () => { expect( resolveDiscordGatewayConfig({ diff --git a/apps/discord-gateway/src/config.ts b/apps/discord-gateway/src/config.ts index 5e07ae17..25f1aea2 100644 --- a/apps/discord-gateway/src/config.ts +++ b/apps/discord-gateway/src/config.ts @@ -36,6 +36,32 @@ export type DiscordGatewayConfig = { loginRetryMaxMs: number; }; +/** + * ENCRYPTION_KEY fallback is local-dev only. Production and preview must set a + * dedicated `R_DISCORD_GATEWAY_SECRET` shared by API and BullMQ/gateway. + */ +function allowsEncryptionKeyFallback(env: NodeJS.ProcessEnv): boolean { + const appEnv = (env.R_APP_ENV || env.APP_ENV)?.trim(); + if (appEnv === 'development') { + return true; + } + if (appEnv === 'production' || appEnv === 'preview') { + return false; + } + return env.NODE_ENV?.trim() === 'development'; +} + +function resolveApiSecret(env: NodeJS.ProcessEnv): string | null { + const dedicated = env.R_DISCORD_GATEWAY_SECRET?.trim(); + if (dedicated) { + return dedicated; + } + if (!allowsEncryptionKeyFallback(env)) { + return null; + } + return env.ENCRYPTION_KEY?.trim() || null; +} + export function resolveDiscordGatewayConfig( env: NodeJS.ProcessEnv = process.env, ): DiscordGatewayConfig { @@ -44,13 +70,7 @@ export function resolveDiscordGatewayConfig( return { port: positiveInteger(env.PORT, DEFAULT_PORT), apiEventsUrl: `${withoutTrailingSlashes(apiBaseUrl)}/api/internal/discord/events`, - // ENCRYPTION_KEY is already shared between API/control-plane processes. - // A dedicated secret is preferred, while this fallback keeps existing - // self-hosted and local deployments bootable during the rollout. - apiSecret: - env.R_DISCORD_GATEWAY_SECRET?.trim() || - env.ENCRYPTION_KEY?.trim() || - null, + apiSecret: resolveApiSecret(env), apiTimeoutMs: positiveInteger( env.DISCORD_GATEWAY_API_TIMEOUT_MS, DEFAULT_API_TIMEOUT_MS, diff --git a/apps/discord-gateway/src/service.ts b/apps/discord-gateway/src/service.ts index 3a11e7fd..df981468 100644 --- a/apps/discord-gateway/src/service.ts +++ b/apps/discord-gateway/src/service.ts @@ -179,7 +179,7 @@ export class DiscordGatewayService { phase: 'error', forwardingReady: false, lastError: - 'R_DISCORD_GATEWAY_SECRET or ENCRYPTION_KEY is required to forward Discord events', + 'R_DISCORD_GATEWAY_SECRET is required to forward Discord events', }); } diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index b4b19ee9..83293f75 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -297,7 +297,7 @@ as per-task auth tokens or workspace paths. | `R_TELEGRAM_BOT_TOKEN` | Telegram | Telegram bot token. | | `R_TELEGRAM_WEBHOOK_SECRET` | Telegram | Optional. Telegram webhook secret; generated automatically when unset in the UI. | | `R_DISCORD_BOT_TOKEN` | Discord | Discord bot token. The bot and application identity are read from this token. | -| `R_DISCORD_GATEWAY_SECRET` | Optional | Advanced shared internal secret for Discord delivery between BullMQ and API. | +| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Required outside local development when Discord is enabled. Keep this distinct from `ENCRYPTION_KEY`. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | | `R_SLACK_CLIENT_ID` | Slack sign-in | Slack sign-in client ID. `R_SLACK_CLIENT_ID` is also accepted. | | `R_SLACK_CLIENT_SECRET` | Slack sign-in | Slack sign-in client secret. `R_SLACK_CLIENT_SECRET` is also accepted. | diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 1744ddfe..c177edfb 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -123,15 +123,18 @@ environment variable instead, set: ```sh R_DISCORD_BOT_TOKEN= +R_DISCORD_GATEWAY_SECRET= ``` The Discord Gateway runs as an opt-in subsystem inside Roomote's existing BullMQ process, so enabling Discord does not add another container, process, or port. It stays dormant until a token is saved, uses Redis to elect a single active replica, and disconnects if Discord is removed. Durable event delivery -uses Redis and the deployment encryption key by default. Operators that want a -dedicated internal secret can set the same `R_DISCORD_GATEWAY_SECRET` value on -the API and BullMQ processes. +uses Redis and a dedicated internal shared secret. Set the same +`R_DISCORD_GATEWAY_SECRET` value on the API and BullMQ processes (installers +and platform templates generate this for you). Keep it separate from +`ENCRYPTION_KEY` so a leaked transport secret cannot unlock secrets sealed at +rest. Gateway session state is stored in the Roomote database. On a service restart or leader handoff, Roomote asks Discord to resume the previous session and diff --git a/deploy/README.md b/deploy/README.md index e03167d7..173583d1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -115,6 +115,7 @@ The file must include the required production values from - `PREVIEW_AUTH_PRIVATE_KEY` - `PREVIEW_AUTH_PUBLIC_KEY` - `ENCRYPTION_KEY` +- `R_DISCORD_GATEWAY_SECRET` (required outside local development when Discord is enabled) - `ARTIFACT_SIGNING_KEY` - `DASHBOARD_PASSWORD` - `R_GITHUB_APP_SLUG` @@ -178,6 +179,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (distinct from ENCRYPTION_KEY) openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD ``` diff --git a/deploy/ci/validate-deployment-artifacts.mjs b/deploy/ci/validate-deployment-artifacts.mjs index f07ea01b..85f6e5f0 100644 --- a/deploy/ci/validate-deployment-artifacts.mjs +++ b/deploy/ci/validate-deployment-artifacts.mjs @@ -57,6 +57,7 @@ const composeEnv = { JOB_AUTH_PUBLIC_KEY: 'deployment-ci-job-public-key', R_GITHUB_APP_SLUG: 'deployment-ci', R_DISCORD_BOT_TOKEN: 'deployment-ci-discord-bot-token', + R_DISCORD_GATEWAY_SECRET: 'deployment-ci-discord-gateway-secret', PREVIEW_AUTH_PRIVATE_KEY: 'deployment-ci-preview-private-key', PREVIEW_AUTH_PUBLIC_KEY: 'deployment-ci-preview-public-key', REDIS_URL: 'redis://redis:6379', @@ -136,6 +137,18 @@ function validateComposeShape(shape) { composeEnv.R_DISCORD_BOT_TOKEN, `${shape.name}: bullmq must receive R_DISCORD_BOT_TOKEN`, ); + // Coolify uses platform magic vars that compose does not interpolate here. + if ( + !shape.coolify && + 'R_DISCORD_GATEWAY_SECRET' in + (config.services.bullmq.environment ?? {}) + ) { + assert( + config.services.bullmq.environment?.R_DISCORD_GATEWAY_SECRET === + composeEnv.R_DISCORD_GATEWAY_SECRET, + `${shape.name}: bullmq must receive R_DISCORD_GATEWAY_SECRET`, + ); + } } console.log(`validated compose shape: ${shape.name}`); @@ -171,6 +184,14 @@ assert( 'R_DISCORD_BOT_TOKEN' in railway.services.bullmq.env, 'railway: bullmq must receive R_DISCORD_BOT_TOKEN', ); +assert( + 'R_DISCORD_GATEWAY_SECRET' in railway.services.api.env, + 'railway: api must define R_DISCORD_GATEWAY_SECRET', +); +assert( + 'R_DISCORD_GATEWAY_SECRET' in railway.services.bullmq.env, + 'railway: bullmq must receive R_DISCORD_GATEWAY_SECRET', +); const render = YAML.parse(read('render.yaml')); const renderServices = new Map( @@ -208,11 +229,23 @@ assert( ), 'render: shared app environment must include R_DISCORD_BOT_TOKEN', ); +assert( + renderSharedEnvironment?.envVars?.some( + (entry) => entry.key === 'R_DISCORD_GATEWAY_SECRET', + ), + 'render: shared app environment must include R_DISCORD_GATEWAY_SECRET', +); const productionCompose = YAML.parse( read('deploy/compose/docker-compose.prod.yml'), ); const coolify = YAML.parse(read('deploy/coolify/docker-compose.yaml')); +assert( + read('deploy/coolify/docker-compose.yaml').includes( + 'R_DISCORD_GATEWAY_SECRET', + ), + 'coolify: shared env must define R_DISCORD_GATEWAY_SECRET', +); for (const [name, contract] of Object.entries(catalog.runtimeServices)) { const service = coolify.services?.[name]; assert(service, `coolify: missing ${name}`); diff --git a/deploy/coolify/docker-compose.yaml b/deploy/coolify/docker-compose.yaml index 92925a38..f48eb2ea 100644 --- a/deploy/coolify/docker-compose.yaml +++ b/deploy/coolify/docker-compose.yaml @@ -48,6 +48,8 @@ x-roomote-shared-env: &roomote-shared-env ROOMOTE_DOCKER_LOAD_ENV_FILE: 'false' R_AUTO_GENERATE_KEYS: 'true' ENCRYPTION_KEY: ${SERVICE_BASE64_64_ENCRYPTIONKEY} + # Distinct from ENCRYPTION_KEY; shared by API and BullMQ Discord Gateway. + R_DISCORD_GATEWAY_SECRET: ${SERVICE_PASSWORD_DISCORDGATEWAY} ARTIFACT_SIGNING_KEY: ${SERVICE_BASE64_64_ARTIFACTSIGNING} DASHBOARD_PASSWORD: ${SERVICE_PASSWORD_DASHBOARD} SETUP_TOKEN: ${SERVICE_PASSWORD_SETUPTOKEN} diff --git a/deploy/fly/README.md b/deploy/fly/README.md index ebe5ed26..2cef4cac 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -111,7 +111,8 @@ fly secrets set --app "$APP" \ ENCRYPTION_KEY="$(openssl rand -base64 32)" \ ARTIFACT_SIGNING_KEY="$(openssl rand -base64 32)" \ DASHBOARD_PASSWORD="$(openssl rand -base64 24)" \ - SETUP_TOKEN="$(openssl rand -hex 16)" + SETUP_TOKEN="$(openssl rand -hex 16)" \ + R_DISCORD_GATEWAY_SECRET="$(openssl rand -base64 32)" # One Machine per process group (web, api, controller, and bullmq). The # db-migrate release command runs schema migrations first. @@ -157,6 +158,7 @@ Everything sensitive is a Fly secret, set once for the app: | `REDIS_URL` | printed by `fly redis create` | | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | printed by `fly storage create` | | `ENCRYPTION_KEY`, `ARTIFACT_SIGNING_KEY` | `openssl rand -base64 32` | +| `R_DISCORD_GATEWAY_SECRET` | `openssl rand -base64 32` (API + BullMQ Discord Gateway) | | `DASHBOARD_PASSWORD` | `openssl rand -base64 24` | | `SETUP_TOKEN` | `openssl rand -hex 16` (gates `/setup`) | diff --git a/deploy/install.sh b/deploy/install.sh index 0d9abc03..e2c783ee 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -496,6 +496,12 @@ if ! env_has_value SETUP_TOKEN; then set_env_value SETUP_TOKEN "$(openssl rand -hex 16)" fi +# Dedicated Discord gateway↔API transport secret. Always generate one so +# enabling Discord later does not fall back to ENCRYPTION_KEY. +if ! env_has_value R_DISCORD_GATEWAY_SECRET; then + set_env_value R_DISCORD_GATEWAY_SECRET "$(openssl rand -base64 32 | tr -d '\n')" +fi + worker_image="$image_registry/$image_namespace/roomote-worker:$roomote_version" # The published worker image doubles as the Modal worker base image. Keep diff --git a/deploy/railway/README.md b/deploy/railway/README.md index fc6ac680..6b4e1f52 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -146,6 +146,7 @@ R_APP_ENV=production ROOMOTE_DOCKER_LOAD_ENV_FILE=false R_AUTO_GENERATE_KEYS=true ENCRYPTION_KEY=${{secret(32)}} +R_DISCORD_GATEWAY_SECRET=${{secret(32)}} ARTIFACT_SIGNING_KEY=${{secret(32)}} DASHBOARD_PASSWORD=${{secret(24)}} S3_SECRET_ACCESS_KEY=${{secret(32)}} @@ -175,6 +176,7 @@ R_APP_ENV=${{api.R_APP_ENV}} ROOMOTE_DOCKER_LOAD_ENV_FILE=${{api.ROOMOTE_DOCKER_LOAD_ENV_FILE}} R_AUTO_GENERATE_KEYS=${{api.R_AUTO_GENERATE_KEYS}} ENCRYPTION_KEY=${{api.ENCRYPTION_KEY}} +R_DISCORD_GATEWAY_SECRET=${{api.R_DISCORD_GATEWAY_SECRET}} ARTIFACT_SIGNING_KEY=${{api.ARTIFACT_SIGNING_KEY}} DASHBOARD_PASSWORD=${{api.DASHBOARD_PASSWORD}} S3_SECRET_ACCESS_KEY=${{api.S3_SECRET_ACCESS_KEY}} diff --git a/deploy/railway/template.yaml b/deploy/railway/template.yaml index afd24907..3c8f758a 100644 --- a/deploy/railway/template.yaml +++ b/deploy/railway/template.yaml @@ -130,6 +130,9 @@ services: ROOMOTE_DOCKER_LOAD_ENV_FILE: 'false' R_AUTO_GENERATE_KEYS: 'true' ENCRYPTION_KEY: ${{secret(32)}} + # Transport secret between BullMQ Discord Gateway and API. Distinct from + # ENCRYPTION_KEY so a leaked gateway header cannot unlock the vault. + R_DISCORD_GATEWAY_SECRET: ${{secret(32)}} R_DISCORD_BOT_TOKEN: '' # optional; Discord can also be configured after boot in Settings ARTIFACT_SIGNING_KEY: ${{secret(32)}} DASHBOARD_PASSWORD: ${{secret(24)}} @@ -184,6 +187,7 @@ services: ROOMOTE_DOCKER_LOAD_ENV_FILE: ${{api.ROOMOTE_DOCKER_LOAD_ENV_FILE}} R_AUTO_GENERATE_KEYS: ${{api.R_AUTO_GENERATE_KEYS}} ENCRYPTION_KEY: ${{api.ENCRYPTION_KEY}} + R_DISCORD_GATEWAY_SECRET: ${{api.R_DISCORD_GATEWAY_SECRET}} R_DISCORD_BOT_TOKEN: ${{api.R_DISCORD_BOT_TOKEN}} ARTIFACT_SIGNING_KEY: ${{api.ARTIFACT_SIGNING_KEY}} DASHBOARD_PASSWORD: ${{api.DASHBOARD_PASSWORD}} diff --git a/packages/communication/evals/run-discord-scenario.ts b/packages/communication/evals/run-discord-scenario.ts index 2086eac3..5156f079 100644 --- a/packages/communication/evals/run-discord-scenario.ts +++ b/packages/communication/evals/run-discord-scenario.ts @@ -119,9 +119,14 @@ function substitute(value: unknown): unknown { return value; } +const appEnv = ( + process.env.R_APP_ENV || + process.env.APP_ENV || + process.env.NODE_ENV +)?.trim(); const gatewaySecret = process.env.R_DISCORD_GATEWAY_SECRET?.trim() || - process.env.ENCRYPTION_KEY?.trim() || + (appEnv === 'development' ? process.env.ENCRYPTION_KEY?.trim() : undefined) || ''; const server = new MockDiscordServer({ diff --git a/packages/communication/scripts/run-mock-discord.ts b/packages/communication/scripts/run-mock-discord.ts index 3021a91e..8de68fee 100644 --- a/packages/communication/scripts/run-mock-discord.ts +++ b/packages/communication/scripts/run-mock-discord.ts @@ -78,11 +78,15 @@ function resolveRoomoteTarget( ? 'config' : process.env.R_DISCORD_GATEWAY_SECRET?.trim() ? 'Env.R_DISCORD_GATEWAY_SECRET' - : 'Env.ENCRYPTION_KEY'; + : Env.APP_ENV === 'development' || process.env.NODE_ENV === 'development' + ? 'Env.ENCRYPTION_KEY' + : 'missing'; const gatewaySecret = roomoteTarget.gatewaySecret ?? process.env.R_DISCORD_GATEWAY_SECRET?.trim() ?? - Env.ENCRYPTION_KEY ?? + (Env.APP_ENV === 'development' || process.env.NODE_ENV === 'development' + ? Env.ENCRYPTION_KEY + : undefined) ?? ''; console.info(`Using Discord gateway secret from ${secretSource}.`); diff --git a/render.yaml b/render.yaml index 43158643..7cf7852b 100644 --- a/render.yaml +++ b/render.yaml @@ -210,6 +210,10 @@ envVarGroups: value: 'true' - key: ENCRYPTION_KEY generateValue: true + # Transport secret between BullMQ Discord Gateway and API. Distinct from + # ENCRYPTION_KEY so a leaked gateway header cannot unlock the vault. + - key: R_DISCORD_GATEWAY_SECRET + generateValue: true # Optional process-env override. The setup UI stores the token encrypted # in Postgres when this is left blank. - key: R_DISCORD_BOT_TOKEN From 470454d0d28483ddd96fdd6bc7e2f9a4424a092b Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:34:39 +0000 Subject: [PATCH 2/6] fix: generate Discord gateway secret on upgrade paths Ensure host and deploy-script upgrades mint R_DISCORD_GATEWAY_SECRET when missing, document one-time PaaS secret wiring, and include the secret in manual Production Compose generation steps. --- SELF_HOSTING.md | 7 +++++-- deploy/coolify/README.md | 7 +++++++ deploy/fly/README.md | 7 +++++++ deploy/host/roomote | 8 ++++++++ deploy/railway/README.md | 8 ++++++++ deploy/render/README.md | 7 +++++++ deploy/scripts/upgrade.sh | 32 ++++++++++++++++++++++++++++++++ 7 files changed, 74 insertions(+), 2 deletions(-) diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 08aa6356..829735f7 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -484,6 +484,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (distinct from ENCRYPTION_KEY; required for Discord) openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD openssl rand -hex 16 # SETUP_TOKEN @@ -496,8 +497,10 @@ example PaaS platforms such as Railway or Render), leave the four keypairs once at first startup, stores them encrypted with `ENCRYPTION_KEY` in the database, and reuses them on every later boot. Env-provided key values always take precedence. The random string secrets (`ENCRYPTION_KEY`, -`ARTIFACT_SIGNING_KEY`, `DASHBOARD_PASSWORD`, `SETUP_TOKEN`) are still -required and can come from the platform's secret generator. +`R_DISCORD_GATEWAY_SECRET`, `ARTIFACT_SIGNING_KEY`, `DASHBOARD_PASSWORD`, +`SETUP_TOKEN`) are still required and can come from the platform's secret +generator. Keep `R_DISCORD_GATEWAY_SECRET` distinct from `ENCRYPTION_KEY`; +API and BullMQ must share the same value when Discord is enabled. ### Port exposure and the queue dashboard diff --git a/deploy/coolify/README.md b/deploy/coolify/README.md index 605bea90..614996b8 100644 --- a/deploy/coolify/README.md +++ b/deploy/coolify/README.md @@ -227,6 +227,13 @@ wildcard-capable TLS setup on the Coolify proxy: in Postgres, so sessions, job tokens, and preview tokens survive redeploys. On an immutable pin, bump the tag in the `x-roomote-app-image` anchor first. +- **One-time Discord gateway secret (existing resources).** New template + resources generate `R_DISCORD_GATEWAY_SECRET` via + `${SERVICE_PASSWORD_DISCORDGATEWAY}` in the shared env block. Older + resources deployed from a pre-hardening compose file must add that variable + (or an equivalent shared secret) once to the resource environment so API + and BullMQ receive the same value before Discord forwarding will work + again. Redeploys do not invent missing secrets from template diffs. - **Back up** the Postgres volume (or run `pg_dump` against the `postgres` container) and the MinIO volume. Everything else is reproducible from config plus the generated environment values on the resource. diff --git a/deploy/fly/README.md b/deploy/fly/README.md index 2cef4cac..ddc9a29a 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -319,6 +319,13 @@ previews.` and `fly certs add -a -previews a deployment that wants every main build can run `fly deploy` from its own CI with a [deploy token](https://fly.io/docs/security/tokens/) (`fly tokens create deploy`) and the edited `fly.toml`. +- **One-time Discord gateway secret (existing apps).** New installs set + `R_DISCORD_GATEWAY_SECRET` alongside the other random secrets. Older apps + that previously reused `ENCRYPTION_KEY` for Discord gateway↔API auth need + a one-time: + `fly secrets set --app "$APP" R_DISCORD_GATEWAY_SECRET="$(openssl rand -base64 32)"` + before continuing Discord after this hardening ships (all process groups + share Fly secrets). - **Back up** the Managed Postgres cluster (Fly's MPG backups or `pg_dump` via `fly mpg connect`) and the Tigris bucket. Everything else is reproducible from `fly.toml` plus the secrets. diff --git a/deploy/host/roomote b/deploy/host/roomote index 928ee74f..3ac0161e 100755 --- a/deploy/host/roomote +++ b/deploy/host/roomote @@ -568,6 +568,14 @@ cmd_upgrade() { set_env_value MODAL_BASE_IMAGE_REF "$worker_image" fi + # Production no longer accepts ENCRYPTION_KEY as Discord gateway auth. + # Generate a dedicated transport secret on first upgrade when missing so + # Discord-enabled deployments keep forwarding after the rollout. + if [ -z "$(read_env_value R_DISCORD_GATEWAY_SECRET | tr -d '[:space:]')" ]; then + set_env_value R_DISCORD_GATEWAY_SECRET "$(openssl rand -base64 32 | tr -d '\n')" + log "Generated R_DISCORD_GATEWAY_SECRET for Discord gateway↔API auth" + fi + compose config >/dev/null log "Stopping the controller so new tasks stay queued during the rollout" compose stop controller || true diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 6b4e1f52..110508bb 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -398,6 +398,14 @@ domain, which requires a domain you control: preview tokens survive redeploys. These redeploys can be automated against Railway's GraphQL API — see [Auto-deploying every develop build](#auto-deploying-every-develop-build-optional). +- **One-time Discord gateway secret (existing projects).** New template + deploys generate `R_DISCORD_GATEWAY_SECRET` on the api service and share it + with the other app services. Older projects that relied on + `ENCRYPTION_KEY` must set a distinct random value once on **api**, then + reference the same value on **bullmq** (and any other service that already + copies `api.*` secrets) before enabling or continuing Discord after this + hardening ships. Template edits do not rewrite variables on existing + projects. - **Back up** the Railway Postgres database (Railway backups or `pg_dump`) and the MinIO volume or external bucket. Everything else is reproducible from config. diff --git a/deploy/render/README.md b/deploy/render/README.md index ac88f43c..9ea3c86b 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -350,6 +350,13 @@ Live previews need a wildcard domain, which requires a domain you control: service's `db-migrate` pre-deploy applies any schema changes, and the auto-generated keypairs persist in Postgres, so sessions, job tokens, and preview tokens survive redeploys. +- **One-time Discord gateway secret (existing Blueprints).** New deploys + generate `R_DISCORD_GATEWAY_SECRET` in the `roomote-shared` env group. + Blueprint sync on an older workspace may not invent a new generated secret + for an already-created group; if Discord was using `ENCRYPTION_KEY` before, + add `R_DISCORD_GATEWAY_SECRET` once to the shared group (Generate value or + a random string) so web/api/controller/bullmq all see the same value + before continuing Discord after this hardening ships. - **Back up** the Render Postgres database (Render's built-in backups or `pg_dump`) and the MinIO disk or external bucket. Everything else is reproducible from the Blueprint plus the generated environment group diff --git a/deploy/scripts/upgrade.sh b/deploy/scripts/upgrade.sh index 2276544b..83a052e1 100755 --- a/deploy/scripts/upgrade.sh +++ b/deploy/scripts/upgrade.sh @@ -227,6 +227,38 @@ awk \ cat "$tmp_env" > .env rm -f "$tmp_env" chmod 600 .env + +# Production no longer accepts ENCRYPTION_KEY as Discord gateway auth. +# Generate a dedicated transport secret on first upgrade when missing so +# Discord-enabled deployments keep forwarding after the rollout. +if [ -z "$(read_env_value R_DISCORD_GATEWAY_SECRET | tr -d '[:space:]')" ]; then + secret="$(openssl rand -base64 32 | tr -d '\n')" + tmp_env="$(mktemp)" + awk -v secret="$secret" ' + BEGIN { + seen = 0 + pattern = "^[[:space:]]*(export[[:space:]]+)?R_DISCORD_GATEWAY_SECRET=" + } + $0 ~ pattern { + if (!seen) { + print "R_DISCORD_GATEWAY_SECRET=" secret + seen = 1 + } + next + } + { print } + END { + if (!seen) { + print "R_DISCORD_GATEWAY_SECRET=" secret + } + } + ' .env > "$tmp_env" + cat "$tmp_env" > .env + rm -f "$tmp_env" + chmod 600 .env + echo "Generated R_DISCORD_GATEWAY_SECRET for Discord gateway↔API auth" +fi + docker compose --env-file .env -f docker-compose.prod.yml config >/dev/null docker pull "$worker_image" docker compose --env-file .env -f docker-compose.prod.yml pull From 0d747ceeb618d8678ea5f6cf8ea23cd5c9282724 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:42:45 +0000 Subject: [PATCH 3/6] fix: auto-generate Discord gateway secret like Telegram Drop all ENCRYPTION_KEY fallbacks for Discord gateway auth. Mint R_DISCORD_GATEWAY_SECRET on Discord save when missing, resolve it from process env or the deployment vault on API and gateway, and supply a local-development default so pnpm dev still works without vault reuse. --- .agents/skills/mock-discord-testing/SKILL.md | 4 +- .../handlers/discord/__tests__/auth.test.ts | 98 +++++-------------- apps/api/src/handlers/discord/auth.ts | 49 +--------- apps/api/src/handlers/discord/index.ts | 2 +- apps/discord-gateway/src/config.test.ts | 32 +----- apps/discord-gateway/src/config.ts | 31 +----- apps/discord-gateway/src/credentials.ts | 22 +++++ apps/discord-gateway/src/service.ts | 8 +- apps/docs/environment-variables.mdx | 2 +- .../docs/providers/communications/discord.mdx | 11 ++- .../web/src/trpc/commands/comms/index.test.ts | 48 ++++++++- apps/web/src/trpc/commands/comms/index.ts | 20 ++++ deploy/README.md | 2 +- .../evals/run-discord-scenario.ts | 10 +- .../communication/scripts/run-mock-discord.ts | 8 +- .../db/src/lib/discord-runtime-credentials.ts | 29 ++++++ packages/env/src/index.ts | 2 + 17 files changed, 170 insertions(+), 208 deletions(-) diff --git a/.agents/skills/mock-discord-testing/SKILL.md b/.agents/skills/mock-discord-testing/SKILL.md index 5c26260c..701c8cbb 100644 --- a/.agents/skills/mock-discord-testing/SKILL.md +++ b/.agents/skills/mock-discord-testing/SKILL.md @@ -22,7 +22,7 @@ Discord continuity is inferred from **task thread → active cloud job**: root-c | Mock state endpoint | `http://127.0.0.1:3014/mock/state` | | Mock event injection endpoint | `http://127.0.0.1:3014/mock/events` | | Example scenario | `packages/communication/scripts/mock-discord.example.json` | -| Gateway secret source | `R_DISCORD_GATEWAY_SECRET` (local-dev-only fallback: `ENCRYPTION_KEY`) | +| Gateway secret source | `R_DISCORD_GATEWAY_SECRET` (auto-generated on Discord save; local-dev env default also available) | | Mock bot identity | `RoomoteBot` (id `100000000000000001`) | | Mock application id | `200000000000000001` | | Mock guild id | `300000000000000001` | @@ -57,7 +57,7 @@ The API resolves Discord credentials from real env vars first (`resolveDiscordRu ```bash R_DISCORD_BOT_TOKEN=mock-discord-token # must match the harness botToken (default: mock-discord-token) DISCORD_API_BASE_URL=http://127.0.0.1:3014/api/v10 # reroutes ALL outbound Discord REST calls to the harness -# Prefer R_DISCORD_GATEWAY_SECRET. Local-dev only may fall back to ENCRYPTION_KEY. +# R_DISCORD_GATEWAY_SECRET is required; development injects a local default when unset. ``` The harness reads the same gateway secret via dotenvx, so the `x-roomote-discord-gateway-secret` header matches automatically. Without any secret configured the events endpoint returns 503; with a mismatched secret it returns 401. diff --git a/apps/api/src/handlers/discord/__tests__/auth.test.ts b/apps/api/src/handlers/discord/__tests__/auth.test.ts index 1fd5c18d..80134312 100644 --- a/apps/api/src/handlers/discord/__tests__/auth.test.ts +++ b/apps/api/src/handlers/discord/__tests__/auth.test.ts @@ -1,115 +1,61 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const envMock = vi.hoisted(() => ({ - APP_ENV: 'production' as string | undefined, - NODE_ENV: 'test' as string | undefined, - ENCRYPTION_KEY: 'production-encryption-key-that-is-long-enough', - R_DISCORD_GATEWAY_SECRET: undefined as string | undefined, +const mocks = vi.hoisted(() => ({ + resolveDiscordGatewaySecret: vi.fn(), })); -vi.mock('@roomote/env', () => ({ - Env: envMock, +vi.mock('@roomote/db/server', () => ({ + resolveDiscordGatewaySecret: mocks.resolveDiscordGatewaySecret, })); import { verifyDiscordGatewaySecret } from '../auth'; describe('verifyDiscordGatewaySecret', () => { - const previousEnv = { - R_DISCORD_GATEWAY_SECRET: process.env.R_DISCORD_GATEWAY_SECRET, - R_APP_ENV: process.env.R_APP_ENV, - APP_ENV: process.env.APP_ENV, - NODE_ENV: process.env.NODE_ENV, - ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, - }; - beforeEach(() => { - envMock.APP_ENV = 'production'; - envMock.NODE_ENV = 'test'; - envMock.ENCRYPTION_KEY = 'production-encryption-key-that-is-long-enough'; - envMock.R_DISCORD_GATEWAY_SECRET = undefined; - delete process.env.R_DISCORD_GATEWAY_SECRET; - delete process.env.R_APP_ENV; - delete process.env.APP_ENV; - delete process.env.ENCRYPTION_KEY; - process.env.NODE_ENV = 'test'; + vi.clearAllMocks(); + mocks.resolveDiscordGatewaySecret.mockResolvedValue(null); }); afterEach(() => { - for (const [key, value] of Object.entries(previousEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + vi.clearAllMocks(); }); - it('accepts the dedicated gateway secret', () => { - process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; + it('accepts the dedicated gateway secret', async () => { + mocks.resolveDiscordGatewaySecret.mockResolvedValue('gateway-secret'); - expect(verifyDiscordGatewaySecret('gateway-secret')).toBeNull(); + await expect( + verifyDiscordGatewaySecret('gateway-secret'), + ).resolves.toBeNull(); }); - it('rejects a wrong dedicated gateway secret', () => { - process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; + it('rejects a wrong dedicated gateway secret', async () => { + mocks.resolveDiscordGatewaySecret.mockResolvedValue('gateway-secret'); - expect(verifyDiscordGatewaySecret('wrong-secret')).toEqual({ + await expect(verifyDiscordGatewaySecret('wrong-secret')).resolves.toEqual({ error: 'discord_gateway_unauthorized', status: 401, }); }); - it('does not accept ENCRYPTION_KEY in production-like config', () => { - envMock.APP_ENV = 'production'; - envMock.ENCRYPTION_KEY = 'production-encryption-key-that-is-long-enough'; - process.env.R_APP_ENV = 'production'; + it('does not accept ENCRYPTION_KEY as the gateway secret', async () => { + mocks.resolveDiscordGatewaySecret.mockResolvedValue(null); - expect( + await expect( verifyDiscordGatewaySecret( 'production-encryption-key-that-is-long-enough', ), - ).toEqual({ + ).resolves.toEqual({ error: 'discord_gateway_secret_not_configured', status: 503, }); }); - it('does not accept ENCRYPTION_KEY in preview', () => { - envMock.APP_ENV = 'preview'; - process.env.R_APP_ENV = 'preview'; + it('returns 503 when the dedicated secret is unset', async () => { + mocks.resolveDiscordGatewaySecret.mockResolvedValue(null); - expect( - verifyDiscordGatewaySecret( - 'production-encryption-key-that-is-long-enough', - ), - ).toEqual({ + await expect(verifyDiscordGatewaySecret(undefined)).resolves.toEqual({ error: 'discord_gateway_secret_not_configured', status: 503, }); }); - - it('falls back to ENCRYPTION_KEY only in local development', () => { - envMock.APP_ENV = 'development'; - envMock.ENCRYPTION_KEY = 'local-encryption-key-that-is-long-enough'; - process.env.R_APP_ENV = 'development'; - - expect( - verifyDiscordGatewaySecret('local-encryption-key-that-is-long-enough'), - ).toBeNull(); - }); - - it('prefers the dedicated secret over ENCRYPTION_KEY in development', () => { - envMock.APP_ENV = 'development'; - envMock.ENCRYPTION_KEY = 'local-encryption-key-that-is-long-enough'; - process.env.R_APP_ENV = 'development'; - process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; - - expect(verifyDiscordGatewaySecret('gateway-secret')).toBeNull(); - expect( - verifyDiscordGatewaySecret('local-encryption-key-that-is-long-enough'), - ).toEqual({ - error: 'discord_gateway_unauthorized', - status: 401, - }); - }); }); diff --git a/apps/api/src/handlers/discord/auth.ts b/apps/api/src/handlers/discord/auth.ts index 8d98fd3f..8ed9d4bb 100644 --- a/apps/api/src/handlers/discord/auth.ts +++ b/apps/api/src/handlers/discord/auth.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from 'node:crypto'; -import { Env } from '@roomote/env'; +import { resolveDiscordGatewaySecret } from '@roomote/db/server'; const DISCORD_GATEWAY_SECRET_HEADER = 'x-roomote-discord-gateway-secret' as const; @@ -12,46 +12,6 @@ type DiscordGatewayAuthError = { status: 503 | 401; }; -/** - * ENCRYPTION_KEY fallback is local-dev only. Production and preview must set a - * dedicated `R_DISCORD_GATEWAY_SECRET` so a leaked transport header cannot - * unlock the vault that encrypts stored secrets. - */ -function allowsEncryptionKeyFallback( - processEnv: NodeJS.ProcessEnv = process.env, -): boolean { - const appEnv = ( - processEnv.R_APP_ENV || - processEnv.APP_ENV || - Env.APP_ENV - )?.trim(); - if (appEnv === 'development') { - return true; - } - if (appEnv === 'production' || appEnv === 'preview') { - return false; - } - const nodeEnv = (processEnv.NODE_ENV || Env.NODE_ENV)?.trim(); - return nodeEnv === 'development'; -} - -function configuredGatewaySecret( - processEnv: NodeJS.ProcessEnv = process.env, -): string | null { - const dedicated = processEnv.R_DISCORD_GATEWAY_SECRET?.trim(); - if (dedicated) { - return dedicated; - } - - if (!allowsEncryptionKeyFallback(processEnv)) { - return null; - } - - return ( - Env.ENCRYPTION_KEY?.trim() || processEnv.ENCRYPTION_KEY?.trim() || null - ); -} - function secretsMatch(expected: string, received: string): boolean { const expectedBytes = Buffer.from(expected, 'utf8'); const receivedBytes = Buffer.from(received, 'utf8'); @@ -61,11 +21,10 @@ function secretsMatch(expected: string, received: string): boolean { ); } -export function verifyDiscordGatewaySecret( +export async function verifyDiscordGatewaySecret( received: string | undefined, - processEnv: NodeJS.ProcessEnv = process.env, -): DiscordGatewayAuthError | null { - const expected = configuredGatewaySecret(processEnv); +): Promise { + const expected = await resolveDiscordGatewaySecret(); if (!expected) { return { error: 'discord_gateway_secret_not_configured', diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index b4e22cab..baf8dda9 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -431,7 +431,7 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { export const discord = new Hono(); discord.post('/events', async (c) => { - const authError = verifyDiscordGatewaySecret( + const authError = await verifyDiscordGatewaySecret( c.req.header(DISCORD_GATEWAY_SECRET_HEADER), ); if (authError) { diff --git a/apps/discord-gateway/src/config.test.ts b/apps/discord-gateway/src/config.test.ts index cac2a9af..4de9800c 100644 --- a/apps/discord-gateway/src/config.test.ts +++ b/apps/discord-gateway/src/config.test.ts @@ -1,49 +1,19 @@ import { resolveDiscordGatewayConfig } from './config'; describe('resolveDiscordGatewayConfig', () => { - it('preserves an API path prefix and prefers the dedicated secret', () => { + it('preserves an API path prefix', () => { expect( resolveDiscordGatewayConfig({ TRPC_URL: 'https://roomote.example/_roomote-api/', - R_DISCORD_GATEWAY_SECRET: 'discord-secret', - ENCRYPTION_KEY: 'fallback-key', PORT: '13003', }), ).toMatchObject({ apiEventsUrl: 'https://roomote.example/_roomote-api/api/internal/discord/events', - apiSecret: 'discord-secret', port: 13003, }); }); - it('uses ENCRYPTION_KEY only as a local-development fallback', () => { - expect( - resolveDiscordGatewayConfig({ - ENCRYPTION_KEY: 'shared-key', - R_APP_ENV: 'development', - }).apiSecret, - ).toBe('shared-key'); - }); - - it('does not accept ENCRYPTION_KEY under production-like config', () => { - expect( - resolveDiscordGatewayConfig({ - ENCRYPTION_KEY: 'shared-key', - R_APP_ENV: 'production', - }).apiSecret, - ).toBeNull(); - }); - - it('does not accept ENCRYPTION_KEY under preview config', () => { - expect( - resolveDiscordGatewayConfig({ - ENCRYPTION_KEY: 'shared-key', - R_APP_ENV: 'preview', - }).apiSecret, - ).toBeNull(); - }); - it('supports bounded delivery and login retry tuning', () => { expect( resolveDiscordGatewayConfig({ diff --git a/apps/discord-gateway/src/config.ts b/apps/discord-gateway/src/config.ts index 25f1aea2..d4573aa3 100644 --- a/apps/discord-gateway/src/config.ts +++ b/apps/discord-gateway/src/config.ts @@ -21,7 +21,8 @@ function withoutTrailingSlashes(value: string): string { export type DiscordGatewayConfig = { port: number; apiEventsUrl: string; - apiSecret: string | null; + /** Snapshot used for secret/credential env resolution when embedded. */ + processEnv: NodeJS.ProcessEnv; apiTimeoutMs: number; leaderLeaseTtlSeconds: number; leaderLeaseRenewMs: number; @@ -36,32 +37,6 @@ export type DiscordGatewayConfig = { loginRetryMaxMs: number; }; -/** - * ENCRYPTION_KEY fallback is local-dev only. Production and preview must set a - * dedicated `R_DISCORD_GATEWAY_SECRET` shared by API and BullMQ/gateway. - */ -function allowsEncryptionKeyFallback(env: NodeJS.ProcessEnv): boolean { - const appEnv = (env.R_APP_ENV || env.APP_ENV)?.trim(); - if (appEnv === 'development') { - return true; - } - if (appEnv === 'production' || appEnv === 'preview') { - return false; - } - return env.NODE_ENV?.trim() === 'development'; -} - -function resolveApiSecret(env: NodeJS.ProcessEnv): string | null { - const dedicated = env.R_DISCORD_GATEWAY_SECRET?.trim(); - if (dedicated) { - return dedicated; - } - if (!allowsEncryptionKeyFallback(env)) { - return null; - } - return env.ENCRYPTION_KEY?.trim() || null; -} - export function resolveDiscordGatewayConfig( env: NodeJS.ProcessEnv = process.env, ): DiscordGatewayConfig { @@ -70,7 +45,7 @@ export function resolveDiscordGatewayConfig( return { port: positiveInteger(env.PORT, DEFAULT_PORT), apiEventsUrl: `${withoutTrailingSlashes(apiBaseUrl)}/api/internal/discord/events`, - apiSecret: resolveApiSecret(env), + processEnv: env, apiTimeoutMs: positiveInteger( env.DISCORD_GATEWAY_API_TIMEOUT_MS, DEFAULT_API_TIMEOUT_MS, diff --git a/apps/discord-gateway/src/credentials.ts b/apps/discord-gateway/src/credentials.ts index face8487..1f9d880f 100644 --- a/apps/discord-gateway/src/credentials.ts +++ b/apps/discord-gateway/src/credentials.ts @@ -52,3 +52,25 @@ export async function resolveDiscordGatewayCredentials(): Promise { + const fromEnv = env.R_DISCORD_GATEWAY_SECRET?.trim() || null; + if (fromEnv) { + return fromEnv; + } + + const dbServer = (await import('@roomote/db/server')) as unknown as { + resolveDiscordGatewaySecret?: () => Promise; + }; + if (dbServer.resolveDiscordGatewaySecret) { + return dbServer.resolveDiscordGatewaySecret(); + } + + return null; +} diff --git a/apps/discord-gateway/src/service.ts b/apps/discord-gateway/src/service.ts index df981468..47935d1a 100644 --- a/apps/discord-gateway/src/service.ts +++ b/apps/discord-gateway/src/service.ts @@ -3,6 +3,7 @@ import { acquireRedisLock, type Redis } from '@roomote/redis'; import { DiscordApiForwarder } from './api-forwarder'; import type { DiscordGatewayConfig } from './config'; import { + resolveDiscordGatewayApiSecret, resolveDiscordGatewayCredentials, type DiscordGatewayCredentials, } from './credentials'; @@ -158,10 +159,13 @@ export class DiscordGatewayService { forwardingReady: false, }); - if (this.config.apiSecret) { + const apiSecret = await resolveDiscordGatewayApiSecret( + this.config.processEnv, + ); + if (apiSecret) { const forwarder = new DiscordApiForwarder( this.config.apiEventsUrl, - this.config.apiSecret, + apiSecret, this.config.apiTimeoutMs, ); deliveryPromise = runSupervisedDeliveryLoop({ diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 83293f75..a9b58fdf 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -297,7 +297,7 @@ as per-task auth tokens or workspace paths. | `R_TELEGRAM_BOT_TOKEN` | Telegram | Telegram bot token. | | `R_TELEGRAM_WEBHOOK_SECRET` | Telegram | Optional. Telegram webhook secret; generated automatically when unset in the UI. | | `R_DISCORD_BOT_TOKEN` | Discord | Discord bot token. The bot and application identity are read from this token. | -| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Required outside local development when Discord is enabled. Keep this distinct from `ENCRYPTION_KEY`. | +| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset. Keep this distinct from `ENCRYPTION_KEY`. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | | `R_SLACK_CLIENT_ID` | Slack sign-in | Slack sign-in client ID. `R_SLACK_CLIENT_ID` is also accepted. | | `R_SLACK_CLIENT_SECRET` | Slack sign-in | Slack sign-in client secret. `R_SLACK_CLIENT_SECRET` is also accepted. | diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index c177edfb..33b7f983 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -130,11 +130,12 @@ The Discord Gateway runs as an opt-in subsystem inside Roomote's existing BullMQ process, so enabling Discord does not add another container, process, or port. It stays dormant until a token is saved, uses Redis to elect a single active replica, and disconnects if Discord is removed. Durable event delivery -uses Redis and a dedicated internal shared secret. Set the same -`R_DISCORD_GATEWAY_SECRET` value on the API and BullMQ processes (installers -and platform templates generate this for you). Keep it separate from -`ENCRYPTION_KEY` so a leaked transport secret cannot unlock secrets sealed at -rest. +uses Redis and a dedicated internal shared secret. Saving Discord credentials in +settings generates `R_DISCORD_GATEWAY_SECRET` when it is not already configured. +For env-var-only setups, set the same value on the API and BullMQ processes +yourself (installers and platform templates also generate one). Keep it separate +from `ENCRYPTION_KEY` so a leaked transport secret cannot unlock secrets sealed +at rest. Gateway session state is stored in the Roomote database. On a service restart or leader handoff, Roomote asks Discord to resume the previous session and diff --git a/apps/web/src/trpc/commands/comms/index.test.ts b/apps/web/src/trpc/commands/comms/index.test.ts index de42ca86..b86a58c0 100644 --- a/apps/web/src/trpc/commands/comms/index.test.ts +++ b/apps/web/src/trpc/commands/comms/index.test.ts @@ -489,7 +489,13 @@ describe('comms commands', () => { expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - values: [{ name: 'R_DISCORD_BOT_TOKEN', value: 'discord-token' }], + values: expect.arrayContaining([ + { name: 'R_DISCORD_BOT_TOKEN', value: 'discord-token' }, + expect.objectContaining({ + name: 'R_DISCORD_GATEWAY_SECRET', + value: expect.any(String), + }), + ]), }), ); expect(mockDiscordRegisterCommands).toHaveBeenCalledWith({ @@ -497,6 +503,46 @@ describe('comms commands', () => { }); }); + it('auto-generates a Discord gateway secret when omitted on save', async () => { + mockDbTransaction.mockImplementation(async (callback) => { + return callback({} as never); + }); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([]); + mockValidateDiscordBotToken.mockResolvedValue({ + applicationId: 'app-1', + applicationName: 'Roomote', + botUserId: 'bot-1', + botUsername: 'roomote', + botDisplayName: 'Roomote', + }); + mockResolveDiscordRuntimeCredentials.mockResolvedValue({ + botToken: 'discord-token', + applicationId: 'app-1', + applicationName: 'Roomote', + botUserId: 'bot-1', + botUsername: 'roomote', + botDisplayName: 'Roomote', + identitySource: 'live', + identityErrorCode: null, + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'discord', + values: { R_DISCORD_BOT_TOKEN: 'discord-token' }, + }); + + const savedValues = + mockUpsertDeploymentEnvironmentVariables.mock.calls[0]?.[1]?.values ?? + []; + const generatedSecret = savedValues.find( + (value: { name: string; value: string }) => + value.name === 'R_DISCORD_GATEWAY_SECRET', + )?.value; + expect(generatedSecret).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + it('upserts only non-empty submitted values', async () => { process.env.R_SLACK_CLIENT_SECRET = 'env-secret'; mockDbTransaction.mockImplementation(async (callback) => { diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 205a2c02..28aac995 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -132,6 +132,10 @@ function createTelegramWebhookSecret() { return crypto.randomUUID(); } +function createDiscordGatewaySecret() { + return crypto.randomUUID(); +} + export type CommsProviderStatus = Omit< SetupAuthStatus['providers'][number], 'id' @@ -998,6 +1002,22 @@ export async function saveCommsAuthConfigCommand( } } + if (input.provider === 'discord') { + const hasDiscordGatewaySecret = + Boolean(process.env.R_DISCORD_GATEWAY_SECRET?.trim()) || + persistedEnvVarNames.includes('R_DISCORD_GATEWAY_SECRET') || + Boolean(input.values?.R_DISCORD_GATEWAY_SECRET?.trim()); + const discordGatewaySecret = + input.values?.R_DISCORD_GATEWAY_SECRET?.trim() ?? + (hasDiscordGatewaySecret ? undefined : createDiscordGatewaySecret()); + if (discordGatewaySecret) { + valuesToSave.push({ + name: 'R_DISCORD_GATEWAY_SECRET', + value: discordGatewaySecret, + }); + } + } + const hasConfiguredAuthEnvVar = (name: string) => Boolean(process.env[name]?.trim()) || persistedEnvVarNames.includes(name) || diff --git a/deploy/README.md b/deploy/README.md index 173583d1..8589ac55 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -115,7 +115,7 @@ The file must include the required production values from - `PREVIEW_AUTH_PRIVATE_KEY` - `PREVIEW_AUTH_PUBLIC_KEY` - `ENCRYPTION_KEY` -- `R_DISCORD_GATEWAY_SECRET` (required outside local development when Discord is enabled) +- `R_DISCORD_GATEWAY_SECRET` (Discord gateway↔API secret; generated on Discord save / install / upgrade when missing) - `ARTIFACT_SIGNING_KEY` - `DASHBOARD_PASSWORD` - `R_GITHUB_APP_SLUG` diff --git a/packages/communication/evals/run-discord-scenario.ts b/packages/communication/evals/run-discord-scenario.ts index 5156f079..f2dfb71c 100644 --- a/packages/communication/evals/run-discord-scenario.ts +++ b/packages/communication/evals/run-discord-scenario.ts @@ -119,15 +119,7 @@ function substitute(value: unknown): unknown { return value; } -const appEnv = ( - process.env.R_APP_ENV || - process.env.APP_ENV || - process.env.NODE_ENV -)?.trim(); -const gatewaySecret = - process.env.R_DISCORD_GATEWAY_SECRET?.trim() || - (appEnv === 'development' ? process.env.ENCRYPTION_KEY?.trim() : undefined) || - ''; +const gatewaySecret = process.env.R_DISCORD_GATEWAY_SECRET?.trim() || ''; const server = new MockDiscordServer({ ...(scenario.state.botToken ? { botToken: scenario.state.botToken } : {}), diff --git a/packages/communication/scripts/run-mock-discord.ts b/packages/communication/scripts/run-mock-discord.ts index 8de68fee..40629d16 100644 --- a/packages/communication/scripts/run-mock-discord.ts +++ b/packages/communication/scripts/run-mock-discord.ts @@ -78,15 +78,11 @@ function resolveRoomoteTarget( ? 'config' : process.env.R_DISCORD_GATEWAY_SECRET?.trim() ? 'Env.R_DISCORD_GATEWAY_SECRET' - : Env.APP_ENV === 'development' || process.env.NODE_ENV === 'development' - ? 'Env.ENCRYPTION_KEY' - : 'missing'; + : 'missing'; const gatewaySecret = roomoteTarget.gatewaySecret ?? process.env.R_DISCORD_GATEWAY_SECRET?.trim() ?? - (Env.APP_ENV === 'development' || process.env.NODE_ENV === 'development' - ? Env.ENCRYPTION_KEY - : undefined) ?? + Env.R_DISCORD_GATEWAY_SECRET ?? ''; console.info(`Using Discord gateway secret from ${secretSource}.`); diff --git a/packages/db/src/lib/discord-runtime-credentials.ts b/packages/db/src/lib/discord-runtime-credentials.ts index 92001be6..7ada1bca 100644 --- a/packages/db/src/lib/discord-runtime-credentials.ts +++ b/packages/db/src/lib/discord-runtime-credentials.ts @@ -66,6 +66,11 @@ let cachedCredentials: { expiresAtMs: number; } | null = null; +let gatewaySecretCache: { + value: string | null; + expiresAtMs: number; +} | null = null; + /** * Accept a raw token or the value copied from an Authorization header. Discord * bot tokens contain no whitespace, so paste-introduced whitespace is safe to @@ -396,4 +401,28 @@ export async function resolveDiscordRuntimeCredentials(): Promise { + const nowMs = Date.now(); + if (gatewaySecretCache && gatewaySecretCache.expiresAtMs > nowMs) { + return gatewaySecretCache.value; + } + + const fromEnv = process.env.R_DISCORD_GATEWAY_SECRET?.trim() || null; + if (fromEnv) { + gatewaySecretCache = { value: fromEnv, expiresAtMs: nowMs + CACHE_TTL_MS }; + return fromEnv; + } + + const deploymentEnvVars = await resolveEffectiveDeploymentEnvVars(); + const value = deploymentEnvVars.R_DISCORD_GATEWAY_SECRET?.trim() || null; + gatewaySecretCache = { value, expiresAtMs: nowMs + CACHE_TTL_MS }; + return value; } diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 9209de29..82ad84ea 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -25,6 +25,7 @@ type NodeEnv = 'test' | 'development' | 'production'; const LOCAL_ENCRYPTION_KEY = 'local-roomote-encryption-key-0001'; const LOCAL_ARTIFACT_SIGNING_KEY = 'local-roomote-artifact-signing-key-1'; +const LOCAL_DISCORD_GATEWAY_SECRET = 'local-roomote-discord-gateway-secret-01'; const LOCAL_DASHBOARD_PASSWORD = 'roomote-local-admin'; const LOCAL_PREVIEW_DOMAINS = 'localhost,127.0.0.1,roomotepreview.localhost'; const LOCAL_S3_ENDPOINT = 'http://localhost:19000'; @@ -711,6 +712,7 @@ function buildRoomoteRuntimeEnv( env.DASHBOARD_PASSWORD ??= LOCAL_DASHBOARD_PASSWORD; env.ENCRYPTION_KEY ??= LOCAL_ENCRYPTION_KEY; env.ARTIFACT_SIGNING_KEY ??= LOCAL_ARTIFACT_SIGNING_KEY; + env.R_DISCORD_GATEWAY_SECRET ??= LOCAL_DISCORD_GATEWAY_SECRET; env.PREVIEW_PROXY_BASE_URL ??= getDefaultPreviewProxyBaseUrl(appEnv); env.PREVIEW_DOMAINS ??= LOCAL_PREVIEW_DOMAINS; env.S3_ENDPOINT ??= LOCAL_S3_ENDPOINT; From ac998d8446d917ae69ff90244d55c8be4a779acc Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:45:36 +0000 Subject: [PATCH 4/6] docs: drop stale ENCRYPTION_KEY commentary around Discord gateway secret Rewrite comments and ops notes to describe R_DISCORD_GATEWAY_SECRET on its own terms without referring to the removed encryption-key fallback. --- .env.production.example | 4 ++-- SELF_HOSTING.md | 6 +++--- apps/api/src/handlers/discord/__tests__/auth.test.ts | 8 +++----- apps/discord-gateway/src/credentials.ts | 2 +- apps/discord-gateway/src/embedded.test.ts | 2 +- apps/docs/environment-variables.mdx | 2 +- apps/docs/providers/communications/discord.mdx | 4 +--- deploy/README.md | 2 +- deploy/coolify/docker-compose.yaml | 2 +- deploy/fly/README.md | 6 ++---- deploy/host/roomote | 6 +++--- deploy/install.sh | 2 +- deploy/railway/README.md | 11 +++++------ deploy/railway/template.yaml | 3 +-- deploy/render/README.md | 8 ++++---- deploy/scripts/upgrade.sh | 5 ++--- packages/db/src/lib/discord-runtime-credentials.ts | 3 +-- render.yaml | 3 +-- 18 files changed, 34 insertions(+), 45 deletions(-) diff --git a/.env.production.example b/.env.production.example index 0c889b07..628916c2 100644 --- a/.env.production.example +++ b/.env.production.example @@ -43,8 +43,8 @@ PREVIEW_AUTH_PUBLIC_KEY= # Random single-line secrets generated by the operator. ENCRYPTION_KEY= # Shared internal secret for Discord-gateway→API event delivery (API + BullMQ). -# Distinct from ENCRYPTION_KEY. Required outside local development when Discord -# is enabled. The installer and Railway/Render/Coolify templates generate it. +# Required when Discord is enabled. The installer and Railway/Render/Coolify +# templates generate it; UI Discord save also mints it when missing. # R_DISCORD_GATEWAY_SECRET= ARTIFACT_SIGNING_KEY= DASHBOARD_PASSWORD= diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 829735f7..468cf34d 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -484,7 +484,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY -openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (distinct from ENCRYPTION_KEY; required for Discord) +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD openssl rand -hex 16 # SETUP_TOKEN @@ -499,8 +499,8 @@ the database, and reuses them on every later boot. Env-provided key values always take precedence. The random string secrets (`ENCRYPTION_KEY`, `R_DISCORD_GATEWAY_SECRET`, `ARTIFACT_SIGNING_KEY`, `DASHBOARD_PASSWORD`, `SETUP_TOKEN`) are still required and can come from the platform's secret -generator. Keep `R_DISCORD_GATEWAY_SECRET` distinct from `ENCRYPTION_KEY`; -API and BullMQ must share the same value when Discord is enabled. +generator. API and BullMQ must share the same `R_DISCORD_GATEWAY_SECRET` value +when Discord is enabled. ### Port exposure and the queue dashboard diff --git a/apps/api/src/handlers/discord/__tests__/auth.test.ts b/apps/api/src/handlers/discord/__tests__/auth.test.ts index 80134312..2989ded3 100644 --- a/apps/api/src/handlers/discord/__tests__/auth.test.ts +++ b/apps/api/src/handlers/discord/__tests__/auth.test.ts @@ -37,20 +37,18 @@ describe('verifyDiscordGatewaySecret', () => { }); }); - it('does not accept ENCRYPTION_KEY as the gateway secret', async () => { + it('returns 503 when the dedicated secret is unset', async () => { mocks.resolveDiscordGatewaySecret.mockResolvedValue(null); await expect( - verifyDiscordGatewaySecret( - 'production-encryption-key-that-is-long-enough', - ), + verifyDiscordGatewaySecret('unrelated-secret-value'), ).resolves.toEqual({ error: 'discord_gateway_secret_not_configured', status: 503, }); }); - it('returns 503 when the dedicated secret is unset', async () => { + it('returns 503 when no header is provided either', async () => { mocks.resolveDiscordGatewaySecret.mockResolvedValue(null); await expect(verifyDiscordGatewaySecret(undefined)).resolves.toEqual({ diff --git a/apps/discord-gateway/src/credentials.ts b/apps/discord-gateway/src/credentials.ts index 1f9d880f..0db7e45f 100644 --- a/apps/discord-gateway/src/credentials.ts +++ b/apps/discord-gateway/src/credentials.ts @@ -55,7 +55,7 @@ export async function resolveDiscordGatewayCredentials(): Promise { ); mocks.stop.mockImplementation(async () => finishRun?.()); const redis = { status: 'ready' }; - const env = { ENCRYPTION_KEY: 'shared-secret' }; + const env = { R_DISCORD_GATEWAY_SECRET: 'gateway-secret' }; const supervisor = startDiscordGatewaySupervisor(redis as never, env); diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index a9b58fdf..733caa80 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -297,7 +297,7 @@ as per-task auth tokens or workspace paths. | `R_TELEGRAM_BOT_TOKEN` | Telegram | Telegram bot token. | | `R_TELEGRAM_WEBHOOK_SECRET` | Telegram | Optional. Telegram webhook secret; generated automatically when unset in the UI. | | `R_DISCORD_BOT_TOKEN` | Discord | Discord bot token. The bot and application identity are read from this token. | -| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset. Keep this distinct from `ENCRYPTION_KEY`. | +| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | | `R_SLACK_CLIENT_ID` | Slack sign-in | Slack sign-in client ID. `R_SLACK_CLIENT_ID` is also accepted. | | `R_SLACK_CLIENT_SECRET` | Slack sign-in | Slack sign-in client secret. `R_SLACK_CLIENT_SECRET` is also accepted. | diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 33b7f983..97000982 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -133,9 +133,7 @@ active replica, and disconnects if Discord is removed. Durable event delivery uses Redis and a dedicated internal shared secret. Saving Discord credentials in settings generates `R_DISCORD_GATEWAY_SECRET` when it is not already configured. For env-var-only setups, set the same value on the API and BullMQ processes -yourself (installers and platform templates also generate one). Keep it separate -from `ENCRYPTION_KEY` so a leaked transport secret cannot unlock secrets sealed -at rest. +yourself (installers and platform templates also generate one). Gateway session state is stored in the Roomote database. On a service restart or leader handoff, Roomote asks Discord to resume the previous session and diff --git a/deploy/README.md b/deploy/README.md index 8589ac55..745af08a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -179,7 +179,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY -openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (distinct from ENCRYPTION_KEY) +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD ``` diff --git a/deploy/coolify/docker-compose.yaml b/deploy/coolify/docker-compose.yaml index f48eb2ea..24cc1654 100644 --- a/deploy/coolify/docker-compose.yaml +++ b/deploy/coolify/docker-compose.yaml @@ -48,7 +48,7 @@ x-roomote-shared-env: &roomote-shared-env ROOMOTE_DOCKER_LOAD_ENV_FILE: 'false' R_AUTO_GENERATE_KEYS: 'true' ENCRYPTION_KEY: ${SERVICE_BASE64_64_ENCRYPTIONKEY} - # Distinct from ENCRYPTION_KEY; shared by API and BullMQ Discord Gateway. + # Transport secret between API and BullMQ Discord Gateway. R_DISCORD_GATEWAY_SECRET: ${SERVICE_PASSWORD_DISCORDGATEWAY} ARTIFACT_SIGNING_KEY: ${SERVICE_BASE64_64_ARTIFACTSIGNING} DASHBOARD_PASSWORD: ${SERVICE_PASSWORD_DASHBOARD} diff --git a/deploy/fly/README.md b/deploy/fly/README.md index ddc9a29a..e8a26779 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -321,11 +321,9 @@ previews.` and `fly certs add -a -previews (`fly tokens create deploy`) and the edited `fly.toml`. - **One-time Discord gateway secret (existing apps).** New installs set `R_DISCORD_GATEWAY_SECRET` alongside the other random secrets. Older apps - that previously reused `ENCRYPTION_KEY` for Discord gateway↔API auth need - a one-time: + that are missing it need a one-time: `fly secrets set --app "$APP" R_DISCORD_GATEWAY_SECRET="$(openssl rand -base64 32)"` - before continuing Discord after this hardening ships (all process groups - share Fly secrets). + before continuing Discord (all process groups share Fly secrets). - **Back up** the Managed Postgres cluster (Fly's MPG backups or `pg_dump` via `fly mpg connect`) and the Tigris bucket. Everything else is reproducible from `fly.toml` plus the secrets. diff --git a/deploy/host/roomote b/deploy/host/roomote index 3ac0161e..92cb39c9 100755 --- a/deploy/host/roomote +++ b/deploy/host/roomote @@ -568,9 +568,9 @@ cmd_upgrade() { set_env_value MODAL_BASE_IMAGE_REF "$worker_image" fi - # Production no longer accepts ENCRYPTION_KEY as Discord gateway auth. - # Generate a dedicated transport secret on first upgrade when missing so - # Discord-enabled deployments keep forwarding after the rollout. + # Dedicated Discord gateway↔API transport secret for forwarding events. + # Generate on first upgrade when missing so Discord-enabled deployments + # keep working after rollouts that require this var. if [ -z "$(read_env_value R_DISCORD_GATEWAY_SECRET | tr -d '[:space:]')" ]; then set_env_value R_DISCORD_GATEWAY_SECRET "$(openssl rand -base64 32 | tr -d '\n')" log "Generated R_DISCORD_GATEWAY_SECRET for Discord gateway↔API auth" diff --git a/deploy/install.sh b/deploy/install.sh index e2c783ee..54184270 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -497,7 +497,7 @@ if ! env_has_value SETUP_TOKEN; then fi # Dedicated Discord gateway↔API transport secret. Always generate one so -# enabling Discord later does not fall back to ENCRYPTION_KEY. +# enabling Discord later is ready without a separate operator step. if ! env_has_value R_DISCORD_GATEWAY_SECRET; then set_env_value R_DISCORD_GATEWAY_SECRET "$(openssl rand -base64 32 | tr -d '\n')" fi diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 110508bb..6f38f0e0 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -400,12 +400,11 @@ domain, which requires a domain you control: [Auto-deploying every develop build](#auto-deploying-every-develop-build-optional). - **One-time Discord gateway secret (existing projects).** New template deploys generate `R_DISCORD_GATEWAY_SECRET` on the api service and share it - with the other app services. Older projects that relied on - `ENCRYPTION_KEY` must set a distinct random value once on **api**, then - reference the same value on **bullmq** (and any other service that already - copies `api.*` secrets) before enabling or continuing Discord after this - hardening ships. Template edits do not rewrite variables on existing - projects. + with the other app services. Older projects without that variable must set a + distinct random value once on **api**, then reference the same value on + **bullmq** (and any other service that already copies `api.*` secrets) + before enabling or continuing Discord. Template edits do not rewrite + variables on existing projects. - **Back up** the Railway Postgres database (Railway backups or `pg_dump`) and the MinIO volume or external bucket. Everything else is reproducible from config. diff --git a/deploy/railway/template.yaml b/deploy/railway/template.yaml index 3c8f758a..85cb58ae 100644 --- a/deploy/railway/template.yaml +++ b/deploy/railway/template.yaml @@ -130,8 +130,7 @@ services: ROOMOTE_DOCKER_LOAD_ENV_FILE: 'false' R_AUTO_GENERATE_KEYS: 'true' ENCRYPTION_KEY: ${{secret(32)}} - # Transport secret between BullMQ Discord Gateway and API. Distinct from - # ENCRYPTION_KEY so a leaked gateway header cannot unlock the vault. + # Transport secret between BullMQ Discord Gateway and API. R_DISCORD_GATEWAY_SECRET: ${{secret(32)}} R_DISCORD_BOT_TOKEN: '' # optional; Discord can also be configured after boot in Settings ARTIFACT_SIGNING_KEY: ${{secret(32)}} diff --git a/deploy/render/README.md b/deploy/render/README.md index 9ea3c86b..83dd1ce4 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -353,10 +353,10 @@ Live previews need a wildcard domain, which requires a domain you control: - **One-time Discord gateway secret (existing Blueprints).** New deploys generate `R_DISCORD_GATEWAY_SECRET` in the `roomote-shared` env group. Blueprint sync on an older workspace may not invent a new generated secret - for an already-created group; if Discord was using `ENCRYPTION_KEY` before, - add `R_DISCORD_GATEWAY_SECRET` once to the shared group (Generate value or - a random string) so web/api/controller/bullmq all see the same value - before continuing Discord after this hardening ships. + for an already-created group; if the variable is missing, add + `R_DISCORD_GATEWAY_SECRET` once to the shared group (Generate value or a + random string) so web/api/controller/bullmq all see the same value before + continuing Discord. - **Back up** the Render Postgres database (Render's built-in backups or `pg_dump`) and the MinIO disk or external bucket. Everything else is reproducible from the Blueprint plus the generated environment group diff --git a/deploy/scripts/upgrade.sh b/deploy/scripts/upgrade.sh index 83a052e1..63f7a193 100755 --- a/deploy/scripts/upgrade.sh +++ b/deploy/scripts/upgrade.sh @@ -228,9 +228,8 @@ cat "$tmp_env" > .env rm -f "$tmp_env" chmod 600 .env -# Production no longer accepts ENCRYPTION_KEY as Discord gateway auth. -# Generate a dedicated transport secret on first upgrade when missing so -# Discord-enabled deployments keep forwarding after the rollout. +# Dedicated Discord gateway↔API transport secret. Generate on first upgrade +# when missing so Discord-enabled deployments keep forwarding after rollouts. if [ -z "$(read_env_value R_DISCORD_GATEWAY_SECRET | tr -d '[:space:]')" ]; then secret="$(openssl rand -base64 32 | tr -d '\n')" tmp_env="$(mktemp)" diff --git a/packages/db/src/lib/discord-runtime-credentials.ts b/packages/db/src/lib/discord-runtime-credentials.ts index 7ada1bca..12af7e13 100644 --- a/packages/db/src/lib/discord-runtime-credentials.ts +++ b/packages/db/src/lib/discord-runtime-credentials.ts @@ -406,8 +406,7 @@ export function invalidateDiscordRuntimeCredentialsCache(): void { /** * Resolve the Discord gateway↔API transport secret. Process env wins over the - * encrypted deployment vault (same source order as the bot token). Never falls - * back to ENCRYPTION_KEY. + * encrypted deployment vault (same source order as the bot token). */ export async function resolveDiscordGatewaySecret(): Promise { const nowMs = Date.now(); diff --git a/render.yaml b/render.yaml index 7cf7852b..73bae616 100644 --- a/render.yaml +++ b/render.yaml @@ -210,8 +210,7 @@ envVarGroups: value: 'true' - key: ENCRYPTION_KEY generateValue: true - # Transport secret between BullMQ Discord Gateway and API. Distinct from - # ENCRYPTION_KEY so a leaked gateway header cannot unlock the vault. + # Transport secret between BullMQ Discord Gateway and API. - key: R_DISCORD_GATEWAY_SECRET generateValue: true # Optional process-env override. The setup UI stores the token encrypted From 5c92296fee3399b0bbd7180d0b8e0ca3c1228fbe Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:38:05 +0000 Subject: [PATCH 5/6] fix: poll Discord gateway secret and align local defaults Re-check R_DISCORD_GATEWAY_SECRET each credential poll so UI-saved secrets start delivery without a BullMQ restart. Write the local-development default into process.env so API and gateway share it, and restore the operator guidance to keep the gateway secret distinct from ENCRYPTION_KEY. --- .env.production.example | 5 +- SELF_HOSTING.md | 4 +- apps/discord-gateway/src/credentials.ts | 15 ++- apps/discord-gateway/src/service.ts | 112 ++++++++++++------ apps/docs/environment-variables.mdx | 2 +- .../docs/providers/communications/discord.mdx | 7 +- deploy/README.md | 2 +- packages/env/src/index.ts | 5 + 8 files changed, 104 insertions(+), 48 deletions(-) diff --git a/.env.production.example b/.env.production.example index 628916c2..87ed182d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -43,8 +43,9 @@ PREVIEW_AUTH_PUBLIC_KEY= # Random single-line secrets generated by the operator. ENCRYPTION_KEY= # Shared internal secret for Discord-gateway→API event delivery (API + BullMQ). -# Required when Discord is enabled. The installer and Railway/Render/Coolify -# templates generate it; UI Discord save also mints it when missing. +# Required when Discord is enabled. Use a dedicated random value — do not reuse +# ENCRYPTION_KEY. The installer and Railway/Render/Coolify templates generate +# it; UI Discord save also mints it when missing. # R_DISCORD_GATEWAY_SECRET= ARTIFACT_SIGNING_KEY= DASHBOARD_PASSWORD= diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 468cf34d..6ade6f84 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -484,7 +484,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY -openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (dedicated; do not reuse ENCRYPTION_KEY) openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD openssl rand -hex 16 # SETUP_TOKEN @@ -500,7 +500,7 @@ always take precedence. The random string secrets (`ENCRYPTION_KEY`, `R_DISCORD_GATEWAY_SECRET`, `ARTIFACT_SIGNING_KEY`, `DASHBOARD_PASSWORD`, `SETUP_TOKEN`) are still required and can come from the platform's secret generator. API and BullMQ must share the same `R_DISCORD_GATEWAY_SECRET` value -when Discord is enabled. +when Discord is enabled; that value must not be the same as `ENCRYPTION_KEY`. ### Port exposure and the queue dashboard diff --git a/apps/discord-gateway/src/credentials.ts b/apps/discord-gateway/src/credentials.ts index 0db7e45f..22208e42 100644 --- a/apps/discord-gateway/src/credentials.ts +++ b/apps/discord-gateway/src/credentials.ts @@ -54,15 +54,20 @@ export async function resolveDiscordGatewayCredentials(): Promise { - const fromEnv = env.R_DISCORD_GATEWAY_SECRET?.trim() || null; - if (fromEnv) { - return fromEnv; + const fromLive = process.env.R_DISCORD_GATEWAY_SECRET?.trim() || null; + if (fromLive) { + return fromLive; + } + + const fromSnapshot = env.R_DISCORD_GATEWAY_SECRET?.trim() || null; + if (fromSnapshot) { + return fromSnapshot; } const dbServer = (await import('@roomote/db/server')) as unknown as { diff --git a/apps/discord-gateway/src/service.ts b/apps/discord-gateway/src/service.ts index 47935d1a..d32bbd4a 100644 --- a/apps/discord-gateway/src/service.ts +++ b/apps/discord-gateway/src/service.ts @@ -111,29 +111,42 @@ export class DiscordGatewayService { private async runAsLeader(renewLease: () => Promise): Promise { const queue = new DiscordInboundQueue(this.redis); const session = new DiscordGatewaySession(queue, this.status); - const deliveryAbort = new AbortController(); this.activeSession = session; - this.activeDeliveryAbort = deliveryAbort; let leaseValid = true; let activeFingerprint: string | null = null; - let deliveryPromise: Promise | null = null; + const delivery: { + promise: Promise | null; + abort: AbortController; + secret: string | null; + } = { + promise: null, + abort: new AbortController(), + secret: null, + }; + this.activeDeliveryAbort = delivery.abort; const loginBackoff = new DiscordLoginBackoff( this.config.loginRetryBaseMs, this.config.loginRetryMaxMs, ); + + const abortDelivery = () => { + delivery.abort.abort(); + this.activeDeliveryAbort = delivery.abort; + }; + const leaseTimer = setInterval(() => { void renewLease() .then((renewed) => { if (!renewed) { leaseValid = false; - deliveryAbort.abort(); + abortDelivery(); void session.disconnect(); } }) .catch(() => { leaseValid = false; - deliveryAbort.abort(); + abortDelivery(); void session.disconnect(); }); }, this.config.leaderLeaseRenewMs); @@ -148,6 +161,50 @@ export class DiscordGatewayService { ); }, this.config.statusRefreshMs); + const ensureDeliveryLoop = async (apiSecret: string | null) => { + if (apiSecret && apiSecret === delivery.secret && delivery.promise) { + return; + } + + if (delivery.promise) { + abortDelivery(); + await delivery.promise.catch(() => undefined); + delivery.promise = null; + } + + if (!apiSecret) { + delivery.secret = null; + await this.status.update({ + forwardingReady: false, + lastError: + 'R_DISCORD_GATEWAY_SECRET is required to forward Discord events', + }); + return; + } + + delivery.abort = new AbortController(); + this.activeDeliveryAbort = delivery.abort; + const forwarder = new DiscordApiForwarder( + this.config.apiEventsUrl, + apiSecret, + this.config.apiTimeoutMs, + ); + delivery.promise = runSupervisedDeliveryLoop({ + queue, + forwarder, + status: this.status, + signal: delivery.abort.signal, + pollMs: this.config.deliveryPollMs, + maxAttempts: this.config.deliveryMaxAttempts, + maxBackoffMs: this.config.deliveryMaxBackoffMs, + restartMaxBackoffMs: this.config.deliveryMaxBackoffMs, + }); + delivery.secret = apiSecret; + await this.status.update({ + forwardingReady: true, + }); + }; + try { await this.status.update({ phase: 'awaiting_configuration', @@ -159,35 +216,18 @@ export class DiscordGatewayService { forwardingReady: false, }); - const apiSecret = await resolveDiscordGatewayApiSecret( - this.config.processEnv, - ); - if (apiSecret) { - const forwarder = new DiscordApiForwarder( - this.config.apiEventsUrl, - apiSecret, - this.config.apiTimeoutMs, - ); - deliveryPromise = runSupervisedDeliveryLoop({ - queue, - forwarder, - status: this.status, - signal: deliveryAbort.signal, - pollMs: this.config.deliveryPollMs, - maxAttempts: this.config.deliveryMaxAttempts, - maxBackoffMs: this.config.deliveryMaxBackoffMs, - restartMaxBackoffMs: this.config.deliveryMaxBackoffMs, - }); - } else { - await this.status.update({ - phase: 'error', - forwardingReady: false, - lastError: - 'R_DISCORD_GATEWAY_SECRET is required to forward Discord events', - }); - } - while (!this.stopped && leaseValid) { + try { + const apiSecret = await resolveDiscordGatewayApiSecret( + this.config.processEnv, + ); + await ensureDeliveryLoop(apiSecret); + } catch (error) { + await this.status.update({ + lastError: `Discord gateway secret lookup failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + let credentials: DiscordGatewayCredentials | null; try { credentials = await resolveDiscordGatewayCredentials(); @@ -255,8 +295,10 @@ export class DiscordGatewayService { } finally { clearInterval(leaseTimer); clearInterval(statusTimer); - deliveryAbort.abort(); - await deliveryPromise?.catch(() => undefined); + abortDelivery(); + if (delivery.promise) { + await delivery.promise.catch(() => undefined); + } await session.disconnect(); this.activeSession = null; this.activeDeliveryAbort = null; diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 733caa80..cf47ee4d 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -297,7 +297,7 @@ as per-task auth tokens or workspace paths. | `R_TELEGRAM_BOT_TOKEN` | Telegram | Telegram bot token. | | `R_TELEGRAM_WEBHOOK_SECRET` | Telegram | Optional. Telegram webhook secret; generated automatically when unset in the UI. | | `R_DISCORD_BOT_TOKEN` | Discord | Discord bot token. The bot and application identity are read from this token. | -| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset. | +| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset. Dedicated value — do not reuse `ENCRYPTION_KEY`. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | | `R_SLACK_CLIENT_ID` | Slack sign-in | Slack sign-in client ID. `R_SLACK_CLIENT_ID` is also accepted. | | `R_SLACK_CLIENT_SECRET` | Slack sign-in | Slack sign-in client secret. `R_SLACK_CLIENT_SECRET` is also accepted. | diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 97000982..fa477260 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -123,6 +123,7 @@ environment variable instead, set: ```sh R_DISCORD_BOT_TOKEN= +# Use a dedicated random value — do not reuse ENCRYPTION_KEY or other secrets. R_DISCORD_GATEWAY_SECRET= ``` @@ -132,8 +133,10 @@ port. It stays dormant until a token is saved, uses Redis to elect a single active replica, and disconnects if Discord is removed. Durable event delivery uses Redis and a dedicated internal shared secret. Saving Discord credentials in settings generates `R_DISCORD_GATEWAY_SECRET` when it is not already configured. -For env-var-only setups, set the same value on the API and BullMQ processes -yourself (installers and platform templates also generate one). +For env-var-only setups, set the same secret on the API and BullMQ processes +yourself (installers and platform templates also generate one). That value must +be unique to Discord transport — do not reuse `ENCRYPTION_KEY` or any other +deployment secret. Gateway session state is stored in the Roomote database. On a service restart or leader handoff, Roomote asks Discord to resume the previous session and diff --git a/deploy/README.md b/deploy/README.md index 745af08a..5969d3ca 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -179,7 +179,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY -openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (dedicated; do not reuse ENCRYPTION_KEY) openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD ``` diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 82ad84ea..2c781973 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -713,6 +713,11 @@ function buildRoomoteRuntimeEnv( env.ENCRYPTION_KEY ??= LOCAL_ENCRYPTION_KEY; env.ARTIFACT_SIGNING_KEY ??= LOCAL_ARTIFACT_SIGNING_KEY; env.R_DISCORD_GATEWAY_SECRET ??= LOCAL_DISCORD_GATEWAY_SECRET; + // Keep process.env aligned so API auth, BullMQ, and the gateway share the + // same local default instead of only one process reading Env.*. + if (!processEnv.R_DISCORD_GATEWAY_SECRET?.trim()) { + processEnv.R_DISCORD_GATEWAY_SECRET = env.R_DISCORD_GATEWAY_SECRET; + } env.PREVIEW_PROXY_BASE_URL ??= getDefaultPreviewProxyBaseUrl(appEnv); env.PREVIEW_DOMAINS ??= LOCAL_PREVIEW_DOMAINS; env.S3_ENDPOINT ??= LOCAL_S3_ENDPOINT; From c83aeaf3501ae533f054618700ec382abb36c9b2 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:46:51 +0000 Subject: [PATCH 6/6] fix: never restart Discord delivery after lease is lost ensureDeliveryLoop re-checks leadership after awaits and the credential poll exits early once the lease is invalid so a former leader cannot spawn a concurrent forwarder while shutting down. --- apps/discord-gateway/src/service.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/discord-gateway/src/service.ts b/apps/discord-gateway/src/service.ts index d32bbd4a..0fd9bb96 100644 --- a/apps/discord-gateway/src/service.ts +++ b/apps/discord-gateway/src/service.ts @@ -162,6 +162,11 @@ export class DiscordGatewayService { }, this.config.statusRefreshMs); const ensureDeliveryLoop = async (apiSecret: string | null) => { + // Leadership can be lost while we resolve secrets or wait for an old + // delivery worker to unwind. Never start a new forwarder unless we still + // own the lease. + const canLead = () => leaseValid && !this.stopped; + if (apiSecret && apiSecret === delivery.secret && delivery.promise) { return; } @@ -172,6 +177,11 @@ export class DiscordGatewayService { delivery.promise = null; } + if (!canLead()) { + delivery.secret = null; + return; + } + if (!apiSecret) { delivery.secret = null; await this.status.update({ @@ -182,6 +192,11 @@ export class DiscordGatewayService { return; } + if (!canLead()) { + delivery.secret = null; + return; + } + delivery.abort = new AbortController(); this.activeDeliveryAbort = delivery.abort; const forwarder = new DiscordApiForwarder( @@ -221,13 +236,20 @@ export class DiscordGatewayService { const apiSecret = await resolveDiscordGatewayApiSecret( this.config.processEnv, ); - await ensureDeliveryLoop(apiSecret); + // Secret lookup is async; re-check leadership in ensureDeliveryLoop. + if (leaseValid && !this.stopped) { + await ensureDeliveryLoop(apiSecret); + } } catch (error) { await this.status.update({ lastError: `Discord gateway secret lookup failed: ${error instanceof Error ? error.message : String(error)}`, }); } + if (!leaseValid || this.stopped) { + break; + } + let credentials: DiscordGatewayCredentials | null; try { credentials = await resolveDiscordGatewayCredentials();