Merge pull request #485 from trails-cool/federation/outbound-follows
feat(journal): outbound trails-to-trails follows (social-federation §6)
This commit is contained in:
commit
965df640ad
12 changed files with 703 additions and 7 deletions
155
apps/journal/app/lib/federation-outbound.integration.test.ts
Normal file
155
apps/journal/app/lib/federation-outbound.integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
40
apps/journal/app/lib/federation-outbound.server.test.ts
Normal file
40
apps/journal/app/lib/federation-outbound.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
300
apps/journal/app/lib/federation-outbound.server.ts
Normal file
300
apps/journal/app/lib/federation-outbound.server.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
144
apps/journal/app/routes/follows.outgoing.tsx
Normal file
144
apps/journal/app/routes/follows.outgoing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue