feat: add JWT cookie auth to Socket.io connections and enforce DM-only atmosphere

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 00:33:35 -04:00
parent fae1e75f6f
commit a37600fdfa
3 changed files with 78 additions and 24 deletions

View file

@ -17,6 +17,7 @@
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
@ -504,6 +505,13 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",

View file

@ -19,6 +19,7 @@
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",

View file

@ -1,6 +1,9 @@
import { Server } from "socket.io";
import { parse as parseCookie } from "cookie";
import type { RowDataPacket } from "mysql2";
import db from "./db.js";
import { rollDice } from "./dice.js";
import { verifyToken } from "./auth/jwt.js";
interface EffectState {
active: boolean;
@ -16,6 +19,19 @@ interface AtmosphereUpdateData {
}
export function setupSocket(io: Server) {
// Verify JWT from cookie on every connection
io.use((socket, next) => {
try {
const cookies = parseCookie(socket.handshake.headers.cookie ?? "");
const token = cookies["darkwatch_token"];
if (!token) throw new Error("No token");
socket.data.user = verifyToken(token);
next();
} catch {
next(new Error("Unauthorized"));
}
});
io.on("connection", (socket) => {
socket.on("join-campaign", (campaignId: string) => {
socket.join(`campaign:${campaignId}`);
@ -27,7 +43,7 @@ export function setupSocket(io: Server) {
socket.on(
"roll:request",
(data: {
async (data: {
campaignId: number;
characterId?: number;
characterName?: string;
@ -39,6 +55,30 @@ export function setupSocket(io: Server) {
advantage?: boolean;
disadvantage?: boolean;
}) => {
// Verify user is a member of this campaign
const userId = socket.data.user?.userId;
const [memberRows] = await db.execute<RowDataPacket[]>(
"SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
[data.campaignId, userId]
);
if (memberRows.length === 0) {
socket.emit("roll:error", { error: "Not a campaign member" });
return;
}
const isDM = memberRows[0].role === "dm";
// If rolling for a specific character, verify ownership (DMs can roll any)
if (data.characterId && !isDM) {
const [charRows] = await db.execute<RowDataPacket[]>(
"SELECT user_id FROM characters WHERE id = ?",
[data.characterId]
);
if (charRows.length === 0 || charRows[0].user_id !== userId) {
socket.emit("roll:error", { error: "Cannot roll for another player's character" });
return;
}
}
const result = rollDice(data.dice, {
advantage: data.advantage,
disadvantage: data.disadvantage,
@ -49,34 +89,29 @@ export function setupSocket(io: Server) {
return;
}
// Detect nat 20
const isD20Roll = data.dice.match(/d20/i);
let nat20 = false;
if (isD20Roll && result.rolls.length > 0) {
if (data.advantage) {
// With advantage, nat20 if the chosen (higher) die is 20
nat20 = Math.max(...result.rolls) === 20;
} else if (data.disadvantage) {
// With disadvantage, nat20 if the chosen (lower) die is 20
nat20 = Math.min(...result.rolls) === 20;
} else {
nat20 = result.rolls[0] === 20;
}
}
const row = db
.prepare(
`
INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
const [insertResult] = await db.execute<import("mysql2").ResultSetHeader>(
`INSERT INTO roll_log
(campaign_id, character_id, character_name, character_color, type, label,
dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.campaignId,
data.characterId ?? null,
data.characterName || "Roll",
data.characterColor || "",
data.type || "custom",
data.characterName ?? "Roll",
data.characterColor ?? "",
data.type ?? "custom",
data.label,
data.dice,
JSON.stringify(result.rolls),
@ -85,25 +120,35 @@ export function setupSocket(io: Server) {
data.advantage ? 1 : 0,
data.disadvantage ? 1 : 0,
nat20 ? 1 : 0,
]
);
const saved = db
.prepare("SELECT * FROM roll_log WHERE id = ?")
.get(row.lastInsertRowid) as Record<string, unknown>;
const [savedRows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM roll_log WHERE id = ?",
[insertResult.insertId]
);
const broadcast = {
...saved,
...savedRows[0],
rolls: result.rolls,
advantage: data.advantage || false,
disadvantage: data.disadvantage || false,
advantage: data.advantage ?? false,
disadvantage: data.disadvantage ?? false,
nat20,
};
io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast);
},
}
);
socket.on("atmosphere:update", (data: AtmosphereUpdateData) => {
socket.on("atmosphere:update", async (data: AtmosphereUpdateData) => {
// Only DMs can broadcast atmosphere changes
const userId = socket.data.user?.userId;
const [rows] = await db.execute<RowDataPacket[]>(
"SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
[data.campaignId, userId]
);
if (rows.length === 0 || rows[0].role !== "dm") return;
const { campaignId, ...atmosphere } = data;
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
});