fix: persistent random Snowflake worker ID + clean reaction API
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.
This commit is contained in:
@@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { runMigrations } from './migrate.js';
|
import { runMigrations } from './migrate.js';
|
||||||
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
@@ -180,6 +181,7 @@ function createTables(db: Database.Database): void {
|
|||||||
CREATE TABLE IF NOT EXISTS instance_settings (
|
CREATE TABLE IF NOT EXISTS instance_settings (
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
instance_name TEXT DEFAULT 'Backspace',
|
instance_name TEXT DEFAULT 'Backspace',
|
||||||
|
worker_id INTEGER,
|
||||||
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
||||||
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
||||||
bitrate_step_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');
|
sqlite.pragma('foreign_keys = ON');
|
||||||
createTables(sqlite);
|
createTables(sqlite);
|
||||||
runMigrations(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}`);
|
console.log(`Database initialized at ${config.dbPath}`);
|
||||||
return drizzle(sqlite, { schema });
|
return drizzle(sqlite, { schema });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||||
|
|
||||||
export function runMigrations(db: Database.Database): void {
|
export function runMigrations(db: Database.Database): void {
|
||||||
@@ -65,7 +66,8 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
{
|
{
|
||||||
name: 'instance_settings',
|
name: 'instance_settings',
|
||||||
columns: [
|
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 ──────────────────────────
|
// ─── Instance settings: ensure default row exists ──────────────────────────
|
||||||
migrateInstanceSettings(db);
|
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) ──
|
// ─── Admin flag: ensure at least one admin exists (first registered user) ──
|
||||||
migrateFirstAdmin(db);
|
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 */
|
/** For each server, ensure an @everyone role exists with id === server.id */
|
||||||
function migrateEveryoneRoles(db: Database.Database): void {
|
function migrateEveryoneRoles(db: Database.Database): void {
|
||||||
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
|
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export const serverFolderMembers = sqliteTable('server_folder_members', {
|
|||||||
export const instanceSettings = sqliteTable('instance_settings', {
|
export const instanceSettings = sqliteTable('instance_settings', {
|
||||||
id: integer('id').primaryKey().default(1),
|
id: integer('id').primaryKey().default(1),
|
||||||
instanceName: text('instance_name').default('Backspace'),
|
instanceName: text('instance_name').default('Backspace'),
|
||||||
|
workerId: integer('worker_id'),
|
||||||
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
||||||
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
||||||
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
|
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
|
||||||
|
|||||||
@@ -10,15 +10,35 @@
|
|||||||
* - ~139 years of IDs from epoch
|
* - ~139 years of IDs from epoch
|
||||||
* - 1024 workers
|
* - 1024 workers
|
||||||
* - 4096 IDs per millisecond per worker
|
* - 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 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 sequence = 0n;
|
||||||
let lastTimestamp = -1n;
|
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 {
|
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());
|
let timestamp = BigInt(Date.now());
|
||||||
|
|
||||||
if (timestamp === lastTimestamp) {
|
if (timestamp === lastTimestamp) {
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
const toggleReaction = (emoji: string) => {
|
const toggleReaction = (emoji: string) => {
|
||||||
const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji);
|
const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji);
|
||||||
if (hasReacted) {
|
if (hasReacted) {
|
||||||
removeReaction(message.id, emoji, channelKey);
|
removeReaction(message.id, emoji);
|
||||||
} else {
|
} else {
|
||||||
addReaction(message.id, emoji, channelKey);
|
addReaction(message.id, emoji);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ interface ChatState {
|
|||||||
addRealtimeMessage: (channelId: string, message: MessageWithUser) => void;
|
addRealtimeMessage: (channelId: string, message: MessageWithUser) => void;
|
||||||
updateMessage: (message: MessageWithUser) => void;
|
updateMessage: (message: MessageWithUser) => void;
|
||||||
removeMessage: (messageId: string, channelId: string) => void;
|
removeMessage: (messageId: string, channelId: string) => void;
|
||||||
addReaction: (messageId: string, emoji: string, channelId: string) => void;
|
addReaction: (messageId: string, emoji: string) => void;
|
||||||
removeReaction: (messageId: string, emoji: string, channelId: string) => void;
|
removeReaction: (messageId: string, emoji: string) => void;
|
||||||
onReactionAdded: (messageId: string, reaction: any) => void;
|
onReactionAdded: (messageId: string, reaction: any) => void;
|
||||||
onReactionRemoved: (messageId: string, userId: string, emoji: string) => void;
|
onReactionRemoved: (messageId: string, userId: string, emoji: string) => void;
|
||||||
setTyping: (channelId: string, userId: string, username: string) => void;
|
setTyping: (channelId: string, userId: string, username: string) => void;
|
||||||
@@ -57,6 +57,16 @@ interface ChatState {
|
|||||||
onChannelAck: (channelId: string, messageId: string) => void;
|
onChannelAck: (channelId: string, messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find which channel a message belongs to by scanning the message cache. */
|
||||||
|
function findChannelForMessage(messages: Map<string, MessageWithUser[]>, 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<ChatState>((set, get) => ({
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
messages: new Map(),
|
messages: new Map(),
|
||||||
currentChannelId: null,
|
currentChannelId: null,
|
||||||
@@ -356,13 +366,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addReaction: (messageId: string, emoji: string, channelId: string) => {
|
addReaction: (messageId: string, emoji: string) => {
|
||||||
const origin = getChannelOrigin(channelId);
|
// 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);
|
wsSend({ type: 'reaction_add', messageId, emoji }, origin);
|
||||||
},
|
},
|
||||||
|
|
||||||
removeReaction: (messageId: string, emoji: string, channelId: string) => {
|
removeReaction: (messageId: string, emoji: string) => {
|
||||||
const origin = getChannelOrigin(channelId);
|
const channelId = findChannelForMessage(get().messages, messageId);
|
||||||
|
const origin = channelId ? getChannelOrigin(channelId) : '';
|
||||||
wsSend({ type: 'reaction_remove', messageId, emoji }, origin);
|
wsSend({ type: 'reaction_remove', messageId, emoji }, origin);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user