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.
This commit is contained in:
@@ -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<void> {
|
||||
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
|
||||
@@ -29,7 +17,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
},
|
||||
}, 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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
passwordHash,
|
||||
status: 'online',
|
||||
isAdmin: isFirstUser ? 1 : 0,
|
||||
homeInstance: homeInstance || null,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
// GET /api/social/friends - List all friends
|
||||
|
||||
@@ -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<void> {
|
||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
@@ -30,8 +18,27 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<string, string | null | undefined> = {};
|
||||
@@ -71,6 +78,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user