From 72e07c1cc1407a5e125a015aed57c4efbf6fbf82 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:03:29 +0100 Subject: [PATCH] fix: persistent random Snowflake worker ID + clean reaction API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes addressing architectural review feedback: 1. Snowflake ID collisions: Replace process.pid-based worker ID with a cryptographically random value (0-1023) generated once at first boot and persisted to instance_settings.worker_id. Eliminates deterministic ID collisions between Docker instances that all run as PID 1. 2. Reaction API leak: Revert addReaction/removeReaction signatures to (messageId, emoji) — the store now resolves the channel internally by scanning its message cache, keeping routing logic out of the UI layer. --- packages/server/src/db/index.ts | 12 ++++++++++ packages/server/src/db/migrate.ts | 22 ++++++++++++++++- packages/server/src/db/schema.ts | 1 + packages/server/src/utils/snowflake.ts | 22 ++++++++++++++++- packages/web/src/components/chat/Message.tsx | 4 ++-- packages/web/src/stores/chatStore.ts | 25 +++++++++++++++----- 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 0cf20ab8..ffce3f9e 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import { config } from '../config.js'; import * as schema from './schema.js'; import { runMigrations } from './migrate.js'; +import { setWorkerId } from '../utils/snowflake.js'; import { mkdirSync } from 'fs'; import { dirname } from 'path'; @@ -180,6 +181,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS instance_settings ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), instance_name TEXT DEFAULT 'Backspace', + worker_id INTEGER, max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000, min_bitrate_kbps INTEGER NOT NULL DEFAULT 500, bitrate_step_kbps INTEGER NOT NULL DEFAULT 500, @@ -199,6 +201,16 @@ export function initDatabase() { sqlite.pragma('foreign_keys = ON'); createTables(sqlite); runMigrations(sqlite); + + // Initialize Snowflake worker ID from persisted value (set by migration) + const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined; + if (settings?.worker_id !== undefined && settings.worker_id !== null) { + setWorkerId(settings.worker_id); + console.log(`Snowflake worker ID: ${settings.worker_id}`); + } else { + throw new Error('Snowflake worker_id not found in instance_settings — migration failed'); + } + console.log(`Database initialized at ${config.dbPath}`); return drizzle(sqlite, { schema }); } diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 78ca6b3d..044f9a0f 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3'; +import crypto from 'crypto'; import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; export function runMigrations(db: Database.Database): void { @@ -65,7 +66,8 @@ export function runMigrations(db: Database.Database): void { { name: 'instance_settings', columns: [ - { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" } + { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" }, + { name: 'worker_id', type: 'INTEGER' } ] } ]; @@ -104,6 +106,9 @@ export function runMigrations(db: Database.Database): void { // ─── Instance settings: ensure default row exists ────────────────────────── migrateInstanceSettings(db); + // ─── Worker ID: ensure a unique Snowflake worker ID is persisted ─────────── + migrateWorkerId(db); + // ─── Admin flag: ensure at least one admin exists (first registered user) ── migrateFirstAdmin(db); @@ -133,6 +138,21 @@ function migrateFirstAdmin(db: Database.Database): void { } } +/** + * Ensure a unique Snowflake worker ID is persisted for this instance. + * Generated randomly on first boot (0-1023) and never changed. + * This prevents ID collisions between federated instances that would + * otherwise share worker_id = 1 when running as Docker PID 1. + */ +function migrateWorkerId(db: Database.Database): void { + const row = db.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number | null } | undefined; + if (!row || row.worker_id === null) { + const workerId = crypto.randomInt(0, 1024); // 0-1023 (10-bit range) + db.prepare('UPDATE instance_settings SET worker_id = ? WHERE id = 1').run(workerId); + console.log(`Migrating: Generated Snowflake worker ID ${workerId} for this instance`); + } +} + /** For each server, ensure an @everyone role exists with id === server.id */ function migrateEveryoneRoles(db: Database.Database): void { const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index f6d0df7c..911b39d7 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -180,6 +180,7 @@ export const serverFolderMembers = sqliteTable('server_folder_members', { export const instanceSettings = sqliteTable('instance_settings', { id: integer('id').primaryKey().default(1), instanceName: text('instance_name').default('Backspace'), + workerId: integer('worker_id'), maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000), minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500), bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500), diff --git a/packages/server/src/utils/snowflake.ts b/packages/server/src/utils/snowflake.ts index 89bdd988..2816920c 100644 --- a/packages/server/src/utils/snowflake.ts +++ b/packages/server/src/utils/snowflake.ts @@ -10,15 +10,35 @@ * - ~139 years of IDs from epoch * - 1024 workers * - 4096 IDs per millisecond per worker + * + * IMPORTANT: The worker ID MUST be unique per instance to prevent ID + * collisions in a federation setup. It is generated randomly at first boot + * and persisted to the database. Call setWorkerId() before generating any IDs. */ const EPOCH = 1704067200000n; // Jan 1, 2024 00:00:00 UTC -const WORKER_ID = BigInt(process.pid % 1024); +let WORKER_ID: bigint | null = null; let sequence = 0n; let lastTimestamp = -1n; +/** + * Set the worker ID for this instance. Must be called once during server + * startup, after the database is initialized, before any IDs are generated. + * The value is persisted in instance_settings.worker_id. + */ +export function setWorkerId(id: number): void { + if (id < 0 || id > 1023) { + throw new Error(`Worker ID must be 0-1023, got ${id}`); + } + WORKER_ID = BigInt(id); +} + export function generateSnowflake(): string { + if (WORKER_ID === null) { + throw new Error('Snowflake worker ID not initialized — call setWorkerId() during startup'); + } + let timestamp = BigInt(Date.now()); if (timestamp === lastTimestamp) { diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 1845e301..c32defc3 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -60,9 +60,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const toggleReaction = (emoji: string) => { const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji); if (hasReacted) { - removeReaction(message.id, emoji, channelKey); + removeReaction(message.id, emoji); } else { - addReaction(message.id, emoji, channelKey); + addReaction(message.id, emoji); } }; diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 48a9cab9..3d4a7ee3 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -43,8 +43,8 @@ interface ChatState { addRealtimeMessage: (channelId: string, message: MessageWithUser) => void; updateMessage: (message: MessageWithUser) => void; removeMessage: (messageId: string, channelId: string) => void; - addReaction: (messageId: string, emoji: string, channelId: string) => void; - removeReaction: (messageId: string, emoji: string, channelId: string) => void; + addReaction: (messageId: string, emoji: string) => void; + removeReaction: (messageId: string, emoji: string) => void; onReactionAdded: (messageId: string, reaction: any) => void; onReactionRemoved: (messageId: string, userId: string, emoji: string) => void; setTyping: (channelId: string, userId: string, username: string) => void; @@ -57,6 +57,16 @@ interface ChatState { onChannelAck: (channelId: string, messageId: string) => void; } +/** Find which channel a message belongs to by scanning the message cache. */ +function findChannelForMessage(messages: Map, messageId: string): string | null { + for (const [channelId, msgs] of messages) { + if (msgs.some(m => m.id === messageId)) { + return channelId; + } + } + return null; +} + export const useChatStore = create((set, get) => ({ messages: new Map(), currentChannelId: null, @@ -356,13 +366,16 @@ export const useChatStore = create((set, get) => ({ }); }, - addReaction: (messageId: string, emoji: string, channelId: string) => { - const origin = getChannelOrigin(channelId); + addReaction: (messageId: string, emoji: string) => { + // Resolve the channel from our message cache so the UI doesn't need to pass it + const channelId = findChannelForMessage(get().messages, messageId); + const origin = channelId ? getChannelOrigin(channelId) : ''; wsSend({ type: 'reaction_add', messageId, emoji }, origin); }, - removeReaction: (messageId: string, emoji: string, channelId: string) => { - const origin = getChannelOrigin(channelId); + removeReaction: (messageId: string, emoji: string) => { + const channelId = findChannelForMessage(get().messages, messageId); + const origin = channelId ? getChannelOrigin(channelId) : ''; wsSend({ type: 'reaction_remove', messageId, emoji }, origin); },