fix(dm): ownership transfer divergence after back-and-forth — canonicalize ownerHomeInstance + normalize authority checks

Manual ownership transfers between two federated instances diverged because
`dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`)
for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance`
verbatim — while `sourceInstance` always arrives as a full URL on the wire.
`processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the
two with strict equality and rejected legitimate inbound events as
`unauthorized_source`, keeping ownership permanently divergent across peers.
Live DB inspection on the two test instances confirmed both rows (nova + orbit)
had a BARE `owner_home_instance`, matching the bug report exactly.

Three compounding fixes:

1. Receiver authority checks now compare via `normalizeOriginForCompare` so
   legacy bare-vs-full rows accept legitimate transfers (and kicks).
2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write
   site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM
   creation, lazy federation in member-add, `processMemberAddEvent` bootstrap,
   `processOwnershipTransferEvent` receiver storage) routes through it. Full URL
   is the canonical storage form, matching how `sourceInstance` arrives.
3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and
   `newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when
   present and leaves existing values untouched otherwise (legacy-server safe).
   Without this, `getOwnerInstanceForDm` returned the previous owner's home
   after a successful WS broadcast, routing the next owner-only op to the wrong
   instance.

Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including
the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full
case in `federation.kick.test.ts`; two new client-side cases in
`groupDm.ownerRouting.test.ts` covering both the extended-payload write path and
the legacy-server passthrough. Tests: 1053 server + 364 web, all green.

Specs updated: `dm-system.md` historical bugs + frontend handler table + WS
state-change events table; `federation.md` `ownership_transfer` receiver flow;
`websocket.md` event-fields table.
This commit is contained in:
Jannis Braun
2026-05-10 22:38:03 +02:00
parent b6842c5590
commit 3c7bb02901
12 changed files with 585 additions and 29 deletions
+31 -8
View File
@@ -46,7 +46,7 @@ import {
sendTypingRelay,
normalizeIconForWire,
} from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import { getOurOrigin, canonicalizeHomeInstance } from '../utils/federationAuth.js';
import type { FederationRelayEvent } from '@backspace/shared';
import { resolveLocalUser, resolveOrCreateReplicatedUser } from './federation.js';
@@ -510,7 +510,16 @@ function transferGroupDmOwnership(
const domainOrigin = isFederationRelayEnabled() ? getOurOrigin() : null;
const newOwnerHomeUserId = newOwnerRow?.homeUserId || newOwnerId;
const newOwnerHomeInstance = newOwnerRow?.homeInstance || domainOrigin || '';
// Canonicalize to a full origin URL. `users.homeInstance` is stored as a
// bare host (e.g. `orbit.ddns.net`) for federated users, but
// `dm_channels.ownerHomeInstance` is compared against `sourceInstance`
// (always a full URL) in S2S authority checks. Storing the bare form here
// caused legitimate `ownership_transfer` events to be rejected with
// `unauthorized_source` after back-and-forth transfers — see the historical
// bug entry in `docs/systems/dm-system.md`.
const newOwnerHomeInstance = canonicalizeHomeInstance(
newOwnerRow?.homeInstance || domainOrigin || '',
);
const ownerSysMsgId = generateSnowflake();
const ownerNow = Date.now();
@@ -520,6 +529,11 @@ function transferGroupDmOwnership(
newOwnerDisplayName,
});
// Wire homeInstance values are canonicalized to full URLs so receivers store
// the canonical form too. Future authority checks then compare full-URL to
// full-URL without needing defensive normalization at every site.
const previousOwnerHomeInstanceWire =
canonicalizeHomeInstance(previousOwnerRow?.homeInstance || domainOrigin || '') ?? '';
const transferPayload: FederationRelayEvent | null = federationActive
? {
eventType: 'ownership_transfer',
@@ -531,11 +545,11 @@ function transferGroupDmOwnership(
ownership: {
newOwner: {
homeUserId: newOwnerHomeUserId,
homeInstance: newOwnerHomeInstance || (domainOrigin ?? ''),
homeInstance: newOwnerHomeInstance ?? (domainOrigin ?? ''),
},
previousOwner: {
homeUserId: previousOwnerRow?.homeUserId || previousOwnerId,
homeInstance: previousOwnerRow?.homeInstance || (domainOrigin ?? ''),
homeInstance: previousOwnerHomeInstanceWire,
},
},
}
@@ -584,12 +598,17 @@ function transferGroupDmOwnership(
.where(eq(schema.dmMembers.dmChannelId, channelId))
.all();
// Broadcast dm_owner_updated to local members.
// Broadcast dm_owner_updated to local members. Include the new owner's
// home identity so receiving clients can update `dm.ownerHomeInstance`
// (and thus keep `getOwnerInstanceForDm` correct for the next owner-only
// request) without waiting for a fresh `ready` payload on reconnect.
for (const member of members) {
connectionManager.sendToUser(member.userId, {
type: 'dm_owner_updated',
dmChannelId: channelId,
newOwnerId,
newOwnerHomeUserId,
newOwnerHomeInstance: newOwnerHomeInstance ?? null,
});
}
@@ -1211,7 +1230,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.set({
federatedId,
ownerHomeUserId: callerUser?.homeUserId || request.userId,
ownerHomeInstance: callerUser?.homeInstance || domainOrigin,
// Canonicalize for federation-authority parity (see
// `transferGroupDmOwnership` for the full rationale).
ownerHomeInstance: canonicalizeHomeInstance(callerUser?.homeInstance || domainOrigin),
})
.where(eq(schema.dmChannels.id, dmChannelId))
.run();
@@ -1754,7 +1775,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.set({
federatedId: newFederatedId,
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
ownerHomeInstance: ownerUser?.homeInstance || domainOrigin,
// Canonicalize for federation-authority parity (see
// `transferGroupDmOwnership` for the full rationale).
ownerHomeInstance: canonicalizeHomeInstance(ownerUser?.homeInstance || domainOrigin),
})
.where(eq(schema.dmChannels.id, id))
.run();
@@ -1763,7 +1786,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
...dmChannel,
federatedId: newFederatedId,
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
ownerHomeInstance: ownerUser?.homeInstance || domainOrigin,
ownerHomeInstance: canonicalizeHomeInstance(ownerUser?.homeInstance || domainOrigin),
};
}
}
@@ -287,6 +287,40 @@ describe('processMemberRemoveEvent — kick authority', () => {
expect(vi.mocked(connectionManager.sendToDmMembers)).not.toHaveBeenCalled();
});
it('accepts a kick when ownerHomeInstance is BARE and sourceInstance is FULL (bare-vs-full normalization)', async () => {
// Regression: pre-fix, `processMemberRemoveEvent` compared
// `sourceInstance` (full URL, from outbox worker) to `ownerHomeInstance`
// verbatim. After an `ownership_transfer` to a federated user, the
// column would be written as a bare host (`users.homeInstance`), causing
// legitimate downstream kicks to be rejected with `unauthorized_source`.
//
// The fix normalizes both sides via `normalizeOriginForCompare`. This
// test re-seeds the channel with a bare `ownerHomeInstance` to lock in
// the new behavior. Mirrors the ownership-transfer authority test.
seedChannelAndMembers();
// Overwrite ownerHomeInstance to the legacy bare form.
testDb.update(schema.dmChannels)
.set({ ownerHomeInstance: 'owner.test' })
.where(eq(schema.dmChannels.id, CHANNEL_ID))
.run();
const fed = await import('./federation.js');
const event = buildKickEvent('evt-kick-bare-owner');
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processMemberRemoveEvent(event, OWNER_ORIGIN, testDb, accepted, rejected);
expect(rejected).toEqual([]);
expect(accepted).toEqual([event.messageId]);
// Victim's dm_members row was deleted (kick applied)
const victimRow = testDb.select().from(schema.dmMembers)
.where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, VICTIM_USER_ID)))
.get();
expect(victimRow).toBeUndefined();
});
it('accepts a self-leave from any source instance (not the owner instance) and removes the leaver', async () => {
seedChannelAndMembers();
const fed = await import('./federation.js');
@@ -0,0 +1,352 @@
// Receiver-side authority and storage tests for `processOwnershipTransferEvent`.
//
// The headline regression these tests pin down:
//
// On a federated back-and-forth — A transfers ownership to B (federated), B
// transfers it back to A — the second event was rejected at the receiver
// with `unauthorized_source` because `dm_channels.ownerHomeInstance` was
// written as a bare host (`orbit.ddns.net`) by
// `transferGroupDmOwnership` (which copies `users.homeInstance`) while
// `sourceInstance` always arrives as a full URL (`https://orbit.ddns.net`).
// The strict-string-equality check fired, the event went back into the
// outbox, and every retry hit the same mismatch — divergent ownership
// between the two instances stuck until manual repair.
//
// The fix is two-fold:
//
// 1. Authority check normalizes both sides via `normalizeOriginForCompare`
// so legacy bare-vs-full rows accept legitimate transfers.
// 2. Both the sender (`transferGroupDmOwnership`) and the receiver
// (`processOwnershipTransferEvent`) canonicalize `ownerHomeInstance` to a
// full origin URL on storage, so going forward the column is uniform.
//
// These tests cover the receiver — sender canonicalization is covered by the
// existing `dm.transfer.test.ts`.
import { describe, it, expect, beforeEach, 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 { 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;
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,
uploadDir: '/tmp/bs-fed-transfer-test',
},
}));
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(),
},
}));
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);
}
}
}
// Identities used by these tests. Owner is on `owner.test`, new owner is on
// `new.test`. The previous owner / source for the transfer event is the owner.
const OWNER_ORIGIN_FULL = 'https://owner.test';
const OWNER_ORIGIN_BARE = 'owner.test';
const NEW_OWNER_ORIGIN_FULL = 'https://new.test';
const NEW_OWNER_ORIGIN_BARE = 'new.test';
const FEDERATED_ID = 'fed-transfer-1';
const CHANNEL_ID = 'ch-transfer-local-1';
const OWNER_USER_ID = 'owner-user-stub';
const OWNER_HOME_USER_ID = 'home-owner-1';
const NEW_OWNER_USER_ID = 'new-owner-stub';
const NEW_OWNER_HOME_USER_ID = 'home-new-owner-1';
function seedUsers(): void {
const now = Date.now();
testDb.insert(schema.users).values({
id: OWNER_USER_ID,
username: 'owner@owner.test',
displayName: 'owner',
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: OWNER_ORIGIN_BARE,
homeUserId: OWNER_HOME_USER_ID,
createdAt: now,
}).run();
testDb.insert(schema.users).values({
id: NEW_OWNER_USER_ID,
username: 'new@new.test',
displayName: 'new owner',
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: NEW_OWNER_ORIGIN_BARE,
homeUserId: NEW_OWNER_HOME_USER_ID,
createdAt: now,
}).run();
}
interface SeedOpts {
ownerHomeInstance: string;
}
function seedChannel(opts: SeedOpts): void {
const now = Date.now();
testDb.insert(schema.dmChannels).values({
id: CHANNEL_ID,
federatedId: FEDERATED_ID,
ownerId: OWNER_USER_ID,
ownerHomeUserId: OWNER_HOME_USER_ID,
ownerHomeInstance: opts.ownerHomeInstance,
name: 'group',
icon: null,
metadataUpdatedAt: 1000,
createdAt: now,
}).run();
testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: OWNER_USER_ID, closed: 0 }).run();
testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: NEW_OWNER_USER_ID, closed: 0 }).run();
}
function buildTransferEvent(opts: {
messageId?: string;
newOwnerHomeInstance?: string;
previousOwnerHomeInstance?: string;
} = {}): FederationRelayEvent {
return {
eventType: 'ownership_transfer',
contextType: 'dm',
messageId: opts.messageId ?? 'evt-transfer-1',
federatedId: FEDERATED_ID,
encryptionVersion: 0,
timestamp: Date.now(),
ownership: {
newOwner: {
homeUserId: NEW_OWNER_HOME_USER_ID,
homeInstance: opts.newOwnerHomeInstance ?? NEW_OWNER_ORIGIN_FULL,
},
previousOwner: {
homeUserId: OWNER_HOME_USER_ID,
homeInstance: opts.previousOwnerHomeInstance ?? OWNER_ORIGIN_FULL,
},
},
};
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedUsers();
vi.mocked(connectionManager.sendToDmMembers).mockReset();
});
describe('processOwnershipTransferEvent — authority + canonicalization', () => {
it('accepts a transfer when ownerHomeInstance is BARE and sourceInstance is FULL (legacy storage)', async () => {
// Legacy state — `transferGroupDmOwnership` used to copy `users.homeInstance`
// verbatim (bare host) for federated new owners. With the fix, this
// pre-existing row must still accept legitimate inbound transfers.
seedChannel({ ownerHomeInstance: OWNER_ORIGIN_BARE });
const fed = await import('./federation.js');
const event = buildTransferEvent();
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted, rejected);
expect(rejected).toEqual([]);
expect(accepted).toEqual([event.messageId]);
const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get();
expect(channel?.ownerId).toBe(NEW_OWNER_USER_ID);
expect(channel?.ownerHomeUserId).toBe(NEW_OWNER_HOME_USER_ID);
// Receiver canonicalizes to full URL on storage so future authority
// checks are stable regardless of what the wire format was.
expect(channel?.ownerHomeInstance).toBe(NEW_OWNER_ORIGIN_FULL);
});
it('accepts a transfer when ownerHomeInstance is FULL and sourceInstance is FULL (current happy path)', async () => {
seedChannel({ ownerHomeInstance: OWNER_ORIGIN_FULL });
const fed = await import('./federation.js');
const event = buildTransferEvent();
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted, rejected);
expect(rejected).toEqual([]);
expect(accepted).toEqual([event.messageId]);
const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get();
expect(channel?.ownerId).toBe(NEW_OWNER_USER_ID);
expect(channel?.ownerHomeInstance).toBe(NEW_OWNER_ORIGIN_FULL);
});
it('rejects a transfer when the source peer cannot attest the previous owner (attribution_mismatch)', async () => {
// First-line attribution check: `previousOwner.homeInstance` must match
// `sourceInstance` (or be us). An attacker peer cannot attest a transfer
// on behalf of a user whose home isn't them.
seedChannel({ ownerHomeInstance: OWNER_ORIGIN_FULL });
const fed = await import('./federation.js');
const event = buildTransferEvent({ messageId: 'evt-transfer-bad-source' });
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, 'https://attacker.test', testDb, accepted, rejected);
expect(accepted).toEqual([]);
expect(rejected).toEqual([{ messageId: event.messageId, reason: 'attribution_mismatch' }]);
// No mutation
const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get();
expect(channel?.ownerId).toBe(OWNER_USER_ID);
expect(channel?.ownerHomeInstance).toBe(OWNER_ORIGIN_FULL);
});
it('rejects a transfer when the source matches previousOwner but is NOT the channel\'s current owner instance (unauthorized_source)', async () => {
// Authority check at the channel level: even if a peer can attest the
// previous owner, the channel's current `ownerHomeInstance` must still
// match the source. This guards against an outdated peer trying to
// forward an old transfer after ownership has moved on.
//
// Setup: channel currently owned by `new.test` (after some other prior
// transfer this receiver already applied). An event arrives FROM
// `owner.test` claiming `previousOwner` is on `owner.test`. Attribution
// is fine (source attests its own user), but the channel says the
// current authority is `new.test` — reject as `unauthorized_source`.
seedChannel({ ownerHomeInstance: NEW_OWNER_ORIGIN_FULL });
const fed = await import('./federation.js');
const event = buildTransferEvent({
messageId: 'evt-transfer-stale-source',
previousOwnerHomeInstance: OWNER_ORIGIN_FULL,
});
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted, rejected);
expect(accepted).toEqual([]);
expect(rejected).toEqual([{ messageId: event.messageId, reason: 'unauthorized_source' }]);
});
it('canonicalizes ownerHomeInstance on storage even when the wire payload sends a bare host', async () => {
// Defensive: a legacy peer may still send the bare host on the wire after
// an upgrade. Receiver storage must end up canonical regardless.
seedChannel({ ownerHomeInstance: OWNER_ORIGIN_FULL });
const fed = await import('./federation.js');
const event = buildTransferEvent({
messageId: 'evt-transfer-bare-wire',
newOwnerHomeInstance: NEW_OWNER_ORIGIN_BARE,
});
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted, rejected);
expect(rejected).toEqual([]);
expect(accepted).toEqual([event.messageId]);
const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get();
expect(channel?.ownerHomeInstance).toBe(NEW_OWNER_ORIGIN_FULL);
});
it('broadcasts dm_owner_updated with newOwnerHomeUserId + newOwnerHomeInstance so clients keep owner-routing fresh', async () => {
seedChannel({ ownerHomeInstance: OWNER_ORIGIN_FULL });
const fed = await import('./federation.js');
const event = buildTransferEvent({ messageId: 'evt-transfer-broadcast' });
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted, rejected);
expect(rejected).toEqual([]);
const sendSpy = vi.mocked(connectionManager.sendToDmMembers);
const ownerUpdatedCalls = sendSpy.mock.calls.filter(c => (c[1] as { type?: string }).type === 'dm_owner_updated');
expect(ownerUpdatedCalls).toHaveLength(1);
const payload = ownerUpdatedCalls[0]![1] as {
type: string;
dmChannelId: string;
newOwnerId: string;
newOwnerHomeUserId?: string | null;
newOwnerHomeInstance?: string | null;
};
expect(payload).toMatchObject({
type: 'dm_owner_updated',
dmChannelId: CHANNEL_ID,
newOwnerId: NEW_OWNER_USER_ID,
newOwnerHomeUserId: NEW_OWNER_HOME_USER_ID,
newOwnerHomeInstance: NEW_OWNER_ORIGIN_FULL,
});
});
it('is idempotent on replay — repeats short-circuit at the (sourceInstance, messageId) dedup', async () => {
seedChannel({ ownerHomeInstance: OWNER_ORIGIN_BARE });
const fed = await import('./federation.js');
const event = buildTransferEvent({ messageId: 'evt-transfer-replay' });
const accepted1: string[] = [];
const rejected1: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted1, rejected1);
// Second delivery — the new ownerHomeInstance on the channel is now
// `new.test`; running the same event again must NOT clobber owner back
// because the dedup short-circuits.
const accepted2: string[] = [];
const rejected2: Array<{ messageId: string; reason: string }> = [];
fed.processOwnershipTransferEvent(event, OWNER_ORIGIN_FULL, testDb, accepted2, rejected2);
expect(accepted1).toEqual([event.messageId]);
expect(accepted2).toEqual([event.messageId]);
expect(rejected1).toEqual([]);
expect(rejected2).toEqual([]);
const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, CHANNEL_ID)).get();
expect(channel?.ownerId).toBe(NEW_OWNER_USER_ID);
// Only one system message inserted across two deliveries
const sysRows = testDb.select().from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID))
.all();
expect(sysRows.filter(r => r.sourceMessageId === event.messageId)).toHaveLength(1);
});
});
+39 -8
View File
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { eq, and, or, isNull, inArray, sql, desc } from 'drizzle-orm';
import { authenticate, requireAdmin } from '../utils/auth.js';
import { generateHmacSecret, getOurOrigin, parseFederationHeaders, verifySignature, verifyPeerSignature, buildFederationHeaders, normalizeOriginForCompare } from '../utils/federationAuth.js';
import { generateHmacSecret, getOurOrigin, parseFederationHeaders, verifySignature, verifyPeerSignature, buildFederationHeaders, normalizeOriginForCompare, canonicalizeHomeInstance } from '../utils/federationAuth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { getDb, getRawDb, schema } from '../db/index.js';
import { config } from '../config.js';
@@ -4120,7 +4120,10 @@ export async function processMemberAddEvent(
federatedId: event.federatedId,
ownerId,
ownerHomeUserId: event.group.owner?.homeUserId ?? null,
ownerHomeInstance: event.group.owner?.homeInstance ?? null,
// Canonicalize on storage so future authority comparisons against
// `sourceInstance` (always a full URL) match cleanly. Defensive: older
// peers may have sent a bare host on the wire.
ownerHomeInstance: canonicalizeHomeInstance(event.group.owner?.homeInstance) ?? null,
createdAt: now,
name: bootstrapName,
icon: bootstrapResolvedIcon,
@@ -4362,8 +4365,20 @@ export function processMemberRemoveEvent(
return;
}
// Validate authority: owner's instance for kicks, any instance for self-leave
if (event.membership.reason !== 'leave' && channel.ownerHomeInstance && sourceInstance !== channel.ownerHomeInstance) {
// Validate authority: owner's instance for kicks, any instance for self-leave.
//
// `sourceInstance` arrives as a full URL from `federationWorker.ts` (always
// `getOurOrigin()` on the sender). `channel.ownerHomeInstance`, however, can be
// stored either as a bare host (from `users.homeInstance`, written by
// `resolveOrCreateReplicatedUser` and by group DM ownership transfers to a
// federated user) OR as a full URL (group DM creation / transfers to a local
// user, which fall back to `domainOrigin = getOurOrigin()`). Strict equality
// here mis-fires for the bare-vs-full mismatch — see the historical bug entry
// in `docs/systems/dm-system.md`. Always compare through
// `normalizeOriginForCompare`, matching the established pattern for federation
// authority checks.
if (event.membership.reason !== 'leave' && channel.ownerHomeInstance &&
normalizeOriginForCompare(sourceInstance) !== normalizeOriginForCompare(channel.ownerHomeInstance)) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
return;
}
@@ -4456,7 +4471,7 @@ export function processMemberRemoveEvent(
accepted.push(event.messageId);
}
function processOwnershipTransferEvent(
export function processOwnershipTransferEvent(
event: FederationRelayEvent,
sourceInstance: string,
db: ReturnType<typeof getDb>,
@@ -4502,8 +4517,14 @@ function processOwnershipTransferEvent(
return;
}
// Validate authority: only the current owner's instance can transfer ownership
if (channel.ownerHomeInstance && sourceInstance !== channel.ownerHomeInstance) {
// Validate authority: only the current owner's instance can transfer ownership.
//
// See the matching note in `processMemberRemoveEvent`: `sourceInstance` is
// always a full URL but `channel.ownerHomeInstance` can be bare or full.
// Normalize both sides through `normalizeOriginForCompare` so we don't reject
// legitimate back-and-forth transfers that wrote a bare host into the column.
if (channel.ownerHomeInstance &&
normalizeOriginForCompare(sourceInstance) !== normalizeOriginForCompare(channel.ownerHomeInstance)) {
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
return;
}
@@ -4522,11 +4543,19 @@ function processOwnershipTransferEvent(
return;
}
// Canonicalize to a full origin URL on storage so future authority checks
// can compare cleanly against `sourceInstance` (also a full URL). Mirrors
// the canonicalization performed in `transferGroupDmOwnership` on the
// sender side. Falls back to the wire value if normalization yields null
// (shouldn't happen for valid events; defensive).
const canonicalOwnerHome =
canonicalizeHomeInstance(event.ownership.newOwner.homeInstance) ?? event.ownership.newOwner.homeInstance;
db.update(schema.dmChannels)
.set({
ownerId: newOwnerLocal.id,
ownerHomeUserId: event.ownership.newOwner.homeUserId,
ownerHomeInstance: event.ownership.newOwner.homeInstance,
ownerHomeInstance: canonicalOwnerHome,
})
.where(eq(schema.dmChannels.id, channel.id))
.run();
@@ -4535,6 +4564,8 @@ function processOwnershipTransferEvent(
type: 'dm_owner_updated',
dmChannelId: channel.id,
newOwnerId: newOwnerLocal.id,
newOwnerHomeUserId: event.ownership.newOwner.homeUserId,
newOwnerHomeInstance: canonicalOwnerHome,
});
const prevOwnerLocal = event.ownership.previousOwner