diff --git a/README.md b/README.md index 67d76010..8998e670 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ no administrator accounts. Save it privately: log readers can use it, and later starts do not print it again. See [room controls](docs/ADMIN_ROOM_CONTROLS.md) and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Owners can [manage administrator accounts](docs/ADMIN_ACCOUNTS.md) with -one-time personal keys, role changes and access disabling. Save a new key -privately before clearing its tab-scoped recovery record. +one-time personal keys, role changes, disabling and fresh-key rotation +that can safely re-enable a disabled account. Save a new key privately +before clearing its tab-scoped recovery record; an old key cannot be reused. Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat and initial AI players. [Configure lobby](docs/ADMIN_LOBBY_CONFIGURATION.md) manages supported rules, AI diff --git a/app/admin/accounts/page.tsx b/app/admin/accounts/page.tsx index 6688f1fc..c21705a3 100644 --- a/app/admin/accounts/page.tsx +++ b/app/admin/accounts/page.tsx @@ -5,12 +5,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request'; import type { AdminAccountInput, AdminAccountRow, AdminAccountsDirectory, AdminAccountResult } from '@/lib/admin-accounts'; -import { clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult, type AdminAccountRecord } from '@/lib/admin-accounts-client'; +import { adminAccountKeyStatus, clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult, type AdminAccountRecord } from '@/lib/admin-accounts-client'; import '../admin.css'; import './accounts.css'; type SessionAccount = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' }; -type Mutation = 'provision' | 'role' | 'disable'; +type Mutation = 'provision' | 'role' | 'disable' | 'rotate'; const message = (error: unknown) => error instanceof Error ? error.message : 'The account request could not be completed.'; const denied = (error: unknown) => error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0); @@ -101,12 +101,12 @@ export default function AccountsPage() { try { const input = action === 'provision' ? newAdminAccountRequest({ action, name, role, reason }) - : target && target.enabled && target.id !== owner.id + : selected && selected.id !== owner.id ? action === 'role' - ? newAdminAccountRequest({ action, target: target.id, expectedUpdatedAt: target.updatedAt, role, reason }) - : newAdminAccountRequest({ action, target: target.id, expectedUpdatedAt: target.updatedAt, reason }) + ? newAdminAccountRequest({ action, target: selected.id, expectedUpdatedAt: selected.updatedAt, role, reason }) + : newAdminAccountRequest({ action, target: selected.id, expectedUpdatedAt: selected.updatedAt, reason }) : null; - if (!input) throw new Error('Choose a current, enabled account other than your own.'); + if (!input) throw new Error('Choose a current account other than your own; role changes and disabling require an enabled account.'); saveAdminAccountRequest(window.sessionStorage, owner.id, input); setRecord({ kind: 'pending', input }); setConfirmed(false); setStale(false); setNotice('Review the exact request below. No account change has been sent yet.'); @@ -115,7 +115,7 @@ export default function AccountsPage() { async function submit(input: AdminAccountInput) { if (!owner || inFlight.current || !confirmed || storageProblem || stale || record?.kind !== 'pending' || - (input.action !== 'provision' && !directory?.accounts.some(row => row.id === input.target))) return; + (input.action !== 'provision' && (input.target === owner.id || !directory?.accounts.some(row => row.id === input.target)))) return; inFlight.current = true; setBusy(true); setConfirmed(false); try { // A restored tab must not submit under another or a demoted account. @@ -128,10 +128,10 @@ export default function AccountsPage() { if (!validAdminAccountResult(result, input)) throw new Error('The server did not confirm the exact account request. Keep the saved retry.'); completeAdminAccountRequest(window.sessionStorage, owner.id, input, result); if (!mounted.current) return; - setRecord(input.action === 'provision' ? { kind: 'completed', input, account: result.account } : null); + setRecord(input.action === 'provision' || input.action === 'rotate' ? { kind: 'completed', input, account: result.account } : null); setTarget(null); setReason(''); setName(''); setStale(false); setNotice(result.replayed ? 'The original account operation was confirmed; no duplicate change was made.' : 'Account operation confirmed.'); - try { await loadDirectory(owner, 1); } + try { await loadDirectory(owner, input.action === 'rotate' ? directory?.page ?? 1 : 1); } catch (error) { if (denied(error)) loseAccess(); else setNotice(`Account operation confirmed, but refresh failed: ${message(error)}`); } } catch (error) { if (!mounted.current) return; @@ -156,51 +156,57 @@ export default function AccountsPage() { } function select(row: AdminAccountRow, next: Mutation) { - if (!owner || row.id === owner.id || !row.enabled || record || busy) return; + if (!owner || row.id === owner.id || (next !== 'rotate' && !row.enabled) || record || busy) return; setTarget(row); setAction(next); setRole(row.role); setReason(''); setConfirmed(false); setNotice(''); } const pending = record?.kind === 'pending' ? record.input : null; const pendingTarget = pending?.action !== 'provision' && pending ? directory?.accounts.find(row => row.id === pending.target) : null; const completed = record?.kind === 'completed' ? record : null; - const selected = target && directory?.accounts.find(item => item.id === target.id && item.updatedAt === target.updatedAt && item.enabled); + const keyStatus = completed ? adminAccountKeyStatus(completed, directory) : null; + const selected = target && directory?.accounts.find(item => item.id === target.id && item.updatedAt === target.updatedAt && (action === 'rotate' || item.enabled)); const totalPages = directory ? Math.max(1, Math.ceil(directory.total / directory.pageSize)) : 1; return
Administration

Administrator accounts

{owner && {owner.name} owner}
-

Only an owner can provision accounts, change roles or disable account access. Account changes do not change games or seats.

+

Only an owner can provision accounts, change roles, disable access or rotate another account’s key. Account changes do not change games or seats.

{notice &&

{notice}

} {loading ? Checking owner access and account directory… : !owner ?

Return to administrator sign-in

: <> {storageProblem ?

Unreadable local request

-

Account changes are blocked. Inspect the account directory before discarding the unreadable record. A previously submitted operation may have committed, and a provisioned key may be lost if you discard it.

+

Account changes are blocked. Inspect the account directory before discarding the unreadable record. A previously submitted operation may have committed, and a newly issued key may be lost if you discard it.

: pending ?

Saved account request

{stale ? 'This request conflicted. Inspect the refreshed directory and explicitly discard it to prepare another request.' : 'The exact operation is saved in this tab. A previous attempt may have succeeded. Retry only this request to confirm its outcome.'}

Action
{pending.action}
Account name
{pending.action === 'provision' ? pending.name : pendingTarget?.name ?? 'Find this account in the directory before sending'}
Account ID
{pending.action === 'provision' ? pending.id : pending.target}
- {pending.action !== 'provision' && <>
Current role
{pendingTarget?.role ?? 'Unavailable'}
Reviewed version
{new Date(pending.expectedUpdatedAt).toLocaleString()}
} - {pending.action !== 'disable' && <>
Desired role
{pending.role}
} + {pending.action !== 'provision' && <>
Current role
{pendingTarget?.role ?? 'Unavailable'}
Current state
{pendingTarget ? pendingTarget.enabled ? 'Enabled' : 'Disabled' : 'Unavailable'}
Reviewed version
{new Date(pending.expectedUpdatedAt).toLocaleString()}
} + {(pending.action === 'provision' || pending.action === 'role') && <>
Desired role
{pending.role}
}
Reason
{pending.reason}
Operation ID
{pending.operationId}
- {pending.action === 'disable' &&

Disabling {pendingTarget?.name ?? pending.target} blocks the existing access key and permanently revokes every current session. This page cannot re-enable the account. Check its current role and exact ID before proceeding.

} -

Provisioning key stays hidden until the exact operation is confirmed. Do not enter credentials in the reason. Discarding an uncertain provision request can permanently lose the only copy of its key.

- -
+ {pending.action !== 'provision' && pendingTarget?.updatedAt !== pending.expectedUpdatedAt &&

This account’s current version differs from the reviewed version. Only the exact saved operation can be retried; a new request requires discarding it after checking the directory.

} + {pending.action === 'disable' &&

Disabling {pendingTarget?.name ?? pending.target} blocks the existing access key and permanently revokes every current session. Re-enabling requires a new key rotation; its old key will never work again. Check its current role and exact ID before proceeding.

} + {pending.action === 'rotate' &&

Rotating {pendingTarget?.name ?? pending.target} ({pending.target}) replaces its key with a fresh one and revokes every existing session. Its role and name stay unchanged. A disabled account is re-enabled with the new key; its old key remains invalid. Save the new key securely after confirmation. Do not rotate your own account.

} +

A new key stays hidden until the exact operation is confirmed. Do not enter credentials in the reason. Discarding an uncertain provision or rotation request can permanently lose the only copy of its key.

+ +
-
: completed ?

Save this account key now

-

Account {completed.account.name} ({completed.account.id}) is provisioned. This key is available only in this tab until you explicitly clear its local record. It is not returned by the server. Give it privately to its owner; it cannot be recovered after clearing.

+
: completed ?

Save or verify this account key

+

Account {completed.account.name} ({completed.account.id}) {completed.input.action === 'rotate' ? 'was rotated and enabled by this operation; its previous key and sessions were revoked at that time.' : 'was provisioned by this operation.'} This key is available only in this tab until you explicitly clear its local record. It is not returned by the server. Do not distribute it without checking its current access; it cannot be recovered after clearing.

+ {keyStatus === 'matching' ?

The directory matched this operation at the last read. A later change can still invalidate the key; verify sign-in before distributing it.

: +

{keyStatus === 'changed' ? 'The account changed or was disabled after this operation.' : 'The account is not shown on the current directory page, so its current access cannot be confirmed.'} This saved key may no longer work. Do not distribute it; check the account and rotate a fresh key if needed.

} event.currentTarget.select()} /> -
:

{action === 'provision' ? 'Provision an account' : action === 'role' ? 'Change account role' : 'Disable an account'}

+
:

{action === 'provision' ? 'Provision an account' : action === 'role' ? 'Change account role' : action === 'rotate' ? 'Rotate account key' : 'Disable an account'}

{action === 'provision' ? <> : selected ? <>

Target: {selected.name} ({selected.id}) · current role {selected.role}

{action === 'role' && } - {action === 'disable' &&

Disabling blocks this account’s existing key and permanently revokes its current sessions. It cannot be re-enabled here.

} :

The selected account changed. Refresh and choose it again.

} + {action === 'disable' &&

Disabling blocks this account’s existing key and permanently revokes its current sessions. Re-enabling requires a fresh key rotation; its old key cannot be used again.

} + {action === 'rotate' &&

Current state: {selected.enabled ? 'enabled' : 'disabled'}. Rotation revokes all existing sessions, replaces the old key, preserves the current role and name, and enables this account with only the newly generated key. Your own key cannot be rotated here.

} :

The selected account changed. Refresh and choose it again.

}

Use an operational reason without access keys, credentials or private game information. The next screen shows the exact request for confirmation before anything is sent.

@@ -210,8 +216,9 @@ export default function AccountsPage() { {directory ? <>

{directory.total.toLocaleString()} accounts · page {directory.page} of {totalPages}

    {directory.accounts.map(row =>
  • {row.name} {row.role} {row.enabled ? 'Enabled' : 'Disabled'}

    {row.id}

    Updated {new Date(row.updatedAt).toLocaleString()}

    - {row.enabled && row.id !== owner.id && !record && !storageProblem &&
    -
    }
  • )}
+ {row.id !== owner.id && !record && !storageProblem &&
{row.enabled && <> + } +
})} {directory.accounts.length === 0 &&

No accounts on this page.

} :

Account directory unavailable. Changes are blocked until it loads.

} diff --git a/db/admin-accounts.ts b/db/admin-accounts.ts index 2354fb95..79794729 100644 --- a/db/admin-accounts.ts +++ b/db/admin-accounts.ts @@ -6,6 +6,7 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON AND s.expires_at>? AND s.generation=a.session_generation`; const accountFields = 'id,name,role,enabled,created_at,updated_at'; const receiptFields = 'operation_id,actor_admin_id,request_hash,target_admin_id,name,role,enabled,created_at,updated_at'; +const allReceipts = `SELECT ${receiptFields} FROM admin_account_operations UNION ALL SELECT ${receiptFields} FROM admin_account_rotations`; type Account = { id: string; name: string; role: AdminAccountRow['role']; enabled: number; created_at: number; updated_at: number }; type Receipt = Omit & { operation_id: string; actor_admin_id: string; request_hash: string; target_admin_id: string }; const project = (row: Account): AdminAccountRow => ({ id: row.id, name: row.name, role: row.role, enabled: row.enabled === 1, createdAt: row.created_at, updatedAt: row.updated_at }); @@ -43,11 +44,13 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde ? { action:input.action,id:input.id,key:input.key,name:input.name,role:input.role,reason:input.reason } : input.action === 'role' ? { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,role:input.role,reason:input.reason } - : { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,reason:input.reason })); + : input.action === 'rotate' + ? { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,key:input.key,reason:input.reason } + : { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,reason:input.reason })); const previous = await database.batch([ database.prepare(authority).bind(...args), - database.prepare(`SELECT ${receiptFields} FROM admin_account_operations WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args), - database.prepare(`SELECT ${accountFields} FROM admin_accounts WHERE id=? AND EXISTS (${authority})`).bind(target,...args), + database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args), + database.prepare(`SELECT ${accountFields},key_hash FROM admin_accounts WHERE id=? AND EXISTS (${authority})`).bind(target,...args), ]); authorized(previous[0]); const resolve = (receipt: Receipt | undefined, replayed: boolean): AdminAccountResult | null => { @@ -58,27 +61,63 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde }; const existing = resolve(previous[1].results[0] as Receipt | undefined,true); if (existing) return existing; - const account = previous[2].results[0] as Account | undefined; + const account = previous[2].results[0] as (Account & { key_hash: string }) | undefined; if (input.action !== 'provision') { - if (target === identity.id) throw new AdminError('You cannot disable or demote your own account.',409); - if (!account || !account.enabled || account.updated_at !== input.expectedUpdatedAt) + if (target === identity.id) throw new AdminError('You cannot change your own account credentials or authority.',409); + if (!account || input.action !== 'rotate' && !account.enabled || account.updated_at !== input.expectedUpdatedAt) throw new AdminError('The account changed or was disabled. Refresh before choosing a new operation.',409); if (input.action === 'role' && account.role === input.role) throw new AdminError('Choose a different role for this account.',409); } const nextTime = input.action === 'provision' ? now : Math.max(now,account!.updated_at+1); + if (input.action === 'rotate') { + const keyHash = await fingerprint(input.key); + const result = await database.batch([ + database.prepare(authority).bind(...args), + database.prepare(`UPDATE admin_accounts SET key_hash=?,enabled=1,session_generation=session_generation+1,updated_at=? + WHERE id=? AND updated_at=? AND role=? AND key_hash=? AND key_hash<>? AND id!=? + AND NOT EXISTS (SELECT 1 FROM admin_account_retired_keys WHERE key_hash=?) + AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) + AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`) + .bind(keyHash,nextTime,target,input.expectedUpdatedAt,account!.role,account!.key_hash,keyHash,identity.id,keyHash,...args,input.operationId,input.operationId), + database.prepare(`INSERT INTO admin_account_retired_keys (key_hash,admin_id) + SELECT ?,? WHERE changes()=1 AND EXISTS (${authority})`) + .bind(account!.key_hash,target,...args), + database.prepare(`INSERT INTO admin_account_rotations + (operation_id,actor_admin_id,request_hash,target_admin_id,name,role,previous_enabled,enabled,created_at,updated_at,expected_updated_at,reason,rotated_at) + SELECT ?,?,?,?,name,role,?,enabled,created_at,updated_at,?,?,? FROM admin_accounts + WHERE id=? AND changes()=1 AND EXISTS (${authority})`) + .bind(input.operationId,identity.id,requestHash,target,account!.enabled, input.expectedUpdatedAt,reason,now,target,...args), + database.prepare(`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail,reason) + SELECT ?,?,?,'rotate',?,json_object('previousEnabled',?,'enabled',1),? + WHERE changes()=1 AND EXISTS (${authority})`) + .bind(crypto.randomUUID(),identity.id,target,now,account!.enabled,reason,...args), + database.prepare(`UPDATE admin_sessions SET revoked_at=? WHERE admin_id=? AND revoked_at IS NULL + AND changes()=1 AND EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=? AND actor_admin_id=? AND request_hash=? AND target_admin_id=?) + AND EXISTS (${authority})`) + .bind(now,target,input.operationId,identity.id,requestHash,target,...args), + database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args), + ]); + authorized(result[0]); + const committed = resolve(result[6].results[0] as Receipt | undefined,result[1].results.length !== 1); + if (committed) return committed; + throw new AdminError('The account or administrator authority changed. Refresh before choosing a new operation.',409); + } const mutation = input.action === 'provision' ? database.prepare(`INSERT INTO admin_accounts(id,name,role,key_hash,created_at,updated_at) SELECT ?,?,?,?,?,? WHERE EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) + AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) AND NOT EXISTS (SELECT 1 FROM admin_accounts WHERE id=?) RETURNING id`) - .bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,input.operationId,input.id) + .bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,input.operationId,input.operationId,input.id) : input.action === 'role' ? database.prepare(`UPDATE admin_accounts SET role=?,updated_at=? WHERE id=? AND enabled=1 AND updated_at=? AND role=? AND role<>? - AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) RETURNING id`) - .bind(input.role,nextTime,target,input.expectedUpdatedAt,account!.role,input.role,identity.id,...args,input.operationId) + AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) + AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`) + .bind(input.role,nextTime,target,input.expectedUpdatedAt,account!.role,input.role,identity.id,...args,input.operationId,input.operationId) : database.prepare(`UPDATE admin_accounts SET enabled=0,updated_at=? WHERE id=? AND enabled=1 AND updated_at=? AND role=? - AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) RETURNING id`) - .bind(nextTime,target,input.expectedUpdatedAt,account!.role,identity.id,...args,input.operationId); + AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) + AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`) + .bind(nextTime,target,input.expectedUpdatedAt,account!.role,identity.id,...args,input.operationId,input.operationId); const result = await database.batch([ database.prepare(authority).bind(...args), mutation, @@ -94,7 +133,7 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde : database.prepare(`UPDATE admin_audit SET actor_admin_id=?,reason=? WHERE id=( SELECT id FROM admin_audit WHERE target_admin_id=? AND action=? AND actor_admin_id IS NULL ORDER BY rowid DESC LIMIT 1) AND changes()=1 AND EXISTS (${authority})`).bind(identity.id,reason,target,input.action === 'provision' ? 'provision' : 'revoke',...args), - database.prepare(`SELECT ${receiptFields} FROM admin_account_operations WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args), + database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args), ]); authorized(result[0]); const committed = resolve(result[4].results[0] as Receipt | undefined,result[1].results.length !== 1); diff --git a/db/admin-audit.ts b/db/admin-audit.ts index 31cceefb..f146e702 100644 --- a/db/admin-audit.ts +++ b/db/admin-audit.ts @@ -14,7 +14,7 @@ const actionType = `json_extract(${json('action')},'$.type')`; // bounded groups so SQLite cannot flatten the nine sources back into one union. const rawEvents = `WITH first_events AS MATERIALIZED ( SELECT 'account' category,rowid row_key,id receipt_id,actor_admin_id actor_id,NULL room_code,target_admin_id target_admin_id,NULL target_seat_id, - CASE WHEN action IN ('provision','revoke','role','login','logout','logout_all') THEN action ELSE 'unknown' END action, + CASE WHEN action IN ('provision','revoke','role','rotate','login','logout','logout_all') THEN action ELSE 'unknown' END action, reason,NULL before_json,substr(detail,1,8192) after_json,created_at FROM admin_audit UNION ALL SELECT 'control',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,'room_control',reason, json_object('paused',before_paused,'joinLocked',before_join_locked,'revision',expected_revision), @@ -73,6 +73,7 @@ export function projectAdminAudit(row: Row, reasonsVisible: boolean): AdminAudit const before = parse(row.before_json), after = parse(row.after_json), changes: AuditChange[] = []; const add = (field: AuditChange['field'], a: string | null, b: string | null) => { if (a !== null || b !== null) changes.push({ field,before:a,after:b }); }; if (row.category === 'account') add('Role',role(after.previousRole),role(after.role)); + if (row.category === 'account' && row.action === 'rotate') add('Enabled',flag(after.previousEnabled),flag(after.enabled)); if (row.category === 'lobby' || row.category === 'creation') { const a = configuration(before), b = configuration(after); for (const field of ['Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','Player count','AI seats'] as const) add(field,a[field],b[field]); diff --git a/db/schema.ts b/db/schema.ts index 0212c659..0f95f487 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -495,6 +495,35 @@ export const adminAccountOperations = sqliteTable( ], ); +// Rotation receipts use a separate table: the original operation CHECK remains intact. +// Only the request fingerprint and safe account metadata persist, never either key. +export const adminAccountRotations = sqliteTable( + 'admin_account_rotations', + { + operationId: text('operation_id').primaryKey(), + actorAdminId: text('actor_admin_id').notNull(), + requestHash: text('request_hash').notNull(), + targetAdminId: text('target_admin_id').notNull(), + name: text('name').notNull(), + role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(), + previousEnabled: integer('previous_enabled').notNull(), + enabled: integer('enabled').notNull(), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + expectedUpdatedAt: integer('expected_updated_at').notNull(), + reason: text('reason').notNull(), + rotatedAt: integer('rotated_at').notNull(), + }, + table => [index('admin_account_rotations_target').on(table.targetAdminId, table.rotatedAt)], +); + +// Credential history is deliberately separate from public receipts and audit. +// Retained hashes prevent a later A -> B -> A rotation from reactivating A. +export const adminAccountRetiredKeys = sqliteTable('admin_account_retired_keys', { + keyHash: text('key_hash').primaryKey(), + adminId: text('admin_id').notNull(), +}); + // Immutable private room snapshots survive room/account deletion; only owner APIs expose them. export const adminRoomBackups = sqliteTable( 'admin_room_backups', diff --git a/docs/ADMIN_ACCOUNTS.md b/docs/ADMIN_ACCOUNTS.md index ab05bb30..9b30d9eb 100644 --- a/docs/ADMIN_ACCOUNTS.md +++ b/docs/ADMIN_ACCOUNTS.md @@ -6,26 +6,54 @@ Open **Administration → Accounts** as an enabled owner. Operators, viewers and Choose a name, owner/operator/viewer role and operational reason, then review the exact account ID, name, role and reason before confirming. The browser generates a new UUID and 256-bit personal access key with Web Crypto. It saves the exact request in this tab before sending it. The server stores only its SHA-256 hash and returns account metadata, **not the key**. After confirmation, save the one-time displayed key privately and explicitly clear its tab record. Refresh restores an unconfirmed request or the confirmed key until it is cleared. If the response is uncertain, retry the same saved request; do not generate a replacement request or discard the only key copy. Do not place keys in reasons, screenshots, issue reports or shared logs. -## Change or disable access +## Change roles, disable access or rotate a key -Choose an enabled account other than your own. Review its exact name, ID, -current role, requested new role or disable, and operational reason before -sending. A role change uses the current account timestamp; a stale target -is rejected and must be reviewed again. Disabling blocks the key and -permanently invalidates current sessions; this page cannot re-enable the -account. **Do not manually re-enable its database row:** without a separate -key rotation, its old access key would become usable again. Provision a new -account instead. Another owner must perform a self-demotion or self-disable. -The database refuses to remove the final enabled owner. Existing game rooms, -seats and recovery kits are not modified. +Choose another account and review its exact name, ID, current role and +enabled state, target action, account timestamp and operational reason. +Role changes and disabling require an enabled target. A stale target is +rejected and must be reviewed again. Disabling blocks the existing access +key and permanently invalidates current sessions. Another owner must +perform a self-demotion or self-disable. The database refuses to remove the +final enabled owner. Existing game rooms, seats and recovery kits are not +modified. -Each operation has an exact saved identifier and request fingerprint. Concurrent or repeated identical requests resolve to one account change and one actor-attributed audit record; a different request cannot reuse that identifier. Live owner role, session expiry/generation and target version are rechecked at the database write, not inferred from the rendered page. An uncertain role or disable response can be retried from this tab; a conflicting or revoked request remains blocked for review. Tab records are scoped to the original owner account. +**Rotate key** works for another enabled or disabled account. The browser +generates a fresh target-bound 256-bit key, saves the exact request in this +tab, and asks for confirmation of the target and session impact. Rotation +replaces the old hash, increments the session generation, revokes existing +sessions and enables the account while retaining its name and role. A +disabled account is re-enabled **only with the new key**. Previously +retired key hashes are kept privately so later A→B→A rotations cannot +revive an old credential. Never use manual SQL to re-enable an account +without rotating its key. Rotation of your own account is unavailable. +On success, save the one-time displayed key privately before clearing its +tab record. The server never returns the key. An uncertain response must +be retried with the same saved request; discarding it may lose the only +copy. A retry confirms a historical operation, not that its key still +works: another owner may have rotated or disabled the account afterward. +The page compares the latest visible directory row with the saved +operation and warns when it changed or is not on the current page. +Even matching metadata is only a last-read observation; verify sign-in +before distributing a key. -Action history shows successful account operations with safe role changes and an operational reason for authorized readers. External CLI provisioning or revocation may still have no human actor attribution. Rejected attempts are not yet part of that history. No browser account operation grants access to a playing seat or reveals saved game data. +Each operation has an exact saved identifier and request fingerprint. +Concurrent or repeated identical requests resolve to one account change +and one actor-attributed audit record; a different request cannot reuse +that identifier. Live owner role, session expiry/generation and target +version are rechecked at the database write, not inferred from the +rendered page. An uncertain response can be retried from this tab; a +conflicting or revoked request remains blocked for review. Tab records +are scoped to the original owner account. + +Action history shows successful account operations with safe role/enabled +changes and an operational reason for authorized readers. External CLI +provisioning or revocation may still have no human actor attribution. +Rejected attempts are not yet part of that history. No browser account +operation grants access to a playing seat or reveals saved game data. ## Verification and remaining work -Implementation: [account query and transactions](../db/admin-accounts.ts), [request contract](../lib/admin-accounts.ts), [route](../app/api/admin/accounts/route.ts), [page](../app/admin/accounts/page.tsx) and [additive operation receipt](../drizzle/0019_admin_accounts.sql). Keep the original owner-protection and session-revocation triggers in migration `0008`. +Implementation: [account query and transactions](../db/admin-accounts.ts), [request contract](../lib/admin-accounts.ts), [route](../app/api/admin/accounts/route.ts), [page](../app/admin/accounts/page.tsx), [original receipt](../drizzle/0019_admin_accounts.sql) and [additive rotation receipt/key history](../drizzle/0020_admin_key_rotation.sql). Keep the original owner-protection and session-revocation triggers in migration `0008`. ```sh npm test -- admin-accounts admin-audit @@ -42,9 +70,29 @@ confirmation. Independent security and persistence reviews resolved unchanged-role audit no-ops, missing point-of-risk copy and a logout-all timestamp rollback. +The rotation follow-up passes typecheck, lint and 5,787/5,787 offline +cases, the app build, Drizzle migration check and 55/55 HTTP tests against +the supported local development server. An isolated built-worker D1 store +with its own disposable owner/target completed authenticated **network** +provision, rotation, exact replay, disable, fresh-key re-enable, old +key/session rejection, A→B→A refusal, safe audit and foreign-origin +denial. No production account, room or credential was changed. +An actual phone-width owner page exposed the enabled-target review and +confirmation with no key before submission; a separate local-only +completed-record fixture showed the superseded-key warning without +sending a mutation. Independent security review found the historical +replay presentation risk, now corrected by last-read directory +comparison; persistence review reported no scoped finding. + +The full 55-case suite against `wrangler dev` of the production build did +**not** pass: the local workerd process intermittently returned 503 +mid-request under that suite. A single oversized request returned 413 +and later requests succeeded after draining its body, but that does not +resolve the broader worker-restart observation. Development-server +success and focused built-worker mutations are not deployed acceptance. + Use an isolated owner and disposable target for authenticated end-to-end account changes; never use a person's normal owner key in a destructive QA flow. Client/browser fixtures alone do not prove a signed-in **network** -HTTP mutation or production deployment. Key rotation, re-enabling a -disabled account, failed-attempt audit, complete administration and live -acceptance remain unfinished. +HTTP mutation or production deployment. Failed-attempt audit, complete +administration and live acceptance remain unfinished. diff --git a/docs/ADMIN_ACTION_HISTORY.md b/docs/ADMIN_ACTION_HISTORY.md index 5eef6834..d5dde5e8 100644 --- a/docs/ADMIN_ACTION_HISTORY.md +++ b/docs/ADMIN_ACTION_HISTORY.md @@ -18,12 +18,14 @@ operational reason when permitted, and bounded before/after settings. Expand **Recorded settings** for the public configuration, lifecycle flags, revisions, AI difficulty or changed player circle. Names come from current administrator accounts; deleted accounts retain their recorded identifiers. Web-managed -provisioning, role changes and disabling identify the acting owner and record -the supplied reason. External CLI provisioning/revocation may lack human -actor attribution, and the page says so. A **Recorded** outcome means the -operation was applied; a -room-control operation may successfully save identical settings, and later -operations may supersede it. It does not assert the room's current state. +provisioning, role changes, disabling and fresh-key rotation/re-enable +identify the acting owner and record the supplied reason. Rotation shows +only the before/after enabled state, never the old or new key, hash or +session. External CLI provisioning/revocation may lack human actor +attribution, and the page says so. A **Recorded** outcome means the +operation was applied; a room-control operation may successfully save +identical settings; later operations may supersede it. It does not assert +the room's current state. ## Access and privacy @@ -55,8 +57,10 @@ Implementation: [query/projection](../db/admin-audit.ts), [route](../app/api/admin/audit/route.ts), [page](../app/admin/audit/page.tsx). The original nine-source read needed no migration. Owner account management adds a safe `reason` field and durable retry receipts in additive -[migration 0019](../drizzle/0019_admin_accounts.sql); it does not add a -new raw game or message source. +[migration 0019](../drizzle/0019_admin_accounts.sql). Additive +[migration 0020](../drizzle/0020_admin_key_rotation.sql) keeps rotation +receipts and retired key hashes out of the history projection. Neither +adds a raw game or message source. ```sh npm test -- admin-audit @@ -76,7 +80,7 @@ sources under its five-term compound-query limit, with live role demotion and filtered reads. Materialized groups keep the unified query within that limit. Existing records cover successful operations only. Failed/rejected attempts, -key rotation and re-enable, full operational health/diagnostics, -backup/import/restore and the remaining participant support are still required. -Local/browser/source-bound evidence is separate from deployed acceptance, which -must identify the actual running revision and use authorized QA access. +full operational health/diagnostics, backup/import/restore and the +remaining participant support are still required. Local/browser/source-bound +evidence is separate from deployed acceptance, which must identify the +actual running revision and use authorized QA access. diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 15b9243b..c8e5d4ac 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -9,7 +9,7 @@ recovery and persistence systems where suitable. | Area | Required behavior | Stage | | --- | --- | --- | -| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions and individual/all-session sign-out. [Owner-only account management](ADMIN_ACCOUNTS.md) adds a paginated directory, browser-created one-time keys, role changes, disabling, live authority and exact retries. A room host is not a site administrator. Key rotation/re-enable, failed-attempt history and further operational permissions remain. | Prototyped, partial | +| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions and individual/all-session sign-out. [Owner-only account management](ADMIN_ACCOUNTS.md) adds a paginated directory, browser-created one-time keys, role changes, disabling and fresh-key rotation/re-enable with session revocation, live authority and exact retries. A room host is not a site administrator. Failed-attempt history and further operational permissions remain. | Prototyped, partial | | Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped | | Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial | | 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 | @@ -53,12 +53,14 @@ Disabling an account invalidates its sessions; later re-enabling it cannot resto them. The database refuses to disable, demote or delete the final enabled owner. No default key, public account creation or host-to-administrator promotion exists. -[Manage accounts](ADMIN_ACCOUNTS.md) lets a current owner provision a personal -key, change another administrator’s role or disable its access. The exact -request and generated key remain tab-scoped through uncertain responses; -the server never returns a key. The owner reviews the target and reason -before submitting, and no game or seat is modified. Key rotation and -re-enabling disabled accounts are not available in this prototype. +[Manage accounts](ADMIN_ACCOUNTS.md) lets a current owner provision a +personal key, change another administrator’s role, disable access or +rotate another account’s key. Rotation also re-enables a disabled account +with a fresh key while invalidating every earlier key and session. The +exact request and generated key remain tab-scoped through uncertain +responses; the server never returns a key. The owner reviews the target, +current state, reason and revocation impact before submitting. No game +or seat is modified. [Participant AI](ADMIN_PARTICIPANT_AI.md) lets owners/operators support an absent human in a paused, started game. The player retains access and takeback controls; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 553f5ae4..0e718485 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 29 September 2026. **The full twelve-faction goal is unfinished. Basic +Updated 30 September 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -34,14 +34,19 @@ arrival composition, natural assassination reveal coverage, normal expansion starts or deployed acceptance. The owner-only [administrator accounts](ADMIN_ACCOUNTS.md) prototype now -lists accounts, provisions a browser-generated one-time personal key, changes -another account’s role and disables access. Server actions repeat live owner -authority and target-version checks at commit, preserve exact tab-scoped -retries and attribute successful changes in action history without exposing -keys or saved games. Key rotation/re-enable, rejected-attempt history, -authenticated positive **network** HTTP on a dedicated isolated QA account, deployed -acceptance and the rest of full administration remain unfinished. This code -is not in the last verified live image. +lists, provisions, changes roles, disables and rotates another account’s +personal key. Rotation can re-enable a disabled target only with a fresh +key; old credentials and sessions remain revoked, including attempted +A→B→A reuse. Live owner/target fences, durable exact retries and safe +actor-attributed history preserve rooms and seats. A disposable owner +and target completed authenticated local **network** rotation and +re-enable on an isolated built-worker D1 store, and mobile browser review +showed exact impact and a superseded-replay warning. The supported dev +HTTP suite passes 55/55; the built-worker full suite hit intermittent +local workerd 503 restarts and is not a green production-bundle suite. +Failed-attempt history, the rest of administration, deployment and +production acceptance remain unfinished. This code is not in the last +verified live image. The bounded [Advanced Moritani Assassinate Leaders preview](MORITANI_ASSASSINATE_LEADERS.md) now has repeatable genuine games across two through six supported seats. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 284c94b2..09c1f8a4 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 30 September 2026 — owner key rotation and safe re-enable + +The [account panel](ADMIN_ACCOUNTS.md) now rotates another enabled or +disabled administrator’s personal access key with one exact tab-scoped +request. A disabled target becomes enabled only with the new key; all +old sessions are revoked and historical hashes prevent A→B→A key reuse. +The owner reviews name, ID, role, enabled state, reason and revocation +impact; one-time key disclosure remains confined to the confirmed tab. +Additive migration `0020` preserves previous account receipts and games. +Live SQL owner/version/key checks, one durable rotation receipt and +actor-attributed safe audit prevent stale or duplicate changes. + +Independent persistence review found no scoped issue. Security review +identified an exact historical replay that could show a superseded key +as currently usable. The client now compares its receipt with the +last-read account metadata, warns when changed/unlisted, and advises +sign-in verification even when matching. The focused security follow-up +found no remaining issue in those changed lines. A phone-width +authenticated local page showed exact pre-send confirmation; a +completed-record fixture showed the superseded warning without sending +a mutation. +The isolated built-worker network smoke provisioned, rotated, replayed, +disabled and re-enabled a disposable target, rejected old keys and +sessions, refused key reuse and preserved safe audit and origin denial. +No live account or saved game was modified. + +`npm run check` passes typecheck, lint and 5,787/5,787 offline cases; +`npm run build`, `drizzle-kit check` and all 55/55 development-server +HTTP cases pass. A second full HTTP run against the local production +bundle did not pass: workerd intermittently returned 503 mid-request +under the suite. Draining oversized admin bodies removed an early +unread-stream return; isolated built requests returned 413 and later +requests remained healthy, but the full bundle load issue remains +unresolved. None of this proves a deployed revision, complete +administration or production recovery. + ## 29 September 2026 — natural Advanced Moritani reveal and replacement A source-unchanged genuine six-seat sample at base seed `20261307` diff --git a/drizzle/0020_admin_key_rotation.sql b/drizzle/0020_admin_key_rotation.sql new file mode 100644 index 00000000..b07519c2 --- /dev/null +++ b/drizzle/0020_admin_key_rotation.sql @@ -0,0 +1,22 @@ +CREATE TABLE `admin_account_rotations` ( + `operation_id` text PRIMARY KEY NOT NULL, + `actor_admin_id` text NOT NULL, + `request_hash` text NOT NULL, + `target_admin_id` text NOT NULL, + `name` text NOT NULL, + `role` text NOT NULL, + `previous_enabled` integer NOT NULL, + `enabled` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `expected_updated_at` integer NOT NULL, + `reason` text NOT NULL, + `rotated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `admin_account_rotations_target` ON `admin_account_rotations` (`target_admin_id`,`rotated_at`); +--> statement-breakpoint +CREATE TABLE `admin_account_retired_keys` ( + `key_hash` text PRIMARY KEY NOT NULL, + `admin_id` text NOT NULL +); diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..041d506f --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,2535 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f8f91aa8-3515-4b2e-b549-c3c8a9b7c841", + "prevId": "86bbac97-cbf4-42dd-ada2-3092e96d39cc", + "tables": { + "admin_account_operations": { + "name": "admin_account_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_account_operations_action": { + "name": "admin_account_operations_action", + "value": "\"admin_account_operations\".\"action\" IN ('provision','role','disable')" + } + } + }, + "admin_account_retired_keys": { + "name": "admin_account_retired_keys", + "columns": { + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_account_rotations": { + "name": "admin_account_rotations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_enabled": { + "name": "previous_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_updated_at": { + "name": "expected_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_account_rotations_target": { + "name": "admin_account_rotations_target", + "columns": [ + "target_admin_id", + "rotated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_accounts": { + "name": "admin_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_accounts_key_hash_unique": { + "name": "admin_accounts_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_accounts_role": { + "name": "admin_accounts_role", + "value": "\"admin_accounts\".\"role\" IN ('owner', 'operator', 'viewer')" + }, + "admin_accounts_enabled": { + "name": "admin_accounts_enabled", + "value": "\"admin_accounts\".\"enabled\" IN (0, 1)" + }, + "admin_accounts_generation": { + "name": "admin_accounts_generation", + "value": "\"admin_accounts\".\"session_generation\" >= 0" + } + } + }, + "admin_audit": { + "name": "admin_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_audit_created_at": { + "name": "admin_audit_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_discussion_operations": { + "name": "admin_discussion_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_discussion_room": { + "name": "admin_discussion_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_lobby_operations": { + "name": "admin_lobby_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_configuration": { + "name": "before_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_configuration": { + "name": "after_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_lobby_operations_room": { + "name": "admin_lobby_operations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_archives": { + "name": "admin_room_archives", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_archives_room": { + "name": "admin_room_archives_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_audit": { + "name": "admin_room_audit", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'room_control'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_paused": { + "name": "before_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_join_locked": { + "name": "before_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_paused": { + "name": "after_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_join_locked": { + "name": "after_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_audit_room": { + "name": "admin_room_audit_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_backup_downloads": { + "name": "admin_room_backup_downloads", + "columns": { + "download_id": { + "name": "download_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_backup_downloads_room": { + "name": "admin_room_backup_downloads_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_backups": { + "name": "admin_room_backups", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_version": { + "name": "room_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "digest": { + "name": "digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_backups_room": { + "name": "admin_room_backups_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_room_backups_size": { + "name": "admin_room_backups_size", + "value": "\"admin_room_backups\".\"size_bytes\" BETWEEN 1 AND 1800000 AND \"admin_room_backups\".\"size_bytes\" = length(CAST(\"admin_room_backups\".\"payload\" AS BLOB))" + } + } + }, + "admin_room_closures": { + "name": "admin_room_closures", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_closures_room": { + "name": "admin_room_closures_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_creations": { + "name": "admin_room_creations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_creations_session_hash_unique": { + "name": "admin_room_creations_session_hash_unique", + "columns": [ + "session_hash" + ], + "isUnique": true + }, + "admin_room_creations_room": { + "name": "admin_room_creations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_removals": { + "name": "admin_room_removals", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_removals_room": { + "name": "admin_room_removals_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_seat_ai_operations": { + "name": "admin_seat_ai_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_control_revision": { + "name": "expected_control_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_seat_ai_room": { + "name": "admin_seat_ai_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_sessions": { + "name": "admin_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_sessions_id_unique": { + "name": "admin_sessions_id_unique", + "columns": [ + "id" + ], + "isUnique": true + }, + "admin_sessions_account": { + "name": "admin_sessions_account", + "columns": [ + "admin_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "admin_sessions_admin_id_admin_accounts_id_fk": { + "name": "admin_sessions_admin_id_admin_accounts_id_fk", + "tableFrom": "admin_sessions", + "tableTo": "admin_accounts", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_archives": { + "name": "room_archives", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_archives_room_code_rooms_code_fk": { + "name": "room_archives_room_code_rooms_code_fk", + "tableFrom": "room_archives", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_archives_archived": { + "name": "room_archives_archived", + "value": "\"room_archives\".\"archived\" IN (0, 1)" + }, + "room_archives_revision": { + "name": "room_archives_revision", + "value": "\"room_archives\".\"revision\" >= 0" + }, + "room_archives_time": { + "name": "room_archives_time", + "value": "(\"room_archives\".\"archived\" = 1 AND \"room_archives\".\"archived_at\" IS NOT NULL) OR (\"room_archives\".\"archived\" = 0 AND \"room_archives\".\"archived_at\" IS NULL)" + } + } + }, + "room_closures": { + "name": "room_closures", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_closures_room_code_rooms_code_fk": { + "name": "room_closures_room_code_rooms_code_fk", + "tableFrom": "room_closures", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_closures_closed": { + "name": "room_closures_closed", + "value": "\"room_closures\".\"closed\" IN (0, 1)" + }, + "room_closures_revision": { + "name": "room_closures_revision", + "value": "\"room_closures\".\"revision\" >= 0" + }, + "room_closures_time": { + "name": "room_closures_time", + "value": "(\"room_closures\".\"closed\" = 1 AND \"room_closures\".\"closed_at\" IS NOT NULL) OR (\"room_closures\".\"closed\" = 0 AND \"room_closures\".\"closed_at\" IS NULL)" + } + } + }, + "room_controls": { + "name": "room_controls", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "join_locked": { + "name": "join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_controls_room_code_rooms_code_fk": { + "name": "room_controls_room_code_rooms_code_fk", + "tableFrom": "room_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_controls_paused": { + "name": "room_controls_paused", + "value": "\"room_controls\".\"paused\" IN (0, 1)" + }, + "room_controls_join_locked": { + "name": "room_controls_join_locked", + "value": "\"room_controls\".\"join_locked\" IN (0, 1)" + }, + "room_controls_revision": { + "name": "room_controls_revision", + "value": "\"room_controls\".\"revision\" >= 0" + } + } + }, + "room_entry_receipts": { + "name": "room_entry_receipts", + "columns": { + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_entry_receipts_room_code_rooms_code_fk": { + "name": "room_entry_receipts_room_code_rooms_code_fk", + "tableFrom": "room_entry_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_messages": { + "name": "room_messages", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_session_hash": { + "name": "sender_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_faction": { + "name": "sender_faction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "room_messages_room_id": { + "name": "room_messages_room_id", + "columns": [ + "room_code", + "id" + ], + "isUnique": true + }, + "room_messages_channel": { + "name": "room_messages_channel", + "columns": [ + "room_code", + "recipient_id", + "sequence" + ], + "isUnique": false + }, + "room_messages_sender": { + "name": "room_messages_sender", + "columns": [ + "room_code", + "sender_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "room_messages_room_code_rooms_code_fk": { + "name": "room_messages_room_code_rooms_code_fk", + "tableFrom": "room_messages", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_removals": { + "name": "room_removals", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_removals_room_code_rooms_code_fk": { + "name": "room_removals_room_code_rooms_code_fk", + "tableFrom": "room_removals", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_removals_removed": { + "name": "room_removals_removed", + "value": "\"room_removals\".\"removed\" IN (0, 1)" + }, + "room_removals_revision": { + "name": "room_removals_revision", + "value": "\"room_removals\".\"revision\" >= 0" + }, + "room_removals_time": { + "name": "room_removals_time", + "value": "(\"room_removals\".\"removed\" = 1 AND \"room_removals\".\"removed_at\" IS NOT NULL) OR (\"room_removals\".\"removed\" = 0 AND \"room_removals\".\"removed_at\" IS NULL)" + } + } + }, + "rooms": { + "name": "rooms", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rooms_updated_at": { + "name": "rooms_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_ai_delegations": { + "name": "seat_ai_delegations", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_id": { + "name": "delegate_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_session_hash": { + "name": "owner_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_session_hash": { + "name": "delegate_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_ai_delegations_grant_id_unique": { + "name": "seat_ai_delegations_grant_id_unique", + "columns": [ + "grant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_ai_delegations_room_code_rooms_code_fk": { + "name": "seat_ai_delegations_room_code_rooms_code_fk", + "tableFrom": "seat_ai_delegations", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_ai_delegations_room_code_owner_id_grant_id_pk": { + "columns": [ + "room_code", + "owner_id", + "grant_id" + ], + "name": "seat_ai_delegations_room_code_owner_id_grant_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_discussion_controls": { + "name": "seat_discussion_controls", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_discussion_controls_room_code_rooms_code_fk": { + "name": "seat_discussion_controls_room_code_rooms_code_fk", + "tableFrom": "seat_discussion_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_discussion_controls_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_discussion_controls_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "seat_discussion_muted": { + "name": "seat_discussion_muted", + "value": "\"seat_discussion_controls\".\"muted\" IN (0, 1)" + }, + "seat_discussion_revision": { + "name": "seat_discussion_revision", + "value": "\"seat_discussion_controls\".\"revision\" >= 0" + } + } + }, + "seat_handover_claim_receipts": { + "name": "seat_handover_claim_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_fence": { + "name": "claim_fence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_claim_receipts_claim_fence_unique": { + "name": "seat_handover_claim_receipts_claim_fence_unique", + "columns": [ + "claim_fence" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_claim_receipts_room_code_rooms_code_fk": { + "name": "seat_handover_claim_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_handover_claim_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_offers": { + "name": "seat_handover_offers", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuer_session_hash": { + "name": "issuer_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_operation_hash": { + "name": "claim_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_offers_offer_hash_unique": { + "name": "seat_handover_offers_offer_hash_unique", + "columns": [ + "offer_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_offers_room_code_rooms_code_fk": { + "name": "seat_handover_offers_room_code_rooms_code_fk", + "tableFrom": "seat_handover_offers", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_offers_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_handover_offers_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_keys": { + "name": "seat_recovery_keys", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_operation_hash": { + "name": "current_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_keys_room_code_rooms_code_fk": { + "name": "seat_recovery_keys_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_keys", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_keys_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_recovery_keys_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_receipts": { + "name": "seat_recovery_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_receipts_room_code_rooms_code_fk": { + "name": "seat_recovery_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seats": { + "name": "seats", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seats_room_code_rooms_code_fk": { + "name": "seats_room_code_rooms_code_fk", + "tableFrom": "seats", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cc4f0f01..89140534 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1790682568001, "tag": "0019_admin_accounts", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1790682568002, + "tag": "0020_admin_key_rotation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/admin-accounts-client.ts b/lib/admin-accounts-client.ts index 3f1dc1fd..2473a708 100644 --- a/lib/admin-accounts-client.ts +++ b/lib/admin-accounts-client.ts @@ -1,9 +1,22 @@ -import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow } from './admin-accounts'; +import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow, type AdminAccountsDirectory } from './admin-accounts'; type Storage = Pick; export type AdminAccountRecord = | { kind: 'pending'; input: AdminAccountInput } - | { kind: 'completed'; input: Extract; account: AdminAccountRow }; + | { kind: 'completed'; input: Extract; account: AdminAccountRow }; + +/** A receipt describes a past write. The directory can only confirm whether + * its account metadata still matched at the most recent read, not future key validity. */ +export function adminAccountKeyStatus( + record: Extract, + directory: AdminAccountsDirectory | null, +): 'matching' | 'changed' | 'unverified' { + const live = directory?.accounts.find(row => row.id === record.account.id); + if (!live) return 'unverified'; + return live.enabled && live.updatedAt === record.account.updatedAt && + live.createdAt === record.account.createdAt && live.name === record.account.name && + live.role === record.account.role ? 'matching' : 'changed'; +} const storageKey = (owner: string) => `dune.admin-accounts.v1:${owner}`; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; @@ -36,20 +49,24 @@ export function validAdminAccountResult(value: unknown, input: AdminAccountInput if (input.action === 'provision') return account.enabled && account.name === input.name && account.role === input.role; return account.updatedAt > input.expectedUpdatedAt && - (input.action === 'role' ? account.enabled && account.role === input.role : !account.enabled); + (input.action === 'role' ? account.enabled && account.role === input.role : + input.action === 'rotate' ? account.enabled : !account.enabled); } +const newSecret = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); + export function newAdminAccountRequest(fields: | { action: 'provision'; name: string; role: AdminAccountRow['role']; reason: string } | { action: 'role'; target: string; expectedUpdatedAt: number; role: AdminAccountRow['role']; reason: string } - | { action: 'disable'; target: string; expectedUpdatedAt: number; reason: string }, + | { action: 'disable' | 'rotate'; target: string; expectedUpdatedAt: number; reason: string }, ): AdminAccountInput { const operationId = crypto.randomUUID(); const input = fields.action === 'provision' ? (() => { const id = crypto.randomUUID(); - const secret = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); - return { action: 'provision' as const, operationId, id, key: `dune-admin.${id}.${secret}`, name: fields.name.trim(), role: fields.role, reason: fields.reason.trim() }; - })() : { ...fields, operationId, reason: fields.reason.trim() }; + return { action: 'provision' as const, operationId, id, key: `dune-admin.${id}.${newSecret()}`, name: fields.name.trim(), role: fields.role, reason: fields.reason.trim() }; + })() : fields.action === 'rotate' + ? { ...fields, operationId, key: `dune-admin.${fields.target}.${newSecret()}`, reason: fields.reason.trim() } + : { ...fields, operationId, reason: fields.reason.trim() }; if (!validAdminAccountInput(input)) throw new Error('Check the account, role and operational reason. Do not include credentials in the reason.'); return input; } @@ -63,10 +80,10 @@ export function readAdminAccountRecord(storage: Storage, owner: string): AdminAc const record = value as Record; if (Object.keys(record).length === 2 && record.kind === 'pending' && validAdminAccountInput(record.input)) return value as AdminAccountRecord; if (Object.keys(record).length === 3 && record.kind === 'completed' && validAdminAccountInput(record.input) && - record.input.action === 'provision' && validAdminAccountRow(record.account)) { + (record.input.action === 'provision' || record.input.action === 'rotate') && validAdminAccountRow(record.account)) { const input = record.input, account = record.account; - if (account.id === input.id && account.name === input.name && - account.role === input.role && account.enabled) return value as AdminAccountRecord; + if (validAdminAccountResult({ operationId: input.operationId, replayed: false, account }, input)) + return value as AdminAccountRecord; } } throw new Error('This tab’s saved account request is unreadable. Do not send a new request or discard this record until you have checked the account directory.'); @@ -91,7 +108,8 @@ export function completeAdminAccountRequest(storage: Storage, owner: string, inp const previous = readAdminAccountRecord(storage, owner); if (!previous || previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input)) throw new Error('The saved account request changed. Keep it and inspect the directory.'); - if (input.action === 'provision') persist(storage, owner, { kind: 'completed', input, account: result.account }); + if (input.action === 'provision' || input.action === 'rotate') + persist(storage, owner, { kind: 'completed', input, account: result.account }); else clearAdminAccountRecord(storage, owner); } diff --git a/lib/admin-accounts.ts b/lib/admin-accounts.ts index fdaac190..b1c8999e 100644 --- a/lib/admin-accounts.ts +++ b/lib/admin-accounts.ts @@ -7,7 +7,8 @@ export type AdminAccountsDirectory = { accounts: AdminAccountRow[]; total: numbe export type AdminAccountInput = | { action: 'provision'; operationId: string; id: string; key: string; name: string; role: AdminRole; reason: string } | { action: 'role'; operationId: string; target: string; expectedUpdatedAt: number; role: AdminRole; reason: string } - | { action: 'disable'; operationId: string; target: string; expectedUpdatedAt: number; reason: string }; + | { action: 'disable'; operationId: string; target: string; expectedUpdatedAt: number; reason: string } + | { action: 'rotate'; operationId: string; target: string; expectedUpdatedAt: number; key: string; reason: string }; export type AdminAccountResult = { operationId: string; replayed: boolean; account: AdminAccountRow }; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; @@ -40,5 +41,10 @@ export function validAdminAccountInput(value: unknown): value is AdminAccountInp if (input.action === 'disable') return exact(input, ['action','operationId','target','expectedUpdatedAt','reason']) && typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt); + if (input.action === 'rotate') + return exact(input, ['action','operationId','target','expectedUpdatedAt','key','reason']) && + typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt) && + typeof input.key === 'string' && input.key === `dune-admin.${input.target}.${input.key.slice(-64)}` && + /^dune-admin\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.[0-9a-f]{64}$/.test(input.key); return false; } diff --git a/lib/admin-audit.ts b/lib/admin-audit.ts index eb9eead8..01eee666 100644 --- a/lib/admin-audit.ts +++ b/lib/admin-audit.ts @@ -4,14 +4,14 @@ export const AUDIT_CATEGORIES = { } as const; export type AuditCategory = keyof typeof AUDIT_CATEGORIES; export const AUDIT_ACTIONS = { - provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere', + provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', rotate: 'Rotate administrator key', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere', room_control: 'Apply room controls', create: 'Create room', rules: 'Change rules', techTokens: 'Change Tech Tokens', strongholdCards: 'Change Stronghold Cards', addBot: 'Add AI player', removeBot: 'Remove AI player', assignHost: 'Assign host', configureBot: 'Configure AI player', remove: 'Remove room', restore: 'Restore room', close: 'Close room', reopen: 'Reopen room', archive: 'Archive room', unarchive: 'Unarchive room', participant_ai: 'Enable participant AI', mute: 'Mute discussion', unmute: 'Unmute discussion', unknown: 'Unrecognized recorded action', } as const; export type AuditAction = keyof typeof AUDIT_ACTIONS; -export const AUDIT_FIELDS = ['Role','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const; +export const AUDIT_FIELDS = ['Role','Enabled','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const; export type AuditChange = { field: typeof AUDIT_FIELDS[number]; before: string | null; after: string | null }; export type AdminAuditEvent = { id: string; category: AuditCategory; action: AuditAction; createdAt: number | null; diff --git a/lib/admin-http.ts b/lib/admin-http.ts index 2ce8ad8f..f2821eea 100644 --- a/lib/admin-http.ts +++ b/lib/admin-http.ts @@ -22,9 +22,10 @@ export async function adminBody(request: Request, configuredOrigin?: string): Pr const { done, value } = await reader.read(); if (done) break; size += value.byteLength; - if (size > 2048) { void reader.cancel(); throw new AdminError('Request too large.', 413); } - chunks.push(value); + // Drain the request without retaining bytes beyond the limit. + if (size <= 2048) chunks.push(value); } + if (size > 2048) throw new AdminError('Request too large.', 413); const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } diff --git a/tests/admin-accounts-controls.test.tsx b/tests/admin-accounts-controls.test.tsx index 9ebcdfae..e20a9a2c 100644 --- a/tests/admin-accounts-controls.test.tsx +++ b/tests/admin-accounts-controls.test.tsx @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { register } from 'node:module'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult } from '../lib/admin-accounts-client'; +import { adminAccountKeyStatus, clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult } from '../lib/admin-accounts-client'; import type { AdminAccountRow } from '../lib/admin-accounts'; function storage() { @@ -20,7 +20,7 @@ void test('SSR exposes no account directory or private key before owner session const { default: AccountsPage } = await import('../app/admin/accounts/page'); const html = renderToStaticMarkup(createElement(AccountsPage)); assert.match(html, /Checking owner access and account directory/); - assert.doesNotMatch(html, /One-time account access key|Change role ·|Disable ·|dune-admin\./); + assert.doesNotMatch(html, /One-time account access key|Change role ·|Disable ·|Rotate key ·|accounts-private-key|dune-admin\./); }); void test('provisioned key is generated only in the browser, saved exactly before sending, and recoverable from its original tab', () => { @@ -79,6 +79,68 @@ void test('role and disable requests retain exact target version and reason unti } }); +void test('disabled-account rotation keeps one fresh target-bound key through exact replay and local recovery', () => { + const owner = crypto.randomUUID(), target = crypto.randomUUID(), tab = storage(); + const disabled = { ...account(target, 'owner'), enabled: false }; + const input = newAdminAccountRequest({ action: 'rotate', target, expectedUpdatedAt: disabled.updatedAt, reason: ' Replace compromised key ' }); + const next = newAdminAccountRequest({ action: 'rotate', target, expectedUpdatedAt: disabled.updatedAt, reason: 'Replace compromised key' }); + assert.equal(input.action, 'rotate'); + assert.equal(next.action, 'rotate'); + if (input.action !== 'rotate' || next.action !== 'rotate') return; + assert.equal(input.reason, 'Replace compromised key'); + assert.equal(new RegExp(`^dune-admin\\.${target}\\.[0-9a-f]{64}$`).test(input.key), true); + assert.equal(input.key === next.key, false); + assert.notEqual(input.operationId, next.operationId); + saveAdminAccountRequest(tab, owner, input); + const pending = readAdminAccountRecord(tab, owner); + assert.equal(pending?.kind, 'pending'); + assert.equal(pending?.input.operationId, input.operationId); + assert.equal(pending?.input.action === 'rotate' && pending.input.key === input.key, true); + assert.throws(() => saveAdminAccountRequest(tab, owner, next)); + const original = { ...disabled, enabled: true, updatedAt: disabled.updatedAt + 1 }; + const receipt = { operationId: input.operationId, replayed: true, account: original }; + for (const badAccount of [ + { ...original, id: crypto.randomUUID() }, + { ...original, enabled: false }, + { ...original, updatedAt: input.expectedUpdatedAt }, + ]) { + assert.equal(validAdminAccountResult({ ...receipt, account: badAccount }, input), false); + assert.throws(() => completeAdminAccountRequest(tab, owner, input, { ...receipt, account: badAccount })); + assert.equal(readAdminAccountRecord(tab, owner)?.kind, 'pending'); + } + assert.equal(validAdminAccountResult({ ...receipt, account: { ...original, key: 'returned-secret' } }, input), false); + completeAdminAccountRequest(tab, owner, input, receipt); + const completed = readAdminAccountRecord(tab, owner); + assert.equal(completed?.kind === 'completed' && completed.input.action === 'rotate' && + completed.input.key === input.key && completed.account.updatedAt === original.updatedAt, true); + assert.throws(() => saveAdminAccountRequest(tab, owner, next)); + const storageKey = [...tab.values.keys()][0], savedRecord = tab.values.get(storageKey)!; + tab.values.set(storageKey, JSON.stringify({ kind: 'completed', input: { ...input, target: crypto.randomUUID() }, account: original })); + assert.throws(() => readAdminAccountRecord(tab, owner)); + tab.values.set(storageKey, JSON.stringify({ kind: 'completed', input, account: { ...original, enabled: false } })); + assert.throws(() => readAdminAccountRecord(tab, owner)); + tab.values.set(storageKey, savedRecord); + clearAdminAccountRecord(tab, owner); + assert.equal(readAdminAccountRecord(tab, owner), null); +}); + +void test('a historic exact replay never presents its superseded key as current access', () => { + const owner = crypto.randomUUID(), target = crypto.randomUUID(), tab = storage(); + const input = newAdminAccountRequest({ action: 'rotate', target, expectedUpdatedAt: 1700000000000, reason: 'Staff handover' }); + if (input.action !== 'rotate') throw new Error('Expected a rotation request.'); + const receipt = { operationId: input.operationId, replayed: true, + account: { ...account(target), updatedAt: input.expectedUpdatedAt + 1 } }; + saveAdminAccountRequest(tab, owner, input); + completeAdminAccountRequest(tab, owner, input, receipt); + const completed = readAdminAccountRecord(tab, owner); + if (completed?.kind !== 'completed') throw new Error('Expected a retained one-time key.'); + const directory = { accounts: [receipt.account], total: 1, page: 1, pageSize: 25 as const }; + assert.equal(adminAccountKeyStatus(completed, directory), 'matching'); + assert.equal(adminAccountKeyStatus(completed, { ...directory, accounts: [{ ...receipt.account, updatedAt: receipt.account.updatedAt + 1 }] }), 'changed'); + assert.equal(adminAccountKeyStatus(completed, { ...directory, accounts: [{ ...receipt.account, enabled: false }] }), 'changed'); + assert.equal(adminAccountKeyStatus(completed, { ...directory, accounts: [] }), 'unverified'); +}); + void test('unavailable, corrupt or changed storage fails closed and never replaces an uncertain credential', () => { const tab = storage(), owner = crypto.randomUUID(); const input = newAdminAccountRequest({ action: 'provision', name: 'QA colleague', role: 'viewer', reason: 'QA' }); diff --git a/tests/admin-accounts.test.ts b/tests/admin-accounts.test.ts index 4ff8d62b..1048eab3 100644 --- a/tests/admin-accounts.test.ts +++ b/tests/admin-accounts.test.ts @@ -21,6 +21,11 @@ function newProvision(role: 'owner'|'operator'|'viewer' = 'viewer'): Extract { + return { action:'rotate',operationId:randomUUID(),target,expectedUpdatedAt, + key:`dune-admin.${target}.${randomBytes(32).toString('hex')}`,reason:'Replace compromised credential' }; +} + void test('account receipt migration adds metadata without changing existing games or credentials',() => { const sqlite=new DatabaseSync(':memory:'); @@ -45,9 +50,18 @@ void test('account receipt migration adds metadata without changing existing gam ]); const before=saved(); sqlite.exec(readFileSync(new URL('../drizzle/0019_admin_accounts.sql',import.meta.url),'utf8')); + const operation=randomUUID(); + sqlite.prepare(`INSERT INTO admin_account_operations + (operation_id,actor_admin_id,request_hash,target_admin_id,action,name,role,enabled,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`).run(operation,id,'a'.repeat(64),id,'role','Original owner','owner',1,1000,1000); + const previousReceipt=sqlite.prepare('SELECT * FROM admin_account_operations WHERE operation_id=?').get(operation); + sqlite.exec(readFileSync(new URL('../drizzle/0020_admin_key_rotation.sql',import.meta.url),'utf8')); + assert.deepEqual(sqlite.prepare('SELECT * FROM admin_account_operations WHERE operation_id=?').get(operation),previousReceipt); + assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n,0); + assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_retired_keys').get()!.n,0); assert.equal(saved(),before); assert.equal(sqlite.prepare('SELECT reason FROM admin_audit WHERE target_admin_id=?').get(id)!.reason,null); - assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations').get()!.n,0); + assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations').get()!.n,1); assert.throws(()=>sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(id)); assert.equal(saved(),before); } finally {sqlite.close();} @@ -194,6 +208,113 @@ void test('logout-all cannot roll back an account version and admit a stale disa assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.target.id)!.enabled,1); } finally {f.sqlite.close();} }); +void test('rotating an enabled administrator invalidates old keys and sessions while preserving account and game state',async () => { + const f=await fixture(); + try { + f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('ROTATE01','{"private":"saved"}',3,1000); + f.sqlite.prepare('INSERT INTO seats(token_hash,revoked,room_code,player_id) VALUES(?,?,?,?)').run('e'.repeat(64),0,'ROTATE01','player'); + const saved=JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]); + const oldSession=await adminLogin(f.database,f.target.key,now); + const input=newRotation(f.target.id), result=await applyAdminAccount(f.database,f.identity,input,now); + assert.equal(result.replayed,false); + assert.deepEqual({id:result.account.id,name:result.account.name,role:result.account.role,enabled:result.account.enabled}, + {id:f.target.id,name:f.target.name,role:f.target.role,enabled:true}); + assert.ok(result.account.updatedAt>1000); + const row=f.sqlite.prepare('SELECT key_hash,session_generation,updated_at FROM admin_accounts WHERE id=?').get(f.target.id)!; + assert.equal(row.key_hash,sha256(input.key));assert.equal(row.session_generation,1); + assert.equal(row.updated_at,result.account.updatedAt); + assert.ok(f.sqlite.prepare('SELECT revoked_at FROM admin_sessions WHERE token_hash=?').get(sha256(oldSession.token))!.revoked_at); + await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401)); + await assert.rejects(requireAdmin(f.database,oldSession.token,undefined,now),denied(401)); + const fresh=await adminLogin(f.database,input.key,now); + assert.equal((await requireAdmin(f.database,fresh.token,undefined,now)).id,f.target.id); + const audit=f.sqlite.prepare("SELECT action,actor_admin_id,detail,reason FROM admin_audit WHERE target_admin_id=? AND action='rotate'").all(f.target.id); + assert.equal(audit.length,1); + assert.equal(audit[0].action,'rotate'); + assert.equal(audit[0].actor_admin_id,f.owner.id); + assert.equal(audit[0].reason,input.reason); + assert.deepEqual(JSON.parse(String(audit[0].detail)),{previousEnabled:1,enabled:1}); + assert.deepEqual((await readAdminAudit(f.database,f.identity,new URLSearchParams('category=account'),now)) + .events.find(event=>event.action==='rotate' && event.targetAdminId===f.target.id)?.changes,[{field:'Enabled',before:'Yes',after:'Yes'}]); + assert.equal(f.sqlite.prepare('SELECT admin_id FROM admin_account_retired_keys WHERE key_hash=?').get(sha256(f.target.key))!.admin_id,f.target.id); + assert.deepEqual(await applyAdminAccount(f.database,f.identity,input,now),{...result,replayed:true}); + const second=newRotation(f.target.id,result.account.updatedAt); + const rotatedAgain=await applyAdminAccount(f.database,f.identity,second,now); + const revive={...newRotation(f.target.id,rotatedAgain.account.updatedAt),key:f.target.key}; + await assert.rejects(applyAdminAccount(f.database,f.identity,revive,now),denied(409)); + assert.equal(f.sqlite.prepare('SELECT key_hash FROM admin_accounts WHERE id=?').get(f.target.id)!.key_hash,sha256(second.key)); + await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401)); + await assert.rejects(requireAdmin(f.database,fresh.token,undefined,now),denied(401)); + assert.deepEqual(await applyAdminAccount(f.database,f.identity,input,now),{...result,replayed:true}); + assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_retired_keys WHERE admin_id=?').get(f.target.id)!.n,2); + assert.equal(JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]),saved); + const persisted=JSON.stringify([f.sqlite.prepare('SELECT * FROM admin_account_rotations').all(),audit,result]); + assert.equal(persisted.includes(input.key),false);assert.equal(persisted.includes(sha256(input.key)),false); + } finally {f.sqlite.close();} +}); + +void test('disabled administrators return only with a fresh key, and replay retains original metadata after later changes',async () => { + const f=await fixture(); + try { + const disable:AdminAccountInput={action:'disable',operationId:randomUUID(),target:f.target.id,expectedUpdatedAt:1000,reason:'Disable access'}; + const stopped=await applyAdminAccount(f.database,f.identity,disable,now); + await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401)); + const reuse={...newRotation(f.target.id,stopped.account.updatedAt),key:f.target.key}; + await assert.rejects(applyAdminAccount(f.database,f.identity,reuse,now),denied(409)); + assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.target.id)!.enabled,0); + const rotate=newRotation(f.target.id,stopped.account.updatedAt); + const restored=await applyAdminAccount(f.database,f.identity,rotate,now); + assert.equal(restored.account.enabled,true);assert.ok(restored.account.updatedAt>stopped.account.updatedAt); + await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401)); + assert.equal((await adminLogin(f.database,rotate.key,now)).admin.id,f.target.id); + const audit=(await readAdminAudit(f.database,f.identity,new URLSearchParams('category=account'),now)).events.find(event=>event.action==='rotate' && event.targetAdminId===f.target.id); + assert.deepEqual(audit?.changes,[{field:'Enabled',before:'No',after:'Yes'}]);assert.equal(audit?.reason,rotate.reason); + const role:AdminAccountInput={action:'role',operationId:randomUUID(),target:f.target.id,expectedUpdatedAt:restored.account.updatedAt,role:'operator',reason:'New duty'}; + await applyAdminAccount(f.database,f.identity,role,now); + assert.deepEqual(await applyAdminAccount(f.database,f.identity,rotate,now),{...restored,replayed:true}); + assert.equal(f.sqlite.prepare('SELECT role FROM admin_accounts WHERE id=?').get(f.target.id)!.role,'operator'); + assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=? AND action=?').get(f.target.id,'rotate')!.n,1); + } finally {f.sqlite.close();} +}); + +void test('rotation rejects stale versions, simultaneous target changes, other owners and reused operation IDs',async () => { + const f=await fixture(); + try { + const original=newRotation(f.target.id); + for(const bad of [{...original,key:f.owner.key},{...original,key:original.key.toUpperCase()}, + {...original,key:original.key+'\\n'},{...original,reason:original.key},{...original,unknown:true}]) + assert.equal(validAdminAccountInput(bad),false); + assert.equal(validAdminAccountInput(original),true); + const self=newRotation(f.owner.id); + await assert.rejects(applyAdminAccount(f.database,f.identity,self,now),denied(409)); + const winner=await applyAdminAccount(f.database,f.identity,original,now); + const stale=newRotation(f.target.id); + await assert.rejects(applyAdminAccount(f.database,f.identity,stale,now),denied(409)); + await assert.rejects(applyAdminAccount(f.database,f.identity,{...original,key:stale.key},now),denied(409)); + await assert.rejects(applyAdminAccount(f.database,f.identity,{...original,reason:'Changed reason'},now),denied(409)); + const another=f.provision('owner'), login=await adminLogin(f.database,another.key,now); + const other=await requireAdmin(f.database,login.token,['owner'],now); + await assert.rejects(applyAdminAccount(f.database,other,original,now),denied(409)); + const cross:AdminAccountInput={action:'role',operationId:original.operationId,target:f.target.id, + expectedUpdatedAt:winner.account.updatedAt,role:'operator',reason:'Cross-table operation ID'}; + await assert.rejects(applyAdminAccount(f.database,f.identity,cross,now),denied(409)); + const before=Number(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n); + const race=newRotation(f.target.id,winner.account.updatedAt); + f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{ + delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_accounts SET updated_at=updated_at+1 WHERE id=?').run(f.target.id); + };}; + await assert.rejects(applyAdminAccount(f.database,f.identity,race,now),denied(409)); + assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n,before); + const expired=newRotation(f.target.id,Number(f.sqlite.prepare('SELECT updated_at FROM admin_accounts WHERE id=?').get(f.target.id)!.updated_at)); + f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{ + delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_sessions SET revoked_at=1 WHERE admin_id=?').run(f.owner.id); + };}; + await assert.rejects(applyAdminAccount(f.database,f.identity,expired,now),denied(401)); + assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n,before); + await assert.rejects(applyAdminAccount(f.database,f.identity,original,now),denied(401)); + } finally {f.sqlite.close();} +}); + void test('workerd D1 applies provision, exact retry and revocation in transactional batches',async () => { const { Miniflare }=await import('miniflare'); @@ -226,6 +347,20 @@ void test('workerd D1 applies provision, exact retry and revocation in transacti const result=await applyAdminAccount(database,f.identity,disable,now); assert.equal(result.account.enabled,false); assert.equal((await db.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').bind(input.id).first<{n:number}>())?.n,2); + const rotation=newRotation(input.id,result.account.updatedAt); + const restored=await applyAdminAccount(database,f.identity,rotation,now); + assert.equal(restored.account.enabled,true); + assert.equal((await adminLogin(database,rotation.key,now)).admin.id,input.id); + await assert.rejects(adminLogin(database,input.key,now),denied(401)); + const competingA=newRotation(input.id,restored.account.updatedAt),competingB=newRotation(input.id,restored.account.updatedAt); + const outcomes=await Promise.allSettled([ + applyAdminAccount(database,f.identity,competingA,now), + applyAdminAccount(database,f.identity,competingB,now), + ]); + assert.equal(outcomes.filter(outcome=>outcome.status==='fulfilled').length,1); + assert.equal(outcomes.filter(outcome=>outcome.status==='rejected' && denied(409)(outcome.reason)).length,1); + assert.deepEqual(await applyAdminAccount(database,f.identity,rotation,now),{...restored,replayed:true}); + assert.equal((await db.prepare("SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=? AND action='rotate'").bind(input.id).first<{n:number}>())?.n,2); await assert.rejects(adminLogin(database,input.key,now),denied(401)); } finally {await worker.dispose();f.sqlite.close();} }); diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 64a2f11a..0d8d6024 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -27,6 +27,16 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.equal(account.status, 401); assert.equal(account.headers.get('set-cookie'), null); assert.deepEqual(Object.keys(await account.json()), ['error']); + const target=crypto.randomUUID(); + const rotate=await fetch(base + '/api/admin/accounts', { + method: 'POST',headers:{ ...headers,origin:base,'Content-Type':'application/json' }, + body:JSON.stringify({ action:'rotate',operationId:crypto.randomUUID(),target,expectedUpdatedAt:1000, + key:`dune-admin.${target}.${'a'.repeat(64)}`,reason:'Denied rotation request' }), + signal:AbortSignal.timeout(15000), + }); + assert.equal(rotate.status,401); + assert.equal(rotate.headers.get('set-cookie'),null); + assert.deepEqual(Object.keys(await rotate.json()),['error']); const write = await fetch(`${base}/api/admin/rooms/${room}/control`, { method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ operationId: crypto.randomUUID(), expectedRevision: 0, paused: true, joinLocked: true, reason: 'Denied QA request' }),