feat(journal): outbound trails-to-trails follows (social-federation §6)

Tasks 6.1–6.6. A local user can follow a user on another trails
instance: WebFinger-resolve the handle, verify the target runs
trails.cool via NodeInfo — checked against the ACTOR IRI's host, never
the handle's domain (split-domain constraint) — record a Pending
follow row, deliver a signed Follow. The §4 inbox listeners already
settle (Accept) or drop (Reject) the row.

- federation-outbound.server.ts: followRemoteActor / cancelRemoteFollow
  (Undo delivery) / listOutgoingRemoteFollows. Network steps (lookup,
  NodeInfo, delivery) injectable for offline integration tests.
  Software allowlist: trails-cool.
- /follows/outgoing: follow-by-handle form + pending/accepted list +
  cancel; linked from the feed empty state; i18n en+de. Remote actors
  have no local profile page, so the remote Pending state lives here
  (the profile Pending button from locked accounts covers local).
- /.well-known/trails-cool now publishes software: trails-cool (6.2).
- Clear 4xx codes: invalid_handle, local_handle, remote_resolve_failed,
  not_trails (6.3).
- Tests: handle-parser + allowlist units; integration suite for the
  Pending lifecycle (create/idempotent/refuse-non-trails/refuse-
  unresolvable/own-host/cancel+Undo) with injected network deps; e2e
  anonymous-redirect guard for the new page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-07 11:50:35 +02:00
parent be53f6670a
commit 62b40a25f3
12 changed files with 703 additions and 7 deletions

View file

