Skip to content
129 changes: 129 additions & 0 deletions src/api/federation-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

import type { QueryClient } from "@tanstack/react-query";
import { queryOptions } from "@tanstack/react-query";
import * as v from "valibot";

import { accessToken } from "@/stores/auth";
import { ensureResponseOk, fetch } from "@/utils/fetch";

const ALLOWLIST_BASE_PATH =
"/_synapse/io.element/admin/v1/federation/whitelist";

const AllowlistEntry = v.object({
server_name: v.string(),
creator_user_id: v.string(),
created_at: v.number(),
});

const AllowlistResponse = v.object({
server_names: v.array(AllowlistEntry),
total_count: v.number(),
});

const baseOptions = async (
client: QueryClient,
signal?: AbortSignal,
): Promise<{ signal?: AbortSignal; headers: HeadersInit }> => ({
headers: {
Authorization: `Bearer ${await accessToken(client, signal)}`,
},
signal,
});

/**
* Probe whether the SBG federation allowlist module is available.
* Returns true if the endpoint responds, false on 404 or other errors.
*/
export const federationAllowlistAvailableQuery = (synapseRoot: string) =>
queryOptions({
queryKey: ["federation", "allowlist", "available", synapseRoot],
queryFn: async ({ client, signal }) => {
const url = new URL(ALLOWLIST_BASE_PATH, synapseRoot);
url.searchParams.set("page", "0");
url.searchParams.set("limit", "1");

try {
const response = await fetch(url, await baseOptions(client, signal));
ensureResponseOk(response);
return true;
} catch {
return false;
}
},
});

/**
* Get a paginated list of allowed federation destinations.
*/
export const federationAllowlistQuery = (
synapseRoot: string,
page: number,
limit: number,
) =>
queryOptions({
queryKey: ["federation", "allowlist", "list", synapseRoot, page, limit],
queryFn: async ({ client, signal }) => {
const url = new URL(ALLOWLIST_BASE_PATH, synapseRoot);
url.searchParams.set("page", String(page));
url.searchParams.set("limit", String(limit));

const response = await fetch(url, await baseOptions(client, signal));
ensureResponseOk(response);

return v.parse(AllowlistResponse, await response.json());
},
});

/**
* Add one or more server names/patterns to the federation allowlist.
*/
export const addToAllowlist = async (
client: QueryClient,
synapseRoot: string,
serverNames: string[],
signal?: AbortSignal,
): Promise<void> => {
const url = new URL(ALLOWLIST_BASE_PATH, synapseRoot);

const requestOptions = await baseOptions(client, signal);
const response = await fetch(url, {
...requestOptions,
method: "PUT",
body: JSON.stringify({ server_names: serverNames }),
headers: {
...requestOptions.headers,
"Content-Type": "application/json",
},
});

ensureResponseOk(response);
};

/**
* Remove one or more server names/patterns from the federation allowlist.
*/
export const removeFromAllowlist = async (
client: QueryClient,
synapseRoot: string,
serverNames: string[],
signal?: AbortSignal,
): Promise<void> => {
const url = new URL(ALLOWLIST_BASE_PATH, synapseRoot);

const requestOptions = await baseOptions(client, signal);
const response = await fetch(url, {
...requestOptions,
method: "DELETE",
body: JSON.stringify({ server_names: serverNames }),
headers: {
...requestOptions.headers,
"Content-Type": "application/json",
},
});

ensureResponseOk(response);
};
87 changes: 87 additions & 0 deletions src/api/federation-tester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

import { queryOptions } from "@tanstack/react-query";
import * as v from "valibot";

// The Matrix Federation Tester (https://github.com/matrix-org/matrix-federation-tester)
// exposes a public `report` endpoint with permissive CORS. We use the canonical
// host directly: `https://matrix.org/federationtester/...` only 302-redirects here.
const FEDERATION_TESTER_BASE = "https://federationtester.matrix.org";

// We only model the subset of the report we surface; valibot drops unknown keys.
const VersionReport = v.object({
name: v.optional(v.string()),
version: v.optional(v.string()),
error: v.optional(v.string()),
});

const WellKnownReport = v.object({
"m.server": v.optional(v.string()),
result: v.optional(v.string()),
});

const ConnectionChecks = v.object({
AllChecksOK: v.optional(v.boolean()),
ValidCertificates: v.optional(v.boolean()),
MatchingServerName: v.optional(v.boolean()),
FutureValidUntilTS: v.optional(v.boolean()),
});

const CipherSummary = v.object({
Version: v.optional(v.string()),
CipherSuite: v.optional(v.string()),
});

const ConnectionReport = v.object({
Cipher: v.optional(CipherSummary),
Checks: v.optional(ConnectionChecks),
});

const FederationReport = v.object({
FederationOK: v.boolean(),
Version: v.optional(VersionReport),
WellKnownResult: v.optional(WellKnownReport),
// Per-address reports/errors. Not surfaced in the UI yet — we link out to the
// full federation tester report instead — but modelled so we can render them
// inline later without re-deriving the shape.
ConnectionReports: v.optional(v.record(v.string(), ConnectionReport)),
// Map of "<ip>:<port>" -> error.
ConnectionErrors: v.optional(
v.record(v.string(), v.object({ Message: v.optional(v.string()) })),
),
// An error that happened before connecting to the server.
Error: v.optional(v.string()),
});

