feat(federation): server-side acknowledge for reset events (acknowledged_at + admin endpoint) (detach spec §4.6)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user