@ -0,0 +1,155 @@
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import {
followRemoteActor,
cancelRemoteFollow,
listOutgoingRemoteFollows,
type OutboundFollowDeps,
type ResolvedRemoteActor,
} from "./federation-outbound.server.ts";
import { FollowError } from "./follow.server.ts";
// Opt-in: real Postgres; network steps injected so no instance is
// contacted. Same convention as the other *.integration.test.ts files.
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
const REMOTE = "https://other-trails.example/users/alice";
const createdUserIds: string[] = [];
async function makeUser(username: string) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `${username}@example.test`,
username,
domain: "test.local",
profileVisibility: "public",
});
createdUserIds.push(id);
return id;
}
function fakeDeps(opts?: {
software?: string;
resolveTo?: ResolvedRemoteActor | null;
}): OutboundFollowDeps & { delivered: string[]; undone: string[] } {
const delivered: string[] = [];
const undone: string[] = [];
return {
delivered,
undone,
async resolveActor() {
return opts?.resolveTo !== undefined
? opts.resolveTo
: { iri: REMOTE, inboxUrl: `${REMOTE}/inbox`, username: "alice", displayName: "Alice" };
},
async fetchSoftwareName() {
return opts?.software ?? "trails-cool";
},
async deliverFollow(_u, followId) {
delivered.push(followId);
},
async deliverUndoFollow(_u, followId) {
undone.push(followId);
},
};
}
describe.runIf(runIntegration)("outbound trails-to-trails follows (integration)", () => {
beforeAll(() => {
process.env.ORIGIN ??= "http://localhost:3000";
});
afterEach(async () => {
const db = getDb();
for (const id of createdUserIds.splice(0)) {
await db.delete(follows).where(eq(follows.followerId, id));
await db.delete(users).where(eq(users.id, id));
}
await db.delete(remoteActors).where(eq(remoteActors.actorIri, REMOTE));
});
it("creates a Pending row, caches the actor, and delivers Follow", async () => {
const userId = await makeUser(`out-${Date.now()}`);
const deps = fakeDeps();
const state = await followRemoteActor(userId, "@alice@other-trails.example", deps);
expect(state).toEqual({ pending: true, actorIri: REMOTE });
expect(deps.delivered).toHaveLength(1);
const db = getDb();
const [row] = await db.select().from(follows).where(eq(follows.followerId, userId));
expect(row!.followedActorIri).toBe(REMOTE);
expect(row!.followedUserId).toBeNull();
expect(row!.acceptedAt).toBeNull();
const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, REMOTE));
expect(cached!.inboxUrl).toBe(`${REMOTE}/inbox`);
expect(cached!.displayName).toBe("Alice");
});
it("is idempotent — re-following returns the existing state without re-delivering", async () => {
const userId = await makeUser(`out-idem-${Date.now()}`);
const deps = fakeDeps();
await followRemoteActor(userId, "@alice@other-trails.example", deps);
const second = await followRemoteActor(userId, "@alice@other-trails.example", deps);
expect(second.pending).toBe(true);
expect(deps.delivered).toHaveLength(1);
});
it("refuses non-trails instances with a clear code and writes nothing", async () => {
const userId = await makeUser(`out-mast-${Date.now()}`);
const deps = fakeDeps({ software: "mastodon" });
await expect(followRemoteActor(userId, "@alice@other-trails.example", deps)).rejects.toMatchObject({
code: "not_trails",
});
const db = getDb();
expect(await db.select().from(follows).where(eq(follows.followerId, userId))).toHaveLength(0);
});
it("refuses unresolvable handles and local handles", async () => {
const userId = await makeUser(`out-bad-${Date.now()}`);
await expect(
followRemoteActor(userId, "@ghost@other-trails.example", fakeDeps({ resolveTo: null })),
).rejects.toMatchObject({ code: "remote_resolve_failed" });
await expect(followRemoteActor(userId, "not a handle", fakeDeps())).rejects.toMatchObject({
code: "invalid_handle",
});
// Own-host check needs a parseable host (the default localhost:3000
// fails the TLD shape first) — point ORIGIN at a real-looking domain.
const prevOrigin = process.env.ORIGIN;
process.env.ORIGIN = "https://own-instance.example";
try {
await expect(
followRemoteActor(userId, "@someone@own-instance.example", fakeDeps()),
).rejects.toMatchObject({ code: "local_handle" });
} finally {
process.env.ORIGIN = prevOrigin;
}
});
it("lists outgoing follows and cancel removes the row + delivers Undo", async () => {
const userId = await makeUser(`out-cancel-${Date.now()}`);
const deps = fakeDeps();
await followRemoteActor(userId, "@alice@other-trails.example", deps);
const entries = await listOutgoingRemoteFollows(userId);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ actorIri: REMOTE, pending: true, username: "alice" });
await cancelRemoteFollow(userId, REMOTE, deps);
expect(deps.undone).toHaveLength(1);
expect(await listOutgoingRemoteFollows(userId)).toHaveLength(0);
// Idempotent second cancel
await cancelRemoteFollow(userId, REMOTE, deps);
expect(deps.undone).toHaveLength(1);
});
it("FollowError codes are exported for the route layer", () => {
expect(new FollowError("not_trails", "x").code).toBe("not_trails");
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("./db.ts", () => ({ getDb: () => ({}) }));
const { parseRemoteHandle, isTrailsSoftware } = await import("./federation-outbound.server.ts");
describe("parseRemoteHandle", () => {
it("accepts the common handle spellings", () => {
expect(parseRemoteHandle("@alice@other.example")).toEqual({ username: "alice", host: "other.example" });
expect(parseRemoteHandle("alice@other.example")).toEqual({ username: "alice", host: "other.example" });
expect(parseRemoteHandle("acct:alice@other.example")).toEqual({ username: "alice", host: "other.example" });
expect(parseRemoteHandle(" @alice@other.example ")).toEqual({ username: "alice", host: "other.example" });
expect(parseRemoteHandle("@a_l-i.ce@sub.other.example")).toEqual({ username: "a_l-i.ce", host: "sub.other.example" });
});
it("lowercases hosts but preserves username case", () => {
expect(parseRemoteHandle("@Alice@Other.Example")).toEqual({ username: "Alice", host: "other.example" });
});
it("accepts a port (dev instances)", () => {
expect(parseRemoteHandle("@bob@localhost:3000")).toBeNull(); // bare hostname without TLD is rejected
expect(parseRemoteHandle("@bob@staging.trails.cool:3000")).toEqual({ username: "bob", host: "staging.trails.cool:3000" });
});
it("rejects everything else", () => {
expect(parseRemoteHandle("alice")).toBeNull();
expect(parseRemoteHandle("https://other.example/users/alice")).toBeNull();
expect(parseRemoteHandle("@@broken@host.example")).toBeNull();
expect(parseRemoteHandle("")).toBeNull();
});
});
describe("isTrailsSoftware", () => {
it("accepts trails-cool and nothing else", () => {
expect(isTrailsSoftware("trails-cool")).toBe(true);
expect(isTrailsSoftware("mastodon")).toBe(false);
expect(isTrailsSoftware("hollo")).toBe(false);
expect(isTrailsSoftware(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,300 @@
// Outbound trails-to-trails follows (spec: social-federation §6).
//
// A local user follows a user on another *trails* instance: resolve the
// handle via WebFinger, verify the target instance runs trails.cool via
// NodeInfo (checked against the ACTOR IRI's host, never the handle's
// domain — remote instances may run split domains, see
// docs/ideas/split-domain-handles.md), record a Pending follow row, and
// deliver a signed Follow. The remote's Accept(Follow) settles the row
// (inbox listener from §4); Reject deletes it.
//
// Network-touching steps (actor lookup, NodeInfo fetch, delivery) are
// injectable so the row lifecycle is integration-testable offline.
import { randomUUID } from "node:crypto";
import { getNodeInfo } from "@fedify/fedify";
import { Follow, Undo } from "@fedify/fedify/vocab";
import { and, eq, isNull, isNotNull, desc } from "drizzle-orm";
import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import { getDb } from "./db.ts";
import { getOrigin } from "./config.server.ts";
import { localActorIri } from "./actor-iri.ts";
import { getFederation } from "./federation.server.ts";
import { upsertRemoteActor } from "./federation-delivery.server.ts";
import { FollowError } from "./follow.server.ts";
import { logger } from "./logger.server.ts";
/**
* Software names accepted by the trails-to-trails check. Forks that
* keep the NodeInfo name federate out of the box; rebrands need a PR
* here (see the open question in social-federation design.md; the
* Wanderer-interop idea would extend this list).
*/
const TRAILS_SOFTWARE = new Set(["trails-cool"]);
export interface RemoteHandle {
username: string;
host: string;
}
/**
* Parse `@user@host`, `user@host`, or an `acct:` URI into its parts.
* Returns null for anything else (including bare usernames and URLs
* follows by URL can come later; handles are the user-facing shape).
*/
export function parseRemoteHandle(input: string): RemoteHandle | null {
const trimmed = input.trim().replace(/^acct:/, "").replace(/^@/, "");
const match = /^([a-z0-9_.-]+)@([a-z0-9.-]+\.[a-z]{2,}(?::\d+)?)$/i.exec(trimmed);
if (!match) return null;
return { username: match[1]!, host: match[2]!.toLowerCase() };
}
export function isTrailsSoftware(name: string | undefined): boolean {
return name !== undefined && TRAILS_SOFTWARE.has(name);
}
export interface ResolvedRemoteActor {
iri: string;
inboxUrl: string;
username: string | null;
displayName: string | null;
}
export interface OutboundFollowDeps {
resolveActor(handle: RemoteHandle): Promise<ResolvedRemoteActor | null>;
fetchSoftwareName(actorIri: string): Promise<string | undefined>;
deliverFollow(followerUsername: string, followId: string, target: ResolvedRemoteActor): Promise<void>;
deliverUndoFollow(followerUsername: string, followId: string, target: { iri: string; inboxUrl: string }): Promise<void>;
}
function followActivityId(followerUsername: string, followId: string): URL {
return new URL(`${localActorIri(followerUsername)}#follows/${followId}`);
}
function defaultDeps(): OutboundFollowDeps {
const federation = getFederation();
const ctx = () => federation.createContext(new URL(getOrigin()), undefined);
return {
async resolveActor(handle) {
const actor = await ctx().lookupObject(`@${handle.username}@${handle.host}`);
if (actor == null || !("inboxId" in actor) || actor.id == null) return null;
const inbox = actor.inboxId as URL | null;
if (inbox == null) return null;
return {
iri: actor.id.href,
inboxUrl: inbox.href,
username: ("preferredUsername" in actor ? (actor.preferredUsername as unknown as string | null) : null) ?? handle.username,
displayName: ("name" in actor ? (actor.name as unknown as string | null) : null),
};
},
async fetchSoftwareName(actorIri) {
// Split-domain constraint: NodeInfo is looked up on the ACTOR
// IRI's origin (the server domain), never the handle's domain.
const info = await getNodeInfo(new URL(actorIri).origin);
return info?.software?.name;
},
async deliverFollow(followerUsername, followId, target) {
await ctx().sendActivity(
{ identifier: followerUsername },
{ id: new URL(target.iri), inboxId: new URL(target.inboxUrl) },
new Follow({
id: followActivityId(followerUsername, followId),
actor: new URL(localActorIri(followerUsername)),
object: new URL(target.iri),
}),
);
},
async deliverUndoFollow(followerUsername, followId, target) {
await ctx().sendActivity(
{ identifier: followerUsername },
{ id: new URL(target.iri), inboxId: new URL(target.inboxUrl) },
new Undo({
id: new URL(`${localActorIri(followerUsername)}#follows/${followId}/undo`),
actor: new URL(localActorIri(followerUsername)),
object: new Follow({
id: followActivityId(followerUsername, followId),
actor: new URL(localActorIri(followerUsername)),
object: new URL(target.iri),
}),
}),
);
},
};
}
export interface OutgoingFollowState {
pending: boolean;
actorIri: string;
}
/**
* Follow a user on another trails instance (spec 6.1/6.4). Pending
* until their Accept(Follow) lands in our inbox. Idempotent: an
* existing row (pending or accepted) is returned unchanged.
*/
export async function followRemoteActor(
followerId: string,
handleInput: string,
deps: OutboundFollowDeps = defaultDeps(),
): Promise<OutgoingFollowState> {
const handle = parseRemoteHandle(handleInput);
if (!handle) {
throw new FollowError("invalid_handle", "Enter a handle like @user@instance.example");
}
const ownHost = new URL(getOrigin()).host;
if (handle.host === ownHost) {
throw new FollowError("local_handle", "That user is on this instance — follow them from their profile");
}
const db = getDb();
const [follower] = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, followerId))
.limit(1);
if (!follower) throw new FollowError("user_not_found", "User not found");
const actor = await deps.resolveActor(handle);
if (!actor) {
throw new FollowError("remote_resolve_failed", "Couldn't find that user — check the handle");
}
// Trails-to-trails gate (spec 6.1/6.3).
let software: string | undefined;
try {
software = await deps.fetchSoftwareName(actor.iri);
} catch {
software = undefined;
}
if (!isTrailsSoftware(software)) {
throw new FollowError(
"not_trails",
"Outbound federation is currently trails-to-trails only — that instance doesn't run trails.cool",
);
}
// Idempotent re-follow.
const [existing] = await db
.select({ id: follows.id, acceptedAt: follows.acceptedAt })
.from(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, actor.iri)));
if (existing) {
return { pending: existing.acceptedAt === null, actorIri: actor.iri };
}
const followId = randomUUID();
await db.insert(follows).values({
id: followId,
followerId,
followedActorIri: actor.iri,
followedUserId: null,
acceptedAt: null,
});
await upsertRemoteActor({
actorIri: actor.iri,
inboxUrl: actor.inboxUrl,
username: actor.username,
displayName: actor.displayName,
domain: new URL(actor.iri).host,
});
try {
await deps.deliverFollow(follower.username, followId, actor);
} catch (err) {
// Keep the Pending row — Fedify's queue retries delivery; the row
// is also the user's visible record that a request is in flight.
logger.warn({ err, actorIri: actor.iri }, "outbound Follow delivery failed (queued retries may still succeed)");
}
logger.info({ followerId, actorIri: actor.iri }, "federation: outbound follow pending");
return { pending: true, actorIri: actor.iri };
}
/**
* Cancel an outgoing remote follow (pending or accepted): delete the
* row and deliver Undo(Follow) (spec 6.6 / social-follows "Cancel a
* Pending follow").
*/
export async function cancelRemoteFollow(
followerId: string,
actorIri: string,
deps: OutboundFollowDeps = defaultDeps(),
): Promise<void> {
const db = getDb();
const [follower] = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, followerId))
.limit(1);
if (!follower) throw new FollowError("user_not_found", "User not found");
const deleted = await db
.delete(follows)
.where(
and(
eq(follows.followerId, followerId),
eq(follows.followedActorIri, actorIri),
isNull(follows.followedUserId), // remote rows only
),
)
.returning({ id: follows.id });
if (deleted.length === 0) return; // idempotent
const [cached] = await db
.select({ inboxUrl: remoteActors.inboxUrl })
.from(remoteActors)
.where(eq(remoteActors.actorIri, actorIri))
.limit(1);
if (cached?.inboxUrl) {
try {
await deps.deliverUndoFollow(follower.username, deleted[0]!.id, {
iri: actorIri,
inboxUrl: cached.inboxUrl,
});
} catch (err) {
logger.warn({ err, actorIri }, "Undo(Follow) delivery failed; row already removed locally");
}
}
logger.info({ followerId, actorIri }, "federation: outbound follow cancelled");
}
export interface OutgoingFollowEntry {
actorIri: string;
pending: boolean;
createdAt: Date;
displayName: string | null;
username: string | null;
domain: string | null;
}
/** All outgoing remote follows for the user, newest first (spec 6.6). */
export async function listOutgoingRemoteFollows(followerId: string): Promise<OutgoingFollowEntry[]> {
const db = getDb();
const rows = await db
.select({
actorIri: follows.followedActorIri,
acceptedAt: follows.acceptedAt,
createdAt: follows.createdAt,
displayName: remoteActors.displayName,
username: remoteActors.username,
domain: remoteActors.domain,
})
.from(follows)
.leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri))
.where(
and(
eq(follows.followerId, followerId),
isNull(follows.followedUserId),
isNotNull(follows.followedActorIri),
),
)
.orderBy(desc(follows.createdAt));
return rows.map((r) => ({
actorIri: r.actorIri,
pending: r.acceptedAt === null,
createdAt: r.createdAt,
displayName: r.displayName,
username: r.username,
domain: r.domain,
}));
}

