From 83dc44050e4ea788fd35945eba4fb54b72298d9d Mon Sep 17 00:00:00 2001
From: khannurien
Date: Fri, 9 Oct 2026 16:18:21 +0000
Subject: [PATCH] =?UTF-8?q?v4:=20a=20deployment=20chooses=20which=20ways?=
=?UTF-8?q?=20federated=20sign-in=20goes=20=E2=80=94=20every=20instance=20?=
=?UTF-8?q?took=20users=20of=20the=20others=20and=20let=20its=20own=20sign?=
=?UTF-8?q?=20in=20to=20them,=20with=20no=20way=20to=20turn=20either=20off?=
=?UTF-8?q?.=20GERBEUR=5FFEDERATION=5FINCOMING=3D0=20keeps=20users=20of=20?=
=?UTF-8?q?other=20instances=20out:=20the=20login=20and=20register=20pages?=
=?UTF-8?q?=20offer=20no=20other=20instance,=20/manage=20no=20list=20of=20?=
=?UTF-8?q?instances,=20their=20sign-ins=20are=20refused,=20and=20those=20?=
=?UTF-8?q?already=20in=20are=20signed=20out=20until=20it=20is=20on=20agai?=
=?UTF-8?q?n,=20their=20homes'=20events=20still=20applied=20meanwhile.=20G?=
=?UTF-8?q?ERBEUR=5FFEDERATION=5FOUTGOING=3D0=20keeps=20the=20instance's?=
=?UTF-8?q?=20users=20from=20signing=20in=20elsewhere:=20its=20/authorize?=
=?UTF-8?q?=20page=20lets=20no=20instance=20sign=20them=20in,=20its=20meta?=
=?UTF-8?q?data=20says=20so=20(gerbeur=5Foutgoing),=20which=20other=20inst?=
=?UTF-8?q?ances=20check=20before=20sending=20anyone=20there,=20the=20sett?=
=?UTF-8?q?ings=20page=20lists=20no=20instances,=20and=20at=20startup=20ev?=
=?UTF-8?q?ery=20grant=20is=20withdrawn,=20which=20signs=20them=20out=20th?=
=?UTF-8?q?ere.=20Both=20are=20on=20by=20default=20and=20reach=20the=20cli?=
=?UTF-8?q?ent=20through=20index.html.=20The=20dev=20instance=20gerbeur=20?=
=?UTF-8?q?(:3000)=20takes=20no=20users=20of=20the=20others,=20while=20its?=
=?UTF-8?q?=20own=20still=20sign=20in=20to=20them.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
.env.example | 6 ++
API.md | 4 +-
README.md | 10 ++-
api/config.ts | 23 ++++++
api/lib/index-html.ts | 8 +-
api/main.ts | 23 +++++-
api/services/federation-service.ts | 48 +++++++++---
api/services/identity-provider-service.ts | 34 ++++++++-
api/services/user-service.ts | 11 ++-
api/test/helpers.ts | 26 ++++++-
api/test/lib/index-html.test.ts | 4 +-
api/test/services/federation-service.test.ts | 44 +++++++++++
.../identity-provider-service.test.ts | 37 ++++++++++
dev/instances.ts | 14 +++-
index.html | 2 +
locales/en.po | 74 +++++++++----------
locales/fr.po | 74 +++++++++----------
shared/federation.ts | 10 ++-
src/config/instance.ts | 25 ++++++-
src/pages/Manage.tsx | 6 +-
src/pages/Settings.tsx | 6 +-
src/pages/UserLogin.tsx | 6 +-
src/pages/UserRegister.tsx | 6 +-
src/test/pages/Settings.test.tsx | 17 ++++-
src/test/pages/UserLogin.test.tsx | 17 +++++
vite.config.ts | 6 ++
26 files changed, 420 insertions(+), 121 deletions(-)
diff --git a/.env.example b/.env.example
index 11bd7f60..7270bc08 100644
--- a/.env.example
+++ b/.env.example
@@ -71,6 +71,12 @@ GERBEUR_DEFAULT_STYLE=smooth
# How often, in minutes, the /community page's figures are recomputed.
GERBEUR_COMMUNITY_REFRESH_MINUTES=15
+# Set to 0 to keep users of other gerbeur instances from signing in here, which
+# also signs out those already in until it is set back, or to keep this
+# instance's users from signing in to others, which signs them out there.
+# GERBEUR_FEDERATION_INCOMING=1
+# GERBEUR_FEDERATION_OUTGOING=1
+
# Set to 1 to let federated sign-in reach other instances on private addresses
# (localhost included) and over plain http, so that two instances on one
# machine can trust each other in development. Never in production.
diff --git a/API.md b/API.md
index fdbd4e48..4974a1a6 100644
--- a/API.md
+++ b/API.md
@@ -211,7 +211,9 @@ there: those endpoints answer it 400.
Federated sign-in (`shared/federation.ts`): a user signs in here with their
account on another gerbeur instance, their home, by OpenID Connect's
authorization code flow with PKCE, with tokens signed by each instance's Ed25519
-key.
+key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and
+`/sessions` answer 403; with `GERBEUR_FEDERATION_OUTGOING=0`,
+`GET /api/oauth/clients` and `POST /api/oauth/codes` do.
- `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone.
The instance's metadata and public keys, bare JSON: the one exception to the
diff --git a/README.md b/README.md
index edeb425f..de349913 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,8 @@ variables:
| `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` |
| `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`): the site's until one is picked, and the welcome and reset emails' | `smooth` |
| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template |
+| `GERBEUR_FEDERATION_INCOMING` | `0` keeps users of other instances from signing in here, and signs out those already in | `1` |
+| `GERBEUR_FEDERATION_OUTGOING` | `0` keeps this instance's users from signing in to other instances, and signs them out there | `1` |
| `GERBEUR_FEDERATION_ALLOW_PRIVATE` | `1` lets federated sign-in reach instances on private addresses and over http — for two instances on one machine, never in production | unset |
| `GERBEUR_COMMUNITY_REFRESH_MINUTES` | How often, in minutes, the `/community` page's figures are recomputed | `15` |
| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` |
@@ -150,7 +152,10 @@ home instance, and the others follow its changes.
Every instance is ready for it: nothing to configure, as long as
`GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances
-can reach each other. An admin decides who comes in, on `/manage`:
+can reach each other. Either way can be turned off:
+`GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances, and
+`GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. An admin decides
+who comes in, on `/manage`:
- users of a **trusted** instance sign in directly, from a button on the login
page;
@@ -160,7 +165,8 @@ can reach each other. An admin decides who comes in, on `/manage`:
`deno task dev` runs several instances on one machine (see [Run](#run)), with
`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over
-plain http on localhost. Never set it in production.
+plain http on localhost. Never set it in production. The first, `gerbeur`, takes
+no users of the others, whose users sign in to each other.
## Project structure
diff --git a/api/config.ts b/api/config.ts
index 12f576d6..7bb9d3d4 100644
--- a/api/config.ts
+++ b/api/config.ts
@@ -232,3 +232,26 @@ export const ALLOWED_ORIGINS: string[] = Array.from(
*/
export const FEDERATION_ALLOW_PRIVATE: boolean =
Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1";
+/**
+ * Which ways federated sign-in goes, each on unless set to `0`.
+ *
+ * - `incoming` (`GERBEUR_FEDERATION_INCOMING`): users of other instances sign
+ * in here. Off, the login and register pages offer no other instance,
+ * `/manage` no list of instances to trust or block,
+ * `federation-service.ts` refuses their sign-ins, and the accounts that came
+ * from one are signed out (`getSessionState`) until it is on again. Their
+ * homes' events are still applied, so that none is lost meanwhile.
+ * - `outgoing` (`GERBEUR_FEDERATION_OUTGOING`): this instance's users sign in
+ * to others. Off, `identity-provider-service.ts` lets no instance sign them
+ * in, and its metadata says so, which the others check before sending
+ * anyone here to sign in. The settings page lists no instances, since at
+ * startup `main.ts` withdraws every grant they gave, which signs them out
+ * there.
+ *
+ * index.html tells the client both. An object, so that the tests can turn one
+ * off for a while.
+ */
+export const FEDERATION: { incoming: boolean; outgoing: boolean } = {
+ incoming: Deno.env.get("GERBEUR_FEDERATION_INCOMING")?.trim() !== "0",
+ outgoing: Deno.env.get("GERBEUR_FEDERATION_OUTGOING")?.trim() !== "0",
+};
diff --git a/api/lib/index-html.ts b/api/lib/index-html.ts
index c211554e..785b045d 100644
--- a/api/lib/index-html.ts
+++ b/api/lib/index-html.ts
@@ -10,6 +10,7 @@ import {
BANDCAMP_PLAYER,
DEFAULT_STYLE,
EMAIL_ENABLED,
+ FEDERATION,
OG_SITE_NAME,
SITE_EMOJI,
} from "../config.ts";
@@ -28,7 +29,8 @@ const indexHtml = new Map();
/**
* Returns `${root}/index.html` with the site name, emoji, icon version,
- * Bandcamp player mode, default style and whether email is on filled in.
+ * Bandcamp player mode, default style, and whether email and each way of
+ * federated sign-in are on filled in.
*
* @throws {Error} When the file can't be read.
*/
@@ -43,7 +45,9 @@ export async function loadIndexHtml(root: string): Promise {
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
- .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off");
+ .replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off")
+ .replaceAll("__FEDERATION_INCOMING__", FEDERATION.incoming ? "on" : "off")
+ .replaceAll("__FEDERATION_OUTGOING__", FEDERATION.outgoing ? "on" : "off");
indexHtml.set(root, { mtime, html });
return html;
}
diff --git a/api/main.ts b/api/main.ts
index a72708eb..11eb1b20 100644
--- a/api/main.ts
+++ b/api/main.ts
@@ -1,6 +1,8 @@
/**
* Builds the Oak application and, when run directly, starts the recurring
- * jobs and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes
+ * jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
+ * the users out of other instances if they may no longer sign in there
+ * (`FEDERATION.outgoing`). A request goes
* through the error middleware, the OG tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
@@ -37,7 +39,13 @@ import usersRouter from "./routes/users.ts";
import wellKnownRouter from "./routes/well-known.ts";
import wsRouter from "./routes/ws.ts";
-import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
+import {
+ ALLOWED_ORIGINS,
+ FEDERATION,
+ LISTEN_HOST,
+ PORT,
+ PUBLIC_URL,
+} from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts";
@@ -46,7 +54,10 @@ import { rateLimitSweepJob } from "./lib/rate-limit.ts";
import { digestJob } from "./services/digest-service.ts";
import { communityJob } from "./services/community-service.ts";
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
-import { outboxJob } from "./services/identity-provider-service.ts";
+import {
+ outboxJob,
+ withdrawAllGrants,
+} from "./services/identity-provider-service.ts";
/**
* The whole server, exported so the tests' helpers can send it requests
@@ -115,6 +126,12 @@ app.addEventListener(
);
if (import.meta.main) {
+ if (!FEDERATION.outgoing) {
+ const withdrawn = await withdrawAllGrants();
+ if (withdrawn > 0) {
+ console.log(`Signed users out of other instances (${withdrawn} grants)`);
+ }
+ }
startJobs([
digestJob,
communityJob,
diff --git a/api/services/federation-service.ts b/api/services/federation-service.ts
index 48ae0875..75a99a37 100644
--- a/api/services/federation-service.ts
+++ b/api/services/federation-service.ts
@@ -16,7 +16,8 @@
* Whether a user of another instance may join is the admin's call
* (`federation-peer-service.ts`): from a trusted instance they may, from a
* blocked one they may not, and from any other only with an invite, which the
- * register page carries. Once here, an account works like any other, but its
+ * register page carries. None may while the deployment turns sign-ins from
+ * other instances off (`FEDERATION.incoming`). Once here, an account works like any other, but its
* password, email, avatar and name are its home's (`assertLocalUser`).
*
* @module
@@ -25,6 +26,7 @@
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
import { createHash, randomBytes } from "node:crypto";
import { APIException } from "../lib/api-exception.ts";
+import { FEDERATION } from "../config.ts";
import { db, transaction } from "../db/connection.ts";
import { verifyInstanceToken } from "../lib/jwt.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
@@ -33,6 +35,7 @@ import {
type FederationSessionRequest,
type IdentityClaims,
instanceHost,
+ type InstanceMetadata,
isFederationEvent,
isIdentityClaims,
parseInstance,
@@ -73,6 +76,22 @@ const LOGIN_TTL_MS = 10 * 60_000;
*/
const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 };
+/**
+ * Refuses a sign-in from another instance while the deployment turns them off
+ * (`FEDERATION.incoming`), as {@link startLogin} and {@link completeLogin}
+ * must first.
+ *
+ * @throws {APIException} 403 when they are off.
+ */
+function assertIncoming(): void {
+ if (!FEDERATION.incoming) {
+ throw new APIException(
+ 403,
+ "This instance doesn't take accounts from other instances",
+ );
+ }
+}
+
/**
* Returns the origin of the instance `instance` names, checked as a home: not
* this instance, nor a blocked one.
@@ -98,13 +117,15 @@ function homeOf(instance: string): string {
* claimed only once the account is made; {@link completeLogin} makes no
* account without one.
*
- * @throws {APIException} 400 when the instance isn't one; 403 when it is
- * blocked, or neither trusted nor invited; 404 or 409 when the invite doesn't
- * work; 502 when it doesn't answer as a gerbeur.
+ * @throws {APIException} 400 when the instance isn't one; 403 when sign-ins
+ * from other instances are off, or it is blocked, neither trusted nor
+ * invited, or keeps its users from signing in elsewhere; 404 or 409 when the
+ * invite doesn't work; 502 when it doesn't answer as a gerbeur.
*/
export async function startLogin(
request: FederationLoginRequest,
): Promise {
+ assertIncoming();
const issuer = homeOf(request.instance);
if (request.inviteToken) {
validateInvite(request.inviteToken);
@@ -121,14 +142,19 @@ export async function startLogin(
);
}
- let authorizationEndpoint: string;
+ let metadata: InstanceMetadata;
try {
- authorizationEndpoint = (await fetchMetadata(issuer))
- .authorization_endpoint;
+ metadata = await fetchMetadata(issuer);
} catch (err) {
console.error(`[federation] ${issuer} unreachable:`, err);
throw new APIException(502, "That instance doesn't answer as a gerbeur");
}
+ if (metadata.gerbeur_outgoing === false) {
+ throw new APIException(
+ 403,
+ "Accounts on that instance don't sign in to other instances",
+ );
+ }
const state = randomBytes(24).toString("base64url");
const verifier = randomBytes(48).toString("base64url");
@@ -151,7 +177,7 @@ export async function startLogin(
);
});
- const url = new URL(authorizationEndpoint);
+ const url = new URL(metadata.authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code",
scope: "openid profile email",
@@ -244,12 +270,14 @@ function checkClaims(claims: unknown, issuer: string): IdentityClaims | null {
* login was started with, claimed now.
*
* @throws {APIException} 400 when the login is unknown or expired; 401 when
- * the identity token doesn't verify; 403 when the account may not be created;
- * 409 when its handle is someone's; 502 when the home fails.
+ * the identity token doesn't verify; 403 when sign-ins from other instances
+ * are off, or the account may not be created; 409 when its handle is
+ * someone's; 502 when the home fails.
*/
export async function completeLogin(
request: FederationSessionRequest,
): Promise {
+ assertIncoming();
const login = db.prepare(
`DELETE FROM federation_logins WHERE state = ?
RETURNING issuer, code_verifier, nonce, invite_token, created_at;`,
diff --git a/api/services/identity-provider-service.ts b/api/services/identity-provider-service.ts
index f6d8c0f6..9ce7ece4 100644
--- a/api/services/identity-provider-service.ts
+++ b/api/services/identity-provider-service.ts
@@ -28,6 +28,7 @@ import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import {
AVATARS_DIR,
+ FEDERATION,
FRONTEND_URL,
OG_SITE_NAME,
PUBLIC_URL,
@@ -102,6 +103,7 @@ export function getInstanceMetadata(): InstanceMetadata {
gerbeur_site_name: OG_SITE_NAME,
gerbeur_redirect_uri: REDIRECT_URI,
gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`,
+ gerbeur_outgoing: FEDERATION.outgoing,
};
}
@@ -143,16 +145,24 @@ export function identityClaims(user: User): IdentityClaims {
/**
* Returns the client `clientId` names, checked: an instance other than this
* one, not blocked here, answering as a gerbeur. `user` must have a local
- * account, which alone may sign in elsewhere.
+ * account, which alone may sign in elsewhere, and this instance must let its
+ * users sign in elsewhere (`FEDERATION.outgoing`).
*
* @throws {APIException} 400 when `clientId` isn't an instance's address, is
- * this instance, or `user` comes from another instance; 403 when the client
- * is blocked here; 502 when it doesn't answer as a gerbeur.
+ * this instance, or `user` comes from another instance; 403 when this
+ * instance's users don't sign in elsewhere, or the client is blocked here;
+ * 502 when it doesn't answer as a gerbeur.
*/
async function checkClient(
user: User,
clientId: string,
): Promise<{ clientId: string; metadata: InstanceMetadata }> {
+ if (!FEDERATION.outgoing) {
+ throw new APIException(
+ 403,
+ "Accounts on this instance don't sign in to other instances",
+ );
+ }
if (isRemoteUser(user)) {
throw new APIException(
400,
@@ -362,6 +372,24 @@ export async function revokeGrant(userId: string, host: string): Promise {
).run(userId, grant.clientId);
}
+/**
+ * Withdraws every grant, as each user would theirs, and drops the codes not
+ * yet traded, so that no account here stays signed in elsewhere: `main.ts`
+ * calls it at startup when `FEDERATION.outgoing` is off. Returns how many
+ * grants it withdrew.
+ */
+export async function withdrawAllGrants(): Promise {
+ db.prepare(`DELETE FROM oauth_codes;`).run();
+ const grants = db.prepare(
+ `SELECT user_id, client_id FROM federation_grants;`,
+ ).all() as { user_id: string; client_id: string }[];
+ for (const { user_id, client_id } of grants) {
+ await notifyClients(user_id, { type: "revoke" }, [client_id]);
+ }
+ db.prepare(`DELETE FROM federation_grants;`).run();
+ return grants.length;
+}
+
/**
* Tells each instance user `userId` signed in to of `event`, or only those of
* `clientIds`: `update` when their name, email or avatar changed, `revoke`
diff --git a/api/services/user-service.ts b/api/services/user-service.ts
index 9adf36f1..288c01b5 100644
--- a/api/services/user-service.ts
+++ b/api/services/user-service.ts
@@ -31,7 +31,7 @@ import {
updateClientAvatar,
} from "./ws-service.ts";
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
-import { AVATARS_DIR } from "../config.ts";
+import { AVATARS_DIR, FEDERATION } from "../config.ts";
import { hashPassword } from "../lib/password.ts";
import { linkAttachments } from "./attachment-service.ts";
@@ -187,14 +187,15 @@ export function getUserById(userId: string): User {
/**
* Returns the user's live role and their last password change, before which
* their tokens don't count, or undefined when the user is gone, or comes from
- * an instance an admin blocked. `lib/auth.ts` checks every session and media
- * token against it.
+ * another instance while an admin blocks it or the deployment turns sign-ins
+ * from other instances off (`FEDERATION.incoming`). `lib/auth.ts` checks
+ * every session and media token against it.
*/
export function getSessionState(
userId: string,
): { role: Role; tokensValidAfter: string | null } | undefined {
const row = db.prepare(
- `SELECT u.role, u.tokens_valid_after, p.status AS peer_status
+ `SELECT u.role, u.tokens_valid_after, ui.issuer, p.status AS peer_status
FROM users u
LEFT JOIN user_identities ui ON ui.user_id = u.id
LEFT JOIN federation_peers p ON p.issuer = ui.issuer
@@ -203,10 +204,12 @@ export function getSessionState(
| {
role: Role;
tokens_valid_after: string | null;
+ issuer: string | null;
peer_status: string | null;
}
| undefined;
if (!row || row.peer_status === "blocked") return undefined;
+ if (row.issuer && !FEDERATION.incoming) return undefined;
return {
role: row.role,
tokensValidAfter: row.tokens_valid_after,
diff --git a/api/test/helpers.ts b/api/test/helpers.ts
index b6309390..7a9bfaa8 100644
--- a/api/test/helpers.ts
+++ b/api/test/helpers.ts
@@ -19,6 +19,7 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../remote/image-proxy.ts";
import { clearInstanceCaches } from "../remote/gerbeur-instance.ts";
+import { FEDERATION } from "../config.ts";
import {
type OutgoingEmail,
setEmailTransport,
@@ -464,8 +465,14 @@ export interface FakeInstance {
handle(url: URL, init?: RequestInit): Promise | null;
}
-/** Makes a {@link FakeInstance} at `origin` with a fresh key. */
-export async function fakeInstance(origin: string): Promise {
+/**
+ * Makes a {@link FakeInstance} at `origin` with a fresh key, its metadata
+ * with `metadata` over the defaults.
+ */
+export async function fakeInstance(
+ origin: string,
+ metadata: Record = {},
+): Promise {
const { kid, privateJwk, publicJwk } = await generateInstanceKey();
const key = await importInstanceKey(kid, privateJwk);
const codes = new Map<
@@ -496,6 +503,7 @@ export async function fakeInstance(origin: string): Promise {
gerbeur_site_name: `Fake ${new URL(origin).host}`,
gerbeur_redirect_uri: `${origin}/federation/callback`,
gerbeur_events_endpoint: `${origin}/api/federation/events`,
+ ...metadata,
});
case "/.well-known/jwks.json":
return json({ keys: [publicJwk] });
@@ -564,6 +572,20 @@ export async function fakeInstance(origin: string): Promise {
};
}
+/**
+ * Turns one way of federated sign-in off for the life of the returned handle
+ * (`using _off = federationOff("incoming")`), as `GERBEUR_FEDERATION_*=0`
+ * would for the server's life.
+ */
+export function federationOff(way: keyof typeof FEDERATION): Disposable {
+ FEDERATION[way] = false;
+ return {
+ [Symbol.dispose]() {
+ FEDERATION[way] = true;
+ },
+ };
+}
+
// ── Email ────────────────────────────────────────────────────────────────────
/**
diff --git a/api/test/lib/index-html.test.ts b/api/test/lib/index-html.test.ts
index b5917446..84636c46 100644
--- a/api/test/lib/index-html.test.ts
+++ b/api/test/lib/index-html.test.ts
@@ -8,11 +8,11 @@ Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", a
const path = `${root}/index.html`;
await Deno.writeTextFile(
path,
- "__SITE_NAME____BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__",
+ "__SITE_NAME____BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__ __FEDERATION_INCOMING__ __FEDERATION_OUTGOING__",
);
assertEquals(
await loadIndexHtml(root),
- "gerbeurembed smooth off",
+ "gerbeurembed smooth off on on",
);
await Deno.writeTextFile(path, "rebuilt __SITE_NAME__");
diff --git a/api/test/services/federation-service.test.ts b/api/test/services/federation-service.test.ts
index 67d3691f..dec2f57f 100644
--- a/api/test/services/federation-service.test.ts
+++ b/api/test/services/federation-service.test.ts
@@ -1,9 +1,11 @@
import {
+ count,
createUser,
db,
del,
type FakeInstance,
fakeInstance,
+ federationOff,
formWithFile,
get,
patch,
@@ -193,6 +195,48 @@ test("federation: blocking an instance refuses its users and ends their sessions
assertEquals((await get("/api/me", { auth })).status, 200);
});
+test("federation: with sign-ins from other instances off, none starts or finishes, and those in are out until it is on", async () => {
+ const home = await fakeInstance(HOME);
+ using _net = network(home);
+ await trust(HOME);
+ const { token } = (await signIn(home, ALICE)).data as AuthResponse;
+ const auth = { Authorization: `Bearer ${token}` };
+ const started = await post(
+ "/api/federation/logins",
+ { json: { instance: "home.test" } },
+ );
+ const state = new URL(started.data.authorizeUrl).searchParams.get("state");
+ const code = home.issue(started.data.authorizeUrl, ALICE);
+
+ {
+ using _off = federationOff("incoming");
+ assertEquals((await get("/api/me", { auth })).status, 401);
+ assertEquals(
+ (await post("/api/federation/logins", {
+ json: { instance: "home.test" },
+ })).status,
+ 403,
+ );
+ assertEquals(
+ (await post("/api/federation/sessions", { json: { code, state } }))
+ .status,
+ 403,
+ );
+ }
+ assertEquals((await get("/api/me", { auth })).status, 200);
+});
+
+test("federation: a home whose users don't sign in elsewhere is refused before the user leaves", async () => {
+ const home = await fakeInstance(HOME, { gerbeur_outgoing: false });
+ using _net = network(home);
+ await trust(HOME);
+ const start = await post("/api/federation/logins", {
+ json: { instance: "home.test" },
+ });
+ assertEquals(start.status, 403);
+ assertEquals(count(`SELECT COUNT(*) AS n FROM federation_logins;`), 0);
+});
+
test("federation: only admins edit the peers, which must answer as gerbeurs", async () => {
const home = await fakeInstance(HOME);
using _net = network(home);
diff --git a/api/test/services/identity-provider-service.test.ts b/api/test/services/identity-provider-service.test.ts
index 317f6f75..6ad8416b 100644
--- a/api/test/services/identity-provider-service.test.ts
+++ b/api/test/services/identity-provider-service.test.ts
@@ -6,6 +6,7 @@ import {
del,
type FakeInstance,
fakeInstance,
+ federationOff,
formWithFile,
get,
patch,
@@ -25,6 +26,7 @@ import {
import {
deliverOutbox,
identityClaims,
+ withdrawAllGrants,
} from "../../services/identity-provider-service.ts";
import { getUserById } from "../../services/user-service.ts";
import type {
@@ -105,6 +107,7 @@ test("identity provider: metadata and keys are served at the well-known paths",
assertEquals(body.authorization_endpoint, `${BASE_URL}/authorize`);
assertEquals(body.gerbeur_redirect_uri, `${BASE_URL}/federation/callback`);
assertEquals(body.gerbeur_federation, 1);
+ assertEquals(body.gerbeur_outgoing, true);
const jwks = await get("/.well-known/jwks.json");
const keys = JSON.parse(new TextDecoder().decode(jwks.bytes)).keys;
assertEquals(keys[0].crv, "Ed25519");
@@ -260,6 +263,40 @@ test("identity provider: the instances a user signed in to hear of their changes
);
});
+test("identity provider: with sign-ins elsewhere off, no instance signs the users in, and their grants are withdrawn", async () => {
+ const fake = await fakeInstance(CLIENT);
+ using _net = network(fake);
+ const user = await createUser();
+ const { verifier, challenge } = pkce();
+ const pending = new URL(
+ (await authorize(user.auth, challenge)).data.redirectTo,
+ ).searchParams.get("code")!;
+
+ using _off = federationOff("outgoing");
+ const meta = await get("/.well-known/openid-configuration");
+ assertEquals(
+ (JSON.parse(new TextDecoder().decode(meta.bytes)) as InstanceMetadata)
+ .gerbeur_outgoing,
+ false,
+ );
+ assertEquals(
+ (await get(`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`, {
+ auth: user.auth,
+ })).status,
+ 403,
+ );
+ assertEquals((await authorize(user.auth, challenge)).status, 403);
+
+ assertEquals(await withdrawAllGrants(), 1);
+ await until(() => fake.received.length === 1);
+ assertEquals(decodeJwt(fake.received[0]).type, "revoke");
+ assertEquals((await trade(pending, verifier)).status, 400);
+ assertEquals(
+ (await get("/api/me/grants", { auth: user.auth })).data,
+ [],
+ );
+});
+
test("identity provider: an event an instance fails to take waits in the outbox", async () => {
const fake = await fakeInstance(CLIENT);
using _net = network(fake, () => new Response("", { status: 503 }));
diff --git a/dev/instances.ts b/dev/instances.ts
index 6f8f81aa..0047cc03 100644
--- a/dev/instances.ts
+++ b/dev/instances.ts
@@ -34,17 +34,25 @@ export interface DevInstance {
webPort: number;
/**
* `GERBEUR_*` settings of its own, over the base `.env`: a site name, an
- * emoji or a style, to tell the instances apart.
+ * emoji or a style, to tell the instances apart, or which ways federated
+ * sign-in goes.
*/
env?: Record;
}
/**
* The instances `deno task dev` starts, in this order. The first keeps the
- * ports the single dev server always had.
+ * ports the single dev server always had, and takes no users of the others,
+ * while its own sign in to them, so that both switches of federated sign-in
+ * can be tried.
*/
export const INSTANCES: readonly DevInstance[] = [
- { name: "gerbeur", apiPort: 8000, webPort: 3000 },
+ {
+ name: "gerbeur",
+ apiPort: 8000,
+ webPort: 3000,
+ env: { GERBEUR_FEDERATION_INCOMING: "0" },
+ },
{
name: "musique",
apiPort: 8001,
diff --git a/index.html b/index.html
index 11a2dc3b..ee470f12 100644
--- a/index.html
+++ b/index.html
@@ -16,6 +16,8 @@
+
+
__SITE_NAME__
diff --git a/locales/en.po b/locales/en.po
index 9395ef32..1bc40909 100644
--- a/locales/en.po
+++ b/locales/en.po
@@ -51,7 +51,7 @@ msgstr "{0} more online: {restNames}"
#. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX
-#: src/pages/UserRegister.tsx:147
+#: src/pages/UserRegister.tsx:149
msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} characters: letters, numbers, or underscores"
@@ -350,7 +350,7 @@ msgstr "All"
msgid "Already dumped by {0} {1}"
msgstr "Already dumped by {0} {1}"
-#: src/pages/UserRegister.tsx:179
+#: src/pages/UserRegister.tsx:181
msgid "Already have an account? <0>Log in0>"
msgstr "Already have an account? <0>Log in0>"
@@ -374,7 +374,7 @@ msgstr "Appearance"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/profile/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:124
-#: src/pages/UserRegister.tsx:166
+#: src/pages/UserRegister.tsx:168
msgid "At least {0} characters"
msgstr "At least {0} characters"
@@ -391,7 +391,7 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
-#: src/pages/FederationCallback.tsx:71
+#: src/pages/FederationCallback.tsx:72
msgid "Back to the login page"
msgstr "Back to the login page"
@@ -453,7 +453,7 @@ msgid "Cancel search"
msgstr "Cancel search"
#: src/components/category/CategorySelect.tsx:33
-#: src/pages/Manage.tsx:36
+#: src/pages/Manage.tsx:38
msgid "Categories"
msgstr "Categories"
@@ -498,7 +498,7 @@ msgstr "Chat"
msgid "Chat ({unreadChatCount} unread)"
msgstr "Chat ({unreadChatCount} unread)"
-#: src/pages/UserRegister.tsx:108
+#: src/pages/UserRegister.tsx:110
msgid "Checking invite…"
msgstr "Checking invite…"
@@ -838,7 +838,7 @@ msgstr "Editing {0}"
msgid "Email"
msgstr "Email"
-#: src/pages/UserRegister.tsx:154
+#: src/pages/UserRegister.tsx:156
msgid "Email address"
msgstr "Email address"
@@ -958,7 +958,7 @@ msgstr "Following"
msgid "email.digest.forYou"
msgstr "For you"
-#: src/pages/UserLogin.tsx:101
+#: src/pages/UserLogin.tsx:103
msgid "Forgot password?"
msgstr "Forgot password?"
@@ -997,7 +997,7 @@ msgstr "Hi {username},"
msgid "Hot"
msgstr "Hot"
-#: src/pages/UserLogin.tsx:142
+#: src/pages/UserLogin.tsx:144
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
@@ -1029,11 +1029,11 @@ msgstr "In the chat"
msgid "Insert emoji"
msgstr "Insert emoji"
-#: src/pages/Manage.tsx:43
+#: src/pages/Manage.tsx:45
msgid "Instances"
msgstr "Instances"
-#: src/pages/UserRegister.tsx:119
+#: src/pages/UserRegister.tsx:121
msgid "Invalid invite"
msgstr "Invalid invite"
@@ -1156,9 +1156,9 @@ msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32
-#: src/pages/UserLogin.tsx:44
-#: src/pages/UserLogin.tsx:71
-#: src/pages/UserLogin.tsx:90
+#: src/pages/UserLogin.tsx:46
+#: src/pages/UserLogin.tsx:73
+#: src/pages/UserLogin.tsx:92
msgid "Log in"
msgstr "Log in"
@@ -1180,12 +1180,12 @@ msgstr "Log in with {0}"
msgid "Log out"
msgstr "Log out"
-#: src/pages/UserLogin.tsx:89
+#: src/pages/UserLogin.tsx:91
msgid "Logging in…"
msgstr "Logging in…"
-#: src/pages/FederationCallback.tsx:68
-#: src/pages/UserLogin.tsx:76
+#: src/pages/FederationCallback.tsx:69
+#: src/pages/UserLogin.tsx:78
msgid "Login failed"
msgstr "Login failed"
@@ -1194,8 +1194,8 @@ msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:114
-#: src/pages/Manage.tsx:27
-#: src/pages/Manage.tsx:34
+#: src/pages/Manage.tsx:29
+#: src/pages/Manage.tsx:36
#: src/pages/UserPublicProfile.tsx:164
msgid "Manage"
msgstr "Manage"
@@ -1412,12 +1412,12 @@ msgstr "Page not found"
#: src/pages/Settings.tsx:96
#: src/pages/Settings.tsx:106
-#: src/pages/UserLogin.tsx:86
+#: src/pages/UserLogin.tsx:88
msgid "Password"
msgstr "Password"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
-#: src/pages/UserRegister.tsx:160
+#: src/pages/UserRegister.tsx:162
msgid "Password (min. {0} characters)"
msgstr "Password (min. {0} characters)"
@@ -1519,17 +1519,17 @@ msgstr "Refreshing stream…"
msgid "Refreshing…"
msgstr "Refreshing…"
-#: src/pages/UserRegister.tsx:47
-#: src/pages/UserRegister.tsx:131
-#: src/pages/UserRegister.tsx:171
+#: src/pages/UserRegister.tsx:49
+#: src/pages/UserRegister.tsx:133
+#: src/pages/UserRegister.tsx:173
msgid "Register"
msgstr "Register"
-#: src/pages/UserRegister.tsx:170
+#: src/pages/UserRegister.tsx:172
msgid "Registering…"
msgstr "Registering…"
-#: src/pages/UserRegister.tsx:136
+#: src/pages/UserRegister.tsx:138
msgid "Registration failed"
msgstr "Registration failed"
@@ -1581,7 +1581,7 @@ msgstr "Reply"
msgid "Replying to"
msgstr "Replying to"
-#: src/pages/UserLogin.tsx:154
+#: src/pages/UserLogin.tsx:156
msgid "Request failed"
msgstr "Request failed"
@@ -1688,12 +1688,12 @@ msgstr "Seek"
msgid "Send"
msgstr "Send"
-#: src/pages/UserLogin.tsx:163
+#: src/pages/UserLogin.tsx:165
msgid "Send reset link"
msgstr "Send reset link"
#: src/components/chat/PendingItem.tsx:104
-#: src/pages/UserLogin.tsx:162
+#: src/pages/UserLogin.tsx:164
msgid "Sending…"
msgstr "Sending…"
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
msgstr "Sign in to {name}?"
#: src/pages/Authorize.tsx:154
-#: src/pages/FederationCallback.tsx:78
+#: src/pages/FederationCallback.tsx:79
msgid "Signing you in…"
msgstr "Signing you in…"
@@ -1818,11 +1818,11 @@ msgstr "The new order couldn't be saved. Try again."
msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again."
-#: src/pages/UserRegister.tsx:120
+#: src/pages/UserRegister.tsx:122
msgid "This invite link is missing, expired, or already used."
msgstr "This invite link is missing, expired, or already used."
-#: src/pages/UserLogin.tsx:110
+#: src/pages/UserLogin.tsx:112
msgid "This is a mirage."
msgstr "This is a mirage."
@@ -1838,11 +1838,11 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed."
-#: src/pages/FederationCallback.tsx:39
+#: src/pages/FederationCallback.tsx:40
msgid "This sign-in link is incomplete."
msgstr "This sign-in link is incomplete."
-#: src/pages/FederationCallback.tsx:47
+#: src/pages/FederationCallback.tsx:42
msgid "This sign-in was started elsewhere. Start again from the login page."
msgstr "This sign-in was started elsewhere. Start again from the login page."
@@ -2022,8 +2022,8 @@ msgstr "User menu"
#: src/components/profile/ProfileManageTab.tsx:61
#: src/components/profile/UsernameField.tsx:51
#: src/pages/Settings.tsx:91
-#: src/pages/UserLogin.tsx:79
-#: src/pages/UserRegister.tsx:139
+#: src/pages/UserLogin.tsx:81
+#: src/pages/UserRegister.tsx:141
msgid "Username"
msgstr "Username"
@@ -2151,7 +2151,7 @@ msgstr "Your account lives on {home}: sign in to other instances from there."
msgid "Your account lives on {home}: your password, email address, avatar and username change there."
msgstr "Your account lives on {home}: your password, email address, avatar and username change there."
-#: src/pages/UserLogin.tsx:158
+#: src/pages/UserLogin.tsx:160
msgid "Your email address"
msgstr "Your email address"
diff --git a/locales/fr.po b/locales/fr.po
index 6ca5c910..c6a80044 100644
--- a/locales/fr.po
+++ b/locales/fr.po
@@ -51,7 +51,7 @@ msgstr "{0} de plus en ligne : {restNames}"
#. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX
-#: src/pages/UserRegister.tsx:147
+#: src/pages/UserRegister.tsx:149
msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
@@ -350,7 +350,7 @@ msgstr "Tout"
msgid "Already dumped by {0} {1}"
msgstr "Déjà recommandé par {0} {1}"
-#: src/pages/UserRegister.tsx:179
+#: src/pages/UserRegister.tsx:181
msgid "Already have an account? <0>Log in0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter0>"
@@ -374,7 +374,7 @@ msgstr "Apparence"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/profile/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:124
-#: src/pages/UserRegister.tsx:166
+#: src/pages/UserRegister.tsx:168
msgid "At least {0} characters"
msgstr "Au moins {0} caractères"
@@ -391,7 +391,7 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
-#: src/pages/FederationCallback.tsx:71
+#: src/pages/FederationCallback.tsx:72
msgid "Back to the login page"
msgstr "Retour à la page de connexion"
@@ -453,7 +453,7 @@ msgid "Cancel search"
msgstr "Annuler la recherche"
#: src/components/category/CategorySelect.tsx:33
-#: src/pages/Manage.tsx:36
+#: src/pages/Manage.tsx:38
msgid "Categories"
msgstr "Catégories"
@@ -498,7 +498,7 @@ msgstr "Tribune"
msgid "Chat ({unreadChatCount} unread)"
msgstr "{unreadChatCount} messages non lus"
-#: src/pages/UserRegister.tsx:108
+#: src/pages/UserRegister.tsx:110
msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
@@ -838,7 +838,7 @@ msgstr "Édition de {0}"
msgid "Email"
msgstr "E-mail"
-#: src/pages/UserRegister.tsx:154
+#: src/pages/UserRegister.tsx:156
msgid "Email address"
msgstr "Adresse e-mail"
@@ -958,7 +958,7 @@ msgstr "Abonné"
msgid "email.digest.forYou"
msgstr "Pour vous"
-#: src/pages/UserLogin.tsx:101
+#: src/pages/UserLogin.tsx:103
msgid "Forgot password?"
msgstr "Mot de passe oublié ?"
@@ -997,7 +997,7 @@ msgstr "Bonjour {username},"
msgid "Hot"
msgstr "Tendances"
-#: src/pages/UserLogin.tsx:142
+#: src/pages/UserLogin.tsx:144
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
@@ -1029,11 +1029,11 @@ msgstr "Dans la tribune"
msgid "Insert emoji"
msgstr "Insérer un emoji"
-#: src/pages/Manage.tsx:43
+#: src/pages/Manage.tsx:45
msgid "Instances"
msgstr "Instances"
-#: src/pages/UserRegister.tsx:119
+#: src/pages/UserRegister.tsx:121
msgid "Invalid invite"
msgstr "Invitation invalide"
@@ -1156,9 +1156,9 @@ msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32
-#: src/pages/UserLogin.tsx:44
-#: src/pages/UserLogin.tsx:71
-#: src/pages/UserLogin.tsx:90
+#: src/pages/UserLogin.tsx:46
+#: src/pages/UserLogin.tsx:73
+#: src/pages/UserLogin.tsx:92
msgid "Log in"
msgstr "Se connecter"
@@ -1180,12 +1180,12 @@ msgstr "Se connecter avec {0}"
msgid "Log out"
msgstr "Se déconnecter"
-#: src/pages/UserLogin.tsx:89
+#: src/pages/UserLogin.tsx:91
msgid "Logging in…"
msgstr "Connexion…"
-#: src/pages/FederationCallback.tsx:68
-#: src/pages/UserLogin.tsx:76
+#: src/pages/FederationCallback.tsx:69
+#: src/pages/UserLogin.tsx:78
msgid "Login failed"
msgstr "Connexion échouée"
@@ -1194,8 +1194,8 @@ msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:114
-#: src/pages/Manage.tsx:27
-#: src/pages/Manage.tsx:34
+#: src/pages/Manage.tsx:29
+#: src/pages/Manage.tsx:36
#: src/pages/UserPublicProfile.tsx:164
msgid "Manage"
msgstr "Administration"
@@ -1412,12 +1412,12 @@ msgstr "Page introuvable"
#: src/pages/Settings.tsx:96
#: src/pages/Settings.tsx:106
-#: src/pages/UserLogin.tsx:86
+#: src/pages/UserLogin.tsx:88
msgid "Password"
msgstr "Mot de passe"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
-#: src/pages/UserRegister.tsx:160
+#: src/pages/UserRegister.tsx:162
msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)"
@@ -1519,17 +1519,17 @@ msgstr "Actualisation du flux…"
msgid "Refreshing…"
msgstr "Actualisation…"
-#: src/pages/UserRegister.tsx:47
-#: src/pages/UserRegister.tsx:131
-#: src/pages/UserRegister.tsx:171
+#: src/pages/UserRegister.tsx:49
+#: src/pages/UserRegister.tsx:133
+#: src/pages/UserRegister.tsx:173
msgid "Register"
msgstr "S'inscrire"
-#: src/pages/UserRegister.tsx:170
+#: src/pages/UserRegister.tsx:172
msgid "Registering…"
msgstr "Inscription…"
-#: src/pages/UserRegister.tsx:136
+#: src/pages/UserRegister.tsx:138
msgid "Registration failed"
msgstr "Inscription échouée"
@@ -1581,7 +1581,7 @@ msgstr "Répondre"
msgid "Replying to"
msgstr "En réponse à"
-#: src/pages/UserLogin.tsx:154
+#: src/pages/UserLogin.tsx:156
msgid "Request failed"
msgstr "Échec de la demande"
@@ -1688,12 +1688,12 @@ msgstr "Position"
msgid "Send"
msgstr "Envoyer"
-#: src/pages/UserLogin.tsx:163
+#: src/pages/UserLogin.tsx:165
msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation"
#: src/components/chat/PendingItem.tsx:104
-#: src/pages/UserLogin.tsx:162
+#: src/pages/UserLogin.tsx:164
msgid "Sending…"
msgstr "Envoi…"
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
msgstr "Se connecter à {name} ?"
#: src/pages/Authorize.tsx:154
-#: src/pages/FederationCallback.tsx:78
+#: src/pages/FederationCallback.tsx:79
msgid "Signing you in…"
msgstr "Connexion…"
@@ -1818,11 +1818,11 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
-#: src/pages/UserRegister.tsx:120
+#: src/pages/UserRegister.tsx:122
msgid "This invite link is missing, expired, or already used."
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
-#: src/pages/UserLogin.tsx:110
+#: src/pages/UserLogin.tsx:112
msgid "This is a mirage."
msgstr "C'est un mirage."
@@ -1838,11 +1838,11 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
-#: src/pages/FederationCallback.tsx:39
+#: src/pages/FederationCallback.tsx:40
msgid "This sign-in link is incomplete."
msgstr "Ce lien de connexion est incomplet."
-#: src/pages/FederationCallback.tsx:47
+#: src/pages/FederationCallback.tsx:42
msgid "This sign-in was started elsewhere. Start again from the login page."
msgstr "Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion."
@@ -2022,8 +2022,8 @@ msgstr "Menu utilisateur"
#: src/components/profile/ProfileManageTab.tsx:61
#: src/components/profile/UsernameField.tsx:51
#: src/pages/Settings.tsx:91
-#: src/pages/UserLogin.tsx:79
-#: src/pages/UserRegister.tsx:139
+#: src/pages/UserLogin.tsx:81
+#: src/pages/UserRegister.tsx:141
msgid "Username"
msgstr "Nom d'utilisateur"
@@ -2151,7 +2151,7 @@ msgstr "Votre compte est sur {home} : connectez-vous aux autres instances depuis
msgid "Your account lives on {home}: your password, email address, avatar and username change there."
msgstr "Votre compte est sur {home} : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas."
-#: src/pages/UserLogin.tsx:158
+#: src/pages/UserLogin.tsx:160
msgid "Your email address"
msgstr "Votre adresse e-mail"
diff --git a/shared/federation.ts b/shared/federation.ts
index 00449894..4e093530 100644
--- a/shared/federation.ts
+++ b/shared/federation.ts
@@ -58,6 +58,12 @@ export interface InstanceMetadata {
gerbeur_redirect_uri: string;
/** `POST /api/federation/events`, where it receives a home's events. */
gerbeur_events_endpoint: string;
+ /**
+ * Whether its users may sign in to other instances, so that a client
+ * refuses at once to send them to its `/authorize` page, which would. An
+ * instance that doesn't say lets them.
+ */
+ gerbeur_outgoing?: boolean;
}
/**
@@ -74,7 +80,9 @@ export function isInstanceMetadata(value: unknown): value is InstanceMetadata {
v.gerbeur_federation === FEDERATION_VERSION &&
typeof v.gerbeur_site_name === "string" &&
typeof v.gerbeur_redirect_uri === "string" &&
- typeof v.gerbeur_events_endpoint === "string";
+ typeof v.gerbeur_events_endpoint === "string" &&
+ (v.gerbeur_outgoing === undefined ||
+ typeof v.gerbeur_outgoing === "boolean");
}
/**
diff --git a/src/config/instance.ts b/src/config/instance.ts
index 6edd9956..160658e2 100644
--- a/src/config/instance.ts
+++ b/src/config/instance.ts
@@ -1,11 +1,13 @@
/**
* Lets a deployment set what the app can't know from the browser: the style
- * a visitor sees before choosing one, and whether the server sends email,
- * without which the digest settings do nothing.
+ * a visitor sees before choosing one, whether the server sends email, without
+ * which the digest settings do nothing, and which ways federated sign-in goes.
*
* The server writes `GERBEUR_DEFAULT_STYLE` into index.html's `default-style`
- * `` tag, and whether `GERBEUR_SMTPS_URL` is set into `email-enabled`
- * (`api/lib/index-html.ts`, and the Vite config in development). index.html's
+ * `` tag, whether `GERBEUR_SMTPS_URL` is set into `email-enabled`, and
+ * `FEDERATION` (`api/config.ts`) into `federation-incoming` and
+ * `federation-outgoing` (`api/lib/index-html.ts`, and the Vite config in
+ * development). index.html's
* inline theme script reads the first one too.
*
* @module
@@ -33,3 +35,18 @@ export const DEFAULT_STYLE: StyleName = (() => {
* digest switches disabled, with a note why.
*/
export const EMAIL_ENABLED: boolean = readMeta("email-enabled") === "on";
+
+/**
+ * Whether users of other instances sign in here. Without it the login and
+ * register pages offer no other instance.
+ */
+export const FEDERATION_INCOMING: boolean =
+ readMeta("federation-incoming") !== "off";
+
+/**
+ * Whether this instance's users sign in to other instances. Without it the
+ * settings page doesn't list the instances they signed in to, since none
+ * remain.
+ */
+export const FEDERATION_OUTGOING: boolean =
+ readMeta("federation-outgoing") !== "off";
diff --git a/src/pages/Manage.tsx b/src/pages/Manage.tsx
index 84315cd6..0463df64 100644
--- a/src/pages/Manage.tsx
+++ b/src/pages/Manage.tsx
@@ -16,18 +16,20 @@ import { PageHeader } from "../components/layout/PageHeader.tsx";
import { PageGroup } from "../components/layout/PageGroup.tsx";
import { CategoryManager } from "../components/category/CategoryManager.tsx";
import { PeerManager } from "../components/federation/PeerManager.tsx";
+import { FEDERATION_INCOMING } from "../config/instance.ts";
import { NotFound } from "./NotFound.tsx";
/**
* Renders the category manager and the instances the site trusts or blocks,
- * each to those who hold its permission, which the API checks again; without
+ * each to those who hold its permission, which the API checks again, the
+ * instances only while users of other instances may sign in here; without
* either, the page isn't found.
*/
export function Manage(): React.JSX.Element {
useDocumentTitle(t`Manage`);
const { user } = useRequiredAuth();
const categories = can(user, "category:manage");
- const instances = can(user, "federation:manage");
+ const instances = FEDERATION_INCOMING && can(user, "federation:manage");
if (!categories && !instances) return ;
return (
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 83d1be8e..10a769d2 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -20,7 +20,7 @@ import { useSoundsEnabled } from "../hooks/shared/useSoundsEnabled.ts";
import { useSettings } from "../hooks/auth/useSettings.ts";
import { useRequiredAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
-import { EMAIL_ENABLED } from "../config/instance.ts";
+import { EMAIL_ENABLED, FEDERATION_OUTGOING } from "../config/instance.ts";
import { previewChime } from "../utils/shared/sound.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx";
@@ -66,7 +66,7 @@ const ON_OFF_OPTIONS: SegmentedOption[] = [
* Renders `/settings`, signed in only: the username, which only a moderator
* changes (`ProfileManageTab`), the password change, which opens
* {@link ChangePasswordModal}, the other instances the account signs in to
- * ({@link GrantList}), and the settings, each saved as soon as it is
+ * ({@link GrantList}) unless it may sign in to none, and the settings, each saved as soon as it is
* clicked. An account from another instance changes its password there. The style, color scheme and digests are saved to the account; the
* default feed tab and sounds, for now, to this browser.
*/
@@ -124,7 +124,7 @@ export function Settings(): React.JSX.Element {
diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx
index ca7687c1..8ff13eaa 100644
--- a/src/pages/UserLogin.tsx
+++ b/src/pages/UserLogin.tsx
@@ -24,6 +24,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx";
+import { FEDERATION_INCOMING } from "../config/instance.ts";
import { nextPath } from "../utils/auth/next.ts";
interface LoginValues {
@@ -38,7 +39,8 @@ interface ResetValues {
/**
* Renders `/login`, for the signed out. A sign-in lands on the page `?next=`
* names, or `/`, and "Forgot password?" unfolds the reset request form below.
- * Users of another instance sign in through {@link FederatedSignIn}.
+ * Users of another instance sign in through {@link FederatedSignIn}, unless
+ * the instance takes none.
*/
export function UserLogin(): React.JSX.Element {
useDocumentTitle(t`Log in`);
@@ -104,7 +106,7 @@ export function UserLogin(): React.JSX.Element {
{showReset && }
-
+ {FEDERATION_INCOMING && }
This is a mirage.
diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx
index c6b9521a..3ac6f350 100644
--- a/src/pages/UserRegister.tsx
+++ b/src/pages/UserRegister.tsx
@@ -26,6 +26,7 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx";
+import { FEDERATION_INCOMING } from "../config/instance.ts";
type TokenState =
| { status: "checking" }
@@ -41,7 +42,8 @@ interface Values {
/**
* Renders `/register?token=`, for the signed out. It checks the invite before
* showing the form, and signs the new user in on success. A user of another
- * instance joins with that account instead, through {@link FederatedSignIn}.
+ * instance joins with that account instead, through {@link FederatedSignIn},
+ * unless the instance takes none.
*/
export function UserRegister(): React.JSX.Element {
useDocumentTitle(t`Register`);
@@ -173,7 +175,7 @@ export function UserRegister(): React.JSX.Element {
-
+ {FEDERATION_INCOMING && }