From a28183326ed12328a949ef2350d492e6b7fccf23 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 17:39:49 +0200 Subject: [PATCH] P15b: server data model for shared campaigns + member-owned characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CloudStore (server/src/campaigns.ts): file-backed campaigns (owner + invite-code members) and characters owned by the player who published them. Only the owner updates a character (last-write-wins); the campaign owner gets read access to all and may remove. Usage bytes per user for later quota tracking. +3 tests. - API: POST/GET /api/campaigns, POST /api/campaigns/join, PUT /api/characters, GET /api/campaigns/:id/characters, DELETE /api/characters/:id, GET /api/usage — all bearer-authed via the existing accounts. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/campaigns.test.ts | 58 ++++++++++++++ server/src/campaigns.ts | 147 +++++++++++++++++++++++++++++++++++ server/src/index.ts | 49 ++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 server/src/campaigns.test.ts create mode 100644 server/src/campaigns.ts diff --git a/server/src/campaigns.test.ts b/server/src/campaigns.test.ts new file mode 100644 index 0000000..41b6ad8 --- /dev/null +++ b/server/src/campaigns.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { CloudStore } from './campaigns'; + +describe('CloudStore', () => { + let dir: string; + let store: CloudStore; + beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cloud-')); store = new CloudStore(dir); }); + + it('creates a campaign and lets a player join by invite', async () => { + const c = await store.createCampaign('gm1', 'Strahd', '5e'); + expect(c.ownerUserId).toBe('gm1'); + expect(c.inviteCode).toHaveLength(6); + expect(store.isMember(c.id, 'gm1')).toBe(true); + + expect(await store.joinByInvite('p1', 'ZZZZZZ')).toBeNull(); // bad code + const joined = await store.joinByInvite('p1', c.inviteCode); + expect(joined?.id).toBe(c.id); + expect(store.isMember(c.id, 'p1')).toBe(true); + expect(store.isMember(c.id, 'stranger')).toBe(false); + + const forP1 = await store.listForUser('p1'); + expect(forP1[0]).toMatchObject({ id: c.id, role: 'member' }); + expect((await store.listForUser('gm1'))[0]).toMatchObject({ role: 'owner' }); + }); + + it('enforces character ownership on upsert', async () => { + const c = await store.createCampaign('gm1', 'C', '5e'); + await store.joinByInvite('p1', c.inviteCode); + await store.joinByInvite('p2', c.inviteCode); + + expect(await store.putCharacter('stranger', c.id, { id: 'ch1', name: 'X', data: '{}' })).toBeNull(); // not a member + const r = await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' }); + expect(r?.ownerUserId).toBe('p1'); + expect(await store.putCharacter('p2', c.id, { id: 'ch1', name: 'hijack', data: '{}' })).toBeNull(); // p2 can't update p1's char + + const all = await store.listCharacters(c.id); + expect(all).toHaveLength(1); + expect(all[0]!.name).toBe('Lia'); + expect(await store.usageBytes('p1')).toBeGreaterThan(0); + }); + + it('lets the char owner or campaign owner delete; persists across instances', async () => { + const c = await store.createCampaign('gm1', 'C', '5e'); + await store.joinByInvite('p1', c.inviteCode); + await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{}' }); + + expect(await store.removeCharacter('p2', 'ch1')).toBe(false); // unrelated user + expect(await store.removeCharacter('gm1', 'ch1')).toBe(true); // campaign owner may remove + + const store2 = new CloudStore(dir); + await store2.load(); + expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id); + expect(await store2.listCharacters(c.id)).toHaveLength(0); + }); +}); diff --git a/server/src/campaigns.ts b/server/src/campaigns.ts new file mode 100644 index 0000000..4df43a4 --- /dev/null +++ b/server/src/campaigns.ts @@ -0,0 +1,147 @@ +import crypto from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * File-backed cloud store for shared campaigns + member-owned characters. Sits + * alongside AccountStore (users). A campaign has an owner (the GM) and members + * (players who joined by invite code). Characters are owned by the player who + * published them — only the owner updates them (last-write-wins); the GM gets + * read access to all of a campaign's characters. Single-process; writes are + * serialised. Swap for a real DB later without changing the wire shape. + */ + +export interface CloudCampaign { + id: string; + ownerUserId: string; + name: string; + system: string; + inviteCode: string; + members: string[]; // userIds (excludes the owner) + createdAt: number; + updatedAt: number; +} + +export interface CloudCharacter { + id: string; + campaignId: string; + ownerUserId: string; + name: string; + data: string; // serialized character JSON + updatedAt: number; +} + +const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +export class CloudStore { + private campaigns = new Map(); + private characters = new Map(); + private byInvite = new Map(); + private queue: Promise = Promise.resolve(); + private loaded = false; + constructor(private dir: string, private now: () => number = () => Date.now()) {} + + private file() { return path.join(this.dir, 'cloud.json'); } + + async load(): Promise { + if (this.loaded) return; + this.loaded = true; + try { + const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] }; + for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); } + for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch); + } catch { /* no file yet */ } + } + + private persist(): Promise { + this.queue = this.queue.then(async () => { + await fs.mkdir(this.dir, { recursive: true }); + const tmp = `${this.file()}.tmp`; + await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] })); + await fs.rename(tmp, this.file()); + }).catch(() => {}); + return this.queue as Promise; + } + + private mintInvite(): string { + for (let i = 0; i < 50; i++) { + const code = Array.from({ length: 6 }, () => INVITE_ALPHABET[crypto.randomInt(INVITE_ALPHABET.length)]).join(''); + if (!this.byInvite.has(code)) return code; + } + return crypto.randomBytes(4).toString('hex').toUpperCase(); + } + + async createCampaign(ownerUserId: string, name: string, system: string): Promise { + await this.load(); + const c: CloudCampaign = { + id: crypto.randomUUID(), ownerUserId, name: name.slice(0, 120) || 'Campaign', system: system === 'pf2e' ? 'pf2e' : '5e', + inviteCode: this.mintInvite(), members: [], createdAt: this.now(), updatedAt: this.now(), + }; + this.campaigns.set(c.id, c); + this.byInvite.set(c.inviteCode, c.id); + await this.persist(); + return c; + } + + isMember(campaignId: string, userId: string): boolean { + const c = this.campaigns.get(campaignId); + return !!c && (c.ownerUserId === userId || c.members.includes(userId)); + } + isOwner(campaignId: string, userId: string): boolean { + return this.campaigns.get(campaignId)?.ownerUserId === userId; + } + + async listForUser(userId: string): Promise> { + await this.load(); + const out: Array = []; + for (const c of this.campaigns.values()) { + if (c.ownerUserId === userId) out.push({ ...c, role: 'owner' }); + else if (c.members.includes(userId)) out.push({ ...c, role: 'member' }); + } + return out; + } + + async joinByInvite(userId: string, inviteCode: string): Promise { + await this.load(); + const id = this.byInvite.get(inviteCode.trim().toUpperCase()); + const c = id ? this.campaigns.get(id) : undefined; + if (!c) return null; + if (c.ownerUserId !== userId && !c.members.includes(userId)) { c.members.push(userId); c.updatedAt = this.now(); await this.persist(); } + return c; + } + + /** Upsert a character the user owns. Returns null if they're not a member or don't own an existing record. */ + async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise { + await this.load(); + if (!this.isMember(campaignId, userId)) return null; + const existing = this.characters.get(char.id); + if (existing && existing.ownerUserId !== userId) return null; // only the owner may update + const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() }; + this.characters.set(record.id, record); + await this.persist(); + return record; + } + + async listCharacters(campaignId: string): Promise { + await this.load(); + return [...this.characters.values()].filter((c) => c.campaignId === campaignId).sort((a, b) => a.name.localeCompare(b.name)); + } + + async removeCharacter(userId: string, characterId: string): Promise { + await this.load(); + const ch = this.characters.get(characterId); + if (!ch) return false; + if (ch.ownerUserId !== userId && !this.isOwner(ch.campaignId, userId)) return false; // owner of char or campaign owner + this.characters.delete(characterId); + await this.persist(); + return true; + } + + /** Total bytes a user is responsible for (their characters' data) — for usage tracking. */ + async usageBytes(userId: string): Promise { + await this.load(); + let bytes = 0; + for (const c of this.characters.values()) if (c.ownerUserId === userId) bytes += Buffer.byteLength(c.data); + return bytes; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 7c9b6d4..f7e00d8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import type { FastifyRequest } from 'fastify'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { RoomHub, type Sender } from './rooms'; import { AccountStore } from './accounts'; +import { CloudStore } from './campaigns'; const PORT = Number(process.env.PORT ?? 8787); const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); @@ -27,6 +28,8 @@ export function buildServer() { const hub = new RoomHub(); const accounts = new AccountStore(DATA_DIR); void accounts.load(); + const cloud = new CloudStore(DATA_DIR); + void cloud.load(); const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); app.addHook('onClose', async () => clearInterval(sweeper)); @@ -70,6 +73,52 @@ export function buildServer() { return reply.header('content-type', 'application/json').send(blob); }); + // ---- shared cloud campaigns + member-owned characters ---- + const userOf = (req: FastifyRequest) => accounts.userByToken(bearer(req)); + app.post('/api/campaigns', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const { name, system } = (req.body ?? {}) as { name?: string; system?: string }; + const c = await cloud.createCampaign(u.id, String(name ?? ''), String(system ?? '5e')); + return { id: c.id, name: c.name, system: c.system, inviteCode: c.inviteCode, role: 'owner' }; + }); + app.get('/api/campaigns', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const list = await cloud.listForUser(u.id); + return list.map((c) => ({ id: c.id, name: c.name, system: c.system, role: c.role, ...(c.role === 'owner' ? { inviteCode: c.inviteCode } : {}) })); + }); + app.post('/api/campaigns/join', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const { inviteCode } = (req.body ?? {}) as { inviteCode?: string }; + const c = await cloud.joinByInvite(u.id, String(inviteCode ?? '')); + if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' }); + return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' }; + }); + app.put('/api/characters', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } }; + if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' }); + const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data }); + if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' }); + return { ok: true, updatedAt: r.updatedAt }; + }); + app.get('/api/campaigns/:id/characters', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const id = (req.params as { id: string }).id; + await cloud.load(); + if (!cloud.isMember(id, u.id)) return reply.code(403).send({ error: 'forbidden' }); + const chars = await cloud.listCharacters(id); + return chars.map((c) => ({ id: c.id, name: c.name, ownerUserId: c.ownerUserId, mine: c.ownerUserId === u.id, data: c.data, updatedAt: c.updatedAt })); + }); + app.delete('/api/characters/:id', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const ok = await cloud.removeCharacter(u.id, (req.params as { id: string }).id); + return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' }); + }); + app.get('/api/usage', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + return { bytes: await cloud.usageBytes(u.id) }; + }); + void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); void app.register(async (instance) => {