export type FederationReport = v.InferOutput<typeof FederationReport>;

/**
* Fetch a federation report for a server from the public Matrix Federation
* Tester. This service can be slow to respond, so callers should load it inside
* its own suspense boundary rather than blocking the surrounding view.
*/
export const federationReportQuery = (serverName: string) =>
queryOptions({
queryKey: ["federation-tester", "report", serverName],
queryFn: async ({ signal }) => {
const url = new URL("/api/report", FEDERATION_TESTER_BASE);
url.searchParams.set("server_name", serverName);

const response = await globalThis.fetch(url, { signal });

if (!response.ok) {
throw new Error(
`Federation tester returned ${response.status} ${response.statusText}`,
);
}

return v.parse(FederationReport, await response.json());
},
// The report is expensive to compute on the remote side; cache it for a
// while and don't retry aggressively on failure.
staleTime: 1000 * 60 * 5, // 5 minutes
retry: false,
});
157 changes: 157 additions & 0 deletions src/api/synapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,163 @@ const ScheduledTaskList = v.object({
scheduled_tasks: v.array(ScheduledTask),
});

// Federation destinations

const Destination = v.object({
destination: v.string(),
retry_last_ts: v.number(),
retry_interval: v.number(),
failure_ts: v.nullable(v.number()),
last_successful_stream_ordering: v.nullable(v.number()),
});

export type Destination = v.InferOutput<typeof Destination>;

const DestinationsListResponse = v.object({
destinations: v.array(Destination),
next_token: v.optional(v.union([v.string(), v.number()])),
total: v.number(),
});

export type DestinationsListResponse = v.InferOutput<
typeof DestinationsListResponse
>;

export interface DestinationListFilters {
order_by?:
| "destination"
| "retry_last_ts"
| "retry_interval"
| "failure_ts"
| "last_successful_stream_ordering";
dir?: "f" | "b";
// Undocumented Synapse param: filters destinations with a
// `LOWER(destination) LIKE '%<destination>%'` match.
destination?: string;
}

export const federationDestinationsInfiniteQuery = (
synapseRoot: string,
parameters: DestinationListFilters = {},
) =>
infiniteQueryOptions({
queryKey: [
"synapse",
"federation",
"destinations",
"infinite",
synapseRoot,
parameters,
],
queryFn: async ({ client, signal, pageParam }) => {
const url = new URL(
"/_synapse/admin/v1/federation/destinations",
synapseRoot,
);

url.searchParams.set("limit", String(PAGE_SIZE));

if (pageParam !== null) {
url.searchParams.set("from", String(pageParam));
}

if (parameters.order_by)
url.searchParams.set("order_by", parameters.order_by);
if (parameters.dir) url.searchParams.set("dir", parameters.dir);
if (parameters.destination)
url.searchParams.set("destination", parameters.destination);

const response = await fetch(url, await baseOptions(client, signal));

await ensureNotError(response);

const destinations = v.parse(
DestinationsListResponse,
await response.json(),
);

return destinations;
},
initialPageParam: null as number | string | null,
getNextPageParam: (lastPage): number | string | null =>
lastPage.next_token ?? null,
});

export const federationDestinationsCountQuery = (synapseRoot: string) =>
queryOptions({
queryKey: ["synapse", "federation", "destinations-count", synapseRoot],
queryFn: async ({ client, signal }) => {
const url = new URL(
"/_synapse/admin/v1/federation/destinations?limit=0",
synapseRoot,
);

const response = await fetch(url, await baseOptions(client, signal));

await ensureNotError(response);

const destinations = v.parse(
DestinationsListResponse,
await response.json(),
);

return destinations.total;
},
});

export const federationDestinationQuery = (
synapseRoot: string,
destination: string,
) =>
queryOptions({
queryKey: [
"synapse",
"federation",
"destination",
synapseRoot,
destination,
],
queryFn: async ({ client, signal }) => {
const url = new URL(
`/_synapse/admin/v1/federation/destinations/${encodeURIComponent(destination)}`,
synapseRoot,
);

const response = await fetch(url, await baseOptions(client, signal));

await ensureNotError(response, true);

const dest = v.parse(Destination, await response.json());

return dest;
},
});

export const resetFederationConnection = async (
client: QueryClient,
synapseRoot: string,
destination: string,
signal?: AbortSignal,
): Promise<void> => {
const url = new URL(
`/_synapse/admin/v1/federation/destinations/${encodeURIComponent(destination)}/reset_connection`,
synapseRoot,
);

const requestOptions = await baseOptions(client, signal);
const response = await fetch(url, {
...requestOptions,
method: "POST",
body: JSON.stringify({}),
headers: {
...requestOptions.headers,
"Content-Type": "application/json",
},
});

await ensureNotError(response);
};

export const scheduledTasksForResource = (
synapseRoot: string,
resourceId: string,
Expand Down
Loading
Loading