OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the call plays the clip locally, instead of mixing it into the presser's microphone or publishing a LiveKit track. No upstream bandwidth, no media stack changes, and the clip is not degraded by voice processing. Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter broadcasts a space room to the whole space, which is right for the presence the sidebar shows and wrong for anything audible. The cooldown is enforced server-side — a client-side one only slows down people not trying to abuse it, and a soundboard is the easiest thing here to turn into a weapon. Playing is open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE. Account menu: the name in the user bar had cursor-pointer and no handler, so the interface was already promising a click that did nothing. Offers profile, status and copy-id — not the Clips or account switching the reference design shows, which would be dead UI here. Call timer: startedAt comes from the server, so a late joiner sees the call's age rather than their own arrival. Empty space rooms are destroyed already, which is what makes the next call start from zero — no reset logic needed.
648 lines
29 KiB
TypeScript
648 lines
29 KiB
TypeScript
import { sqliteTable, text, integer, primaryKey, foreignKey, real, unique, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
import { sql } from 'drizzle-orm';
|
|
|
|
export const users = sqliteTable('users', {
|
|
id: text('id').primaryKey(),
|
|
username: text('username').unique().notNull(),
|
|
displayName: text('display_name'),
|
|
passwordHash: text('password_hash').notNull(),
|
|
avatar: text('avatar'),
|
|
status: text('status').default('offline'),
|
|
customStatus: text('custom_status'),
|
|
isAdmin: integer('is_admin').default(0),
|
|
homeInstance: text('home_instance'),
|
|
homeUserId: text('home_user_id'),
|
|
replicatedInstances: text('replicated_instances').default('[]'),
|
|
banner: text('banner'),
|
|
accentColor: text('accent_color'),
|
|
avatarColor: text('avatar_color'),
|
|
bio: text('bio'),
|
|
isDeleted: integer('is_deleted').default(0),
|
|
discoverable: integer('discoverable').default(1),
|
|
profileUpdatedAt: integer('profile_updated_at'),
|
|
passwordChangedAt: integer('password_changed_at'),
|
|
showActivity: integer('show_activity').notNull().default(1),
|
|
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
|
federationHealPending: integer('federation_heal_pending').default(0),
|
|
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
|
createdAt: integer('created_at').notNull(),
|
|
});
|
|
|
|
export const spaces = sqliteTable('spaces', {
|
|
id: text('id').primaryKey(),
|
|
name: text('name').notNull(),
|
|
icon: text('icon'),
|
|
banner: text('banner'),
|
|
avatarColor: text('avatar_color'),
|
|
ownerId: text('owner_id').notNull().references(() => users.id),
|
|
inviteCode: text('invite_code').unique(),
|
|
visibility: text('visibility').default('private'),
|
|
description: text('description'),
|
|
createdAt: integer('created_at').notNull(),
|
|
});
|
|
|
|
export const spaceMembers = sqliteTable('space_members', {
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
nickname: text('nickname'),
|
|
joinedAt: integer('joined_at').notNull(),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
|
userIdx: index('idx_space_members_user_id').on(table.userId),
|
|
}));
|
|
|
|
export const channelCategories = sqliteTable('channel_categories', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
name: text('name').notNull(),
|
|
position: integer('position').default(0),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
spaceIdx: index('idx_channel_categories_space_id').on(table.spaceId),
|
|
}));
|
|
|
|
export const channels = sqliteTable('channels', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
name: text('name').notNull(),
|
|
type: text('type').notNull(),
|
|
topic: text('topic'),
|
|
position: integer('position').default(0),
|
|
categoryId: text('category_id'),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
spaceIdx: index('idx_channels_space_id').on(table.spaceId),
|
|
}));
|
|
|
|
export const messages = sqliteTable('messages', {
|
|
id: text('id').primaryKey(),
|
|
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id),
|
|
replyToId: text('reply_to_id'),
|
|
content: text('content'),
|
|
editedAt: integer('edited_at'),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
replyToFk: foreignKey({
|
|
columns: [table.replyToId],
|
|
foreignColumns: [table.id],
|
|
}).onDelete('set null'),
|
|
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
|
userIdx: index('idx_messages_user_id').on(table.userId),
|
|
}));
|
|
|
|
export const attachments = sqliteTable('attachments', {
|
|
id: text('id').primaryKey(),
|
|
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
|
|
dmMessageId: text('dm_message_id').references(() => dmMessages.id, { onDelete: 'cascade' }),
|
|
uploaderId: text('uploader_id'),
|
|
filename: text('filename').notNull(),
|
|
originalName: text('original_name').notNull(),
|
|
mimetype: text('mimetype').notNull(),
|
|
size: integer('size').notNull(),
|
|
thumbnailFilename: text('thumbnail_filename'),
|
|
width: integer('width'),
|
|
height: integer('height'),
|
|
duration: real('duration'),
|
|
// Tri-state web-playability for video attachments: 1 = decodable in a
|
|
// browser <video>, 0 = known-undecodable (e.g. HEVC .mov), NULL = unknown
|
|
// (codec unprobed / non-video). Drives the client's download fallback.
|
|
playable: integer('playable', { mode: 'boolean' }),
|
|
sourceUrl: text('source_url'),
|
|
federationStatus: text('federation_status'),
|
|
federationMeta: text('federation_meta'),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
messageIdx: index('idx_attachments_message_id').on(table.messageId),
|
|
dmMessageIdx: index('idx_attachments_dm_message_id').on(table.dmMessageId),
|
|
}));
|
|
|
|
export const embeds = sqliteTable('embeds', {
|
|
id: text('id').primaryKey(),
|
|
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
|
|
dmMessageId: text('dm_message_id').references(() => dmMessages.id, { onDelete: 'cascade' }),
|
|
url: text('url').notNull(),
|
|
embedType: text('embed_type').notNull(),
|
|
provider: text('provider'),
|
|
title: text('title'),
|
|
description: text('description'),
|
|
image: text('image'),
|
|
embedUrl: text('embed_url'),
|
|
width: integer('width'),
|
|
height: integer('height'),
|
|
color: text('color'),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
messageIdx: index('idx_embeds_message_id').on(table.messageId),
|
|
dmMessageIdx: index('idx_embeds_dm_message_id').on(table.dmMessageId),
|
|
}));
|
|
|
|
export const dmChannels = sqliteTable('dm_channels', {
|
|
id: text('id').primaryKey(),
|
|
ownerId: text('owner_id'),
|
|
federatedId: text('federated_id'),
|
|
ownerHomeUserId: text('owner_home_user_id'),
|
|
ownerHomeInstance: text('owner_home_instance'),
|
|
deletedAt: integer('deleted_at'),
|
|
createdAt: integer('created_at').notNull(),
|
|
name: text('name'),
|
|
icon: text('icon'),
|
|
metadataUpdatedAt: integer('metadata_updated_at').default(0).notNull(),
|
|
}, (table) => ({
|
|
federatedIdx: uniqueIndex('idx_dm_federated')
|
|
.on(table.federatedId)
|
|
.where(sql`federated_id IS NOT NULL`),
|
|
}));
|
|
|
|
export const dmMembers = sqliteTable('dm_members', {
|
|
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
closed: integer('closed').default(0),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.dmChannelId, table.userId] }),
|
|
userIdx: index('idx_dm_members_user_id').on(table.userId),
|
|
}));
|
|
|
|
export const dmMessages = sqliteTable('dm_messages', {
|
|
id: text('id').primaryKey(),
|
|
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id),
|
|
replyToId: text('reply_to_id'),
|
|
content: text('content'),
|
|
type: text('type').notNull().default('user'),
|
|
editedAt: integer('edited_at'),
|
|
sourceInstance: text('source_instance'),
|
|
sourceMessageId: text('source_message_id'),
|
|
encryptionVersion: integer('encryption_version').default(0),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
replyToFk: foreignKey({
|
|
columns: [table.replyToId],
|
|
foreignColumns: [table.id],
|
|
}).onDelete('set null'),
|
|
channelIdx: index('idx_dm_messages_dm_channel_id').on(table.dmChannelId),
|
|
userIdx: index('idx_dm_messages_user_id').on(table.userId),
|
|
sourceUniqueIdx: uniqueIndex('idx_dm_messages_source_unique')
|
|
.on(table.sourceInstance, table.sourceMessageId)
|
|
.where(sql`source_instance IS NOT NULL`),
|
|
}));
|
|
|
|
export const friends = sqliteTable('friends', {
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
friendId: text('friend_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.userId, table.friendId] }),
|
|
userIdx: index('idx_friends_user_id').on(table.userId),
|
|
friendIdx: index('idx_friends_friend_id').on(table.friendId),
|
|
}));
|
|
|
|
export const friendRequests = sqliteTable('friend_requests', {
|
|
id: text('id').primaryKey(),
|
|
fromId: text('from_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
toId: text('to_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
status: text('status').default('pending'), // 'pending', 'accepted', 'declined'
|
|
createdAt: integer('created_at').notNull(),
|
|
relayMessageId: text('relay_message_id'), // entityId of the originating relay event; null for local-only requests. Used by the rollback hook in federationRollback.ts to find rows for deletion when a federated friend_request_create is permanently rejected.
|
|
}, (table) => ({
|
|
toIdx: index('idx_friend_requests_to_id').on(table.toId),
|
|
fromIdx: index('idx_friend_requests_from_id').on(table.fromId),
|
|
relayMessageIdx: index('idx_friend_requests_relay_message_id').on(table.relayMessageId),
|
|
}));
|
|
|
|
export const reactions = sqliteTable('reactions', {
|
|
id: text('id').primaryKey(),
|
|
messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
emoji: text('emoji').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
messageIdx: index('idx_reactions_message_id').on(table.messageId),
|
|
}));
|
|
|
|
export const dmReactions = sqliteTable('dm_reactions', {
|
|
id: text('id').primaryKey(),
|
|
dmMessageId: text('dm_message_id').notNull().references(() => dmMessages.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
emoji: text('emoji').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
dmMessageIdx: index('idx_dm_reactions_dm_message_id').on(table.dmMessageId),
|
|
}));
|
|
|
|
export const roles = sqliteTable('roles', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
name: text('name').notNull(),
|
|
color: text('color').default('#b9bbbe'),
|
|
position: integer('position').default(0),
|
|
permissions: text('permissions'), // JSON string of permission keys
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
spaceIdx: index('idx_roles_space_id').on(table.spaceId),
|
|
}));
|
|
|
|
export const memberRoles = sqliteTable('member_roles', {
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
roleId: text('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.spaceId, table.userId, table.roleId] }),
|
|
userSpaceIdx: index('idx_member_roles_user_id_space_id').on(table.userId, table.spaceId),
|
|
}));
|
|
|
|
export const channelOverrides = sqliteTable('channel_overrides', {
|
|
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
|
|
targetType: text('target_type').notNull(), // 'role' | 'member'
|
|
targetId: text('target_id').notNull(), // role ID or user ID
|
|
allow: text('allow').notNull().default('0'), // BigInt decimal string
|
|
deny: text('deny').notNull().default('0'), // BigInt decimal string
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.channelId, table.targetType, table.targetId] }),
|
|
channelIdx: index('idx_channel_overrides_channel_id').on(table.channelId),
|
|
}));
|
|
|
|
export const categoryOverrides = sqliteTable('category_overrides', {
|
|
categoryId: text('category_id').notNull().references(() => channelCategories.id, { onDelete: 'cascade' }),
|
|
targetType: text('target_type').notNull(),
|
|
targetId: text('target_id').notNull(),
|
|
allow: text('allow').notNull().default('0'),
|
|
deny: text('deny').notNull().default('0'),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.categoryId, table.targetType, table.targetId] }),
|
|
categoryIdx: index('idx_category_overrides_category_id').on(table.categoryId),
|
|
}));
|
|
|
|
export const readStates = sqliteTable('read_states', {
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
channelId: text('channel_id').notNull(),
|
|
lastReadMessageId: text('last_read_message_id').notNull(),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.userId, table.channelId] }),
|
|
userIdx: index('idx_read_states_user_id').on(table.userId),
|
|
}));
|
|
|
|
export const spaceFolders = sqliteTable('space_folders', {
|
|
id: text('id').primaryKey(),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
name: text('name'),
|
|
color: text('color'),
|
|
position: integer('position').default(0),
|
|
createdAt: integer('created_at').notNull(),
|
|
});
|
|
|
|
export const spaceFolderMembers = sqliteTable('space_folder_members', {
|
|
folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }),
|
|
spaceId: text('space_id').notNull(), // No FK — federated space IDs don't exist locally
|
|
position: integer('position').default(0),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.folderId, table.spaceId] }),
|
|
}));
|
|
|
|
export const userSpaceLayout = sqliteTable('user_space_layout', {
|
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
|
layout: text('layout').notNull().default('[]'),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
});
|
|
|
|
export const instanceSettings = sqliteTable('instance_settings', {
|
|
id: integer('id').primaryKey().default(1),
|
|
instanceName: text('instance_name').default('Backspace'),
|
|
workerId: integer('worker_id'),
|
|
instanceId: text('instance_id'),
|
|
discoveryEnabled: integer('discovery_enabled').notNull().default(1),
|
|
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
|
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
|
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
|
|
allowedResolutions: text('allowed_resolutions').notNull().default('540,720,1080'),
|
|
allowedFramerates: text('allowed_framerates').notNull().default('30,45,60'),
|
|
maxResolution: integer('max_resolution').notNull().default(1080),
|
|
maxFramerate: integer('max_framerate').notNull().default(60),
|
|
registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit
|
|
federatedRegistrationOpen: integer('federated_registration_open').notNull().default(1),
|
|
gifApiKey: text('gif_api_key'),
|
|
bitrateMatrixOverrides: text('bitrate_matrix_overrides'),
|
|
allowCustomBitrate: integer('allow_custom_bitrate').notNull().default(1),
|
|
maxUploadSizeBytes: integer('max_upload_size_bytes'),
|
|
federationRelayEnabled: integer('federation_relay_enabled').notNull().default(1),
|
|
federationRelayTtlDays: integer('federation_relay_ttl_days').notNull().default(30),
|
|
defaultAutoRotateIntervalDays: integer('default_auto_rotate_interval_days').notNull().default(90),
|
|
autoAcceptPeering: integer('auto_accept_peering').notNull().default(1),
|
|
updatedAt: integer('updated_at').notNull(),
|
|
});
|
|
|
|
export const bans = sqliteTable('bans', {
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
reason: text('reason'),
|
|
bannedBy: text('banned_by').references(() => users.id),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
|
spaceIdx: index('idx_bans_space_id').on(table.spaceId),
|
|
}));
|
|
|
|
export const joinRequests = sqliteTable('join_requests', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
message: text('message'),
|
|
status: text('status').notNull().default('pending'),
|
|
decidedBy: text('decided_by').references(() => users.id),
|
|
createdAt: integer('created_at').notNull(),
|
|
decidedAt: integer('decided_at'),
|
|
}, (table) => ({
|
|
spaceStatusIdx: index('idx_join_requests_space_id_status').on(table.spaceId, table.status),
|
|
}));
|
|
|
|
export const voiceRestrictions = sqliteTable('voice_restrictions', {
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
restrictionType: text('restriction_type').notNull(), // 'mute' | 'deafen'
|
|
moderatorId: text('moderator_id').references(() => users.id),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }),
|
|
spaceIdx: index('idx_voice_restrictions_space_id').on(table.spaceId),
|
|
}));
|
|
|
|
export const federationPeers = sqliteTable('federation_peers', {
|
|
id: text('id').primaryKey(),
|
|
origin: text('origin').notNull().unique(),
|
|
instanceName: text('instance_name'),
|
|
hmacSecret: text('hmac_secret').notNull(),
|
|
status: text('status').notNull().default('active'),
|
|
lastSeenAt: integer('last_seen_at'),
|
|
lastFailureAt: integer('last_failure_at'),
|
|
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
|
|
consecutiveAuthFailures: integer('consecutive_auth_failures').notNull().default(0),
|
|
lastProbeAt: integer('last_probe_at'),
|
|
probeAttempts: integer('probe_attempts').notNull().default(0),
|
|
lastSyncedAt: integer('last_synced_at').default(0),
|
|
remoteMaxUploadSize: integer('remote_max_upload_size'),
|
|
nonceSupported: integer('nonce_supported').notNull().default(0),
|
|
pendingHmacSecret: text('pending_hmac_secret'),
|
|
secretRotationAt: integer('secret_rotation_at'),
|
|
secretRotatedAt: integer('secret_rotated_at'),
|
|
autoRotateIntervalDays: integer('auto_rotate_interval_days').notNull().default(90),
|
|
createdAt: integer('created_at').notNull(),
|
|
approvalToken: text('approval_token'),
|
|
peerInstanceId: text('peer_instance_id'),
|
|
observedPeerInstanceId: text('observed_peer_instance_id'),
|
|
needsAttentionReason: text('needs_attention_reason'),
|
|
});
|
|
|
|
// Records a detected federated-peer reset (same origin, new instance epoch).
|
|
// One row per origin; upserted when a live epoch change is observed, resolved
|
|
// once stale replicated identities are healed.
|
|
export const federationResetEvents = sqliteTable('federation_reset_events', {
|
|
origin: text('origin').primaryKey(),
|
|
deadEpoch: text('dead_epoch').notNull(),
|
|
newEpoch: text('new_epoch'),
|
|
detectedAt: integer('detected_at').notNull(),
|
|
resolvedAt: integer('resolved_at'),
|
|
stubCount: integer('stub_count').notNull().default(0),
|
|
orphanedAccountCount: integer('orphaned_account_count').notNull().default(0),
|
|
acknowledgedAt: integer('acknowledged_at'),
|
|
});
|
|
|
|
// One-time proof tokens for detached-account re-attach (re-attach spec §3.1).
|
|
// Minted by POST /api/auth/attach-proof for a logged-in native user, verified
|
|
// once by a peer via POST /api/federation/verify-attach-proof. Expired/used
|
|
// rows are deleted opportunistically on each mint.
|
|
export const federationAttachProofs = sqliteTable('federation_attach_proofs', {
|
|
token: text('token').primaryKey(),
|
|
homeUserId: text('home_user_id').notNull(),
|
|
targetDomain: text('target_domain').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
expiresAt: integer('expires_at').notNull(),
|
|
usedAt: integer('used_at'),
|
|
});
|
|
|
|
// SQL-level CHECK constraint enforces (direction='inbound' → hmac_secret NOT NULL).
|
|
// See packages/server/drizzle/0003_brave_inhumans.sql. drizzle-kit cannot represent
|
|
// CHECK constraints in its snapshot, so any future migration that recreates this
|
|
// table MUST re-add the CHECK clause by hand. The snapshot WILL silently drop it
|
|
// otherwise.
|
|
export const peerApprovalRequests = sqliteTable('peer_approval_requests', {
|
|
id: text('id').primaryKey(),
|
|
origin: text('origin').notNull(),
|
|
direction: text('direction', { enum: ['inbound', 'outbound'] }).notNull().default('inbound'),
|
|
instanceName: text('instance_name'),
|
|
hmacSecret: text('hmac_secret'),
|
|
requestedAt: integer('requested_at').notNull(),
|
|
expiresAt: integer('expires_at').notNull(),
|
|
approvalToken: text('approval_token'),
|
|
}, (table) => ({
|
|
uniqOriginDirection: unique().on(table.origin, table.direction),
|
|
}));
|
|
|
|
export const peerApprovalSubscribers = sqliteTable('peer_approval_subscribers', {
|
|
id: text('id').primaryKey(),
|
|
requestId: text('request_id').notNull().references(() => peerApprovalRequests.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
triggerReason: text('trigger_reason').notNull(),
|
|
triggerTarget: text('trigger_target').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
uniqSubscription: unique().on(table.requestId, table.userId, table.triggerReason, table.triggerTarget),
|
|
userIdx: index('idx_peer_approval_subscribers_user_id').on(table.userId),
|
|
}));
|
|
|
|
export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', {
|
|
id: text('id').primaryKey(),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
kind: text('kind', { enum: ['approved', 'denied', 'expired'] }).notNull(),
|
|
peerOrigin: text('peer_origin').notNull(),
|
|
triggerReason: text('trigger_reason').notNull(),
|
|
triggerTarget: text('trigger_target').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
readAt: integer('read_at'),
|
|
}, (table) => ({
|
|
userIdx: index('idx_peer_approval_notifications_user_id').on(table.userId),
|
|
}));
|
|
|
|
export const federationOutbox = sqliteTable('federation_outbox', {
|
|
id: text('id').primaryKey(),
|
|
peerId: text('peer_id').notNull().references(() => federationPeers.id, { onDelete: 'cascade' }),
|
|
contextId: text('context_id').notNull(),
|
|
entityId: text('entity_id').notNull(),
|
|
contextType: text('context_type').notNull().default('dm'),
|
|
eventType: text('event_type').notNull(),
|
|
payload: text('payload').notNull(),
|
|
encryptionVersion: integer('encryption_version').default(0),
|
|
attempts: integer('attempts').default(0),
|
|
nextRetryAt: integer('next_retry_at').notNull(),
|
|
expiresAt: integer('expires_at').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
uniquePerPeerMessage: unique().on(table.peerId, table.entityId),
|
|
retryIdx: index('idx_outbox_retry').on(table.nextRetryAt),
|
|
}));
|
|
|
|
export const federationFileQueue = sqliteTable('federation_file_queue', {
|
|
id: text('id').primaryKey(),
|
|
peerOrigin: text('peer_origin').notNull(),
|
|
dmMessageId: text('dm_message_id').notNull(),
|
|
sourceUrl: text('source_url').notNull(),
|
|
targetFilename: text('target_filename'),
|
|
originalName: text('original_name').notNull(),
|
|
mimetype: text('mimetype').notNull(),
|
|
size: integer('size').notNull(),
|
|
status: text('status').notNull().default('pending'),
|
|
rejectionReason: text('rejection_reason'),
|
|
attempts: integer('attempts').default(0),
|
|
nextRetryAt: integer('next_retry_at').notNull(),
|
|
expiresAt: integer('expires_at').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
});
|
|
|
|
export const federationMutationLog = sqliteTable('federation_mutation_log', {
|
|
id: text('id').primaryKey(),
|
|
entityId: text('entity_id').notNull(),
|
|
contextId: text('context_id').notNull(),
|
|
contextType: text('context_type').notNull().default('dm'),
|
|
mutationType: text('mutation_type').notNull(),
|
|
mutatedAt: integer('mutated_at').notNull(),
|
|
payload: text('payload'),
|
|
}, (table) => ({
|
|
timeIdx: index('idx_mutation_log_time').on(table.mutatedAt),
|
|
}));
|
|
|
|
export const userFederationRegistry = sqliteTable('user_federation_registry', {
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
origin: text('origin').notNull(),
|
|
label: text('label').notNull().default(''),
|
|
username: text('username').notNull().default(''),
|
|
remoteUserId: text('remote_user_id').notNull().default(''),
|
|
status: text('status').notNull().default('connected'),
|
|
addedAt: integer('added_at').notNull(),
|
|
lastConnectedAt: integer('last_connected_at'),
|
|
disconnectedAt: integer('disconnected_at'),
|
|
errorMessage: text('error_message'),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.userId, table.origin] }),
|
|
}));
|
|
|
|
export const inviteLinks = sqliteTable('invite_links', {
|
|
id: text('id').primaryKey(),
|
|
token: text('token').notNull().unique(),
|
|
name: text('name').notNull(),
|
|
createdBy: text('created_by').notNull().references(() => users.id),
|
|
createdAt: integer('created_at').notNull(),
|
|
maxUses: integer('max_uses'),
|
|
usedCount: integer('used_count').notNull().default(0),
|
|
expiresAt: integer('expires_at'),
|
|
revokedAt: integer('revoked_at'),
|
|
}, (table) => ({
|
|
createdAtIdx: index('idx_invite_links_created_at').on(table.createdAt),
|
|
}));
|
|
|
|
export const inviteRedemptions = sqliteTable('invite_redemptions', {
|
|
id: text('id').primaryKey(),
|
|
inviteId: text('invite_id').notNull().references(() => inviteLinks.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
registrantUsername: text('registrant_username').notNull(),
|
|
redeemedAt: integer('redeemed_at').notNull(),
|
|
}, (table) => ({
|
|
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
|
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
|
|
}));
|
|
|
|
/**
|
|
* Spotify tokens, one row per user.
|
|
*
|
|
* Kept server-side on purpose: refreshing requires the client secret, so the
|
|
* browser never holds a Spotify token at all — it asks this server what is
|
|
* playing and this server talks to Spotify.
|
|
*/
|
|
export const spotifyConnections = sqliteTable('spotify_connections', {
|
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
|
accessToken: text('access_token').notNull(),
|
|
refreshToken: text('refresh_token').notNull(),
|
|
// Epoch millis at which accessToken stops working.
|
|
expiresAt: integer('expires_at').notNull(),
|
|
spotifyUserId: text('spotify_user_id'),
|
|
createdAt: integer('created_at').notNull(),
|
|
});
|
|
|
|
/**
|
|
* Favourited GIFs, one row per user per GIF.
|
|
*
|
|
* Stores the whole result rather than an id: the provider offers no lookup by
|
|
* id, so a favourites tab that only kept ids could not render without
|
|
* re-searching for something the user may never find again.
|
|
*/
|
|
export const gifFavorites = sqliteTable('gif_favorites', {
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
gifId: text('gif_id').notNull(),
|
|
title: text('title').notNull(),
|
|
previewUrl: text('preview_url').notNull(),
|
|
url: text('url').notNull(),
|
|
width: integer('width').notNull(),
|
|
height: integer('height').notNull(),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
|
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
|
}));
|
|
|
|
/**
|
|
* Append-only record of who changed what in a space.
|
|
*
|
|
* Deliberately generic (action + target + JSON metadata) rather than a column
|
|
* per event type: new actions must not require a migration. Statistics read
|
|
* this same table — two features, one mechanism, instead of two logs that
|
|
* drift apart.
|
|
*/
|
|
export const auditEvents = sqliteTable('audit_events', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
actorId: text('actor_id').references(() => users.id, { onDelete: 'set null' }),
|
|
action: text('action').notNull(),
|
|
targetType: text('target_type'),
|
|
targetId: text('target_id'),
|
|
// JSON blob; shape depends on `action`. Never trusted for permissions.
|
|
metadata: text('metadata'),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
|
|
actorIdx: index('idx_audit_events_actor').on(table.actorId),
|
|
}));
|
|
|
|
/**
|
|
* One row per stay in a voice room, closed when the user leaves.
|
|
*
|
|
* Separate from `auditEvents` on purpose: that table records points in time,
|
|
* while a call is an interval. Storing joins and leaves as separate point
|
|
* events would make every statistics query pair rows by hand and guess at
|
|
* joins whose leave never arrived (a crash, a restart).
|
|
*
|
|
* `endedAt` null means still connected. `spaceId` is null for DM calls.
|
|
*/
|
|
export const voiceSessions = sqliteTable('voice_sessions', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
|
|
channelId: text('channel_id').notNull(),
|
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
startedAt: integer('started_at').notNull(),
|
|
endedAt: integer('ended_at'),
|
|
}, (table) => ({
|
|
spaceIdx: index('idx_voice_sessions_space_started').on(table.spaceId, table.startedAt),
|
|
userIdx: index('idx_voice_sessions_user').on(table.userId),
|
|
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
|
|
}));
|
|
|
|
/** Soundboard clips, per space. The file lives in the normal upload dir. */
|
|
export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
|
id: text('id').primaryKey(),
|
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
|
name: text('name').notNull(),
|
|
filename: text('filename').notNull(),
|
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
|
createdAt: integer('created_at').notNull(),
|
|
}, (table) => ({
|
|
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
|
}));
|