Files
Dune/app/api/rooms/[code]/route.ts
T
Quintine 98b1b3261b Add administrative room close and reopen controls
Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped.

Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
2026-09-22 12:31:02 +10:00

144 lines
4.6 KiB
TypeScript

import { requestOrigin } from '@/lib/request-origin';
import {
readSeatView,
authenticate,
joinRoom,
act,
RoomEntryError,
needsAutomaticRoomRecovery,
type SeatAuth,
} from '@/db/rooms';
import { RuleError } from '@/game/engine';
import type { FactionId } from '@/game/catalog';
import { env, waitUntil } from 'cloudflare:workers';
import { resumeRoom } from '@/db/room-continuation';
const codeOf = (req: Request) => {
const code = new URL(req.url).pathname.split('/').pop()!.toUpperCase();
if (!/^[A-Z2-9]{8}$/.test(code))
throw new RuleError('Enter the eight-character room code.');
return code;
};
const tokenOf = (req: Request, code: string) =>
req.headers
.get('cookie')
?.split(';')
.map((x) => x.trim())
.find((x) => x.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const failure = (e: unknown) =>
Response.json(
{
...(e instanceof RoomEntryError ? { code: e.code } : {}),
error:
e instanceof RuleError
? e.message
: e instanceof SyntaxError
? 'Invalid JSON.'
: 'The game server could not complete this request.',
},
{
status:
e instanceof RoomEntryError
? e.status
: e instanceof RuleError
? 409
: e instanceof SyntaxError
? 400
: 500,
headers: { 'Cache-Control': 'no-store' },
},
);
export async function GET(req: Request) {
try {
const code = codeOf(req),
id = await authenticate(code, tokenOf(req, code));
const view = await readSeatView(code, id);
// Reconnect resumes persisted automatic effects/acknowledgements and AI work
// after an interruption. Actual human choices remain engine-owned decisions.
if (!view.roomControl?.paused && !view.roomControl?.closed && (view.botsPending || needsAutomaticRoomRecovery(view)))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (e) {
return failure(e);
}
}
export async function POST(req: Request) {
try {
if (
req.headers.get('origin') &&
req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
)
return Response.json(
{ error: 'Invalid request origin.' },
{ status: 403 },
);
const code = codeOf(req);
const raw = await req.text();
if (raw.length > 8000)
return Response.json({ error: 'Request too large.' }, { status: 413 });
const body = JSON.parse(raw);
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new RuleError('The request is invalid.');
if (body.type === 'join') {
const existing = tokenOf(req, code);
let auth: SeatAuth | null = null;
if (existing) {
try {
auth = await authenticate(code, existing);
} catch (error) {
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
}
}
if (body.entry === undefined && auth) {
return Response.json(await readSeatView(code, auth), {
headers: { 'Cache-Control': 'no-store' },
});
}
if (typeof body.name !== 'string' || typeof body.faction !== 'string')
throw new RuleError('Enter your name and faction.');
const r = await joinRoom(
code,
body.name,
body.faction as FactionId,
body.entry,
auth,
);
return Response.json(
{
...r.view,
...(r.entryReceipt ? { entryReceipt: r.entryReceipt } : {}),
...(r.alreadySeated ? { alreadySeated: true } : {}),
},
{
headers: {
...(r.token
? {
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
}
: {}),
'Cache-Control': 'no-store',
},
},
);
}
const id = await authenticate(code, tokenOf(req, code));
if (
!Number.isSafeInteger(body.version) ||
!body.action ||
typeof body.action !== 'object'
)
throw new RuleError('The action is invalid.');
const view = await act(code, id, body.version, body.action);
if (!view.roomControl?.paused && !view.roomControl?.closed && (view.botsPending || needsAutomaticRoomRecovery(view)))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (e) {
return failure(e);
}
}