View file

@ -7,7 +7,16 @@ import { createNotification } from "./notifications.server.ts";
import { logger } from "./logger.server.ts";
export class FollowError extends Error {
readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden";
readonly code:
| "self_follow"
| "user_not_found"
| "not_found"
| "forbidden"
// outbound federation codes (federation-outbound.server.ts)
| "invalid_handle"
| "local_handle"
| "remote_resolve_failed"
| "not_trails";
constructor(code: FollowError["code"], message: string) {
super(message);
this.name = "FollowError";

View file

@ -36,6 +36,7 @@ export default [
route("api/users/:username/follow", "routes/api.users.$username.follow.ts"),
route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"),
route("follows/requests", "routes/follows.requests.tsx"),
route("follows/outgoing", "routes/follows.outgoing.tsx"),
route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"),
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
route("feed", "routes/feed.tsx"),

View file

@ -15,5 +15,9 @@ export function loader() {
apiVersion: API_VERSION,
instanceName: domain,
apiBaseUrl: `${origin}/api/v1`,
// Software identity for trails-to-trails federation discovery
// (social-federation 6.2). NodeInfo (/nodeinfo/2.1) is the primary
// check; this is the trails-specific secondary signal.
software: "trails-cool",
});
}

View file

@ -81,6 +81,14 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
{t("social.feed.seePublic")}
</Link>
)}
{view === "followed" && (
<Link
to="/follows/outgoing"
className="mt-1 block text-sm text-blue-600 hover:underline"
>
{t("social.feed.followRemote")}
</Link>
)}
</div>
) : (
<ul className="mt-6 space-y-4">

View file

@ -0,0 +1,144 @@
import { data, redirect, Form, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/follows.outgoing";
import { getSessionUser } from "~/lib/auth/session.server";
import { FollowError } from "~/lib/follow.server";
import {
followRemoteActor,
cancelRemoteFollow,
listOutgoingRemoteFollows,
} from "~/lib/federation-outbound.server";
import { federationEnabled } from "~/lib/federation.server";
import { ClientDate } from "~/components/ClientDate";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const entries = await listOutgoingRemoteFollows(user.id);
return data({ entries, federationEnabled: federationEnabled() });
}
export async function action({ request }: Route.ActionArgs) {
const user = await getSessionUser(request);
if (!user) return data({ error: "Unauthorized" }, { status: 401 });
const form = await request.formData();
const intent = form.get("intent");
try {
if (intent === "follow") {
const handle = String(form.get("handle") ?? "");
const state = await followRemoteActor(user.id, handle);
return data({ ok: true, ...state });
}
if (intent === "cancel") {
const actorIri = String(form.get("actorIri") ?? "");
await cancelRemoteFollow(user.id, actorIri);
return data({ ok: true });
}
return data({ error: "Unknown intent" }, { status: 400 });
} catch (e) {
if (e instanceof FollowError) {
return data({ error: e.message, code: e.code }, { status: 400 });
}
throw e;
}
}
export function meta() {
return [{ title: "Following across instances — trails.cool" }];
}
export default function OutgoingFollows({ loaderData, actionData }: Route.ComponentProps) {
const { entries, federationEnabled } = loaderData;
const { t } = useTranslation("journal");
const navigation = useNavigation();
const busy = navigation.state !== "idle";
const error = actionData && "error" in actionData ? actionData.error : null;
const errorCode = actionData && "code" in actionData ? (actionData.code as string) : null;
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{t("social.outgoing.heading")}</h1>
<p className="mt-1 text-sm text-gray-500">{t("social.outgoing.subtitle")}</p>
{!federationEnabled ? (
<p className="mt-8 rounded-md bg-gray-50 p-4 text-sm text-gray-600">
{t("social.outgoing.disabled")}
</p>
) : (
<>
<Form method="post" className="mt-6 flex items-end gap-3">
<input type="hidden" name="intent" value="follow" />
<div className="flex-1">
<label htmlFor="handle" className="block text-sm font-medium text-gray-700">
{t("social.outgoing.handleLabel")}
</label>
<input
id="handle"
name="handle"
type="text"
placeholder="@alice@other-trails.example"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={busy}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{t("social.outgoing.followButton")}
</button>
</Form>
{error && (
<p className="mt-2 text-sm text-red-600">
{errorCode === "not_trails" ? t("social.outgoing.notTrails") : error}
</p>
)}
{entries.length === 0 ? (
<p className="mt-10 text-center text-gray-500">{t("social.outgoing.empty")}</p>
) : (
<ul className="mt-8 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((e) => (
<li key={e.actorIri} className="flex items-center justify-between px-4 py-3">
<div>
<a
href={e.actorIri}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-gray-900 hover:underline"
>
{e.displayName ?? e.username ?? e.actorIri}
</a>
<p className="text-xs text-gray-500">
{e.username && e.domain ? `@${e.username}@${e.domain}` : e.actorIri}
{" · "}
{e.pending ? (
<span className="text-amber-600">{t("social.outgoing.pendingBadge")}</span>
) : (
<span className="text-green-700">{t("social.outgoing.acceptedBadge")}</span>
)}
{" · "}
<ClientDate iso={e.createdAt as unknown as string} />
</p>
</div>
<Form method="post">
<input type="hidden" name="intent" value="cancel" />
<input type="hidden" name="actorIri" value={e.actorIri} />
<button
type="submit"
disabled={busy}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{e.pending ? t("social.outgoing.cancelRequest") : t("social.outgoing.unfollow")}
</button>
</Form>
</li>
))}
</ul>
)}
</>
)}
</div>
);
}

