From 62b40a25f35965ab40bde850fadadee4410102f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 11:50:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(journal):=20outbound=20trails-to-trails=20?= =?UTF-8?q?follows=20(social-federation=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../federation-outbound.integration.test.ts | 155 +++++++++ .../lib/federation-outbound.server.test.ts | 40 +++ .../app/lib/federation-outbound.server.ts | 300 ++++++++++++++++++ apps/journal/app/lib/follow.server.ts | 11 +- apps/journal/app/routes.ts | 1 + .../app/routes/api.well-known.trails-cool.ts | 4 + apps/journal/app/routes/feed.tsx | 8 + apps/journal/app/routes/follows.outgoing.tsx | 144 +++++++++ e2e/social.test.ts | 5 + openspec/changes/social-federation/tasks.md | 14 +- packages/i18n/src/locales/de.ts | 14 + packages/i18n/src/locales/en.ts | 14 + 12 files changed, 703 insertions(+), 7 deletions(-) create mode 100644 apps/journal/app/lib/federation-outbound.integration.test.ts create mode 100644 apps/journal/app/lib/federation-outbound.server.test.ts create mode 100644 apps/journal/app/lib/federation-outbound.server.ts create mode 100644 apps/journal/app/routes/follows.outgoing.tsx diff --git a/apps/journal/app/lib/federation-outbound.integration.test.ts b/apps/journal/app/lib/federation-outbound.integration.test.ts new file mode 100644 index 0000000..dc65e55 --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.integration.test.ts @@ -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"); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.test.ts b/apps/journal/app/lib/federation-outbound.server.test.ts new file mode 100644 index 0000000..63201bd --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.test.ts @@ -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); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.ts b/apps/journal/app/lib/federation-outbound.server.ts new file mode 100644 index 0000000..983129b --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.ts @@ -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; + fetchSoftwareName(actorIri: string): Promise; + deliverFollow(followerUsername: string, followId: string, target: ResolvedRemoteActor): Promise; + deliverUndoFollow(followerUsername: string, followId: string, target: { iri: string; inboxUrl: string }): Promise; +} + +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 { + 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 { + 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 { + 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, + })); +} diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index 0105e5d..1ac3250 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -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"; diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 0f630a3..77077c8 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -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"), diff --git a/apps/journal/app/routes/api.well-known.trails-cool.ts b/apps/journal/app/routes/api.well-known.trails-cool.ts index d42fe04..c26e264 100644 --- a/apps/journal/app/routes/api.well-known.trails-cool.ts +++ b/apps/journal/app/routes/api.well-known.trails-cool.ts @@ -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", }); } diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index 37f5b67..b6cf51d 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -81,6 +81,14 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {t("social.feed.seePublic")} )} + {view === "followed" && ( + + {t("social.feed.followRemote")} + + )} ) : (
    diff --git a/apps/journal/app/routes/follows.outgoing.tsx b/apps/journal/app/routes/follows.outgoing.tsx new file mode 100644 index 0000000..d462f8c --- /dev/null +++ b/apps/journal/app/routes/follows.outgoing.tsx @@ -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 ( +
    +

    {t("social.outgoing.heading")}

    +

    {t("social.outgoing.subtitle")}

    + + {!federationEnabled ? ( +

    + {t("social.outgoing.disabled")} +

    + ) : ( + <> +
    + +
    + + +
    + +
    + {error && ( +

    + {errorCode === "not_trails" ? t("social.outgoing.notTrails") : error} +

    + )} + + {entries.length === 0 ? ( +

    {t("social.outgoing.empty")}

    + ) : ( +
      + {entries.map((e) => ( +
    • +
      + + {e.displayName ?? e.username ?? e.actorIri} + +

      + {e.username && e.domain ? `@${e.username}@${e.domain}` : e.actorIri} + {" · "} + {e.pending ? ( + {t("social.outgoing.pendingBadge")} + ) : ( + {t("social.outgoing.acceptedBadge")} + )} + {" · "} + +

      +
      +
      + + + +
      +
    • + ))} +
    + )} + + )} +
    + ); +} diff --git a/e2e/social.test.ts b/e2e/social.test.ts index b18587f..210afd9 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -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 diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index d41da8d..5e5eced 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -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 diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b5f5714..347aaba 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e6c5770..26b26a3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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",