fix(federation): create replicated users during group DM bootstrap for cross-instance members
This commit is contained in:
@@ -11,6 +11,7 @@ import { sanitizeUser } from '../utils/sanitize.js';
|
|||||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js';
|
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js';
|
||||||
import { getDmMessageWithUser } from './dm.js';
|
import { getDmMessageWithUser } from './dm.js';
|
||||||
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser } from '@backspace/shared';
|
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser } from '@backspace/shared';
|
||||||
|
|
||||||
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
||||||
@@ -855,6 +856,80 @@ function resolveLocalUser(
|
|||||||
return candidates.find(u => u.homeUserId === homeUserId) ?? candidates[0];
|
return candidates.find(u => u.homeUserId === homeUserId) ?? candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a federated participant to a local user, creating a minimal
|
||||||
|
* replicated user stub if one doesn't already exist. This is needed
|
||||||
|
* for the group-DM bootstrap path: when Instance C receives a
|
||||||
|
* member_add event whose roster includes users that only live on
|
||||||
|
* Instance A or B, those users won't have been pre-replicated via the
|
||||||
|
* friend-connect flow. We create a bare-bones row so the local DB
|
||||||
|
* can reference them in dm_members / dm_messages.
|
||||||
|
*/
|
||||||
|
function resolveOrCreateReplicatedUser(
|
||||||
|
homeUserId: string,
|
||||||
|
homeInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): typeof schema.users.$inferSelect {
|
||||||
|
const existing = resolveLocalUser(homeUserId, db);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Build a username@domain identifier. Extract the domain from the
|
||||||
|
// homeInstance URL (strip protocol) so it matches the convention used
|
||||||
|
// by the normal replicated-user registration path.
|
||||||
|
let domain: string;
|
||||||
|
try {
|
||||||
|
domain = new URL(homeInstance).hostname;
|
||||||
|
} catch {
|
||||||
|
// Fallback: strip protocol manually
|
||||||
|
domain = homeInstance.replace(/^https?:\/\//, '').split('/')[0] ?? homeInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the snowflake-style homeUserId as the local part; append the
|
||||||
|
// domain so the username is globally unique and human-readable.
|
||||||
|
const baseUsername = `${homeUserId}@${domain}`.toLowerCase();
|
||||||
|
|
||||||
|
// Guard against the (unlikely) case where this username already
|
||||||
|
// exists — e.g. a prior partial replication or manual creation.
|
||||||
|
let username = baseUsername;
|
||||||
|
let collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
|
||||||
|
let attempt = 0;
|
||||||
|
while (collision) {
|
||||||
|
attempt++;
|
||||||
|
username = `${homeUserId}_${attempt}@${domain}`.toLowerCase();
|
||||||
|
collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
|
||||||
|
if (attempt > 10) {
|
||||||
|
// Extremely unlikely; use a random suffix to break out
|
||||||
|
username = `${homeUserId}_${randomBytes(4).toString('hex')}@${domain}`.toLowerCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
const avatarColor = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
||||||
|
|
||||||
|
db.insert(schema.users).values({
|
||||||
|
id: userId,
|
||||||
|
username,
|
||||||
|
displayName: null,
|
||||||
|
passwordHash: '!federation-replicated', // Cannot be used to log in (bcrypt never produces this)
|
||||||
|
status: 'offline',
|
||||||
|
isAdmin: 0,
|
||||||
|
homeInstance,
|
||||||
|
homeUserId,
|
||||||
|
avatarColor,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const created = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||||
|
if (!created) {
|
||||||
|
throw new Error(`Failed to create replicated user for homeUserId=${homeUserId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[federation] Auto-created replicated user ${userId} (${username}) for homeUserId=${homeUserId} from ${homeInstance}`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find or create a local DM channel for a federated DM.
|
* Find or create a local DM channel for a federated DM.
|
||||||
* Uses federated_id for deterministic cross-instance lookup.
|
* Uses federated_id for deterministic cross-instance lookup.
|
||||||
@@ -1518,11 +1593,11 @@ function processMemberAddEvent(
|
|||||||
const channelId = generateSnowflake();
|
const channelId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Resolve owner
|
// Resolve owner — create a replicated stub if unknown
|
||||||
let ownerId: string | null = null;
|
let ownerId: string | null = null;
|
||||||
if (event.group.owner) {
|
if (event.group.owner) {
|
||||||
const ownerLocal = resolveLocalUser(event.group.owner.homeUserId, db);
|
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db);
|
||||||
ownerId = ownerLocal?.id ?? null;
|
ownerId = ownerLocal.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insert(schema.dmChannels)
|
db.insert(schema.dmChannels)
|
||||||
@@ -1536,22 +1611,21 @@ function processMemberAddEvent(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Add all roster members
|
// Add all roster members — create replicated user stubs for any
|
||||||
|
// participants from remote instances that haven't been seen before.
|
||||||
for (const member of event.group.members) {
|
for (const member of event.group.members) {
|
||||||
const localUser = resolveLocalUser(member.homeUserId, db);
|
const localUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db);
|
||||||
if (localUser) {
|
const existing = db.select().from(schema.dmMembers)
|
||||||
const existing = db.select().from(schema.dmMembers)
|
.where(and(
|
||||||
.where(and(
|
eq(schema.dmMembers.dmChannelId, channelId),
|
||||||
eq(schema.dmMembers.dmChannelId, channelId),
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
eq(schema.dmMembers.userId, localUser.id),
|
)).get();
|
||||||
)).get();
|
if (!existing) {
|
||||||
if (!existing) {
|
db.insert(schema.dmMembers).values({
|
||||||
db.insert(schema.dmMembers).values({
|
dmChannelId: channelId,
|
||||||
dmChannelId: channelId,
|
userId: localUser.id,
|
||||||
userId: localUser.id,
|
closed: 0,
|
||||||
closed: 0,
|
}).run();
|
||||||
}).run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1574,12 +1648,12 @@ function processMemberAddEvent(
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the added user
|
// Resolve the added user — create a replicated stub if unknown
|
||||||
const localUser = resolveLocalUser(event.membership.user.homeUserId, db);
|
const localUser = resolveOrCreateReplicatedUser(
|
||||||
if (!localUser) {
|
event.membership.user.homeUserId,
|
||||||
rejected.push({ messageId: event.messageId, reason: 'user_not_found' });
|
event.membership.user.homeInstance,
|
||||||
return;
|
db,
|
||||||
}
|
);
|
||||||
|
|
||||||
// Enforce max 10 members
|
// Enforce max 10 members
|
||||||
const memberCount = db.select()
|
const memberCount = db.select()
|
||||||
|
|||||||
Reference in New Issue
Block a user