diff --git a/apps/api/prod.env b/apps/api/prod.env index d3843dc..8817364 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -1,6 +1,4 @@ -DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id" DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret" -DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri" JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret" DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url" ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret" @@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port" CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" -DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" -DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index 8b82ae8..612a355 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -7,6 +7,9 @@ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ import { logger } from "./logger.js"; +const discordClientId = "1465425348375089182"; +const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback"; + interface DiscordTokenResponse { access_token: string; token_type: string; @@ -31,24 +34,18 @@ interface DiscordUser { const exchangeCode = async( code: string, ): Promise => { - const clientId = process.env.DISCORD_CLIENT_ID; const clientSecret = process.env.DISCORD_CLIENT_SECRET; - const redirectUri = process.env.DISCORD_REDIRECT_URI; - if ( - clientId === undefined || clientId === "" - || clientSecret === undefined || clientSecret === "" - || redirectUri === undefined || redirectUri === "" - ) { + if (clientSecret === undefined || clientSecret === "") { throw new Error("Discord OAuth environment variables are required"); } const parameters = new URLSearchParams({ - client_id: clientId, + client_id: discordClientId, client_secret: clientSecret, code: code, grant_type: "authorization_code", - redirect_uri: redirectUri, + redirect_uri: discordRedirectUri, }); try { @@ -146,19 +143,9 @@ const fetchDiscordUserById = async( * @throws {Error} If OAuth environment variables are missing. */ const buildOAuthUrl = (): string => { - const clientId = process.env.DISCORD_CLIENT_ID; - const redirectUri = process.env.DISCORD_REDIRECT_URI; - - if ( - clientId === undefined || clientId === "" - || redirectUri === undefined || redirectUri === "" - ) { - throw new Error("Discord OAuth environment variables are required"); - } - const parameters = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, + client_id: discordClientId, + redirect_uri: discordRedirectUri, response_type: "code", scope: "identify", }); diff --git a/apps/api/src/services/gateway.ts b/apps/api/src/services/gateway.ts index c973e49..5f0f6bc 100644 --- a/apps/api/src/services/gateway.ts +++ b/apps/api/src/services/gateway.ts @@ -8,6 +8,8 @@ import { prisma } from "../db/client.js"; import { logger } from "./logger.js"; +const discordGuildId = "1354624415861833870"; + /** * Discord Gateway opcodes used by this client. */ @@ -36,8 +38,7 @@ const handleGuildMemberAdd = async( discordId: string, guildId: string, ): Promise => { - const configuredGuildId = process.env.DISCORD_GUILD_ID; - if (guildId !== configuredGuildId) { + if (guildId !== discordGuildId) { return; } try { @@ -66,8 +67,7 @@ const handleGuildMemberRemove = async( discordId: string, guildId: string, ): Promise => { - const configuredGuildId = process.env.DISCORD_GUILD_ID; - if (guildId !== configuredGuildId) { + if (guildId !== discordGuildId) { return; } try { diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index 6bd6b09..5bbdcee 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -18,7 +18,9 @@ const suppressNotifications = 4096; /** * The Discord role ID for the Elysian role granted to all Elysium players. */ +const discordGuildId = "1354624415861833870"; const elysianRoleId = "1486144823684628490"; +const apotheosisRoleId = "1479966598210129991"; /** * Grants the Elysian Discord role to the given player and returns whether they are in the guild. @@ -28,18 +30,14 @@ const elysianRoleId = "1486144823684628490"; */ const grantElysianRole = async(discordId: string): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; - const guildId = process.env.DISCORD_GUILD_ID; - if ( - botToken === undefined || botToken === "" - || guildId === undefined || guildId === "" - ) { + if (botToken === undefined || botToken === "") { return false; } try { const response = await fetch( - `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${elysianRoleId}`, + `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`, { headers: { "Authorization": `Bot ${botToken}`, @@ -68,20 +66,14 @@ const grantElysianRole = async(discordId: string): Promise => { */ const grantApotheosisRole = async(discordId: string): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; - const guildId = process.env.DISCORD_GUILD_ID; - const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID; - if ( - botToken === undefined || botToken === "" - || guildId === undefined || guildId === "" - || roleId === undefined || roleId === "" - ) { + if (botToken === undefined || botToken === "") { return; } try { await fetch( - `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, + `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`, { headers: { "Authorization": `Bot ${botToken}`, diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index cf924ae..3292f19 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -18,51 +18,31 @@ describe("discord service", () => { }); describe("buildOAuthUrl", () => { - it("throws when DISCORD_CLIENT_ID is missing", async () => { - delete process.env["DISCORD_CLIENT_ID"]; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback"; - const { buildOAuthUrl } = await import("../../src/services/discord.js"); - expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required"); - }); - - it("throws when DISCORD_REDIRECT_URI is missing", async () => { - process.env["DISCORD_CLIENT_ID"] = "client123"; - delete process.env["DISCORD_REDIRECT_URI"]; - const { buildOAuthUrl } = await import("../../src/services/discord.js"); - expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required"); - }); - it("returns a URL with correct query params", async () => { - process.env["DISCORD_CLIENT_ID"] = "client123"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback"; const { buildOAuthUrl } = await import("../../src/services/discord.js"); const url = buildOAuthUrl(); - expect(url).toContain("client_id=client123"); + expect(url).toContain("client_id=1465425348375089182"); expect(url).toContain("response_type=code"); expect(url).toContain("scope=identify"); }); }); describe("exchangeCode", () => { - it("throws when env vars are missing", async () => { - delete process.env["DISCORD_CLIENT_ID"]; + it("throws when DISCORD_CLIENT_SECRET is missing", async () => { + delete process.env["DISCORD_CLIENT_SECRET"]; const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required"); }); it("throws when response is not ok", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" }); const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed"); }); it("returns parsed body on success", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) }); const { exchangeCode } = await import("../../src/services/discord.js"); @@ -96,9 +76,7 @@ describe("discord service", () => { describe("exchangeCode non-Error throw", () => { it("re-throws when fetch rejects with a non-Error value", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; mockFetch.mockRejectedValueOnce("raw string error"); const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); diff --git a/apps/api/test/services/gateway.spec.ts b/apps/api/test/services/gateway.spec.ts index 67387b6..1112bf4 100644 --- a/apps/api/test/services/gateway.spec.ts +++ b/apps/api/test/services/gateway.spec.ts @@ -16,60 +16,48 @@ vi.mock("../../src/services/logger.js", () => ({ import { prisma } from "../../src/db/client.js"; -describe("gateway service", () => { - const ORIGINAL_ENV = process.env; +const discordGuildId = "1354624415861833870"; +describe("gateway service", () => { beforeEach(() => { - process.env = { ...ORIGINAL_ENV }; vi.resetAllMocks(); }); afterEach(() => { - process.env = ORIGINAL_ENV; + vi.clearAllMocks(); }); describe("handleGuildMemberAdd", () => { it("sets inGuild to true for the matching guild", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); - await handleGuildMemberAdd("user123", "guild123"); + await handleGuildMemberAdd("user123", discordGuildId); expect(prisma.player.updateMany).toHaveBeenCalledWith({ data: { inGuild: true }, where: { discordId: "user123" }, }); }); - it("no-ops when guild id does not match", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; + it("no-ops when guild id does not match the configured guild", async () => { const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); await handleGuildMemberAdd("user123", "other_guild"); expect(prisma.player.updateMany).not.toHaveBeenCalled(); }); - it("no-ops when DISCORD_GUILD_ID env var is missing and guild does not match undefined", async () => { - delete process.env["DISCORD_GUILD_ID"]; - const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); - await handleGuildMemberAdd("user123", "guild123"); - expect(prisma.player.updateMany).not.toHaveBeenCalled(); - }); - it("logs error when prisma throws an Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; const dbError = new Error("DB failure"); vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberAdd("user123", "guild123"); + await handleGuildMemberAdd("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError); }); it("logs error when prisma throws a non-Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberAdd("user123", "guild123"); + await handleGuildMemberAdd("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith( "gateway_member_add", new Error("raw error"), @@ -79,46 +67,35 @@ describe("gateway service", () => { describe("handleGuildMemberRemove", () => { it("sets inGuild to false for the matching guild", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); - await handleGuildMemberRemove("user123", "guild123"); + await handleGuildMemberRemove("user123", discordGuildId); expect(prisma.player.updateMany).toHaveBeenCalledWith({ data: { inGuild: false }, where: { discordId: "user123" }, }); }); - it("no-ops when guild id does not match", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; + it("no-ops when guild id does not match the configured guild", async () => { const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); await handleGuildMemberRemove("user123", "other_guild"); expect(prisma.player.updateMany).not.toHaveBeenCalled(); }); - it("no-ops when DISCORD_GUILD_ID env var is missing", async () => { - delete process.env["DISCORD_GUILD_ID"]; - const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); - await handleGuildMemberRemove("user123", "guild123"); - expect(prisma.player.updateMany).not.toHaveBeenCalled(); - }); - it("logs error when prisma throws an Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; const dbError = new Error("DB failure"); vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberRemove("user123", "guild123"); + await handleGuildMemberRemove("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError); }); it("logs error when prisma throws a non-Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberRemove("user123", "guild123"); + await handleGuildMemberRemove("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith( "gateway_member_remove", new Error("raw error"), diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index dd1eff5..4e9f045 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -20,42 +20,20 @@ describe("webhook service", () => { describe("grantApotheosisRole", () => { it("does nothing when bot token is missing", async () => { delete process.env["DISCORD_BOT_TOKEN"]; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123"; const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await grantApotheosisRole("user123"); expect(mockFetch).not.toHaveBeenCalled(); }); - it("does nothing when guild id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - delete process.env["DISCORD_GUILD_ID"]; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123"; - const { grantApotheosisRole } = await import("../../src/services/webhook.js"); - await grantApotheosisRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("does nothing when role id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"]; - const { grantApotheosisRole } = await import("../../src/services/webhook.js"); - await grantApotheosisRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("calls Discord API with correct URL and auth when env vars are set", async () => { + it("calls Discord API with correct URL and auth when bot token is set", async () => { process.env["DISCORD_BOT_TOKEN"] = "bot_token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456"; mockFetch.mockResolvedValueOnce({ ok: true }); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await grantApotheosisRole("user789"); expect(mockFetch).toHaveBeenCalledWith( - "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456", + "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991", expect.objectContaining({ - method: "PUT", + method: "PUT", headers: expect.objectContaining({ Authorization: "Bot bot_token" }), }), ); @@ -63,8 +41,6 @@ describe("webhook service", () => { it("swallows fetch errors gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce(new Error("Network error")); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); @@ -72,8 +48,6 @@ describe("webhook service", () => { it("swallows non-Error fetch rejections gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce("raw string error"); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); @@ -83,18 +57,6 @@ describe("webhook service", () => { describe("grantElysianRole", () => { it("does nothing when bot token is missing", async () => { delete process.env["DISCORD_BOT_TOKEN"]; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123"; - const { grantElysianRole } = await import("../../src/services/webhook.js"); - const result = await grantElysianRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - expect(result).toBe(false); - }); - - it("does nothing when guild id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - delete process.env["DISCORD_GUILD_ID"]; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123"; const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user123"); expect(mockFetch).not.toHaveBeenCalled(); @@ -103,12 +65,11 @@ describe("webhook service", () => { it("returns true when Discord API responds with ok", async () => { process.env["DISCORD_BOT_TOKEN"] = "bot_token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user789"); expect(mockFetch).toHaveBeenCalledWith( - "https://discord.com/api/v10/guilds/guild123/members/user789/roles/1486144823684628490", + "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490", expect.objectContaining({ method: "PUT", headers: expect.objectContaining({ Authorization: "Bot bot_token" }), @@ -119,7 +80,6 @@ describe("webhook service", () => { it("returns true when Discord API responds with 204", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockResolvedValueOnce({ ok: false, status: 204 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -128,7 +88,6 @@ describe("webhook service", () => { it("returns false when Discord API responds with an error status", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -137,7 +96,6 @@ describe("webhook service", () => { it("returns false and swallows fetch errors gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockRejectedValueOnce(new Error("Network error")); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -146,7 +104,6 @@ describe("webhook service", () => { it("returns false and swallows non-Error fetch rejections", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockRejectedValueOnce("raw string error"); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 24f39db..fd89b84 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -737,7 +737,7 @@ export const GameProvider = ({ setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); - setInGuild(data.inGuild === true); + setInGuild(data.inGuild); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`).