Initial public release of PSX Adventure Engine

Browser reference stack for PSX-era third-person adventure: fixed cameras,
inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular
character harness. Ships the Ashgrove Precinct Level 1 investigation demo with
a full cast and nine linked rooms.
This commit is contained in:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@psx/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^1.19.7",
"@psx/shared": "workspace:*",
"hono": "^4.12.32",
"ws": "^8.21.1"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3",
"vitest": "^4.1.10"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { InMemoryStore, type Store } from "./db/store.js";
import { authRoutes } from "./routes/auth.js";
import { lobbyRoutes } from "./routes/lobbies.js";
import { savesRoutes } from "./routes/saves.js";
/**
* Route wiring, separated from the bootstrap so tests can mount the app
* against a fresh store without opening a port.
*/
export function createApp(store: Store = new InMemoryStore()) {
const app = new Hono();
// The client page is cross-origin isolated (for wasm threads), which does not
// restrict its own outbound fetches, but in dev it runs on a different port.
app.use("/*", cors({ origin: (origin) => origin ?? "*", credentials: true }));
app.get("/health", (context) =>
context.json({ ok: true, storage: "in-memory", version: "0.1.0" }),
);
app.route("/auth", authRoutes(store));
app.route("/lobbies", lobbyRoutes(store));
app.route("/saves", savesRoutes(store));
return { app, store };
}
+93
View File
@@ -0,0 +1,93 @@
import { createHmac, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import type { Store, UserRecord } from "./db/store.js";
/**
* Password hashing and session tokens.
*
* Real scrypt and real HMAC even though the store is in-memory: getting these
* wrong is the kind of thing that survives into production once a Neon
* connection string appears, and there is no cost to doing it properly now.
*/
const scryptAsync = promisify(scrypt) as (
password: string,
salt: string,
keylen: number,
) => Promise<Buffer>;
const SCRYPT_KEYLEN = 64;
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
/**
* Dev-only fallback secret. Regenerated per process, so restarting invalidates
* every outstanding token rather than signing with a known constant.
*/
const SECRET = process.env["PSX_SESSION_SECRET"] ?? randomBytes(32).toString("hex");
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const derived = await scryptAsync(password, salt, SCRYPT_KEYLEN);
return `${salt}:${derived.toString("hex")}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [salt, expected] = stored.split(":");
if (!salt || !expected) return false;
const derived = await scryptAsync(password, salt, SCRYPT_KEYLEN);
const expectedBuffer = Buffer.from(expected, "hex");
// Length check first: timingSafeEqual throws on mismatched lengths.
if (expectedBuffer.length !== derived.length) return false;
return timingSafeEqual(derived, expectedBuffer);
}
interface TokenPayload {
sub: string;
exp: number;
jti: string;
}
const sign = (data: string): string =>
createHmac("sha256", SECRET).update(data).digest("base64url");
/** Compact signed token. Not a full JWT — there is no need for the header. */
export function issueToken(userId: string): string {
const payload: TokenPayload = {
sub: userId,
exp: Date.now() + TOKEN_TTL_MS,
jti: randomUUID(),
};
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${body}.${sign(body)}`;
}
export function verifyToken(token: string): { userId: string } | null {
const [body, signature] = token.split(".");
if (!body || !signature) return null;
const expected = sign(body);
const givenBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (givenBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(givenBuffer, expectedBuffer)) return null;
try {
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null;
if (payload.exp < Date.now()) return null;
return { userId: payload.sub };
} catch {
return null;
}
}
/** Resolves the caller from an `Authorization: Bearer …` header. */
export async function userFromAuthHeader(
store: Store,
header: string | undefined,
): Promise<UserRecord | null> {
if (!header?.startsWith("Bearer ")) return null;
const verified = verifyToken(header.slice(7));
return verified ? store.findUserById(verified.userId) : null;
}
+53
View File
@@ -0,0 +1,53 @@
-- PSX Adventure Engine — Neon schema (GDD §9, Appendix B).
-- JSONB is used heavily so save shape and lobby settings can evolve without
-- a migration for every gameplay addition.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS saves (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
slot INT NOT NULL,
campaign TEXT NOT NULL,
title TEXT,
data JSONB NOT NULL,
playtime_seconds INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, slot, campaign)
);
CREATE INDEX IF NOT EXISTS saves_user_idx ON saves (user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS lobbies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
host_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'waiting'
CHECK (status IN ('waiting', 'starting', 'in_game', 'closed')),
max_players INT NOT NULL DEFAULT 4,
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '4 hours'
);
-- Lobby codes are looked up on every join attempt; keep it cheap.
CREATE INDEX IF NOT EXISTS lobbies_open_idx
ON lobbies (code) WHERE status <> 'closed';
CREATE TABLE IF NOT EXISTS lobby_players (
lobby_id UUID NOT NULL REFERENCES lobbies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_id INT NOT NULL,
is_ready BOOLEAN NOT NULL DEFAULT false,
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (lobby_id, user_id)
);
+268
View File
@@ -0,0 +1,268 @@
import { randomUUID } from "node:crypto";
import type { SaveData } from "@psx/shared";
import { MAX_PLAYERS } from "@psx/shared";
/**
* Persistence port.
*
* Milestone 2 still has no Neon credentials, so the in-memory implementation
* below is what runs. The interface mirrors `schema.sql` closely enough that a
* Neon-backed implementation is a matter of writing queries rather than
* reshaping call sites.
*/
export interface UserRecord {
id: string;
username: string;
email: string;
displayName: string | null;
passwordHash: string;
createdAt: string;
lastLogin: string | null;
}
export interface SaveRecord {
userId: string;
slot: number;
campaign: string;
title: string | null;
data: SaveData;
playtimeSeconds: number;
updatedAt: string;
}
export type LobbyStatus = "waiting" | "starting" | "in_game" | "closed";
export interface LobbyPlayerRecord {
userId: string;
peerId: number;
displayName: string;
isReady: boolean;
joinedAt: string;
}
export interface LobbyRecord {
id: string;
code: string;
hostId: string;
status: LobbyStatus;
maxPlayers: number;
settings: Record<string, unknown>;
createdAt: string;
expiresAt: string;
players: LobbyPlayerRecord[];
}
export interface Store {
createUser(input: {
username: string;
email: string;
displayName?: string;
passwordHash: string;
}): Promise<UserRecord>;
findUserByUsername(username: string): Promise<UserRecord | null>;
findUserById(id: string): Promise<UserRecord | null>;
touchLogin(id: string): Promise<void>;
putSave(record: Omit<SaveRecord, "updatedAt">): Promise<SaveRecord>;
getSave(userId: string, campaign: string, slot: number): Promise<SaveRecord | null>;
listSaves(userId: string): Promise<SaveRecord[]>;
deleteSave(userId: string, campaign: string, slot: number): Promise<boolean>;
createLobby(hostId: string, hostName: string, settings?: Record<string, unknown>): Promise<LobbyRecord>;
findLobbyByCode(code: string): Promise<LobbyRecord | null>;
joinLobby(code: string, userId: string, displayName: string): Promise<LobbyRecord | { error: string }>;
leaveLobby(code: string, userId: string): Promise<LobbyRecord | null>;
setReady(code: string, userId: string, isReady: boolean): Promise<LobbyRecord | null>;
setLobbyStatus(code: string, status: LobbyStatus): Promise<LobbyRecord | null>;
}
const saveKey = (userId: string, campaign: string, slot: number) =>
`${userId}:${campaign}:${slot}`;
/**
* Lobby codes are read aloud over voice chat, so the alphabet omits characters
* that sound or look alike: no O/0, no I/1, no S/5.
*/
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRTUVWXY2346789";
const CODE_LENGTH = 5;
export class InMemoryStore implements Store {
private readonly users = new Map<string, UserRecord>();
private readonly usersByUsername = new Map<string, string>();
private readonly saves = new Map<string, SaveRecord>();
private readonly lobbies = new Map<string, LobbyRecord>();
// --- users ---------------------------------------------------------------
async createUser(input: {
username: string;
email: string;
displayName?: string;
passwordHash: string;
}): Promise<UserRecord> {
const key = input.username.toLowerCase();
if (this.usersByUsername.has(key)) throw new Error("username already taken");
const user: UserRecord = {
id: randomUUID(),
username: input.username,
email: input.email,
displayName: input.displayName ?? input.username,
passwordHash: input.passwordHash,
createdAt: new Date().toISOString(),
lastLogin: null,
};
this.users.set(user.id, user);
this.usersByUsername.set(key, user.id);
return user;
}
async findUserByUsername(username: string): Promise<UserRecord | null> {
const id = this.usersByUsername.get(username.toLowerCase());
return id ? (this.users.get(id) ?? null) : null;
}
async findUserById(id: string): Promise<UserRecord | null> {
return this.users.get(id) ?? null;
}
async touchLogin(id: string): Promise<void> {
const user = this.users.get(id);
if (user) user.lastLogin = new Date().toISOString();
}
// --- saves ---------------------------------------------------------------
async putSave(record: Omit<SaveRecord, "updatedAt">): Promise<SaveRecord> {
const stored: SaveRecord = { ...record, updatedAt: new Date().toISOString() };
this.saves.set(saveKey(record.userId, record.campaign, record.slot), stored);
return stored;
}
async getSave(userId: string, campaign: string, slot: number): Promise<SaveRecord | null> {
return this.saves.get(saveKey(userId, campaign, slot)) ?? null;
}
async listSaves(userId: string): Promise<SaveRecord[]> {
return [...this.saves.values()]
.filter((record) => record.userId === userId)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async deleteSave(userId: string, campaign: string, slot: number): Promise<boolean> {
return this.saves.delete(saveKey(userId, campaign, slot));
}
// --- lobbies -------------------------------------------------------------
private generateCode(): string {
for (let attempt = 0; attempt < 50; attempt++) {
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
}
if (!this.lobbies.has(code)) return code;
}
throw new Error("could not allocate a free lobby code");
}
async createLobby(
hostId: string,
hostName: string,
settings: Record<string, unknown> = {},
): Promise<LobbyRecord> {
const now = Date.now();
const lobby: LobbyRecord = {
id: randomUUID(),
code: this.generateCode(),
hostId,
status: "waiting",
maxPlayers: MAX_PLAYERS,
settings,
createdAt: new Date(now).toISOString(),
expiresAt: new Date(now + 4 * 60 * 60 * 1000).toISOString(),
// The host is peer 0 by construction, which is what makes "peer 0 is
// authoritative" a safe assumption everywhere else in the stack.
players: [
{
userId: hostId,
peerId: 0,
displayName: hostName,
isReady: true,
joinedAt: new Date(now).toISOString(),
},
],
};
this.lobbies.set(lobby.code, lobby);
return lobby;
}
async findLobbyByCode(code: string): Promise<LobbyRecord | null> {
const lobby = this.lobbies.get(code.toUpperCase());
if (!lobby) return null;
if (Date.parse(lobby.expiresAt) < Date.now()) {
lobby.status = "closed";
}
return lobby;
}
async joinLobby(
code: string,
userId: string,
displayName: string,
): Promise<LobbyRecord | { error: string }> {
const lobby = await this.findLobbyByCode(code);
if (!lobby) return { error: "lobby not found" };
if (lobby.status === "closed") return { error: "lobby is closed" };
const existing = lobby.players.find((player) => player.userId === userId);
if (existing) return lobby; // rejoin is idempotent
if (lobby.players.length >= lobby.maxPlayers) return { error: "lobby is full" };
// Reuse the lowest free peer id so a 4-player lobby always spans 0..3 even
// after churn — the client indexes spawn points by peer id.
const taken = new Set(lobby.players.map((player) => player.peerId));
let peerId = 0;
while (taken.has(peerId)) peerId++;
lobby.players.push({
userId,
peerId,
displayName,
isReady: false,
joinedAt: new Date().toISOString(),
});
return lobby;
}
async leaveLobby(code: string, userId: string): Promise<LobbyRecord | null> {
const lobby = await this.findLobbyByCode(code);
if (!lobby) return null;
lobby.players = lobby.players.filter((player) => player.userId !== userId);
// Losing the host ends the session: the authoritative simulation lived in
// that browser, so there is nothing for the remaining peers to continue.
if (lobby.players.length === 0 || userId === lobby.hostId) {
lobby.status = "closed";
}
return lobby;
}
async setReady(code: string, userId: string, isReady: boolean): Promise<LobbyRecord | null> {
const lobby = await this.findLobbyByCode(code);
if (!lobby) return null;
const player = lobby.players.find((entry) => entry.userId === userId);
if (player) player.isReady = isReady;
return lobby;
}
async setLobbyStatus(code: string, status: LobbyStatus): Promise<LobbyRecord | null> {
const lobby = await this.findLobbyByCode(code);
if (!lobby) return null;
lobby.status = status;
return lobby;
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { Server } from "node:http";
import { serve } from "@hono/node-server";
import { createApp } from "./app.js";
import { SignalingServer } from "./ws/signaling.js";
/**
* Hono API + WebSocket signalling — GDD §4, §8, §9.
*
* Storage is in-memory until Neon credentials exist; `db/schema.sql` is already
* the target shape and `Store` is the seam.
*/
const { app, store } = createApp();
const port = Number(process.env["PORT"] ?? 8787);
const server = serve({ fetch: app.fetch, port }, (info) => {
console.log(`psx server listening on http://localhost:${info.port}`);
console.log(`signalling on ws://localhost:${info.port}/ws`);
});
// `serve` returns the underlying Node http server, which `ws` attaches to.
const signaling = new SignalingServer(server as unknown as Server, store);
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
signaling.close();
server.close(() => process.exit(0));
});
}
export { app, store };
+84
View File
@@ -0,0 +1,84 @@
import { Hono } from "hono";
import { hashPassword, issueToken, userFromAuthHeader, verifyPassword } from "../auth.js";
import type { Store } from "../db/store.js";
/** Accounts and sessions — GDD §9. */
const MIN_PASSWORD_LENGTH = 8;
const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{3,24}$/;
export function authRoutes(store: Store) {
const app = new Hono();
app.post("/register", async (context) => {
const body = (await context.req.json().catch(() => null)) as {
username?: unknown;
email?: unknown;
password?: unknown;
displayName?: unknown;
} | null;
const { username, email, password, displayName } = body ?? {};
if (typeof username !== "string" || !USERNAME_PATTERN.test(username)) {
return context.json({ error: "username must be 3-24 chars, alphanumeric/_/-" }, 400);
}
if (typeof email !== "string" || !email.includes("@")) {
return context.json({ error: "a valid email is required" }, 400);
}
if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
return context.json({ error: `password must be at least ${MIN_PASSWORD_LENGTH} chars` }, 400);
}
if (await store.findUserByUsername(username)) {
return context.json({ error: "username already taken" }, 409);
}
const user = await store.createUser({
username,
email,
displayName: typeof displayName === "string" ? displayName : username,
passwordHash: await hashPassword(password),
});
return context.json(
{ token: issueToken(user.id), user: publicUser(user) },
201,
);
});
app.post("/login", async (context) => {
const body = (await context.req.json().catch(() => null)) as {
username?: unknown;
password?: unknown;
} | null;
const { username, password } = body ?? {};
if (typeof username !== "string" || typeof password !== "string") {
return context.json({ error: "username and password are required" }, 400);
}
const user = await store.findUserByUsername(username);
// Same response whether the user is missing or the password is wrong, so
// this endpoint cannot be used to enumerate accounts.
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return context.json({ error: "invalid credentials" }, 401);
}
await store.touchLogin(user.id);
return context.json({ token: issueToken(user.id), user: publicUser(user) });
});
app.get("/me", async (context) => {
const user = await userFromAuthHeader(store, context.req.header("authorization"));
if (!user) return context.json({ error: "unauthorized" }, 401);
return context.json({ user: publicUser(user) });
});
return app;
}
/** Never let `passwordHash` reach a response body. */
function publicUser(user: { id: string; username: string; displayName: string | null }) {
return { id: user.id, username: user.username, displayName: user.displayName };
}
+111
View File
@@ -0,0 +1,111 @@
import { Hono } from "hono";
import { userFromAuthHeader } from "../auth.js";
import type { LobbyRecord, Store } from "../db/store.js";
/** Lobby lifecycle — GDD §9. Signalling itself lives in `src/ws/`. */
export function lobbyRoutes(store: Store) {
const app = new Hono();
app.post("/", async (context) => {
const user = await userFromAuthHeader(store, context.req.header("authorization"));
if (!user) return context.json({ error: "unauthorized" }, 401);
const body = (await context.req.json().catch(() => null)) as {
settings?: Record<string, unknown>;
} | null;
const lobby = await store.createLobby(
user.id,
user.displayName ?? user.username,
body?.settings ?? {},
);
return context.json(publicLobby(lobby), 201);
});
app.get("/:code", async (context) => {
const lobby = await store.findLobbyByCode(context.req.param("code"));
if (!lobby) return context.json({ error: "lobby not found" }, 404);
return context.json(publicLobby(lobby));
});
app.post("/:code/join", async (context) => {
const user = await userFromAuthHeader(store, context.req.header("authorization"));
if (!user) return context.json({ error: "unauthorized" }, 401);
const result = await store.joinLobby(
context.req.param("code"),
user.id,
user.displayName ?? user.username,
);
if ("error" in result) {
const status = result.error === "lobby not found" ? 404 : 409;
return context.json({ error: result.error }, status);
}
return context.json(publicLobby(result));
});
app.post("/:code/ready", async (context) => {
const user = await userFromAuthHeader(store, context.req.header("authorization"));
if (!user) return context.json({ error: "unauthorized" }, 401);
const body = (await context.req.json().catch(() => null)) as { isReady?: unknown } | null;
const lobby = await store.setReady(
context.req.param("code"),
user.id,
body?.isReady === true,
);
if (!lobby) return context.json({ error: "lobby not found" }, 404);
return context.json(publicLobby(lobby));
});
app.post("/:code/leave", async (context) => {
const user = await userFromAuthHeader(store, context.req.header("authorization"));
if (!user) return context.json({ error: "unauthorized" }, 401);
const lobby = await store.leaveLobby(context.req.param("code"), user.id);
if (!lobby) return context.json({ error: "lobby not found" }, 404);
return context.json(publicLobby(lobby));
});
app.post("/:code/start", async (context) => {
const user = await userFromAuthHeader(store, context.req.header("authorization"));
if (!user) return context.json({ error: "unauthorized" }, 401);
const lobby = await store.findLobbyByCode(context.req.param("code"));
if (!lobby) return context.json({ error: "lobby not found" }, 404);
if (lobby.hostId !== user.id) return context.json({ error: "only the host can start" }, 403);
const notReady = lobby.players.filter((player) => !player.isReady);
if (notReady.length > 0) {
return context.json(
{ error: "not everyone is ready", waitingOn: notReady.map((p) => p.displayName) },
409,
);
}
const started = await store.setLobbyStatus(lobby.code, "in_game");
return context.json(publicLobby(started!));
});
return app;
}
/** Strips internal user ids; peers address each other by peer id only. */
function publicLobby(lobby: LobbyRecord) {
return {
code: lobby.code,
status: lobby.status,
maxPlayers: lobby.maxPlayers,
settings: lobby.settings,
createdAt: lobby.createdAt,
expiresAt: lobby.expiresAt,
players: lobby.players.map((player) => ({
peerId: player.peerId,
displayName: player.displayName,
isReady: player.isReady,
isHost: player.userId === lobby.hostId,
})),
};
}
+55
View File
@@ -0,0 +1,55 @@
import { Hono } from "hono";
import { validateSaveData } from "@psx/shared";
import type { Store } from "../db/store.js";
/**
* Cloud save endpoints (GDD §9).
*
* Save payloads are validated with the shared schema before they are stored —
* the client is not trusted, and in co-op the host writes on behalf of the
* party, which widens the blast radius of a malformed payload.
*/
export function savesRoutes(store: Store) {
const app = new Hono();
app.get("/:campaign/:slot", async (context) => {
const userId = context.req.header("x-user-id");
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
const slot = Number(context.req.param("slot"));
if (!Number.isInteger(slot)) return context.json({ error: "slot must be an integer" }, 400);
const record = await store.getSave(userId, context.req.param("campaign"), slot);
return record ? context.json(record) : context.json({ error: "not found" }, 404);
});
app.put("/:campaign/:slot", async (context) => {
const userId = context.req.header("x-user-id");
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
const slot = Number(context.req.param("slot"));
if (!Number.isInteger(slot)) return context.json({ error: "slot must be an integer" }, 400);
const body = (await context.req.json().catch(() => null)) as { data?: unknown; title?: string } | null;
const validation = validateSaveData(body?.data);
if (!validation.ok) return context.json({ error: "invalid save", details: validation.errors }, 422);
const record = await store.putSave({
userId,
slot,
campaign: context.req.param("campaign"),
title: body?.title ?? null,
data: validation.value,
playtimeSeconds: validation.value.playtimeSeconds,
});
return context.json(record, 200);
});
app.get("/", async (context) => {
const userId = context.req.header("x-user-id");
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
return context.json(await store.listSaves(userId));
});
return app;
}
+251
View File
@@ -0,0 +1,251 @@
import { createServer, type Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import type { SignalMessage } from "@psx/shared";
import { PROTOCOL_VERSION } from "@psx/shared";
import { createApp } from "../app.js";
import { InMemoryStore } from "../db/store.js";
import { SignalingServer } from "./signaling.js";
/**
* End-to-end signalling tests against a real HTTP + WebSocket server.
*
* The impersonation test is the important one: the relay stamps `from` off the
* authenticated socket, so a peer claiming to be someone else must be ignored.
*/
let server: Server;
let signaling: SignalingServer;
let store: InMemoryStore;
let baseUrl: string;
let wsUrl: string;
/** Bridges Hono's fetch handler onto a plain Node server so `ws` can attach. */
function nodeAdapter(app: ReturnType<typeof createApp>["app"]): Server {
return createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const url = `http://localhost${req.url ?? "/"}`;
const hasBody = chunks.length > 0;
const request = new Request(url, {
method: req.method,
headers: req.headers as Record<string, string>,
...(hasBody ? { body: Buffer.concat(chunks) } : {}),
});
// Hono's `fetch` is sync-or-async depending on the route.
void Promise.resolve(app.fetch(request)).then(async (response: Response) => {
res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
res.end(Buffer.from(await response.arrayBuffer()));
});
});
});
}
beforeEach(async () => {
store = new InMemoryStore();
const { app } = createApp(store);
server = nodeAdapter(app);
signaling = new SignalingServer(server, store);
await new Promise<void>((resolve) => server.listen(0, resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
baseUrl = `http://localhost:${port}`;
wsUrl = `ws://localhost:${port}/ws`;
});
afterEach(async () => {
signaling.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function register(username: string): Promise<string> {
const response = await fetch(`${baseUrl}/auth/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, email: `${username}@example.com`, password: "hunter2hunter2" }),
});
expect(response.status).toBe(201);
return ((await response.json()) as { token: string }).token;
}
/** Opens a socket, sends `hello`, and resolves once `welcome` arrives. */
async function connect(token: string, lobbyCode: string) {
const socket = new WebSocket(wsUrl);
const inbox: SignalMessage[] = [];
await new Promise<void>((resolve, reject) => {
socket.on("open", resolve);
socket.on("error", reject);
});
socket.on("message", (raw) => inbox.push(JSON.parse(raw.toString()) as SignalMessage));
socket.send(JSON.stringify({ t: "hello", token, lobbyCode, protocol: PROTOCOL_VERSION }));
const welcome = await waitFor(inbox, (m) => m.t === "welcome" || m.t === "error");
return { socket, inbox, welcome };
}
/** Polls `inbox` until a matching message appears, or times out. */
async function waitFor(
inbox: SignalMessage[],
predicate: (message: SignalMessage) => boolean,
timeoutMs = 3000,
): Promise<SignalMessage> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const found = inbox.find(predicate);
if (found) return found;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timed out; inbox was ${JSON.stringify(inbox)}`);
}
describe("signalling", () => {
it("rejects a socket with an invalid token", async () => {
const hostToken = await register("host");
const created = await fetch(`${baseUrl}/lobbies`, {
method: "POST",
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
body: "{}",
});
const { code } = (await created.json()) as { code: string };
const { welcome, socket } = await connect("not-a-real-token", code);
expect(welcome.t).toBe("error");
socket.close();
});
it("refuses a peer that has not joined the lobby over REST", async () => {
const hostToken = await register("host");
const strangerToken = await register("stranger");
const created = await fetch(`${baseUrl}/lobbies`, {
method: "POST",
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
body: "{}",
});
const { code } = (await created.json()) as { code: string };
const { welcome, socket } = await connect(strangerToken, code);
expect(welcome.t).toBe("error");
socket.close();
});
it("welcomes host as peer 0 and announces a guest joining", async () => {
const hostToken = await register("host");
const guestToken = await register("guest");
const created = await fetch(`${baseUrl}/lobbies`, {
method: "POST",
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
body: "{}",
});
const { code } = (await created.json()) as { code: string };
await fetch(`${baseUrl}/lobbies/${code}/join`, {
method: "POST",
headers: { authorization: `Bearer ${guestToken}` },
});
const host = await connect(hostToken, code);
expect(host.welcome).toMatchObject({ t: "welcome", peerId: 0, isHost: true });
const guest = await connect(guestToken, code);
expect(guest.welcome).toMatchObject({ t: "welcome", peerId: 1, isHost: false });
const announced = await waitFor(host.inbox, (m) => m.t === "peer-joined");
expect(announced).toMatchObject({ t: "peer-joined", peer: { peerId: 1 } });
host.socket.close();
guest.socket.close();
});
it("relays SDP between peers and stamps the true sender", async () => {
const hostToken = await register("host");
const guestToken = await register("guest");
const created = await fetch(`${baseUrl}/lobbies`, {
method: "POST",
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
body: "{}",
});
const { code } = (await created.json()) as { code: string };
await fetch(`${baseUrl}/lobbies/${code}/join`, {
method: "POST",
headers: { authorization: `Bearer ${guestToken}` },
});
const host = await connect(hostToken, code);
const guest = await connect(guestToken, code);
host.socket.send(
JSON.stringify({
t: "signal",
// Lie about the sender; the relay must overwrite this with peer 0.
from: 99,
to: 1,
payload: { sdp: { type: "offer", sdp: "v=0 fake" } },
}),
);
const relayed = await waitFor(guest.inbox, (m) => m.t === "signal");
expect(relayed).toMatchObject({
t: "signal",
from: 0,
to: 1,
payload: { sdp: { type: "offer", sdp: "v=0 fake" } },
});
host.socket.close();
guest.socket.close();
});
it("rejects a protocol version mismatch", async () => {
const hostToken = await register("host");
const created = await fetch(`${baseUrl}/lobbies`, {
method: "POST",
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
body: "{}",
});
const { code } = (await created.json()) as { code: string };
const socket = new WebSocket(wsUrl);
const inbox: SignalMessage[] = [];
await new Promise<void>((resolve) => socket.on("open", () => resolve()));
socket.on("message", (raw) => inbox.push(JSON.parse(raw.toString()) as SignalMessage));
socket.send(JSON.stringify({ t: "hello", token: hostToken, lobbyCode: code, protocol: 999 }));
const error = await waitFor(inbox, (m) => m.t === "error");
expect(error).toMatchObject({ t: "error" });
expect((error as { message: string }).message).toContain("protocol mismatch");
socket.close();
});
it("tells remaining peers when the host disconnects", async () => {
const hostToken = await register("host");
const guestToken = await register("guest");
const created = await fetch(`${baseUrl}/lobbies`, {
method: "POST",
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
body: "{}",
});
const { code } = (await created.json()) as { code: string };
await fetch(`${baseUrl}/lobbies/${code}/join`, {
method: "POST",
headers: { authorization: `Bearer ${guestToken}` },
});
const host = await connect(hostToken, code);
const guest = await connect(guestToken, code);
host.socket.close();
const left = await waitFor(guest.inbox, (m) => m.t === "peer-left");
expect(left).toMatchObject({ t: "peer-left", peerId: 0 });
guest.socket.close();
});
});
+240
View File
@@ -0,0 +1,240 @@
import type { Server } from "node:http";
import { WebSocketServer, type WebSocket } from "ws";
import type { PeerInfo, SignalMessage, SignalPayload } from "@psx/shared";
import { validateSignalMessage } from "@psx/shared";
import { verifyToken } from "../auth.js";
import type { LobbyRecord, Store } from "../db/store.js";
/**
* SDP/ICE exchange — GDD §8.
*
* The server is a relay and nothing more. It never sees gameplay: once peers
* have exchanged descriptors they talk over data channels directly, and this
* socket carries only membership changes for the rest of the session.
*
* Two rules make the relay safe to expose:
* 1. `from` on a relayed signal is stamped from the authenticated socket,
* never read from the payload, so peers cannot impersonate one another.
* 2. A socket may only address peers inside the lobby it authenticated into.
*/
interface Connection {
socket: WebSocket;
peerId: number;
userId: string;
lobbyCode: string;
isHost: boolean;
alive: boolean;
}
const HEARTBEAT_INTERVAL_MS = 30_000;
export class SignalingServer {
private readonly wss: WebSocketServer;
/** lobby code → peer id → connection */
private readonly rooms = new Map<string, Map<number, Connection>>();
private readonly heartbeat: NodeJS.Timeout;
constructor(
server: Server,
private readonly store: Store,
path = "/ws",
) {
this.wss = new WebSocketServer({ server, path });
this.wss.on("connection", (socket) => this.onConnection(socket));
// Half-open TCP connections would otherwise linger as phantom lobby members.
this.heartbeat = setInterval(() => {
for (const room of this.rooms.values()) {
for (const connection of room.values()) {
if (!connection.alive) {
connection.socket.terminate();
continue;
}
connection.alive = false;
connection.socket.ping();
}
}
}, HEARTBEAT_INTERVAL_MS);
}
private onConnection(socket: WebSocket): void {
let connection: Connection | null = null;
socket.on("pong", () => {
if (connection) connection.alive = true;
});
socket.on("message", async (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return send(socket, { t: "error", message: "malformed JSON" });
}
const validation = validateSignalMessage(parsed);
if (!validation.ok) {
return send(socket, { t: "error", message: validation.errors.join("; ") });
}
const message = validation.value;
// Everything except `hello` requires an authenticated socket.
if (!connection && message.t !== "hello") {
return send(socket, { t: "error", message: "expected hello first" });
}
switch (message.t) {
case "hello": {
if (connection) return send(socket, { t: "error", message: "already authenticated" });
connection = await this.authenticate(socket, message.token, message.lobbyCode);
break;
}
case "signal":
this.relaySignal(connection!, message.to, message.payload);
break;
case "ready":
await this.setReady(connection!, message.isReady);
break;
default:
send(socket, { t: "error", message: `unsupported message: ${message.t}` });
}
});
socket.on("close", () => {
if (connection) void this.disconnect(connection);
});
}
private async authenticate(
socket: WebSocket,
token: string,
lobbyCode: string,
): Promise<Connection | null> {
const verified = verifyToken(token);
if (!verified) {
send(socket, { t: "error", message: "invalid or expired token" });
socket.close();
return null;
}
const lobby = await this.store.findLobbyByCode(lobbyCode);
if (!lobby || lobby.status === "closed") {
send(socket, { t: "error", message: "lobby not found or closed" });
socket.close();
return null;
}
// Membership is established over REST (`POST /lobbies/:code/join`) so that
// capacity and duplicate-join rules live in exactly one place.
const member = lobby.players.find((player) => player.userId === verified.userId);
if (!member) {
send(socket, { t: "error", message: "join the lobby before connecting" });
socket.close();
return null;
}
const room = this.rooms.get(lobby.code) ?? new Map<number, Connection>();
this.rooms.set(lobby.code, room);
// A reconnect from the same peer supersedes the stale socket.
room.get(member.peerId)?.socket.terminate();
const connection: Connection = {
socket,
peerId: member.peerId,
userId: verified.userId,
lobbyCode: lobby.code,
isHost: verified.userId === lobby.hostId,
alive: true,
};
room.set(member.peerId, connection);
send(socket, {
t: "welcome",
peerId: connection.peerId,
isHost: connection.isHost,
peers: this.peerList(lobby, room),
});
this.broadcast(
lobby.code,
{ t: "peer-joined", peer: this.peerInfo(lobby, member.peerId) },
connection.peerId,
);
return connection;
}
private relaySignal(from: Connection, to: number, payload: SignalPayload): void {
const target = this.rooms.get(from.lobbyCode)?.get(to);
if (!target) return;
send(target.socket, {
t: "signal",
// Stamped from the authenticated socket — never trusted from the payload.
from: from.peerId,
to,
payload,
});
}
private async setReady(connection: Connection, isReady: boolean): Promise<void> {
const lobby = await this.store.setReady(connection.lobbyCode, connection.userId, isReady);
if (!lobby) return;
this.broadcast(connection.lobbyCode, {
t: "peer-updated",
peer: this.peerInfo(lobby, connection.peerId),
});
}
private async disconnect(connection: Connection): Promise<void> {
const room = this.rooms.get(connection.lobbyCode);
// Guard against a superseded socket removing its replacement.
if (room?.get(connection.peerId)?.socket === connection.socket) {
room.delete(connection.peerId);
}
if (room && room.size === 0) this.rooms.delete(connection.lobbyCode);
await this.store.leaveLobby(connection.lobbyCode, connection.userId);
this.broadcast(connection.lobbyCode, { t: "peer-left", peerId: connection.peerId });
// The authoritative simulation lived in the host's browser, so its departure
// ends the session for everyone rather than silently freezing the world.
if (connection.isHost) {
this.broadcast(connection.lobbyCode, { t: "error", message: "the host left the session" });
for (const remaining of room?.values() ?? []) remaining.socket.close();
this.rooms.delete(connection.lobbyCode);
}
}
private peerInfo(lobby: LobbyRecord, peerId: number): PeerInfo {
const player = lobby.players.find((entry) => entry.peerId === peerId);
return {
peerId,
displayName: player?.displayName ?? `Player ${peerId}`,
isHost: player ? player.userId === lobby.hostId : false,
isReady: player?.isReady ?? false,
};
}
private peerList(lobby: LobbyRecord, room: Map<number, Connection>): PeerInfo[] {
return [...room.keys()].map((peerId) => this.peerInfo(lobby, peerId));
}
private broadcast(lobbyCode: string, message: SignalMessage, exceptPeerId?: number): void {
for (const [peerId, connection] of this.rooms.get(lobbyCode) ?? []) {
if (peerId === exceptPeerId) continue;
send(connection.socket, message);
}
}
close(): void {
clearInterval(this.heartbeat);
this.wss.close();
}
}
function send(socket: WebSocket, message: SignalMessage): void {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}