diff --git a/app/admin/operations/page.tsx b/app/admin/operations/page.tsx index 218ba668..79b2ee66 100644 --- a/app/admin/operations/page.tsx +++ b/app/admin/operations/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { ClientRequestError, requestJson } from '@/lib/client-request'; -import type { AdminOperations } from '@/db/admin-operations'; +import type { AdminIntegrity, AdminOperations } from '@/db/admin-operations'; import '../admin.css'; import './operations.css'; @@ -16,8 +16,13 @@ export default function OperationsPage() { const [notice, setNotice] = useState(''); const [loading, setLoading] = useState(true); const [refresh, setRefresh] = useState(0); + const [integrity, setIntegrity] = useState(null); + const [integrityNotice, setIntegrityNotice] = useState(''); + const [checking, setChecking] = useState(false); + const integrityEpoch = useRef(0); useEffect(() => { let canceled = false; + const epoch = integrityEpoch; 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.'); @@ -33,12 +38,37 @@ export default function OperationsPage() { ? 'Owner access changed or expired. Sign in to administration again.' : message(error)); }) .finally(() => { if (!canceled) setLoading(false); }); - return () => { canceled = true; }; + return () => { canceled = true; epoch.current++; }; }, [refresh]); + async function checkIntegrity() { + const epoch = ++integrityEpoch.current; + setChecking(true); + setIntegrity(null); + setIntegrityNotice(''); + try { + const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' }); + if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.'); + const result = await requestJson('/api/admin/operations?integrity=1', { + cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id }, + }); + if (epoch === integrityEpoch.current) setIntegrity(result); + } catch (error) { + if (epoch === integrityEpoch.current) + setIntegrityNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0) + ? 'Owner access changed or expired. Sign in to administration again.' : message(error)); + } finally { + if (epoch === integrityEpoch.current) setChecking(false); + } + } + 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 ?
@@ -58,6 +88,17 @@ export default function OperationsPage() {
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.

+ +

This manual, read-only SQLite quick check and foreign-key check may take time. It reports pass/fail only: not disk capacity, backup recoverability or game-rule validity.

+ {checking && Checking database structure and references…} + {integrityNotice &&

{integrityNotice}

} + {integrity &&
+

Checked {new Date(integrity.observedAt).toLocaleString()}.

+

SQLite structure: {integrity.sqlite === 'passed' ? 'Passed' : 'Failed'}. + Foreign keys: {integrity.foreignKeys === 'passed' ? 'Passed' : 'Failed'}.

+ {integrity.sqlite === 'failed' || integrity.foreignKeys === 'failed' + ?

Do not repair or restore this database automatically. Preserve a protected snapshot and investigate privately.

: null} +
}
:

Sign in to administration

}
; } diff --git a/app/api/admin/operations/route.ts b/app/api/admin/operations/route.ts index d5464ce8..7ffbf566 100644 --- a/app/api/admin/operations/route.ts +++ b/app/api/admin/operations/route.ts @@ -1,6 +1,6 @@ import { env } from 'cloudflare:workers'; import { AdminError, requireAdmin } from '@/db/admin-access'; -import { readAdminOperations } from '@/db/admin-operations'; +import { readAdminIntegrity, readAdminOperations } from '@/db/admin-operations'; import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; import { buildRevision } from '@/lib/build-revision'; @@ -10,7 +10,11 @@ export async function GET(request: Request) { 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 }); + const query = new URL(request.url).searchParams; + if (query.size && (query.size !== 1 || query.get('integrity') !== '1')) + throw new AdminError('Invalid operations request.', 400); + if (query.has('integrity')) + return adminResponse(await readAdminIntegrity(env.DB, identity)); + return adminResponse({ revision: buildRevision, ...await readAdminOperations(env.DB, identity) }); } catch (error) { return adminFailure(error); } } diff --git a/db/admin-operations.ts b/db/admin-operations.ts index 86b57cc5..9ae02457 100644 --- a/db/admin-operations.ts +++ b/db/admin-operations.ts @@ -15,6 +15,16 @@ export type AdminOperations = { backupDownloads: number; }; +export type AdminIntegrity = { + observedAt: number; + sqlite: 'passed' | 'failed'; + foreignKeys: 'passed' | 'failed'; +}; + +const ownerAuthority = `SELECT a.role 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`; + type Counts = Omit; /** Aggregate only operational counters, never serialized game or private backup rows. */ @@ -31,10 +41,24 @@ export async function readAdminOperations(database: D1Database, identity: AdminI (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)`) + WHERE EXISTS (${ownerAuthority})`) .bind(identity.sessionHash, identity.id, now).first(); if (!row) throw new AdminError('Owner sign-in required.', 403); return { observedAt: now, ...row }; } + +/** Explicit owner request; report only pass/fail, never SQLite diagnostics or row IDs. */ +export async function readAdminIntegrity(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise { + const results = await database.batch([ + database.prepare(ownerAuthority).bind(identity.sessionHash, identity.id, now), + database.prepare('PRAGMA quick_check(1)'), + database.prepare('SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check LIMIT 1) AS violation'), + ]); + if (!results[0].results.length) throw new AdminError('Owner sign-in required.', 403); + const quick = (results[1].results[0] as { quick_check?: unknown } | undefined)?.quick_check; + const foreign = (results[2].results[0] as { violation?: unknown } | undefined)?.violation; + if (typeof quick !== 'string' || foreign !== 0 && foreign !== 1) + throw new AdminError('Database integrity check unavailable.', 503); + return { observedAt: now, sqlite: quick === 'ok' ? 'passed' : 'failed', + foreignKeys: foreign === 0 ? 'passed' : 'failed' }; +} diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index c8e5d4ac..cf74e782 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 | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. 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. Failed-attempt records, integrity/disk checks, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial | +| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. 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. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. Failed-attempt records, disk capacity, 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, @@ -76,8 +76,13 @@ game state and hashed seat/recovery credentials. Import/restore are not availabl 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 database query repeats current owner authority. **Run integrity check** is +a separate, explicit read-only D1 quick check and foreign-key check. It repeats +owner authority inside the query batch and returns only pass/fail and sample +time, never a table name, row ID, private save or SQLite diagnostic text. +The check can take time; a failed check calls for a protected snapshot and +private diagnosis, not automatic repair. It does not prove disk capacity, +backup recoverability, valid game rules or the absence of stalled decisions. The directory uses an explicit field allowlist, independent of any player view. It never returns hands, Traitors, predictions, plans, private messages, recovery diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 93e9e2d7..c6c2f8c1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -209,12 +209,14 @@ 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 owner-only [operations page](ADMIN_PANEL.md) samples build revision, +room/seat and lifecycle counts, invalid JSON, latest write and backup usage. +It now also offers a separate **manual** SQLite quick/foreign-key check. +Only owner requests can run it; the result contains pass/fail and time, never +private rows or raw diagnostics. In-memory SQLite tests and disposable +local D1 command probes passed; built-worker owner UI and live NAS checks +remain unverified. It is not a disk-capacity, restore-safety or game-rule +validation tool. No game or seat changes during a check. Current work adds [action history](ADMIN_ACTION_HISTORY.md): a searchable, paginated view of nine durable admin receipt sources with public before/after diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8c82b5a8..daa89d5f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,30 @@ # Dune implementation status +## 30 September 2026 — Owner-only manual integrity check + +Administration → Operations now has a separate **Run integrity check** +control. An owner-only request batches live owner authority with +SQLite `quick_check(1)` and a bounded foreign-key existence query; +the response contains only pass/fail and sample time. It exposes no +database diagnostic, table, row, game state or credential and makes +no repair. Refresh/owner changes clear stale browser results. A +failed check advises a protected snapshot and private investigation, +not automatic restore. + +Focused in-memory SQLite tests verified healthy structure, an actual +orphaned foreign key, unchanged seats and live-role demotion. Both +query forms succeeded separately in a disposable Wrangler D1 store; +an isolated programmatic Miniflare batch timed out, so it did not +prove the combined workerd path. Local HTTP tests covered anonymous +denial; the owner UI was visually exercised on phone width with +explicitly mocked owner responses, not a real authenticated network +integrity request. Independent rules/security reviews found no scoped +defect. `npm run check` passed typecheck, lint and 5,833 offline +tests; `npm run build` and 55 local HTTP tests passed. No production +database scan or deployed revision was exercised. Disk capacity, +backup restore safety, stalled-decision diagnosis and complete +administration remain unfinished. + ## 30 September 2026 — Semuta after BG Charity Karama In the explicit Richese preview, a clean printed Karama diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 0d8d6024..566544c5 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.ok(cookie); const room = (await created.json() as { code: string }).code; for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record[]) { - for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { + for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) }); assert.equal(result.status, 401, path); assert.equal(result.headers.get('cache-control'), 'no-store'); diff --git a/tests/admin-operations.test.ts b/tests/admin-operations.test.ts index 3a3cd31f..fd8f3ca9 100644 --- a/tests/admin-operations.test.ts +++ b/tests/admin-operations.test.ts @@ -2,7 +2,7 @@ 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 { readAdminIntegrity, readAdminOperations } from '../db/admin-operations'; import { adminStore } from './admin-access-fixture'; const now = 10_000; @@ -35,6 +35,9 @@ void test('owner operations sample counts saved states and backup provenance wit }); assert.equal(JSON.stringify(result).includes('secret'), false); assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before); + assert.deepEqual(await readAdminIntegrity(store.database, identity, now), + { observedAt: now, sqlite: 'passed', foreignKeys: 'passed' }); + assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before); } finally { store.sqlite.close(); } }); @@ -53,3 +56,40 @@ void test('stale owner identity, demotion and revoked session cannot sample oper await assert.rejects(readAdminOperations(store.database, identity, now), denied); } finally { store.sqlite.close(); } }); + +void test('manual integrity check detects orphaned foreign keys without exposing their rows', 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.exec('PRAGMA foreign_keys=OFF'); + store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)') + .run('private-orphan-token', 'GHOSTABC', 'private-player'); + store.sqlite.exec('PRAGMA foreign_keys=ON'); + const before = store.sqlite.prepare('SELECT * FROM seats').all(); + const result = await readAdminIntegrity(store.database, identity, now); + assert.deepEqual(result, { observedAt: now, sqlite: 'passed', foreignKeys: 'failed' }); + assert.equal(JSON.stringify(result).includes('private'), false); + assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats').all(), before); + store.provision('owner', 'Backup owner'); + store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id); + await assert.rejects(readAdminIntegrity(store.database, identity, now), + (error: unknown) => error instanceof AdminError && error.status === 403); + } finally { store.sqlite.close(); } +}); + +void test('manual integrity check revalidates owner authority before returning results', async () => { + const store = adminStore(); + try { + const owner = store.provision('owner'); + store.provision('owner', 'Remaining owner'); + const login = await adminLogin(store.database, owner.key, now); + const identity = await requireAdmin(store.database, login.token, ['owner'], now); + store.hooks.beforeBatch = async () => { + store.sqlite.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?").run(owner.id); + }; + await assert.rejects(readAdminIntegrity(store.database, identity, now), + (error: unknown) => error instanceof AdminError && error.status === 403); + } finally { store.sqlite.close(); } +});