Add administrator pause and joining-lock controls

Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices.

Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
This commit is contained in:
Quintine committed 2026-09-22 10:41:09 +10:00
1 parent f29d05620c
commit 8c922b446b
39 files changed
+3196 -104

No files matched your search

+2
View File
@@ -1,4 +1,5 @@
/** Public operational metadata only; never a player view or a saved game. */
import type { RoomControl } from './room-control';
export type AdminRoom = {
code: string;
version: number;
@@ -13,6 +14,7 @@ export type AdminRoom = {
turn: number | null;
phase: string;
pending: { label: string; owners: string[] };
control: RoomControl;
};
export type AdminDirectory = {
rooms: AdminRoom[];
+41
View File
@@ -0,0 +1,41 @@
import { randomId } from './random-id';
import { ClientRequestError, requestMayHaveCompleted } from './client-request';
import { validAdminRoomControlInput, type AdminRoomControlInput, type RoomControl } from './room-control';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
const storageKey = (account: string, room: string) => `dune.admin-room-control.v1:${account}:${room}`;
/** Denial now cannot establish whether an earlier attempt committed under the original session. */
export function retainAdminRoomRequest(error: unknown): boolean {
return requestMayHaveCompleted(error) || (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0));
}
export function newAdminRoomRequest(control: RoomControl, paused: boolean, joinLocked: boolean, reason: string): AdminRoomControlInput {
const input = { operationId: randomId(), expectedRevision: control.revision, paused, joinLocked, reason: reason.trim() };
if (!validAdminRoomControlInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.');
return input;
}
export function readAdminRoomRequest(storage: Storage, account: string, room: string): AdminRoomControlInput | null {
const raw = storage.getItem(storageKey(account, room));
if (raw === null) return null;
let value: unknown;
try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; }
if (!validAdminRoomControlInput(value)) throw new Error('This tab’s saved room-settings request is unreadable. Inspect the current settings before discarding the local retry record.');
return value;
}
/** Save before sending; never overwrite a different unresolved request. */
export function saveAdminRoomRequest(storage: Storage, account: string, room: string, input: AdminRoomControlInput) {
if (!validAdminRoomControlInput(input)) throw new Error('Invalid room-settings request.');
const previous = readAdminRoomRequest(storage, account, room);
const encoded = JSON.stringify(input);
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved room-settings request before sending a new one.');
storage.setItem(storageKey(account, room), encoded);
if (storage.getItem(storageKey(account, room)) !== encoded) throw new Error('Could not preserve the room-settings request in this tab. Nothing was sent.');
}
export function clearAdminRoomRequest(storage: Storage, account: string, room: string) {
storage.removeItem(storageKey(account, room));
if (storage.getItem(storageKey(account, room)) !== null) throw new Error('The saved retry record could not be cleared.');
}
+57
View File
@@ -0,0 +1,57 @@
/** Public room operations only. Reasons and administrator identities stay private. */
export type RoomControl = {
paused: boolean;
joinLocked: boolean;
revision: number;
updatedAt: number | null;
};
export const DEFAULT_ROOM_CONTROL: Readonly<RoomControl> = Object.freeze({
paused: false,
joinLocked: false,
revision: 0,
updatedAt: null,
});
export type AdminRoomControlInput = {
operationId: string;
expectedRevision: number;
paused: boolean;
joinLocked: boolean;
reason: string;
};
export function validAdminRoomControlInput(
value: unknown,
): value is AdminRoomControlInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const input = value as Record<string, unknown>;
return (
Object.keys(input).length === 5 &&
Object.keys(input).every((key) =>
[
'operationId',
'expectedRevision',
'paused',
'joinLocked',
'reason',
].includes(key),
) &&
typeof input.operationId === 'string' &&
input.operationId.length === 36 &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
input.operationId,
) &&
Number.isSafeInteger(input.expectedRevision) &&
Number(input.expectedRevision) >= 0 &&
Number(input.expectedRevision) < Number.MAX_SAFE_INTEGER &&
typeof input.paused === 'boolean' &&
typeof input.joinLocked === 'boolean' &&
typeof input.reason === 'string' &&
input.reason.trim().length > 0 &&
input.reason.length <= 300 &&
input.reason
.split('')
.every((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127)
);
}