Files
Dune/db/schema.ts
T
Quintine e7d4efc3db Add configured room creation to administration
Create an explicitly owned new host seat with supported rules/modules and ordered AI seats through one live-authorized transaction. Save exact account-bound retries across refresh, record durable audit metadata, and never restore a revoked or transferred host credential. Connect invitation joining and ordinary voluntary host handover. Preserve normal readiness and Advanced preview gates.

Verified types, lint, 5240 offline tests, 52 HTTP tests and build. Eight authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one room after a lost response and refresh/retry, working host controls and saved continuation. Mobile layout passed; requested desktop viewport override did not take effect. Existing games preserved. Authenticated deployed administrator acceptance remains pending explicit QA access.
2026-09-22 11:03:12 +10:00

275 lines
9.0 KiB
TypeScript

import {
sqliteTable,
text,
integer,
primaryKey,
index,
uniqueIndex,
check,
} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const rooms = sqliteTable(
'rooms',
{
code: text('code').primaryKey(),
state: text('state').notNull(),
version: integer('version').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [index('rooms_updated_at').on(table.updatedAt)],
);
export const seats = sqliteTable('seats', {
tokenHash: text('token_hash').primaryKey(),
revoked: integer('revoked').notNull().default(0),
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
});
export const roomControls = sqliteTable(
'room_controls',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
paused: integer('paused').notNull().default(0),
joinLocked: integer('join_locked').notNull().default(0),
revision: integer('revision').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_controls_paused', sql`${table.paused} IN (0, 1)`),
check('room_controls_join_locked', sql`${table.joinLocked} IN (0, 1)`),
check('room_controls_revision', sql`${table.revision} >= 0`),
],
);
// Durable receipts are also the room operations audit. They intentionally survive
// account/room removal, and contain no credentials, game state or private cards.
export const adminRoomAudit = sqliteTable(
'admin_room_audit',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
roomCode: text('room_code').notNull(),
action: text('action').notNull().default('room_control'),
reason: text('reason').notNull(),
expectedRevision: integer('expected_revision').notNull(),
beforePaused: integer('before_paused').notNull(),
beforeJoinLocked: integer('before_join_locked').notNull(),
afterPaused: integer('after_paused').notNull(),
afterJoinLocked: integer('after_join_locked').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_audit_room').on(table.roomCode, table.createdAt),
],
);
// Durable creation receipts retain public audit metadata after room/account removal.
// Secret material is hashed; replay never inserts a replacement seat.
export const adminRoomCreations = sqliteTable(
'admin_room_creations',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
roomCode: text('room_code').notNull(),
hostId: text('host_id').notNull(),
requestHash: text('request_hash').notNull(),
sessionHash: text('session_hash').notNull().unique(),
configuration: text('configuration').notNull(),
reason: text('reason').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_creations_room').on(table.roomCode, table.createdAt),
],
);
export const seatRecoveryKeys = sqliteTable(
'seat_recovery_keys',
{
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
recoveryHash: text('recovery_hash').notNull(),
currentOperationHash: text('current_operation_hash'),
},
(table) => [primaryKey({ columns: [table.roomCode, table.playerId] })],
);
export const seatRecoveryReceipts = sqliteTable(
'seat_recovery_receipts',
{
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
operationHash: text('operation_hash').notNull(),
recoveryHash: text('recovery_hash').notNull(),
sessionHash: text('session_hash').notNull(),
},
(table) => [
primaryKey({
columns: [table.roomCode, table.playerId, table.operationHash],
}),
],
);
export const seatHandoverOffers = sqliteTable(
'seat_handover_offers',
{
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
offerHash: text('offer_hash').notNull().unique(),
secretHash: text('secret_hash').notNull(),
issuerSessionHash: text('issuer_session_hash').notNull(),
expiresAt: integer('expires_at').notNull(),
claimOperationHash: text('claim_operation_hash'),
sessionHash: text('session_hash'),
claimedAt: integer('claimed_at'),
},
(table) => [primaryKey({ columns: [table.roomCode, table.playerId] })],
);
export const seatHandoverClaimReceipts = sqliteTable(
'seat_handover_claim_receipts',
{
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
operationHash: text('operation_hash').notNull(),
offerHash: text('offer_hash').notNull(),
secretHash: text('secret_hash').notNull(),
sessionHash: text('session_hash').notNull(),
claimFence: text('claim_fence').notNull().unique(),
claimedAt: integer('claimed_at').notNull(),
},
(table) => [
primaryKey({
columns: [table.roomCode, table.playerId, table.operationHash],
}),
],
);
export const seatAiDelegations = sqliteTable(
'seat_ai_delegations',
{
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
ownerId: text('owner_id').notNull(),
grantId: text('grant_id').notNull().unique(),
delegateId: text('delegate_id').notNull(),
difficulty: text('difficulty').notNull(),
ownerSessionHash: text('owner_session_hash').notNull(),
delegateSessionHash: text('delegate_session_hash').notNull(),
expiresAt: integer('expires_at').notNull(),
usedAt: integer('used_at'),
revokedAt: integer('revoked_at'),
},
(table) => [
primaryKey({ columns: [table.roomCode, table.ownerId, table.grantId] }),
],
);
export const roomEntryReceipts = sqliteTable('room_entry_receipts', {
operationHash: text('operation_hash').primaryKey(),
requestHash: text('request_hash').notNull(),
sessionHash: text('session_hash').notNull(),
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
});
export const roomMessages = sqliteTable(
'room_messages',
{
sequence: integer('sequence').primaryKey({ autoIncrement: true }),
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
id: text('id').notNull(),
senderId: text('sender_id').notNull(),
senderSessionHash: text('sender_session_hash').notNull(),
senderName: text('sender_name').notNull(),
senderFaction: text('sender_faction').notNull(),
recipientId: text('recipient_id'),
recipientName: text('recipient_name'),
body: text('body').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
uniqueIndex('room_messages_room_id').on(table.roomCode, table.id),
index('room_messages_channel').on(
table.roomCode,
table.recipientId,
table.sequence,
),
index('room_messages_sender').on(
table.roomCode,
table.senderId,
table.createdAt,
),
],
);
export const adminAccounts = sqliteTable(
'admin_accounts',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
keyHash: text('key_hash').notNull().unique(),
enabled: integer('enabled').notNull().default(1),
sessionGeneration: integer('session_generation').notNull().default(0),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check(
'admin_accounts_role',
sql`${table.role} IN ('owner', 'operator', 'viewer')`,
),
check('admin_accounts_enabled', sql`${table.enabled} IN (0, 1)`),
check('admin_accounts_generation', sql`${table.sessionGeneration} >= 0`),
],
);
export const adminSessions = sqliteTable(
'admin_sessions',
{
tokenHash: text('token_hash').primaryKey(),
id: text('id').notNull().unique(),
adminId: text('admin_id')
.notNull()
.references(() => adminAccounts.id, { onDelete: 'cascade' }),
generation: integer('generation').notNull(),
createdAt: integer('created_at').notNull(),
expiresAt: integer('expires_at').notNull(),
revokedAt: integer('revoked_at'),
},
(table) => [index('admin_sessions_account').on(table.adminId)],
);
// Audit records intentionally have no cascading account/room dependency.
// `detail` contains safe operation IDs only, never credentials or game state.
export const adminAudit = sqliteTable(
'admin_audit',
{
id: text('id').primaryKey(),
actorAdminId: text('actor_admin_id'),
targetAdminId: text('target_admin_id').notNull(),
action: text('action').notNull(),
createdAt: integer('created_at').notNull(),
detail: text('detail').notNull().default('{}'),
},
(table) => [index('admin_audit_created_at').on(table.createdAt)],
);