diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 976bd10..4255179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,23 +21,16 @@ permissions: contents: read jobs: - # ─── Job 1: Run all unit tests (+ bump version on push events) ──────────────── + # ─── Job 1: Run all unit tests ──────────────────────────────────────────────── test: name: Unit Tests runs-on: ubuntu-22.04 - # Version bump needs write access on push events; PRs only need read permissions: - contents: write - - outputs: - new-version: ${{ steps.bump.outputs.new-version }} + contents: read steps: - name: Checkout uses: actions/checkout@v6 - with: - # Use GITHUB_TOKEN so the bot can push the version bump commit back - token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js uses: actions/setup-node@v6 @@ -45,84 +38,12 @@ jobs: node-version: '24' cache: 'npm' - # ── Version bump (push events only, not PRs, not uat) ───────────────────── - # Display version format: RELEASE.BETA.ALPHA.BUILD (stored in build.extraMetadata.version) - # Semver format: RELEASE.BETA.ALPHA (stored in version — required by electron-builder) - # - # develop → increment BUILD (4th digit only) - # alpha → increment ALPHA (3rd digit), reset BUILD to 0 - # beta → increment BETA (2nd digit), reset ALPHA and BUILD to 0 - # main → increment RELEASE (1st digit), reset BETA/ALPHA/BUILD to 0, tag release/X.0.0.0 - - name: Bump version - id: bump - if: github.event_name == 'push' && github.ref_name != 'uat' && github.actor != 'dependabot[bot]' - run: | - # Read the 4-part display version from extraMetadata - EXT_VERSION=$(node -p "require('./package.json').build.extraMetadata.version") - IFS='.' read -r RELEASE BETA ALPHA BUILD <<< "$EXT_VERSION" - - case "${{ github.ref_name }}" in - develop) - BUILD=$((BUILD + 1)) - ;; - alpha) - ALPHA=$((ALPHA + 1)) - BUILD=0 - ;; - beta) - BETA=$((BETA + 1)) - ALPHA=0 - BUILD=0 - ;; - main) - RELEASE=$((RELEASE + 1)) - BETA=0 - ALPHA=0 - BUILD=0 - ;; - esac - - NEW_EXT_VERSION="${RELEASE}.${BETA}.${ALPHA}.${BUILD}" - # 3-part semver for electron-builder (strips the BUILD digit) - NEW_VERSION="${RELEASE}.${BETA}.${ALPHA}" - - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.version = '${NEW_VERSION}'; - pkg.build.extraMetadata.version = '${NEW_EXT_VERSION}'; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " - echo "new-version=${NEW_EXT_VERSION}" >> "$GITHUB_OUTPUT" - echo "Bumped to: ${NEW_EXT_VERSION} (semver: ${NEW_VERSION})" - - name: Install dependencies run: npm ci - name: Run unit tests run: npm run test:run - # Push the version bump commit AFTER tests pass so a failed test doesn't - # advance the version counter. - - name: Commit and push version bump - if: github.event_name == 'push' && github.ref_name != 'uat' && github.actor != 'dependabot[bot]' && steps.bump.outputs.new-version != '' - run: | - NEW_VERSION="${{ steps.bump.outputs.new-version }}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json - # Only commit if package.json actually changed (idempotent guard) - git diff --cached --quiet || git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]" - git push - - # On main: create a release tag after the version bump commit is pushed. - - name: Tag release - if: github.event_name == 'push' && github.ref_name == 'main' && github.actor != 'dependabot[bot]' && steps.bump.outputs.new-version != '' - run: | - TAG="release/${{ steps.bump.outputs.new-version }}" - git tag "$TAG" - git push origin "$TAG" - # ─── Job 2: Build the .deb installer (only runs when all tests pass) ────────── build: name: Build Linux DEB @@ -135,7 +56,6 @@ jobs: actions: write # required to list and delete artifacts steps: - # Checkout the branch tip (picks up the version bump commit from the test job) - name: Checkout uses: actions/checkout@v6 with: diff --git a/README.md b/README.md index 109c083..50c20e5 100644 --- a/README.md +++ b/README.md @@ -225,9 +225,26 @@ The Scene, Scene Collection, Source, Input, and Transition pickers in the Proper 1. Install the plugin (see [Installing a plugin](#installing-a-plugin) above) 2. Open Discord desktop app -3. In the button's Property Inspector, enter Discord application `client_id` and `client_secret` -4. Click **Authorize** and approve the Discord prompt -5. For voice/text channel actions, pick a guild and channel from the inspector dropdowns +3. In the button's Property Inspector, enter Discord application `client_id` +4. Configure `relay_url` (recommended) so your `client_secret` and refresh token stay server-side +5. Click **Authorize** and approve the Discord prompt +6. For voice/text channel actions, pick a guild and channel from the inspector dropdowns + +Hosted relay quick start (free, Cloudflare Workers): + +```bash +# from repo root +cd discord-relay +wrangler login +wrangler kv namespace create DISCORD_SESSIONS +wrangler kv namespace create DISCORD_SESSIONS --preview +# paste generated KV IDs into discord-relay/wrangler.toml +wrangler secret put DISCORD_CLIENT_SECRET +wrangler secret put RELAY_API_KEY +wrangler deploy +``` + +Then paste the deployed Worker URL into the `Relay URL` field in the Discord inspector. If you set `RELAY_API_KEY`, also paste it into `Relay API Key`. --- diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs index c7a2797..0d174d2 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs @@ -13,6 +13,45 @@ const OPCODE_PING = 3 const OPCODE_PONG = 4 const REQUEST_TIMEOUT_MS = 12_000 +const RELAY_TIMEOUT_MS = 12_000 + +async function postJson(url, payload, headers = {}) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS) + + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload || {}), + signal: controller.signal, + }) + + let data = null + try { data = await res.json() } catch {} + + if (!res.ok) { + const reason = data?.error || data?.message || `HTTP ${res.status}` + throw new Error(reason) + } + + return data || {} + } catch (err) { + if (err?.name === 'AbortError') throw new Error('Relay request timed out') + throw err + } finally { + clearTimeout(timeout) + } +} + +function normalizeRelayUrl(relayUrl) { + const base = String(relayUrl || '').trim().replace(/\/+$/, '') + if (!base) return '' + if (!/^https?:\/\//i.test(base)) { + throw new Error('Relay URL must start with http:// or https://') + } + return base +} function makeNonce() { return crypto.randomBytes(8).toString('hex') @@ -314,7 +353,31 @@ class DiscordRpcClient { } } -async function exchangeAuthCode({ clientId, clientSecret, code }) { +async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId }) { + const relayBaseUrl = normalizeRelayUrl(relayUrl) + const relayKey = String(relayApiKey || '').trim() + const relaySession = String(relaySessionId || '').trim() + + if (relayBaseUrl) { + if (!clientId) throw new Error('Missing client ID for relay exchange') + if (!code) throw new Error('Missing authorization code from Discord') + + const relayPayload = await postJson( + `${relayBaseUrl}/oauth/discord/exchange`, + { clientId, code, sessionId: relaySession || null }, + relayKey ? { 'x-relay-key': relayKey } : {} + ) + + return { + accessToken: relayPayload.accessToken || '', + refreshToken: relayPayload.refreshToken || null, + sessionId: relayPayload.sessionId || relaySession || null, + expiresAt: Number(relayPayload.expiresAt || 0), + scope: relayPayload.scope || null, + tokenType: relayPayload.tokenType || null, + } + } + if (!clientId) throw new Error('Missing client ID for token exchange') if (!clientSecret) throw new Error('Missing client secret for token exchange') if (!code) throw new Error('Missing authorization code from Discord') @@ -349,7 +412,31 @@ async function exchangeAuthCode({ clientId, clientSecret, code }) { } } -async function refreshAccessToken({ clientId, clientSecret, refreshToken }) { +async function refreshAccessToken({ clientId, clientSecret, refreshToken, relayUrl, relayApiKey, relaySessionId }) { + const relayBaseUrl = normalizeRelayUrl(relayUrl) + const relayKey = String(relayApiKey || '').trim() + const relaySession = String(relaySessionId || '').trim() + + if (relayBaseUrl) { + if (!clientId) throw new Error('Missing client ID for relay refresh') + if (!relaySession && !refreshToken) throw new Error('Missing relay session or refresh token') + + const relayPayload = await postJson( + `${relayBaseUrl}/oauth/discord/access`, + { clientId, sessionId: relaySession || null, refreshToken: refreshToken || null }, + relayKey ? { 'x-relay-key': relayKey } : {} + ) + + return { + accessToken: relayPayload.accessToken || '', + refreshToken: relayPayload.refreshToken || refreshToken || null, + sessionId: relayPayload.sessionId || relaySession || null, + expiresAt: Number(relayPayload.expiresAt || 0), + scope: relayPayload.scope || null, + tokenType: relayPayload.tokenType || null, + } + } + if (!clientId) throw new Error('Missing client ID for refresh') if (!clientSecret) throw new Error('Missing client secret for refresh') if (!refreshToken) throw new Error('Missing refresh token') diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index a7c96d9..ee2111c 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -31,6 +31,9 @@ function sanitizeSettings(settings = {}) { return { clientId: String(settings.clientId || '').trim(), clientSecret: String(settings.clientSecret || '').trim(), + relayUrl: String(settings.relayUrl || '').trim(), + relayApiKey: String(settings.relayApiKey || '').trim(), + relaySessionId: String(settings.relaySessionId || '').trim(), accessToken: String(settings.accessToken || '').trim(), refreshToken: String(settings.refreshToken || '').trim(), expiresAt: Number(settings.expiresAt || 0), @@ -60,6 +63,33 @@ async function ensureAuthenticated(actionUUID, settings) { clientId: s.clientId, clientSecret: s.clientSecret, refreshToken: s.refreshToken, + relayUrl: s.relayUrl, + relayApiKey: s.relayApiKey, + relaySessionId: s.relaySessionId, + }) + + await rpc.authenticate(refreshed.accessToken) + + const patch = { + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + scope: refreshed.scope, + tokenType: refreshed.tokenType, + } + if (refreshed.refreshToken) patch.refreshToken = refreshed.refreshToken + if (refreshed.sessionId) patch.relaySessionId = refreshed.sessionId + if (s.relayUrl) { patch.clientSecret = ''; patch.refreshToken = '' } + + sendToInspector(actionUUID, { type: 'patchSettings', patch }) + return { ...s, ...refreshed } + } + + if (s.relayUrl && s.relaySessionId) { + const refreshed = await refreshAccessToken({ + clientId: s.clientId, + relayUrl: s.relayUrl, + relayApiKey: s.relayApiKey, + relaySessionId: s.relaySessionId, }) await rpc.authenticate(refreshed.accessToken) @@ -68,10 +98,12 @@ async function ensureAuthenticated(actionUUID, settings) { type: 'patchSettings', patch: { accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt, scope: refreshed.scope, tokenType: refreshed.tokenType, + relaySessionId: refreshed.sessionId || s.relaySessionId, + clientSecret: '', + refreshToken: '', }, }) @@ -222,19 +254,23 @@ async function handleInspectorMessage(actionUUID, payload) { clientId: s.clientId, clientSecret: s.clientSecret, code: auth.code, + relayUrl: s.relayUrl, + relayApiKey: s.relayApiKey, + relaySessionId: s.relaySessionId, }) await rpc.authenticate(token.accessToken) - sendToInspector(actionUUID, { - type: 'patchSettings', - patch: { - accessToken: token.accessToken, - refreshToken: token.refreshToken, - expiresAt: token.expiresAt, - scope: token.scope, - tokenType: token.tokenType, - }, - }) + const patch = { + accessToken: token.accessToken, + expiresAt: token.expiresAt, + scope: token.scope, + tokenType: token.tokenType, + } + if (token.refreshToken) patch.refreshToken = token.refreshToken + if (token.sessionId) patch.relaySessionId = token.sessionId + if (s.relayUrl) { patch.clientSecret = ''; patch.refreshToken = '' } + + sendToInspector(actionUUID, { type: 'patchSettings', patch }) sendToInspector(actionUUID, { type: 'status', diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html b/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html index c5a507a..c3a5e2d 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html @@ -140,8 +140,14 @@

