feat(federation): server-side acknowledge for reset events (acknowledged_at + admin endpoint) (detach spec §4.6)
This commit is contained in:
+5
-2
@@ -327,6 +327,7 @@ POST /federation/peer/initiate (admin) { remoteOrigin }
|
||||
POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret, instanceName?, instanceId?, approvalToken? } → { accepted:true, instanceName, instanceId } (200) | queued (202 + { approvalToken }) | 409 { accepted:false, code:'PEER_EXISTS_RESET_REQUIRED', instanceName, instanceId }
|
||||
GET /federation/peers (admin) → { peers[] } (no secrets; each peer carries needsAttentionReason)
|
||||
GET /federation/reset-events (admin) → FederationResetEventsResponse
|
||||
POST /federation/reset-events/acknowledge (admin) { origin } → { success } (200) | 400 missing origin | 404 unknown origin
|
||||
DELETE /federation/peers/:id (admin) → { success } + outbox cleanup
|
||||
POST /federation/relay (HMAC-signed S2S) FederationRelayRequest (+ sourceInstanceId?) → { accepted[], rejected[] }
|
||||
POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint }
|
||||
@@ -345,7 +346,7 @@ POST /federation/epoch (HMAC-signed S2S, HMAC-signed response) {}
|
||||
```typescript
|
||||
type 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 }[];
|
||||
@@ -354,13 +355,15 @@ type FederationOrphanedAccount = {
|
||||
};
|
||||
type FederationResetEvent = {
|
||||
origin: string; deadEpoch: string; newEpoch: string | null;
|
||||
detectedAt: number; resolvedAt: number | null;
|
||||
detectedAt: number; resolvedAt: number | null; acknowledgedAt: number | null;
|
||||
stubCount: number; orphanedAccountCount: number;
|
||||
orphanedAccounts: FederationOrphanedAccount[];
|
||||
};
|
||||
type FederationResetEventsResponse = { events: FederationResetEvent[] };
|
||||
```
|
||||
|
||||
**`POST /api/federation/reset-events/acknowledge`** — admin-only, no S2S. Body `{ origin }`: `400` if missing, `404` if no journal row for that origin, else stamps `acknowledged_at = Date.now()` **only if currently null** (idempotent — a second call keeps the original timestamp) and returns `{ success: true }`. Lets the admin banner be dismissed server-side (Task 7) instead of client-only state; purely informational, detached accounts stay detached (detach spec §4.6).
|
||||
|
||||
Disposition actions reuse existing endpoints (no new mutating routes): one-click Re-peer = `POST /peers/:id/reset` → `POST /peer/initiate`; full-purge Remove = `DELETE /api/admin/users/:id` (owns-spaces → transfer first). **`needsAttentionReason`** (`'auth_failures' | 'peer_reset_detected' | 'repeer_incomplete' | null`) is now included on each `GET /federation/peers` peer object so the client can raise the persistent Reset-cleanup banner only for reset-detected peers and surface an "incomplete Re-peer" warning for `repeer_incomplete`. See `federation.md` "Instance Epoch" and `client-federation.md` §8.
|
||||
|
||||
**`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract.
|
||||
|
||||
@@ -426,6 +426,7 @@ Instance-epoch self-healing ledger. One row per origin recording a detected fede
|
||||
| resolvedAt | integer | | Epoch ms healing completed; `NULL` while in progress |
|
||||
| stubCount | integer NOT NULL | 0 | Count of replicated identity stubs affected by the reset |
|
||||
| orphanedAccountCount | integer NOT NULL | 0 | Count of accounts that could not be re-linked to the new epoch |
|
||||
| acknowledgedAt | integer | | Epoch ms the admin dismissed this reset event from the banner (`POST /api/federation/reset-events/acknowledge`, idempotent); `NULL` while unacknowledged. Purely informational — detach spec §4.6 |
|
||||
|
||||
### peer_approval_requests
|
||||
Queue of peering requests pending admin review when `autoAcceptPeering` is `false`. Holds **both directions**: inbound rows (remote asked to peer with us) and outbound rows (a local user-initiated `ensurePeered` call gated on this side; see [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate)). UNIQUE on `(origin, direction)` so the same origin may have at most one row per direction simultaneously. Rows expire after 30 days via janitor cleanup.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user