diff --git a/app/admin/operations/operations.css b/app/admin/operations/operations.css new file mode 100644 index 00000000..1d6568d9 --- /dev/null +++ b/app/admin/operations/operations.css @@ -0,0 +1,16 @@ +.admin-operations-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: 0.75rem; + margin: 1rem 0; +} + +.admin-operations-grid > div { + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: #171c16; +} + +.admin-operations-grid dt { color: #b7bbaf; font-size: 0.9rem; } +.admin-operations-grid dd { margin: 0.35rem 0 0; font-size: 1.2rem; overflow-wrap: anywhere; } diff --git a/app/admin/operations/page.tsx b/app/admin/operations/page.tsx new file mode 100644 index 00000000..218ba668 --- /dev/null +++ b/app/admin/operations/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ClientRequestError, requestJson } from '@/lib/client-request'; +import type { AdminOperations } from '@/db/admin-operations'; +import '../admin.css'; +import './operations.css'; + +type Account = { id: string; role: 'owner' | 'operator' | 'viewer' }; +type Snapshot = AdminOperations & { revision: string }; +const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.'; + +export default function OperationsPage() { + const [snapshot, setSnapshot] = useState(null); + const [notice, setNotice] = useState(''); + const [loading, setLoading] = useState(true); + const [refresh, setRefresh] = useState(0); + useEffect(() => { + let canceled = false; + requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' }) + .then(async ({ admin }) => { + if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.'); + const result = await requestJson('/api/admin/operations', { + cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id }, + }); + if (!canceled) { setSnapshot(result); setNotice(''); } + }) + .catch(error => { + if (canceled) return; + setSnapshot(null); + setNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0) + ? 'Owner access changed or expired. Sign in to administration again.' : message(error)); + }) + .finally(() => { if (!canceled) setLoading(false); }); + return () => { canceled = true; }; + }, [refresh]); + + return
+
Administration

Operations

+
+

Owner-only, read-only counts from the current database and the revision baked into this server build. Refresh to take a new sample; no monitoring or automatic repair runs here.

+ {notice &&

{notice}

} + {loading ? Checking owner access and database… : snapshot ?
+

Sample at {new Date(snapshot.observedAt).toLocaleString()}

+
+
Server build revision
{snapshot.revision}
+
Saved rooms (including removed/archived)
{snapshot.rooms}
+
Removed rooms
{snapshot.removedRooms}
+
Archived rooms
{snapshot.archivedRooms}
+
Paused rooms
{snapshot.pausedRooms}
+
Closed rooms
{snapshot.closedRooms}
+
Active seat credentials
{snapshot.activeSeats}
+
Invalid JSON room states
{snapshot.unreadableRooms}
+
Latest room write
{snapshot.lastRoomChange === null ? 'No rooms' : new Date(snapshot.lastRoomChange).toLocaleString()}
+
Room backup snapshots
{snapshot.backupSnapshots}
+
Backup snapshot payload bytes
{snapshot.backupBytes.toLocaleString()}
+
Recorded private exports
{snapshot.backupDownloads}
+
+

These counters do not check SQLite integrity, disk free space, backup recoverability, stalled decisions or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.

+
:

Sign in to administration

} +
; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 457050f8..977b725a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -125,6 +125,7 @@ export default function Administration() {

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

{account.role !== 'viewer' && } {account.role === 'owner' && Room backups} + {account.role === 'owner' && Operations}
{creatingRoom && account.role !== 'viewer' && setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy} diff --git a/app/api/admin/operations/route.ts b/app/api/admin/operations/route.ts new file mode 100644 index 00000000..d5464ce8 --- /dev/null +++ b/app/api/admin/operations/route.ts @@ -0,0 +1,16 @@ +import { env } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { readAdminOperations } from '@/db/admin-operations'; +import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; +import { buildRevision } from '@/lib/build-revision'; + +export async function GET(request: Request) { + try { + const identity = await requireAdmin(env.DB, adminToken(request), ['owner']); + const expected = request.headers.get('X-Dune-Admin-Id'); + if (expected && expected !== identity.id) + throw new AdminError('The signed-in administrator changed. Reload operations.', 401); + const counts = await readAdminOperations(env.DB, identity); + return adminResponse({ revision: buildRevision, ...counts }); + } catch (error) { return adminFailure(error); } +} diff --git a/db/admin-operations.ts b/db/admin-operations.ts new file mode 100644 index 00000000..86b57cc5 --- /dev/null +++ b/db/admin-operations.ts @@ -0,0 +1,40 @@ +import { AdminError, type AdminIdentity } from './admin-access'; + +export type AdminOperations = { + observedAt: number; + rooms: number; + removedRooms: number; + archivedRooms: number; + pausedRooms: number; + closedRooms: number; + activeSeats: number; + unreadableRooms: number; + lastRoomChange: number | null; + backupSnapshots: number; + backupBytes: number; + backupDownloads: number; +}; + +type Counts = Omit; + +/** Aggregate only operational counters, never serialized game or private backup rows. */ +export async function readAdminOperations(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise { + const row = await database.prepare(`SELECT + (SELECT COUNT(*) FROM rooms) rooms, + (SELECT COUNT(*) FROM rooms r JOIN room_removals m ON m.room_code=r.code WHERE m.removed=1) removedRooms, + (SELECT COUNT(*) FROM rooms r JOIN room_archives a ON a.room_code=r.code WHERE a.archived=1) archivedRooms, + (SELECT COUNT(*) FROM rooms r JOIN room_controls c ON c.room_code=r.code WHERE c.paused=1) pausedRooms, + (SELECT COUNT(*) FROM rooms r JOIN room_closures c ON c.room_code=r.code WHERE c.closed=1) closedRooms, + (SELECT COUNT(*) FROM seats WHERE revoked=0) activeSeats, + (SELECT COUNT(*) FROM rooms WHERE json_valid(state)=0) unreadableRooms, + (SELECT MAX(updated_at) FROM rooms) lastRoomChange, + (SELECT COUNT(*) FROM admin_room_backups) backupSnapshots, + (SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups) backupBytes, + (SELECT COUNT(*) FROM admin_room_backup_downloads) backupDownloads + WHERE EXISTS (SELECT 1 FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id + WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner' + AND s.revoked_at IS NULL AND s.expires_at>? AND s.generation=a.session_generation)`) + .bind(identity.sessionHash, identity.id, now).first(); + if (!row) throw new AdminError('Owner sign-in required.', 403); + return { observedAt: now, ...row }; +} diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 08409d8b..75e8d57e 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -15,7 +15,7 @@ recovery and persistence systems where suitable. | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial | | Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial | -| Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing | +| Operations and audit | The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. Searchable action history, failed-attempt records, integrity/disk checks, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial | Track each area through Missing, Prototyped, Integrated, Verified and Polished. As implementation begins, link its controls, server actions, persistence, @@ -64,6 +64,12 @@ state and related room-owned records without pausing or editing the room. The metadata directory omits private contents; downloaded JSON contains secret game state and hashed seat/recovery credentials. Import/restore are not available. +The owner-only [operations page](../app/admin/operations/page.tsx) provides +manual read-only samples of room lifecycle counts, active seat credentials, +invalid JSON saves, latest room write, backup payload usage and export receipts. +The database query repeats current owner authority. This is not an integrity +check, disk-capacity monitor, restore validation or automated repair. + The directory uses an explicit field allowlist, independent of any player view. It never returns hands, Traitors, predictions, plans, private messages, recovery records or raw saved state. Searches inspect only codes and public player names. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0d182cf9..5c953cae 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -39,6 +39,13 @@ limits, missing import/restore and absent unified action-history entries keep the saved-game operations gate open. Local isolated capture/export and a signed-out browser denial passed; this is not deployed evidence. +The owner-only [read-only operations sample](ADMIN_PANEL.md) now exposes the +build revision, saved-room and active-seat counts, lifecycle flags, invalid +JSON room counts, latest room write and backup/export usage. Live SQL owner +authorization and desktop/mobile local UI checks passed. It does not inspect +SQLite integrity, disk space, stalled turns or restore safety; no live NAS +deployment or production administrator acceptance is claimed. + The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path) prototype connects an initially empty worm's public offer, one spent private card, a Karama response, a typed remote ride after the Nexus, human controls diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 75996b5d..ba6ac51b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,21 @@ # Dune implementation status +## 28 September 2026 — owner-only operational counts + +`/admin/operations` now samples the build revision and live database aggregates: +saved/removed/archived/paused/closed rooms, active seat credentials, syntactically +invalid JSON saves, last room write, backup snapshot bytes/count and committed +private exports. Its read query repeats owner authority in SQL and returns no +saved state, seat token, message, backup payload or audit reason. The page is +read-only and refreshes only on request; it makes no integrity, disk-capacity, +repair or stalled-decision claim. Two isolated D1 tests pass for counts, live +demotion/revocation and unchanged saved games. The production HTTP suite +passed 54/54 with anonymous/forged-session denial, and the build includes the +new route/page. A real local signed-out browser denied access; desktop and +390-pixel samples rendered correctly using **mocked owner API responses** only, +not an authenticated production owner. Full administrator operations and +deployed acceptance remain gated; no production data was changed. + ## 28 September 2026 — owner-only room backup capture The new `/admin/backups` page lets an authenticated owner capture, list and diff --git a/tests/admin-backups-http.test.ts b/tests/admin-backups-http.test.ts index 342dec1b..37955e08 100644 --- a/tests/admin-backups-http.test.ts +++ b/tests/admin-backups-http.test.ts @@ -39,3 +39,16 @@ void test('room-host and forged administrator credentials cannot list, create or assert.deepEqual(Object.keys(await response.json()), ['error']); } }); + +void test('anonymous and forged administrator session cannot read operational counters', async () => { + const credentials: Record[] = [ + {}, + { cookie: 'dune_admin_session=' + 'a'.repeat(64), 'X-Dune-Admin-Id': backupId }, + ]; + for (const headers of credentials) { + const response = await fetch(base + '/api/admin/operations', { headers, signal: AbortSignal.timeout(15_000) }); + assert.equal(response.status, 401); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.deepEqual(Object.keys(await response.json()), ['error']); + } +}); diff --git a/tests/admin-operations.test.ts b/tests/admin-operations.test.ts new file mode 100644 index 00000000..3a3cd31f --- /dev/null +++ b/tests/admin-operations.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { adminLogin, requireAdmin, AdminError } from '../db/admin-access'; +import { readAdminOperations } from '../db/admin-operations'; +import { adminStore } from './admin-access-fixture'; + +const now = 10_000; + +void test('owner operations sample counts saved states and backup provenance without leaking contents or changing rooms', async () => { + const store = adminStore(); + try { + const owner = store.provision('owner'); + const login = await adminLogin(store.database, owner.key, now); + const identity = await requireAdmin(store.database, login.token, ['owner'], now); + store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABC', '{"private":"sealed card"}', 12, 9000); + store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABD', '{broken private state', 3, 9200); + store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run('private-session', 'OPERAABC', 'one'); + store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,1)').run('revoked-session', 'OPERAABC', 'two'); + store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now); + store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now); + store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)').run('OPERAABC', now); + store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now); + const backupId = randomUUID(); + store.sqlite.prepare(`INSERT INTO admin_room_backups + (operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest,payload,reason) + VALUES(?,?,?,'request',12,?,14,'digest','secret payload','private reason')`).run(backupId, owner.id, 'OPERAABC', now); + store.sqlite.prepare('INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at) VALUES(?,?,?,?,?)').run(randomUUID(), backupId, owner.id, 'OPERAABC', now); + const before = store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(); + const result = await readAdminOperations(store.database, identity, now); + assert.deepEqual(result, { + observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1, + pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1, + lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1, + }); + assert.equal(JSON.stringify(result).includes('secret'), false); + assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before); + } finally { store.sqlite.close(); } +}); + +void test('stale owner identity, demotion and revoked session cannot sample operational counts', async () => { + const store = adminStore(); + try { + const owner = store.provision('owner'); + store.provision('owner', 'Other owner'); + const login = await adminLogin(store.database, owner.key, now); + const identity = await requireAdmin(store.database, login.token, ['owner'], now); + const denied = (error: unknown) => error instanceof AdminError && error.status === 403; + store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id); + await assert.rejects(readAdminOperations(store.database, identity, now), denied); + store.sqlite.prepare("UPDATE admin_accounts SET role='owner' WHERE id=?").run(owner.id); + store.sqlite.prepare('UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?').run(now, identity.sessionHash); + await assert.rejects(readAdminOperations(store.database, identity, now), denied); + } finally { store.sqlite.close(); } +});