feat: enforce character ownership — players own their characters, DMs can modify any

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 00:31:49 -04:00
parent 56d46166cd
commit fae1e75f6f

View file

@ -5,6 +5,7 @@ import type { Server } from "socket.io";
import db from "../db.js";
import { broadcastToCampaign } from "../socket.js";
import { parseJson } from "../utils/parseJson.js";
import { requireAuth } from "../auth/middleware.js";
type CampaignParams = ParamsDictionary & { campaignId: string };
@ -29,6 +30,24 @@ function parseTalents(rows: RowDataPacket[]) {
return rows.map((r) => ({ ...r, effect: parseJson(r.effect) }));
}
async function canModifyCharacter(characterId: string, userId: number): Promise<boolean> {
// DMs can modify any character in their campaign
const [dmCheck] = await db.execute<RowDataPacket[]>(
`SELECT cm.role FROM campaign_members cm
JOIN characters c ON c.campaign_id = cm.campaign_id
WHERE c.id = ? AND cm.user_id = ? AND cm.role = 'dm'`,
[characterId, userId]
);
if (dmCheck.length > 0) return true;
// Players can only modify their own characters
const [ownerCheck] = await db.execute<RowDataPacket[]>(
"SELECT id FROM characters WHERE id = ? AND user_id = ?",
[characterId, userId]
);
return ownerCheck.length > 0;
}
async function enrichCharacters(characters: RowDataPacket[]) {
return Promise.all(
characters.map(async (char) => {
@ -72,7 +91,7 @@ router.get<CampaignParams>("/", async (req, res) => {
});
// POST /api/campaigns/:campaignId/characters
router.post<CampaignParams>("/", async (req, res) => {
router.post<CampaignParams>("/", requireAuth, async (req, res) => {
try {
const { campaignId } = req.params;
const { name, class: charClass, ancestry, hp_max } = req.body;
@ -82,12 +101,15 @@ router.post<CampaignParams>("/", async (req, res) => {
return;
}
const userId = req.user?.userId ?? null;
const [result] = await db.execute<ResultSetHeader>(
`INSERT INTO characters
(campaign_id, name, class, ancestry, hp_current, hp_max, color)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
(campaign_id, user_id, name, class, ancestry, hp_current, hp_max, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
campaignId,
userId,
name.trim(),
charClass ?? "Fighter",
ancestry ?? "Human",
@ -129,9 +151,16 @@ router.post<CampaignParams>("/", async (req, res) => {
});
// PATCH /api/characters/:id
router.patch("/:id", async (req, res) => {
router.patch("/:id", requireAuth, async (req, res) => {
try {
const { id } = req.params;
const allowed = await canModifyCharacter(String(id), req.user!.userId);
if (!allowed) {
res.status(403).json({ error: "Not authorized to modify this character" });
return;
}
const allowedFields = [
"name", "class", "ancestry", "level", "xp", "hp_current", "hp_max",
"ac", "alignment", "title", "notes", "background", "deity", "languages",
@ -182,7 +211,7 @@ router.patch("/:id", async (req, res) => {
});
// DELETE /api/characters/:id
router.delete("/:id", async (req, res) => {
router.delete("/:id", requireAuth, async (req, res) => {
try {
const [rows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE id = ?",
@ -193,6 +222,12 @@ router.delete("/:id", async (req, res) => {
return;
}
const allowed = await canModifyCharacter(String(req.params.id), req.user!.userId);
if (!allowed) {
res.status(403).json({ error: "Not authorized to delete this character" });
return;
}
await db.execute("DELETE FROM characters WHERE id = ?", [req.params.id]);
const io: Server = req.app.get("io");