View file

@ -47,6 +47,11 @@ test.describe("Social follows + /feed", () => {
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("/follows/outgoing redirects anonymous visitors to login", async ({ page }) => {
await page.goto("/follows/outgoing");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("/follows/requests redirects to /notifications?tab=requests", async ({ page, browser }) => {
// The route was folded into the Notifications page as a tab. The
// 301 still resolves; for anonymous visitors the notifications

View file

@ -45,13 +45,15 @@
## 6. Outbound follow + trails-to-trails check
- [ ] 6.1 Update `followUser` to allow remote IRIs; before creating, fetch the remote actor object and inspect `software`/discovery endpoint
- [x] 6.1 Update `followUser` to allow remote IRIs; before creating, fetch the remote actor object and inspect `software`/discovery endpoint
> Split-domain interop constraint (2026-06-07): resolve the handle via WebFinger first, then run the NodeInfo/`software` check against the **actor IRI's host** (the server domain), never the handle's domain — Hollo/Mastodon-style split-domain instances (`@user@example.com` served from `social.example.com`) would otherwise be wrongly refused. See `docs/ideas/split-domain-handles.md`.
- [ ] 6.2 Implement `/.well-known/trails-cool` endpoint on our side (publishes our software identity) so other trails instances recognize us
- [ ] 6.3 If the discovery check fails, return 4xx with a clear "outbound federation to non-trails instances isn't supported yet" message + docs link
- [ ] 6.4 If the check passes, write follow row with `accepted_at = NULL`, push `Follow` activity to remote inbox
- [ ] 6.5 UI: "Pending" button state on profile (replaces Follow/Unfollow) when `accepted_at IS NULL`
- [ ] 6.6 Outgoing-follows page or section listing Pending requests with cancel control (`Undo(Follow)` + delete row)
- [x] 6.2 Implement `/.well-known/trails-cool` endpoint on our side (publishes our software identity) so other trails instances recognize us
- [x] 6.3 If the discovery check fails, return 4xx with a clear "outbound federation to non-trails instances isn't supported yet" message + docs link
- [x] 6.4 If the check passes, write follow row with `accepted_at = NULL`, push `Follow` activity to remote inbox
- [x] 6.5 UI: "Pending" button state on profile (replaces Follow/Unfollow) when `accepted_at IS NULL`
> The Pending button already existed from locked accounts. Remote actors have no local profile page, so the remote Pending state lives on /follows/outgoing (6.6) instead.
- [x] 6.6 Outgoing-follows page or section listing Pending requests with cancel control (`Undo(Follow)` + delete row)
> Shipped as /follows/outgoing: follow-by-handle form + pending/accepted list + cancel (also satisfies the Reject-surfacing note on 4.5 — rejected rows simply disappear from this list). Linked from the feed empty state.
## 7. Outbox-poll ingestion

View file

@ -321,6 +321,19 @@ export default {
count_other: "Folgt {{count}}",
empty: "Folgt noch niemandem.",
},
outgoing: {
heading: "Folgen über Instanzen hinweg",
subtitle: "Folge Leuten auf anderen trails.cool-Instanzen. Anfragen bleiben ausstehend, bis ihre Instanz sie annimmt.",
handleLabel: "Handle der Person",
followButton: "Folgen",
pendingBadge: "Ausstehend",
acceptedBadge: "Folge ich",
cancelRequest: "Anfrage zurückziehen",
unfollow: "Entfolgen",
empty: "Noch keine Follows auf anderen Instanzen. Gib oben ein Handle ein, um zu starten.",
notTrails: "Ausgehende Föderation ist derzeit nur zwischen trails-Instanzen möglich — diese Instanz nutzt kein trails.cool.",
disabled: "Föderation ist auf dieser Instanz nicht aktiviert.",
},
prevPage: "Zurück",
nextPage: "Weiter",
pageOfTotal: "Seite {{page}} von {{totalPages}}",
@ -328,6 +341,7 @@ export default {
title: "Feed",
heading: "Folge ich",
empty: "Du folgst noch niemandem. Stöbere Profile durch und klicke auf Folgen, um deinen Feed aufzubauen.",
followRemote: "Folge jemandem auf einer anderen trails-Instanz →",
seePublic: "Oder durchstöbere den öffentlichen Feed dieser Instanz →",
toggle: {
followed: "Folge ich",

View file

@ -321,6 +321,19 @@ export default {
count_other: "Following {{count}}",
empty: "Not following anyone yet.",
},
outgoing: {
heading: "Following across instances",
subtitle: "Follow people on other trails.cool instances. Requests stay pending until their instance accepts.",
handleLabel: "Their handle",
followButton: "Follow",
pendingBadge: "Pending",
acceptedBadge: "Following",
cancelRequest: "Cancel request",
unfollow: "Unfollow",
empty: "No follows on other instances yet. Enter a handle above to start.",
notTrails: "Outbound federation is currently trails-to-trails only — that instance doesn't run trails.cool.",
disabled: "Federation is not enabled on this instance.",
},
prevPage: "Previous",
nextPage: "Next",
pageOfTotal: "Page {{page}} of {{totalPages}}",
@ -328,6 +341,7 @@ export default {
title: "Feed",
heading: "Following",
empty: "You're not following anyone yet. Browse profiles and tap Follow to start building your feed.",
followRemote: "Follow someone on another trails instance →",
seePublic: "Or browse the instance public feed →",
toggle: {
followed: "Following",