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:
@@ -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