Sign panel sessions and isolate test runs
Some checks failed
Deploy to Production / test (push) Failing after 29s

- 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
This commit is contained in:
syntaxbullet
2026-04-09 21:44:05 +02:00
parent 6abbd4652a
commit 25a0bd3431
25 changed files with 354 additions and 157 deletions

View File

@@ -133,13 +133,14 @@ The main variables you need in `.env` are:
- `DISCORD_CLIENT_SECRET` - `DISCORD_CLIENT_SECRET`
- `DISCORD_GUILD_ID` - `DISCORD_GUILD_ID`
- `ADMIN_USER_IDS` - `ADMIN_USER_IDS`
- `SESSION_SECRET`
- `DB_USER` - `DB_USER`
- `DB_PASSWORD` - `DB_PASSWORD`
- `DB_NAME` - `DB_NAME`
- `DATABASE_URL` - `DATABASE_URL`
- `PANEL_BASE_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 ## API and panel summary

View File

@@ -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` - Entry point: `api/src/server.ts`
- Route dispatcher: `api/src/routes/index.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` - WebSocket: `/ws`
- Static assets: `/assets/*` - Static assets: `/assets/*`
- Built panel fallback: `panel/dist` - Built panel fallback: `panel/dist`

View File

@@ -10,7 +10,7 @@
## Authentication and authorization ## Authentication and authorization
- OAuth routes live in `api/src/routes/auth.routes.ts`. - 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. - Session TTL is 7 days.
- Login succeeds only for users already present in the `users` table. - 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`. - Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
@@ -62,7 +62,7 @@ Hard limits:
## Gotchas ## 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. - The server registers game plugins at startup; duplicate registration throws.
- BigInt-safe JSON matters for nearly every domain route. - BigInt-safe JSON matters for nearly every domain route.
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin. - The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.

View File

@@ -16,7 +16,7 @@ export class GameServer {
readonly roomManager = new RoomManager(); readonly roomManager = new RoomManager();
private connections = new Map<string, ServerWebSocket<WsConnectionData>>(); private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>(); private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
private bunServer: Server | null = null; private bunServer: Server<WsConnectionData> | null = null;
constructor() { constructor() {
// Subscribe to room events and route them to the right clients // 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<WsConnectionData>): void {
this.bunServer = server; this.bunServer = server;
} }

View File

@@ -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<typeof spyOn> | 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();
});
});

View File

@@ -3,6 +3,8 @@
* Handles login flow, callback, logout, and session management. * 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 type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils"; import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger"; import { logger } from "@shared/lib/logger";
@@ -10,7 +12,7 @@ import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema"; import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm"; 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 { export interface Session {
discordId: string; discordId: string;
username: string; username: string;
@@ -19,10 +21,21 @@ export interface Session {
expiresAt: number; expiresAt: number;
} }
const sessions = new Map<string, Session>(); interface SessionTokenPayload extends Session {
const redirects = new Map<string, string>(); // redirect token -> return_to URL 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 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 { function getEnv(key: string): string {
const val = process.env[key]; const val = process.env[key];
@@ -30,15 +43,70 @@ function getEnv(key: string): string {
return val; 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[] { function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? ""; const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean); return raw.split(",").map(s => s.trim()).filter(Boolean);
} }
function generateToken(): string { function encodeBase64Url(value: string): string {
const bytes = new Uint8Array(32); return Buffer.from(value, "utf8").toString("base64url");
crypto.getRandomValues(bytes); }
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
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<T>(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 { function getBaseUrl(): string {
@@ -55,18 +123,65 @@ function parseCookies(header: string | null): Record<string, string> {
return cookies; 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 */ /** Get session from request cookie */
export function getSession(req: Request): Session | null { export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie")); const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies["aurora_session"]; const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
if (!token) return null;
const session = sessions.get(token); if (!payload || payload.v !== 1) return null;
if (!session) return null; if (Date.now() > payload.expiresAt) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token); return {
return null; discordId: payload.discordId,
} username: payload.username,
return session; avatar: payload.avatar,
role: payload.role,
expiresAt: payload.expiresAt,
};
} }
/** Check if request is authenticated as admin */ /** Check if request is authenticated as admin */
@@ -84,20 +199,22 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`); const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
const scope = "identify+email"; const scope = "identify+email";
const secret = requireSessionSecret();
// Store return_to URL if provided // Store return_to URL in signed OAuth state
const returnTo = ctx.url.searchParams.get("return_to") || "/"; const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
const redirectToken = generateToken(); const state = serializeSignedToken("oauth", {
redirects.set(redirectToken, returnTo); 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, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
Location: url, Location: url,
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
}, },
}); });
} catch (e) { } catch (e) {
@@ -116,8 +233,13 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const clientSecret = getEnv("DISCORD_CLIENT_SECRET"); const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
const redirectUri = `${baseUrl}/auth/callback`; const redirectUri = `${baseUrl}/auth/callback`;
const secret = requireSessionSecret();
const statePayload = parseSignedToken<OAuthStatePayload>(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", { const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -165,40 +287,24 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const adminIds = getAdminIds(); const adminIds = getAdminIds();
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player"; const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
// Create session // Create signed session cookie
const token = generateToken(); const sessionToken = serializeSignedToken("session", {
sessions.set(token, {
discordId: user.id, discordId: user.id,
username: user.username, username: user.username,
avatar: user.avatar, avatar: user.avatar,
role, role,
expiresAt: Date.now() + SESSION_MAX_AGE, expiresAt: Date.now() + SESSION_MAX_AGE,
}); v: 1,
}, secret);
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`); 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 // Redirect to panel with session cookie
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
Location: returnTo, Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`, "Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`,
}, },
}); });
} catch (e) { } catch (e) {
@@ -209,14 +315,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
// POST /auth/logout — clear session // POST /auth/logout — clear session
if (pathname === "/auth/logout" && method === "POST") { 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, { return new Response(null, {
status: 200, status: 200,
headers: { headers: {
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", "Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });

View File

@@ -240,18 +240,3 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}, },
}; };
} }
/**
* Starts the web server from the main application root.
* Kept for backward compatibility.
*
* @param webProjectPath - Deprecated, no longer used
* @param config - Server configuration options
* @returns Promise resolving to server instance
*/
export async function startWebServerFromRoot(
webProjectPath: string,
config: WebServerConfig = {}
): Promise<WebServerInstance> {
return createWebServer(config);
}

