From e6002ad619e8f07cfbae6c9acb96612de3a1c1dd Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 7 Sep 2026 11:57:23 +0200 Subject: [PATCH] fix: block development writes to Discord channels --- src/sharing/config.ts | 4 ++- src/sharing/development-writes.test.ts | 36 ++++++++++++++++++++++++++ src/sharing/live-discord.ts | 7 +++-- src/sharing/routes.ts | 1 + 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/sharing/development-writes.test.ts diff --git a/src/sharing/config.ts b/src/sharing/config.ts index 4eae9d9..c48d4d7 100644 --- a/src/sharing/config.ts +++ b/src/sharing/config.ts @@ -1,10 +1,12 @@ -export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] }; +export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[]; writesEnabled?: boolean }; /** Read only from the server entrypoint; never include credentials in public config. */ export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig { const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean); if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs"); return { + // Real channel writes are production-only. Test fixtures may inject a stub config. + writesEnabled: env.NODE_ENV === "production", allowedUserIds, token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""), channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(), diff --git a/src/sharing/development-writes.test.ts b/src/sharing/development-writes.test.ts new file mode 100644 index 0000000..51f99eb --- /dev/null +++ b/src/sharing/development-writes.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'bun:test'; +import { fixture } from '../habits/test-fixture'; +import { createApi } from '../api'; +import { liveDiscordCards } from '../db/schema'; +import { readDiscordSharingConfig } from './config'; + +test('real Discord channel writes are enabled only in production', () => { + for (const NODE_ENV of ['development', 'test', undefined]) { + expect(readDiscordSharingConfig({ NODE_ENV, DISCORD_BOT_TOKEN: 'token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }).writesEnabled).toBe(false); + } + expect(readDiscordSharingConfig({ NODE_ENV: 'production' }).writesEnabled).toBe(true); +}); + +test('development preserves copied live state, allows PNG previews, and blocks all channel sends', async () => { + const f = fixture(); + f.db.insert(liveDiscordCards).values({ userId: 'alice', channelId: '223456789012345678', messageId: '423456789012345678', nonce: 'copied-nonce', theme: 'light', status: 'active', dirty: true, updatedAt: 1 }).run(); + const before = f.db.select().from(liveDiscordCards).all(); + const requests: string[] = []; + const app = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-signing-secret-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), async url => { + requests.push(url); throw new Error('Development contacted Discord'); + }, readDiscordSharingConfig({ NODE_ENV: 'development', DISCORD_BOT_TOKEN: 'real-looking-token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' })); + const headers = { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' }; + try { + for (const path of ['/sharing/live', '/sharing/discord/send']) { + const response = await app.request(`${f.origin}/api${path}`, { method: 'POST', headers, body: JSON.stringify({ theme: 'light' }) }); + expect(response.status).toBe(403); + } + await app.flushLiveSharing('alice'); + const preview = await app.request(`${f.origin}/api/sharing/live/preview`, { headers }); + expect(preview.status).toBe(200); + expect(preview.headers.get('Content-Type')).toBe('image/png'); + await Bun.sleep(2100); + expect(requests).toEqual([]); + expect(f.db.select().from(liveDiscordCards).all()).toEqual(before); + } finally { app.stopLiveSharing(); f.close(); } +}); diff --git a/src/sharing/live-discord.ts b/src/sharing/live-discord.ts index bba5fd2..56e7bf0 100644 --- a/src/sharing/live-discord.ts +++ b/src/sharing/live-discord.ts @@ -33,12 +33,13 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType { timers.delete(id); void flush(id); }, Math.max(0, delay)); timer.unref(); timers.set(id, timer); } function changed(id: string) { + if (config.writesEnabled === false) return; const current = row(id); if (!current || !['active', 'sending'].includes(current.status)) return; update(id, { dirty: true }); @@ -52,7 +53,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType { + if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403); if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.'); const user = c.get('user'); if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403); @@ -183,6 +185,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType { + if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403); const userId = c.get("user").id; if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403); if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");