From ad55de02325d6088092d471ff5ece99ce8cdfb66 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 12:21:00 -0600 Subject: [PATCH] Validate Discord app distribution config --- .../targets/chat-discord/src/index.test.ts | 34 +++++++++++++++++++ packages/targets/chat-discord/src/index.ts | 22 ++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/targets/chat-discord/src/index.test.ts b/packages/targets/chat-discord/src/index.test.ts index 9d235e24..d21e8c0a 100644 --- a/packages/targets/chat-discord/src/index.test.ts +++ b/packages/targets/chat-discord/src/index.test.ts @@ -92,6 +92,40 @@ describe('Discord chat target', () => { })).rejects.toThrow('DISCORD_APP_TOKEN not in vault'); }); + it('rejects non-numeric application IDs before writing manifests', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-discord-')); + tempDirs.push(outDir); + + await expect(adapter.build(fakeBuildContext({ + outDir, + version: '1.2.3', + }) as any, { + applicationId: 'abc-123', + distribution: 'public', + })).rejects.toThrow('numeric Discord snowflake'); + }); + + it('rejects unsupported distributions in dry-run shipping', async () => { + await expect(adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: true, + }) as any, { + applicationId: '123456', + distribution: 'server-listing', + } as any)).rejects.toThrow('distribution must be one of'); + }); + + it('rejects unsupported OAuth scopes in dry-run shipping', async () => { + await expect(adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: true, + }) as any, { + applicationId: '123456', + distribution: 'public', + scopes: ['identify'], + } as any)).rejects.toThrow('scope must be one of'); + }); + it('patches the application and overwrites global slash commands', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, status: 200, text: async () => '{"id":"123456"}' }) diff --git a/packages/targets/chat-discord/src/index.ts b/packages/targets/chat-discord/src/index.ts index 804e6eb1..b6d9b09e 100644 --- a/packages/targets/chat-discord/src/index.ts +++ b/packages/targets/chat-discord/src/index.ts @@ -27,12 +27,24 @@ interface DiscordCommand { options?: unknown[]; } +const DISTRIBUTIONS = ['private', 'public', 'directory'] as const; +const SCOPES = ['bot', 'applications.commands'] as const; + function requireApplicationId(config: Config): string { const applicationId = config.applicationId?.trim(); if (!applicationId) throw new Error('chat-discord requires applicationId'); + if (!/^\d+$/.test(applicationId)) throw new Error('chat-discord applicationId must be a numeric Discord snowflake'); return applicationId; } +function requireDistribution(config: Config): Config['distribution'] { + const distribution = String(config.distribution ?? '').trim(); + if (!DISTRIBUTIONS.includes(distribution as Config['distribution'])) { + throw new Error(`chat-discord distribution must be one of: ${DISTRIBUTIONS.join(', ')}`); + } + return distribution as Config['distribution']; +} + function normalizeCommands(commands: Config['slashCommands']): DiscordCommand[] { return (commands ?? []).map((command) => { const name = command.name.replace(/^\//, '').trim().toLowerCase(); @@ -49,7 +61,10 @@ function normalizeCommands(commands: Config['slashCommands']): DiscordCommand[] } function scopesFor(config: Config): string[] { - return config.scopes?.length ? config.scopes : ['bot', 'applications.commands']; + const scopes = config.scopes?.length ? config.scopes : ['bot', 'applications.commands']; + const invalid = scopes.find((scope) => !SCOPES.includes(scope as typeof SCOPES[number])); + if (invalid) throw new Error(`chat-discord scope must be one of: ${SCOPES.join(', ')}`); + return scopes; } function inviteUrl(config: Config): string { @@ -63,17 +78,18 @@ function inviteUrl(config: Config): string { function manifestFor(config: Config, version: string) { const commands = normalizeCommands(config.slashCommands); + const distribution = requireDistribution(config); return { provider: 'discord', applicationId: requireApplicationId(config), version, - distribution: config.distribution, + distribution, interactionsEndpointUrl: config.interactionsEndpointUrl, scopes: scopesFor(config), permissions: config.permissions ?? 0, inviteUrl: inviteUrl(config), commands, - directoryReviewRequired: config.distribution === 'directory', + directoryReviewRequired: distribution === 'directory', }; }