View File

@@ -1,9 +1,8 @@
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { env } from "@shared/lib/env"; import { env } from "@shared/lib/env";
import { join } from "node:path";
import { initializeConfig } from "@shared/lib/config"; import { initializeConfig } from "@shared/lib/config";
import { registerDomainEventListeners } from "@shared/lib/eventWiring"; import { registerDomainEventListeners } from "@shared/lib/eventWiring";
import { startWebServerFromRoot } from "../api/src/server"; import { createWebServer } from "../api/src/server";
// Initialize config from database // Initialize config from database
await initializeConfig(); await initializeConfig();
@@ -21,12 +20,11 @@ console.log("🌐 Starting web server...");
let shuttingDown = false; let shuttingDown = false;
const webProjectPath = join(import.meta.dir, "../api");
const webPort = Number(process.env.WEB_PORT) || 3000; const webPort = Number(process.env.WEB_PORT) || 3000;
const webHost = process.env.HOST || "0.0.0.0"; const webHost = process.env.HOST || "0.0.0.0";
// Start web server in the same process // Start web server in the same process
const webServer = await startWebServerFromRoot(webProjectPath, { const webServer = await createWebServer({
port: webPort, port: webPort,
hostname: webHost, hostname: webHost,
}); });
@@ -53,4 +51,4 @@ const shutdownHandler = async () => {
}; };
process.on("SIGINT", shutdownHandler); process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler); process.on("SIGTERM", shutdownHandler);

View File

@@ -163,8 +163,9 @@ export function getShopListingMessage(
if (line) { if (line) {
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 }; if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
tiers[rarity].items.push(line); const tier = tiers[rarity]!;
tiers[rarity].totalChance += chance; tier.items.push(line);
tier.totalChance += chance;
} }
} }

View File

@@ -28,8 +28,8 @@
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts", "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-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'", "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": "bash shared/scripts/test-isolated.sh",
"test:ci": "bash shared/scripts/test-sequential.sh --integration", "test:ci": "bash shared/scripts/test-isolated.sh --integration",
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh", "test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
"panel:dev": "cd panel && bun run dev", "panel:dev": "cd panel && bun run dev",
"panel:build": "cd panel && bun run build", "panel:build": "cd panel && bun run build",
@@ -48,4 +48,4 @@
"postgres": "^3.4.8", "postgres": "^3.4.8",
"zod": "^4.3.6" "zod": "^4.3.6"
} }
} }

View File

