P15b: server data model for shared campaigns + member-owned characters

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:39:49 +02:00
parent 9b5c63f23c
commit a28183326e
3 changed files with 254 additions and 0 deletions
+58
View File
@@ -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);
});
});
+147
View File
@@ -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<string, CloudCampaign>();
private characters = new Map<string, CloudCharacter>();
private byInvite = new Map<string, string>();
private queue: Promise<unknown> = 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<void> {
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<void> {
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<void>;
}
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<CloudCampaign> {
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<Array<CloudCampaign & { role: 'owner' | 'member' }>> {
await this.load();
const out: Array<CloudCampaign & { role: 'owner' | 'member' }> = [];
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<CloudCampaign | null> {
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<CloudCharacter | null> {
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<CloudCharacter[]> {
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<boolean> {
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<number> {
await this.load();
let bytes = 0;
for (const c of this.characters.values()) if (c.ownerUserId === userId) bytes += Buffer.byteLength(c.data);
return bytes;
}
}
+49
View File
@@ -6,6 +6,7 @@ import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { RoomHub, type Sender } from './rooms'; import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts'; import { AccountStore } from './accounts';
import { CloudStore } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787); const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); 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 hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR); const accounts = new AccountStore(DATA_DIR);
void accounts.load(); void accounts.load();
const cloud = new CloudStore(DATA_DIR);
void cloud.load();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper)); app.addHook('onClose', async () => clearInterval(sweeper));
@@ -70,6 +73,52 @@ export function buildServer() {
return reply.header('content-type', 'application/json').send(blob); 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(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
void app.register(async (instance) => { void app.register(async (instance) => {