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.
This commit is contained in:
1 parent
c0cc4de9e1
commit
238de3860d
44 files changed
+4626
-90
No files matched your search
@@ -3,6 +3,7 @@ import type { RoomControl } from './room-control';
|
||||
export type AdminRoom = {
|
||||
code: string;
|
||||
version: number;
|
||||
removed: boolean;
|
||||
updatedAt: number;
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable';
|
||||
host: string | null;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomId } from './random-id';
|
||||
import { validAdminRemovalInput, type AdminRemovalInput, type AdminRemovalView, type AdminRemovalResult } from './admin-removal';
|
||||
|
||||
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
const key = (account: string, code: string) => `dune.admin-removal-request.v1:${account}:${code}`;
|
||||
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const keys = (value: Record<string, unknown>, names: string[]) => Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name));
|
||||
const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000;
|
||||
|
||||
export function validAdminRemovalView(value: unknown): value is AdminRemovalView {
|
||||
return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked']) &&
|
||||
typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) &&
|
||||
typeof value.removed === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' &&
|
||||
timestamp(value.removedAt) && timestamp(value.updatedAt);
|
||||
}
|
||||
|
||||
export function adminRemovalResponse(value: unknown, code: string): AdminRemovalView {
|
||||
if (!validAdminRemovalView(value) || value.code !== code) throw new Error('The server returned unreadable room availability.');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function adminRemovalConfirmation(value: unknown, code: string, input: AdminRemovalInput): AdminRemovalResult {
|
||||
if (!object(value) || !keys(value, ['operationId', 'replayed', 'appliedRevision', 'appliedVersion', 'room']) ||
|
||||
value.operationId !== input.operationId || typeof value.replayed !== 'boolean' ||
|
||||
!integer(value.appliedVersion) || value.appliedVersion !== input.expectedVersion + 1 ||
|
||||
!integer(value.appliedRevision) || value.appliedRevision !== input.expectedRevision + 1)
|
||||
throw new Error('The server did not confirm this removal or restoration request.');
|
||||
const room = adminRemovalResponse(value.room, code);
|
||||
if (room.version < value.appliedVersion || room.revision < value.appliedRevision)
|
||||
throw new Error('The server returned room availability older than the confirmed change.');
|
||||
return value as AdminRemovalResult;
|
||||
}
|
||||
|
||||
export function newAdminRemovalRequest(view: AdminRemovalView, removed: boolean, reason: string): AdminRemovalInput {
|
||||
const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: view.revision, removed, reason: reason.trim() };
|
||||
if (!validAdminRemovalInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.');
|
||||
if (removed === view.removed) throw new Error('Refresh the room before choosing a removal or restoration.');
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readAdminRemovalRequest(storage: Storage, account: string, code: string): AdminRemovalInput | null {
|
||||
const raw = storage.getItem(key(account, code));
|
||||
if (raw === null) return null;
|
||||
let value: unknown;
|
||||
try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; }
|
||||
if (!validAdminRemovalInput(value)) throw new Error('This tab’s saved removal or restoration request is unreadable. Inspect the current room before discarding its local retry record.');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Save before sending; a different unresolved request must never be replaced. */
|
||||
export function saveAdminRemovalRequest(storage: Storage, account: string, code: string, input: AdminRemovalInput) {
|
||||
if (!validAdminRemovalInput(input)) throw new Error('Invalid removal or restoration request.');
|
||||
const previous = readAdminRemovalRequest(storage, account, code), encoded = JSON.stringify(input);
|
||||
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved removal or restoration request before sending a different change.');
|
||||
storage.setItem(key(account, code), encoded);
|
||||
if (storage.getItem(key(account, code)) !== encoded) throw new Error('The removal or restoration request could not be preserved in this tab. Nothing was sent.');
|
||||
}
|
||||
|
||||
export function clearAdminRemovalRequest(storage: Storage, account: string, code: string) {
|
||||
storage.removeItem(key(account, code));
|
||||
if (storage.getItem(key(account, code)) !== null) throw new Error('The saved removal or restoration request could not be cleared.');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type AdminRemovalInput = {
|
||||
operationId: string;
|
||||
expectedVersion: number;
|
||||
expectedRevision: number;
|
||||
removed: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type AdminRemovalView = {
|
||||
code: string;
|
||||
version: number;
|
||||
removed: boolean;
|
||||
revision: number;
|
||||
removedAt: number | null;
|
||||
updatedAt: number | null;
|
||||
paused: boolean;
|
||||
joinLocked: boolean;
|
||||
};
|
||||
|
||||
export type AdminRemovalResult = {
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
appliedRevision: number;
|
||||
appliedVersion: number;
|
||||
room: AdminRemovalView;
|
||||
};
|
||||
|
||||
export function validAdminRemovalInput(
|
||||
value: unknown,
|
||||
): value is AdminRemovalInput {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const input = value as Record<string, unknown>;
|
||||
const fields = [
|
||||
'operationId',
|
||||
'expectedVersion',
|
||||
'expectedRevision',
|
||||
'removed',
|
||||
'reason',
|
||||
];
|
||||
return (
|
||||
Object.keys(input).length === fields.length &&
|
||||
fields.every((field) => Object.hasOwn(input, field)) &&
|
||||
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,
|
||||
) &&
|
||||
[input.expectedVersion, input.expectedRevision].every(
|
||||
(version) =>
|
||||
Number.isSafeInteger(version) &&
|
||||
Number(version) >= 0 &&
|
||||
Number(version) < Number.MAX_SAFE_INTEGER,
|
||||
) &&
|
||||
typeof input.removed === 'boolean' &&
|
||||
typeof input.reason === 'string' &&
|
||||
!!input.reason.trim() &&
|
||||
input.reason.length <= 300 &&
|
||||
!input.reason
|
||||
.split('')
|
||||
.some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ const uuid = (value: unknown) => typeof value === 'string' && value.length === 3
|
||||
export function validAdminRoomCreationResult(value: unknown): value is AdminRoomCreationResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const result = value as Record<string, unknown>;
|
||||
return Object.keys(result).length === 5 && typeof result.code === 'string' && result.code.length === 8 && /^[A-Z2-9]{8}$/.test(result.code) &&
|
||||
return Object.keys(result).length === (result.roomRemoved === true ? 6 : 5) &&
|
||||
(!Object.hasOwn(result, 'roomRemoved') || result.roomRemoved === true && result.hostAccess === false) && typeof result.code === 'string' && result.code.length === 8 && /^[A-Z2-9]{8}$/.test(result.code) &&
|
||||
uuid(result.hostId) && uuid(result.operationId) && typeof result.replayed === 'boolean' && typeof result.hostAccess === 'boolean';
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export function saveAdminRoomCreation(storage: Storage, account: string, input:
|
||||
|
||||
export function completeAdminRoomCreation(storage: Storage, account: string, input: AdminRoomCreationInput, result: AdminRoomCreationResult) {
|
||||
if (!validAdminRoomCreationResult(result) || result.operationId !== input.operationId) throw new Error('The server did not confirm this room-creation request.');
|
||||
if (result.roomRemoved) throw new Error('The room is temporarily removed. Keep the exact creation proof until an administrator restores it.');
|
||||
const previous = readAdminRoomCreation(storage, account);
|
||||
if (!previous || previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input))
|
||||
throw new Error('The saved room-creation record changed. Keep the current record and inspect the room directory.');
|
||||
|
||||
@@ -20,6 +20,7 @@ export type AdminRoomCreationResult = {
|
||||
hostId: string;
|
||||
replayed: boolean;
|
||||
hostAccess: boolean;
|
||||
roomRemoved?: true;
|
||||
};
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
|
||||
+25
-1
@@ -10,14 +10,30 @@ export class ClientRequestError extends Error {
|
||||
| 'http'
|
||||
| 'invalid-response',
|
||||
readonly status?: number,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClientRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export const isRoomRemoved = (error: unknown): error is ClientRequestError =>
|
||||
error instanceof ClientRequestError && error.code === 'ROOM_REMOVED';
|
||||
|
||||
// Controllers subscribe to a public room identifier, never response bodies or
|
||||
// credentials. A removal observed by discussion or seat controls also closes the
|
||||
// table. Pending controllers can stay mounted to retain their private retry proof.
|
||||
const removalListeners = new Set<(room: string) => void>();
|
||||
export function subscribeRoomRemoval(listener: (room: string) => void) {
|
||||
removalListeners.add(listener);
|
||||
return () => { removalListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** Gateways can report failure after the server has already committed a mutation. */
|
||||
export function requestMayHaveCompleted(error: unknown): boolean {
|
||||
// Creation/recovery can commit before their final private read loses to removal.
|
||||
// A temporary removal is never proof that a preceding mutation did not commit.
|
||||
if (isRoomRemoved(error)) return true;
|
||||
if (!(error instanceof ClientRequestError) || error.kind !== 'http')
|
||||
return true;
|
||||
return (
|
||||
@@ -92,7 +108,15 @@ export async function requestJson<T>(
|
||||
typeof data.error === 'string'
|
||||
? data.error
|
||||
: `Request failed (${response.status}).`;
|
||||
throw new ClientRequestError(message, 'http', response.status);
|
||||
const code = data && typeof data === 'object' && 'code' in data &&
|
||||
typeof data.code === 'string' && /^[A-Z_]{1,80}$/.test(data.code) ? data.code : undefined;
|
||||
if (code === 'ROOM_REMOVED') {
|
||||
const room = new URL(url, 'http://local.invalid').pathname.match(/^\/api\/rooms\/([A-Z2-9]{8})(?:\/|$)/)?.[1];
|
||||
if (room) for (const listener of removalListeners) {
|
||||
try { listener(room); } catch { /* A controller cannot change request semantics. */ }
|
||||
}
|
||||
}
|
||||
throw new ClientRequestError(message, 'http', response.status, code);
|
||||
}
|
||||
return data as T;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user