feat(federation): server-side acknowledge for reset events (acknowledged_at + admin endpoint) (detach spec §4.6)

This commit is contained in:
Jannis Braun
2026-07-02 18:59:33 +02:00
parent 7e1e32de69
commit 42ad5e141d
10 changed files with 3938 additions and 3 deletions
@@ -0,0 +1 @@
ALTER TABLE `federation_reset_events` ADD `acknowledged_at` integer;
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,13 @@
"when": 1782932926154,
"tag": "0008_cute_sebastian_shaw",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1783011477517,
"tag": "0009_uneven_red_ghost",
"breakpoints": true
}
]
}
+1
View File
@@ -404,6 +404,7 @@ export const federationResetEvents = sqliteTable('federation_reset_events', {
resolvedAt: integer('resolved_at'),
stubCount: integer('stub_count').notNull().default(0),
orphanedAccountCount: integer('orphaned_account_count').notNull().default(0),
acknowledgedAt: integer('acknowledged_at'),
});
// SQL-level CHECK constraint enforces (direction='inbound' → hmac_secret NOT NULL).
+32
View File
@@ -1646,6 +1646,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
resolvedAt: ev.resolvedAt,
stubCount: ev.stubCount,
orphanedAccountCount: ev.orphanedAccountCount,
acknowledgedAt: ev.acknowledgedAt,
orphanedAccounts,
};
});
@@ -1654,6 +1655,37 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
},
);
// ─── POST /api/federation/reset-events/acknowledge ─────────────────────────
// Admin-only: dismiss a reset event from the admin banner. Purely
// informational state — detached accounts stay detached and functional;
// acknowledging just stops the surface from re-listing them (detach spec §4.6).
app.post<{ Body: { origin: string } }>(
'/api/federation/reset-events/acknowledge',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const { origin } = request.body;
if (!origin || typeof origin !== 'string') {
return reply.code(400).send({ error: 'origin is required', statusCode: 400 });
}
const db = getDb();
const existing = db
.select()
.from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, origin))
.get();
if (!existing) {
return reply.code(404).send({ error: 'No reset event for this origin', statusCode: 404 });
}
if (existing.acknowledgedAt === null) {
db.update(schema.federationResetEvents)
.set({ acknowledgedAt: Date.now() })
.where(eq(schema.federationResetEvents.origin, origin))
.run();
}
return reply.code(200).send({ success: true });
},
);
// ─── DELETE /api/federation/peers/:id ──────────────────────────────────────
// Admin-only: revoke a federation peer and clean up its outbox.
app.delete<{ Params: { id: string } }>(
@@ -5,6 +5,7 @@ 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 { signJwt } from '../utils/auth.js';
@@ -177,3 +178,92 @@ describe('GET /api/federation/reset-events', () => {
expect([401, 403]).toContain(res.statusCode);
});
});
describe('POST /api/federation/reset-events/acknowledge', () => {
function seedEvent(origin: string): void {
testDb.insert(schema.federationResetEvents).values({
origin,
deadEpoch: 'E0',
newEpoch: 'E1',
detectedAt: 1000,
resolvedAt: 2000,
stubCount: 0,
orphanedAccountCount: 0,
}).run();
}
it('stamps acknowledged_at (idempotent) and GET returns it', async () => {
seedEvent('https://peer.example');
const ack = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: { origin: 'https://peer.example' },
});
expect(ack.statusCode).toBe(200);
expect(JSON.parse(ack.body)).toEqual({ success: true });
const first = testDb
.select()
.from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, 'https://peer.example'))
.get();
expect(first?.acknowledgedAt).toBeTypeOf('number');
// Idempotent: second call keeps the original timestamp.
const ack2 = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: { origin: 'https://peer.example' },
});
expect(ack2.statusCode).toBe(200);
const second = testDb
.select()
.from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, 'https://peer.example'))
.get();
expect(second?.acknowledgedAt).toBe(first?.acknowledgedAt);
// GET includes the field.
const get = await app.inject({
method: 'GET',
url: '/api/federation/reset-events',
headers: { authorization: `Bearer ${adminToken()}` },
});
expect(get.statusCode).toBe(200);
expect(JSON.parse(get.body).events[0].acknowledgedAt).toBe(first?.acknowledgedAt);
});
it('returns 404 for an unknown origin', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: { origin: 'https://nope.example' },
});
expect(res.statusCode).toBe(404);
});
it('returns 400 when origin is missing', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('requires admin (401/403 for non-admin)', async () => {
seedEvent('https://peer.example');
const res = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${userToken()}` },
payload: { origin: 'https://peer.example' },
});
expect([401, 403]).toContain(res.statusCode);
});
});
+2 -1
View File
@@ -1203,7 +1203,7 @@ export interface FederationPeer {
*/
export interface FederationOrphanedAccount {
id: string;
username: string; // '!orphaned:{uid}@domain' for freed handles; real for space owners
username: string; // preserved original handle (detach spec); legacy rows may carry '!orphaned:{uid}@domain'
displayName: string | null;
avatarColor: string | null;
ownedSpaces: { id: string; name: string }[];
@@ -1221,6 +1221,7 @@ export interface FederationResetEvent {
newEpoch: string | null;
detectedAt: number;
resolvedAt: number | null;
acknowledgedAt: number | null;
stubCount: number;
orphanedAccountCount: number;
orphanedAccounts: FederationOrphanedAccount[];
+3
View File
@@ -281,6 +281,7 @@ export class BackspaceApiClient {
ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>;
peers: () => Promise<{ peers: FederationPeer[] }>;
resetEvents: () => Promise<FederationResetEventsResponse>;
acknowledgeResetEvent: (origin: string) => Promise<{ success: boolean }>;
revokePeer: (id: string) => Promise<{ success: boolean }>;
resetPeer: (id: string) => Promise<{ success: boolean }>;
recheckPeer: (id: string) => Promise<{ recovered: boolean; status: string }>;
@@ -710,6 +711,8 @@ export class BackspaceApiClient {
),
resetEvents: () =>
request<FederationResetEventsResponse>('GET', '/federation/reset-events'),
acknowledgeResetEvent: (origin: string) =>
request<{ success: boolean }>('POST', '/federation/reset-events/acknowledge', { origin }),
revokePeer: (id: string) =>
request<{ success: boolean }>('DELETE', `/federation/peers/${id}`),
resetPeer: (id: string) =>