From 22440b5a24633b2e2896efaa4f5adbf28d388ffa Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:15:43 +0100 Subject: [PATCH] feat: implement Phase 1 server-side federation groundwork Add multi-instance support foundation: shared federation types (ReplicatedInstance, InstanceInfoResponse, VerifyPasswordRequest), database schema changes (home_instance, replicated_instances on users, instance_name on settings), public instance info endpoint, auth registration with homeInstance and username@domain collision fallback, password verification endpoint, and replicatedInstances sync on user profile. Extract duplicated sanitizeUser into shared utility across 8 server files. --- packages/server/src/db/index.ts | 3 + packages/server/src/db/migrate.ts | 13 ++++ packages/server/src/db/schema.ts | 3 + packages/server/src/index.ts | 2 + packages/server/src/routes/auth.ts | 67 +++++++++++++------ packages/server/src/routes/dm.ts | 15 +---- packages/server/src/routes/instance.ts | 24 +++++++ packages/server/src/routes/messages.ts | 15 +---- packages/server/src/routes/servers.ts | 15 +---- packages/server/src/routes/social.ts | 15 +---- packages/server/src/routes/users.ts | 55 ++++++++++----- packages/server/src/utils/sanitize.ts | 26 +++++++ packages/server/src/ws/events.ts | 16 +---- packages/server/src/ws/handler.ts | 14 +--- packages/shared/src/types.ts | 25 +++++++ .../src/components/chat/FriendsPage.test.tsx | 8 +++ 16 files changed, 195 insertions(+), 121 deletions(-) create mode 100644 packages/server/src/routes/instance.ts create mode 100644 packages/server/src/utils/sanitize.ts diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 48b7bd00..0cf20ab8 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -23,6 +23,8 @@ function createTables(db: Database.Database): void { avatar TEXT, status TEXT DEFAULT 'offline', custom_status TEXT, + home_instance TEXT, + replicated_instances TEXT DEFAULT '[]', created_at INTEGER NOT NULL ); @@ -177,6 +179,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', max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000, min_bitrate_kbps INTEGER NOT NULL DEFAULT 500, bitrate_step_kbps INTEGER NOT NULL DEFAULT 500, diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 29060fd8..78ca6b3d 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -54,6 +54,19 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'is_admin', type: 'INTEGER DEFAULT 0' } ] + }, + { + name: 'users', + columns: [ + { name: 'home_instance', type: 'TEXT' }, + { name: 'replicated_instances', type: "TEXT DEFAULT '[]'" } + ] + }, + { + name: 'instance_settings', + columns: [ + { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" } + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 5b405d6b..f6d0df7c 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -9,6 +9,8 @@ export const users = sqliteTable('users', { status: text('status').default('offline'), customStatus: text('custom_status'), isAdmin: integer('is_admin').default(0), + homeInstance: text('home_instance'), + replicatedInstances: text('replicated_instances').default('[]'), createdAt: integer('created_at').notNull(), }); @@ -177,6 +179,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'), 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/index.ts b/packages/server/src/index.ts index 59187f71..a74a0b78 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,6 +18,7 @@ import { livekitRoutes } from './routes/livekit.js'; import { socialRoutes } from './routes/social.js'; import { settingsRoutes } from './routes/settings.js'; import { utilRoutes } from './routes/utils.js'; +import { instanceRoutes } from './routes/instance.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; import fs from 'fs'; @@ -75,6 +76,7 @@ async function main(): Promise { await app.register(socialRoutes); await app.register(settingsRoutes); await app.register(utilRoutes); + await app.register(instanceRoutes); await app.register(registerWebSocket); app.get('/api/health', async () => { diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 6cd5e4d7..15ea3d35 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -4,20 +4,8 @@ import { getDb, schema } from '../db/index.js'; import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { config } from '../config.js'; -import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared'; +import { sanitizeUser } from '../utils/sanitize.js'; export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: RegisterRequest }>('/api/auth/register', { @@ -29,7 +17,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }, }, }, async (request, reply) => { - const { username, password, displayName } = request.body; + const { username, password, displayName, homeInstance } = request.body; if (!username || typeof username !== 'string') { return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); @@ -41,12 +29,46 @@ export async function authRoutes(app: FastifyInstance): Promise { const trimmedUsername = username.trim(); - if (trimmedUsername.length < 3 || trimmedUsername.length > 32) { - return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 }); - } + // Replicated registrations (homeInstance provided) may use username@domain format + // for collision fallback. Local registrations use strict alphanumeric+underscore. + if (homeInstance) { + // Validate homeInstance is a reasonable domain string + if (typeof homeInstance !== 'string' || homeInstance.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(homeInstance)) { + return reply.code(400).send({ error: 'Invalid homeInstance domain', statusCode: 400 }); + } - if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) { - return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 }); + if (trimmedUsername.includes('@')) { + // username@domain format: validate local part + domain part + const atIndex = trimmedUsername.indexOf('@'); + const localPart = trimmedUsername.slice(0, atIndex); + const domainPart = trimmedUsername.slice(atIndex + 1); + + if (localPart.length < 3 || localPart.length > 32 || !/^[a-zA-Z0-9_]+$/.test(localPart)) { + return reply.code(400).send({ error: 'Username local part must be 3-32 alphanumeric/underscore characters', statusCode: 400 }); + } + if (domainPart.length === 0 || domainPart.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(domainPart)) { + return reply.code(400).send({ error: 'Username domain part is invalid', statusCode: 400 }); + } + if (trimmedUsername.length > 100) { + return reply.code(400).send({ error: 'Username must be 100 characters or less', statusCode: 400 }); + } + } else { + // Plain username from a replicated registration — same rules as local + if (trimmedUsername.length < 3 || trimmedUsername.length > 32) { + return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 }); + } + if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) { + return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 }); + } + } + } else { + // Local registration — strict validation + if (trimmedUsername.length < 3 || trimmedUsername.length > 32) { + return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 }); + } + if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) { + return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 }); + } } if (password.length < 6) { @@ -68,9 +90,9 @@ export async function authRoutes(app: FastifyInstance): Promise { const userId = generateSnowflake(); const now = Date.now(); - // First registered user becomes instance admin + // First registered user becomes instance admin (replicated users are never admins) const userCount = db.select().from(schema.users).all().length; - const isFirstUser = userCount === 0; + const isFirstUser = userCount === 0 && !homeInstance; db.insert(schema.users).values({ id: userId, @@ -79,6 +101,7 @@ export async function authRoutes(app: FastifyInstance): Promise { passwordHash, status: 'online', isAdmin: isFirstUser ? 1 : 0, + homeInstance: homeInstance || null, createdAt: now, }).run(); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 24c57411..226c1d4b 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -6,7 +6,6 @@ import { generateSnowflake } from '../utils/snowflake.js'; import { isDmMember } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; import type { - User, DmChannel, DmMessage, DmMessageWithUser, @@ -17,19 +16,7 @@ import type { Attachment, Reaction, } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import { sanitizeUser } from '../utils/sanitize.js'; /** * Batch-fetch reactions for a set of DM message IDs. diff --git a/packages/server/src/routes/instance.ts b/packages/server/src/routes/instance.ts new file mode 100644 index 00000000..e5971cac --- /dev/null +++ b/packages/server/src/routes/instance.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { config } from '../config.js'; +import type { InstanceInfoResponse } from '@backspace/shared'; + +const BACKSPACE_VERSION = '1.0.0'; + +export async function instanceRoutes(app: FastifyInstance): Promise { + app.get('/api/instance/info', async (_request, reply) => { + const db = getDb(); + + const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + const instanceName = settings?.instanceName ?? 'Backspace'; + + const response: InstanceInfoResponse = { + name: instanceName, + version: BACKSPACE_VERSION, + registrationOpen: config.registrationOpen, + }; + + return reply.code(200).send(response); + }); +} diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index 43ac8ab4..91953444 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -9,23 +9,10 @@ import type { CreateMessageRequest, UpdateMessageRequest, PaginatedQuery, - User, MessageWithUser, Reaction, } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import { sanitizeUser } from '../utils/sanitize.js'; /** * Fetch reactions for a set of message IDs. diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index c5bf43a9..21e4641e 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -12,26 +12,13 @@ import type { UpdateServerRequest, JoinServerRequest, UpdateMemberRequest, - User, Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import { sanitizeUser } from '../utils/sanitize.js'; function rowToServer(row: typeof schema.servers.$inferSelect): Server { return { diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 0a1cf8d4..8ec77c09 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -5,25 +5,12 @@ import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from '../ws/handler.js'; import type { - User, Friend, FriendRequest, SendFriendRequest, UpdateFriendRequest, } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import { sanitizeUser } from '../utils/sanitize.js'; export async function socialRoutes(app: FastifyInstance): Promise { // GET /api/social/friends - List all friends diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index aa015c14..b9791966 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -1,22 +1,10 @@ import type { FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; -import { authenticate } from '../utils/auth.js'; +import { authenticate, verifyPassword } from '../utils/auth.js'; import { connectionManager } from '../ws/handler.js'; -import type { User, UpdateUserRequest } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ReplicatedInstance } from '@backspace/shared'; +import { sanitizeUser } from '../utils/sanitize.js'; export async function userRoutes(app: FastifyInstance): Promise { app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { @@ -30,8 +18,27 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(200).send(sanitizeUser(user)); }); + // POST /api/users/@me/verify-password — verify password matches current account + app.post<{ Body: VerifyPasswordRequest }>('/api/users/@me/verify-password', { preHandler: authenticate }, async (request, reply) => { + const { password } = request.body; + + if (!password || typeof password !== 'string') { + return reply.code(400).send({ error: 'Password is required', statusCode: 400 }); + } + + const db = getDb(); + const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!user) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + const valid = await verifyPassword(password, user.passwordHash); + const response: VerifyPasswordResponse = { valid }; + return reply.code(200).send(response); + }); + app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { - const { displayName, avatar, customStatus, status } = request.body; + const { displayName, avatar, customStatus, status, replicatedInstances } = request.body; const db = getDb(); const updateData: Record = {}; @@ -71,6 +78,22 @@ export async function userRoutes(app: FastifyInstance): Promise { updateData.status = status; } + if (replicatedInstances !== undefined) { + if (!Array.isArray(replicatedInstances)) { + return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 }); + } + // Validate each entry has domain and username strings + for (const inst of replicatedInstances) { + if (!inst || typeof inst.domain !== 'string' || typeof inst.username !== 'string') { + return reply.code(400).send({ error: 'Each replicated instance must have domain and username strings', statusCode: 400 }); + } + } + if (replicatedInstances.length > 50) { + return reply.code(400).send({ error: 'Maximum 50 replicated instances', statusCode: 400 }); + } + updateData.replicatedInstances = JSON.stringify(replicatedInstances); + } + if (Object.keys(updateData).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts new file mode 100644 index 00000000..39d6c379 --- /dev/null +++ b/packages/server/src/utils/sanitize.ts @@ -0,0 +1,26 @@ +import type { User, ReplicatedInstance } from '@backspace/shared'; +import { schema } from '../db/index.js'; + +export function sanitizeUser(row: typeof schema.users.$inferSelect): User { + let replicatedInstances: ReplicatedInstance[] = []; + if (row.replicatedInstances) { + try { + replicatedInstances = JSON.parse(row.replicatedInstances); + } catch { + replicatedInstances = []; + } + } + + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, + createdAt: row.createdAt, + homeInstance: row.homeInstance ?? null, + replicatedInstances, + }; +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 1840667c..09de7c6b 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -5,20 +5,8 @@ import { connectionManager } from './handler.js'; import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js'; import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js'; import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js'; -import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared'; - -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} +import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared'; +import { sanitizeUser } from '../utils/sanitize.js'; function getMessageWithUser(messageId: string): MessageWithUser | null { const db = getDb(); diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 6ed239d6..aae878c0 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -16,6 +16,7 @@ import type { ReadState, ActiveCallInfo, } from '@backspace/shared'; +import { sanitizeUser } from '../utils/sanitize.js'; // SQLite's SQLITE_MAX_VARIABLE_NUMBER default is 999. // Chunk inArray() calls to stay safely under this limit. @@ -30,19 +31,6 @@ function batchInArray(ids: TId[], queryFn: (chunk: TId[]) => TResu return results; } -function sanitizeUser(row: typeof schema.users.$inferSelect): User { - return { - id: row.id, - username: row.username, - displayName: row.displayName, - avatar: row.avatar, - status: (row.status ?? 'offline') as User['status'], - customStatus: row.customStatus, - isAdmin: row.isAdmin === 1, - createdAt: row.createdAt, - }; -} - export interface AuthenticatedSocket { ws: WebSocket; userId: string; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index dff5ff69..c3855540 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -9,6 +9,13 @@ export interface User { customStatus: string | null; isAdmin: boolean; createdAt: number; + homeInstance: string | null; + replicatedInstances: ReplicatedInstance[]; +} + +export interface ReplicatedInstance { + domain: string; + username: string; } export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'; @@ -244,6 +251,7 @@ export interface RegisterRequest { username: string; password: string; displayName?: string; + homeInstance?: string; } export interface LoginRequest { @@ -283,6 +291,7 @@ export interface UpdateUserRequest { avatar?: string; customStatus?: string; status?: UserStatus; + replicatedInstances?: ReplicatedInstance[]; } export interface UpdateMemberRequest { @@ -379,3 +388,19 @@ export interface InstanceStreamingLimits { maxResolution: number; maxFramerate: number; } + +// ─── Federation Types ────────────────────────────────────────────────────── + +export interface InstanceInfoResponse { + name: string; + version: string; + registrationOpen: boolean; +} + +export interface VerifyPasswordRequest { + password: string; +} + +export interface VerifyPasswordResponse { + valid: boolean; +} diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index 26c022f8..61c084cf 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -61,6 +61,8 @@ const makeRequest = (overrides: Partial = {}): FriendRequest => ( customStatus: null, isAdmin: false, createdAt: Date.now(), + homeInstance: null, + replicatedInstances: [], }, ...overrides, }); @@ -215,6 +217,8 @@ describe('FriendsPage', () => { customStatus: null, isAdmin: false, createdAt: Date.now(), + homeInstance: null, + replicatedInstances: [], }, }); @@ -260,6 +264,8 @@ describe('FriendsPage', () => { customStatus: null, isAdmin: false, createdAt: Date.now(), + homeInstance: null, + replicatedInstances: [], }, }); @@ -300,6 +306,8 @@ describe('FriendsPage', () => { customStatus: null, isAdmin: false, createdAt: Date.now(), + homeInstance: null, + replicatedInstances: [], }, });