feat(auth): upgrade existing federation stubs during registration instead of creating duplicates
This commit is contained in:
@@ -7,6 +7,7 @@ import { config } from '../config.js';
|
|||||||
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
import { findFederatedUser } from './federation.js';
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
|
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
|
||||||
@@ -83,12 +84,77 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
|
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<string, string | number | null> = {
|
||||||
|
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();
|
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
|
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
const userId = generateSnowflake();
|
const userId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user