feat(federation): bootstrap carries group name + icon + metadataUpdatedAt
This commit is contained in:
@@ -1294,6 +1294,19 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const domainOrigin = getOurOrigin();
|
const domainOrigin = getOurOrigin();
|
||||||
const allParticipants = getDmParticipants(dmChannelId);
|
const allParticipants = getDmParticipants(dmChannelId);
|
||||||
|
|
||||||
|
// Re-fetch the channel row so the bootstrap payload carries the
|
||||||
|
// current name/icon/metadataUpdatedAt. On a freshly-created group
|
||||||
|
// these are null/null/0, but we read them rather than hard-code
|
||||||
|
// so future producer paths (or test mutations between create and
|
||||||
|
// relay) stay correct without touching this site.
|
||||||
|
const channelRow = db.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.id, dmChannelId)).get();
|
||||||
|
const wireIcon = channelRow?.icon
|
||||||
|
? (channelRow.icon.startsWith('http://') || channelRow.icon.startsWith('https://')
|
||||||
|
? channelRow.icon
|
||||||
|
: `${domainOrigin}/api/uploads/${channelRow.icon}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const targetUser of targetUsers) {
|
for (const targetUser of targetUsers) {
|
||||||
if (!targetUser.homeInstance || targetUser.homeInstance === domainOrigin) continue;
|
if (!targetUser.homeInstance || targetUser.homeInstance === domainOrigin) continue;
|
||||||
|
|
||||||
@@ -1320,9 +1333,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
homeInstance: callerUser?.homeInstance || domainOrigin,
|
homeInstance: callerUser?.homeInstance || domainOrigin,
|
||||||
},
|
},
|
||||||
members: allParticipants,
|
members: allParticipants,
|
||||||
name: null, // safe default — Phase 4 task 4.3 replaces with channel.name
|
name: channelRow?.name ?? null,
|
||||||
icon: null, // safe default — Phase 4 task 4.3 replaces with normalized URL
|
icon: wireIcon,
|
||||||
metadataUpdatedAt: 0, // safe default — Phase 4 task 4.3 replaces with channel.metadataUpdatedAt
|
metadataUpdatedAt: channelRow?.metadataUpdatedAt ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1845,6 +1858,18 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const addedUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get();
|
const addedUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get();
|
||||||
const adderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
const adderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
|
||||||
|
// Carry the current group metadata snapshot so a fresh peer can
|
||||||
|
// bootstrap the channel with the correct name + icon. Re-fetch to
|
||||||
|
// pick up any concurrent metadata mutation; the row may have been
|
||||||
|
// patched between the start of this handler and now.
|
||||||
|
const channelRow = db.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.id, id)).get();
|
||||||
|
const wireIcon = channelRow?.icon
|
||||||
|
? (channelRow.icon.startsWith('http://') || channelRow.icon.startsWith('https://')
|
||||||
|
? channelRow.icon
|
||||||
|
: `${domainOrigin}/api/uploads/${channelRow.icon}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
const memberAddPayload: FederationRelayEvent = {
|
const memberAddPayload: FederationRelayEvent = {
|
||||||
eventType: 'member_add',
|
eventType: 'member_add',
|
||||||
dmChannelId: id,
|
dmChannelId: id,
|
||||||
@@ -1868,9 +1893,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
homeInstance: dmChannel.ownerHomeInstance || domainOrigin,
|
homeInstance: dmChannel.ownerHomeInstance || domainOrigin,
|
||||||
},
|
},
|
||||||
members: allParticipants,
|
members: allParticipants,
|
||||||
name: null, // safe default — Phase 4 task 4.3 replaces with channel.name
|
name: channelRow?.name ?? null,
|
||||||
icon: null, // safe default — Phase 4 task 4.3 replaces with normalized URL
|
icon: wireIcon,
|
||||||
metadataUpdatedAt: 0, // safe default — Phase 4 task 4.3 replaces with channel.metadataUpdatedAt
|
metadataUpdatedAt: channelRow?.metadataUpdatedAt ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
|
import type { FederationRelayEvent } from '@backspace/shared';
|
||||||
|
|
||||||
|
setWorkerId(5);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
let testDb: TestDb;
|
||||||
|
let tmpUploadDir: string;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => sqlite,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../config.js', () => ({
|
||||||
|
config: {
|
||||||
|
domain: 'local.test',
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
jwtSecret: 'test-secret-12345678901234567890123456789012',
|
||||||
|
maxUploadSize: 100 * 1024 * 1024,
|
||||||
|
registrationOpen: true,
|
||||||
|
get uploadDir(): string { return tmpUploadDir; },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ws/handler.js', () => ({
|
||||||
|
connectionManager: {
|
||||||
|
sendToUser: vi.fn(),
|
||||||
|
sendToSpace: vi.fn(),
|
||||||
|
sendToDmMembers: vi.fn(),
|
||||||
|
sendToAdmins: vi.fn(),
|
||||||
|
getAllOnlineUserIds: () => [],
|
||||||
|
evictFederatedCallsForHost: vi.fn(),
|
||||||
|
federatedCalls: new Map(),
|
||||||
|
isUserOnline: vi.fn(),
|
||||||
|
lateBindFederatedCall: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/fileCleanup.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../utils/fileCleanup.js')>('../utils/fileCleanup.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
deleteUploadFile: vi.fn(),
|
||||||
|
deleteAttachmentByFilename: vi.fn(),
|
||||||
|
deleteAttachmentFiles: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { connectionManager } from '../ws/handler.js';
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||||
|
for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_INSTANCE = 'orbit.ddns.net';
|
||||||
|
const OWNER_ORIGIN = `https://${OWNER_INSTANCE}`;
|
||||||
|
const FEDERATED_ID = 'fed-bootstrap-1';
|
||||||
|
|
||||||
|
// Tiny PNG body for fetch stubs — matches the shape downloadProfileAsset
|
||||||
|
// expects (image/* content-type, non-empty body).
|
||||||
|
const TINY_PNG = Buffer.from(
|
||||||
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63600000000200015d3a87' +
|
||||||
|
'6f0000000049454e44ae426082',
|
||||||
|
'hex',
|
||||||
|
);
|
||||||
|
|
||||||
|
function makeImageResponse(): Response {
|
||||||
|
return new Response(TINY_PNG, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/png' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFailureResponse(): Response {
|
||||||
|
return new Response('boom', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a member_add event whose group payload bootstraps a fresh peer.
|
||||||
|
* Optional fields default to safe values so tests can omit them when
|
||||||
|
* exercising the "older peer that doesn't yet emit the new fields" branch.
|
||||||
|
*/
|
||||||
|
function buildBootstrapEvent(opts: {
|
||||||
|
messageId?: string;
|
||||||
|
name?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
metadataUpdatedAt?: number;
|
||||||
|
// When true, omit the metadata fields entirely (older-peer payload).
|
||||||
|
omitMetadataFields?: boolean;
|
||||||
|
}): FederationRelayEvent {
|
||||||
|
const group: FederationRelayEvent['group'] = {
|
||||||
|
owner: {
|
||||||
|
homeUserId: 'home-owner-1',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'owner', displayName: 'owner' },
|
||||||
|
},
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
homeUserId: 'home-owner-1',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'owner', displayName: 'owner' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
homeUserId: 'home-added-1',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'added', displayName: 'added' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Defaults match what an older peer emits; explicit overrides below.
|
||||||
|
name: null,
|
||||||
|
icon: null,
|
||||||
|
metadataUpdatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!opts.omitMetadataFields) {
|
||||||
|
group.name = opts.name ?? null;
|
||||||
|
group.icon = opts.icon ?? null;
|
||||||
|
group.metadataUpdatedAt = opts.metadataUpdatedAt ?? 0;
|
||||||
|
} else {
|
||||||
|
// Simulate an older peer's payload by deleting the metadata-snapshot
|
||||||
|
// fields entirely. Validates the consumer's `?? null`/`?? 0` fallbacks.
|
||||||
|
delete (group as Partial<typeof group>).name;
|
||||||
|
delete (group as Partial<typeof group>).icon;
|
||||||
|
delete (group as Partial<typeof group>).metadataUpdatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventType: 'member_add',
|
||||||
|
contextType: 'dm',
|
||||||
|
messageId: opts.messageId ?? 'evt-bootstrap-1',
|
||||||
|
federatedId: FEDERATED_ID,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
membership: {
|
||||||
|
user: {
|
||||||
|
homeUserId: 'home-added-1',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'added', displayName: 'added' },
|
||||||
|
},
|
||||||
|
addedBy: {
|
||||||
|
homeUserId: 'home-owner-1',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'owner', displayName: 'owner' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
tmpUploadDir = path.join(os.tmpdir(), `bs-fed-bootstrap-${crypto.randomBytes(6).toString('hex')}`);
|
||||||
|
fs.mkdirSync(tmpUploadDir, { recursive: true });
|
||||||
|
vi.mocked(connectionManager.sendToUser).mockReset();
|
||||||
|
vi.mocked(connectionManager.sendToDmMembers).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { fs.rmSync(tmpUploadDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processMemberAddEvent — bootstrap with group metadata snapshot', () => {
|
||||||
|
it('creates the channel with name and downloaded icon when the icon fetch succeeds', async () => {
|
||||||
|
const fed = await import('./federation.js');
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => makeImageResponse()));
|
||||||
|
|
||||||
|
const event = buildBootstrapEvent({
|
||||||
|
name: 'Cool Group',
|
||||||
|
icon: `${OWNER_ORIGIN}/api/uploads/x.png`,
|
||||||
|
metadataUpdatedAt: 12345,
|
||||||
|
});
|
||||||
|
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
await fed.processMemberAddEvent(event, OWNER_ORIGIN, testDb, accepted, rejected);
|
||||||
|
|
||||||
|
expect(rejected).toEqual([]);
|
||||||
|
expect(accepted).toEqual(['evt-bootstrap-1']);
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get();
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.name).toBe('Cool Group');
|
||||||
|
expect(row!.metadataUpdatedAt).toBe(12345);
|
||||||
|
// Icon was downloaded → stored as local filename, not absolute URL
|
||||||
|
expect(row!.icon).not.toBeNull();
|
||||||
|
expect(row!.icon!.startsWith('http')).toBe(false);
|
||||||
|
expect(row!.icon).toMatch(/\.png$/);
|
||||||
|
expect(fs.existsSync(path.join(tmpUploadDir, row!.icon!))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the absolute URL when the icon download fails', async () => {
|
||||||
|
const fed = await import('./federation.js');
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => makeFailureResponse()));
|
||||||
|
|
||||||
|
const remoteIcon = `${OWNER_ORIGIN}/api/uploads/x.png`;
|
||||||
|
const event = buildBootstrapEvent({
|
||||||
|
name: 'Group With Failed Icon',
|
||||||
|
icon: remoteIcon,
|
||||||
|
metadataUpdatedAt: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
await fed.processMemberAddEvent(event, OWNER_ORIGIN, testDb, accepted, rejected);
|
||||||
|
|
||||||
|
expect(rejected).toEqual([]);
|
||||||
|
const row = testDb.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get();
|
||||||
|
expect(row!.name).toBe('Group With Failed Icon');
|
||||||
|
expect(row!.icon).toBe(remoteIcon);
|
||||||
|
expect(row!.metadataUpdatedAt).toBe(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records metadataUpdatedAt so subsequent stale group_metadata_update events are rejected', async () => {
|
||||||
|
const fed = await import('./federation.js');
|
||||||
|
const event = buildBootstrapEvent({
|
||||||
|
name: 'Versioned',
|
||||||
|
icon: null,
|
||||||
|
metadataUpdatedAt: 12345,
|
||||||
|
});
|
||||||
|
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
await fed.processMemberAddEvent(event, OWNER_INSTANCE, testDb, accepted, rejected);
|
||||||
|
|
||||||
|
expect(rejected).toEqual([]);
|
||||||
|
const row = testDb.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get();
|
||||||
|
expect(row!.metadataUpdatedAt).toBe(12345);
|
||||||
|
|
||||||
|
// Replay a stale metadata-update with timestamp <= bootstrap value.
|
||||||
|
// The receiver must silently accept and not mutate the channel.
|
||||||
|
const staleMeta: FederationRelayEvent = {
|
||||||
|
eventType: 'group_metadata_update',
|
||||||
|
contextType: 'dm',
|
||||||
|
messageId: 'stale-meta-1',
|
||||||
|
federatedId: FEDERATED_ID,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
metadata: {
|
||||||
|
name: 'older name',
|
||||||
|
icon: null,
|
||||||
|
metadataUpdatedAt: 12345, // equal to bootstrap → stale by <=
|
||||||
|
actor: {
|
||||||
|
homeUserId: 'home-owner-1',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'owner', displayName: 'owner' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const accepted2: string[] = [];
|
||||||
|
const rejected2: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
await fed.processGroupMetadataUpdateEvent(staleMeta, OWNER_INSTANCE, testDb, accepted2, rejected2);
|
||||||
|
expect(rejected2).toEqual([]);
|
||||||
|
expect(accepted2).toEqual(['stale-meta-1']);
|
||||||
|
|
||||||
|
const rowAfter = testDb.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get();
|
||||||
|
expect(rowAfter!.name).toBe('Versioned');
|
||||||
|
expect(rowAfter!.metadataUpdatedAt).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to safe defaults when the older-peer payload omits the metadata fields', async () => {
|
||||||
|
const fed = await import('./federation.js');
|
||||||
|
|
||||||
|
const event = buildBootstrapEvent({ omitMetadataFields: true });
|
||||||
|
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
await fed.processMemberAddEvent(event, OWNER_INSTANCE, testDb, accepted, rejected);
|
||||||
|
|
||||||
|
expect(rejected).toEqual([]);
|
||||||
|
const row = testDb.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, FEDERATED_ID)).get();
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.name).toBeNull();
|
||||||
|
expect(row!.icon).toBeNull();
|
||||||
|
expect(row!.metadataUpdatedAt).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite local metadata when the channel already exists (incremental path)', async () => {
|
||||||
|
const fed = await import('./federation.js');
|
||||||
|
|
||||||
|
// Seed: channel already bootstrapped with current metadata. The
|
||||||
|
// incremental member_add must not touch any of these fields.
|
||||||
|
const channelId = 'ch-existing-1';
|
||||||
|
const ownerLocalId = 'owner-stub-1';
|
||||||
|
testDb.insert(schema.users).values({
|
||||||
|
id: ownerLocalId,
|
||||||
|
username: `owner@${OWNER_INSTANCE}`,
|
||||||
|
displayName: 'owner',
|
||||||
|
passwordHash: '!federation-replicated',
|
||||||
|
status: 'offline',
|
||||||
|
isAdmin: 0,
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
homeUserId: 'home-owner-1',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const localMemberId = 'local-member-1';
|
||||||
|
testDb.insert(schema.users).values({
|
||||||
|
id: localMemberId,
|
||||||
|
username: 'localmember',
|
||||||
|
displayName: 'localmember',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
status: 'online',
|
||||||
|
isAdmin: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
testDb.insert(schema.dmChannels).values({
|
||||||
|
id: channelId,
|
||||||
|
federatedId: FEDERATED_ID,
|
||||||
|
ownerId: ownerLocalId,
|
||||||
|
ownerHomeUserId: 'home-owner-1',
|
||||||
|
ownerHomeInstance: OWNER_INSTANCE,
|
||||||
|
name: 'Local Current Name',
|
||||||
|
icon: 'local-current-icon.png',
|
||||||
|
metadataUpdatedAt: 9999,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
testDb.insert(schema.dmMembers).values({
|
||||||
|
dmChannelId: channelId,
|
||||||
|
userId: ownerLocalId,
|
||||||
|
closed: 0,
|
||||||
|
}).run();
|
||||||
|
testDb.insert(schema.dmMembers).values({
|
||||||
|
dmChannelId: channelId,
|
||||||
|
userId: localMemberId,
|
||||||
|
closed: 0,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
// Incremental member_add carrying STALE bootstrap metadata. Must not
|
||||||
|
// overwrite the local row even though the receiver reads these fields
|
||||||
|
// for the bootstrap branch.
|
||||||
|
const event = buildBootstrapEvent({
|
||||||
|
name: 'STALE bootstrap name',
|
||||||
|
icon: `${OWNER_ORIGIN}/api/uploads/stale.png`,
|
||||||
|
metadataUpdatedAt: 1, // way older than local 9999
|
||||||
|
messageId: 'evt-incremental-1',
|
||||||
|
});
|
||||||
|
// Replace the added user with a fresh remote member so the incremental
|
||||||
|
// path actually inserts a new dm_members row (instead of dedup-ing).
|
||||||
|
event.membership!.user = {
|
||||||
|
homeUserId: 'home-added-2',
|
||||||
|
homeInstance: OWNER_INSTANCE,
|
||||||
|
profile: { username: 'added2', displayName: 'added2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// No fetch stub — if the receiver mistakenly tried to download the
|
||||||
|
// icon for an existing channel, the un-stubbed call would surface as
|
||||||
|
// a thrown ECONNREFUSED. The current implementation downloads only
|
||||||
|
// in the bootstrap branch, so this also locks in that scoping.
|
||||||
|
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
await fed.processMemberAddEvent(event, OWNER_INSTANCE, testDb, accepted, rejected);
|
||||||
|
|
||||||
|
expect(rejected).toEqual([]);
|
||||||
|
expect(accepted).toEqual(['evt-incremental-1']);
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.id, channelId)).get();
|
||||||
|
expect(row!.name).toBe('Local Current Name');
|
||||||
|
expect(row!.icon).toBe('local-current-icon.png');
|
||||||
|
expect(row!.metadataUpdatedAt).toBe(9999);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2960,7 +2960,7 @@ export async function processRelayEvents(
|
|||||||
processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
break;
|
break;
|
||||||
case 'member_add':
|
case 'member_add':
|
||||||
processMemberAddEvent(event, sourceInstance, db, accepted, rejected);
|
await processMemberAddEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
break;
|
break;
|
||||||
case 'member_remove':
|
case 'member_remove':
|
||||||
processMemberRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
processMemberRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
@@ -4043,13 +4043,13 @@ function processReactionRemoveEvent(
|
|||||||
|
|
||||||
// ─── Membership mutation processors ──────────────────────────────────────────
|
// ─── Membership mutation processors ──────────────────────────────────────────
|
||||||
|
|
||||||
function processMemberAddEvent(
|
export async function processMemberAddEvent(
|
||||||
event: FederationRelayEvent,
|
event: FederationRelayEvent,
|
||||||
sourceInstance: string,
|
sourceInstance: string,
|
||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
accepted: string[],
|
accepted: string[],
|
||||||
rejected: Array<{ messageId: string; reason: string }>,
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!event.federatedId || !event.membership?.user) {
|
if (!event.federatedId || !event.membership?.user) {
|
||||||
rejected.push({ messageId: event.messageId, reason: 'missing_membership_payload' });
|
rejected.push({ messageId: event.messageId, reason: 'missing_membership_payload' });
|
||||||
return;
|
return;
|
||||||
@@ -4101,6 +4101,19 @@ function processMemberAddEvent(
|
|||||||
ownerId = ownerLocal?.id ?? null;
|
ownerId = ownerLocal?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group metadata snapshot. Older peers omit these fields — fall back
|
||||||
|
// to safe defaults (null name/icon, metadataUpdatedAt=0). When an icon
|
||||||
|
// URL is present, mirror processGroupMetadataUpdateEvent and try to
|
||||||
|
// download a local copy; on failure, persist the absolute URL.
|
||||||
|
const bootstrapName = event.group.name ?? null;
|
||||||
|
const bootstrapIconUrl = event.group.icon ?? null;
|
||||||
|
const bootstrapMetadataUpdatedAt = event.group.metadataUpdatedAt ?? 0;
|
||||||
|
let bootstrapResolvedIcon: string | null = bootstrapIconUrl;
|
||||||
|
if (bootstrapIconUrl !== null) {
|
||||||
|
const localFile = await downloadProfileAsset(bootstrapIconUrl, sourceInstance);
|
||||||
|
bootstrapResolvedIcon = localFile ?? bootstrapIconUrl;
|
||||||
|
}
|
||||||
|
|
||||||
db.insert(schema.dmChannels)
|
db.insert(schema.dmChannels)
|
||||||
.values({
|
.values({
|
||||||
id: channelId,
|
id: channelId,
|
||||||
@@ -4109,6 +4122,9 @@ function processMemberAddEvent(
|
|||||||
ownerHomeUserId: event.group.owner?.homeUserId ?? null,
|
ownerHomeUserId: event.group.owner?.homeUserId ?? null,
|
||||||
ownerHomeInstance: event.group.owner?.homeInstance ?? null,
|
ownerHomeInstance: event.group.owner?.homeInstance ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
name: bootstrapName,
|
||||||
|
icon: bootstrapResolvedIcon,
|
||||||
|
metadataUpdatedAt: bootstrapMetadataUpdatedAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user