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
+49
View File
@@ -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) => {