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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user