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

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

View File

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

View File

@@ -16,7 +16,7 @@ export class GameServer {
readonly roomManager = new RoomManager();
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
private bunServer: Server | null = null;
private bunServer: Server<WsConnectionData> | 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<WsConnectionData>): void {
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.
*/
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<string, Session>();
const redirects = new Map<string, string>(); // 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<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 {
@@ -55,18 +123,65 @@ function parseCookies(header: string | null): Record<string, string> {
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<SessionTokenPayload>(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<Response | null> {
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<Response | null> {
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl();
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", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -165,40 +287,24 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
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<Response | null> {
// 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",
},
});

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 { 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);
process.on("SIGTERM", shutdownHandler);

View File

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

View File

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

View File

@@ -19,11 +19,12 @@ export function useAuth(): AuthState & { logout: () => Promise<void> } {
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 }));

View File

@@ -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<ReturnType<typeof setTimeout>>();
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
useEffect(() => () => clearTimeout(errorTimerRef.current), []);
useEffect(() => () => {
if (errorTimerRef.current !== null) {
clearTimeout(errorTimerRef.current);
}
}, []);
const [state, setState] = useState<GameRoomState>({
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);

View File

@@ -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 }: {
</div>
<div className="bg-card rounded-xl overflow-hidden">
{resultUrl ? (
<div style={BG_PRESETS[bgPreset].style}>
<div style={BG_PRESETS[bgPreset]!.style}>
<img src={resultUrl} className="w-full block" alt="Result" />
</div>
) : (
@@ -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() {
</div>
</div>
<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" />
</div>
{!hasResult && (

View File

@@ -468,7 +468,7 @@ export function CanvasTool() {
</div>
</div>
<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" />
</div>
{!imageReady && (

View File

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

View File

@@ -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. */

View File

@@ -435,7 +435,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
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<BlackjackState, BlackjackAction> = {
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,

View File

@@ -43,10 +43,12 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
createInitialState(players: string[], options?: Record<string, unknown>): 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<ChessState, ChessAction> = {
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,
};

View File

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

View File

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

View File

@@ -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 () => {

View File

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

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": {
// Environment setup & latest features
"lib": [
"ESNext"
"ESNext",
"DOM",
"DOM.Iterable"
],
"target": "ESNext",
"module": "Preserve",
@@ -54,4 +56,4 @@
"dist",
"drizzle"
]
}
}