From 3eb46ede67bcad6e7540d6b0d4309ddbcb2e9906 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 04:49:19 +0200 Subject: [PATCH] feat(auth): upgrade existing federation stubs during registration instead of creating duplicates --- packages/server/src/routes/auth.ts | 68 +++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index ff353376..d2449c3e 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -7,6 +7,7 @@ import { config } from '../config.js'; import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { findFederatedUser } from './federation.js'; export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: RegisterRequest }>('/api/auth/register', { @@ -83,12 +84,77 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); } + const passwordHash = await hashPassword(password); + + // --- Federated stub upgrade path (BEFORE username uniqueness check) --- + // If this is a federated registration, check if a relay-created stub already + // exists for this person. If so, upgrade it (add credentials, update username) + // instead of creating a duplicate record. The user gets their full DM history. + // This must run BEFORE the username check because the stub may have a different + // username (e.g., "291255103060533248@nova.ddns.net") that wouldn't collide. + if (homeInstance && homeUserId) { + const usernameBase = trimmedUsername.includes('@') ? trimmedUsername.split('@')[0]! : trimmedUsername; + const existingStub = findFederatedUser(homeUserId, homeInstance, db, { username: usernameBase }); + + if (existingStub) { + // If the found user already has real credentials, they already registered. + // Return 409 so the client falls back to login. + if (existingStub.passwordHash !== '!federation-replicated') { + return reply.code(409).send({ error: 'Username already taken', statusCode: 409 }); + } + + // Check the NEW username isn't taken by someone else (not the stub itself) + const usernameCollision = db.select().from(schema.users) + .where(eq(schema.users.username, trimmedUsername)).get(); + if (usernameCollision && usernameCollision.id !== existingStub.id) { + return reply.code(409).send({ error: 'Username already taken', statusCode: 409 }); + } + + // Upgrade the stub: add credentials, update username and profile + const updates: Record = { + passwordHash, + username: trimmedUsername, + homeUserId, + }; + if (displayName?.trim() && !existingStub.displayName) { + updates.displayName = displayName.trim(); + } + const avatarColor = (requestedAvatarColor && (AVATAR_COLORS as readonly string[]).includes(requestedAvatarColor)) + ? requestedAvatarColor + : AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]!; + if (!existingStub.avatarColor) { + updates.avatarColor = avatarColor; + } + + db.update(schema.users) + .set(updates) + .where(eq(schema.users.id, existingStub.id)) + .run(); + + const upgraded = db.select().from(schema.users).where(eq(schema.users.id, existingStub.id)).get(); + if (!upgraded) { + return reply.code(500).send({ error: 'Failed to upgrade user stub', statusCode: 500 }); + } + + console.log(`[auth] Upgraded federation stub ${existingStub.id} (${existingStub.username} → ${trimmedUsername}) to full account`); + + const token = signJwt({ userId: upgraded.id, username: upgraded.username }); + const response: AuthResponse = { + token, + user: sanitizeUser(upgraded, true), + }; + return reply.code(200).send(response); + } + } + + // --- Normal registration path (no existing stub found) --- + // Username uniqueness check (for non-federated registrations, or federated + // registrations where no stub was found to upgrade) const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get(); if (existing) { return reply.code(409).send({ error: 'Username already taken', statusCode: 409 }); } - const passwordHash = await hashPassword(password); const userId = generateSnowflake(); const now = Date.now();