Rejected administrator authentication and authorization attempts are now recorded durably in a new additive admin_attempts table with a fixed reason (invalid_key_format, unknown_or_disabled_key, missing_session, unknown_or_expired_session, role_denied), the attempt kind, the attempted role and an already-known account id. No key, key hash, session token, session hash, request body, origin, address or user agent is stored, and the unknown/disabled distinction is deliberately collapsed so the lookup cannot become an account-enumeration oracle. GET /api/admin/operations?attempts=1 returns seven-day per-reason counters plus the twenty most recent attempts, newest first, to a live owner only, with no-store and the same query guard as the other reads. The operations page adds "List failed attempts" with the counters and the bounded sample, truncating each account id to eight characters. Recording is best-effort inside the rejection path, so a rejected request keeps its own answer even when the table is unavailable, and the history never locks out, disables or revokes anything by itself. Evidence: tests/admin-attempts.test.ts asserts every recorded reason, the kind/role/account fields, that successful sign-ins and privileged reads record nothing, that no credential text appears anywhere in the page, the reporting window, the twenty-row bound and the owner-only recheck after demotion, and that a dropped attempts table leaves the original rejection answer unchanged. The anonymous HTTP boundary denies ?attempts=1, and the panel was verified in the real browser against a genuine recorded rejection. The shared AdminError moved to db/admin-error.ts so the recorder and the access module no longer form a value-import cycle. Offline suite 7,801/7,801 with typecheck and lint clean. No game, seat, saved state or deployment changes.
586 lines
22 KiB
TypeScript
586 lines
22 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`),
|
|
],
|
|
);
|
|
|
|
// Removal is an access boundary, independent of saved game state and pause settings.
|
|
export const roomRemovals = sqliteTable(
|
|
'room_removals',
|
|
{
|
|
roomCode: text('room_code')
|
|
.primaryKey()
|
|
.references(() => rooms.code, { onDelete: 'cascade' }),
|
|
removed: integer('removed').notNull().default(0),
|
|
revision: integer('revision').notNull().default(0),
|
|
removedAt: integer('removed_at'),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
},
|
|
(table) => [
|
|
check('room_removals_removed', sql`${table.removed} IN (0, 1)`),
|
|
check('room_removals_revision', sql`${table.revision} >= 0`),
|
|
check(
|
|
'room_removals_time',
|
|
sql`(${table.removed} = 1 AND ${table.removedAt} IS NOT NULL) OR (${table.removed} = 0 AND ${table.removedAt} IS NULL)`,
|
|
),
|
|
],
|
|
);
|
|
|
|
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
|
|
export const adminRoomRemovals = sqliteTable(
|
|
'admin_room_removals',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
expectedVersion: integer('expected_version').notNull(),
|
|
expectedRevision: integer('expected_revision').notNull(),
|
|
appliedVersion: integer('applied_version').notNull(),
|
|
appliedRevision: integer('applied_revision').notNull(),
|
|
removed: integer('removed').notNull(),
|
|
reason: text('reason').notNull(),
|
|
beforeMetadata: text('before_metadata').notNull(),
|
|
afterMetadata: text('after_metadata').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
},
|
|
(table) => [
|
|
index('admin_room_removals_room').on(table.roomCode, table.createdAt),
|
|
],
|
|
);
|
|
|
|
// Closure freezes mutations while retaining private read access, independent of saved game state and pause settings.
|
|
export const roomClosures = sqliteTable(
|
|
'room_closures',
|
|
{
|
|
roomCode: text('room_code')
|
|
.primaryKey()
|
|
.references(() => rooms.code, { onDelete: 'cascade' }),
|
|
closed: integer('closed').notNull().default(0),
|
|
revision: integer('revision').notNull().default(0),
|
|
closedAt: integer('closed_at'),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
},
|
|
(table) => [
|
|
check('room_closures_closed', sql`${table.closed} IN (0, 1)`),
|
|
check('room_closures_revision', sql`${table.revision} >= 0`),
|
|
check(
|
|
'room_closures_time',
|
|
sql`(${table.closed} = 1 AND ${table.closedAt} IS NOT NULL) OR (${table.closed} = 0 AND ${table.closedAt} IS NULL)`,
|
|
),
|
|
],
|
|
);
|
|
|
|
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
|
|
export const adminRoomClosures = sqliteTable(
|
|
'admin_room_closures',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
expectedVersion: integer('expected_version').notNull(),
|
|
expectedRevision: integer('expected_revision').notNull(),
|
|
appliedVersion: integer('applied_version').notNull(),
|
|
appliedRevision: integer('applied_revision').notNull(),
|
|
closed: integer('closed').notNull(),
|
|
reason: text('reason').notNull(),
|
|
beforeMetadata: text('before_metadata').notNull(),
|
|
afterMetadata: text('after_metadata').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
},
|
|
(table) => [
|
|
index('admin_room_closures_room').on(table.roomCode, table.createdAt),
|
|
],
|
|
);
|
|
|
|
// Archive organizes closed rooms without changing saved games or credentials.
|
|
export const roomArchives = sqliteTable(
|
|
'room_archives',
|
|
{
|
|
roomCode: text('room_code')
|
|
.primaryKey()
|
|
.references(() => rooms.code, { onDelete: 'cascade' }),
|
|
archived: integer('archived').notNull().default(0),
|
|
revision: integer('revision').notNull().default(0),
|
|
archivedAt: integer('archived_at'),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
},
|
|
(table) => [
|
|
check('room_archives_archived', sql`${table.archived} IN (0, 1)`),
|
|
check('room_archives_revision', sql`${table.revision} >= 0`),
|
|
check(
|
|
'room_archives_time',
|
|
sql`(${table.archived} = 1 AND ${table.archivedAt} IS NOT NULL) OR (${table.archived} = 0 AND ${table.archivedAt} IS NULL)`,
|
|
),
|
|
],
|
|
);
|
|
|
|
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
|
|
export const adminRoomArchives = sqliteTable(
|
|
'admin_room_archives',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
expectedVersion: integer('expected_version').notNull(),
|
|
expectedRevision: integer('expected_revision').notNull(),
|
|
appliedVersion: integer('applied_version').notNull(),
|
|
appliedRevision: integer('applied_revision').notNull(),
|
|
archived: integer('archived').notNull(),
|
|
reason: text('reason').notNull(),
|
|
beforeMetadata: text('before_metadata').notNull(),
|
|
afterMetadata: text('after_metadata').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
},
|
|
(table) => [
|
|
index('admin_room_archives_room').on(table.roomCode, table.createdAt),
|
|
],
|
|
);
|
|
|
|
// Participant AI never gives administrators a private seat. Receipts survive room removal.
|
|
export const adminSeatAiOperations = sqliteTable(
|
|
'admin_seat_ai_operations',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
expectedVersion: integer('expected_version').notNull(),
|
|
expectedControlRevision: integer('expected_control_revision').notNull(),
|
|
appliedVersion: integer('applied_version').notNull(),
|
|
target: text('target').notNull(),
|
|
difficulty: text('difficulty').notNull(),
|
|
reason: text('reason').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
},
|
|
table => [index('admin_seat_ai_room').on(table.roomCode, table.createdAt)],
|
|
);
|
|
|
|
// 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),
|
|
],
|
|
);
|
|
|
|
// Durable neutral lobby operation receipts and safe before/after audit metadata.
|
|
export const adminLobbyOperations = sqliteTable(
|
|
'admin_lobby_operations',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
expectedVersion: integer('expected_version').notNull(),
|
|
appliedVersion: integer('applied_version').notNull(),
|
|
action: text('action').notNull(),
|
|
reason: text('reason').notNull(),
|
|
beforeConfiguration: text('before_configuration').notNull(),
|
|
afterConfiguration: text('after_configuration').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
},
|
|
(table) => [
|
|
index('admin_lobby_operations_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(),
|
|
});
|
|
|
|
// Discussion moderation follows the logical seat across recovery and handover.
|
|
export const seatDiscussionControls = sqliteTable('seat_discussion_controls', {
|
|
roomCode: text('room_code').notNull().references(() => rooms.code, { onDelete: 'cascade' }),
|
|
playerId: text('player_id').notNull(),
|
|
muted: integer('muted').notNull().default(0),
|
|
revision: integer('revision').notNull().default(0),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
}, table => [primaryKey({ columns: [table.roomCode, table.playerId] }),
|
|
check('seat_discussion_muted', sql`${table.muted} IN (0, 1)`),
|
|
check('seat_discussion_revision', sql`${table.revision} >= 0`)]);
|
|
|
|
// Keep audit receipts independent of room/account deletion, like other admin operations.
|
|
export const adminDiscussionOperations = sqliteTable('admin_discussion_operations', {
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
expectedVersion: integer('expected_version').notNull(),
|
|
expectedRevision: integer('expected_revision').notNull(),
|
|
appliedRevision: integer('applied_revision').notNull(),
|
|
target: text('target').notNull(),
|
|
muted: integer('muted').notNull(),
|
|
reason: text('reason').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, table => [index('admin_discussion_room').on(table.roomCode, table.createdAt)]);
|
|
|
|
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 role/session metadata 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('{}'),
|
|
reason: text('reason'),
|
|
},
|
|
(table) => [index('admin_audit_created_at').on(table.createdAt)],
|
|
);
|
|
|
|
// Durable idempotency receipts contain a request fingerprint, never an access key.
|
|
export const adminAccountOperations = sqliteTable(
|
|
'admin_account_operations',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
targetAdminId: text('target_admin_id').notNull(),
|
|
action: text('action').notNull(),
|
|
name: text('name').notNull(),
|
|
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
|
|
enabled: integer('enabled').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
},
|
|
(table) => [
|
|
check('admin_account_operations_action', sql`${table.action} IN ('provision','role','disable')`),
|
|
],
|
|
);
|
|
|
|
// 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',
|
|
{
|
|
operationId: text('operation_id').primaryKey(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
requestHash: text('request_hash').notNull(),
|
|
roomVersion: integer('room_version').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
sizeBytes: integer('size_bytes').notNull(),
|
|
digest: text('digest').notNull(),
|
|
payload: text('payload').notNull(),
|
|
reason: text('reason').notNull(),
|
|
},
|
|
(table) => [
|
|
index('admin_room_backups_room').on(table.roomCode, table.createdAt),
|
|
check('admin_room_backups_size', sql`${table.sizeBytes} BETWEEN 1 AND 1800000 AND ${table.sizeBytes} = length(CAST(${table.payload} AS BLOB))`),
|
|
],
|
|
);
|
|
|
|
// Each private export is independently recorded only on successful authorized download.
|
|
export const adminRoomBackupDownloads = sqliteTable(
|
|
'admin_room_backup_downloads',
|
|
{
|
|
downloadId: text('download_id').primaryKey(),
|
|
operationId: text('operation_id').notNull(),
|
|
actorAdminId: text('actor_admin_id').notNull(),
|
|
roomCode: text('room_code').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
},
|
|
(table) => [index('admin_room_backup_downloads_room').on(table.roomCode, table.createdAt)],
|
|
);
|
|
|
|
// Rejected administrator authentication and authorization attempts. Secret-free:
|
|
// only a fixed reason code, the attempted role and an already-known account id.
|
|
export const adminAttempts = sqliteTable(
|
|
'admin_attempts',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
createdAt: integer('created_at').notNull(),
|
|
kind: text('kind').notNull(),
|
|
reason: text('reason').notNull(),
|
|
role: text('role'),
|
|
accountId: text('account_id'),
|
|
},
|
|
(table) => [
|
|
index('admin_attempts_created').on(table.createdAt),
|
|
check('admin_attempts_kind', sql`${table.kind} IN ('login','session','role')`),
|
|
check(
|
|
'admin_attempts_reason',
|
|
sql`${table.reason} IN ('invalid_key_format','unknown_or_disabled_key','missing_session','unknown_or_expired_session','role_denied')`,
|
|
),
|
|
check(
|
|
'admin_attempts_role',
|
|
sql`${table.role} IS NULL OR ${table.role} IN ('owner','operator','viewer')`,
|
|
),
|
|
],
|
|
);
|