fix(server): local-fast-path for invite snapshot — skip HTTP self-reach

POST /api/dm/space-invite was hanging 5s and returning invite_invalid
for any local-space invite. fetchSpaceInviteSnapshot was being called
against our own public domain from inside the backspace container, which
fails (Docker NAT loopback) and aborts on timeout.

Add getLocalInviteSnapshot — reads the snapshot directly from the DB —
and branch in dm.ts so local invites bypass the HTTP roundtrip entirely.
Cross-instance invites still go through fetchSpaceInviteSnapshot with
its existing SSRF guard.

Also refactor the GET /api/spaces/invite/:code/preview handler to use
the same helper, keeping the snapshot shape in one place.

Tests assert fetchSpaceInviteSnapshot is NOT called for the local case
(critical regression guard) and that the cross-instance path still hits
the HTTP fetch.
This commit is contained in:
Jannis Braun
2026-04-29 22:29:44 +02:00
parent 5481eb9e7e
commit 44e9a4234c
5 changed files with 260 additions and 33 deletions
@@ -59,11 +59,13 @@ vi.mock('../utils/federationAuth.js', async (importActual) => {
return { ...actual, getOurOrigin: () => 'https://local.test' };
});
// Mock the snapshot fetch so tests don't make real HTTP calls.
// Mock the snapshot helpers so tests don't make real HTTP calls and we can
// observe which lookup path the route takes (local DB vs. cross-instance HTTP).
vi.mock('../utils/spaceInviteSnapshot.js', () => ({
fetchSpaceInviteSnapshot: vi.fn(),
getLocalInviteSnapshot: vi.fn(),
}));
import { fetchSpaceInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
@@ -130,6 +132,7 @@ describe('POST /api/dm/space-invite', () => {
seedFriendship('alice', 'bob');
currentUserId = 'alice';
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReset();
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReset();
app = await buildApp();
});
@@ -147,12 +150,13 @@ describe('POST /api/dm/space-invite', () => {
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toBe('not_a_friend');
// Snapshot should not be fetched if friendship gate fails first.
// Snapshot should not be looked up if friendship gate fails first.
expect(fetchSpaceInviteSnapshot).not.toHaveBeenCalled();
expect(getLocalInviteSnapshot).not.toHaveBeenCalled();
});
it('rejects 400 invite_invalid when upstream preview 404s (snapshot null)', async () => {
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
it('rejects 400 invite_invalid when local snapshot lookup returns null', async () => {
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce(null);
const res = await app.inject({
method: 'POST',
url: '/api/dm/space-invite',
@@ -171,7 +175,7 @@ describe('POST /api/dm/space-invite', () => {
});
it('rejects 400 invite_invalid when snapshot.spaceId mismatches the requested spaceId', async () => {
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
spaceId: 'WRONG',
spaceName: 'X',
description: null,
@@ -197,7 +201,7 @@ describe('POST /api/dm/space-invite', () => {
});
it('inserts a type=system message with parseable space_invite content on success', async () => {
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
spaceId: 'S1',
spaceName: 'Aether',
description: 'desc',
@@ -247,7 +251,7 @@ describe('POST /api/dm/space-invite', () => {
});
it('reuses an existing 1-on-1 DM rather than creating a new one', async () => {
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
spaceId: 'S1',
spaceName: 'Aether',
description: null,
@@ -297,4 +301,89 @@ describe('POST /api/dm/space-invite', () => {
expect(messages.length).toBe(2);
expect(messages.every(m => m.type === 'system')).toBe(true);
});
it('uses local DB lookup (skips HTTP) when spaceInstanceOrigin is empty/local', async () => {
// Regression guard for the production hang: when the space is local, the
// route MUST NOT call fetchSpaceInviteSnapshot — that path tries to reach
// our own public domain over HTTPS, which fails inside Docker (NAT loopback).
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
spaceId: 'S1',
spaceName: 'Aether',
description: null,
icon: null,
avatarColor: null,
memberCount: 1,
instanceName: 'Backspace',
});
const res = await app.inject({
method: 'POST',
url: '/api/dm/space-invite',
payload: {
target: { userId: 'bob' },
spaceId: 'S1',
spaceInstanceOrigin: '',
inviteCode: 'abc',
},
});
expect(res.statusCode).toBe(200);
expect(getLocalInviteSnapshot).toHaveBeenCalledWith('abc');
// CRITICAL: the HTTP fetch path must NOT run for local invites.
expect(fetchSpaceInviteSnapshot).not.toHaveBeenCalled();
});
it('uses local DB lookup when spaceInstanceOrigin equals our own origin', async () => {
// Same fast-path applies if the client sends our origin explicitly.
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
spaceId: 'S1',
spaceName: 'Aether',
description: null,
icon: null,
avatarColor: null,
memberCount: 1,
instanceName: 'Backspace',
});
const res = await app.inject({
method: 'POST',
url: '/api/dm/space-invite',
payload: {
target: { userId: 'bob' },
spaceId: 'S1',
spaceInstanceOrigin: 'https://local.test',
inviteCode: 'abc',
},
});
expect(res.statusCode).toBe(200);
expect(getLocalInviteSnapshot).toHaveBeenCalledWith('abc');
expect(fetchSpaceInviteSnapshot).not.toHaveBeenCalled();
});
it('uses HTTP fetch path for cross-instance invites', async () => {
// Regression guard: when the space is on a different instance, we must
// hit the SSRF-validated HTTP path, not the local DB.
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
spaceId: 'S1',
spaceName: 'Remote',
description: null,
icon: null,
avatarColor: null,
memberCount: 5,
instanceName: 'OtherHost',
});
const res = await app.inject({
method: 'POST',
url: '/api/dm/space-invite',
payload: {
target: { userId: 'bob' },
spaceId: 'S1',
spaceInstanceOrigin: 'https://remote.example',
inviteCode: 'abc',
},
});
expect(res.statusCode).toBe(200);
expect(fetchSpaceInviteSnapshot).toHaveBeenCalledWith('https://remote.example', 'abc');
expect(getLocalInviteSnapshot).not.toHaveBeenCalled();
});
});
+8 -3
View File
@@ -22,7 +22,7 @@ import {
type SpaceInviteResponse,
type SpaceInviteSystemPayload,
} from '@backspace/shared';
import { fetchSpaceInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
@@ -1731,10 +1731,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'not_a_friend', statusCode: 400 });
}
// 3. Server-to-server snapshot fetch from the space's home instance.
// 3. Snapshot lookup. For local spaces, read the DB directly — fetching
// our own /preview endpoint over HTTPS fails inside Docker (NAT loopback).
// Cross-instance previews still go through the SSRF-validated HTTP path.
const ourOrigin = getOurOrigin();
const spaceOrigin = body.spaceInstanceOrigin || ourOrigin;
const snapshot = await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode);
const isLocal = !body.spaceInstanceOrigin || body.spaceInstanceOrigin === ourOrigin;
const snapshot = isLocal
? getLocalInviteSnapshot(body.inviteCode)
: await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode);
if (!snapshot) {
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
}
+4 -21
View File
@@ -26,6 +26,7 @@ import type {
import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { checkVoicePermissions } from '../ws/events.js';
import { getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
return {
@@ -1227,29 +1228,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// GET /api/spaces/invite/:code/preview — Public invite preview (no auth)
app.get<{ Params: { code: string } }>('/api/spaces/invite/:code/preview', async (request, reply) => {
const { code } = request.params;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, code)).get();
if (!space) {
const snapshot = getLocalInviteSnapshot(code);
if (!snapshot) {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
const memberCount = db.select().from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, space.id))
.all().length;
const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
const instanceName = settings?.instanceName ?? 'Backspace';
return reply.code(200).send({
spaceId: space.id,
spaceName: space.name,
description: space.description ?? null,
icon: space.icon ?? null,
avatarColor: space.avatarColor ?? null,
memberCount,
instanceName,
});
return reply.code(200).send(snapshot);
});
// ─── Ban Management ───────────────────────────────────────────────────────
@@ -1,5 +1,25 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchSpaceInviteSnapshot } from './spaceInviteSnapshot';
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 * as schema from '../db/schema.js';
import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from './spaceInviteSnapshot';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable DB state — the vi.mock factory below closes over these
// bindings via a getter, so reassignment in beforeEach is observed.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
// Mock the ssrf module so tests don't need real DNS resolution.
// Default: validateExternalUrl resolves (allow). Individual tests override as needed.
@@ -8,6 +28,19 @@ vi.mock('./ssrf.js', () => ({
isPrivateIp: vi.fn().mockReturnValue(false),
}));
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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
describe('fetchSpaceInviteSnapshot', () => {
const originalFetch = global.fetch;
beforeEach(() => { global.fetch = vi.fn() as any; });
@@ -74,3 +107,86 @@ describe('fetchSpaceInviteSnapshot', () => {
spy.mockRestore();
});
});
describe('getLocalInviteSnapshot', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
// Seed an owner user so the FK on spaces.ownerId is satisfied.
testDb.insert(schema.users).values({
id: 'owner-1',
username: 'owner',
displayName: null,
passwordHash: 'x',
status: 'offline',
isAdmin: 0,
isDeleted: 0,
discoverable: 1,
homeInstance: null,
homeUserId: null,
createdAt: Date.now(),
}).run();
});
it('returns snapshot for an existing local invite code', () => {
testDb.insert(schema.spaces).values({
id: 'S1',
name: 'Aether',
icon: null,
banner: null,
avatarColor: 'mint',
ownerId: 'owner-1',
inviteCode: 'abc123',
visibility: 'private',
description: 'a calm space',
createdAt: Date.now(),
}).run();
testDb.insert(schema.spaceMembers).values({
spaceId: 'S1',
userId: 'owner-1',
nickname: null,
joinedAt: Date.now(),
}).run();
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'TestHost',
updatedAt: Date.now(),
}).run();
const snap = getLocalInviteSnapshot('abc123');
expect(snap).toEqual({
spaceId: 'S1',
spaceName: 'Aether',
description: 'a calm space',
icon: null,
avatarColor: 'mint',
memberCount: 1,
instanceName: 'TestHost',
});
});
it('returns null for unknown code', () => {
expect(getLocalInviteSnapshot('does-not-exist')).toBeNull();
});
it('falls back to "Backspace" when no instance settings row exists', () => {
testDb.insert(schema.spaces).values({
id: 'S2',
name: 'NoSettings',
icon: null,
banner: null,
avatarColor: null,
ownerId: 'owner-1',
inviteCode: 'nosettings',
visibility: 'private',
description: null,
createdAt: Date.now(),
}).run();
const snap = getLocalInviteSnapshot('nosettings');
expect(snap).not.toBeNull();
expect(snap?.instanceName).toBe('Backspace');
expect(snap?.memberCount).toBe(0);
});
});
@@ -1,4 +1,6 @@
import type { AvatarColor } from '@backspace/shared';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { validateExternalUrl } from './ssrf.js';
export interface SpaceInviteSnapshot {
@@ -11,6 +13,38 @@ export interface SpaceInviteSnapshot {
instanceName: string;
}
/**
* Build a local invite snapshot directly from the DB. Used when the space
* lives on this instance — avoids an HTTP roundtrip through our own domain
* (which fails inside Docker due to NAT loopback) and is faster anyway.
*
* Returns the same shape as `fetchSpaceInviteSnapshot` so callers don't
* branch downstream of the snapshot lookup.
*/
export function getLocalInviteSnapshot(inviteCode: string): SpaceInviteSnapshot | null {
const db = getDb();
const space = db.select().from(schema.spaces)
.where(eq(schema.spaces.inviteCode, inviteCode)).get();
if (!space) return null;
const memberCount = db.select().from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, space.id)).all().length;
const settings = db.select().from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1)).get();
const instanceName = settings?.instanceName ?? 'Backspace';
return {
spaceId: space.id,
spaceName: space.name,
description: space.description ?? null,
icon: space.icon ?? null,
avatarColor: (space.avatarColor as AvatarColor | null) ?? null,
memberCount,
instanceName,
};
}
/**
* Fetch a space invite preview from a (possibly remote) instance.
* Returns null on 4xx, network error, or timeout — caller should treat as