trails/apps/journal/app/lib/follow.server.ts
Ullrich Schäfer 62b40a25f3 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>
2026-06-07 11:50:35 +02:00

415 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
import { getDb } from "./db.ts";
import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import { localActorIri } from "./actor-iri.ts";
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"
// 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";
this.code = code;
}
}
export interface FollowState {
following: boolean;
pending: boolean;
}
async function loadFollowableTarget(targetUsername: string) {
const db = getDb();
const [target] = await db
.select({
id: users.id,
username: users.username,
profileVisibility: users.profileVisibility,
})
.from(users)
.where(eq(users.username, targetUsername));
if (!target) throw new FollowError("user_not_found", "User not found");
return target;
}
/**
* Create a follow row from `followerId` to the local user with username
* `targetUsername`. Public targets auto-accept (`accepted_at = now()`),
* private (locked) targets land Pending (`accepted_at = NULL`) and
* appear in the target's Requests tab on /notifications for manual approval.
* Idempotent: re-following keeps the existing row's state.
*/
export async function followUser(followerId: string, targetUsername: string): Promise<FollowState> {
const target = await loadFollowableTarget(targetUsername);
if (target.id === followerId) {
throw new FollowError("self_follow", "Users cannot follow themselves");
}
const db = getDb();
const followedActorIri = localActorIri(target.username);
const [existing] = await db
.select({ id: follows.id, acceptedAt: follows.acceptedAt })
.from(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri)));
if (existing) {
// Idempotent re-follow: do NOT emit a notification (the recipient
// was notified when the row was first created).
return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null };
}
const acceptedAt = target.profileVisibility === "public" ? new Date() : null;
const followId = randomUUID();
await db.insert(follows).values({
id: followId,
followerId,
followedActorIri,
followedUserId: target.id,
acceptedAt,
});
// Notify the target. follow_received for auto-accepted public follows;
// follow_request_received for Pending follows against private profiles.
// Errors are logged but don't fail the follow — the user-visible
// follow already succeeded.
try {
const [follower] = await db
.select({ username: users.username, displayName: users.displayName })
.from(users)
.where(eq(users.id, followerId));
if (follower) {
const payload = {
followerUsername: follower.username,
followerDisplayName: follower.displayName,
};
await createNotification(
acceptedAt !== null
? {
type: "follow_received",
recipientUserId: target.id,
actorUserId: followerId,
subjectId: followId,
payload,
}
: {
type: "follow_request_received",
recipientUserId: target.id,
actorUserId: followerId,
subjectId: followId,
payload,
},
);
}
} catch (err) {
logger.warn({ err, followId }, "followUser: notification emit failed");
}
return {
following: acceptedAt !== null,
pending: acceptedAt === null,
};
}
/**
* Delete the follow row from `followerId` against the local user with
* username `targetUsername`. Idempotent.
*/
export async function unfollowUser(followerId: string, targetUsername: string): Promise<FollowState> {
const target = await loadFollowableTarget(targetUsername);
const db = getDb();
const followedActorIri = localActorIri(target.username);
await db
.delete(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri)));
return { following: false, pending: false };
}
/**
* Read-side helper: is `followerId` currently following `targetUsername`?
* Returns `null` when no row exists (so callers can distinguish "no follow"
* from "row exists but unaccepted" once federation's Pending state lands).
*/
export async function getFollowState(
followerId: string,
targetUsername: string,
): Promise<FollowState | null> {
const db = getDb();
const followedActorIri = localActorIri(targetUsername);
const [row] = await db
.select({ acceptedAt: follows.acceptedAt })
.from(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri)));
if (!row) return null;
return { following: row.acceptedAt !== null, pending: row.acceptedAt === null };
}
// Counts include only accepted relations — Pending requests don't count
// toward the public follower/following tallies (a request not yet
// approved isn't a real follow).
export async function countFollowers(userId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ n: count() })
.from(follows)
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)));
return row?.n ?? 0;
}
export async function countFollowing(userId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ n: count() })
.from(follows)
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)));
return row?.n ?? 0;
}
/**
* Count of incoming Pending follow requests for `userId`. Drives the
* navbar badge. Distinct from countFollowers (which is accepted-only).
*/
export async function countPendingFollowRequests(userId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ n: count() })
.from(follows)
.where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt)));
return row?.n ?? 0;
}
export interface FollowRequest {
id: string;
followerUsername: string;
followerDisplayName: string | null;
followerDomain: string;
createdAt: Date;
}
/**
* Pending incoming follow requests for `userId`. Drives the Requests tab
* on /notifications.
* Reverse-chronological by request creation time.
*/
export async function listPendingFollowRequests(userId: string): Promise<FollowRequest[]> {
const db = getDb();
const rows = await db
.select({
id: follows.id,
followerUsername: users.username,
followerDisplayName: users.displayName,
followerDomain: users.domain,
createdAt: follows.createdAt,
})
.from(follows)
.innerJoin(users, eq(follows.followerId, users.id))
.where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt)))
.orderBy(desc(follows.createdAt));
return rows;
}
/**
* Approve a Pending follow request. Owner-bound: `ownerId` must equal
* `follows.followedUserId` for the row, otherwise the call is a no-op.
*/
export async function approveFollowRequest(ownerId: string, followId: string): Promise<boolean> {
const db = getDb();
const result = await db
.update(follows)
.set({ acceptedAt: new Date() })
.where(
and(
eq(follows.id, followId),
eq(follows.followedUserId, ownerId),
isNull(follows.acceptedAt),
),
)
.returning({ id: follows.id, followerId: follows.followerId });
if (result.length === 0) return false;
// Notify the requester that their request landed. Lookup target's
// display info for the payload. Errors logged but don't undo the
// approve — the follow row is already accepted.
try {
const followerId = result[0]!.followerId;
const [target] = await db
.select({ username: users.username, displayName: users.displayName })
.from(users)
.where(eq(users.id, ownerId));
// followerId is NULL for inbound federated follows — those are
// auto-accepted and never appear in the Requests tab, but guard
// anyway: remote followers can't receive local notifications.
if (target && followerId !== null) {
await createNotification({
type: "follow_request_approved",
recipientUserId: followerId,
actorUserId: ownerId,
subjectId: followId,
payload: {
targetUsername: target.username,
targetDisplayName: target.displayName,
},
});
}
} catch (err) {
logger.warn({ err, followId }, "approveFollowRequest: notification emit failed");
}
return true;
}
/**
* Reject a Pending follow request. Deletes the row entirely so the
* follower can re-request later if they want.
*/
export async function rejectFollowRequest(ownerId: string, followId: string): Promise<boolean> {
const db = getDb();
const result = await db
.delete(follows)
.where(
and(
eq(follows.id, followId),
eq(follows.followedUserId, ownerId),
isNull(follows.acceptedAt),
),
)
.returning({ id: follows.id });
return result.length > 0;
}
export interface CollectionEntry {
username: string;
displayName: string | null;
domain: string;
/** Where the entry's profile lives: local path or remote actor URL. */
profileUrl: string;
remote: boolean;
}
const COLLECTION_PAGE_SIZE = 50;
/**
* Best-effort identity for a remote actor we may or may not have
* cached: prefer the remote_actors row, fall back to parsing the IRI
* (canonical fediverse shape `https://host/users/name`).
*/
function remoteEntry(
actorIri: string,
cached: { username: string | null; displayName: string | null; domain: string | null },
): CollectionEntry {
let host = "";
let lastSegment: string;
try {
const url = new URL(actorIri);
host = url.host;
lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? "";
} catch {
// Malformed IRI in the DB — display it raw rather than hide the row.
lastSegment = actorIri;
}
return {
username: cached.username ?? lastSegment,
displayName: cached.displayName,
domain: cached.domain ?? host,
profileUrl: actorIri,
remote: true,
};
}
/**
* Paginated list of accepted followers of `userId`. Newest acceptance first.
* Includes remote (federated) followers — `follower_actor_iri` rows — so the
* list matches countFollowers; display data comes from the remote_actors
* cache when present, IRI parsing otherwise. Pending requests are excluded —
* they live in the Requests tab on /notifications.
*/
export async function listFollowers(userId: string, page: number = 1): Promise<CollectionEntry[]> {
const db = getDb();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
localUsername: users.username,
localDisplayName: users.displayName,
localDomain: users.domain,
followerActorIri: follows.followerActorIri,
remoteUsername: remoteActors.username,
remoteDisplayName: remoteActors.displayName,
remoteDomain: remoteActors.domain,
})
.from(follows)
.leftJoin(users, eq(follows.followerId, users.id))
.leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri))
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows.map((r) =>
r.localUsername !== null
? {
username: r.localUsername,
displayName: r.localDisplayName,
domain: r.localDomain ?? "",
profileUrl: `/users/${r.localUsername}`,
remote: false,
}
: remoteEntry(r.followerActorIri ?? "", {
username: r.remoteUsername,
displayName: r.remoteDisplayName,
domain: r.remoteDomain,
}),
);
}
/**
* Paginated list of accepted follows from `userId`. Newest acceptance first.
* Includes remote (trails-to-trails) targets — rows whose followed side is
* only an actor IRI — for the same count/list consistency as listFollowers.
* Pending outgoing follows (against private/locked targets) are excluded.
*/
export async function listFollowing(userId: string, page: number = 1): Promise<CollectionEntry[]> {
const db = getDb();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
localUsername: users.username,
localDisplayName: users.displayName,
localDomain: users.domain,
followedActorIri: follows.followedActorIri,
remoteUsername: remoteActors.username,
remoteDisplayName: remoteActors.displayName,
remoteDomain: remoteActors.domain,
})
.from(follows)
.leftJoin(users, eq(follows.followedUserId, users.id))
.leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri))
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows.map((r) =>
r.localUsername !== null
? {
username: r.localUsername,
displayName: r.localDisplayName,
domain: r.localDomain ?? "",
profileUrl: `/users/${r.localUsername}`,
remote: false,
}
: remoteEntry(r.followedActorIri, {
username: r.remoteUsername,
displayName: r.remoteDisplayName,
domain: r.remoteDomain,
}),
);
}