From 25a0bd3431f30e49a3acbfcd3cfcd3b7c14a705c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 9 Apr 2026 21:44:05 +0200 Subject: [PATCH] Sign panel sessions and isolate test runs - Replace in-memory auth sessions with signed cookies and signed OAuth state - Add auth route coverage and update panel/web server wiring - Switch test script to per-file Bun processes and clean up type checks --- README.md | 3 +- api/README.md | 2 +- api/src/AGENTS.md | 4 +- api/src/games/GameServer.ts | 4 +- api/src/routes/auth.routes.test.ts | 103 +++++++++ api/src/routes/auth.routes.ts | 204 +++++++++++++----- api/src/server.ts | 15 -- bot/index.ts | 8 +- bot/modules/economy/shop.view.ts | 5 +- package.json | 6 +- panel/src/lib/useAuth.ts | 7 +- panel/src/lib/useGameRoom.ts | 12 +- panel/src/pages/BackgroundRemoval.tsx | 8 +- panel/src/pages/CanvasTool.tsx | 2 +- panel/src/pages/CropTool.tsx | 4 +- .../games/blackjack/blackjack.plugin.test.ts | 2 +- shared/games/blackjack/blackjack.plugin.ts | 4 +- shared/games/chess/chess.plugin.ts | 8 +- shared/lib/logger.ts | 2 +- shared/modules/quest/quest.service.test.ts | 4 +- shared/modules/trivia/trivia.service.test.ts | 5 +- shared/scripts/simulate-ci.sh | 2 +- shared/scripts/test-isolated.sh | 42 ++++ shared/scripts/test-sequential.sh | 49 ----- tsconfig.json | 6 +- 25 files changed, 354 insertions(+), 157 deletions(-) create mode 100644 api/src/routes/auth.routes.test.ts create mode 100755 shared/scripts/test-isolated.sh delete mode 100755 shared/scripts/test-sequential.sh diff --git a/README.md b/README.md index 5fb1b5f..1edb1cb 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,14 @@ The main variables you need in `.env` are: - `DISCORD_CLIENT_SECRET` - `DISCORD_GUILD_ID` - `ADMIN_USER_IDS` +- `SESSION_SECRET` - `DB_USER` - `DB_PASSWORD` - `DB_NAME` - `DATABASE_URL` - `PANEL_BASE_URL` -Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`. +Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`. ## API and panel summary diff --git a/api/README.md b/api/README.md index c726a00..36372b2 100644 --- a/api/README.md +++ b/api/README.md @@ -6,7 +6,7 @@ Aurora's API is a Bun server that runs inside the same process as the Discord bo - Entry point: `api/src/server.ts` - Route dispatcher: `api/src/routes/index.ts` -- Auth: Discord OAuth with in-memory session cookies +- Auth: Discord OAuth with signed session cookies - WebSocket: `/ws` - Static assets: `/assets/*` - Built panel fallback: `panel/dist` diff --git a/api/src/AGENTS.md b/api/src/AGENTS.md index b5a1e99..e02dbd5 100644 --- a/api/src/AGENTS.md +++ b/api/src/AGENTS.md @@ -10,7 +10,7 @@ ## Authentication and authorization - OAuth routes live in `api/src/routes/auth.routes.ts`. -- Sessions are stored in memory and keyed by the `aurora_session` cookie. +- Sessions are stored in signed `aurora_session` cookies. - Session TTL is 7 days. - Login succeeds only for users already present in the `users` table. - Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`. @@ -62,7 +62,7 @@ Hard limits: ## Gotchas -- Sessions and some caches are in-memory only and are lost on restart. +- Some runtime caches are in-memory only and are lost on restart. - The server registers game plugins at startup; duplicate registration throws. - BigInt-safe JSON matters for nearly every domain route. - The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin. diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts index 3b9b733..86f0310 100644 --- a/api/src/games/GameServer.ts +++ b/api/src/games/GameServer.ts @@ -16,7 +16,7 @@ export class GameServer { readonly roomManager = new RoomManager(); private connections = new Map>(); private replacedConnections = new Map>(); - private bunServer: Server | null = null; + private bunServer: Server | null = null; constructor() { // Subscribe to room events and route them to the right clients @@ -141,7 +141,7 @@ export class GameServer { }); } - setServer(server: Server): void { + setServer(server: Server): void { this.bunServer = server; } diff --git a/api/src/routes/auth.routes.test.ts b/api/src/routes/auth.routes.test.ts new file mode 100644 index 0000000..7be0119 --- /dev/null +++ b/api/src/routes/auth.routes.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; + +const findFirst = mock(async () => ({ id: 123n })); + +mock.module("@shared/db/DrizzleClient", () => ({ + DrizzleClient: { + query: { + users: { + findFirst, + }, + }, + }, +})); + +mock.module("@shared/lib/logger", () => ({ + logger: { + error: () => { }, + info: () => { }, + warn: () => { }, + debug: () => { }, + }, +})); + +import { authRoutes, getSession } from "./auth.routes"; + +describe("Auth Routes", () => { + let fetchSpy: ReturnType | null = null; + + beforeEach(() => { + process.env.DISCORD_CLIENT_ID = "client-id"; + process.env.DISCORD_CLIENT_SECRET = "client-secret"; + process.env.SESSION_SECRET = "session-secret"; + process.env.PANEL_BASE_URL = "http://localhost:3000"; + process.env.ADMIN_USER_IDS = "123"; + findFirst.mockClear(); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + fetchSpy = null; + }); + + it("creates a signed session cookie during OAuth callback", async () => { + const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin"); + const loginRes = await authRoutes.handler({ + req: new Request(loginUrl, { method: "GET" }), + url: loginUrl, + method: "GET", + pathname: "/auth/discord", + }); + + expect(loginRes?.status).toBe(302); + + const redirectLocation = loginRes?.headers.get("Location"); + expect(redirectLocation).not.toBeNull(); + + const state = new URL(redirectLocation!).searchParams.get("state"); + expect(state).not.toBeNull(); + + fetchSpy = spyOn(globalThis, "fetch"); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 })); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + id: "123", + username: "aurora-admin", + avatar: null, + }), { status: 200 })); + + const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`); + const callbackRes = await authRoutes.handler({ + req: new Request(callbackUrl, { method: "GET" }), + url: callbackUrl, + method: "GET", + pathname: "/auth/callback", + }); + + expect(callbackRes?.status).toBe(302); + expect(callbackRes?.headers.get("Location")).toBe("/admin"); + + const setCookie = callbackRes?.headers.get("Set-Cookie"); + expect(setCookie).toContain("aurora_session="); + + const sessionCookie = setCookie!.split(";")[0]!; + const session = getSession(new Request("http://localhost/api/me", { + headers: { cookie: sessionCookie }, + })); + + expect(session).toEqual({ + discordId: "123", + username: "aurora-admin", + avatar: null, + role: "admin", + expiresAt: expect.any(Number), + }); + }); + + it("rejects tampered session cookies", () => { + const session = getSession(new Request("http://localhost/api/me", { + headers: { cookie: "aurora_session=not-a-valid-token" }, + })); + + expect(session).toBeNull(); + }); +}); diff --git a/api/src/routes/auth.routes.ts b/api/src/routes/auth.routes.ts index 772db7b..2325799 100644 --- a/api/src/routes/auth.routes.ts +++ b/api/src/routes/auth.routes.ts @@ -3,6 +3,8 @@ * Handles login flow, callback, logout, and session management. */ +import { Buffer } from "node:buffer"; +import { createHmac, timingSafeEqual } from "node:crypto"; import type { RouteContext, RouteModule } from "./types"; import { jsonResponse, errorResponse } from "./utils"; import { logger } from "@shared/lib/logger"; @@ -10,7 +12,7 @@ import { DrizzleClient } from "@shared/db/DrizzleClient"; import { users } from "@shared/db/schema"; import { eq } from "drizzle-orm"; -// In-memory session store: token โ†’ { discordId, username, avatar, role, expiresAt } +// Signed session payload stored in the aurora_session cookie. export interface Session { discordId: string; username: string; @@ -19,10 +21,21 @@ export interface Session { expiresAt: number; } -const sessions = new Map(); -const redirects = new Map(); // redirect token -> return_to URL +interface SessionTokenPayload extends Session { + v: 1; +} +interface OAuthStatePayload { + exp: number; + returnTo: string; + v: 1; +} + +const COOKIE_NAME = "aurora_session"; const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days +const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes +const TOKEN_NAMESPACE = "aurora.auth"; +const TOKEN_VERSION = "v1"; function getEnv(key: string): string { const val = process.env[key]; @@ -30,15 +43,70 @@ function getEnv(key: string): string { return val; } +function getSessionSecret(required: boolean = false): string | null { + const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null; + if (!secret && required) { + throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET"); + } + return secret; +} + +function requireSessionSecret(): string { + return getSessionSecret(true)!; +} + function getAdminIds(): string[] { const raw = process.env.ADMIN_USER_IDS ?? ""; return raw.split(",").map(s => s.trim()).filter(Boolean); } -function generateToken(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join(""); +function encodeBase64Url(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function decodeBase64Url(value: string): string { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function signValue(kind: string, encodedPayload: string, secret: string): string { + return createHmac("sha256", secret) + .update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`) + .digest("base64url"); +} + +function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string { + const encodedPayload = encodeBase64Url(JSON.stringify(payload)); + const signature = signValue(kind, encodedPayload, secret); + return `${TOKEN_VERSION}.${encodedPayload}.${signature}`; +} + +function parseSignedToken(token: string | undefined, kind: string): T | null { + if (!token) return null; + + const secret = getSessionSecret(); + if (!secret) return null; + + const parts = token.split("."); + if (parts.length !== 3) return null; + + const version = parts[0]; + const encodedPayload = parts[1]; + const providedSignature = parts[2]; + if (version !== TOKEN_VERSION) return null; + if (!encodedPayload || !providedSignature) return null; + + const expectedSignature = signValue(kind, encodedPayload, secret); + const providedBuffer = Buffer.from(providedSignature); + const expectedBuffer = Buffer.from(expectedSignature); + + if (providedBuffer.length !== expectedBuffer.length) return null; + if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null; + + try { + return JSON.parse(decodeBase64Url(encodedPayload)) as T; + } catch { + return null; + } } function getBaseUrl(): string { @@ -55,18 +123,65 @@ function parseCookies(header: string | null): Record { return cookies; } +function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string { + if (!rawReturnTo || rawReturnTo.length > 1024) return "/"; + + try { + if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) { + return rawReturnTo; + } + + const parsed = new URL(rawReturnTo, baseUrl); + const allowedBase = new URL(baseUrl); + const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + + if (parsed.origin === allowedBase.origin || isLocalhostRedirect) { + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch { + return "/"; + } + + return "/"; +} + +function buildCookieAttributes(maxAgeSeconds?: number): string { + const attrs = [ + "Path=/", + "HttpOnly", + "SameSite=Lax", + ]; + + try { + if (new URL(getBaseUrl()).protocol === "https:") { + attrs.push("Secure"); + } + } catch { + // Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly. + } + + if (typeof maxAgeSeconds === "number") { + attrs.push(`Max-Age=${maxAgeSeconds}`); + } + + return attrs.join("; "); +} + /** Get session from request cookie */ export function getSession(req: Request): Session | null { const cookies = parseCookies(req.headers.get("cookie")); - const token = cookies["aurora_session"]; - if (!token) return null; - const session = sessions.get(token); - if (!session) return null; - if (Date.now() > session.expiresAt) { - sessions.delete(token); - return null; - } - return session; + const payload = parseSignedToken(cookies[COOKIE_NAME], "session"); + + if (!payload || payload.v !== 1) return null; + if (Date.now() > payload.expiresAt) return null; + + return { + discordId: payload.discordId, + username: payload.username, + avatar: payload.avatar, + role: payload.role, + expiresAt: payload.expiresAt, + }; } /** Check if request is authenticated as admin */ @@ -84,20 +199,22 @@ async function handler(ctx: RouteContext): Promise { const baseUrl = getBaseUrl(); const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`); const scope = "identify+email"; + const secret = requireSessionSecret(); - // Store return_to URL if provided - const returnTo = ctx.url.searchParams.get("return_to") || "/"; - const redirectToken = generateToken(); - redirects.set(redirectToken, returnTo); + // Store return_to URL in signed OAuth state + const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl); + const state = serializeSignedToken("oauth", { + exp: Date.now() + OAUTH_STATE_MAX_AGE, + returnTo, + v: 1, + }, secret); - const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`; + const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`; - // Set a temporary cookie with the redirect token return new Response(null, { status: 302, headers: { Location: url, - "Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`, }, }); } catch (e) { @@ -116,8 +233,13 @@ async function handler(ctx: RouteContext): Promise { const clientSecret = getEnv("DISCORD_CLIENT_SECRET"); const baseUrl = getBaseUrl(); const redirectUri = `${baseUrl}/auth/callback`; + const secret = requireSessionSecret(); + const statePayload = parseSignedToken(ctx.url.searchParams.get("state") ?? undefined, "oauth"); + + if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) { + return errorResponse("Invalid OAuth state", 400); + } - // Exchange code for token const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -165,40 +287,24 @@ async function handler(ctx: RouteContext): Promise { const adminIds = getAdminIds(); const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player"; - // Create session - const token = generateToken(); - sessions.set(token, { + // Create signed session cookie + const sessionToken = serializeSignedToken("session", { discordId: user.id, username: user.username, avatar: user.avatar, role, expiresAt: Date.now() + SESSION_MAX_AGE, - }); + v: 1, + }, secret); logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`); - // Get return_to URL from redirect token cookie - const cookies = parseCookies(ctx.req.headers.get("cookie")); - const redirectToken = cookies["aurora_redirect"]; - let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/"; - if (redirectToken) redirects.delete(redirectToken); - - // Only allow redirects to localhost or relative paths (prevent open redirect) - try { - const parsed = new URL(returnTo, baseUrl); - if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") { - returnTo = "/"; - } - } catch { - returnTo = "/"; - } - // Redirect to panel with session cookie return new Response(null, { status: 302, headers: { - Location: returnTo, - "Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`, + Location: sanitizeReturnTo(statePayload.returnTo, baseUrl), + "Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`, }, }); } catch (e) { @@ -209,14 +315,10 @@ async function handler(ctx: RouteContext): Promise { // POST /auth/logout โ€” clear session if (pathname === "/auth/logout" && method === "POST") { - const cookies = parseCookies(ctx.req.headers.get("cookie")); - const token = cookies["aurora_session"]; - if (token) sessions.delete(token); - return new Response(null, { status: 200, headers: { - "Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", + "Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`, "Content-Type": "application/json", }, }); diff --git a/api/src/server.ts b/api/src/server.ts index f7eda98..d13f38d 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -240,18 +240,3 @@ export async function createWebServer(config: WebServerConfig = {}): Promise { - return createWebServer(config); -} diff --git a/bot/index.ts b/bot/index.ts index f2b296e..1e5c90c 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -1,9 +1,8 @@ import { AuroraClient } from "@/lib/BotClient"; import { env } from "@shared/lib/env"; -import { join } from "node:path"; import { initializeConfig } from "@shared/lib/config"; import { registerDomainEventListeners } from "@shared/lib/eventWiring"; -import { startWebServerFromRoot } from "../api/src/server"; +import { createWebServer } from "../api/src/server"; // Initialize config from database await initializeConfig(); @@ -21,12 +20,11 @@ console.log("๐ŸŒ Starting web server..."); let shuttingDown = false; -const webProjectPath = join(import.meta.dir, "../api"); const webPort = Number(process.env.WEB_PORT) || 3000; const webHost = process.env.HOST || "0.0.0.0"; // Start web server in the same process -const webServer = await startWebServerFromRoot(webProjectPath, { +const webServer = await createWebServer({ port: webPort, hostname: webHost, }); @@ -53,4 +51,4 @@ const shutdownHandler = async () => { }; process.on("SIGINT", shutdownHandler); -process.on("SIGTERM", shutdownHandler); \ No newline at end of file +process.on("SIGTERM", shutdownHandler); diff --git a/bot/modules/economy/shop.view.ts b/bot/modules/economy/shop.view.ts index 943dcb1..946eeeb 100644 --- a/bot/modules/economy/shop.view.ts +++ b/bot/modules/economy/shop.view.ts @@ -163,8 +163,9 @@ export function getShopListingMessage( if (line) { if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 }; - tiers[rarity].items.push(line); - tiers[rarity].totalChance += chance; + const tier = tiers[rarity]!; + tier.items.push(line); + tier.totalChance += chance; } } diff --git a/package.json b/package.json index 2deb75b..c0a66c6 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts", "db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts", "db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'", - "test": "bash shared/scripts/test-sequential.sh", - "test:ci": "bash shared/scripts/test-sequential.sh --integration", + "test": "bash shared/scripts/test-isolated.sh", + "test:ci": "bash shared/scripts/test-isolated.sh --integration", "test:simulate-ci": "bash shared/scripts/simulate-ci.sh", "panel:dev": "cd panel && bun run dev", "panel:build": "cd panel && bun run build", @@ -48,4 +48,4 @@ "postgres": "^3.4.8", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/panel/src/lib/useAuth.ts b/panel/src/lib/useAuth.ts index dab1683..ca9eb64 100644 --- a/panel/src/lib/useAuth.ts +++ b/panel/src/lib/useAuth.ts @@ -19,11 +19,12 @@ export function useAuth(): AuthState & { logout: () => Promise } { useEffect(() => { fetch("/auth/me", { credentials: "same-origin" }) .then((r) => r.json()) - .then((data: { authenticated: boolean; enrolled: boolean; user?: AuthUser }) => { + .then((data) => { + const auth = data as { authenticated: boolean; enrolled: boolean; user?: AuthUser }; setState({ loading: false, - user: data.authenticated ? data.user! : null, - enrolled: data.enrolled ?? true, + user: auth.authenticated ? auth.user ?? null : null, + enrolled: auth.enrolled ?? true, }); }) .catch(() => setState({ loading: false, user: null, enrolled: true })); diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index 0cddc7e..78383e8 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -28,11 +28,15 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe const { send, subscribe, connected } = useWebSocket(); const navigate = useNavigate(); const navigateRef = useRef(navigate); - const errorTimerRef = useRef>(); + const errorTimerRef = useRef | null>(null); useEffect(() => { navigateRef.current = navigate; }, [navigate]); - useEffect(() => () => clearTimeout(errorTimerRef.current), []); + useEffect(() => () => { + if (errorTimerRef.current !== null) { + clearTimeout(errorTimerRef.current); + } + }, []); const [state, setState] = useState({ gameState: null, @@ -153,7 +157,9 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe setTimeout(() => navigateRef.current("/games"), 2000); } else { setState(prev => ({ ...prev, error: msg.message })); - clearTimeout(errorTimerRef.current); + if (errorTimerRef.current !== null) { + clearTimeout(errorTimerRef.current); + } errorTimerRef.current = setTimeout(() => { setState(prev => ({ ...prev, error: null })); }, 5000); diff --git a/panel/src/pages/BackgroundRemoval.tsx b/panel/src/pages/BackgroundRemoval.tsx index b3b931b..5cfba9a 100644 --- a/panel/src/pages/BackgroundRemoval.tsx +++ b/panel/src/pages/BackgroundRemoval.tsx @@ -44,7 +44,7 @@ function rgbToHex(r: number, g: number, b: number): string { function hexToRgb(hex: string): [number, number, number] | null { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim()); if (!m) return null; - return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; + return [parseInt(m[1]!, 16), parseInt(m[2]!, 16), parseInt(m[3]!, 16)]; } // --------------------------------------------------------------------------- @@ -380,7 +380,7 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
{resultUrl ? ( -
+
Result
) : ( @@ -528,7 +528,7 @@ export function BackgroundRemoval() { const x = Math.floor((e.clientX - rect.left) * scaleX); const y = Math.floor((e.clientY - rect.top) * scaleY); const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data; - setKeyColor([px[0], px[1], px[2]]); + setKeyColor([px[0]!, px[1]!, px[2]!]); }; const handleHexInput = (v: string) => { @@ -832,7 +832,7 @@ export function BackgroundRemoval() {
-
+
{!hasResult && ( diff --git a/panel/src/pages/CanvasTool.tsx b/panel/src/pages/CanvasTool.tsx index ec72615..1504c53 100644 --- a/panel/src/pages/CanvasTool.tsx +++ b/panel/src/pages/CanvasTool.tsx @@ -468,7 +468,7 @@ export function CanvasTool() {
-
+
{!imageReady && ( diff --git a/panel/src/pages/CropTool.tsx b/panel/src/pages/CropTool.tsx index c466967..cb7e59c 100644 --- a/panel/src/pages/CropTool.tsx +++ b/panel/src/pages/CropTool.tsx @@ -261,7 +261,7 @@ export function CropTool() { let minX = width, minY = height, maxX = -1, maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - if (data[(y * width + x) * 4 + 3] > 0) { + if (data[(y * width + x) * 4 + 3]! > 0) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; @@ -287,7 +287,7 @@ export function CropTool() { let minX = width, minY = height, maxX = -1, maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - if (data[(y * width + x) * 4 + 3] > 0) { + if (data[(y * width + x) * 4 + 3]! > 0) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; diff --git a/shared/games/blackjack/blackjack.plugin.test.ts b/shared/games/blackjack/blackjack.plugin.test.ts index d44cfe1..2528ac2 100644 --- a/shared/games/blackjack/blackjack.plugin.test.ts +++ b/shared/games/blackjack/blackjack.plugin.test.ts @@ -22,7 +22,7 @@ function blackjackHand(cards: Card[]): PlayerHand { } function makeSeat(hands: PlayerHand[], activeHandIndex = 0, hasBet = true): PlayerSeat { - return { hands, activeHandIndex, hasBet }; + return { hands, activeHandIndex, hasBet, cumulativePnl: 0 }; } /** Create a rigged state for deterministic testing. */ diff --git a/shared/games/blackjack/blackjack.plugin.ts b/shared/games/blackjack/blackjack.plugin.ts index f7ea019..c726d1a 100644 --- a/shared/games/blackjack/blackjack.plugin.ts +++ b/shared/games/blackjack/blackjack.plugin.ts @@ -435,7 +435,7 @@ export const blackjackPlugin: GamePlugin = { const isMyTurn = activeId === playerId && state.phase === "player_turns"; const mySeat = state.seats[playerId]; const myActiveHand = mySeat && mySeat.activeHandIndex >= 0 - ? mySeat.hands[mySeat.activeHandIndex] + ? mySeat.hands[mySeat.activeHandIndex] ?? null : null; // Determine active hand index for the view @@ -454,7 +454,7 @@ export const blackjackPlugin: GamePlugin = { myPlayerId: playerId, phase: state.phase, canAct: isMyTurn, - canSplit: isMyTurn && myActiveHand !== null && canSplitHand(myActiveHand, mySeat!.hands.length), + canSplit: isMyTurn && myActiveHand !== null && mySeat !== undefined && canSplitHand(myActiveHand, mySeat.hands.length), canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand), roundNumber: state.roundNumber, myCumulativePnl: mySeat?.cumulativePnl ?? 0, diff --git a/shared/games/chess/chess.plugin.ts b/shared/games/chess/chess.plugin.ts index 7c10f6a..ac6b05c 100644 --- a/shared/games/chess/chess.plugin.ts +++ b/shared/games/chess/chess.plugin.ts @@ -43,10 +43,12 @@ export const chessPlugin: GamePlugin = { createInitialState(players: string[], options?: Record): ChessState { const game = new Chess(); const timeControlKey = (options?.timeControl as string) ?? "blitz_5_3"; - const tc = TIME_CONTROLS[timeControlKey] ?? TIME_CONTROLS.blitz_5_3; + const tc = TIME_CONTROLS[timeControlKey] ?? TIME_CONTROLS["blitz_5_3"]!; // Randomly assign colors - const shuffled = Math.random() < 0.5 ? [players[0], players[1]] : [players[1], players[0]]; + const shuffled: [string, string] = Math.random() < 0.5 + ? [players[0]!, players[1]!] + : [players[1]!, players[0]!]; const clock: ChessClock | null = tc.time > 0 ? { white: tc.time, black: tc.time, increment: tc.increment, lastMoveAt: Date.now() } @@ -108,7 +110,7 @@ export const chessPlugin: GamePlugin = { const moveEntry = { from: action.from, to: action.to, - san: game.history().slice(-1)[0], + san: game.history().slice(-1)[0]!, color: turn === "white" ? "w" as const : "b" as const, }; diff --git a/shared/lib/logger.ts b/shared/lib/logger.ts index ed0cf61..94843c6 100644 --- a/shared/lib/logger.ts +++ b/shared/lib/logger.ts @@ -16,7 +16,7 @@ const LogLevelNames = { [LogLevel.ERROR]: "ERROR", }; -export type LogSource = "bot" | "web" | "shared" | "system"; +export type LogSource = "auth" | "bot" | "web" | "shared" | "system"; export interface LogEntry { timestamp: string; diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index cef36f1..7b1971a 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -127,10 +127,10 @@ describe("questService", () => { { userId: 1n, questId: 1, completedAt: null }, { userId: 1n, questId: 2, completedAt: new Date() }, ]); - mockReturning.mockResolvedValue([{ userId: 1n, questId: 3 }]); + mockReturning.mockResolvedValue([{ userId: 1n, questId: 3 }] as any); const result = await questService.assignQuest("1", 3); - expect(result).toEqual([{ userId: 1n, questId: 3 }]); + expect(result).toEqual([{ userId: 1n, questId: 3 }] as any); mockGetSettings.mockRestore(); }); diff --git a/shared/modules/trivia/trivia.service.test.ts b/shared/modules/trivia/trivia.service.test.ts index 14252dd..3540f85 100644 --- a/shared/modules/trivia/trivia.service.test.ts +++ b/shared/modules/trivia/trivia.service.test.ts @@ -69,9 +69,11 @@ mock.module("@shared/lib/config", () => ({ // Mock Events (trivia service emits domain events instead of calling dashboardService directly) const mockEmit = mock(() => true); +const mockEmitAsync = mock(async () => true); mock.module("@shared/lib/events", () => ({ systemEvents: { emit: mockEmit, + emitAsync: mockEmitAsync, }, EVENTS: { DOMAIN: { @@ -115,6 +117,7 @@ describe("TriviaService", () => { mockWhere.mockClear(); mockOnConflictDoUpdate.mockClear(); mockEmit.mockClear(); + mockEmitAsync.mockClear(); // Clear active sessions (triviaService as any).activeSessions.clear(); }); @@ -224,7 +227,7 @@ describe("TriviaService", () => { // Verify balance update expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockInsert).toHaveBeenCalledWith(transactions); - expect(mockEmit).toHaveBeenCalled(); + expect(mockEmitAsync).toHaveBeenCalled(); }); it("should not award prize for incorrect answer", async () => { diff --git a/shared/scripts/simulate-ci.sh b/shared/scripts/simulate-ci.sh index 1e59104..04eab26 100755 --- a/shared/scripts/simulate-ci.sh +++ b/shared/scripts/simulate-ci.sh @@ -92,7 +92,7 @@ if [ -n "$1" ]; then EXIT_CODE=1 fi else - if bash shared/scripts/test-sequential.sh --integration; then + if bash shared/scripts/test-isolated.sh --integration; then echo "โœ… CI Simulation Passed!" EXIT_CODE=0 else diff --git a/shared/scripts/test-isolated.sh b/shared/scripts/test-isolated.sh new file mode 100755 index 0000000..ba7058f --- /dev/null +++ b/shared/scripts/test-isolated.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -euo pipefail + +INCLUDE_INTEGRATION=false +if [[ "${1:-}" == "--integration" ]]; then + INCLUDE_INTEGRATION=true +fi + +JOBS="${AURORA_TEST_JOBS:-4}" + +echo "๐Ÿ” Finding test files..." +if [ "$INCLUDE_INTEGRATION" = true ]; then + FIND_ARGS=( -name "*.test.ts" ) +else + FIND_ARGS=( -name "*.test.ts" -not -name "*.integration.test.ts" ) +fi + +TEST_FILES=() +while IFS= read -r file; do + TEST_FILES+=("$file") +done < <(find . "${FIND_ARGS[@]}" -not -path "*/node_modules/*" | sort) + +if [ "${#TEST_FILES[@]}" -eq 0 ]; then + echo "โš ๏ธ No test files found!" + exit 0 +fi + +echo "๐Ÿงช Running ${#TEST_FILES[@]} test files with isolated Bun processes..." +echo " Workers: $JOBS" +if [ "$INCLUDE_INTEGRATION" = true ]; then + echo " (including integration tests)" +fi + +if printf '%s\n' "${TEST_FILES[@]}" | xargs -n1 -P "$JOBS" bash -lc 'echo "---------------------------------------------------"; echo "running: $1"; bun test "$1"' _; then + echo "---------------------------------------------------" + echo "โœ… All tests passed!" + exit 0 +fi + +echo "---------------------------------------------------" +echo "โŒ Some tests failed." +exit 1 diff --git a/shared/scripts/test-sequential.sh b/shared/scripts/test-sequential.sh deleted file mode 100755 index ac12420..0000000 --- a/shared/scripts/test-sequential.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -set -e - -INCLUDE_INTEGRATION=false -if [[ "$1" == "--integration" ]]; then - INCLUDE_INTEGRATION=true -fi - -echo "๐Ÿ” Finding test files..." -if [ "$INCLUDE_INTEGRATION" = true ]; then - TEST_FILES=$(find . -name "*.test.ts" -not -path "*/node_modules/*") -else - TEST_FILES=$(find . -name "*.test.ts" -not -name "*.integration.test.ts" -not -path "*/node_modules/*") -fi - -if [ -z "$TEST_FILES" ]; then - echo "โš ๏ธ No test files found!" - exit 0 -fi - -echo "๐Ÿงช Running tests sequentially..." -if [ "$INCLUDE_INTEGRATION" = true ]; then - echo " (including integration tests)" -fi - -FAILED=0 - -for FILE in $TEST_FILES; do - echo "---------------------------------------------------" - echo "running: $FILE" - if bun test "$FILE"; then - echo "โœ… passed: $FILE" - else - echo "โŒ failed: $FILE" - FAILED=1 - # Fail fast - exit 1 - fi -done - -if [ $FAILED -eq 0 ]; then - echo "---------------------------------------------------" - echo "โœ… All tests passed!" - exit 0 -else - echo "---------------------------------------------------" - echo "โŒ Some tests failed." - exit 1 -fi diff --git a/tsconfig.json b/tsconfig.json index eee9923..331a883 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { // Environment setup & latest features "lib": [ - "ESNext" + "ESNext", + "DOM", + "DOM.Iterable" ], "target": "ESNext", "module": "Preserve", @@ -54,4 +56,4 @@ "dist", "drizzle" ] -} \ No newline at end of file +}