@@ -19,11 +19,12 @@ export function useAuth(): AuthState & { logout: () => Promise<void> } {
useEffect(() => { useEffect(() => {
fetch("/auth/me", { credentials: "same-origin" }) fetch("/auth/me", { credentials: "same-origin" })
.then((r) => r.json()) .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({ setState({
loading: false, loading: false,
user: data.authenticated ? data.user! : null, user: auth.authenticated ? auth.user ?? null : null,
enrolled: data.enrolled ?? true, enrolled: auth.enrolled ?? true,
}); });
}) })
.catch(() => setState({ loading: false, user: null, enrolled: true })); .catch(() => setState({ loading: false, user: null, enrolled: true }));

View File

@@ -28,11 +28,15 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
const { send, subscribe, connected } = useWebSocket(); const { send, subscribe, connected } = useWebSocket();
const navigate = useNavigate(); const navigate = useNavigate();
const navigateRef = useRef(navigate); const navigateRef = useRef(navigate);
const errorTimerRef = useRef<ReturnType<typeof setTimeout>>(); const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
navigateRef.current = navigate; navigateRef.current = navigate;
}, [navigate]); }, [navigate]);
useEffect(() => () => clearTimeout(errorTimerRef.current), []); useEffect(() => () => {
if (errorTimerRef.current !== null) {
clearTimeout(errorTimerRef.current);
}
}, []);
const [state, setState] = useState<GameRoomState>({ const [state, setState] = useState<GameRoomState>({
gameState: null, gameState: null,
@@ -153,7 +157,9 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
setTimeout(() => navigateRef.current("/games"), 2000); setTimeout(() => navigateRef.current("/games"), 2000);
} else { } else {
setState(prev => ({ ...prev, error: msg.message })); setState(prev => ({ ...prev, error: msg.message }));
clearTimeout(errorTimerRef.current); if (errorTimerRef.current !== null) {
clearTimeout(errorTimerRef.current);
}
errorTimerRef.current = setTimeout(() => { errorTimerRef.current = setTimeout(() => {
setState(prev => ({ ...prev, error: null })); setState(prev => ({ ...prev, error: null }));
}, 5000); }, 5000);

View File

@@ -44,7 +44,7 @@ function rgbToHex(r: number, g: number, b: number): string {
function hexToRgb(hex: string): [number, number, number] | null { 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()); const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
if (!m) return null; 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 }: {
</div> </div>
<div className="bg-card rounded-xl overflow-hidden"> <div className="bg-card rounded-xl overflow-hidden">
{resultUrl ? ( {resultUrl ? (
<div style={BG_PRESETS[bgPreset].style}> <div style={BG_PRESETS[bgPreset]!.style}>
<img src={resultUrl} className="w-full block" alt="Result" /> <img src={resultUrl} className="w-full block" alt="Result" />
</div> </div>
) : ( ) : (
@@ -528,7 +528,7 @@ export function BackgroundRemoval() {
const x = Math.floor((e.clientX - rect.left) * scaleX); const x = Math.floor((e.clientX - rect.left) * scaleX);
const y = Math.floor((e.clientY - rect.top) * scaleY); const y = Math.floor((e.clientY - rect.top) * scaleY);
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data; 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) => { const handleHexInput = (v: string) => {
@@ -832,7 +832,7 @@ export function BackgroundRemoval() {
</div> </div>
</div> </div>
<div className="bg-card rounded-xl overflow-hidden"> <div className="bg-card rounded-xl overflow-hidden">
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}> <div style={BG_PRESETS[bgPreset]!.style} className={cn("w-full", !hasResult && "hidden")}>
<canvas ref={glCanvasRef} className="w-full block" /> <canvas ref={glCanvasRef} className="w-full block" />
</div> </div>
{!hasResult && ( {!hasResult && (

View File

@@ -468,7 +468,7 @@ export function CanvasTool() {
</div> </div>
</div> </div>
<div className="bg-card rounded-xl overflow-hidden"> <div className="bg-card rounded-xl overflow-hidden">
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}> <div style={BG_PRESETS[bgPreset]!.style} className={cn("w-full", !imageReady && "hidden")}>
<canvas ref={previewCanvasRef} className="w-full block" /> <canvas ref={previewCanvasRef} className="w-full block" />
</div> </div>
{!imageReady && ( {!imageReady && (

View File

@@ -261,7 +261,7 @@ export function CropTool() {
let minX = width, minY = height, maxX = -1, maxY = -1; let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) { for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) { 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 < minX) minX = x;
if (x > maxX) maxX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y < minY) minY = y;
@@ -287,7 +287,7 @@ export function CropTool() {
let minX = width, minY = height, maxX = -1, maxY = -1; let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) { for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) { 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 < minX) minX = x;
if (x > maxX) maxX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y < minY) minY = y;

View File

@@ -22,7 +22,7 @@ function blackjackHand(cards: Card[]): PlayerHand {
} }
function makeSeat(hands: PlayerHand[], activeHandIndex = 0, hasBet = true): PlayerSeat { 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. */ /** Create a rigged state for deterministic testing. */

View File

@@ -435,7 +435,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
const isMyTurn = activeId === playerId && state.phase === "player_turns"; const isMyTurn = activeId === playerId && state.phase === "player_turns";
const mySeat = state.seats[playerId]; const mySeat = state.seats[playerId];
const myActiveHand = mySeat && mySeat.activeHandIndex >= 0 const myActiveHand = mySeat && mySeat.activeHandIndex >= 0
? mySeat.hands[mySeat.activeHandIndex] ? mySeat.hands[mySeat.activeHandIndex] ?? null
: null; : null;
// Determine active hand index for the view // Determine active hand index for the view
@@ -454,7 +454,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
myPlayerId: playerId, myPlayerId: playerId,
phase: state.phase, phase: state.phase,
canAct: isMyTurn, 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), canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand),
roundNumber: state.roundNumber, roundNumber: state.roundNumber,
myCumulativePnl: mySeat?.cumulativePnl ?? 0, myCumulativePnl: mySeat?.cumulativePnl ?? 0,

View File

@@ -43,10 +43,12 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
createInitialState(players: string[], options?: Record<string, unknown>): ChessState { createInitialState(players: string[], options?: Record<string, unknown>): ChessState {
const game = new Chess(); const game = new Chess();
const timeControlKey = (options?.timeControl as string) ?? "blitz_5_3"; 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 // 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 const clock: ChessClock | null = tc.time > 0
? { white: tc.time, black: tc.time, increment: tc.increment, lastMoveAt: Date.now() } ? { white: tc.time, black: tc.time, increment: tc.increment, lastMoveAt: Date.now() }
@@ -108,7 +110,7 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
const moveEntry = { const moveEntry = {
from: action.from, from: action.from,
to: action.to, to: action.to,
san: game.history().slice(-1)[0], san: game.history().slice(-1)[0]!,
color: turn === "white" ? "w" as const : "b" as const, color: turn === "white" ? "w" as const : "b" as const,
}; };

View File

@@ -16,7 +16,7 @@ const LogLevelNames = {
[LogLevel.ERROR]: "ERROR", [LogLevel.ERROR]: "ERROR",
}; };
export type LogSource = "bot" | "web" | "shared" | "system"; export type LogSource = "auth" | "bot" | "web" | "shared" | "system";
export interface LogEntry { export interface LogEntry {
timestamp: string; timestamp: string;

View File

@@ -127,10 +127,10 @@ describe("questService", () => {
{ userId: 1n, questId: 1, completedAt: null }, { userId: 1n, questId: 1, completedAt: null },
{ userId: 1n, questId: 2, completedAt: new Date() }, { 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); 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(); mockGetSettings.mockRestore();
}); });

View File

@@ -69,9 +69,11 @@ mock.module("@shared/lib/config", () => ({
// Mock Events (trivia service emits domain events instead of calling dashboardService directly) // Mock Events (trivia service emits domain events instead of calling dashboardService directly)
const mockEmit = mock(() => true); const mockEmit = mock(() => true);
const mockEmitAsync = mock(async () => true);
mock.module("@shared/lib/events", () => ({ mock.module("@shared/lib/events", () => ({
systemEvents: { systemEvents: {
emit: mockEmit, emit: mockEmit,
emitAsync: mockEmitAsync,
}, },
EVENTS: { EVENTS: {
DOMAIN: { DOMAIN: {
@@ -115,6 +117,7 @@ describe("TriviaService", () => {
mockWhere.mockClear(); mockWhere.mockClear();
mockOnConflictDoUpdate.mockClear(); mockOnConflictDoUpdate.mockClear();
mockEmit.mockClear(); mockEmit.mockClear();
mockEmitAsync.mockClear();
// Clear active sessions // Clear active sessions
(triviaService as any).activeSessions.clear(); (triviaService as any).activeSessions.clear();
}); });
@@ -224,7 +227,7 @@ describe("TriviaService", () => {
// Verify balance update // Verify balance update
expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockInsert).toHaveBeenCalledWith(transactions); expect(mockInsert).toHaveBeenCalledWith(transactions);
expect(mockEmit).toHaveBeenCalled(); expect(mockEmitAsync).toHaveBeenCalled();
}); });
it("should not award prize for incorrect answer", async () => { it("should not award prize for incorrect answer", async () => {

View File

@@ -92,7 +92,7 @@ if [ -n "$1" ]; then
EXIT_CODE=1 EXIT_CODE=1
fi fi
else else
if bash shared/scripts/test-sequential.sh --integration; then if bash shared/scripts/test-isolated.sh --integration; then
echo "✅ CI Simulation Passed!" echo "✅ CI Simulation Passed!"
EXIT_CODE=0 EXIT_CODE=0
else else

42
shared/scripts/test-isolated.sh Executable file
View File

@@ -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

View File

@@ -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

View File

@@ -2,7 +2,9 @@
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": [ "lib": [
"ESNext" "ESNext",
"DOM",
"DOM.Iterable"
], ],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
@@ -54,4 +56,4 @@
"dist", "dist",
"drizzle" "drizzle"
] ]
} }