v4: a remote account's links to its home lead to the home's pages — the settings' "Change it on <host>" button and the profile's "Account on <host>" link went to the host in the handle, which is the home's issuer, its API, not necessarily where its pages are: in development musique's button led to localhost:8000/settings, gerbeur's API, which served a stale build, instead of localhost:3000/settings. GET /api/users/:key/home?path= now redirects to a page of a remote user's home, their profile there by default, at the origin of the home's authorization_endpoint, which its metadata gives (homePageUrl in federation-service.ts), the issuer standing in while the home doesn't answer; a local user 404s, and a path that isn't one 400s. homeUrl links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 21:06:14 +00:00
parent 5f2fb4479a
commit 8bcc005140
5 changed files with 90 additions and 9 deletions

2
API.md
View File

@@ -130,6 +130,8 @@ there: those endpoints answer it 400.
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
follow.
- `GET /api/users/:key/invitees` — anyone. The tree of users they invited.
- `GET /api/users/:key/home?path=` — anyone. Redirects to a page of a remote
user's home instance, their profile there by default.
- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's
role, or anyone's name, theirs included.

View File

@@ -30,6 +30,9 @@
* page of the public playlists they follow.
* - `GET /api/users/:key/invitees` — public. Answers the tree of users they
* invited.
* - `GET /api/users/:key/home?path=` — public. Redirects to a page of the
* instance a user from another instance comes from, their profile there
* unless `path` names another; a local user 404s.
* - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another
* user's role.
* - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a
@@ -98,6 +101,7 @@ import {
getFollowedUsersByUser,
} from "../services/follow-service.ts";
import { resolveCategoryFilter } from "../services/category-service.ts";
import { homePageUrl } from "../services/federation-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -261,6 +265,13 @@ router.get("/:key/invitees", (ctx) => {
ctx.response.body = responseBody;
});
router.get("/:key/home", async (ctx) => {
const path = ctx.request.url.searchParams.get("path") ?? undefined;
ctx.response.redirect(
await homePageUrl(resolveUserKey(ctx.params.key), path),
);
});
router.patch(
"/:key/role",
authMiddleware,

View File

@@ -18,7 +18,8 @@
* blocked one they may not, and from any other only with an invite, which the
* 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`).
* password, email, avatar and name are its home's (`assertLocalUser`), and
* change on its pages there ({@link homePageUrl}).
*
* @module
*/
@@ -40,7 +41,8 @@ import {
isIdentityClaims,
parseInstance,
} from "../../shared/federation.ts";
import { EMAIL_RE, USERNAME_RE } from "../../shared/validation.ts";
import { userPath } from "../../shared/paths.ts";
import { EMAIL_RE, splitHandle, USERNAME_RE } from "../../shared/validation.ts";
import { toPublicUser, type User } from "../model/user.ts";
import {
exchangeCode,
@@ -447,3 +449,35 @@ export async function receiveEvent(token: string): Promise<void> {
if (!claims) throw new APIException(400, "Invalid event");
await applyClaims(identity.user_id, issuer, claims);
}
/**
* Returns the URL of `path` on the home of `user`, an account from another
* instance, for `GET /api/users/:key/home`: their profile there by default,
* or another of its pages, such as `/settings`, where they change their
* password. A handle's host is its home's issuer, the API's origin, which
* needn't be its pages': those are where its `/authorize` page is, and the
* home's metadata says. While it doesn't answer, the issuer stands in.
*
* @throws {APIException} 400 when `path` isn't a path; 404 when `user` is
* local.
*/
export async function homePageUrl(user: User, path?: string): Promise<string> {
const identity = db.prepare(
`SELECT issuer FROM user_identities WHERE user_id = ?;`,
).get(user.id) as { issuer: string } | undefined;
if (!identity) throw new APIException(404, "This account lives here");
if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) {
throw new APIException(400, "Not a path");
}
let pages = identity.issuer;
try {
const { origin, protocol } = new URL(
(await fetchMetadata(identity.issuer)).authorization_endpoint,
);
if (protocol === "https:" || protocol === "http:") pages = origin;
} catch (err) {
console.error(`[federation] ${identity.issuer} unreachable:`, err);
}
return `${pages}${path ?? userPath(splitHandle(user.username).name)}`;
}

View File

@@ -123,6 +123,39 @@ test("federation: a trusted instance's user signs in as name@host, once made", a
);
});
test("federation: a remote user's home link leads to its pages, not its API", async () => {
const home = await fakeInstance(HOME, {
authorization_endpoint: "https://www.home.test/authorize",
});
using _net = network(home);
await trust(HOME);
await signIn(home, ALICE);
const profile = await get("/api/users/@alice%40home.test/home");
assertEquals(profile.status, 302);
assertEquals(
profile.headers.get("location"),
"https://www.home.test/@alice",
);
const settings = await get(
"/api/users/@alice%40home.test/home?path=%2Fsettings",
);
assertEquals(
settings.headers.get("location"),
"https://www.home.test/settings",
);
assertEquals(
(await get("/api/users/@alice%40home.test/home?path=%2F%2Fevil.test"))
.status,
400,
);
const local = await createUser();
assertEquals(
(await get(`/api/users/@${local.username}/home`)).status,
404,
);
});
test("federation: a login works once, with the code the home made for it", async () => {
const home = await fakeInstance(HOME);
using _net = network(home);

View File

@@ -7,8 +7,8 @@
* @module
*/
import { API_URL } from "../../config/api.ts";
import { splitHandle } from "../../../shared/validation.ts";
import { userPath } from "../../../shared/paths.ts";
/**
* Returns the host of `handle`'s home instance, or undefined for a local
@@ -19,12 +19,13 @@ export function homeHost(handle: string): string | undefined {
}
/**
* Returns the URL of `path` on `handle`'s home instance, their profile there
* by default, or null for a local user. Instances speak the same protocol as
* this page, https outside development.
* Returns a link to `path` on `handle`'s home instance, their profile there
* by default, or null for a local user. The host in a handle is the home's
* API, not necessarily its pages, so the link goes through
* `GET /api/users/:key/home`, which redirects to them.
*/
export function homeUrl(handle: string, path?: string): string | null {
const { name, host } = splitHandle(handle);
if (!host) return null;
return `${globalThis.location.protocol}//${host}${path ?? userPath(name)}`;
if (!homeHost(handle)) return null;
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return `${API_URL}/api/users/@${encodeURIComponent(handle)}/home${query}`;
}