Files
Dune/app/api/rooms/[code]/route.ts
T
Quintine 238de3860d Add recoverable administrator room removal and restoration
Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials.

Fix AI readiness after a joining human or human faction change so real lobby starts remain legal.

Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
2026-09-22 12:01:22 +10:00

144 lines
4.5 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.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.botsPending || needsAutomaticRoomRecovery(view)))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (e) {
return failure(e);
}
}