feat(PROJ-16): backend for federation status (SYSTEM_ADMIN)

GET /api/system-admin/federation — SYSTEM_ADMIN-only, wraps Synapse's
GET /_synapse/admin/v1/federation/destinations read-only (no block/
unblock, per spec non-goals). Distinguishes federationAvailable:false
(admin API missing) from an empty destinations list (federation
disabled but the API works fine) from a 502 (Synapse unreachable) —
an admin verifying isolation must never read "Synapse is down" as
"no federation partners", which a flattened empty list would imply.
No schema change, no audit entry (pure read, same as the PROJ-14 room
list).

Verified end-to-end on talkhub/matrix: 403 without SYSTEM_ADMIN, 200
with {federationAvailable:true, destinations:[], total:0} — the
expected result with federation disabled.

Frontend page (/system-admin/federation) still outstanding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-15 00:00:33 +02:00
1 parent 9498262836
commit f9bfca9211
3 files changed
+142

No files matched your search

+86
View File
@@ -9,6 +9,7 @@ import {
SynapseError,
deleteRoom,
getRoomDeleteStatus,
listFederationDestinations,
listRooms,
purgeMediaCache,
} from "../services/synapse.js";
@@ -32,6 +33,15 @@ const listQuerySchema = z.object({
const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema });
/**
* PROJ-16: federation list is expected to be empty or tiny, so no search term —
* only paging, kept for the case where federation is switched on later.
*/
const federationQuerySchema = z.object({
from: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
/**
* PROJ-15: cut-off for the media cache purge. Everything cached *before* this
* instant is dropped, so a future timestamp would mean "purge the entire
@@ -359,4 +369,80 @@ export async function systemAdminRoutes(app: FastifyInstance) {
};
},
);
/**
* PROJ-16: read-only view of the remote homeservers Synapse has talked to.
*
* Two distinctions the frontend depends on, and the reason this endpoint does
* more than pass the Synapse response through:
*
* 1. `federationAvailable: false` — the admin API itself is unavailable
* (404/400 from a build without the endpoint). Render as "deaktiviert",
* the expected state for TalkTime.
* 2. A 502 — Synapse is unreachable. That is NOT the same as "no servers
* connected" and must never be flattened into an empty list, otherwise an
* admin verifying isolation would read an outage as a clean bill of health.
*
* An empty list with `federationAvailable: true` is the normal, healthy
* result while `federation_domain_whitelist` stays empty.
*
* No audit entry: this is a pure read with no side effects, consistent with
* the room list (PROJ-14), which is likewise not audited.
*/
app.get(
"/api/system-admin/federation",
{ preHandler: [app.authenticate, requireSystemAdmin] },
async (request, reply) => {
const parsed = federationQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { from, limit } = parsed.data;
let page;
try {
page = await listFederationDestinations({ from, limit });
} catch (err) {
if (err instanceof SynapseError && (err.status === 404 || err.status === 400)) {
app.log.info({ err }, "PROJ-16: federation admin API unavailable — treating as disabled");
return {
federationAvailable: false,
destinations: [],
pagination: { from, limit, total: 0, nextToken: null },
};
}
app.log.error({ err }, "PROJ-16: failed to list federation destinations from Synapse");
return reply
.code(502)
.send({ error: "Synapse ist derzeit nicht erreichbar. Bitte später erneut versuchen." });
}
return {
federationAvailable: true,
destinations: page.destinations.map((entry) => {
// `failure_ts` set means Synapse is currently backing off from this
// server; retry_last_ts/retry_interval describe that backoff. All
// three are 0 (not null) on a healthy destination in most versions,
// so normalize 0 → null instead of rendering "01.01.1970".
const failureTs = entry.failure_ts || null;
const retryLastTs = entry.retry_last_ts || null;
const retryIntervalMs = entry.retry_interval || null;
return {
destination: entry.destination,
failing: failureTs !== null,
failureSince: failureTs ? new Date(failureTs).toISOString() : null,
lastRetryAt: retryLastTs ? new Date(retryLastTs).toISOString() : null,
retryIntervalMs,
// Next attempt is only meaningful while backing off.
nextRetryAt:
retryLastTs && retryIntervalMs
? new Date(retryLastTs + retryIntervalMs).toISOString()
: null,
lastSuccessfulStreamOrdering: entry.last_successful_stream_ordering ?? null,
};
}),
pagination: { from, limit, total: page.total, nextToken: page.next_token },
};
},
);
}
+48
View File
@@ -328,6 +328,54 @@ export async function purgeMediaCache(beforeTs: number): Promise<PurgeMediaCache
};
}
export interface SynapseDestination {
destination: string;
/** Timestamp (ms) of the last *failed* contact attempt, 0/null when healthy. */
retry_last_ts: number | null;
/** Backoff in ms until the next retry; 0 means "not backing off". */
retry_interval: number | null;
/** Timestamp (ms) when this destination started failing, null when healthy. */
failure_ts: number | null;
last_successful_stream_ordering: number | null;
}
export interface SynapseDestinationPage {
destinations: SynapseDestination[];
total: number;
next_token: string | null;
}
/**
* PROJ-16: lists the remote homeservers this Synapse has had contact with.
*
* With federation disabled (TalkTime's default: `federation_domain_whitelist: []`)
* this legitimately returns an empty list — that is the expected healthy state,
* not an error, and callers must not dress it up as one. Verified against the
* live instance: the endpoint answers immediately with `{destinations: [], total: 0}`
* rather than erroring out, so no version-specific fallback is needed here.
*
* Read-only by design; the sibling "block/unblock destination" admin endpoints
* are deliberately not wrapped (PROJ-16 non-goal).
*/
export async function listFederationDestinations(params: {
from: number;
limit: number;
}): Promise<SynapseDestinationPage> {
const query = new URLSearchParams({
from: String(params.from),
limit: String(params.limit),
order_by: "destination",
});
const page = await synapseFetch<Partial<SynapseDestinationPage>>(
`/_synapse/admin/v1/federation/destinations?${query.toString()}`,
);
return {
destinations: page.destinations ?? [],
total: typeof page.total === "number" ? page.total : (page.destinations?.length ?? 0),
next_token: page.next_token ?? null,
};
}
export async function addUserToRoom(roomId: string, userId: string): Promise<void> {
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
method: "POST",
+8
View File
@@ -58,3 +58,11 @@ Keine.
### Status
Vorabprüfung gegen die echte Synapse-Instanz abgeschlossen — keine offenen technischen Fragen mehr vor der Umsetzung.
## Implementation Notes (Backend)
- `listFederationDestinations()` in `backend/src/services/synapse.ts` (read-only Wrapper um `GET /_synapse/admin/v1/federation/destinations`, keine Block-/Unblock-Endpoints — Non-Goal).
- `GET /api/system-admin/federation` in `backend/src/routes/system-admin.ts`, SYSTEM_ADMIN-only via `app.authenticate` + `requireSystemAdmin`, Zod-validiertes `from`/`limit` (max 100).
- Antwort unterscheidet bewusst drei Fälle: `federationAvailable: false` (Admin-API nicht vorhanden → "deaktiviert"), leere Liste bei `federationAvailable: true` (Normalfall, kein Fehler) und HTTP 502 (Synapse nicht erreichbar — wird **nicht** zu einer leeren Liste verflacht).
- Zeitstempel `0` werden zu `null` normalisiert; `nextRetryAt` wird aus `retry_last_ts + retry_interval` berechnet.
- Kein DB-Schema, keine Migration, kein Audit-Eintrag (reiner Lesezugriff, wie die Raum-Liste in PROJ-14).
- Offen: Frontend-Seite `/system-admin/federation` (Tabelle + Hinweistext für den leeren Zustand).