Discord RPC Auth

- - + + + + + + + + @@ -153,7 +159,7 @@

Discord RPC Auth

- Authorize opens Discord's permission prompt. This action stores tokens in your local profile so the plugin can call RPC commands. + Authorize opens Discord's permission prompt. With a relay URL configured, your Discord client secret and refresh token stay on the relay and are not persisted locally.
@@ -219,6 +225,8 @@

Channel Selection

instruction: document.getElementById('instruction'), status: document.getElementById('status'), clientId: document.getElementById('clientId'), + relayUrl: document.getElementById('relayUrl'), + relayApiKey: document.getElementById('relayApiKey'), clientSecret: document.getElementById('clientSecret'), scopes: document.getElementById('scopes'), saveBtn: document.getElementById('saveBtn'), @@ -261,6 +269,8 @@

Channel Selection

function syncFormFromSettings() { el.clientId.value = settings.clientId || '' + el.relayUrl.value = settings.relayUrl || '' + el.relayApiKey.value = settings.relayApiKey || '' el.clientSecret.value = settings.clientSecret || '' el.scopes.value = settings.scopes || 'rpc identify' } @@ -332,18 +342,24 @@

Channel Selection

} el.saveBtn.addEventListener('click', () => { + const relayUrl = el.relayUrl.value.trim() persist({ clientId: el.clientId.value.trim(), - clientSecret: el.clientSecret.value.trim(), + relayUrl, + relayApiKey: el.relayApiKey.value.trim(), + clientSecret: relayUrl ? '' : el.clientSecret.value.trim(), scopes: el.scopes.value.trim() || 'rpc identify', }) setStatus('Saved settings', 'success') }) el.authorizeBtn.addEventListener('click', () => { + const relayUrl = el.relayUrl.value.trim() persist({ clientId: el.clientId.value.trim(), - clientSecret: el.clientSecret.value.trim(), + relayUrl, + relayApiKey: el.relayApiKey.value.trim(), + clientSecret: relayUrl ? '' : el.clientSecret.value.trim(), scopes: el.scopes.value.trim() || 'rpc identify', }) setStatus('Authorizing via Discord...', null) @@ -351,9 +367,12 @@

Channel Selection

}) el.refreshBtn.addEventListener('click', () => { + const relayUrl = el.relayUrl.value.trim() persist({ clientId: el.clientId.value.trim(), - clientSecret: el.clientSecret.value.trim(), + relayUrl, + relayApiKey: el.relayApiKey.value.trim(), + clientSecret: relayUrl ? '' : el.clientSecret.value.trim(), scopes: el.scopes.value.trim() || 'rpc identify', }) setStatus('Refreshing Discord data...', null) diff --git a/discord-relay/README.md b/discord-relay/README.md new file mode 100644 index 0000000..9c37ed7 --- /dev/null +++ b/discord-relay/README.md @@ -0,0 +1,46 @@ +# Discord OAuth Relay (Cloudflare Worker) + +This worker keeps the Discord client secret and refresh tokens on the server side. +The Stream Deck Discord plugin calls this relay for OAuth token exchange and refresh. + +## Endpoints + +- `GET /health` +- `POST /oauth/discord/exchange` +- `POST /oauth/discord/access` +- `POST /oauth/discord/revoke` + +## Required bindings and secrets + +1. Cloudflare KV namespace bound as `DISCORD_SESSIONS` +1. Secret: `DISCORD_CLIENT_SECRET` +1. Optional secret: `RELAY_API_KEY` + +## Local dev + +1. Install Wrangler + - `npm i -g wrangler` +1. Authenticate + - `wrangler login` +1. Create KV + - `wrangler kv namespace create DISCORD_SESSIONS` + - `wrangler kv namespace create DISCORD_SESSIONS --preview` +1. Put generated IDs into `wrangler.toml` +1. Set secrets + - `wrangler secret put DISCORD_CLIENT_SECRET` + - `wrangler secret put RELAY_API_KEY` +1. Start worker + - `wrangler dev` + +## Deploy + +1. `wrangler deploy` +1. Copy deployed worker URL +1. Put relay URL into the Discord action inspector +1. Put `RELAY_API_KEY` into the plugin setting if you enabled it + +## Security notes + +- In hosted mode, the plugin should not store `clientSecret` or `refreshToken`. +- The relay stores refresh tokens in KV with TTL (`SESSION_TTL_SECONDS`). +- Rotate `RELAY_API_KEY` and `DISCORD_CLIENT_SECRET` regularly. diff --git a/discord-relay/worker.mjs b/discord-relay/worker.mjs new file mode 100644 index 0000000..75de8fb --- /dev/null +++ b/discord-relay/worker.mjs @@ -0,0 +1,215 @@ +export default { + async fetch(request, env) { + try { + if (request.method !== 'POST' && !isHealthRequest(request)) { + return json({ error: 'Method not allowed' }, 405) + } + + if (isHealthRequest(request)) { + return json({ ok: true, service: 'discord-relay' }) + } + + const authError = checkRelayAuth(request, env) + if (authError) return authError + + const url = new URL(request.url) + if (url.pathname === '/oauth/discord/exchange') { + return handleExchange(request, env) + } + + if (url.pathname === '/oauth/discord/access') { + return handleAccess(request, env) + } + + if (url.pathname === '/oauth/discord/revoke') { + return handleRevoke(request, env) + } + + return json({ error: 'Not found' }, 404) + } catch (err) { + return json({ error: err?.message || 'Unhandled relay error' }, 500) + } + }, +} + +function isHealthRequest(request) { + const url = new URL(request.url) + return request.method === 'GET' && url.pathname === '/health' +} + +function checkRelayAuth(request, env) { + const requiredKey = String(env.RELAY_API_KEY || '').trim() + if (!requiredKey) return null + + const incomingKey = String(request.headers.get('x-relay-key') || '').trim() + if (incomingKey && incomingKey === requiredKey) return null + + return json({ error: 'Unauthorized relay request' }, 401) +} + +async function handleExchange(request, env) { + const body = await request.json() + const clientId = String(body?.clientId || '').trim() + const code = String(body?.code || '').trim() + const existingSessionId = String(body?.sessionId || '').trim() + + if (!clientId) return json({ error: 'Missing clientId' }, 400) + if (!code) return json({ error: 'Missing authorization code' }, 400) + + const discordPayload = await exchangeWithDiscord({ + clientId, + clientSecret: requiredSecret(env), + grantType: 'authorization_code', + code, + }) + + const sessionId = existingSessionId || crypto.randomUUID() + await writeSession(env, sessionId, { + clientId, + refreshToken: String(discordPayload.refresh_token || ''), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + updatedAt: Date.now(), + }) + + return json(toClientTokenResponse(discordPayload, sessionId)) +} + +async function handleAccess(request, env) { + const body = await request.json() + const clientId = String(body?.clientId || '').trim() + const sessionId = String(body?.sessionId || '').trim() + const legacyRefreshToken = String(body?.refreshToken || '').trim() + + if (!clientId) return json({ error: 'Missing clientId' }, 400) + + let refreshToken = legacyRefreshToken + if (!refreshToken && sessionId) { + const session = await readSession(env, sessionId) + if (!session) return json({ error: 'Relay session not found' }, 404) + if (session.clientId !== clientId) return json({ error: 'Session client mismatch' }, 400) + refreshToken = String(session.refreshToken || '') + } + + if (!refreshToken) { + return json({ error: 'Missing refresh token or relay session' }, 400) + } + + const discordPayload = await exchangeWithDiscord({ + clientId, + clientSecret: requiredSecret(env), + grantType: 'refresh_token', + refreshToken, + }) + + if (sessionId) { + await writeSession(env, sessionId, { + clientId, + refreshToken: String(discordPayload.refresh_token || refreshToken), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + updatedAt: Date.now(), + }) + } + + return json(toClientTokenResponse(discordPayload, sessionId || null)) +} + +async function handleRevoke(request, env) { + const body = await request.json() + const sessionId = String(body?.sessionId || '').trim() + + if (!sessionId) return json({ error: 'Missing sessionId' }, 400) + + await env.DISCORD_SESSIONS.delete(sessionKey(sessionId)) + return json({ revoked: true, sessionId }) +} + +async function exchangeWithDiscord({ clientId, clientSecret, grantType, code, refreshToken }) { + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: grantType, + }) + + if (code) params.set('code', code) + if (refreshToken) params.set('refresh_token', refreshToken) + + const res = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }) + + const payload = await safeJson(res) + if (!res.ok) { + const reason = payload?.error_description || payload?.error || `Discord HTTP ${res.status}` + throw new Error(`Discord token exchange failed: ${reason}`) + } + + return payload +} + +function toClientTokenResponse(discordPayload, sessionId) { + return { + accessToken: String(discordPayload.access_token || ''), + expiresAt: Date.now() + (Number(discordPayload.expires_in || 0) * 1000), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + sessionId: sessionId || null, + } +} + +async function writeSession(env, sessionId, data) { + const ttl = clampTtlSeconds(env.SESSION_TTL_SECONDS) + await env.DISCORD_SESSIONS.put(sessionKey(sessionId), JSON.stringify(data), { + expirationTtl: ttl, + }) +} + +async function readSession(env, sessionId) { + const raw = await env.DISCORD_SESSIONS.get(sessionKey(sessionId)) + if (!raw) return null + try { + return JSON.parse(raw) + } catch { + return null + } +} + +function sessionKey(sessionId) { + return `discord-session:${sessionId}` +} + +function requiredSecret(env) { + const value = String(env.DISCORD_CLIENT_SECRET || '').trim() + if (!value) throw new Error('Server misconfigured: DISCORD_CLIENT_SECRET is missing') + return value +} + +function clampTtlSeconds(raw) { + const fallback = 60 * 60 * 24 * 30 + const value = Number(raw) + if (!Number.isFinite(value)) return fallback + if (value < 60) return 60 + if (value > 60 * 60 * 24 * 90) return 60 * 60 * 24 * 90 + return Math.floor(value) +} + +function json(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function safeJson(response) { + try { + return await response.json() + } catch { + return null + } +} diff --git a/discord-relay/wrangler.toml b/discord-relay/wrangler.toml new file mode 100644 index 0000000..e0a6f87 --- /dev/null +++ b/discord-relay/wrangler.toml @@ -0,0 +1,15 @@ +name = "discord-rpc-relay" +main = "worker.mjs" +compatibility_date = "2026-06-04" + +[[kv_namespaces]] +binding = "DISCORD_SESSIONS" +id = "REPLACE_WITH_PROD_KV_ID" +preview_id = "REPLACE_WITH_PREVIEW_KV_ID" + +[vars] +SESSION_TTL_SECONDS = "2592000" + +# Set these with wrangler secret put: +# DISCORD_CLIENT_SECRET +# RELAY_API_KEY (optional but recommended) diff --git a/docs/installation-guide.md b/docs/installation-guide.md index 6c63f3e..f40a165 100644 --- a/docs/installation-guide.md +++ b/docs/installation-guide.md @@ -259,9 +259,37 @@ If you want the app to start automatically when you log in: --- +## Discord Plugin Hosted Relay Setup (Recommended) + +If you use the Discord plugin, configure a hosted OAuth relay to keep the Discord `client_secret` and refresh tokens off the local machine. + +1. Open a terminal in the project folder: + +```bash +cd discord-relay +wrangler login +wrangler kv namespace create DISCORD_SESSIONS +wrangler kv namespace create DISCORD_SESSIONS --preview +# copy generated KV IDs into discord-relay/wrangler.toml +wrangler secret put DISCORD_CLIENT_SECRET +wrangler secret put RELAY_API_KEY +wrangler deploy +``` + +2. Install the Discord plugin as usual into `~/.config/tech-stack-streamdeck/plugins/`. +3. In the Discord action inspector, set: + - `Client ID` — your Discord application client ID + - `Relay URL` — your deployed Worker URL + - `Relay API Key` — your relay key if enabled +4. Click **Authorize** and accept the Discord prompt. + +In hosted mode, local plugin settings will not keep `client_secret` or `refresh_token`. + +--- + ## Next Steps -- **Install plugins** (OBS Studio, Discord Push-to-Talk) — see the separate Plugin Installation Guide *(coming soon)* +- **Install plugins** (OBS Studio, Discord) — see the Plugin Installation Guide in the README - **Create profiles** — use the profile switcher at the top of the app to create named layouts for different workflows - **Icon Library** — assign images to your buttons by pointing the app at a local folder of `.png` / `.gif` files via the button editor panel