From 9eb712d933addfabc89d801051f16af82b4ac564 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 21:05:03 +0000 Subject: [PATCH 01/15] feat(api): restore commands/languages proxies to api.wolfstar.rocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport the old same-origin /api/commands and /api/languages structure, proxying to the internal bot API and normalizing alias → aliases. Co-authored-by: RedStar --- .env.example | 4 +- app/composables/createApiComposable.ts | 8 ++- app/composables/useCommands.ts | 2 +- app/composables/useLanguages.ts | 2 +- app/pages/commands.stories.ts | 6 +- app/plugins/api.ts | 26 -------- app/storybook/mocks/fixtures.ts | 3 +- app/storybook/mocks/handlers.ts | 9 +-- nuxt.config.ts | 11 ++++ server/api/commands.get.ts | 40 ++++++++++++ server/api/languages.get.ts | 40 ++++++++++++ server/utils/discord/index.ts | 47 +++++++++++++- server/utils/runtimeConfig.ts | 4 +- shared/utils/bot-api-commands.ts | 39 +++++++++++ shared/utils/index.ts | 1 + test/nuxt/api/commands-languages.spec.ts | 58 +++++++++++++++++ .../shared/utils/bot-api-commands.test.ts | 65 +++++++++++++++++++ 17 files changed, 321 insertions(+), 44 deletions(-) delete mode 100644 app/plugins/api.ts create mode 100644 server/api/commands.get.ts create mode 100644 server/api/languages.get.ts create mode 100644 shared/utils/bot-api-commands.ts create mode 100644 test/nuxt/api/commands-languages.spec.ts create mode 100644 test/unit/shared/utils/bot-api-commands.test.ts diff --git a/.env.example b/.env.example index 1a834d29c..cc8e11fd8 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,9 @@ NUXT_CLOUDFLARE_API_TOKEN= # =================================== # Website Environment (Required) -# API base url: +# WolfStar bot API origin used by server proxies (/api/commands, /api/languages, …) +# Production/internal: https://api.wolfstar.rocks (default when unset) +# Local bot instance: http://localhost:8282 NUXT_PUBLIC_API_BASE_URL=http://localhost:8282 # Site URL (e.g., https://wolfstar.rocks) diff --git a/app/composables/createApiComposable.ts b/app/composables/createApiComposable.ts index 55ee14aef..f2f00876c 100644 --- a/app/composables/createApiComposable.ts +++ b/app/composables/createApiComposable.ts @@ -2,18 +2,22 @@ export interface ApiComposableOptions { immediate?: boolean; } +/** + * Lazy-fetch composable for same-origin dashboard API routes (e.g. `/api/commands`). + * Uses the request-scoped fetch cache on SSR and plain `$fetch` on the client. + */ export function createApiComposable( key: string, endpoint: string, defaultValue: T, options?: ApiComposableOptions, ) { - const { $api } = useNuxtApp(); + const cachedFetch = useCachedFetch(); const asyncData = useLazyAsyncData( key, async () => { - const { data, isStale } = await $api(endpoint); + const { data, isStale } = await cachedFetch(endpoint); return { data, isStale }; }, { immediate: options?.immediate !== false }, diff --git a/app/composables/useCommands.ts b/app/composables/useCommands.ts index cd3a302ce..8198e8574 100644 --- a/app/composables/useCommands.ts +++ b/app/composables/useCommands.ts @@ -1,3 +1,3 @@ export function useCommands(options?: ApiComposableOptions) { - return createApiComposable("wolfstar:commands", "/commands", [], options); + return createApiComposable("wolfstar:commands", "/api/commands", [], options); } diff --git a/app/composables/useLanguages.ts b/app/composables/useLanguages.ts index 8cd95275c..20f6cb0bb 100644 --- a/app/composables/useLanguages.ts +++ b/app/composables/useLanguages.ts @@ -1,3 +1,3 @@ export function useLanguages(options?: ApiComposableOptions) { - return createApiComposable("wolfstar:languages", "/languages", [], options); + return createApiComposable("wolfstar:languages", "/api/languages", [], options); } diff --git a/app/pages/commands.stories.ts b/app/pages/commands.stories.ts index e338308bb..e3ac7b976 100644 --- a/app/pages/commands.stories.ts +++ b/app/pages/commands.stories.ts @@ -11,7 +11,7 @@ const meta: Meta = { parameters: { layout: "fullscreen", msw: { - handlers: [http.get(/\/commands$/, () => HttpResponse.json(mockCommands))], + handlers: [http.get(/\/api\/commands$/, () => HttpResponse.json(mockCommands))], }, }, }; @@ -24,7 +24,7 @@ export const WithCommands: Story = {}; export const Empty: Story = { parameters: { msw: { - handlers: [http.get(/\/commands$/, () => HttpResponse.json([]))], + handlers: [http.get(/\/api\/commands$/, () => HttpResponse.json([]))], }, }, }; @@ -36,7 +36,7 @@ export const Loading: Story = { // stable for the snapshot. A fixed timeout (e.g. 60s) would either // resolve mid-capture or trip Chromatic's per-story capture timeout. handlers: [ - http.get(/\/commands$/, async () => { + http.get(/\/api\/commands$/, async () => { await delay("infinite"); return HttpResponse.json(mockCommands); }), diff --git a/app/plugins/api.ts b/app/plugins/api.ts deleted file mode 100644 index e2a0b651b..000000000 --- a/app/plugins/api.ts +++ /dev/null @@ -1,26 +0,0 @@ -export default defineNuxtPlugin(() => { - const cachedFetch = useCachedFetch(); - const runtimeConfig = useRuntimeConfig(); - const apiBaseUrl = runtimeConfig.public.apiBaseUrl; - - return { - provide: { - api: ( - url: Parameters[0], - options?: Parameters[1], - ttl?: Parameters[2], - ) => { - return cachedFetch( - url, - { - ...options, - baseURL: apiBaseUrl, - credentials: "include", - headers: { "Content-Type": "application/json", ...options?.headers }, - }, - ttl, - ); - }, - }, - }; -}); diff --git a/app/storybook/mocks/fixtures.ts b/app/storybook/mocks/fixtures.ts index 6aba01a60..fc24ed7aa 100644 --- a/app/storybook/mocks/fixtures.ts +++ b/app/storybook/mocks/fixtures.ts @@ -70,7 +70,8 @@ export const mockUser = { export const mockCommands: WolfCommand[] = [ { category: "Admin", - alias: ["rs"], + aliases: ["rs"], + subCategory: "", description: "Manage unique role sets.", extendedHelp: { usages: [ diff --git a/app/storybook/mocks/handlers.ts b/app/storybook/mocks/handlers.ts index aa8b8e474..3ecc03f3c 100644 --- a/app/storybook/mocks/handlers.ts +++ b/app/storybook/mocks/handlers.ts @@ -32,10 +32,7 @@ export const handlers = [ }), ), - // External WolfStar bot API — proxied through createApiComposable("/commands") - http.get(/\/commands$/, () => HttpResponse.json(mockCommands)), - - // External WolfStar bot API, proxied through createApiComposable("/languages"). - // The dashboard manage page requests this on mount for the default section. - http.get(/\/languages$/, () => HttpResponse.json(["en-US", "es-ES", "fr-FR"])), + // Same-origin bot-data proxies used by createApiComposable + http.get(/\/api\/commands$/, () => HttpResponse.json(mockCommands)), + http.get(/\/api\/languages$/, () => HttpResponse.json(["en-US", "es-ES", "fr-FR"])), ]; diff --git a/nuxt.config.ts b/nuxt.config.ts index c12998da5..29b21998c 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -220,6 +220,17 @@ export default defineNuxtConfig({ "Vary": "Cookie, Authorization", }, }, + // Public bot-data proxies — safe to CDN-cache (no user-specific data). + "/api/commands": { + headers: { + "Cache-Control": "public, max-age=3600, stale-while-revalidate=21600", + }, + }, + "/api/languages": { + headers: { + "Cache-Control": "public, max-age=86400, stale-while-revalidate=86400", + }, + }, "/oauth/**": { robots: "nosnippet,notranslate,noimageindex,noarchive,max-snippet:-1,max-image-preview:none,max-video-preview:-1", diff --git a/server/api/commands.get.ts b/server/api/commands.get.ts new file mode 100644 index 000000000..b0679d0d8 --- /dev/null +++ b/server/api/commands.get.ts @@ -0,0 +1,40 @@ +import { createError } from "evlog"; + +/** + * Public proxy for the WolfStar bot commands list. + * Fetches from the internal bot API (`NUXT_PUBLIC_API_BASE_URL`, e.g. + * https://api.wolfstar.rocks) and returns the dashboard WolfCommand shape. + */ +export default defineWrappedCachedResponseHandler( + async () => { + const { + public: { apiBaseUrl }, + } = useRuntimeConfig(); + + if (!apiBaseUrl) { + throw createError({ + message: "Bot API base URL is not configured", + status: 500, + why: "NUXT_PUBLIC_API_BASE_URL is missing", + fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", + }); + } + + return await fetchCommands(); + }, + { + auth: false, + getKey: () => "bot-commands", + maxAge: seconds.fromHours(6), + swr: true, + rateLimit: { + enabled: true, + limit: 30, + type: "sliding", + window: seconds(60), + }, + onError(log, error) { + log.error(error); + }, + }, +); diff --git a/server/api/languages.get.ts b/server/api/languages.get.ts new file mode 100644 index 000000000..604850bd2 --- /dev/null +++ b/server/api/languages.get.ts @@ -0,0 +1,40 @@ +import { createError } from "evlog"; + +/** + * Public proxy for supported WolfStar bot languages. + * Fetches from the internal bot API (`NUXT_PUBLIC_API_BASE_URL`, e.g. + * https://api.wolfstar.rocks). + */ +export default defineWrappedCachedResponseHandler( + async () => { + const { + public: { apiBaseUrl }, + } = useRuntimeConfig(); + + if (!apiBaseUrl) { + throw createError({ + message: "Bot API base URL is not configured", + status: 500, + why: "NUXT_PUBLIC_API_BASE_URL is missing", + fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", + }); + } + + return await fetchLanguages(); + }, + { + auth: false, + getKey: () => "bot-languages", + maxAge: seconds.fromDays(1), + swr: true, + rateLimit: { + enabled: true, + limit: 30, + type: "sliding", + window: seconds(60), + }, + onError(log, error) { + log.error(error); + }, + }, +); diff --git a/server/utils/discord/index.ts b/server/utils/discord/index.ts index dfadbe75f..c5906959f 100644 --- a/server/utils/discord/index.ts +++ b/server/utils/discord/index.ts @@ -28,6 +28,10 @@ import { fetchGuildMemberWithRetry, } from "#server/utils/discord/oauth"; import { PermissionsBits } from "#shared/utils/bits"; +import { + type BotApiCommand, + normalizeBotCommands, +} from "#shared/utils/bot-api-commands"; import { hours } from "#shared/utils/times"; import { cast } from "@sapphire/utilities"; import { hasAtLeastOneKeyInMap } from "@sapphire/utilities/hasAtLeastOneKeyInMap"; @@ -476,21 +480,60 @@ export const fetchCommands = defineCachedFunction( const { public: { apiBaseUrl }, } = useRuntimeConfig(); + if (!apiBaseUrl) { + throw createError({ + message: "Bot API base URL is not configured", + status: 500, + why: "NUXT_PUBLIC_API_BASE_URL is missing", + fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", + }); + } Sentry.metrics.count("bot_api.call", 1, { attributes: { endpoint: "commands.fetch" }, }); const commands = await instrumentBotApiCall("commands.fetch", () => - $fetch(`${apiBaseUrl}/commands`, { + $fetch(`${apiBaseUrl}/commands`, { credentials: "include", headers: { "Content-Type": "application/json", }, }), ); - return commands; + return normalizeBotCommands(commands); }, { maxAge: hours(1), getKey: () => "commands", }, ); + +export const fetchLanguages = defineCachedFunction( + async () => { + const { + public: { apiBaseUrl }, + } = useRuntimeConfig(); + if (!apiBaseUrl) { + throw createError({ + message: "Bot API base URL is not configured", + status: 500, + why: "NUXT_PUBLIC_API_BASE_URL is missing", + fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", + }); + } + Sentry.metrics.count("bot_api.call", 1, { + attributes: { endpoint: "languages.fetch" }, + }); + return await instrumentBotApiCall("languages.fetch", () => + $fetch(`${apiBaseUrl}/languages`, { + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }), + ); + }, + { + maxAge: hours(1), + getKey: () => "languages", + }, +); diff --git a/server/utils/runtimeConfig.ts b/server/utils/runtimeConfig.ts index 7648cd1a2..b07ac58b2 100644 --- a/server/utils/runtimeConfig.ts +++ b/server/utils/runtimeConfig.ts @@ -46,7 +46,9 @@ export function generateRuntimeConfig() { redirectURI: process.env.NUXT_OAUTH_DISCORD_REDIRECT_URL, }, public: { - apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL, + // Internal WolfStar bot API. Production default is api.wolfstar.rocks; + // override with NUXT_PUBLIC_API_BASE_URL for local bot instances. + apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || "https://api.wolfstar.rocks", clientId: process.env.NUXT_OAUTH_DISCORD_CLIENT_ID, environment: process.env.NODE_ENV ?? "production", sentry: { diff --git a/shared/utils/bot-api-commands.ts b/shared/utils/bot-api-commands.ts new file mode 100644 index 000000000..f00e14aee --- /dev/null +++ b/shared/utils/bot-api-commands.ts @@ -0,0 +1,39 @@ +import type { FlattenedCommand, Preconditions, WolfCommand } from "#shared/types/discord"; + +/** + * Raw command payload from the WolfStar bot API (`/commands`). + * The bot serializes Sapphire `aliases` as the singular `alias` field. + */ +export interface BotApiCommand { + alias?: string[]; + category: string; + description: string; + extendedHelp: FlattenedCommand["extendedHelp"]; + guarded: boolean; + name: string; + permissionLevel: number; + preconditions: Preconditions; + subCategory?: string | null; +} + +/** + * Normalize a bot API command into the dashboard's WolfCommand shape + * (`aliases` + non-null `subCategory`). + */ +export function normalizeBotCommand(command: BotApiCommand): WolfCommand { + return { + aliases: command.alias ?? [], + category: command.category, + description: command.description, + extendedHelp: command.extendedHelp, + guarded: command.guarded, + name: command.name, + permissionLevel: command.permissionLevel, + preconditions: command.preconditions, + subCategory: command.subCategory ?? "", + }; +} + +export function normalizeBotCommands(commands: BotApiCommand[]): WolfCommand[] { + return commands.map(normalizeBotCommand); +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 2ad52ead2..b25dbfff9 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,5 +1,6 @@ export * from "./discord"; export * from "./bits"; +export * from "./bot-api-commands"; export * from "./comparators"; export * from "./fetch-cache-config"; export * from "./isDeepEqual"; diff --git a/test/nuxt/api/commands-languages.spec.ts b/test/nuxt/api/commands-languages.spec.ts new file mode 100644 index 000000000..f7ffad825 --- /dev/null +++ b/test/nuxt/api/commands-languages.spec.ts @@ -0,0 +1,58 @@ +/** + * Contract tests for public bot-data proxy endpoints. + * + * Real handlers: + * - `server/api/commands.get.ts` + * - `server/api/languages.get.ts` + * + * These routes are unauthenticated proxies to the internal WolfStar bot API + * (`NUXT_PUBLIC_API_BASE_URL`). Contract tests mock the Nitro endpoints to + * validate URL wiring and response shape without hitting the live bot API. + */ + +import type { WolfCommand } from "#shared/types/discord"; +import { registerEndpoint } from "@nuxt/test-utils/runtime"; +import { describe, expect, it } from "vitest"; + +const MOCK_COMMANDS: WolfCommand[] = [ + { + aliases: ["vk"], + category: "Moderation", + description: "Kick a user from voice", + extendedHelp: {}, + guarded: false, + name: "voicekick", + permissionLevel: 5, + preconditions: { entries: [], mode: 0, runCondition: 0 }, + subCategory: "", + }, +]; + +const MOCK_LANGUAGES = ["en-US", "it", "es-ES"]; + +registerEndpoint("/api/commands", { + method: "GET", + handler: () => MOCK_COMMANDS, +}); + +registerEndpoint("/api/languages", { + method: "GET", + handler: () => MOCK_LANGUAGES, +}); + +describe("GET /api/commands", () => { + it("returns a list of normalized WolfCommand objects", async () => { + const data = await $fetch("/api/commands"); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(1); + expect(data[0]?.name).toBe("voicekick"); + expect(data[0]?.aliases).toStrictEqual(["vk"]); + }); +}); + +describe("GET /api/languages", () => { + it("returns a list of language codes", async () => { + const data = await $fetch("/api/languages"); + expect(data).toStrictEqual(MOCK_LANGUAGES); + }); +}); diff --git a/test/unit/shared/utils/bot-api-commands.test.ts b/test/unit/shared/utils/bot-api-commands.test.ts new file mode 100644 index 000000000..7a66e4e66 --- /dev/null +++ b/test/unit/shared/utils/bot-api-commands.test.ts @@ -0,0 +1,65 @@ +import type { BotApiCommand } from "#shared/utils/bot-api-commands"; +import { normalizeBotCommand, normalizeBotCommands } from "#shared/utils/bot-api-commands"; +import { describe, expect, it } from "vitest"; + +const sampleBotCommand: BotApiCommand = { + alias: ["vk", "vkick"], + category: "Moderation", + description: "Kick a user from voice", + extendedHelp: { + usages: ["User"], + examples: ["@Pete"], + }, + guarded: false, + name: "voicekick", + permissionLevel: 5, + preconditions: { + entries: [], + mode: 0, + runCondition: 0, + }, + subCategory: null, +}; + +describe("normalizeBotCommand", () => { + it("maps bot API alias to dashboard aliases and coerces null subCategory", () => { + expect(normalizeBotCommand(sampleBotCommand)).toStrictEqual({ + aliases: ["vk", "vkick"], + category: "Moderation", + description: "Kick a user from voice", + extendedHelp: { + usages: ["User"], + examples: ["@Pete"], + }, + guarded: false, + name: "voicekick", + permissionLevel: 5, + preconditions: { + entries: [], + mode: 0, + runCondition: 0, + }, + subCategory: "", + }); + }); + + it("defaults missing alias to an empty array", () => { + const { alias: _alias, ...withoutAlias } = sampleBotCommand; + expect(normalizeBotCommand(withoutAlias).aliases).toStrictEqual([]); + }); + + it("preserves a non-null subCategory", () => { + expect( + normalizeBotCommand({ ...sampleBotCommand, subCategory: "Voice" }).subCategory, + ).toBe("Voice"); + }); +}); + +describe("normalizeBotCommands", () => { + it("normalizes each command in the list", () => { + const result = normalizeBotCommands([sampleBotCommand]); + expect(result).toHaveLength(1); + expect(result[0]?.aliases).toStrictEqual(["vk", "vkick"]); + expect(result[0]?.subCategory).toBe(""); + }); +}); From 171604888406f9d38ab564b76d4adc011cf284af Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:07:09 +0000 Subject: [PATCH 02/15] [autofix.ci] apply automated fixes --- server/utils/discord/index.ts | 5 +---- test/unit/shared/utils/bot-api-commands.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/server/utils/discord/index.ts b/server/utils/discord/index.ts index c5906959f..d6574355a 100644 --- a/server/utils/discord/index.ts +++ b/server/utils/discord/index.ts @@ -28,10 +28,7 @@ import { fetchGuildMemberWithRetry, } from "#server/utils/discord/oauth"; import { PermissionsBits } from "#shared/utils/bits"; -import { - type BotApiCommand, - normalizeBotCommands, -} from "#shared/utils/bot-api-commands"; +import { type BotApiCommand, normalizeBotCommands } from "#shared/utils/bot-api-commands"; import { hours } from "#shared/utils/times"; import { cast } from "@sapphire/utilities"; import { hasAtLeastOneKeyInMap } from "@sapphire/utilities/hasAtLeastOneKeyInMap"; diff --git a/test/unit/shared/utils/bot-api-commands.test.ts b/test/unit/shared/utils/bot-api-commands.test.ts index 7a66e4e66..750dae411 100644 --- a/test/unit/shared/utils/bot-api-commands.test.ts +++ b/test/unit/shared/utils/bot-api-commands.test.ts @@ -49,9 +49,9 @@ describe("normalizeBotCommand", () => { }); it("preserves a non-null subCategory", () => { - expect( - normalizeBotCommand({ ...sampleBotCommand, subCategory: "Voice" }).subCategory, - ).toBe("Voice"); + expect(normalizeBotCommand({ ...sampleBotCommand, subCategory: "Voice" }).subCategory).toBe( + "Voice", + ); }); }); From e210b2c02955a74d3f4f33902ebb856dd18601ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 21:52:19 +0000 Subject: [PATCH 03/15] feat(api): proxy full guild API surface to api.wolfstar.rocks Route dashboard guild/commands/languages handlers through sapphire-plugin-api with SAPPHIRE_AUTH cookie auth, instead of calling Discord REST locally. Keep moderation logs, members list, users, and auth on the Nuxt server. Co-authored-by: RedStar --- .env.example | 7 +- server/api/commands.get.ts | 18 +- .../guilds/[guild]/channels/[channel].get.ts | 47 +---- .../api/guilds/[guild]/channels/index.get.ts | 38 +--- server/api/guilds/[guild]/index.get.ts | 60 +----- .../api/guilds/[guild]/logs/commands.get.ts | 90 ++------- server/api/guilds/[guild]/logs/index.get.ts | 83 ++------ .../guilds/[guild]/members/[member].get.ts | 23 +-- server/api/guilds/[guild]/roles/[role].get.ts | 32 +-- server/api/guilds/[guild]/roles/index.get.ts | 25 +++ server/api/guilds/[guild]/settings.get.ts | 25 +-- server/api/guilds/[guild]/settings.patch.ts | 102 ++-------- server/api/languages.get.ts | 18 +- server/utils/bot-api.ts | 185 ++++++++++++++++++ .../guilds-index-get-cached-channels.spec.ts | 150 ++------------ .../api/guilds-settings-get-dedup.spec.ts | 75 ++----- test/unit/server/utils/bot-api.spec.ts | 57 ++++++ 17 files changed, 390 insertions(+), 645 deletions(-) create mode 100644 server/api/guilds/[guild]/roles/index.get.ts create mode 100644 server/utils/bot-api.ts create mode 100644 test/unit/server/utils/bot-api.spec.ts diff --git a/.env.example b/.env.example index cc8e11fd8..c0e3c4567 100644 --- a/.env.example +++ b/.env.example @@ -41,11 +41,16 @@ NUXT_CLOUDFLARE_API_TOKEN= # =================================== # Website Environment (Required) -# WolfStar bot API origin used by server proxies (/api/commands, /api/languages, …) +# WolfStar bot API origin used by server proxies (/api/commands, /api/guilds/*, …) # Production/internal: https://api.wolfstar.rocks (default when unset) # Local bot instance: http://localhost:8282 NUXT_PUBLIC_API_BASE_URL=http://localhost:8282 +# Optional: sapphire-plugin-api cookie name / OAuth secret for authenticated bot +# API proxies. Defaults: cookie SAPPHIRE_AUTH, secret = Discord client secret. +# NUXT_BOT_API_AUTH_COOKIE=SAPPHIRE_AUTH +# NUXT_BOT_API_OAUTH_SECRET= + # Site URL (e.g., https://wolfstar.rocks) NUXT_PUBLIC_SITE_URL= # =================================== diff --git a/server/api/commands.get.ts b/server/api/commands.get.ts index b0679d0d8..f38807002 100644 --- a/server/api/commands.get.ts +++ b/server/api/commands.get.ts @@ -1,25 +1,9 @@ -import { createError } from "evlog"; - /** * Public proxy for the WolfStar bot commands list. - * Fetches from the internal bot API (`NUXT_PUBLIC_API_BASE_URL`, e.g. - * https://api.wolfstar.rocks) and returns the dashboard WolfCommand shape. + * Fetches from the internal bot API and returns the dashboard WolfCommand shape. */ export default defineWrappedCachedResponseHandler( async () => { - const { - public: { apiBaseUrl }, - } = useRuntimeConfig(); - - if (!apiBaseUrl) { - throw createError({ - message: "Bot API base URL is not configured", - status: 500, - why: "NUXT_PUBLIC_API_BASE_URL is missing", - fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", - }); - } - return await fetchCommands(); }, { diff --git a/server/api/guilds/[guild]/channels/[channel].get.ts b/server/api/guilds/[guild]/channels/[channel].get.ts index a49b8e370..494959fa5 100644 --- a/server/api/guilds/[guild]/channels/[channel].get.ts +++ b/server/api/guilds/[guild]/channels/[channel].get.ts @@ -1,28 +1,15 @@ import { isNullOrUndefined } from "@sapphire/utilities/isNullish"; import { createError, useLogger } from "evlog"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/channels/:channel + */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - fix: "check bot is a member of the guild", - }); - } - - const currentMember = await getCurrentMember(event, guild.id); - log.set({ member: { id: currentMember.user.id } }); - - await canManage(guild, currentMember); - const channelId = getRouterParam(event, "channel"); if (isNullOrUndefined(channelId)) { throw createError({ @@ -34,33 +21,7 @@ export default defineWrappedResponseHandler( } log.set({ channel: { id: channelId } }); - const channels = await $fetch[]>( - `/api/guilds/${guildId}/channels`, - { - headers: event.headers, - }, - ).catch((error) => { - log.error(error); - throw createError({ - message: "Failed to fetch channels", - status: 500, - why: `Discord API error while fetching channels for guild ${guildId}`, - cause: error, - }); - }); - - const channel = channels.find((channel: any) => channel.id === channelId) ?? null; - - if (isNullOrUndefined(channel)) { - throw createError({ - message: "Channel not found", - status: 404, - why: `No channel with ID "${channelId}" exists in guild ${guildId}`, - fix: "Verify the channel ID is correct and belongs to this guild", - }); - } - - return channel; + return await fetchBotApi(event, `/guilds/${guildId}/channels/${channelId}`); }, { auth: true, diff --git a/server/api/guilds/[guild]/channels/index.get.ts b/server/api/guilds/[guild]/channels/index.get.ts index 217411c77..b545554ef 100644 --- a/server/api/guilds/[guild]/channels/index.get.ts +++ b/server/api/guilds/[guild]/channels/index.get.ts @@ -1,39 +1,21 @@ -import { createError, useLogger } from "evlog"; +import { useLogger } from "evlog"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/channels + */ export default defineWrappedResponseHandler( async (event) => { - const api = useApi(); const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - fix: "check bot is a member of the guild", - }); - } - - const member = await getCurrentMember(event, guild.id); - log.set({ member: { id: member.user.id } }); - await canManage(guild, member); - - const channels = await api.guilds.getChannels(guild.id).catch((error) => { - log.error(error); - throw createError({ - message: "Failed to fetch channels", - status: 500, - why: "Discord API returned an error when fetching the guild's channel list", - cause: error, - }); + const channels = await fetchBotApi(event, `/guilds/${guildId}/channels`); + log.set({ + result: { + channelCount: Array.isArray(channels) ? channels.length : undefined, + }, }); - - log.set({ result: { channelCount: channels.length } }); - return channels.map((channel) => flattenGuildChannel(channel as any)); + return channels; }, { auth: true, diff --git a/server/api/guilds/[guild]/index.get.ts b/server/api/guilds/[guild]/index.get.ts index 7240db37e..1f085fe3e 100644 --- a/server/api/guilds/[guild]/index.get.ts +++ b/server/api/guilds/[guild]/index.get.ts @@ -1,64 +1,18 @@ -import type { RESTAPIPartialCurrentUserGuild } from "discord-api-types/v10"; -import { readSettings } from "#server/database"; -import { GuildQuerySchema } from "#shared/schemas"; -import { createError, useLogger } from "evlog"; -import { parse } from "valibot"; +import { useLogger } from "evlog"; +/** + * Proxies to the internal bot API: GET /guilds/:guild + */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const { shouldSerialize } = await getValidatedQuery(event, (body) => - parse(GuildQuerySchema, body), - ); - - const guild = await resolveGuildForRequest(event, guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - }); - } - - const member = await getCurrentMember(event, guild.id); - log.set({ member: { id: member.user.id } }); - - const settings = await readSettings(guild.id); - await canManage(guild, member, settings); - - const channels = await getGuildChannels(guild.id).catch((error) => { - log.error(error); - throw createError({ - message: "Failed to fetch channels", - status: 500, - why: `Discord API error while fetching channels for guild ${guildId}`, - cause: error, - }); - }); - - const result = shouldSerialize - ? { - ...(await transformGuild( - member.user.id, - guild as RESTAPIPartialCurrentUserGuild, - { - includeChannels: false, - prefetchedGuild: guild, - prefetchedMember: member, - prefetchedSettings: settings, - }, - )), - channels, - } - : flattenGuild({ ...guild, channels }); - log.set({ - result: { channelCount: channels.length, serialized: Boolean(shouldSerialize) }, + const query = getQuery(event); + return await fetchBotApi(event, `/guilds/${guildId}`, { + query: query as Record, }); - return result; }, { auth: true, diff --git a/server/api/guilds/[guild]/logs/commands.get.ts b/server/api/guilds/[guild]/logs/commands.get.ts index 247a7a3e4..07b827068 100644 --- a/server/api/guilds/[guild]/logs/commands.get.ts +++ b/server/api/guilds/[guild]/logs/commands.get.ts @@ -1,94 +1,36 @@ -import type { CommandLogData } from "#server/database"; -import prisma from "#server/database/prisma"; -import { fallbackMember, resolveGuildMembers } from "#server/utils/audit/resolve-members"; import { CommandLogQuerySchema } from "#shared/schemas"; -import { useLogger } from "evlog"; +import { createError, useLogger } from "evlog"; import { parse } from "valibot"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/command-logs + * (dashboard path remains /api/guilds/:guild/logs/commands) + */ export default defineWrappedCachedResponseHandler( async (event) => { const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - // Authorization runs in the `authorize` hook on every request; the - // cached resolver only needs the guild for data assembly. - const guild = await getGuild(guildId); - if (!guild) throw createError({ status: 404, message: "Guild not found" }); - - const { limit, offset, userId, commandName, success, from, to, q } = - await getValidatedQuery(event, (body) => parse(CommandLogQuerySchema, body)); - - const where = { - guildId, - ...(userId && { userId }), - ...(commandName && { commandName }), - ...(success !== "all" && { success: success === "success" }), - ...(from || to - ? { - executedAt: { - ...(from && { gte: new Date(from) }), - ...(to && { lte: new Date(to) }), - }, - } - : {}), - ...(q && { - OR: [ - { commandName: { contains: q, mode: "insensitive" as const } }, - { errorReason: { contains: q, mode: "insensitive" as const } }, - ], - }), - }; - - const [rows, total] = await Promise.all([ - prisma.commandLog.findMany({ - where, - orderBy: { executedAt: "desc" }, - take: limit, - skip: offset, - }), - prisma.commandLog.count({ where }), - ]); + const query = await getValidatedQuery(event, (body) => parse(CommandLogQuerySchema, body)); - const memberMap = await resolveGuildMembers(guildId, [ - ...new Set(rows.map((r) => r.userId)), - ]); - - const entries: CommandLogData[] = rows.map((row) => { - const member = memberMap.get(row.userId) ?? fallbackMember(row.userId); - return { - id: row.id, - guildId: row.guildId, - userId: row.userId, - commandName: row.commandName, - commandType: row.commandType, - commandId: row.commandId ?? null, - subcommand: row.subcommand ?? null, - channelId: row.channelId ?? null, - success: row.success, - errorReason: row.errorReason ?? null, - executedAt: row.executedAt, - latencyMs: row.latencyMs ?? null, - metadata: { member }, - }; + return await fetchBotApi(event, `/guilds/${guildId}/command-logs`, { + query: query as Record, }); - - return { entries, total }; }, { auth: true, maxAge: 30, swr: false, authorize: async (event) => { - const guildId = getGuildParam(event); - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ message: "Guild not found", status: 404 }); + const session = await getUserSession(event); + if (!session?.user?.id) { + throw createError({ status: 401, message: "Unauthorized" }); } - const member = await getCurrentMember(event, guild.id); - useLogger(event).set({ guild: { id: guildId }, member: { id: member.user.id } }); - await canManage(guild, member); + useLogger(event).set({ + guild: { id: getGuildParam(event) }, + member: { id: session.user.id }, + }); }, getKey: (event) => { const guildId = getGuildParam(event); @@ -98,6 +40,6 @@ export default defineWrappedCachedResponseHandler( onError(log, error) { log.error(error); }, - rateLimit: { enabled: true, limit: 30, window: seconds(60) }, + rateLimit: { enabled: true, limit: 5, window: seconds(10) }, }, ); diff --git a/server/api/guilds/[guild]/logs/index.get.ts b/server/api/guilds/[guild]/logs/index.get.ts index 1a92404e3..2d21af3fa 100644 --- a/server/api/guilds/[guild]/logs/index.get.ts +++ b/server/api/guilds/[guild]/logs/index.get.ts @@ -1,95 +1,48 @@ -import type { DashboardAuditEntry } from "#shared/types/audit-log"; -import prisma from "#server/database/prisma"; -import { patchToChanges } from "#server/utils/audit/patch-to-changes"; -import { fallbackMember, resolveGuildMembers } from "#server/utils/audit/resolve-members"; -import { DASHBOARD_AUDIT_ACTIONS } from "#shared/audit/actions"; import { DashboardActivityQuerySchema } from "#shared/schemas"; import { createError, useLogger } from "evlog"; import { parse } from "valibot"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/audit-logs + * (dashboard path remains /api/guilds/:guild/logs for backwards compatibility) + */ export default defineWrappedCachedResponseHandler( async (event) => { const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - // Authorization runs in the `authorize` hook on every request; the - // cached resolver only needs the guild for data assembly. - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ message: "Guild not found", status: 404 }); - } - - const { limit, offset, actorId, from, to, q } = await getValidatedQuery(event, (body) => + const query = await getValidatedQuery(event, (body) => parse(DashboardActivityQuerySchema, body), ); - const where = { - tenantId: guild.id, - action: { in: [...DASHBOARD_AUDIT_ACTIONS] }, - ...(actorId && { actorId }), - ...(from || to - ? { - timestamp: { - ...(from && { gte: new Date(from) }), - ...(to && { lte: new Date(to) }), - }, - } - : {}), - ...(q && { reason: { contains: q, mode: "insensitive" as const } }), - }; - - const [rows, total] = await Promise.all([ - prisma.auditEvent.findMany({ - where, - orderBy: { timestamp: "desc" }, - take: limit, - skip: offset, - }), - prisma.auditEvent.count({ where }), - ]); - - const memberMap = await resolveGuildMembers( - guild.id, - rows.map((r) => r.actorId), - ); - - const entries: DashboardAuditEntry[] = rows.map((row) => ({ - id: row.hash, - guildId: row.tenantId ?? guild.id, - action: row.action as DashboardAuditEntry["action"], - outcome: row.outcome as DashboardAuditEntry["outcome"], - member: memberMap.get(row.actorId) ?? fallbackMember(row.actorId), - changes: patchToChanges(row.changes ?? {}), - reason: row.reason, - timestamp: row.timestamp.toISOString(), - })); - - return { entries, total }; + return await fetchBotApi(event, `/guilds/${guildId}/audit-logs`, { + query: query as Record, + }); }, { auth: true, maxAge: 30, swr: false, authorize: async (event) => { - const guildId = getGuildParam(event); - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ message: "Guild not found", status: 404 }); + // Bot API enforces canManage; ensure the dashboard session exists first. + const session = await getUserSession(event); + if (!session?.user?.id) { + throw createError({ status: 401, message: "Unauthorized" }); } - const member = await getCurrentMember(event, guild.id); - useLogger(event).set({ guild: { id: guildId }, member: { id: member.user.id } }); - await canManage(guild, member); + useLogger(event).set({ + guild: { id: getGuildParam(event) }, + member: { id: session.user.id }, + }); }, getKey: (event) => { const guildId = getGuildParam(event); const url = getRequestURL(event); - return `guild:${guildId}:logs:activity${url.search}`; + return `guild:${guildId}:logs:audit${url.search}`; }, onError(log, error) { log.error(error); }, - rateLimit: { enabled: true, limit: 15, window: seconds(60) }, + rateLimit: { enabled: true, limit: 5, window: seconds(10) }, }, ); diff --git a/server/api/guilds/[guild]/members/[member].get.ts b/server/api/guilds/[guild]/members/[member].get.ts index 9577a2f3f..dfeddf8b9 100644 --- a/server/api/guilds/[guild]/members/[member].get.ts +++ b/server/api/guilds/[guild]/members/[member].get.ts @@ -1,28 +1,15 @@ import { isNullOrUndefined } from "@sapphire/utilities/isNullish"; import { createError, useLogger } from "evlog"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/members/:member + */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - fix: "check bot is a member of the guild", - }); - } - - const currentMember = await getCurrentMember(event, guild.id); - log.set({ member: { id: currentMember.user.id } }); - - await canManage(guild, currentMember); - const memberId = getRouterParam(event, "member"); if (isNullOrUndefined(memberId)) { throw createError({ @@ -34,9 +21,7 @@ export default defineWrappedResponseHandler( } log.set({ targetMember: { id: memberId } }); - const member = await getMember(guild.id, memberId); - - return flattenMember(member, guild); + return await fetchBotApi(event, `/guilds/${guildId}/members/${memberId}`); }, { auth: true, diff --git a/server/api/guilds/[guild]/roles/[role].get.ts b/server/api/guilds/[guild]/roles/[role].get.ts index 6970964be..db998b284 100644 --- a/server/api/guilds/[guild]/roles/[role].get.ts +++ b/server/api/guilds/[guild]/roles/[role].get.ts @@ -1,29 +1,15 @@ import { isNullOrUndefined } from "@sapphire/utilities/isNullish"; import { createError, useLogger } from "evlog"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/roles/:role + */ export default defineWrappedResponseHandler( async (event) => { - const api = useApi(); const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - fix: "check bot is a member of the guild", - }); - } - - const currentMember = await getCurrentMember(event, guild.id); - log.set({ member: { id: currentMember.user.id } }); - - await canManage(guild, currentMember); - const roleId = getRouterParam(event, "role"); if (isNullOrUndefined(roleId)) { throw createError({ @@ -35,17 +21,7 @@ export default defineWrappedResponseHandler( } log.set({ role: { id: roleId } }); - const role = await api.guilds.getRole(guild.id, roleId).catch((error) => { - log.error(error); - throw createError({ - message: "Failed to fetch role", - status: 500, - why: `Discord API error while fetching role ${roleId} for guild ${guildId}`, - cause: error, - }); - }); - - return flattenRole(guild.id, role); + return await fetchBotApi(event, `/guilds/${guildId}/roles/${roleId}`); }, { auth: true, diff --git a/server/api/guilds/[guild]/roles/index.get.ts b/server/api/guilds/[guild]/roles/index.get.ts new file mode 100644 index 000000000..668aaf9d7 --- /dev/null +++ b/server/api/guilds/[guild]/roles/index.get.ts @@ -0,0 +1,25 @@ +import { useLogger } from "evlog"; + +/** + * Proxies to the internal bot API: GET /guilds/:guild/roles + */ +export default defineWrappedResponseHandler( + async (event) => { + const log = useLogger(event); + const guildId = getGuildParam(event); + log.set({ guild: { id: guildId } }); + + const roles = await fetchBotApi(event, `/guilds/${guildId}/roles`); + log.set({ + result: { roleCount: Array.isArray(roles) ? roles.length : undefined }, + }); + return roles; + }, + { + auth: true, + onError(log, error) { + log.error(error); + }, + rateLimit: { enabled: true, limit: 2, window: seconds(5) }, + }, +); diff --git a/server/api/guilds/[guild]/settings.get.ts b/server/api/guilds/[guild]/settings.get.ts index 501d5cbd9..09ddd8267 100644 --- a/server/api/guilds/[guild]/settings.get.ts +++ b/server/api/guilds/[guild]/settings.get.ts @@ -1,30 +1,15 @@ -import { readSettings, serializeSettings } from "#server/database"; -import { createError, useLogger } from "evlog"; +import { useLogger } from "evlog"; +/** + * Proxies to the internal bot API: GET /guilds/:guild/settings + */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); - const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const guild = await resolveGuildForRequest(event, guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - fix: "check bot is a member of the guild", - }); - } - - const member = await getCurrentMember(event, guild.id); - log.set({ member: { id: member.user.id } }); - - const settings = await readSettings(guild.id); - await canManage(guild, member, settings); - - return serializeSettings(settings); + return await fetchBotApi(event, `/guilds/${guildId}/settings`); }, { auth: true, diff --git a/server/api/guilds/[guild]/settings.patch.ts b/server/api/guilds/[guild]/settings.patch.ts index 71994bb20..706b4d94c 100644 --- a/server/api/guilds/[guild]/settings.patch.ts +++ b/server/api/guilds/[guild]/settings.patch.ts @@ -1,19 +1,19 @@ -import { coerceBigIntFields, serializeSettings, writeSettingsTransaction } from "#server/database"; -import { compactSettingsChanges } from "#server/utils/audit/patch-to-changes"; -import { guildSettingsAccessDenied, guildSettingsUpdate } from "#shared/audit/actions"; import { SettingsUpdateSchema } from "#shared/schemas"; import { isNullOrUndefined, isNullishOrEmpty } from "@sapphire/utilities"; -import { createError, useLogger, withAuditMethods } from "evlog"; +import { createError, useLogger } from "evlog"; import { parse } from "valibot"; +/** + * Proxies to the internal bot API: PATCH /guilds/:guild/settings + * Adds `guild_id` expected by the bot while accepting the dashboard body shape. + */ export default defineWrappedResponseHandler( async (event) => { - const log = withAuditMethods(useLogger(event)); - + const log = useLogger(event); const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const body = await readValidatedBody(event, (body) => parse(SettingsUpdateSchema, body)); + const body = await readValidatedBody(event, (raw) => parse(SettingsUpdateSchema, raw)); if (isNullOrUndefined(body) || isNullOrUndefined(body.data)) { throw createError({ @@ -24,9 +24,7 @@ export default defineWrappedResponseHandler( }); } - const { data } = body; - - if (isNullishOrEmpty(data)) { + if (isNullishOrEmpty(body.data)) { throw createError({ message: "Data array cannot be empty", status: 400, @@ -35,83 +33,13 @@ export default defineWrappedResponseHandler( }); } - const guild = await getGuild(guildId); - if (!guild) { - throw createError({ - message: "Guild not found", - status: 404, - why: `The bot is not a member of guild ${guildId}`, - fix: "check bot is a member of the guild", - }); - } - - const member = await getCurrentMember(event, guild.id); - log.set({ member: { id: member.user.id } }); - - try { - await canManage(guild, member); - } catch (canManageErr) { - const status = - canManageErr instanceof Error && "status" in canManageErr - ? (canManageErr as { status: number }).status - : null; - if (status === 403) { - log.audit( - guildSettingsAccessDenied({ - actor: { - type: "user", - id: member.user.id, - displayName: member.user.username, - }, - target: { type: "guild", id: guild.id }, - - outcome: "denied", - reason: "Insufficient permissions to manage guild settings", - }), - ); - } - throw canManageErr; - } - - using trx = await writeSettingsTransaction(guild.id); - - if (!data.every((entry): entry is [string, unknown] => entry !== undefined)) { - throw createError({ - message: "Invalid data entries", - status: 400, - why: "All data entries must be valid [key, value] tuples", - fix: "Ensure every entry in the data array is a two-element array", - }); - } - - const settingsData = Object.fromEntries(data); - log.set({ settings: { keysUpdated: Object.keys(settingsData).length } }); - - // Coerce BigInt fields from JSON (numbers/strings) to BigInt - coerceBigIntFields(settingsData); - - const beforeSettings = JSON.parse(serializeSettings(trx.settings)) as Record< - string, - unknown - >; - await trx.write(settingsData).submit(); - const afterSettings = JSON.parse(serializeSettings(trx.settings)) as Record< - string, - unknown - >; - - log.audit( - guildSettingsUpdate({ - actor: { type: "user", id: member.user.id, displayName: member.user.username }, - target: { type: "guild", id: guild.id }, - outcome: "success", - // Persist only the fields that changed (with old and new values) - // instead of the two full settings snapshots - changes: compactSettingsChanges(beforeSettings, afterSettings), - }), - ); - - return JSON.stringify(afterSettings); + return await fetchBotApi(event, `/guilds/${guildId}/settings`, { + method: "PATCH", + body: { + guild_id: guildId, + data: body.data, + }, + }); }, { auth: true, diff --git a/server/api/languages.get.ts b/server/api/languages.get.ts index 604850bd2..da54ae758 100644 --- a/server/api/languages.get.ts +++ b/server/api/languages.get.ts @@ -1,25 +1,9 @@ -import { createError } from "evlog"; - /** * Public proxy for supported WolfStar bot languages. - * Fetches from the internal bot API (`NUXT_PUBLIC_API_BASE_URL`, e.g. - * https://api.wolfstar.rocks). + * Fetches from the internal bot API. */ export default defineWrappedCachedResponseHandler( async () => { - const { - public: { apiBaseUrl }, - } = useRuntimeConfig(); - - if (!apiBaseUrl) { - throw createError({ - message: "Bot API base URL is not configured", - status: 500, - why: "NUXT_PUBLIC_API_BASE_URL is missing", - fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", - }); - } - return await fetchLanguages(); }, { diff --git a/server/utils/bot-api.ts b/server/utils/bot-api.ts new file mode 100644 index 000000000..f1ae3a9eb --- /dev/null +++ b/server/utils/bot-api.ts @@ -0,0 +1,185 @@ +import type { H3Event } from "h3"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { createError } from "evlog"; +import { $fetch, FetchError } from "ofetch"; + +const DEFAULT_AUTH_COOKIE = "SAPPHIRE_AUTH"; + +export interface SapphireAuthPayload { + expires: number; + id: string; + refresh: string; + token: string; +} + +type BotHttpMethod = "GET" | "PATCH" | "POST" | "PUT" | "DELETE"; + +interface FetchBotApiOptions { + body?: Record | unknown[] | string | null; + method?: BotHttpMethod; + query?: Record; + /** When false, skip sapphire auth cookie (public bot routes). Default true. */ + auth?: boolean; +} + +function getBotApiBaseUrl(): string { + const { + public: { apiBaseUrl }, + } = useRuntimeConfig(); + if (!apiBaseUrl) { + throw createError({ + message: "Bot API base URL is not configured", + status: 500, + why: "NUXT_PUBLIC_API_BASE_URL is missing", + fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", + }); + } + return apiBaseUrl.replace(/\/$/, ""); +} + +function getBotAuthCookieName(): string { + return process.env.NUXT_BOT_API_AUTH_COOKIE || DEFAULT_AUTH_COOKIE; +} + +function getBotOauthSecret(): string { + const secret = + process.env.NUXT_BOT_API_OAUTH_SECRET || useRuntimeConfig().discord.clientSecret || ""; + if (!secret) { + throw createError({ + message: "Bot API OAuth secret is not configured", + status: 500, + why: "Neither NUXT_BOT_API_OAUTH_SECRET nor NUXT_OAUTH_DISCORD_CLIENT_SECRET is set", + fix: "Set the Discord OAuth client secret shared with the WolfStar bot API", + }); + } + return secret; +} + +/** + * Encrypt a sapphire-plugin-api auth payload (aes-256-cbc), matching + * `@sapphire/plugin-api` Auth.encrypt so api.wolfstar.rocks accepts the cookie. + */ +export function encryptSapphireAuth(data: SapphireAuthPayload, secret: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv("aes-256-cbc", secret, iv); + return `${cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64")}.${iv.toString("base64")}`; +} + +/** + * Decrypt a sapphire-plugin-api auth token. Returns null when expired or invalid. + */ +export function decryptSapphireAuth(token: string, secret: string): SapphireAuthPayload | null { + const [data, iv] = token.split("."); + if (!data || !iv) { + return null; + } + try { + const decipher = createDecipheriv("aes-256-cbc", secret, Buffer.from(iv, "base64")); + const parsed = JSON.parse( + decipher.update(data, "base64", "utf8") + decipher.final("utf8"), + ) as SapphireAuthPayload; + return parsed.expires >= Date.now() ? parsed : null; + } catch { + return null; + } +} + +async function buildBotAuthCookie(event: H3Event): Promise { + const session = await getUserSession(event); + const userId = session?.user?.id; + if (!userId) { + throw createError({ + message: "Unauthorized", + status: 401, + why: "No authenticated session is available for the bot API request", + fix: "Sign in with Discord and retry", + }); + } + + const tokens = await refreshSessionTokens(event); + if (!tokens?.access_token) { + throw createError({ + message: "Unauthorized", + status: 401, + why: "Discord access token could not be resolved for the current session", + fix: "Sign out and sign in again to refresh Discord OAuth tokens", + }); + } + + return encryptSapphireAuth( + { + expires: Date.now() + 60 * 60 * 1000, + id: userId, + refresh: "", + token: tokens.access_token, + }, + getBotOauthSecret(), + ); +} + +function toErrorCause(error: unknown): Error | undefined { + return error instanceof Error ? error : undefined; +} + +function mapBotFetchError(error: unknown, path: string): never { + if (error instanceof FetchError) { + const status = error.statusCode ?? error.response?.status ?? 502; + const payload = error.data as { error?: string; message?: string } | string | undefined; + const message = + (typeof payload === "object" && payload !== null + ? (payload.error ?? payload.message) + : typeof payload === "string" + ? payload + : undefined) || + error.message || + "Bot API request failed"; + + throw createError({ + message, + status, + why: `The internal bot API rejected ${path}`, + fix: "Retry the request; if it persists, check bot API availability and auth cookies", + cause: toErrorCause(error), + }); + } + + throw createError({ + message: "Bot API request failed", + status: 502, + why: `Unexpected error calling ${path} on the internal bot API`, + cause: toErrorCause(error), + }); +} + +/** + * Call the internal WolfStar bot API (`api.wolfstar.rocks` by default). + * Authenticated routes receive a sapphire-compatible `SAPPHIRE_AUTH` cookie + * built from the current better-auth Discord session. + */ +export async function fetchBotApi( + event: H3Event, + path: string, + options: FetchBotApiOptions = {}, +): Promise { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = `${getBotApiBaseUrl()}${normalizedPath}`; + const headers: Record = { + "Content-Type": "application/json", + }; + + if (options.auth !== false) { + const cookieValue = await buildBotAuthCookie(event); + headers.Cookie = `${getBotAuthCookieName()}=${cookieValue}`; + } + + try { + return await $fetch(url, { + body: options.body, + headers, + method: options.method, + query: options.query, + }); + } catch (error) { + mapBotFetchError(error, normalizedPath); + } +} diff --git a/test/unit/server/api/guilds-index-get-cached-channels.spec.ts b/test/unit/server/api/guilds-index-get-cached-channels.spec.ts index 06232a206..673840409 100644 --- a/test/unit/server/api/guilds-index-get-cached-channels.spec.ts +++ b/test/unit/server/api/guilds-index-get-cached-channels.spec.ts @@ -1,58 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -// vi.hoisted() ensures globals are present before the route module is evaluated. -const { - mockReadSettings, - mockGetGuild, - mockGetCurrentMember, - mockCanManage, - mockGetGuildChannels, - mockFlattenGuild, - mockTransformGuild, - mockGetValidatedQuery, -} = vi.hoisted(() => { - const mockReadSettings = vi.fn(); - const mockGetGuild = vi.fn(); - const mockGetCurrentMember = vi.fn(); - const mockCanManage = vi.fn().mockResolvedValue(undefined); - const mockGetGuildChannels = vi.fn(); - const mockFlattenGuild = vi.fn(); - const mockTransformGuild = vi.fn(); - const mockGetValidatedQuery = vi.fn(); +const { mockFetchBotApi, mockGetQuery, mockUseLoggerSet } = vi.hoisted(() => { + const mockFetchBotApi = vi.fn(); + const mockGetQuery = vi.fn().mockReturnValue({}); + const mockUseLoggerSet = vi.fn(); (globalThis as Record).defineWrappedResponseHandler = (fn: unknown) => fn; (globalThis as Record).getGuildParam = vi.fn().mockReturnValue("guild-123"); - (globalThis as Record).getGuild = mockGetGuild; - (globalThis as Record).resolveGuildForRequest = ( - _event: unknown, - guildId: string, - ) => mockGetGuild(guildId); - (globalThis as Record).getCurrentMember = mockGetCurrentMember; - (globalThis as Record).canManage = mockCanManage; - (globalThis as Record).getGuildChannels = mockGetGuildChannels; - (globalThis as Record).flattenGuild = mockFlattenGuild; - (globalThis as Record).transformGuild = mockTransformGuild; - (globalThis as Record).getValidatedQuery = mockGetValidatedQuery; + (globalThis as Record).getQuery = mockGetQuery; + (globalThis as Record).fetchBotApi = mockFetchBotApi; (globalThis as Record).seconds = (n: number) => n; - return { - mockReadSettings, - mockGetGuild, - mockGetCurrentMember, - mockCanManage, - mockGetGuildChannels, - mockFlattenGuild, - mockTransformGuild, - mockGetValidatedQuery, - }; + return { mockFetchBotApi, mockGetQuery, mockUseLoggerSet }; }); -vi.mock("#server/database", () => ({ - readSettings: mockReadSettings, -})); - vi.mock("evlog", () => ({ - useLogger: vi.fn().mockReturnValue({ set: vi.fn(), error: vi.fn() }), + useLogger: vi.fn().mockReturnValue({ set: mockUseLoggerSet, error: vi.fn() }), createError: vi.fn((opts: Record) => Object.assign(new Error(String(opts["message"])), opts), ), @@ -63,104 +26,25 @@ import indexGetHandler from "#server/api/guilds/[guild]/index.get"; const callHandler = indexGetHandler as unknown as (event: H3Event) => Promise; -const mockGuild = { id: "guild-123", owner_id: "owner-456", roles: [] }; -const mockMember = { user: { id: "user-789" }, roles: [], permissions: "8" }; -const mockSettings = { id: "guild-123", rolesAdmin: [] }; -const mockChannels = [{ id: "ch-1", name: "general" }]; -const mockFlatResult = { id: "guild-123", channels: mockChannels }; -const mockTransformResult = { id: "guild-123", manageable: true, wolfstarIsIn: true, channels: [] }; - -describe("index.get - cached getGuildChannels and readSettings deduplication", () => { +describe("index.get - bot API proxy", () => { const mockEvent = {} as H3Event; beforeEach(() => { vi.clearAllMocks(); - mockGetGuild.mockResolvedValue(mockGuild); - mockGetCurrentMember.mockResolvedValue(mockMember); - mockReadSettings.mockResolvedValue(mockSettings); - mockGetGuildChannels.mockResolvedValue(mockChannels); - mockCanManage.mockResolvedValue(undefined); - mockFlattenGuild.mockReturnValue(mockFlatResult); - mockTransformGuild.mockResolvedValue(mockTransformResult); - // Default: shouldSerialize false - mockGetValidatedQuery.mockResolvedValue({ shouldSerialize: false }); - }); - - it("calls getGuildChannels (not api.guilds.getChannels) to fetch channels", async () => { - await callHandler(mockEvent); - - expect(mockGetGuildChannels).toHaveBeenCalledOnce(); - expect(mockGetGuildChannels).toHaveBeenCalledWith("guild-123"); - }); - - it("calls readSettings exactly once per request", async () => { - await callHandler(mockEvent); - - expect(mockReadSettings).toHaveBeenCalledOnce(); - expect(mockReadSettings).toHaveBeenCalledWith("guild-123"); + mockGetQuery.mockReturnValue({ shouldSerialize: "true" }); + mockFetchBotApi.mockResolvedValue({ id: "guild-123", name: "Test" }); }); - it("passes pre-fetched settings to canManage as third argument", async () => { + it("proxies to /guilds/:guild on the internal bot API", async () => { await callHandler(mockEvent); - expect(mockCanManage).toHaveBeenCalledOnce(); - expect(mockCanManage).toHaveBeenCalledWith(mockGuild, mockMember, mockSettings); - }); - - describe("shouldSerialize: false", () => { - it("calls flattenGuild with the guild merged with pre-fetched channels", async () => { - await callHandler(mockEvent); - - expect(mockFlattenGuild).toHaveBeenCalledOnce(); - expect(mockFlattenGuild).toHaveBeenCalledWith({ ...mockGuild, channels: mockChannels }); - }); - - it("does not call transformGuild", async () => { - await callHandler(mockEvent); - - expect(mockTransformGuild).not.toHaveBeenCalled(); + expect(mockFetchBotApi).toHaveBeenCalledWith(mockEvent, "/guilds/guild-123", { + query: { shouldSerialize: "true" }, }); }); - describe("shouldSerialize: true", () => { - beforeEach(() => { - mockGetValidatedQuery.mockResolvedValue({ shouldSerialize: true }); - }); - - it("calls transformGuild with includeChannels: false and pre-fetched data", async () => { - await callHandler(mockEvent); - - expect(mockTransformGuild).toHaveBeenCalledOnce(); - expect(mockTransformGuild).toHaveBeenCalledWith(mockMember.user.id, mockGuild, { - includeChannels: false, - prefetchedGuild: mockGuild, - prefetchedMember: mockMember, - prefetchedSettings: mockSettings, - }); - }); - - it("merges pre-fetched channels onto the transformGuild result", async () => { - const result = await callHandler(mockEvent); - - expect(result).toMatchObject({ ...mockTransformResult, channels: mockChannels }); - }); - - it("does not call flattenGuild", async () => { - await callHandler(mockEvent); - - expect(mockFlattenGuild).not.toHaveBeenCalled(); - }); - }); - - describe("authorization ordering", () => { - it("does not call getGuildChannels when canManage denies the request", async () => { - const forbiddenError = Object.assign(new Error("Insufficient permissions"), { - status: 403, - }); - mockCanManage.mockRejectedValue(forbiddenError); - - await expect(callHandler(mockEvent)).rejects.toMatchObject({ status: 403 }); - expect(mockGetGuildChannels).not.toHaveBeenCalled(); - }); + it("returns the bot API response unchanged", async () => { + const result = await callHandler(mockEvent); + expect(result).toStrictEqual({ id: "guild-123", name: "Test" }); }); }); diff --git a/test/unit/server/api/guilds-settings-get-dedup.spec.ts b/test/unit/server/api/guilds-settings-get-dedup.spec.ts index 20cfd2b00..c086d30f8 100644 --- a/test/unit/server/api/guilds-settings-get-dedup.spec.ts +++ b/test/unit/server/api/guilds-settings-get-dedup.spec.ts @@ -1,46 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -// vi.hoisted() ensures globals are present before the route module is evaluated. -const { - mockReadSettings, - mockSerializeSettings, - mockGetGuild, - mockGetCurrentMember, - mockCanManage, -} = vi.hoisted(() => { - const mockReadSettings = vi.fn(); - const mockSerializeSettings = vi.fn(); - const mockGetGuild = vi.fn(); - const mockGetCurrentMember = vi.fn(); - const mockCanManage = vi.fn().mockResolvedValue(undefined); +const { mockFetchBotApi, mockUseLoggerSet } = vi.hoisted(() => { + const mockFetchBotApi = vi.fn(); + const mockUseLoggerSet = vi.fn(); (globalThis as Record).defineWrappedResponseHandler = (fn: unknown) => fn; (globalThis as Record).getGuildParam = vi.fn().mockReturnValue("guild-123"); - (globalThis as Record).getGuild = mockGetGuild; - (globalThis as Record).resolveGuildForRequest = ( - _event: unknown, - guildId: string, - ) => mockGetGuild(guildId); - (globalThis as Record).getCurrentMember = mockGetCurrentMember; - (globalThis as Record).canManage = mockCanManage; + (globalThis as Record).fetchBotApi = mockFetchBotApi; (globalThis as Record).seconds = (n: number) => n; - return { - mockReadSettings, - mockSerializeSettings, - mockGetGuild, - mockGetCurrentMember, - mockCanManage, - }; + return { mockFetchBotApi, mockUseLoggerSet }; }); -vi.mock("#server/database", () => ({ - readSettings: mockReadSettings, - serializeSettings: mockSerializeSettings, -})); - vi.mock("evlog", () => ({ - useLogger: vi.fn().mockReturnValue({ set: vi.fn(), error: vi.fn() }), + useLogger: vi.fn().mockReturnValue({ set: mockUseLoggerSet, error: vi.fn() }), + createError: vi.fn((opts: Record) => + Object.assign(new Error(String(opts["message"])), opts), + ), })); import type { H3Event } from "h3"; @@ -48,43 +24,22 @@ import settingsGetHandler from "#server/api/guilds/[guild]/settings.get"; const callHandler = settingsGetHandler as unknown as (event: H3Event) => Promise; -const mockGuild = { id: "guild-123", owner_id: "owner-456", roles: [] }; -const mockMember = { user: { id: "user-789" }, roles: [], permissions: "8" }; -const mockSettings = { id: "guild-123", rolesAdmin: [] }; - -describe("settings.get - readSettings deduplication", () => { +describe("settings.get - bot API proxy", () => { const mockEvent = {} as H3Event; beforeEach(() => { vi.clearAllMocks(); - mockGetGuild.mockResolvedValue(mockGuild); - mockGetCurrentMember.mockResolvedValue(mockMember); - mockReadSettings.mockResolvedValue(mockSettings); - mockSerializeSettings.mockReturnValue(JSON.stringify(mockSettings)); - mockCanManage.mockResolvedValue(undefined); + mockFetchBotApi.mockResolvedValue('{"id":"guild-123","prefix":"!"}'); }); - it("calls readSettings exactly once per request", async () => { + it("proxies to /guilds/:guild/settings on the internal bot API", async () => { await callHandler(mockEvent); - expect(mockReadSettings).toHaveBeenCalledOnce(); - expect(mockReadSettings).toHaveBeenCalledWith("guild-123"); + expect(mockFetchBotApi).toHaveBeenCalledWith(mockEvent, "/guilds/guild-123/settings"); }); - it("passes pre-fetched settings to canManage as third argument", async () => { - await callHandler(mockEvent); - - expect(mockCanManage).toHaveBeenCalledOnce(); - expect(mockCanManage).toHaveBeenCalledWith(mockGuild, mockMember, mockSettings); - }); - - it("returns the result of serializeSettings called with the pre-fetched settings", async () => { - const expected = JSON.stringify(mockSettings); - + it("returns the bot API response unchanged", async () => { const result = await callHandler(mockEvent); - - expect(mockSerializeSettings).toHaveBeenCalledOnce(); - expect(mockSerializeSettings).toHaveBeenCalledWith(mockSettings); - expect(result).toBe(expected); + expect(result).toBe('{"id":"guild-123","prefix":"!"}'); }); }); diff --git a/test/unit/server/utils/bot-api.spec.ts b/test/unit/server/utils/bot-api.spec.ts new file mode 100644 index 000000000..ad5960a8c --- /dev/null +++ b/test/unit/server/utils/bot-api.spec.ts @@ -0,0 +1,57 @@ +import { + decryptSapphireAuth, + encryptSapphireAuth, + type SapphireAuthPayload, +} from "#server/utils/bot-api"; +import { describe, expect, it } from "vitest"; + +// aes-256-cbc requires a 32-byte key — match Discord client-secret length. +const SECRET = "0123456789abcdef0123456789abcdef"; + +describe("sapphire auth cookie crypto", () => { + it("round-trips a valid payload", () => { + const payload: SapphireAuthPayload = { + expires: Date.now() + 60_000, + id: "123456789012345678", + refresh: "refresh-token", + token: "access-token", + }; + + const encrypted = encryptSapphireAuth(payload, SECRET); + expect(encrypted).toContain("."); + + const decrypted = decryptSapphireAuth(encrypted, SECRET); + expect(decrypted).toStrictEqual(payload); + }); + + it("returns null for expired payloads", () => { + const encrypted = encryptSapphireAuth( + { + expires: Date.now() - 1, + id: "1", + refresh: "", + token: "t", + }, + SECRET, + ); + expect(decryptSapphireAuth(encrypted, SECRET)).toBeNull(); + }); + + it("returns null for tampered tokens", () => { + const encrypted = encryptSapphireAuth( + { + expires: Date.now() + 60_000, + id: "1", + refresh: "", + token: "t", + }, + SECRET, + ); + const [data, iv] = encrypted.split("."); + expect(data).toBeTruthy(); + expect(iv).toBeTruthy(); + const tampered = `AAAA${data!.slice(4)}.${iv}`; + expect(decryptSapphireAuth(tampered, SECRET)).toBeNull(); + expect(decryptSapphireAuth("not-a-token", SECRET)).toBeNull(); + }); +}); From 48e7dbcad8ad023b44fbddc866f4fa8180524463 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 11:30:44 +0000 Subject: [PATCH 04/15] refactor(api): use $api for bot API instead of per-route proxies Restore the $api plugin against NUXT_PUBLIC_API_BASE_URL, point dashboard composables at bot paths, and keep a single /api/bot/** BFF for client-side auth cookie injection. Revert guild handlers to local Discord implementations. Co-authored-by: RedStar --- .env.example | 4 +- app/composables/createApiComposable.ts | 8 +- app/composables/useAuditLog.ts | 12 +- app/composables/useCommandLog.ts | 12 +- app/composables/useCommands.ts | 15 +- app/composables/useLanguages.ts | 2 +- app/layouts/dashboard.vue | 70 ++++---- app/pages/commands.stories.ts | 6 +- app/plugins/api.ts | 65 ++++++++ app/storybook/mocks/handlers.ts | 19 +-- nuxt.config.ts | 13 +- server/api/bot/[...path].ts | 44 +++++ server/api/commands.get.ts | 24 --- .../guilds/[guild]/channels/[channel].get.ts | 47 +++++- .../api/guilds/[guild]/channels/index.get.ts | 38 +++-- server/api/guilds/[guild]/index.get.ts | 60 ++++++- .../api/guilds/[guild]/logs/commands.get.ts | 90 +++++++++-- server/api/guilds/[guild]/logs/index.get.ts | 83 +++++++--- .../guilds/[guild]/members/[member].get.ts | 23 ++- server/api/guilds/[guild]/roles/[role].get.ts | 32 +++- server/api/guilds/[guild]/roles/index.get.ts | 25 --- server/api/guilds/[guild]/settings.get.ts | 25 ++- server/api/guilds/[guild]/settings.patch.ts | 102 ++++++++++-- server/api/languages.get.ts | 24 --- server/utils/bot-api.ts | 25 +++ shared/utils/bot-api-commands.ts | 10 +- test/nuxt/api/commands-languages.spec.ts | 22 ++- .../guilds-index-get-cached-channels.spec.ts | 150 ++++++++++++++++-- .../api/guilds-settings-get-dedup.spec.ts | 75 +++++++-- test/unit/server/utils/bot-api.spec.ts | 9 ++ .../shared/utils/bot-api-commands.test.ts | 7 + 31 files changed, 868 insertions(+), 273 deletions(-) create mode 100644 app/plugins/api.ts create mode 100644 server/api/bot/[...path].ts delete mode 100644 server/api/commands.get.ts delete mode 100644 server/api/guilds/[guild]/roles/index.get.ts delete mode 100644 server/api/languages.get.ts diff --git a/.env.example b/.env.example index c0e3c4567..31344d6da 100644 --- a/.env.example +++ b/.env.example @@ -41,13 +41,13 @@ NUXT_CLOUDFLARE_API_TOKEN= # =================================== # Website Environment (Required) -# WolfStar bot API origin used by server proxies (/api/commands, /api/guilds/*, …) +# WolfStar bot API origin used by `$api` (and the `/api/bot/**` client BFF). # Production/internal: https://api.wolfstar.rocks (default when unset) # Local bot instance: http://localhost:8282 NUXT_PUBLIC_API_BASE_URL=http://localhost:8282 # Optional: sapphire-plugin-api cookie name / OAuth secret for authenticated bot -# API proxies. Defaults: cookie SAPPHIRE_AUTH, secret = Discord client secret. +# API calls. Defaults: cookie SAPPHIRE_AUTH, secret = Discord client secret. # NUXT_BOT_API_AUTH_COOKIE=SAPPHIRE_AUTH # NUXT_BOT_API_OAUTH_SECRET= diff --git a/app/composables/createApiComposable.ts b/app/composables/createApiComposable.ts index f2f00876c..c22f711cd 100644 --- a/app/composables/createApiComposable.ts +++ b/app/composables/createApiComposable.ts @@ -3,8 +3,8 @@ export interface ApiComposableOptions { } /** - * Lazy-fetch composable for same-origin dashboard API routes (e.g. `/api/commands`). - * Uses the request-scoped fetch cache on SSR and plain `$fetch` on the client. + * Lazy-fetch composable for WolfStar bot API routes via `$api` + * (e.g. `/commands`, `/languages`, `/guilds/:id`). */ export function createApiComposable( key: string, @@ -12,12 +12,12 @@ export function createApiComposable( defaultValue: T, options?: ApiComposableOptions, ) { - const cachedFetch = useCachedFetch(); + const { $api } = useNuxtApp(); const asyncData = useLazyAsyncData( key, async () => { - const { data, isStale } = await cachedFetch(endpoint); + const { data, isStale } = await $api(endpoint); return { data, isStale }; }, { immediate: options?.immediate !== false }, diff --git a/app/composables/useAuditLog.ts b/app/composables/useAuditLog.ts index 136829bb1..8cb3834d1 100644 --- a/app/composables/useAuditLog.ts +++ b/app/composables/useAuditLog.ts @@ -30,10 +30,12 @@ export function useAuditLog({ const resolvedOffset = computed(() => (offset !== undefined ? toValue(offset) : undefined)); const resolvedFilters = computed(() => (filters !== undefined ? toValue(filters) : undefined)); + const { $api } = useNuxtApp(); + const asyncData = useLazyAsyncData( () => `guild:${toValue(guildId)}:logs:activity:${resolvedLimit.value ?? "default"}:${resolvedOffset.value ?? 0}:${JSON.stringify(resolvedFilters.value ?? {})}`, - () => { + async () => { const query: Record = {}; if (resolvedLimit.value !== undefined) query.limit = resolvedLimit.value; if (resolvedOffset.value !== undefined) query.offset = resolvedOffset.value; @@ -44,9 +46,11 @@ export function useAuditLog({ if (f.to) query.to = f.to; if (f.q) query.q = f.q; } - return $fetch(`/api/guilds/${toValue(guildId)}/logs`, { - query, - }); + const { data } = await $api( + `/guilds/${toValue(guildId)}/audit-logs`, + { query }, + ); + return data; }, { immediate: immediate !== false, diff --git a/app/composables/useCommandLog.ts b/app/composables/useCommandLog.ts index d858d8e65..e49ef7c7e 100644 --- a/app/composables/useCommandLog.ts +++ b/app/composables/useCommandLog.ts @@ -30,10 +30,12 @@ export function useCommandLog({ const resolvedOffset = computed(() => (offset !== undefined ? toValue(offset) : undefined)); const resolvedFilters = computed(() => (filters !== undefined ? toValue(filters) : undefined)); + const { $api } = useNuxtApp(); + const asyncData = useLazyAsyncData( () => `guild:${toValue(guildId)}:logs:commands:${resolvedLimit.value ?? "default"}:${resolvedOffset.value ?? 0}:${JSON.stringify(resolvedFilters.value || {})}`, - () => { + async () => { const query: Record = {}; if (resolvedLimit.value !== undefined) query.limit = resolvedLimit.value; if (resolvedOffset.value !== undefined) query.offset = resolvedOffset.value; @@ -46,9 +48,11 @@ export function useCommandLog({ if (f.to) query.to = f.to; if (f.q) query.q = f.q; } - return $fetch(`/api/guilds/${toValue(guildId)}/logs/commands`, { - query, - }); + const { data } = await $api( + `/guilds/${toValue(guildId)}/command-logs`, + { query }, + ); + return data; }, { immediate: immediate !== false, diff --git a/app/composables/useCommands.ts b/app/composables/useCommands.ts index 8198e8574..e8401ee6a 100644 --- a/app/composables/useCommands.ts +++ b/app/composables/useCommands.ts @@ -1,3 +1,16 @@ +import type { BotApiCommand } from "#shared/utils/bot-api-commands"; +import { normalizeBotCommands } from "#shared/utils/bot-api-commands"; + export function useCommands(options?: ApiComposableOptions) { - return createApiComposable("wolfstar:commands", "/api/commands", [], options); + const result = createApiComposable( + "wolfstar:commands", + "/commands", + [], + options, + ); + + return { + ...result, + data: computed(() => normalizeBotCommands(result.data.value)), + }; } diff --git a/app/composables/useLanguages.ts b/app/composables/useLanguages.ts index 20f6cb0bb..8cd95275c 100644 --- a/app/composables/useLanguages.ts +++ b/app/composables/useLanguages.ts @@ -1,3 +1,3 @@ export function useLanguages(options?: ApiComposableOptions) { - return createApiComposable("wolfstar:languages", "/api/languages", [], options); + return createApiComposable("wolfstar:languages", "/languages", [], options); } diff --git a/app/layouts/dashboard.vue b/app/layouts/dashboard.vue index 45df2d987..f852e7089 100644 --- a/app/layouts/dashboard.vue +++ b/app/layouts/dashboard.vue @@ -190,7 +190,7 @@ watch( { immediate: true }, ); -const requestFetch = useRequestFetch(); +const { $api } = useNuxtApp(); const route = useRoute(); const refreshGuildCache = computed(() => route.query.refresh === "true"); @@ -200,17 +200,18 @@ const { error, } = useAsyncData( () => `dashboard:guild:${guildId.value}`, - () => { + async () => { const refreshQuery = refreshGuildCache.value ? { refresh: "true" } : undefined; - return Promise.all([ - requestFetch>>( - `/api/guilds/${guildId.value}`, + const [guildResult, settingsResult] = await Promise.all([ + $api>>( + `/guilds/${guildId.value}`, { query: refreshQuery }, ), - requestFetch(`/api/guilds/${guildId.value}/settings`, { + $api(`/guilds/${guildId.value}/settings`, { query: refreshQuery, }), ]); + return [guildResult.data, settingsResult.data] as const; }, ); @@ -472,44 +473,49 @@ function isValidGuildId(id: string | undefined | null): boolean { } async function submitChanges() { - const { data } = await useFetch(`/api/guilds/${guildId.value}/settings`, { - body: { - data: objectToTuples(guildSettingsChanges.value as Partial), - }, - transform: (response: string) => { - try { - return JSON.parse(response); - } catch (error) { - logger.error( - `Failed to parse response from settings update for guild Id: ${guildId.value}`, - parseError(error), - ); - throw createError({ - message: "Failed to update guild settings", - why: "Something went wrong while saving your settings.", - status: 500, - fix: "Please try again later. If the issue persists, contact support.", - cause: error as Error, - }); - } + const { data: response } = await $api( + `/guilds/${guildId.value}/settings`, + { + body: { + guild_id: guildId.value, + data: objectToTuples(guildSettingsChanges.value as Partial), + }, + method: "PATCH", }, - method: "PATCH", - }); + ); + + let parsed: GuildData; + try { + parsed = + typeof response === "string" ? (JSON.parse(response) as GuildData) : response; + } catch (error) { + logger.error( + `Failed to parse response from settings update for guild Id: ${guildId.value}`, + parseError(error), + ); + throw createError({ + message: "Failed to update guild settings", + why: "Something went wrong while saving your settings.", + status: 500, + fix: "Please try again later. If the issue persists, contact support.", + cause: error as Error, + }); + } - if (!data.value) { + if (!parsed || objectValues(parsed).length === 0) { return; } - if (!isNullOrUndefined(data.value) && objectValues(data.value).length !== 0) { + if (objectValues(parsed).length !== 0) { if (!document.startViewTransition || effectiveReduceMotion.value) { - setGuildSettings(data.value!); + setGuildSettings(parsed); setGuildSettingsChanges(undefined); } else { if (document.activeViewTransition) { document.activeViewTransition.skipTransition(); } document.startViewTransition(async () => { - setGuildSettings(data.value!); + setGuildSettings(parsed); setGuildSettingsChanges(undefined); await nextTick(); }); diff --git a/app/pages/commands.stories.ts b/app/pages/commands.stories.ts index e3ac7b976..a81115f17 100644 --- a/app/pages/commands.stories.ts +++ b/app/pages/commands.stories.ts @@ -11,7 +11,7 @@ const meta: Meta = { parameters: { layout: "fullscreen", msw: { - handlers: [http.get(/\/api\/commands$/, () => HttpResponse.json(mockCommands))], + handlers: [http.get(/\/api\/bot\/commands$/, () => HttpResponse.json(mockCommands))], }, }, }; @@ -24,7 +24,7 @@ export const WithCommands: Story = {}; export const Empty: Story = { parameters: { msw: { - handlers: [http.get(/\/api\/commands$/, () => HttpResponse.json([]))], + handlers: [http.get(/\/api\/bot\/commands$/, () => HttpResponse.json([]))], }, }, }; @@ -36,7 +36,7 @@ export const Loading: Story = { // stable for the snapshot. A fixed timeout (e.g. 60s) would either // resolve mid-capture or trip Chromatic's per-story capture timeout. handlers: [ - http.get(/\/api\/commands$/, async () => { + http.get(/\/api\/bot\/commands$/, async () => { await delay("infinite"); return HttpResponse.json(mockCommands); }), diff --git a/app/plugins/api.ts b/app/plugins/api.ts new file mode 100644 index 000000000..9c66f299d --- /dev/null +++ b/app/plugins/api.ts @@ -0,0 +1,65 @@ +import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config"; + +/** + * Provides `$api` for the internal WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`). + * + * - Server: calls the bot origin directly and attaches a sapphire `SAPPHIRE_AUTH` + * cookie when a Discord session is available. + * - Client: cannot set cross-origin Cookie headers, so requests go through the + * same-origin BFF at `/api/bot/**` which injects auth server-side. + */ +export default defineNuxtPlugin(() => { + const cachedFetch = useCachedFetch(); + const { + public: { apiBaseUrl }, + } = useRuntimeConfig(); + + return { + provide: { + api: async ( + url: Parameters[0], + options?: Parameters[1], + ttl?: Parameters[2], + ) => { + if (import.meta.server) { + const event = useRequestEvent(); + const authHeaders = + event !== undefined + ? await ( + await import("#server/utils/bot-api") + ).getOptionalBotAuthHeaders(event) + : {}; + + return cachedFetch( + url, + { + ...options, + baseURL: apiBaseUrl, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...authHeaders, + }, + }, + ttl, + ); + } + + return cachedFetch( + url, + { + ...options, + baseURL: "/api/bot", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }, + ttl, + ); + }, + }, + }; +}); diff --git a/app/storybook/mocks/handlers.ts b/app/storybook/mocks/handlers.ts index 3ecc03f3c..d0d3b55a6 100644 --- a/app/storybook/mocks/handlers.ts +++ b/app/storybook/mocks/handlers.ts @@ -18,21 +18,18 @@ export const handlers = [ http.get("/api/guilds", () => HttpResponse.json(mockGuildList)), - // The dashboard layout fetches the single guild to hydrate guild state. - http.get("/api/guilds/:guildId", () => HttpResponse.json(mockGuild)), - - // The dashboard activity feed (General.vue) requests the guild audit log. - http.get("/api/guilds/:guildId/logs", () => HttpResponse.json({ entries: [], total: 0 })), - - http.get("/api/guilds/:guildId/settings", () => + // `$api` client BFF (`/api/bot/**` → bot API paths) + http.get("/api/bot/guilds/:guildId", () => HttpResponse.json(mockGuild)), + http.get("/api/bot/guilds/:guildId/audit-logs", () => + HttpResponse.json({ entries: [], total: 0 }), + ), + http.get("/api/bot/guilds/:guildId/settings", () => HttpResponse.json({ guildId: "123456789012345678", prefix: "!", language: "en-US", }), ), - - // Same-origin bot-data proxies used by createApiComposable - http.get(/\/api\/commands$/, () => HttpResponse.json(mockCommands)), - http.get(/\/api\/languages$/, () => HttpResponse.json(["en-US", "es-ES", "fr-FR"])), + http.get(/\/api\/bot\/commands$/, () => HttpResponse.json(mockCommands)), + http.get(/\/api\/bot\/languages$/, () => HttpResponse.json(["en-US", "es-ES", "fr-FR"])), ]; diff --git a/nuxt.config.ts b/nuxt.config.ts index 29b21998c..1b2a5e902 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -220,15 +220,12 @@ export default defineNuxtConfig({ "Vary": "Cookie, Authorization", }, }, - // Public bot-data proxies — safe to CDN-cache (no user-specific data). - "/api/commands": { + // Bot API BFF used by `$api` on the client — never CDN-cache (may carry auth). + "/api/bot/**": { + isr: false, + cache: false, headers: { - "Cache-Control": "public, max-age=3600, stale-while-revalidate=21600", - }, - }, - "/api/languages": { - headers: { - "Cache-Control": "public, max-age=86400, stale-while-revalidate=86400", + "Cache-Control": "private, no-store", }, }, diff --git a/server/api/bot/[...path].ts b/server/api/bot/[...path].ts new file mode 100644 index 000000000..9f4b964af --- /dev/null +++ b/server/api/bot/[...path].ts @@ -0,0 +1,44 @@ +import { createError, useLogger } from "evlog"; +import { getQuery, readBody } from "h3"; + +type BotHttpMethod = "GET" | "PATCH" | "POST" | "PUT" | "DELETE"; + +/** + * Same-origin BFF for `$api` on the client. + * Forwards `/api/bot/**` to the WolfStar bot API with sapphire auth when needed. + */ +export default defineWrappedResponseHandler( + async (event) => { + const log = useLogger(event); + const pathParam = getRouterParam(event, "path"); + if (!pathParam) { + throw createError({ + message: "Not found", + status: 404, + why: "Bot API proxy path is empty", + }); + } + + const path = pathParam.startsWith("/") ? pathParam : `/${pathParam}`; + const method = (event.method || "GET").toUpperCase() as BotHttpMethod; + log.set({ botApi: { path, method } }); + + const query = getQuery(event); + const body = + method === "GET" || method === "DELETE" ? undefined : await readBody(event); + + return await fetchBotApi(event, path, { + auth: !isPublicBotApiPath(path), + body, + method, + query, + }); + }, + { + auth: false, + onError(log, error) { + log.error(error); + }, + rateLimit: { enabled: true, limit: 30, time: seconds(10) }, + }, +); diff --git a/server/api/commands.get.ts b/server/api/commands.get.ts deleted file mode 100644 index f38807002..000000000 --- a/server/api/commands.get.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Public proxy for the WolfStar bot commands list. - * Fetches from the internal bot API and returns the dashboard WolfCommand shape. - */ -export default defineWrappedCachedResponseHandler( - async () => { - return await fetchCommands(); - }, - { - auth: false, - getKey: () => "bot-commands", - maxAge: seconds.fromHours(6), - swr: true, - rateLimit: { - enabled: true, - limit: 30, - type: "sliding", - window: seconds(60), - }, - onError(log, error) { - log.error(error); - }, - }, -); diff --git a/server/api/guilds/[guild]/channels/[channel].get.ts b/server/api/guilds/[guild]/channels/[channel].get.ts index 494959fa5..a49b8e370 100644 --- a/server/api/guilds/[guild]/channels/[channel].get.ts +++ b/server/api/guilds/[guild]/channels/[channel].get.ts @@ -1,15 +1,28 @@ import { isNullOrUndefined } from "@sapphire/utilities/isNullish"; import { createError, useLogger } from "evlog"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/channels/:channel - */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + fix: "check bot is a member of the guild", + }); + } + + const currentMember = await getCurrentMember(event, guild.id); + log.set({ member: { id: currentMember.user.id } }); + + await canManage(guild, currentMember); + const channelId = getRouterParam(event, "channel"); if (isNullOrUndefined(channelId)) { throw createError({ @@ -21,7 +34,33 @@ export default defineWrappedResponseHandler( } log.set({ channel: { id: channelId } }); - return await fetchBotApi(event, `/guilds/${guildId}/channels/${channelId}`); + const channels = await $fetch[]>( + `/api/guilds/${guildId}/channels`, + { + headers: event.headers, + }, + ).catch((error) => { + log.error(error); + throw createError({ + message: "Failed to fetch channels", + status: 500, + why: `Discord API error while fetching channels for guild ${guildId}`, + cause: error, + }); + }); + + const channel = channels.find((channel: any) => channel.id === channelId) ?? null; + + if (isNullOrUndefined(channel)) { + throw createError({ + message: "Channel not found", + status: 404, + why: `No channel with ID "${channelId}" exists in guild ${guildId}`, + fix: "Verify the channel ID is correct and belongs to this guild", + }); + } + + return channel; }, { auth: true, diff --git a/server/api/guilds/[guild]/channels/index.get.ts b/server/api/guilds/[guild]/channels/index.get.ts index b545554ef..217411c77 100644 --- a/server/api/guilds/[guild]/channels/index.get.ts +++ b/server/api/guilds/[guild]/channels/index.get.ts @@ -1,21 +1,39 @@ -import { useLogger } from "evlog"; +import { createError, useLogger } from "evlog"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/channels - */ export default defineWrappedResponseHandler( async (event) => { + const api = useApi(); const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const channels = await fetchBotApi(event, `/guilds/${guildId}/channels`); - log.set({ - result: { - channelCount: Array.isArray(channels) ? channels.length : undefined, - }, + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + fix: "check bot is a member of the guild", + }); + } + + const member = await getCurrentMember(event, guild.id); + log.set({ member: { id: member.user.id } }); + await canManage(guild, member); + + const channels = await api.guilds.getChannels(guild.id).catch((error) => { + log.error(error); + throw createError({ + message: "Failed to fetch channels", + status: 500, + why: "Discord API returned an error when fetching the guild's channel list", + cause: error, + }); }); - return channels; + + log.set({ result: { channelCount: channels.length } }); + return channels.map((channel) => flattenGuildChannel(channel as any)); }, { auth: true, diff --git a/server/api/guilds/[guild]/index.get.ts b/server/api/guilds/[guild]/index.get.ts index 1f085fe3e..7240db37e 100644 --- a/server/api/guilds/[guild]/index.get.ts +++ b/server/api/guilds/[guild]/index.get.ts @@ -1,18 +1,64 @@ -import { useLogger } from "evlog"; +import type { RESTAPIPartialCurrentUserGuild } from "discord-api-types/v10"; +import { readSettings } from "#server/database"; +import { GuildQuerySchema } from "#shared/schemas"; +import { createError, useLogger } from "evlog"; +import { parse } from "valibot"; -/** - * Proxies to the internal bot API: GET /guilds/:guild - */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const query = getQuery(event); - return await fetchBotApi(event, `/guilds/${guildId}`, { - query: query as Record, + const { shouldSerialize } = await getValidatedQuery(event, (body) => + parse(GuildQuerySchema, body), + ); + + const guild = await resolveGuildForRequest(event, guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + }); + } + + const member = await getCurrentMember(event, guild.id); + log.set({ member: { id: member.user.id } }); + + const settings = await readSettings(guild.id); + await canManage(guild, member, settings); + + const channels = await getGuildChannels(guild.id).catch((error) => { + log.error(error); + throw createError({ + message: "Failed to fetch channels", + status: 500, + why: `Discord API error while fetching channels for guild ${guildId}`, + cause: error, + }); + }); + + const result = shouldSerialize + ? { + ...(await transformGuild( + member.user.id, + guild as RESTAPIPartialCurrentUserGuild, + { + includeChannels: false, + prefetchedGuild: guild, + prefetchedMember: member, + prefetchedSettings: settings, + }, + )), + channels, + } + : flattenGuild({ ...guild, channels }); + log.set({ + result: { channelCount: channels.length, serialized: Boolean(shouldSerialize) }, }); + return result; }, { auth: true, diff --git a/server/api/guilds/[guild]/logs/commands.get.ts b/server/api/guilds/[guild]/logs/commands.get.ts index 07b827068..247a7a3e4 100644 --- a/server/api/guilds/[guild]/logs/commands.get.ts +++ b/server/api/guilds/[guild]/logs/commands.get.ts @@ -1,36 +1,94 @@ +import type { CommandLogData } from "#server/database"; +import prisma from "#server/database/prisma"; +import { fallbackMember, resolveGuildMembers } from "#server/utils/audit/resolve-members"; import { CommandLogQuerySchema } from "#shared/schemas"; -import { createError, useLogger } from "evlog"; +import { useLogger } from "evlog"; import { parse } from "valibot"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/command-logs - * (dashboard path remains /api/guilds/:guild/logs/commands) - */ export default defineWrappedCachedResponseHandler( async (event) => { const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const query = await getValidatedQuery(event, (body) => parse(CommandLogQuerySchema, body)); + // Authorization runs in the `authorize` hook on every request; the + // cached resolver only needs the guild for data assembly. + const guild = await getGuild(guildId); + if (!guild) throw createError({ status: 404, message: "Guild not found" }); + + const { limit, offset, userId, commandName, success, from, to, q } = + await getValidatedQuery(event, (body) => parse(CommandLogQuerySchema, body)); + + const where = { + guildId, + ...(userId && { userId }), + ...(commandName && { commandName }), + ...(success !== "all" && { success: success === "success" }), + ...(from || to + ? { + executedAt: { + ...(from && { gte: new Date(from) }), + ...(to && { lte: new Date(to) }), + }, + } + : {}), + ...(q && { + OR: [ + { commandName: { contains: q, mode: "insensitive" as const } }, + { errorReason: { contains: q, mode: "insensitive" as const } }, + ], + }), + }; + + const [rows, total] = await Promise.all([ + prisma.commandLog.findMany({ + where, + orderBy: { executedAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.commandLog.count({ where }), + ]); - return await fetchBotApi(event, `/guilds/${guildId}/command-logs`, { - query: query as Record, + const memberMap = await resolveGuildMembers(guildId, [ + ...new Set(rows.map((r) => r.userId)), + ]); + + const entries: CommandLogData[] = rows.map((row) => { + const member = memberMap.get(row.userId) ?? fallbackMember(row.userId); + return { + id: row.id, + guildId: row.guildId, + userId: row.userId, + commandName: row.commandName, + commandType: row.commandType, + commandId: row.commandId ?? null, + subcommand: row.subcommand ?? null, + channelId: row.channelId ?? null, + success: row.success, + errorReason: row.errorReason ?? null, + executedAt: row.executedAt, + latencyMs: row.latencyMs ?? null, + metadata: { member }, + }; }); + + return { entries, total }; }, { auth: true, maxAge: 30, swr: false, authorize: async (event) => { - const session = await getUserSession(event); - if (!session?.user?.id) { - throw createError({ status: 401, message: "Unauthorized" }); + const guildId = getGuildParam(event); + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ message: "Guild not found", status: 404 }); } - useLogger(event).set({ - guild: { id: getGuildParam(event) }, - member: { id: session.user.id }, - }); + const member = await getCurrentMember(event, guild.id); + useLogger(event).set({ guild: { id: guildId }, member: { id: member.user.id } }); + await canManage(guild, member); }, getKey: (event) => { const guildId = getGuildParam(event); @@ -40,6 +98,6 @@ export default defineWrappedCachedResponseHandler( onError(log, error) { log.error(error); }, - rateLimit: { enabled: true, limit: 5, window: seconds(10) }, + rateLimit: { enabled: true, limit: 30, window: seconds(60) }, }, ); diff --git a/server/api/guilds/[guild]/logs/index.get.ts b/server/api/guilds/[guild]/logs/index.get.ts index 2d21af3fa..1a92404e3 100644 --- a/server/api/guilds/[guild]/logs/index.get.ts +++ b/server/api/guilds/[guild]/logs/index.get.ts @@ -1,48 +1,95 @@ +import type { DashboardAuditEntry } from "#shared/types/audit-log"; +import prisma from "#server/database/prisma"; +import { patchToChanges } from "#server/utils/audit/patch-to-changes"; +import { fallbackMember, resolveGuildMembers } from "#server/utils/audit/resolve-members"; +import { DASHBOARD_AUDIT_ACTIONS } from "#shared/audit/actions"; import { DashboardActivityQuerySchema } from "#shared/schemas"; import { createError, useLogger } from "evlog"; import { parse } from "valibot"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/audit-logs - * (dashboard path remains /api/guilds/:guild/logs for backwards compatibility) - */ export default defineWrappedCachedResponseHandler( async (event) => { const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const query = await getValidatedQuery(event, (body) => + // Authorization runs in the `authorize` hook on every request; the + // cached resolver only needs the guild for data assembly. + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ message: "Guild not found", status: 404 }); + } + + const { limit, offset, actorId, from, to, q } = await getValidatedQuery(event, (body) => parse(DashboardActivityQuerySchema, body), ); - return await fetchBotApi(event, `/guilds/${guildId}/audit-logs`, { - query: query as Record, - }); + const where = { + tenantId: guild.id, + action: { in: [...DASHBOARD_AUDIT_ACTIONS] }, + ...(actorId && { actorId }), + ...(from || to + ? { + timestamp: { + ...(from && { gte: new Date(from) }), + ...(to && { lte: new Date(to) }), + }, + } + : {}), + ...(q && { reason: { contains: q, mode: "insensitive" as const } }), + }; + + const [rows, total] = await Promise.all([ + prisma.auditEvent.findMany({ + where, + orderBy: { timestamp: "desc" }, + take: limit, + skip: offset, + }), + prisma.auditEvent.count({ where }), + ]); + + const memberMap = await resolveGuildMembers( + guild.id, + rows.map((r) => r.actorId), + ); + + const entries: DashboardAuditEntry[] = rows.map((row) => ({ + id: row.hash, + guildId: row.tenantId ?? guild.id, + action: row.action as DashboardAuditEntry["action"], + outcome: row.outcome as DashboardAuditEntry["outcome"], + member: memberMap.get(row.actorId) ?? fallbackMember(row.actorId), + changes: patchToChanges(row.changes ?? {}), + reason: row.reason, + timestamp: row.timestamp.toISOString(), + })); + + return { entries, total }; }, { auth: true, maxAge: 30, swr: false, authorize: async (event) => { - // Bot API enforces canManage; ensure the dashboard session exists first. - const session = await getUserSession(event); - if (!session?.user?.id) { - throw createError({ status: 401, message: "Unauthorized" }); + const guildId = getGuildParam(event); + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ message: "Guild not found", status: 404 }); } - useLogger(event).set({ - guild: { id: getGuildParam(event) }, - member: { id: session.user.id }, - }); + const member = await getCurrentMember(event, guild.id); + useLogger(event).set({ guild: { id: guildId }, member: { id: member.user.id } }); + await canManage(guild, member); }, getKey: (event) => { const guildId = getGuildParam(event); const url = getRequestURL(event); - return `guild:${guildId}:logs:audit${url.search}`; + return `guild:${guildId}:logs:activity${url.search}`; }, onError(log, error) { log.error(error); }, - rateLimit: { enabled: true, limit: 5, window: seconds(10) }, + rateLimit: { enabled: true, limit: 15, window: seconds(60) }, }, ); diff --git a/server/api/guilds/[guild]/members/[member].get.ts b/server/api/guilds/[guild]/members/[member].get.ts index dfeddf8b9..9577a2f3f 100644 --- a/server/api/guilds/[guild]/members/[member].get.ts +++ b/server/api/guilds/[guild]/members/[member].get.ts @@ -1,15 +1,28 @@ import { isNullOrUndefined } from "@sapphire/utilities/isNullish"; import { createError, useLogger } from "evlog"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/members/:member - */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + fix: "check bot is a member of the guild", + }); + } + + const currentMember = await getCurrentMember(event, guild.id); + log.set({ member: { id: currentMember.user.id } }); + + await canManage(guild, currentMember); + const memberId = getRouterParam(event, "member"); if (isNullOrUndefined(memberId)) { throw createError({ @@ -21,7 +34,9 @@ export default defineWrappedResponseHandler( } log.set({ targetMember: { id: memberId } }); - return await fetchBotApi(event, `/guilds/${guildId}/members/${memberId}`); + const member = await getMember(guild.id, memberId); + + return flattenMember(member, guild); }, { auth: true, diff --git a/server/api/guilds/[guild]/roles/[role].get.ts b/server/api/guilds/[guild]/roles/[role].get.ts index db998b284..6970964be 100644 --- a/server/api/guilds/[guild]/roles/[role].get.ts +++ b/server/api/guilds/[guild]/roles/[role].get.ts @@ -1,15 +1,29 @@ import { isNullOrUndefined } from "@sapphire/utilities/isNullish"; import { createError, useLogger } from "evlog"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/roles/:role - */ export default defineWrappedResponseHandler( async (event) => { + const api = useApi(); const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + fix: "check bot is a member of the guild", + }); + } + + const currentMember = await getCurrentMember(event, guild.id); + log.set({ member: { id: currentMember.user.id } }); + + await canManage(guild, currentMember); + const roleId = getRouterParam(event, "role"); if (isNullOrUndefined(roleId)) { throw createError({ @@ -21,7 +35,17 @@ export default defineWrappedResponseHandler( } log.set({ role: { id: roleId } }); - return await fetchBotApi(event, `/guilds/${guildId}/roles/${roleId}`); + const role = await api.guilds.getRole(guild.id, roleId).catch((error) => { + log.error(error); + throw createError({ + message: "Failed to fetch role", + status: 500, + why: `Discord API error while fetching role ${roleId} for guild ${guildId}`, + cause: error, + }); + }); + + return flattenRole(guild.id, role); }, { auth: true, diff --git a/server/api/guilds/[guild]/roles/index.get.ts b/server/api/guilds/[guild]/roles/index.get.ts deleted file mode 100644 index 668aaf9d7..000000000 --- a/server/api/guilds/[guild]/roles/index.get.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useLogger } from "evlog"; - -/** - * Proxies to the internal bot API: GET /guilds/:guild/roles - */ -export default defineWrappedResponseHandler( - async (event) => { - const log = useLogger(event); - const guildId = getGuildParam(event); - log.set({ guild: { id: guildId } }); - - const roles = await fetchBotApi(event, `/guilds/${guildId}/roles`); - log.set({ - result: { roleCount: Array.isArray(roles) ? roles.length : undefined }, - }); - return roles; - }, - { - auth: true, - onError(log, error) { - log.error(error); - }, - rateLimit: { enabled: true, limit: 2, window: seconds(5) }, - }, -); diff --git a/server/api/guilds/[guild]/settings.get.ts b/server/api/guilds/[guild]/settings.get.ts index 09ddd8267..501d5cbd9 100644 --- a/server/api/guilds/[guild]/settings.get.ts +++ b/server/api/guilds/[guild]/settings.get.ts @@ -1,15 +1,30 @@ -import { useLogger } from "evlog"; +import { readSettings, serializeSettings } from "#server/database"; +import { createError, useLogger } from "evlog"; -/** - * Proxies to the internal bot API: GET /guilds/:guild/settings - */ export default defineWrappedResponseHandler( async (event) => { const log = useLogger(event); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - return await fetchBotApi(event, `/guilds/${guildId}/settings`); + const guild = await resolveGuildForRequest(event, guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + fix: "check bot is a member of the guild", + }); + } + + const member = await getCurrentMember(event, guild.id); + log.set({ member: { id: member.user.id } }); + + const settings = await readSettings(guild.id); + await canManage(guild, member, settings); + + return serializeSettings(settings); }, { auth: true, diff --git a/server/api/guilds/[guild]/settings.patch.ts b/server/api/guilds/[guild]/settings.patch.ts index 706b4d94c..71994bb20 100644 --- a/server/api/guilds/[guild]/settings.patch.ts +++ b/server/api/guilds/[guild]/settings.patch.ts @@ -1,19 +1,19 @@ +import { coerceBigIntFields, serializeSettings, writeSettingsTransaction } from "#server/database"; +import { compactSettingsChanges } from "#server/utils/audit/patch-to-changes"; +import { guildSettingsAccessDenied, guildSettingsUpdate } from "#shared/audit/actions"; import { SettingsUpdateSchema } from "#shared/schemas"; import { isNullOrUndefined, isNullishOrEmpty } from "@sapphire/utilities"; -import { createError, useLogger } from "evlog"; +import { createError, useLogger, withAuditMethods } from "evlog"; import { parse } from "valibot"; -/** - * Proxies to the internal bot API: PATCH /guilds/:guild/settings - * Adds `guild_id` expected by the bot while accepting the dashboard body shape. - */ export default defineWrappedResponseHandler( async (event) => { - const log = useLogger(event); + const log = withAuditMethods(useLogger(event)); + const guildId = getGuildParam(event); log.set({ guild: { id: guildId } }); - const body = await readValidatedBody(event, (raw) => parse(SettingsUpdateSchema, raw)); + const body = await readValidatedBody(event, (body) => parse(SettingsUpdateSchema, body)); if (isNullOrUndefined(body) || isNullOrUndefined(body.data)) { throw createError({ @@ -24,7 +24,9 @@ export default defineWrappedResponseHandler( }); } - if (isNullishOrEmpty(body.data)) { + const { data } = body; + + if (isNullishOrEmpty(data)) { throw createError({ message: "Data array cannot be empty", status: 400, @@ -33,13 +35,83 @@ export default defineWrappedResponseHandler( }); } - return await fetchBotApi(event, `/guilds/${guildId}/settings`, { - method: "PATCH", - body: { - guild_id: guildId, - data: body.data, - }, - }); + const guild = await getGuild(guildId); + if (!guild) { + throw createError({ + message: "Guild not found", + status: 404, + why: `The bot is not a member of guild ${guildId}`, + fix: "check bot is a member of the guild", + }); + } + + const member = await getCurrentMember(event, guild.id); + log.set({ member: { id: member.user.id } }); + + try { + await canManage(guild, member); + } catch (canManageErr) { + const status = + canManageErr instanceof Error && "status" in canManageErr + ? (canManageErr as { status: number }).status + : null; + if (status === 403) { + log.audit( + guildSettingsAccessDenied({ + actor: { + type: "user", + id: member.user.id, + displayName: member.user.username, + }, + target: { type: "guild", id: guild.id }, + + outcome: "denied", + reason: "Insufficient permissions to manage guild settings", + }), + ); + } + throw canManageErr; + } + + using trx = await writeSettingsTransaction(guild.id); + + if (!data.every((entry): entry is [string, unknown] => entry !== undefined)) { + throw createError({ + message: "Invalid data entries", + status: 400, + why: "All data entries must be valid [key, value] tuples", + fix: "Ensure every entry in the data array is a two-element array", + }); + } + + const settingsData = Object.fromEntries(data); + log.set({ settings: { keysUpdated: Object.keys(settingsData).length } }); + + // Coerce BigInt fields from JSON (numbers/strings) to BigInt + coerceBigIntFields(settingsData); + + const beforeSettings = JSON.parse(serializeSettings(trx.settings)) as Record< + string, + unknown + >; + await trx.write(settingsData).submit(); + const afterSettings = JSON.parse(serializeSettings(trx.settings)) as Record< + string, + unknown + >; + + log.audit( + guildSettingsUpdate({ + actor: { type: "user", id: member.user.id, displayName: member.user.username }, + target: { type: "guild", id: guild.id }, + outcome: "success", + // Persist only the fields that changed (with old and new values) + // instead of the two full settings snapshots + changes: compactSettingsChanges(beforeSettings, afterSettings), + }), + ); + + return JSON.stringify(afterSettings); }, { auth: true, diff --git a/server/api/languages.get.ts b/server/api/languages.get.ts deleted file mode 100644 index da54ae758..000000000 --- a/server/api/languages.get.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Public proxy for supported WolfStar bot languages. - * Fetches from the internal bot API. - */ -export default defineWrappedCachedResponseHandler( - async () => { - return await fetchLanguages(); - }, - { - auth: false, - getKey: () => "bot-languages", - maxAge: seconds.fromDays(1), - swr: true, - rateLimit: { - enabled: true, - limit: 30, - type: "sliding", - window: seconds(60), - }, - onError(log, error) { - log.error(error); - }, - }, -); diff --git a/server/utils/bot-api.ts b/server/utils/bot-api.ts index f1ae3a9eb..6c662adaf 100644 --- a/server/utils/bot-api.ts +++ b/server/utils/bot-api.ts @@ -117,6 +117,31 @@ async function buildBotAuthCookie(event: H3Event): Promise { ); } +/** + * Build outbound Cookie header for sapphire-plugin-api when a Discord session exists. + * Returns an empty object when the user is anonymous (public bot routes). + */ +export async function getOptionalBotAuthHeaders( + event: H3Event, +): Promise> { + const session = await getUserSession(event); + if (!session?.user?.id) { + return {}; + } + try { + const cookieValue = await buildBotAuthCookie(event); + return { Cookie: `${getBotAuthCookieName()}=${cookieValue}` }; + } catch { + return {}; + } +} + +/** Bot API paths that do not require a sapphire auth cookie. */ +export function isPublicBotApiPath(path: string): boolean { + const normalized = path.startsWith("/") ? path : `/${path}`; + return normalized === "/commands" || normalized === "/languages"; +} + function toErrorCause(error: unknown): Error | undefined { return error instanceof Error ? error : undefined; } diff --git a/shared/utils/bot-api-commands.ts b/shared/utils/bot-api-commands.ts index f00e14aee..e32205693 100644 --- a/shared/utils/bot-api-commands.ts +++ b/shared/utils/bot-api-commands.ts @@ -20,9 +20,11 @@ export interface BotApiCommand { * Normalize a bot API command into the dashboard's WolfCommand shape * (`aliases` + non-null `subCategory`). */ -export function normalizeBotCommand(command: BotApiCommand): WolfCommand { +export function normalizeBotCommand( + command: BotApiCommand & { aliases?: string[] }, +): WolfCommand { return { - aliases: command.alias ?? [], + aliases: command.alias ?? command.aliases ?? [], category: command.category, description: command.description, extendedHelp: command.extendedHelp, @@ -34,6 +36,8 @@ export function normalizeBotCommand(command: BotApiCommand): WolfCommand { }; } -export function normalizeBotCommands(commands: BotApiCommand[]): WolfCommand[] { +export function normalizeBotCommands( + commands: Array, +): WolfCommand[] { return commands.map(normalizeBotCommand); } diff --git a/test/nuxt/api/commands-languages.spec.ts b/test/nuxt/api/commands-languages.spec.ts index f7ffad825..8c764c40d 100644 --- a/test/nuxt/api/commands-languages.spec.ts +++ b/test/nuxt/api/commands-languages.spec.ts @@ -1,11 +1,9 @@ /** - * Contract tests for public bot-data proxy endpoints. + * Contract tests for the `$api` client BFF (`/api/bot/**`). * - * Real handlers: - * - `server/api/commands.get.ts` - * - `server/api/languages.get.ts` + * Real handler: `server/api/bot/[...path].ts` * - * These routes are unauthenticated proxies to the internal WolfStar bot API + * These routes forward to the internal WolfStar bot API * (`NUXT_PUBLIC_API_BASE_URL`). Contract tests mock the Nitro endpoints to * validate URL wiring and response shape without hitting the live bot API. */ @@ -30,19 +28,19 @@ const MOCK_COMMANDS: WolfCommand[] = [ const MOCK_LANGUAGES = ["en-US", "it", "es-ES"]; -registerEndpoint("/api/commands", { +registerEndpoint("/api/bot/commands", { method: "GET", handler: () => MOCK_COMMANDS, }); -registerEndpoint("/api/languages", { +registerEndpoint("/api/bot/languages", { method: "GET", handler: () => MOCK_LANGUAGES, }); -describe("GET /api/commands", () => { - it("returns a list of normalized WolfCommand objects", async () => { - const data = await $fetch("/api/commands"); +describe("GET /api/bot/commands", () => { + it("returns a list of command objects", async () => { + const data = await $fetch("/api/bot/commands"); expect(Array.isArray(data)).toBe(true); expect(data).toHaveLength(1); expect(data[0]?.name).toBe("voicekick"); @@ -50,9 +48,9 @@ describe("GET /api/commands", () => { }); }); -describe("GET /api/languages", () => { +describe("GET /api/bot/languages", () => { it("returns a list of language codes", async () => { - const data = await $fetch("/api/languages"); + const data = await $fetch("/api/bot/languages"); expect(data).toStrictEqual(MOCK_LANGUAGES); }); }); diff --git a/test/unit/server/api/guilds-index-get-cached-channels.spec.ts b/test/unit/server/api/guilds-index-get-cached-channels.spec.ts index 673840409..06232a206 100644 --- a/test/unit/server/api/guilds-index-get-cached-channels.spec.ts +++ b/test/unit/server/api/guilds-index-get-cached-channels.spec.ts @@ -1,21 +1,58 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockFetchBotApi, mockGetQuery, mockUseLoggerSet } = vi.hoisted(() => { - const mockFetchBotApi = vi.fn(); - const mockGetQuery = vi.fn().mockReturnValue({}); - const mockUseLoggerSet = vi.fn(); +// vi.hoisted() ensures globals are present before the route module is evaluated. +const { + mockReadSettings, + mockGetGuild, + mockGetCurrentMember, + mockCanManage, + mockGetGuildChannels, + mockFlattenGuild, + mockTransformGuild, + mockGetValidatedQuery, +} = vi.hoisted(() => { + const mockReadSettings = vi.fn(); + const mockGetGuild = vi.fn(); + const mockGetCurrentMember = vi.fn(); + const mockCanManage = vi.fn().mockResolvedValue(undefined); + const mockGetGuildChannels = vi.fn(); + const mockFlattenGuild = vi.fn(); + const mockTransformGuild = vi.fn(); + const mockGetValidatedQuery = vi.fn(); (globalThis as Record).defineWrappedResponseHandler = (fn: unknown) => fn; (globalThis as Record).getGuildParam = vi.fn().mockReturnValue("guild-123"); - (globalThis as Record).getQuery = mockGetQuery; - (globalThis as Record).fetchBotApi = mockFetchBotApi; + (globalThis as Record).getGuild = mockGetGuild; + (globalThis as Record).resolveGuildForRequest = ( + _event: unknown, + guildId: string, + ) => mockGetGuild(guildId); + (globalThis as Record).getCurrentMember = mockGetCurrentMember; + (globalThis as Record).canManage = mockCanManage; + (globalThis as Record).getGuildChannels = mockGetGuildChannels; + (globalThis as Record).flattenGuild = mockFlattenGuild; + (globalThis as Record).transformGuild = mockTransformGuild; + (globalThis as Record).getValidatedQuery = mockGetValidatedQuery; (globalThis as Record).seconds = (n: number) => n; - return { mockFetchBotApi, mockGetQuery, mockUseLoggerSet }; + return { + mockReadSettings, + mockGetGuild, + mockGetCurrentMember, + mockCanManage, + mockGetGuildChannels, + mockFlattenGuild, + mockTransformGuild, + mockGetValidatedQuery, + }; }); +vi.mock("#server/database", () => ({ + readSettings: mockReadSettings, +})); + vi.mock("evlog", () => ({ - useLogger: vi.fn().mockReturnValue({ set: mockUseLoggerSet, error: vi.fn() }), + useLogger: vi.fn().mockReturnValue({ set: vi.fn(), error: vi.fn() }), createError: vi.fn((opts: Record) => Object.assign(new Error(String(opts["message"])), opts), ), @@ -26,25 +63,104 @@ import indexGetHandler from "#server/api/guilds/[guild]/index.get"; const callHandler = indexGetHandler as unknown as (event: H3Event) => Promise; -describe("index.get - bot API proxy", () => { +const mockGuild = { id: "guild-123", owner_id: "owner-456", roles: [] }; +const mockMember = { user: { id: "user-789" }, roles: [], permissions: "8" }; +const mockSettings = { id: "guild-123", rolesAdmin: [] }; +const mockChannels = [{ id: "ch-1", name: "general" }]; +const mockFlatResult = { id: "guild-123", channels: mockChannels }; +const mockTransformResult = { id: "guild-123", manageable: true, wolfstarIsIn: true, channels: [] }; + +describe("index.get - cached getGuildChannels and readSettings deduplication", () => { const mockEvent = {} as H3Event; beforeEach(() => { vi.clearAllMocks(); - mockGetQuery.mockReturnValue({ shouldSerialize: "true" }); - mockFetchBotApi.mockResolvedValue({ id: "guild-123", name: "Test" }); + mockGetGuild.mockResolvedValue(mockGuild); + mockGetCurrentMember.mockResolvedValue(mockMember); + mockReadSettings.mockResolvedValue(mockSettings); + mockGetGuildChannels.mockResolvedValue(mockChannels); + mockCanManage.mockResolvedValue(undefined); + mockFlattenGuild.mockReturnValue(mockFlatResult); + mockTransformGuild.mockResolvedValue(mockTransformResult); + // Default: shouldSerialize false + mockGetValidatedQuery.mockResolvedValue({ shouldSerialize: false }); + }); + + it("calls getGuildChannels (not api.guilds.getChannels) to fetch channels", async () => { + await callHandler(mockEvent); + + expect(mockGetGuildChannels).toHaveBeenCalledOnce(); + expect(mockGetGuildChannels).toHaveBeenCalledWith("guild-123"); + }); + + it("calls readSettings exactly once per request", async () => { + await callHandler(mockEvent); + + expect(mockReadSettings).toHaveBeenCalledOnce(); + expect(mockReadSettings).toHaveBeenCalledWith("guild-123"); }); - it("proxies to /guilds/:guild on the internal bot API", async () => { + it("passes pre-fetched settings to canManage as third argument", async () => { await callHandler(mockEvent); - expect(mockFetchBotApi).toHaveBeenCalledWith(mockEvent, "/guilds/guild-123", { - query: { shouldSerialize: "true" }, + expect(mockCanManage).toHaveBeenCalledOnce(); + expect(mockCanManage).toHaveBeenCalledWith(mockGuild, mockMember, mockSettings); + }); + + describe("shouldSerialize: false", () => { + it("calls flattenGuild with the guild merged with pre-fetched channels", async () => { + await callHandler(mockEvent); + + expect(mockFlattenGuild).toHaveBeenCalledOnce(); + expect(mockFlattenGuild).toHaveBeenCalledWith({ ...mockGuild, channels: mockChannels }); + }); + + it("does not call transformGuild", async () => { + await callHandler(mockEvent); + + expect(mockTransformGuild).not.toHaveBeenCalled(); }); }); - it("returns the bot API response unchanged", async () => { - const result = await callHandler(mockEvent); - expect(result).toStrictEqual({ id: "guild-123", name: "Test" }); + describe("shouldSerialize: true", () => { + beforeEach(() => { + mockGetValidatedQuery.mockResolvedValue({ shouldSerialize: true }); + }); + + it("calls transformGuild with includeChannels: false and pre-fetched data", async () => { + await callHandler(mockEvent); + + expect(mockTransformGuild).toHaveBeenCalledOnce(); + expect(mockTransformGuild).toHaveBeenCalledWith(mockMember.user.id, mockGuild, { + includeChannels: false, + prefetchedGuild: mockGuild, + prefetchedMember: mockMember, + prefetchedSettings: mockSettings, + }); + }); + + it("merges pre-fetched channels onto the transformGuild result", async () => { + const result = await callHandler(mockEvent); + + expect(result).toMatchObject({ ...mockTransformResult, channels: mockChannels }); + }); + + it("does not call flattenGuild", async () => { + await callHandler(mockEvent); + + expect(mockFlattenGuild).not.toHaveBeenCalled(); + }); + }); + + describe("authorization ordering", () => { + it("does not call getGuildChannels when canManage denies the request", async () => { + const forbiddenError = Object.assign(new Error("Insufficient permissions"), { + status: 403, + }); + mockCanManage.mockRejectedValue(forbiddenError); + + await expect(callHandler(mockEvent)).rejects.toMatchObject({ status: 403 }); + expect(mockGetGuildChannels).not.toHaveBeenCalled(); + }); }); }); diff --git a/test/unit/server/api/guilds-settings-get-dedup.spec.ts b/test/unit/server/api/guilds-settings-get-dedup.spec.ts index c086d30f8..20cfd2b00 100644 --- a/test/unit/server/api/guilds-settings-get-dedup.spec.ts +++ b/test/unit/server/api/guilds-settings-get-dedup.spec.ts @@ -1,22 +1,46 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockFetchBotApi, mockUseLoggerSet } = vi.hoisted(() => { - const mockFetchBotApi = vi.fn(); - const mockUseLoggerSet = vi.fn(); +// vi.hoisted() ensures globals are present before the route module is evaluated. +const { + mockReadSettings, + mockSerializeSettings, + mockGetGuild, + mockGetCurrentMember, + mockCanManage, +} = vi.hoisted(() => { + const mockReadSettings = vi.fn(); + const mockSerializeSettings = vi.fn(); + const mockGetGuild = vi.fn(); + const mockGetCurrentMember = vi.fn(); + const mockCanManage = vi.fn().mockResolvedValue(undefined); (globalThis as Record).defineWrappedResponseHandler = (fn: unknown) => fn; (globalThis as Record).getGuildParam = vi.fn().mockReturnValue("guild-123"); - (globalThis as Record).fetchBotApi = mockFetchBotApi; + (globalThis as Record).getGuild = mockGetGuild; + (globalThis as Record).resolveGuildForRequest = ( + _event: unknown, + guildId: string, + ) => mockGetGuild(guildId); + (globalThis as Record).getCurrentMember = mockGetCurrentMember; + (globalThis as Record).canManage = mockCanManage; (globalThis as Record).seconds = (n: number) => n; - return { mockFetchBotApi, mockUseLoggerSet }; + return { + mockReadSettings, + mockSerializeSettings, + mockGetGuild, + mockGetCurrentMember, + mockCanManage, + }; }); +vi.mock("#server/database", () => ({ + readSettings: mockReadSettings, + serializeSettings: mockSerializeSettings, +})); + vi.mock("evlog", () => ({ - useLogger: vi.fn().mockReturnValue({ set: mockUseLoggerSet, error: vi.fn() }), - createError: vi.fn((opts: Record) => - Object.assign(new Error(String(opts["message"])), opts), - ), + useLogger: vi.fn().mockReturnValue({ set: vi.fn(), error: vi.fn() }), })); import type { H3Event } from "h3"; @@ -24,22 +48,43 @@ import settingsGetHandler from "#server/api/guilds/[guild]/settings.get"; const callHandler = settingsGetHandler as unknown as (event: H3Event) => Promise; -describe("settings.get - bot API proxy", () => { +const mockGuild = { id: "guild-123", owner_id: "owner-456", roles: [] }; +const mockMember = { user: { id: "user-789" }, roles: [], permissions: "8" }; +const mockSettings = { id: "guild-123", rolesAdmin: [] }; + +describe("settings.get - readSettings deduplication", () => { const mockEvent = {} as H3Event; beforeEach(() => { vi.clearAllMocks(); - mockFetchBotApi.mockResolvedValue('{"id":"guild-123","prefix":"!"}'); + mockGetGuild.mockResolvedValue(mockGuild); + mockGetCurrentMember.mockResolvedValue(mockMember); + mockReadSettings.mockResolvedValue(mockSettings); + mockSerializeSettings.mockReturnValue(JSON.stringify(mockSettings)); + mockCanManage.mockResolvedValue(undefined); }); - it("proxies to /guilds/:guild/settings on the internal bot API", async () => { + it("calls readSettings exactly once per request", async () => { await callHandler(mockEvent); - expect(mockFetchBotApi).toHaveBeenCalledWith(mockEvent, "/guilds/guild-123/settings"); + expect(mockReadSettings).toHaveBeenCalledOnce(); + expect(mockReadSettings).toHaveBeenCalledWith("guild-123"); }); - it("returns the bot API response unchanged", async () => { + it("passes pre-fetched settings to canManage as third argument", async () => { + await callHandler(mockEvent); + + expect(mockCanManage).toHaveBeenCalledOnce(); + expect(mockCanManage).toHaveBeenCalledWith(mockGuild, mockMember, mockSettings); + }); + + it("returns the result of serializeSettings called with the pre-fetched settings", async () => { + const expected = JSON.stringify(mockSettings); + const result = await callHandler(mockEvent); - expect(result).toBe('{"id":"guild-123","prefix":"!"}'); + + expect(mockSerializeSettings).toHaveBeenCalledOnce(); + expect(mockSerializeSettings).toHaveBeenCalledWith(mockSettings); + expect(result).toBe(expected); }); }); diff --git a/test/unit/server/utils/bot-api.spec.ts b/test/unit/server/utils/bot-api.spec.ts index ad5960a8c..b4fde9a9a 100644 --- a/test/unit/server/utils/bot-api.spec.ts +++ b/test/unit/server/utils/bot-api.spec.ts @@ -1,6 +1,7 @@ import { decryptSapphireAuth, encryptSapphireAuth, + isPublicBotApiPath, type SapphireAuthPayload, } from "#server/utils/bot-api"; import { describe, expect, it } from "vitest"; @@ -55,3 +56,11 @@ describe("sapphire auth cookie crypto", () => { expect(decryptSapphireAuth("not-a-token", SECRET)).toBeNull(); }); }); + +describe("isPublicBotApiPath", () => { + it("marks commands and languages as public", () => { + expect(isPublicBotApiPath("/commands")).toBe(true); + expect(isPublicBotApiPath("languages")).toBe(true); + expect(isPublicBotApiPath("/guilds/1/settings")).toBe(false); + }); +}); diff --git a/test/unit/shared/utils/bot-api-commands.test.ts b/test/unit/shared/utils/bot-api-commands.test.ts index 750dae411..0418d050b 100644 --- a/test/unit/shared/utils/bot-api-commands.test.ts +++ b/test/unit/shared/utils/bot-api-commands.test.ts @@ -48,6 +48,13 @@ describe("normalizeBotCommand", () => { expect(normalizeBotCommand(withoutAlias).aliases).toStrictEqual([]); }); + it("falls back to aliases when alias is absent", () => { + const { alias: _alias, ...withoutAlias } = sampleBotCommand; + expect( + normalizeBotCommand({ ...withoutAlias, aliases: ["legacy"] }).aliases, + ).toStrictEqual(["legacy"]); + }); + it("preserves a non-null subCategory", () => { expect(normalizeBotCommand({ ...sampleBotCommand, subCategory: "Voice" }).subCategory).toBe( "Voice", From d49bcac9a767b165ce10a9c875fa3b0cbe272ff9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:32:12 +0000 Subject: [PATCH 05/15] [autofix.ci] apply automated fixes --- app/layouts/dashboard.vue | 18 +++++++----------- server/api/bot/[...path].ts | 3 +-- server/utils/bot-api.ts | 4 +--- shared/utils/bot-api-commands.ts | 4 +--- .../unit/shared/utils/bot-api-commands.test.ts | 6 +++--- 5 files changed, 13 insertions(+), 22 deletions(-) diff --git a/app/layouts/dashboard.vue b/app/layouts/dashboard.vue index f852e7089..6d2792a2c 100644 --- a/app/layouts/dashboard.vue +++ b/app/layouts/dashboard.vue @@ -473,21 +473,17 @@ function isValidGuildId(id: string | undefined | null): boolean { } async function submitChanges() { - const { data: response } = await $api( - `/guilds/${guildId.value}/settings`, - { - body: { - guild_id: guildId.value, - data: objectToTuples(guildSettingsChanges.value as Partial), - }, - method: "PATCH", + const { data: response } = await $api(`/guilds/${guildId.value}/settings`, { + body: { + guild_id: guildId.value, + data: objectToTuples(guildSettingsChanges.value as Partial), }, - ); + method: "PATCH", + }); let parsed: GuildData; try { - parsed = - typeof response === "string" ? (JSON.parse(response) as GuildData) : response; + parsed = typeof response === "string" ? (JSON.parse(response) as GuildData) : response; } catch (error) { logger.error( `Failed to parse response from settings update for guild Id: ${guildId.value}`, diff --git a/server/api/bot/[...path].ts b/server/api/bot/[...path].ts index 9f4b964af..ed920facf 100644 --- a/server/api/bot/[...path].ts +++ b/server/api/bot/[...path].ts @@ -24,8 +24,7 @@ export default defineWrappedResponseHandler( log.set({ botApi: { path, method } }); const query = getQuery(event); - const body = - method === "GET" || method === "DELETE" ? undefined : await readBody(event); + const body = method === "GET" || method === "DELETE" ? undefined : await readBody(event); return await fetchBotApi(event, path, { auth: !isPublicBotApiPath(path), diff --git a/server/utils/bot-api.ts b/server/utils/bot-api.ts index 6c662adaf..06e0fa177 100644 --- a/server/utils/bot-api.ts +++ b/server/utils/bot-api.ts @@ -121,9 +121,7 @@ async function buildBotAuthCookie(event: H3Event): Promise { * Build outbound Cookie header for sapphire-plugin-api when a Discord session exists. * Returns an empty object when the user is anonymous (public bot routes). */ -export async function getOptionalBotAuthHeaders( - event: H3Event, -): Promise> { +export async function getOptionalBotAuthHeaders(event: H3Event): Promise> { const session = await getUserSession(event); if (!session?.user?.id) { return {}; diff --git a/shared/utils/bot-api-commands.ts b/shared/utils/bot-api-commands.ts index e32205693..b40a092ce 100644 --- a/shared/utils/bot-api-commands.ts +++ b/shared/utils/bot-api-commands.ts @@ -20,9 +20,7 @@ export interface BotApiCommand { * Normalize a bot API command into the dashboard's WolfCommand shape * (`aliases` + non-null `subCategory`). */ -export function normalizeBotCommand( - command: BotApiCommand & { aliases?: string[] }, -): WolfCommand { +export function normalizeBotCommand(command: BotApiCommand & { aliases?: string[] }): WolfCommand { return { aliases: command.alias ?? command.aliases ?? [], category: command.category, diff --git a/test/unit/shared/utils/bot-api-commands.test.ts b/test/unit/shared/utils/bot-api-commands.test.ts index 0418d050b..1a1eb6f3c 100644 --- a/test/unit/shared/utils/bot-api-commands.test.ts +++ b/test/unit/shared/utils/bot-api-commands.test.ts @@ -50,9 +50,9 @@ describe("normalizeBotCommand", () => { it("falls back to aliases when alias is absent", () => { const { alias: _alias, ...withoutAlias } = sampleBotCommand; - expect( - normalizeBotCommand({ ...withoutAlias, aliases: ["legacy"] }).aliases, - ).toStrictEqual(["legacy"]); + expect(normalizeBotCommand({ ...withoutAlias, aliases: ["legacy"] }).aliases).toStrictEqual( + ["legacy"], + ); }); it("preserves a non-null subCategory", () => { From d7e525416de167a2cb58999394cd73e093535e7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 11:34:34 +0000 Subject: [PATCH 06/15] fix(api): correct bot BFF rateLimit window option Use window instead of time for defineWrappedResponseHandler rate limits and apply lint formatting on related $api call sites. Co-authored-by: RedStar --- server/api/bot/[...path].ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/bot/[...path].ts b/server/api/bot/[...path].ts index ed920facf..9bc6c4321 100644 --- a/server/api/bot/[...path].ts +++ b/server/api/bot/[...path].ts @@ -38,6 +38,6 @@ export default defineWrappedResponseHandler( onError(log, error) { log.error(error); }, - rateLimit: { enabled: true, limit: 30, time: seconds(10) }, + rateLimit: { enabled: true, limit: 30, type: "fixed", window: 10_000 }, }, ); From 9145a7eb35d96d609c96677b6fae3f536bdfa84f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 11:41:37 +0000 Subject: [PATCH 07/15] refactor(api): call bot API directly via $api like legacy Remove the /api/bot BFF and always point $api at NUXT_PUBLIC_API_BASE_URL with credentials:include, matching legacy apiFetch behavior. Keep optional SSR sapphire cookie injection from the better-auth Discord session. Co-authored-by: RedStar --- .env.example | 6 +- app/pages/commands.stories.ts | 6 +- app/plugins/api.ts | 48 ++++-------- app/storybook/mocks/handlers.ts | 14 ++-- nuxt.config.ts | 9 --- server/api/bot/[...path].ts | 43 ---------- server/utils/bot-api.ts | 99 ------------------------ test/nuxt/api/commands-languages.spec.ts | 56 -------------- test/unit/server/utils/bot-api.spec.ts | 9 --- 9 files changed, 29 insertions(+), 261 deletions(-) delete mode 100644 server/api/bot/[...path].ts delete mode 100644 test/nuxt/api/commands-languages.spec.ts diff --git a/.env.example b/.env.example index 31344d6da..2ea486bd8 100644 --- a/.env.example +++ b/.env.example @@ -41,13 +41,13 @@ NUXT_CLOUDFLARE_API_TOKEN= # =================================== # Website Environment (Required) -# WolfStar bot API origin used by `$api` (and the `/api/bot/**` client BFF). +# WolfStar bot API origin used by `$api` (legacy-style direct calls). # Production/internal: https://api.wolfstar.rocks (default when unset) # Local bot instance: http://localhost:8282 NUXT_PUBLIC_API_BASE_URL=http://localhost:8282 -# Optional: sapphire-plugin-api cookie name / OAuth secret for authenticated bot -# API calls. Defaults: cookie SAPPHIRE_AUTH, secret = Discord client secret. +# Optional: sapphire-plugin-api cookie name / OAuth secret for SSR auth cookie +# injection. Defaults: cookie SAPPHIRE_AUTH, secret = Discord client secret. # NUXT_BOT_API_AUTH_COOKIE=SAPPHIRE_AUTH # NUXT_BOT_API_OAUTH_SECRET= diff --git a/app/pages/commands.stories.ts b/app/pages/commands.stories.ts index a81115f17..e338308bb 100644 --- a/app/pages/commands.stories.ts +++ b/app/pages/commands.stories.ts @@ -11,7 +11,7 @@ const meta: Meta = { parameters: { layout: "fullscreen", msw: { - handlers: [http.get(/\/api\/bot\/commands$/, () => HttpResponse.json(mockCommands))], + handlers: [http.get(/\/commands$/, () => HttpResponse.json(mockCommands))], }, }, }; @@ -24,7 +24,7 @@ export const WithCommands: Story = {}; export const Empty: Story = { parameters: { msw: { - handlers: [http.get(/\/api\/bot\/commands$/, () => HttpResponse.json([]))], + handlers: [http.get(/\/commands$/, () => HttpResponse.json([]))], }, }, }; @@ -36,7 +36,7 @@ export const Loading: Story = { // stable for the snapshot. A fixed timeout (e.g. 60s) would either // resolve mid-capture or trip Chromatic's per-story capture timeout. handlers: [ - http.get(/\/api\/bot\/commands$/, async () => { + http.get(/\/commands$/, async () => { await delay("infinite"); return HttpResponse.json(mockCommands); }), diff --git a/app/plugins/api.ts b/app/plugins/api.ts index 9c66f299d..021c466ec 100644 --- a/app/plugins/api.ts +++ b/app/plugins/api.ts @@ -1,12 +1,13 @@ import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config"; /** - * Provides `$api` for the internal WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`). + * Provides `$api` for the WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`), + * matching the legacy dashboard `apiFetch` behavior: call the bot origin + * directly with `credentials: "include"`. * - * - Server: calls the bot origin directly and attaches a sapphire `SAPPHIRE_AUTH` - * cookie when a Discord session is available. - * - Client: cannot set cross-origin Cookie headers, so requests go through the - * same-origin BFF at `/api/bot/**` which injects auth server-side. + * On the server, when a Discord session exists, a sapphire `SAPPHIRE_AUTH` + * cookie is attached so SSR can authorize guild routes without relying on a + * browser cookie set on the API domain. */ export default defineNuxtPlugin(() => { const cachedFetch = useCachedFetch(); @@ -21,41 +22,26 @@ export default defineNuxtPlugin(() => { options?: Parameters[1], ttl?: Parameters[2], ) => { + const headers: Record = { + "Content-Type": "application/json", + ...(options?.headers as Record | undefined), + }; + if (import.meta.server) { const event = useRequestEvent(); - const authHeaders = - event !== undefined - ? await ( - await import("#server/utils/bot-api") - ).getOptionalBotAuthHeaders(event) - : {}; - - return cachedFetch( - url, - { - ...options, - baseURL: apiBaseUrl, - credentials: "include", - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...authHeaders, - }, - }, - ttl, - ); + if (event !== undefined) { + const { getOptionalBotAuthHeaders } = await import("#server/utils/bot-api"); + Object.assign(headers, await getOptionalBotAuthHeaders(event)); + } } return cachedFetch( url, { ...options, - baseURL: "/api/bot", + baseURL: apiBaseUrl, credentials: "include", - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, + headers, }, ttl, ); diff --git a/app/storybook/mocks/handlers.ts b/app/storybook/mocks/handlers.ts index d0d3b55a6..1530c943b 100644 --- a/app/storybook/mocks/handlers.ts +++ b/app/storybook/mocks/handlers.ts @@ -18,18 +18,16 @@ export const handlers = [ http.get("/api/guilds", () => HttpResponse.json(mockGuildList)), - // `$api` client BFF (`/api/bot/**` → bot API paths) - http.get("/api/bot/guilds/:guildId", () => HttpResponse.json(mockGuild)), - http.get("/api/bot/guilds/:guildId/audit-logs", () => - HttpResponse.json({ entries: [], total: 0 }), - ), - http.get("/api/bot/guilds/:guildId/settings", () => + // `$api` hits NUXT_PUBLIC_API_BASE_URL directly (legacy-style). + http.get("*/guilds/:guildId", () => HttpResponse.json(mockGuild)), + http.get("*/guilds/:guildId/audit-logs", () => HttpResponse.json({ entries: [], total: 0 })), + http.get("*/guilds/:guildId/settings", () => HttpResponse.json({ guildId: "123456789012345678", prefix: "!", language: "en-US", }), ), - http.get(/\/api\/bot\/commands$/, () => HttpResponse.json(mockCommands)), - http.get(/\/api\/bot\/languages$/, () => HttpResponse.json(["en-US", "es-ES", "fr-FR"])), + http.get(/\/commands$/, () => HttpResponse.json(mockCommands)), + http.get(/\/languages$/, () => HttpResponse.json(["en-US", "es-ES", "fr-FR"])), ]; diff --git a/nuxt.config.ts b/nuxt.config.ts index 1b2a5e902..90375444a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -220,15 +220,6 @@ export default defineNuxtConfig({ "Vary": "Cookie, Authorization", }, }, - // Bot API BFF used by `$api` on the client — never CDN-cache (may carry auth). - "/api/bot/**": { - isr: false, - cache: false, - headers: { - "Cache-Control": "private, no-store", - }, - }, - "/oauth/**": { robots: "nosnippet,notranslate,noimageindex,noarchive,max-snippet:-1,max-image-preview:none,max-video-preview:-1", security: { diff --git a/server/api/bot/[...path].ts b/server/api/bot/[...path].ts deleted file mode 100644 index 9bc6c4321..000000000 --- a/server/api/bot/[...path].ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createError, useLogger } from "evlog"; -import { getQuery, readBody } from "h3"; - -type BotHttpMethod = "GET" | "PATCH" | "POST" | "PUT" | "DELETE"; - -/** - * Same-origin BFF for `$api` on the client. - * Forwards `/api/bot/**` to the WolfStar bot API with sapphire auth when needed. - */ -export default defineWrappedResponseHandler( - async (event) => { - const log = useLogger(event); - const pathParam = getRouterParam(event, "path"); - if (!pathParam) { - throw createError({ - message: "Not found", - status: 404, - why: "Bot API proxy path is empty", - }); - } - - const path = pathParam.startsWith("/") ? pathParam : `/${pathParam}`; - const method = (event.method || "GET").toUpperCase() as BotHttpMethod; - log.set({ botApi: { path, method } }); - - const query = getQuery(event); - const body = method === "GET" || method === "DELETE" ? undefined : await readBody(event); - - return await fetchBotApi(event, path, { - auth: !isPublicBotApiPath(path), - body, - method, - query, - }); - }, - { - auth: false, - onError(log, error) { - log.error(error); - }, - rateLimit: { enabled: true, limit: 30, type: "fixed", window: 10_000 }, - }, -); diff --git a/server/utils/bot-api.ts b/server/utils/bot-api.ts index 06e0fa177..c4f1a6915 100644 --- a/server/utils/bot-api.ts +++ b/server/utils/bot-api.ts @@ -1,7 +1,6 @@ import type { H3Event } from "h3"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { createError } from "evlog"; -import { $fetch, FetchError } from "ofetch"; const DEFAULT_AUTH_COOKIE = "SAPPHIRE_AUTH"; @@ -12,31 +11,6 @@ export interface SapphireAuthPayload { token: string; } -type BotHttpMethod = "GET" | "PATCH" | "POST" | "PUT" | "DELETE"; - -interface FetchBotApiOptions { - body?: Record | unknown[] | string | null; - method?: BotHttpMethod; - query?: Record; - /** When false, skip sapphire auth cookie (public bot routes). Default true. */ - auth?: boolean; -} - -function getBotApiBaseUrl(): string { - const { - public: { apiBaseUrl }, - } = useRuntimeConfig(); - if (!apiBaseUrl) { - throw createError({ - message: "Bot API base URL is not configured", - status: 500, - why: "NUXT_PUBLIC_API_BASE_URL is missing", - fix: "Set NUXT_PUBLIC_API_BASE_URL to the WolfStar bot API origin (e.g. https://api.wolfstar.rocks)", - }); - } - return apiBaseUrl.replace(/\/$/, ""); -} - function getBotAuthCookieName(): string { return process.env.NUXT_BOT_API_AUTH_COOKIE || DEFAULT_AUTH_COOKIE; } @@ -133,76 +107,3 @@ export async function getOptionalBotAuthHeaders(event: H3Event): Promise( - event: H3Event, - path: string, - options: FetchBotApiOptions = {}, -): Promise { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const url = `${getBotApiBaseUrl()}${normalizedPath}`; - const headers: Record = { - "Content-Type": "application/json", - }; - - if (options.auth !== false) { - const cookieValue = await buildBotAuthCookie(event); - headers.Cookie = `${getBotAuthCookieName()}=${cookieValue}`; - } - - try { - return await $fetch(url, { - body: options.body, - headers, - method: options.method, - query: options.query, - }); - } catch (error) { - mapBotFetchError(error, normalizedPath); - } -} diff --git a/test/nuxt/api/commands-languages.spec.ts b/test/nuxt/api/commands-languages.spec.ts deleted file mode 100644 index 8c764c40d..000000000 --- a/test/nuxt/api/commands-languages.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Contract tests for the `$api` client BFF (`/api/bot/**`). - * - * Real handler: `server/api/bot/[...path].ts` - * - * These routes forward to the internal WolfStar bot API - * (`NUXT_PUBLIC_API_BASE_URL`). Contract tests mock the Nitro endpoints to - * validate URL wiring and response shape without hitting the live bot API. - */ - -import type { WolfCommand } from "#shared/types/discord"; -import { registerEndpoint } from "@nuxt/test-utils/runtime"; -import { describe, expect, it } from "vitest"; - -const MOCK_COMMANDS: WolfCommand[] = [ - { - aliases: ["vk"], - category: "Moderation", - description: "Kick a user from voice", - extendedHelp: {}, - guarded: false, - name: "voicekick", - permissionLevel: 5, - preconditions: { entries: [], mode: 0, runCondition: 0 }, - subCategory: "", - }, -]; - -const MOCK_LANGUAGES = ["en-US", "it", "es-ES"]; - -registerEndpoint("/api/bot/commands", { - method: "GET", - handler: () => MOCK_COMMANDS, -}); - -registerEndpoint("/api/bot/languages", { - method: "GET", - handler: () => MOCK_LANGUAGES, -}); - -describe("GET /api/bot/commands", () => { - it("returns a list of command objects", async () => { - const data = await $fetch("/api/bot/commands"); - expect(Array.isArray(data)).toBe(true); - expect(data).toHaveLength(1); - expect(data[0]?.name).toBe("voicekick"); - expect(data[0]?.aliases).toStrictEqual(["vk"]); - }); -}); - -describe("GET /api/bot/languages", () => { - it("returns a list of language codes", async () => { - const data = await $fetch("/api/bot/languages"); - expect(data).toStrictEqual(MOCK_LANGUAGES); - }); -}); diff --git a/test/unit/server/utils/bot-api.spec.ts b/test/unit/server/utils/bot-api.spec.ts index b4fde9a9a..ad5960a8c 100644 --- a/test/unit/server/utils/bot-api.spec.ts +++ b/test/unit/server/utils/bot-api.spec.ts @@ -1,7 +1,6 @@ import { decryptSapphireAuth, encryptSapphireAuth, - isPublicBotApiPath, type SapphireAuthPayload, } from "#server/utils/bot-api"; import { describe, expect, it } from "vitest"; @@ -56,11 +55,3 @@ describe("sapphire auth cookie crypto", () => { expect(decryptSapphireAuth("not-a-token", SECRET)).toBeNull(); }); }); - -describe("isPublicBotApiPath", () => { - it("marks commands and languages as public", () => { - expect(isPublicBotApiPath("/commands")).toBe(true); - expect(isPublicBotApiPath("languages")).toBe(true); - expect(isPublicBotApiPath("/guilds/1/settings")).toBe(false); - }); -}); From 8c1f73b4caccb5b1454a23b44feaf9374837f086 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 11:52:09 +0000 Subject: [PATCH 08/15] refactor(api): move bot-api helpers and types into shared Relocate sapphire auth crypto and BotApiCommand types to #shared so the $api plugin can import them without a dynamic #server/utils/bot-api load. Co-authored-by: RedStar --- app/composables/useCommands.ts | 2 +- app/plugins/api.ts | 17 ++- server/utils/bot-api.ts | 109 ------------------ server/utils/discord/index.ts | 3 +- shared/types/bot-api.ts | 27 +++++ shared/types/index.ts | 1 + shared/utils/bot-api-commands.ts | 19 +-- shared/utils/bot-api.ts | 79 +++++++++++++ shared/utils/index.ts | 1 + .../shared/utils/bot-api-commands.test.ts | 2 +- .../{server => shared}/utils/bot-api.spec.ts | 39 ++++++- 11 files changed, 165 insertions(+), 134 deletions(-) delete mode 100644 server/utils/bot-api.ts create mode 100644 shared/types/bot-api.ts create mode 100644 shared/utils/bot-api.ts rename test/unit/{server => shared}/utils/bot-api.spec.ts (57%) diff --git a/app/composables/useCommands.ts b/app/composables/useCommands.ts index e8401ee6a..6955054d0 100644 --- a/app/composables/useCommands.ts +++ b/app/composables/useCommands.ts @@ -1,4 +1,4 @@ -import type { BotApiCommand } from "#shared/utils/bot-api-commands"; +import type { BotApiCommand } from "#shared/types/bot-api"; import { normalizeBotCommands } from "#shared/utils/bot-api-commands"; export function useCommands(options?: ApiComposableOptions) { diff --git a/app/plugins/api.ts b/app/plugins/api.ts index 021c466ec..2cf77f957 100644 --- a/app/plugins/api.ts +++ b/app/plugins/api.ts @@ -1,4 +1,5 @@ import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config"; +import { getBotOauthSecret, getOptionalBotAuthHeaders } from "#shared/utils/bot-api"; /** * Provides `$api` for the WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`), @@ -12,6 +13,7 @@ import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config"; export default defineNuxtPlugin(() => { const cachedFetch = useCachedFetch(); const { + discord: { clientSecret }, public: { apiBaseUrl }, } = useRuntimeConfig(); @@ -29,9 +31,18 @@ export default defineNuxtPlugin(() => { if (import.meta.server) { const event = useRequestEvent(); - if (event !== undefined) { - const { getOptionalBotAuthHeaders } = await import("#server/utils/bot-api"); - Object.assign(headers, await getOptionalBotAuthHeaders(event)); + const authorization = event?.context.$authorization; + if (authorization) { + const user = await authorization.resolveServerUser(); + const tokens = user ? await authorization.resolveServerTokens() : null; + Object.assign( + headers, + getOptionalBotAuthHeaders({ + accessToken: tokens?.access_token, + secret: getBotOauthSecret(clientSecret), + userId: user?.id, + }), + ); } } diff --git a/server/utils/bot-api.ts b/server/utils/bot-api.ts deleted file mode 100644 index c4f1a6915..000000000 --- a/server/utils/bot-api.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { H3Event } from "h3"; -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { createError } from "evlog"; - -const DEFAULT_AUTH_COOKIE = "SAPPHIRE_AUTH"; - -export interface SapphireAuthPayload { - expires: number; - id: string; - refresh: string; - token: string; -} - -function getBotAuthCookieName(): string { - return process.env.NUXT_BOT_API_AUTH_COOKIE || DEFAULT_AUTH_COOKIE; -} - -function getBotOauthSecret(): string { - const secret = - process.env.NUXT_BOT_API_OAUTH_SECRET || useRuntimeConfig().discord.clientSecret || ""; - if (!secret) { - throw createError({ - message: "Bot API OAuth secret is not configured", - status: 500, - why: "Neither NUXT_BOT_API_OAUTH_SECRET nor NUXT_OAUTH_DISCORD_CLIENT_SECRET is set", - fix: "Set the Discord OAuth client secret shared with the WolfStar bot API", - }); - } - return secret; -} - -/** - * Encrypt a sapphire-plugin-api auth payload (aes-256-cbc), matching - * `@sapphire/plugin-api` Auth.encrypt so api.wolfstar.rocks accepts the cookie. - */ -export function encryptSapphireAuth(data: SapphireAuthPayload, secret: string): string { - const iv = randomBytes(16); - const cipher = createCipheriv("aes-256-cbc", secret, iv); - return `${cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64")}.${iv.toString("base64")}`; -} - -/** - * Decrypt a sapphire-plugin-api auth token. Returns null when expired or invalid. - */ -export function decryptSapphireAuth(token: string, secret: string): SapphireAuthPayload | null { - const [data, iv] = token.split("."); - if (!data || !iv) { - return null; - } - try { - const decipher = createDecipheriv("aes-256-cbc", secret, Buffer.from(iv, "base64")); - const parsed = JSON.parse( - decipher.update(data, "base64", "utf8") + decipher.final("utf8"), - ) as SapphireAuthPayload; - return parsed.expires >= Date.now() ? parsed : null; - } catch { - return null; - } -} - -async function buildBotAuthCookie(event: H3Event): Promise { - const session = await getUserSession(event); - const userId = session?.user?.id; - if (!userId) { - throw createError({ - message: "Unauthorized", - status: 401, - why: "No authenticated session is available for the bot API request", - fix: "Sign in with Discord and retry", - }); - } - - const tokens = await refreshSessionTokens(event); - if (!tokens?.access_token) { - throw createError({ - message: "Unauthorized", - status: 401, - why: "Discord access token could not be resolved for the current session", - fix: "Sign out and sign in again to refresh Discord OAuth tokens", - }); - } - - return encryptSapphireAuth( - { - expires: Date.now() + 60 * 60 * 1000, - id: userId, - refresh: "", - token: tokens.access_token, - }, - getBotOauthSecret(), - ); -} - -/** - * Build outbound Cookie header for sapphire-plugin-api when a Discord session exists. - * Returns an empty object when the user is anonymous (public bot routes). - */ -export async function getOptionalBotAuthHeaders(event: H3Event): Promise> { - const session = await getUserSession(event); - if (!session?.user?.id) { - return {}; - } - try { - const cookieValue = await buildBotAuthCookie(event); - return { Cookie: `${getBotAuthCookieName()}=${cookieValue}` }; - } catch { - return {}; - } -} diff --git a/server/utils/discord/index.ts b/server/utils/discord/index.ts index d6574355a..d7e326814 100644 --- a/server/utils/discord/index.ts +++ b/server/utils/discord/index.ts @@ -7,6 +7,7 @@ import type { PartialOauthFlattenedGuild, TransformedLoginData, } from "#shared/types"; +import type { BotApiCommand } from "#shared/types/bot-api"; import type { DiscordAPIError } from "@discordjs/rest"; import type { APIGuild, @@ -28,7 +29,7 @@ import { fetchGuildMemberWithRetry, } from "#server/utils/discord/oauth"; import { PermissionsBits } from "#shared/utils/bits"; -import { type BotApiCommand, normalizeBotCommands } from "#shared/utils/bot-api-commands"; +import { normalizeBotCommands } from "#shared/utils/bot-api-commands"; import { hours } from "#shared/utils/times"; import { cast } from "@sapphire/utilities"; import { hasAtLeastOneKeyInMap } from "@sapphire/utilities/hasAtLeastOneKeyInMap"; diff --git a/shared/types/bot-api.ts b/shared/types/bot-api.ts new file mode 100644 index 000000000..5488730c6 --- /dev/null +++ b/shared/types/bot-api.ts @@ -0,0 +1,27 @@ +import type { FlattenedCommand, Preconditions } from "#shared/types/discord"; + +/** + * Payload encrypted into the sapphire-plugin-api `SAPPHIRE_AUTH` cookie. + */ +export interface SapphireAuthPayload { + expires: number; + id: string; + refresh: string; + token: string; +} + +/** + * Raw command payload from the WolfStar bot API (`/commands`). + * The bot serializes Sapphire `aliases` as the singular `alias` field. + */ +export interface BotApiCommand { + alias?: string[]; + category: string; + description: string; + extendedHelp: FlattenedCommand["extendedHelp"]; + guarded: boolean; + name: string; + permissionLevel: number; + preconditions: Preconditions; + subCategory?: string | null; +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 00aae2b02..3412b2b7e 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,4 +1,5 @@ export * from "./audit-log"; +export * from "./bot-api"; export * from "./configurableData"; export * from "./discord"; export * from "./env"; diff --git a/shared/utils/bot-api-commands.ts b/shared/utils/bot-api-commands.ts index b40a092ce..036f453fc 100644 --- a/shared/utils/bot-api-commands.ts +++ b/shared/utils/bot-api-commands.ts @@ -1,20 +1,5 @@ -import type { FlattenedCommand, Preconditions, WolfCommand } from "#shared/types/discord"; - -/** - * Raw command payload from the WolfStar bot API (`/commands`). - * The bot serializes Sapphire `aliases` as the singular `alias` field. - */ -export interface BotApiCommand { - alias?: string[]; - category: string; - description: string; - extendedHelp: FlattenedCommand["extendedHelp"]; - guarded: boolean; - name: string; - permissionLevel: number; - preconditions: Preconditions; - subCategory?: string | null; -} +import type { BotApiCommand } from "#shared/types/bot-api"; +import type { WolfCommand } from "#shared/types/discord"; /** * Normalize a bot API command into the dashboard's WolfCommand shape diff --git a/shared/utils/bot-api.ts b/shared/utils/bot-api.ts new file mode 100644 index 000000000..469d3d925 --- /dev/null +++ b/shared/utils/bot-api.ts @@ -0,0 +1,79 @@ +import type { SapphireAuthPayload } from "#shared/types/bot-api"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +export const DEFAULT_BOT_API_AUTH_COOKIE = "SAPPHIRE_AUTH"; + +export function getBotAuthCookieName(): string { + return process.env.NUXT_BOT_API_AUTH_COOKIE || DEFAULT_BOT_API_AUTH_COOKIE; +} + +/** + * Resolve the aes-256-cbc secret shared with sapphire-plugin-api. + * Prefers `NUXT_BOT_API_OAUTH_SECRET`, then an optional Discord client-secret fallback. + */ +export function getBotOauthSecret(discordClientSecretFallback = ""): string { + return process.env.NUXT_BOT_API_OAUTH_SECRET || discordClientSecretFallback || ""; +} + +/** + * Encrypt a sapphire-plugin-api auth payload (aes-256-cbc), matching + * `@sapphire/plugin-api` Auth.encrypt so api.wolfstar.rocks accepts the cookie. + */ +export function encryptSapphireAuth(data: SapphireAuthPayload, secret: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv("aes-256-cbc", secret, iv); + return `${cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64")}.${iv.toString("base64")}`; +} + +/** + * Decrypt a sapphire-plugin-api auth token. Returns null when expired or invalid. + */ +export function decryptSapphireAuth(token: string, secret: string): SapphireAuthPayload | null { + const [data, iv] = token.split("."); + if (!data || !iv) { + return null; + } + try { + const decipher = createDecipheriv("aes-256-cbc", secret, Buffer.from(iv, "base64")); + const parsed = JSON.parse( + decipher.update(data, "base64", "utf8") + decipher.final("utf8"), + ) as SapphireAuthPayload; + return parsed.expires >= Date.now() ? parsed : null; + } catch { + return null; + } +} + +export interface BotAuthSessionInput { + accessToken?: string | null; + cookieName?: string; + /** Token lifetime for the sapphire cookie. Defaults to 1 hour. */ + expiresInMs?: number; + secret: string; + userId?: string | null; +} + +/** + * Build outbound Cookie headers for sapphire-plugin-api when session credentials exist. + * Returns an empty object when the user/token/secret is missing. + */ +export function getOptionalBotAuthHeaders(input: BotAuthSessionInput): Record { + const { accessToken, secret, userId } = input; + if (!userId || !accessToken || !secret) { + return {}; + } + + const cookieValue = encryptSapphireAuth( + { + expires: Date.now() + (input.expiresInMs ?? 60 * 60 * 1000), + id: userId, + refresh: "", + token: accessToken, + }, + secret, + ); + + return { + Cookie: `${input.cookieName ?? getBotAuthCookieName()}=${cookieValue}`, + }; +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index b25dbfff9..de89a5ba5 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,5 +1,6 @@ export * from "./discord"; export * from "./bits"; +export * from "./bot-api"; export * from "./bot-api-commands"; export * from "./comparators"; export * from "./fetch-cache-config"; diff --git a/test/unit/shared/utils/bot-api-commands.test.ts b/test/unit/shared/utils/bot-api-commands.test.ts index 1a1eb6f3c..3b11ef5d6 100644 --- a/test/unit/shared/utils/bot-api-commands.test.ts +++ b/test/unit/shared/utils/bot-api-commands.test.ts @@ -1,4 +1,4 @@ -import type { BotApiCommand } from "#shared/utils/bot-api-commands"; +import type { BotApiCommand } from "#shared/types/bot-api"; import { normalizeBotCommand, normalizeBotCommands } from "#shared/utils/bot-api-commands"; import { describe, expect, it } from "vitest"; diff --git a/test/unit/server/utils/bot-api.spec.ts b/test/unit/shared/utils/bot-api.spec.ts similarity index 57% rename from test/unit/server/utils/bot-api.spec.ts rename to test/unit/shared/utils/bot-api.spec.ts index ad5960a8c..fc72f318e 100644 --- a/test/unit/server/utils/bot-api.spec.ts +++ b/test/unit/shared/utils/bot-api.spec.ts @@ -1,8 +1,9 @@ +import type { SapphireAuthPayload } from "#shared/types/bot-api"; import { decryptSapphireAuth, encryptSapphireAuth, - type SapphireAuthPayload, -} from "#server/utils/bot-api"; + getOptionalBotAuthHeaders, +} from "#shared/utils/bot-api"; import { describe, expect, it } from "vitest"; // aes-256-cbc requires a 32-byte key — match Discord client-secret length. @@ -55,3 +56,37 @@ describe("sapphire auth cookie crypto", () => { expect(decryptSapphireAuth("not-a-token", SECRET)).toBeNull(); }); }); + +describe("getOptionalBotAuthHeaders", () => { + it("returns an empty object when credentials are missing", () => { + expect( + getOptionalBotAuthHeaders({ + accessToken: null, + secret: SECRET, + userId: "1", + }), + ).toStrictEqual({}); + expect( + getOptionalBotAuthHeaders({ + accessToken: "token", + secret: "", + userId: "1", + }), + ).toStrictEqual({}); + }); + + it("builds a Cookie header that decrypts to the session payload", () => { + const headers = getOptionalBotAuthHeaders({ + accessToken: "access-token", + cookieName: "SAPPHIRE_AUTH", + secret: SECRET, + userId: "123456789012345678", + }); + + expect(headers.Cookie).toMatch(/^SAPPHIRE_AUTH=.[^\n\r.\u2028\u2029]*\..+$/); + const token = headers.Cookie!.slice("SAPPHIRE_AUTH=".length); + const decrypted = decryptSapphireAuth(token, SECRET); + expect(decrypted?.id).toBe("123456789012345678"); + expect(decrypted?.token).toBe("access-token"); + }); +}); From f370a57b32d09d3984f2cede783757e582bac7fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 11:54:40 +0000 Subject: [PATCH 09/15] refactor(api): rename bot-api modules to botApi Use camelCase botApi file and symbol names (BotApi* types, normalizeBotApiCommands, getOptionalBotApiAuthHeaders) in shared. Co-authored-by: RedStar --- app/composables/useCommands.ts | 6 +- app/plugins/api.ts | 6 +- server/utils/discord/index.ts | 6 +- shared/types/{bot-api.ts => botApi.ts} | 11 +- shared/types/index.ts | 2 +- shared/utils/bot-api-commands.ts | 26 --- shared/utils/{bot-api.ts => botApi.ts} | 60 +++++-- shared/utils/index.ts | 3 +- .../shared/utils/bot-api-commands.test.ts | 72 -------- test/unit/shared/utils/bot-api.spec.ts | 92 ---------- test/unit/shared/utils/botApi.spec.ts | 163 ++++++++++++++++++ 11 files changed, 226 insertions(+), 221 deletions(-) rename shared/types/{bot-api.ts => botApi.ts} (70%) delete mode 100644 shared/utils/bot-api-commands.ts rename shared/utils/{bot-api.ts => botApi.ts} (54%) delete mode 100644 test/unit/shared/utils/bot-api-commands.test.ts delete mode 100644 test/unit/shared/utils/bot-api.spec.ts create mode 100644 test/unit/shared/utils/botApi.spec.ts diff --git a/app/composables/useCommands.ts b/app/composables/useCommands.ts index 6955054d0..2071f5b92 100644 --- a/app/composables/useCommands.ts +++ b/app/composables/useCommands.ts @@ -1,5 +1,5 @@ -import type { BotApiCommand } from "#shared/types/bot-api"; -import { normalizeBotCommands } from "#shared/utils/bot-api-commands"; +import type { BotApiCommand } from "#shared/types/botApi"; +import { normalizeBotApiCommands } from "#shared/utils/botApi"; export function useCommands(options?: ApiComposableOptions) { const result = createApiComposable( @@ -11,6 +11,6 @@ export function useCommands(options?: ApiComposableOptions) { return { ...result, - data: computed(() => normalizeBotCommands(result.data.value)), + data: computed(() => normalizeBotApiCommands(result.data.value)), }; } diff --git a/app/plugins/api.ts b/app/plugins/api.ts index 2cf77f957..0ae55574a 100644 --- a/app/plugins/api.ts +++ b/app/plugins/api.ts @@ -1,5 +1,5 @@ import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config"; -import { getBotOauthSecret, getOptionalBotAuthHeaders } from "#shared/utils/bot-api"; +import { getBotApiOauthSecret, getOptionalBotApiAuthHeaders } from "#shared/utils/botApi"; /** * Provides `$api` for the WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`), @@ -37,9 +37,9 @@ export default defineNuxtPlugin(() => { const tokens = user ? await authorization.resolveServerTokens() : null; Object.assign( headers, - getOptionalBotAuthHeaders({ + getOptionalBotApiAuthHeaders({ accessToken: tokens?.access_token, - secret: getBotOauthSecret(clientSecret), + secret: getBotApiOauthSecret(clientSecret), userId: user?.id, }), ); diff --git a/server/utils/discord/index.ts b/server/utils/discord/index.ts index d7e326814..dcc97975b 100644 --- a/server/utils/discord/index.ts +++ b/server/utils/discord/index.ts @@ -7,7 +7,7 @@ import type { PartialOauthFlattenedGuild, TransformedLoginData, } from "#shared/types"; -import type { BotApiCommand } from "#shared/types/bot-api"; +import type { BotApiCommand } from "#shared/types/botApi"; import type { DiscordAPIError } from "@discordjs/rest"; import type { APIGuild, @@ -29,7 +29,7 @@ import { fetchGuildMemberWithRetry, } from "#server/utils/discord/oauth"; import { PermissionsBits } from "#shared/utils/bits"; -import { normalizeBotCommands } from "#shared/utils/bot-api-commands"; +import { normalizeBotApiCommands } from "#shared/utils/botApi"; import { hours } from "#shared/utils/times"; import { cast } from "@sapphire/utilities"; import { hasAtLeastOneKeyInMap } from "@sapphire/utilities/hasAtLeastOneKeyInMap"; @@ -497,7 +497,7 @@ export const fetchCommands = defineCachedFunction( }, }), ); - return normalizeBotCommands(commands); + return normalizeBotApiCommands(commands); }, { maxAge: hours(1), diff --git a/shared/types/bot-api.ts b/shared/types/botApi.ts similarity index 70% rename from shared/types/bot-api.ts rename to shared/types/botApi.ts index 5488730c6..3c3a161b6 100644 --- a/shared/types/bot-api.ts +++ b/shared/types/botApi.ts @@ -3,7 +3,7 @@ import type { FlattenedCommand, Preconditions } from "#shared/types/discord"; /** * Payload encrypted into the sapphire-plugin-api `SAPPHIRE_AUTH` cookie. */ -export interface SapphireAuthPayload { +export interface BotApiAuthPayload { expires: number; id: string; refresh: string; @@ -25,3 +25,12 @@ export interface BotApiCommand { preconditions: Preconditions; subCategory?: string | null; } + +export interface BotApiAuthSessionInput { + accessToken?: string | null; + cookieName?: string; + /** Token lifetime for the sapphire cookie. Defaults to 1 hour. */ + expiresInMs?: number; + secret: string; + userId?: string | null; +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 3412b2b7e..aea0c5fa6 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,5 +1,5 @@ export * from "./audit-log"; -export * from "./bot-api"; +export * from "./botApi"; export * from "./configurableData"; export * from "./discord"; export * from "./env"; diff --git a/shared/utils/bot-api-commands.ts b/shared/utils/bot-api-commands.ts deleted file mode 100644 index 036f453fc..000000000 --- a/shared/utils/bot-api-commands.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { BotApiCommand } from "#shared/types/bot-api"; -import type { WolfCommand } from "#shared/types/discord"; - -/** - * Normalize a bot API command into the dashboard's WolfCommand shape - * (`aliases` + non-null `subCategory`). - */ -export function normalizeBotCommand(command: BotApiCommand & { aliases?: string[] }): WolfCommand { - return { - aliases: command.alias ?? command.aliases ?? [], - category: command.category, - description: command.description, - extendedHelp: command.extendedHelp, - guarded: command.guarded, - name: command.name, - permissionLevel: command.permissionLevel, - preconditions: command.preconditions, - subCategory: command.subCategory ?? "", - }; -} - -export function normalizeBotCommands( - commands: Array, -): WolfCommand[] { - return commands.map(normalizeBotCommand); -} diff --git a/shared/utils/bot-api.ts b/shared/utils/botApi.ts similarity index 54% rename from shared/utils/bot-api.ts rename to shared/utils/botApi.ts index 469d3d925..c38539112 100644 --- a/shared/utils/bot-api.ts +++ b/shared/utils/botApi.ts @@ -1,9 +1,14 @@ -import type { SapphireAuthPayload } from "#shared/types/bot-api"; +import type { + BotApiAuthPayload, + BotApiAuthSessionInput, + BotApiCommand, +} from "#shared/types/botApi"; +import type { WolfCommand } from "#shared/types/discord"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; export const DEFAULT_BOT_API_AUTH_COOKIE = "SAPPHIRE_AUTH"; -export function getBotAuthCookieName(): string { +export function getBotApiAuthCookieName(): string { return process.env.NUXT_BOT_API_AUTH_COOKIE || DEFAULT_BOT_API_AUTH_COOKIE; } @@ -11,7 +16,7 @@ export function getBotAuthCookieName(): string { * Resolve the aes-256-cbc secret shared with sapphire-plugin-api. * Prefers `NUXT_BOT_API_OAUTH_SECRET`, then an optional Discord client-secret fallback. */ -export function getBotOauthSecret(discordClientSecretFallback = ""): string { +export function getBotApiOauthSecret(discordClientSecretFallback = ""): string { return process.env.NUXT_BOT_API_OAUTH_SECRET || discordClientSecretFallback || ""; } @@ -19,7 +24,7 @@ export function getBotOauthSecret(discordClientSecretFallback = ""): string { * Encrypt a sapphire-plugin-api auth payload (aes-256-cbc), matching * `@sapphire/plugin-api` Auth.encrypt so api.wolfstar.rocks accepts the cookie. */ -export function encryptSapphireAuth(data: SapphireAuthPayload, secret: string): string { +export function encryptBotApiAuth(data: BotApiAuthPayload, secret: string): string { const iv = randomBytes(16); const cipher = createCipheriv("aes-256-cbc", secret, iv); return `${cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64")}.${iv.toString("base64")}`; @@ -28,7 +33,7 @@ export function encryptSapphireAuth(data: SapphireAuthPayload, secret: string): /** * Decrypt a sapphire-plugin-api auth token. Returns null when expired or invalid. */ -export function decryptSapphireAuth(token: string, secret: string): SapphireAuthPayload | null { +export function decryptBotApiAuth(token: string, secret: string): BotApiAuthPayload | null { const [data, iv] = token.split("."); if (!data || !iv) { return null; @@ -37,33 +42,26 @@ export function decryptSapphireAuth(token: string, secret: string): SapphireAuth const decipher = createDecipheriv("aes-256-cbc", secret, Buffer.from(iv, "base64")); const parsed = JSON.parse( decipher.update(data, "base64", "utf8") + decipher.final("utf8"), - ) as SapphireAuthPayload; + ) as BotApiAuthPayload; return parsed.expires >= Date.now() ? parsed : null; } catch { return null; } } -export interface BotAuthSessionInput { - accessToken?: string | null; - cookieName?: string; - /** Token lifetime for the sapphire cookie. Defaults to 1 hour. */ - expiresInMs?: number; - secret: string; - userId?: string | null; -} - /** * Build outbound Cookie headers for sapphire-plugin-api when session credentials exist. * Returns an empty object when the user/token/secret is missing. */ -export function getOptionalBotAuthHeaders(input: BotAuthSessionInput): Record { +export function getOptionalBotApiAuthHeaders( + input: BotApiAuthSessionInput, +): Record { const { accessToken, secret, userId } = input; if (!userId || !accessToken || !secret) { return {}; } - const cookieValue = encryptSapphireAuth( + const cookieValue = encryptBotApiAuth( { expires: Date.now() + (input.expiresInMs ?? 60 * 60 * 1000), id: userId, @@ -74,6 +72,32 @@ export function getOptionalBotAuthHeaders(input: BotAuthSessionInput): Record, +): WolfCommand[] { + return commands.map(normalizeBotApiCommand); +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index de89a5ba5..c1f35ac28 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,7 +1,6 @@ export * from "./discord"; export * from "./bits"; -export * from "./bot-api"; -export * from "./bot-api-commands"; +export * from "./botApi"; export * from "./comparators"; export * from "./fetch-cache-config"; export * from "./isDeepEqual"; diff --git a/test/unit/shared/utils/bot-api-commands.test.ts b/test/unit/shared/utils/bot-api-commands.test.ts deleted file mode 100644 index 3b11ef5d6..000000000 --- a/test/unit/shared/utils/bot-api-commands.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { BotApiCommand } from "#shared/types/bot-api"; -import { normalizeBotCommand, normalizeBotCommands } from "#shared/utils/bot-api-commands"; -import { describe, expect, it } from "vitest"; - -const sampleBotCommand: BotApiCommand = { - alias: ["vk", "vkick"], - category: "Moderation", - description: "Kick a user from voice", - extendedHelp: { - usages: ["User"], - examples: ["@Pete"], - }, - guarded: false, - name: "voicekick", - permissionLevel: 5, - preconditions: { - entries: [], - mode: 0, - runCondition: 0, - }, - subCategory: null, -}; - -describe("normalizeBotCommand", () => { - it("maps bot API alias to dashboard aliases and coerces null subCategory", () => { - expect(normalizeBotCommand(sampleBotCommand)).toStrictEqual({ - aliases: ["vk", "vkick"], - category: "Moderation", - description: "Kick a user from voice", - extendedHelp: { - usages: ["User"], - examples: ["@Pete"], - }, - guarded: false, - name: "voicekick", - permissionLevel: 5, - preconditions: { - entries: [], - mode: 0, - runCondition: 0, - }, - subCategory: "", - }); - }); - - it("defaults missing alias to an empty array", () => { - const { alias: _alias, ...withoutAlias } = sampleBotCommand; - expect(normalizeBotCommand(withoutAlias).aliases).toStrictEqual([]); - }); - - it("falls back to aliases when alias is absent", () => { - const { alias: _alias, ...withoutAlias } = sampleBotCommand; - expect(normalizeBotCommand({ ...withoutAlias, aliases: ["legacy"] }).aliases).toStrictEqual( - ["legacy"], - ); - }); - - it("preserves a non-null subCategory", () => { - expect(normalizeBotCommand({ ...sampleBotCommand, subCategory: "Voice" }).subCategory).toBe( - "Voice", - ); - }); -}); - -describe("normalizeBotCommands", () => { - it("normalizes each command in the list", () => { - const result = normalizeBotCommands([sampleBotCommand]); - expect(result).toHaveLength(1); - expect(result[0]?.aliases).toStrictEqual(["vk", "vkick"]); - expect(result[0]?.subCategory).toBe(""); - }); -}); diff --git a/test/unit/shared/utils/bot-api.spec.ts b/test/unit/shared/utils/bot-api.spec.ts deleted file mode 100644 index fc72f318e..000000000 --- a/test/unit/shared/utils/bot-api.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { SapphireAuthPayload } from "#shared/types/bot-api"; -import { - decryptSapphireAuth, - encryptSapphireAuth, - getOptionalBotAuthHeaders, -} from "#shared/utils/bot-api"; -import { describe, expect, it } from "vitest"; - -// aes-256-cbc requires a 32-byte key — match Discord client-secret length. -const SECRET = "0123456789abcdef0123456789abcdef"; - -describe("sapphire auth cookie crypto", () => { - it("round-trips a valid payload", () => { - const payload: SapphireAuthPayload = { - expires: Date.now() + 60_000, - id: "123456789012345678", - refresh: "refresh-token", - token: "access-token", - }; - - const encrypted = encryptSapphireAuth(payload, SECRET); - expect(encrypted).toContain("."); - - const decrypted = decryptSapphireAuth(encrypted, SECRET); - expect(decrypted).toStrictEqual(payload); - }); - - it("returns null for expired payloads", () => { - const encrypted = encryptSapphireAuth( - { - expires: Date.now() - 1, - id: "1", - refresh: "", - token: "t", - }, - SECRET, - ); - expect(decryptSapphireAuth(encrypted, SECRET)).toBeNull(); - }); - - it("returns null for tampered tokens", () => { - const encrypted = encryptSapphireAuth( - { - expires: Date.now() + 60_000, - id: "1", - refresh: "", - token: "t", - }, - SECRET, - ); - const [data, iv] = encrypted.split("."); - expect(data).toBeTruthy(); - expect(iv).toBeTruthy(); - const tampered = `AAAA${data!.slice(4)}.${iv}`; - expect(decryptSapphireAuth(tampered, SECRET)).toBeNull(); - expect(decryptSapphireAuth("not-a-token", SECRET)).toBeNull(); - }); -}); - -describe("getOptionalBotAuthHeaders", () => { - it("returns an empty object when credentials are missing", () => { - expect( - getOptionalBotAuthHeaders({ - accessToken: null, - secret: SECRET, - userId: "1", - }), - ).toStrictEqual({}); - expect( - getOptionalBotAuthHeaders({ - accessToken: "token", - secret: "", - userId: "1", - }), - ).toStrictEqual({}); - }); - - it("builds a Cookie header that decrypts to the session payload", () => { - const headers = getOptionalBotAuthHeaders({ - accessToken: "access-token", - cookieName: "SAPPHIRE_AUTH", - secret: SECRET, - userId: "123456789012345678", - }); - - expect(headers.Cookie).toMatch(/^SAPPHIRE_AUTH=.[^\n\r.\u2028\u2029]*\..+$/); - const token = headers.Cookie!.slice("SAPPHIRE_AUTH=".length); - const decrypted = decryptSapphireAuth(token, SECRET); - expect(decrypted?.id).toBe("123456789012345678"); - expect(decrypted?.token).toBe("access-token"); - }); -}); diff --git a/test/unit/shared/utils/botApi.spec.ts b/test/unit/shared/utils/botApi.spec.ts new file mode 100644 index 000000000..ab4b9b3f6 --- /dev/null +++ b/test/unit/shared/utils/botApi.spec.ts @@ -0,0 +1,163 @@ +import type { BotApiAuthPayload } from "#shared/types/botApi"; +import { + decryptBotApiAuth, + encryptBotApiAuth, + getOptionalBotApiAuthHeaders, + normalizeBotApiCommand, + normalizeBotApiCommands, +} from "#shared/utils/botApi"; +import { describe, expect, it } from "vitest"; + +// aes-256-cbc requires a 32-byte key — match Discord client-secret length. +const SECRET = "0123456789abcdef0123456789abcdef"; + +describe("botApi auth cookie crypto", () => { + it("round-trips a valid payload", () => { + const payload: BotApiAuthPayload = { + expires: Date.now() + 60_000, + id: "123456789012345678", + refresh: "refresh-token", + token: "access-token", + }; + + const encrypted = encryptBotApiAuth(payload, SECRET); + expect(encrypted).toContain("."); + + const decrypted = decryptBotApiAuth(encrypted, SECRET); + expect(decrypted).toStrictEqual(payload); + }); + + it("returns null for expired payloads", () => { + const encrypted = encryptBotApiAuth( + { + expires: Date.now() - 1, + id: "1", + refresh: "", + token: "t", + }, + SECRET, + ); + expect(decryptBotApiAuth(encrypted, SECRET)).toBeNull(); + }); + + it("returns null for tampered tokens", () => { + const encrypted = encryptBotApiAuth( + { + expires: Date.now() + 60_000, + id: "1", + refresh: "", + token: "t", + }, + SECRET, + ); + const [data, iv] = encrypted.split("."); + expect(data).toBeTruthy(); + expect(iv).toBeTruthy(); + const tampered = `AAAA${data!.slice(4)}.${iv}`; + expect(decryptBotApiAuth(tampered, SECRET)).toBeNull(); + expect(decryptBotApiAuth("not-a-token", SECRET)).toBeNull(); + }); +}); + +describe("getOptionalBotApiAuthHeaders", () => { + it("returns an empty object when credentials are missing", () => { + expect( + getOptionalBotApiAuthHeaders({ + accessToken: null, + secret: SECRET, + userId: "1", + }), + ).toStrictEqual({}); + expect( + getOptionalBotApiAuthHeaders({ + accessToken: "token", + secret: "", + userId: "1", + }), + ).toStrictEqual({}); + }); + + it("builds a Cookie header that decrypts to the session payload", () => { + const headers = getOptionalBotApiAuthHeaders({ + accessToken: "access-token", + cookieName: "SAPPHIRE_AUTH", + secret: SECRET, + userId: "123456789012345678", + }); + + expect(headers.Cookie).toMatch(/^SAPPHIRE_AUTH=.[^\n\r.\u2028\u2029]*\..+$/); + const token = headers.Cookie!.slice("SAPPHIRE_AUTH=".length); + const decrypted = decryptBotApiAuth(token, SECRET); + expect(decrypted?.id).toBe("123456789012345678"); + expect(decrypted?.token).toBe("access-token"); + }); +}); + +const sampleBotApiCommand = { + alias: ["vk", "vkick"], + category: "Moderation", + description: "Kick a user from voice", + extendedHelp: { + usages: ["User"], + examples: ["@Pete"], + }, + guarded: false, + name: "voicekick", + permissionLevel: 5, + preconditions: { + entries: [], + mode: 0, + runCondition: 0, + }, + subCategory: null, +}; + +describe("normalizeBotApiCommand", () => { + it("maps bot API alias to dashboard aliases and coerces null subCategory", () => { + expect(normalizeBotApiCommand(sampleBotApiCommand)).toStrictEqual({ + aliases: ["vk", "vkick"], + category: "Moderation", + description: "Kick a user from voice", + extendedHelp: { + usages: ["User"], + examples: ["@Pete"], + }, + guarded: false, + name: "voicekick", + permissionLevel: 5, + preconditions: { + entries: [], + mode: 0, + runCondition: 0, + }, + subCategory: "", + }); + }); + + it("defaults missing alias to an empty array", () => { + const { alias: _alias, ...withoutAlias } = sampleBotApiCommand; + expect(normalizeBotApiCommand(withoutAlias).aliases).toStrictEqual([]); + }); + + it("falls back to aliases when alias is absent", () => { + const { alias: _alias, ...withoutAlias } = sampleBotApiCommand; + expect( + normalizeBotApiCommand({ ...withoutAlias, aliases: ["legacy"] }).aliases, + ).toStrictEqual(["legacy"]); + }); + + it("preserves a non-null subCategory", () => { + expect( + normalizeBotApiCommand({ ...sampleBotApiCommand, subCategory: "Voice" }).subCategory, + ).toBe("Voice"); + }); +}); + +describe("normalizeBotApiCommands", () => { + it("normalizes each command in the list", () => { + const result = normalizeBotApiCommands([sampleBotApiCommand]); + expect(result).toHaveLength(1); + expect(result[0]?.aliases).toStrictEqual(["vk", "vkick"]); + expect(result[0]?.subCategory).toBe(""); + }); +}); From 04d70333f894cf7bb20d219f536b5d544d11b754 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 11:55:19 +0000 Subject: [PATCH 10/15] refactor(api): use NUXT_OAUTH_DISCORD_CLIENT_SECRET for botApi auth Drop the redundant NUXT_BOT_API_OAUTH_SECRET alias; sapphire cookie encryption uses the existing Discord OAuth client secret. Co-authored-by: RedStar --- .env.example | 5 ++--- app/plugins/api.ts | 7 ++++--- shared/utils/botApi.ts | 9 +-------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 2ea486bd8..86668314c 100644 --- a/.env.example +++ b/.env.example @@ -46,10 +46,9 @@ NUXT_CLOUDFLARE_API_TOKEN= # Local bot instance: http://localhost:8282 NUXT_PUBLIC_API_BASE_URL=http://localhost:8282 -# Optional: sapphire-plugin-api cookie name / OAuth secret for SSR auth cookie -# injection. Defaults: cookie SAPPHIRE_AUTH, secret = Discord client secret. +# Optional: sapphire-plugin-api cookie name for SSR auth cookie injection. +# Defaults to SAPPHIRE_AUTH. Encryption uses NUXT_OAUTH_DISCORD_CLIENT_SECRET. # NUXT_BOT_API_AUTH_COOKIE=SAPPHIRE_AUTH -# NUXT_BOT_API_OAUTH_SECRET= # Site URL (e.g., https://wolfstar.rocks) NUXT_PUBLIC_SITE_URL= diff --git a/app/plugins/api.ts b/app/plugins/api.ts index 0ae55574a..0370f1a15 100644 --- a/app/plugins/api.ts +++ b/app/plugins/api.ts @@ -1,5 +1,5 @@ import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config"; -import { getBotApiOauthSecret, getOptionalBotApiAuthHeaders } from "#shared/utils/botApi"; +import { getOptionalBotApiAuthHeaders } from "#shared/utils/botApi"; /** * Provides `$api` for the WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`), @@ -8,7 +8,8 @@ import { getBotApiOauthSecret, getOptionalBotApiAuthHeaders } from "#shared/util * * On the server, when a Discord session exists, a sapphire `SAPPHIRE_AUTH` * cookie is attached so SSR can authorize guild routes without relying on a - * browser cookie set on the API domain. + * browser cookie set on the API domain. The cookie is encrypted with + * `NUXT_OAUTH_DISCORD_CLIENT_SECRET` (same secret sapphire-plugin-api uses). */ export default defineNuxtPlugin(() => { const cachedFetch = useCachedFetch(); @@ -39,7 +40,7 @@ export default defineNuxtPlugin(() => { headers, getOptionalBotApiAuthHeaders({ accessToken: tokens?.access_token, - secret: getBotApiOauthSecret(clientSecret), + secret: clientSecret ?? "", userId: user?.id, }), ); diff --git a/shared/utils/botApi.ts b/shared/utils/botApi.ts index c38539112..2ea182f10 100644 --- a/shared/utils/botApi.ts +++ b/shared/utils/botApi.ts @@ -12,17 +12,10 @@ export function getBotApiAuthCookieName(): string { return process.env.NUXT_BOT_API_AUTH_COOKIE || DEFAULT_BOT_API_AUTH_COOKIE; } -/** - * Resolve the aes-256-cbc secret shared with sapphire-plugin-api. - * Prefers `NUXT_BOT_API_OAUTH_SECRET`, then an optional Discord client-secret fallback. - */ -export function getBotApiOauthSecret(discordClientSecretFallback = ""): string { - return process.env.NUXT_BOT_API_OAUTH_SECRET || discordClientSecretFallback || ""; -} - /** * Encrypt a sapphire-plugin-api auth payload (aes-256-cbc), matching * `@sapphire/plugin-api` Auth.encrypt so api.wolfstar.rocks accepts the cookie. + * The secret is the Discord OAuth client secret (`NUXT_OAUTH_DISCORD_CLIENT_SECRET`). */ export function encryptBotApiAuth(data: BotApiAuthPayload, secret: string): string { const iv = randomBytes(16); From 2818ed0ed15da96c7447b55fa8de0cb07d5059e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 12:03:47 +0000 Subject: [PATCH 11/15] perf(nuxt): cache rendered payloads for ISR routes Add npmx-style runtime payload cache so HTML SSR can reuse a serialized _payload.json response and skip a second full render. Auth routes are excluded; Netlify uses Blobs storage for the payload-cache mount. Co-authored-by: RedStar --- app/plugins/payload-cache.client.ts | 21 ++++ app/plugins/payload-cache.server.ts | 56 ++++++++++ modules/cache.ts | 9 ++ nuxt.config.ts | 4 + package.json | 1 + pnpm-lock.yaml | 3 + server/plugins/payload-cache.ts | 155 ++++++++++++++++++++++++++++ 7 files changed, 249 insertions(+) create mode 100644 app/plugins/payload-cache.client.ts create mode 100644 app/plugins/payload-cache.server.ts create mode 100644 server/plugins/payload-cache.ts diff --git a/app/plugins/payload-cache.client.ts b/app/plugins/payload-cache.client.ts new file mode 100644 index 000000000..668d07eb7 --- /dev/null +++ b/app/plugins/payload-cache.client.ts @@ -0,0 +1,21 @@ +/** + * When `_payload.json` returns an ISR/empty fallback, async data with defaults + * may skip refetch during hydration. Refresh after suspense resolves so pages + * re-fetch once the client is interactive. + */ +export default defineNuxtPlugin({ + name: "payload-cache-refresh", + setup(nuxtApp) { + nuxtApp.payload.data ||= {}; + + if ( + nuxtApp.isHydrating && + nuxtApp.payload.serverRendered && + !Object.keys(nuxtApp.payload.data).length + ) { + nuxtApp.hooks.hookOnce("app:suspense:resolve", () => { + refreshNuxtData(); + }); + } + }, +}); diff --git a/app/plugins/payload-cache.server.ts b/app/plugins/payload-cache.server.ts new file mode 100644 index 000000000..2e95aadd0 --- /dev/null +++ b/app/plugins/payload-cache.server.ts @@ -0,0 +1,56 @@ +// oxlint-disable no-console -- dev-only payload-cache diagnostics +import { stringify } from "devalue"; + +/** + * Serializes the Nuxt payload after SSR and stashes it on the request event. + * + * The Nitro `payload-cache` plugin picks this up in `render:response` so a + * later `/_payload.json` request for the same route can be served from cache + * without a second full Vue SSR render (same approach as npmx.dev). + */ +export default defineNuxtPlugin({ + name: "payload-cache", + setup(nuxtApp) { + if (import.meta.client) return; + + nuxtApp.hooks.hook("app:rendered", () => { + const ssrContext = nuxtApp.ssrContext; + if (!ssrContext) return; + + if (ssrContext.noSSR || ssrContext.error || ssrContext.payload?.error) return; + + const payloadData = ssrContext.payload?.data; + if (!payloadData || Object.keys(payloadData).length === 0) return; + + try { + const payload = { + data: ssrContext.payload.data, + prerenderedAt: ssrContext.payload.prerenderedAt, + }; + const reducers = + ( + ssrContext as typeof ssrContext & { + "~payloadReducers"?: Record unknown>; + } + )["~payloadReducers"] ?? {}; + const body = stringify(payload, reducers); + + const event = ssrContext.event; + if (event) { + event.context._cachedPayloadResponse = { + body, + headers: { + "content-type": "application/json;charset=utf-8", + "x-powered-by": "Nuxt", + }, + statusCode: 200, + }; + } + } catch (error) { + if (import.meta.dev) { + console.warn("[payload-cache] Failed to serialize payload:", error); + } + } + }); + }, +}); diff --git a/modules/cache.ts b/modules/cache.ts index 767792305..939b609f8 100644 --- a/modules/cache.ts +++ b/modules/cache.ts @@ -2,6 +2,8 @@ import { defineNuxtModule, useRuntimeConfig } from "nuxt/kit"; import { provider } from "std-env"; // Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts const FETCH_CACHE_STORAGE_BASE = "fetch-cache"; +// Storage key for payload cache - must match server/plugins/payload-cache.ts +const PAYLOAD_CACHE_STORAGE_KEY = "payload-cache"; export default defineNuxtModule({ meta: { @@ -32,6 +34,13 @@ export default defineNuxtModule({ name: FETCH_CACHE_STORAGE_BASE, }; + // Runtime _payload.json cache (avoids a second SSR for ISR/cache routes) + nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY] = { + ...nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY], + driver: "netlifyBlobs", + name: PAYLOAD_CACHE_STORAGE_KEY, + }; + nitroConfig.storage["wolfstar:ratelimiter"] = { accountId: config.cloudflare.accountId, apiToken: config.cloudflare.apiToken, diff --git a/nuxt.config.ts b/nuxt.config.ts index 90375444a..60d5d16e6 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -312,6 +312,10 @@ export default defineNuxtConfig({ base: "./.cache/fetch", driver: "fsLite", }, + "payload-cache": { + base: "./.cache/payload", + driver: "fsLite", + }, "wolfstar:ratelimiter": { base: "./.cache/ratelimiter", driver: "fsLite", diff --git a/package.json b/package.json index 5117c1aab..593f06552 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "better-auth": "^1.6.23", "chromatic": "^17.0.0", "cnfast": "^0.0.8", + "devalue": "^5.8.1", "dotenv": "^17.3.1", "evlog": "^2.14.0", "feed": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ca2be832..0cfe69d9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: cnfast: specifier: ^0.0.8 version: 0.0.8 + devalue: + specifier: ^5.8.1 + version: 5.8.1 dotenv: specifier: ^17.3.1 version: 17.4.2 diff --git a/server/plugins/payload-cache.ts b/server/plugins/payload-cache.ts new file mode 100644 index 000000000..a0addd5a6 --- /dev/null +++ b/server/plugins/payload-cache.ts @@ -0,0 +1,155 @@ +// oxlint-disable no-console -- dev-only payload-cache diagnostics +import type { H3Event } from "h3"; + +/** + * Runtime payload cache for ISR/cache-enabled public routes. + * + * Mirrors Nuxt's pre-render `payloadCache` at runtime (see npmx.dev PR #1643): + * - HTML SSR for cacheable routes stashes a serialized payload on the event + * - `_payload.json` requests check the cache first and skip a second SSR + */ + +const PAYLOAD_URL_RE = /^[^?]*\/_payload\.json(?:\?.*)?$/; +const PAYLOAD_CACHE_STORAGE_KEY = "payload-cache"; + +/** Default TTL for cached payloads (seconds). */ +const PAYLOAD_CACHE_TTL = 60; + +/** + * Grace period beyond TTL where stale payloads are still served (seconds). + * Avoids a race where HTML is still cached but the payload entry just expired. + */ +const PAYLOAD_CACHE_STALE_TTL = PAYLOAD_CACHE_TTL * 2; + +interface CachedPayload { + body: string; + buildId: string; + cachedAt: number; + headers: Record; + statusCode: number; +} + +function getRouteFromPayloadUrl(url: string): string { + const withoutQuery = url.replace(/\?.*$/, ""); + return withoutQuery.substring(0, withoutQuery.lastIndexOf("/")) || "/"; +} + +/** + * Only cache public ISR/cache routes — never authenticated dashboard payloads. + */ +function shouldCachePayload(event: H3Event): boolean { + const rules = getRouteRules(event); + if (rules.auth) { + return false; + } + return Boolean(rules.isr || rules.cache); +} + +export default defineNitroPlugin((nitroApp) => { + const storage = useStorage(PAYLOAD_CACHE_STORAGE_KEY); + const buildId = String(useRuntimeConfig().app.buildId ?? "dev"); + + function getCacheKey(routePath: string): string { + return `${buildId}:${routePath}`; + } + + nitroApp.hooks.hook("render:before", async (ctx) => { + if (!PAYLOAD_URL_RE.test(ctx.event.path)) return; + if (!shouldCachePayload(ctx.event)) return; + + const routePath = getRouteFromPayloadUrl(ctx.event.path); + const cacheKey = getCacheKey(routePath); + + try { + const cached = await storage.getItem(cacheKey); + if (!cached) return; + if (cached.buildId !== buildId) return; + + const age = (Date.now() - cached.cachedAt) / 1000; + if (age > PAYLOAD_CACHE_STALE_TTL) return; + + if (import.meta.dev) { + console.log(`[payload-cache] HIT: ${routePath} (age: ${age.toFixed(1)}s)`); + } + + ctx.response = { + body: cached.body, + headers: cached.headers, + statusCode: cached.statusCode, + statusMessage: "OK", + }; + } catch (error) { + if (import.meta.dev) { + console.warn(`[payload-cache] Cache read failed for ${routePath}:`, error); + } + } + }); + + nitroApp.hooks.hook("render:response", (response, ctx) => { + if (!response.statusCode || response.statusCode >= 400) return; + if (!shouldCachePayload(ctx.event)) return; + + const isPayloadRequest = PAYLOAD_URL_RE.test(ctx.event.path); + const contentType = response.headers?.["content-type"]; + const isHtmlResponse = typeof contentType === "string" && contentType.includes("text/html"); + + if (isPayloadRequest) { + if (typeof response.body !== "string") return; + const routePath = getRouteFromPayloadUrl(ctx.event.path); + cachePayload(ctx.event, routePath, { + body: response.body, + headers: { + "content-type": "application/json;charset=utf-8", + "x-powered-by": "Nuxt", + }, + statusCode: response.statusCode ?? 200, + }); + return; + } + + if (!isHtmlResponse) return; + + const cachedPayload = ctx.event.context._cachedPayloadResponse; + if (!cachedPayload) return; + + const pathWithoutQuery = ctx.event.path.replace(/\?.*$/, ""); + const routePath = pathWithoutQuery === "/" ? "/" : pathWithoutQuery.replace(/\/$/, ""); + cachePayload(ctx.event, routePath, cachedPayload); + delete ctx.event.context._cachedPayloadResponse; + }); + + function cachePayload( + event: H3Event, + routePath: string, + payload: { body: string; headers: Record; statusCode: number }, + ) { + const cacheKey = getCacheKey(routePath); + const entry: CachedPayload = { + ...payload, + buildId, + cachedAt: Date.now(), + }; + + event.waitUntil?.( + storage.setItem(cacheKey, entry).catch((error: unknown) => { + if (import.meta.dev) { + console.warn(`[payload-cache] Cache write failed for ${routePath}:`, error); + } + }), + ); + + if (import.meta.dev) { + console.log(`[payload-cache] CACHED: ${routePath}`); + } + } +}); + +declare module "h3" { + interface H3EventContext { + _cachedPayloadResponse?: { + body: string; + headers: Record; + statusCode: number; + }; + } +} From 38e17e1a84abb79c102ef02dcf87a587324313c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 12:27:24 +0000 Subject: [PATCH 12/15] refactor(api): use FlattenedCommand instead of BotApiCommand Drop the duplicate BotApiCommand type and command normalizers; type commands fetches with the existing FlattenedCommand shape. Co-authored-by: RedStar --- app/composables/useCommands.ts | 15 +----- server/utils/discord/index.ts | 8 ++- shared/types/botApi.ts | 18 ------- shared/utils/botApi.ts | 33 +------------ test/unit/shared/utils/botApi.spec.ts | 71 --------------------------- 5 files changed, 6 insertions(+), 139 deletions(-) diff --git a/app/composables/useCommands.ts b/app/composables/useCommands.ts index 2071f5b92..1ff969160 100644 --- a/app/composables/useCommands.ts +++ b/app/composables/useCommands.ts @@ -1,16 +1,5 @@ -import type { BotApiCommand } from "#shared/types/botApi"; -import { normalizeBotApiCommands } from "#shared/utils/botApi"; +import type { FlattenedCommand } from "#shared/types/discord"; export function useCommands(options?: ApiComposableOptions) { - const result = createApiComposable( - "wolfstar:commands", - "/commands", - [], - options, - ); - - return { - ...result, - data: computed(() => normalizeBotApiCommands(result.data.value)), - }; + return createApiComposable("wolfstar:commands", "/commands", [], options); } diff --git a/server/utils/discord/index.ts b/server/utils/discord/index.ts index dcc97975b..3f4fd6a62 100644 --- a/server/utils/discord/index.ts +++ b/server/utils/discord/index.ts @@ -1,13 +1,13 @@ // oxlint-disable no-console import type { ReadonlyGuildData } from "#server/database"; import type { + FlattenedCommand, FlattenedGuild, LoginData, OauthFlattenedGuild, PartialOauthFlattenedGuild, TransformedLoginData, } from "#shared/types"; -import type { BotApiCommand } from "#shared/types/botApi"; import type { DiscordAPIError } from "@discordjs/rest"; import type { APIGuild, @@ -29,7 +29,6 @@ import { fetchGuildMemberWithRetry, } from "#server/utils/discord/oauth"; import { PermissionsBits } from "#shared/utils/bits"; -import { normalizeBotApiCommands } from "#shared/utils/botApi"; import { hours } from "#shared/utils/times"; import { cast } from "@sapphire/utilities"; import { hasAtLeastOneKeyInMap } from "@sapphire/utilities/hasAtLeastOneKeyInMap"; @@ -489,15 +488,14 @@ export const fetchCommands = defineCachedFunction( Sentry.metrics.count("bot_api.call", 1, { attributes: { endpoint: "commands.fetch" }, }); - const commands = await instrumentBotApiCall("commands.fetch", () => - $fetch(`${apiBaseUrl}/commands`, { + return await instrumentBotApiCall("commands.fetch", () => + $fetch(`${apiBaseUrl}/commands`, { credentials: "include", headers: { "Content-Type": "application/json", }, }), ); - return normalizeBotApiCommands(commands); }, { maxAge: hours(1), diff --git a/shared/types/botApi.ts b/shared/types/botApi.ts index 3c3a161b6..b0745d415 100644 --- a/shared/types/botApi.ts +++ b/shared/types/botApi.ts @@ -1,5 +1,3 @@ -import type { FlattenedCommand, Preconditions } from "#shared/types/discord"; - /** * Payload encrypted into the sapphire-plugin-api `SAPPHIRE_AUTH` cookie. */ @@ -10,22 +8,6 @@ export interface BotApiAuthPayload { token: string; } -/** - * Raw command payload from the WolfStar bot API (`/commands`). - * The bot serializes Sapphire `aliases` as the singular `alias` field. - */ -export interface BotApiCommand { - alias?: string[]; - category: string; - description: string; - extendedHelp: FlattenedCommand["extendedHelp"]; - guarded: boolean; - name: string; - permissionLevel: number; - preconditions: Preconditions; - subCategory?: string | null; -} - export interface BotApiAuthSessionInput { accessToken?: string | null; cookieName?: string; diff --git a/shared/utils/botApi.ts b/shared/utils/botApi.ts index 2ea182f10..0584a5d4a 100644 --- a/shared/utils/botApi.ts +++ b/shared/utils/botApi.ts @@ -1,9 +1,4 @@ -import type { - BotApiAuthPayload, - BotApiAuthSessionInput, - BotApiCommand, -} from "#shared/types/botApi"; -import type { WolfCommand } from "#shared/types/discord"; +import type { BotApiAuthPayload, BotApiAuthSessionInput } from "#shared/types/botApi"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; export const DEFAULT_BOT_API_AUTH_COOKIE = "SAPPHIRE_AUTH"; @@ -68,29 +63,3 @@ export function getOptionalBotApiAuthHeaders( Cookie: `${input.cookieName ?? getBotApiAuthCookieName()}=${cookieValue}`, }; } - -/** - * Normalize a bot API command into the dashboard's WolfCommand shape - * (`aliases` + non-null `subCategory`). - */ -export function normalizeBotApiCommand( - command: BotApiCommand & { aliases?: string[] }, -): WolfCommand { - return { - aliases: command.alias ?? command.aliases ?? [], - category: command.category, - description: command.description, - extendedHelp: command.extendedHelp, - guarded: command.guarded, - name: command.name, - permissionLevel: command.permissionLevel, - preconditions: command.preconditions, - subCategory: command.subCategory ?? "", - }; -} - -export function normalizeBotApiCommands( - commands: Array, -): WolfCommand[] { - return commands.map(normalizeBotApiCommand); -} diff --git a/test/unit/shared/utils/botApi.spec.ts b/test/unit/shared/utils/botApi.spec.ts index ab4b9b3f6..93f33e43b 100644 --- a/test/unit/shared/utils/botApi.spec.ts +++ b/test/unit/shared/utils/botApi.spec.ts @@ -3,8 +3,6 @@ import { decryptBotApiAuth, encryptBotApiAuth, getOptionalBotApiAuthHeaders, - normalizeBotApiCommand, - normalizeBotApiCommands, } from "#shared/utils/botApi"; import { describe, expect, it } from "vitest"; @@ -92,72 +90,3 @@ describe("getOptionalBotApiAuthHeaders", () => { expect(decrypted?.token).toBe("access-token"); }); }); - -const sampleBotApiCommand = { - alias: ["vk", "vkick"], - category: "Moderation", - description: "Kick a user from voice", - extendedHelp: { - usages: ["User"], - examples: ["@Pete"], - }, - guarded: false, - name: "voicekick", - permissionLevel: 5, - preconditions: { - entries: [], - mode: 0, - runCondition: 0, - }, - subCategory: null, -}; - -describe("normalizeBotApiCommand", () => { - it("maps bot API alias to dashboard aliases and coerces null subCategory", () => { - expect(normalizeBotApiCommand(sampleBotApiCommand)).toStrictEqual({ - aliases: ["vk", "vkick"], - category: "Moderation", - description: "Kick a user from voice", - extendedHelp: { - usages: ["User"], - examples: ["@Pete"], - }, - guarded: false, - name: "voicekick", - permissionLevel: 5, - preconditions: { - entries: [], - mode: 0, - runCondition: 0, - }, - subCategory: "", - }); - }); - - it("defaults missing alias to an empty array", () => { - const { alias: _alias, ...withoutAlias } = sampleBotApiCommand; - expect(normalizeBotApiCommand(withoutAlias).aliases).toStrictEqual([]); - }); - - it("falls back to aliases when alias is absent", () => { - const { alias: _alias, ...withoutAlias } = sampleBotApiCommand; - expect( - normalizeBotApiCommand({ ...withoutAlias, aliases: ["legacy"] }).aliases, - ).toStrictEqual(["legacy"]); - }); - - it("preserves a non-null subCategory", () => { - expect( - normalizeBotApiCommand({ ...sampleBotApiCommand, subCategory: "Voice" }).subCategory, - ).toBe("Voice"); - }); -}); - -describe("normalizeBotApiCommands", () => { - it("normalizes each command in the list", () => { - const result = normalizeBotApiCommands([sampleBotApiCommand]); - expect(result).toHaveLength(1); - expect(result[0]?.aliases).toStrictEqual(["vk", "vkick"]); - expect(result[0]?.subCategory).toBe(""); - }); -}); From 76bd0eb3cad616b35b849d8543fa73fef013b62d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 13:26:12 +0000 Subject: [PATCH 13/15] fix(api): scope fetch-cache keys and fix commands accordion HTML Include baseURL, query, and auth headers in SSR fetch-cache keys so authenticated $api responses cannot bleed across users or filters. Remove nested UButton/role=region markup from CommandSubCategory so prerendered /commands passes html-validator when live bot API data loads. Co-authored-by: RedStar --- app/components/command/SubCategory.vue | 43 +----- server/plugins/fetch-cache.ts | 59 +++----- shared/utils/fetch-cache-config.ts | 134 +++++++++++++++++- .../shared/utils/fetch-cache-config.test.ts | 82 ++++++++++- 4 files changed, 237 insertions(+), 81 deletions(-) diff --git a/app/components/command/SubCategory.vue b/app/components/command/SubCategory.vue index 8cd204282..99de09ece 100644 --- a/app/components/command/SubCategory.vue +++ b/app/components/command/SubCategory.vue @@ -1,51 +1,20 @@