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.
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
import { requestOrigin } from '@/lib/request-origin';
|
|
import {
|
|
authenticate,
|
|
createSeatHandover,
|
|
revokeSeatHandover,
|
|
claimSeatHandover,
|
|
recoverSeat,
|
|
SeatControlError,
|
|
RoomEntryError,
|
|
setRecoveryKey,
|
|
setSeatAiDelegate,
|
|
revokeSeatAiDelegate,
|
|
useSeatAiDelegate as activateSeatAiDelegate,
|
|
needsAutomaticRoomRecovery,
|
|
} from '@/db/rooms';
|
|
import { RuleError } from '@/game/engine';
|
|
import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation';
|
|
import { resumeRoom } from '@/db/room-continuation';
|
|
import { env, waitUntil } from 'cloudflare:workers';
|
|
const noStore = { 'Cache-Control': 'no-store' };
|
|
export async function POST(req: Request) {
|
|
try {
|
|
const url = new URL(req.url);
|
|
if (req.headers.get('origin') && req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN))
|
|
throw new SeatControlError(
|
|
'Invalid request origin.',
|
|
'INVALID_ORIGIN',
|
|
403,
|
|
);
|
|
const code = url.pathname.split('/').at(-2)!.toUpperCase();
|
|
if (!/^[A-Z2-9]{8}$/.test(code))
|
|
throw new SeatControlError(
|
|
'Enter the eight-character room code.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
const raw = await req.text();
|
|
if (raw.length > 4000)
|
|
throw new SeatControlError(
|
|
'Request too large.',
|
|
'REQUEST_TOO_LARGE',
|
|
413,
|
|
);
|
|
const body: unknown = JSON.parse(raw);
|
|
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
throw new SeatControlError(
|
|
'Choose a recovery operation.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
const input = body as Record<string, unknown>;
|
|
const token =
|
|
req.headers
|
|
.get('cookie')
|
|
?.split(';')
|
|
.map((part) => part.trim())
|
|
.find((part) => part.startsWith(`dune_${code}=`))
|
|
?.split('=')[1] ?? '';
|
|
if (
|
|
input.type === 'setSeatAiDelegate' ||
|
|
input.type === 'revokeSeatAiDelegate' ||
|
|
input.type === 'useSeatAiDelegate'
|
|
) {
|
|
if (!validSeatAiDelegateRequest(input))
|
|
throw new SeatControlError(
|
|
'The AI permission request is invalid.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
const auth = await authenticate(code, token);
|
|
const result =
|
|
input.type === 'setSeatAiDelegate'
|
|
? await setSeatAiDelegate(code, auth, input.version, {
|
|
grantId: input.grantId,
|
|
delegateId: input.delegateId,
|
|
difficulty: input.difficulty,
|
|
})
|
|
: input.type === 'revokeSeatAiDelegate'
|
|
? await revokeSeatAiDelegate(
|
|
code,
|
|
auth,
|
|
input.version,
|
|
input.grantId,
|
|
)
|
|
: await activateSeatAiDelegate(code, auth, input.version, {
|
|
ownerId: input.ownerId,
|
|
grantId: input.grantId,
|
|
});
|
|
if (!result.view.roomControl?.paused && (result.view.botsPending || needsAutomaticRoomRecovery(result.view)))
|
|
waitUntil(resumeRoom(code));
|
|
return Response.json(result, { headers: noStore });
|
|
}
|
|
if (
|
|
input.type === 'setRecoveryKey' ||
|
|
input.type === 'createSeatHandover' ||
|
|
input.type === 'revokeSeatHandover'
|
|
) {
|
|
const auth = await authenticate(code, token);
|
|
if (input.type === 'createSeatHandover')
|
|
return Response.json(
|
|
await createSeatHandover(code, auth, input.version, {
|
|
offerId: input.offerId,
|
|
handoverSecret: input.handoverSecret,
|
|
}),
|
|
{ headers: noStore },
|
|
);
|
|
if (input.type === 'revokeSeatHandover')
|
|
return Response.json(
|
|
await revokeSeatHandover(code, auth, input.version, input.offerId),
|
|
{ headers: noStore },
|
|
);
|
|
return Response.json(
|
|
await setRecoveryKey(code, auth, input.version, input.recoverySecret),
|
|
{ headers: noStore },
|
|
);
|
|
}
|
|
if (input.type !== 'recoverSeat' && input.type !== 'claimSeatHandover')
|
|
throw new SeatControlError(
|
|
'Choose a recovery operation.',
|
|
'INVALID_CONTROL_REQUEST',
|
|
400,
|
|
);
|
|
if (input.type === 'claimSeatHandover' && token) {
|
|
let existing;
|
|
try {
|
|
existing = await authenticate(code, token);
|
|
} catch (error) {
|
|
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
|
|
}
|
|
if (existing && existing.playerId !== input.playerId)
|
|
throw new SeatControlError(
|
|
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
|
|
'ALREADY_SEATED',
|
|
409,
|
|
);
|
|
}
|
|
const result =
|
|
input.type === 'claimSeatHandover'
|
|
? await claimSeatHandover(code, {
|
|
playerId: input.playerId,
|
|
offerId: input.offerId,
|
|
handoverSecret: input.handoverSecret,
|
|
operationId: input.operationId,
|
|
newSessionToken: input.newSessionToken,
|
|
})
|
|
: await recoverSeat(code, {
|
|
playerId: input.playerId,
|
|
recoverySecret: input.recoverySecret,
|
|
operationId: input.operationId,
|
|
newSessionToken: input.newSessionToken,
|
|
});
|
|
return Response.json(
|
|
{
|
|
view: result.view,
|
|
...('transferred' in result
|
|
? { transferred: result.transferred }
|
|
: { recovered: result.recovered }),
|
|
replayed: result.replayed,
|
|
},
|
|
{
|
|
headers: {
|
|
...noStore,
|
|
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
|
},
|
|
},
|
|
);
|
|
} catch (error) {
|
|
return Response.json(
|
|
{
|
|
error:
|
|
error instanceof RuleError
|
|
? error.message
|
|
: error instanceof SyntaxError
|
|
? 'Invalid JSON.'
|
|
: 'The server could not complete this seat request.',
|
|
code:
|
|
(error instanceof SeatControlError || error instanceof RoomEntryError)
|
|
? error.code
|
|
: error instanceof RuleError
|
|
? 'INVALID_SESSION'
|
|
: error instanceof SyntaxError
|
|
? 'INVALID_CONTROL_REQUEST'
|
|
: 'SERVER_ERROR',
|
|
},
|
|
{
|
|
status:
|
|
(error instanceof SeatControlError || error instanceof RoomEntryError)
|
|
? error.status
|
|
: error instanceof RuleError
|
|
? 409
|
|
: error instanceof SyntaxError
|
|
? 400
|
|
: 500,
|
|
headers: noStore,
|
|
},
|
|
);
|
|
}
|
|
}
|