feat(journal): audience-aware social feed with remote activities (§8+§9)

8.1/8.2: listSocialFeed is now a UNION ALL of local and remote
branches, sorted on COALESCE(remote_published_at, created_at):
- local rows: visibility='public' from accepted local follows — and
  the previously missing accepted_at filter is added (the spec's
  'Pending follows contribute nothing' scenario)
- remote rows: gated structurally by joining the viewer's OWN accepted
  follow against the originating actor — which is exactly the
  followers-only audience rule (a row reaches only viewers whose
  follow brought it in); attribution from the remote_actors cache;
  cards link outward to the canonical origin page (no local detail
  page for remote rows)

9.1: annotated — local Pending button shipped with locked accounts;
remote Pending lives on /follows/outgoing.
9.2: already enforced + tested since §3 (actor/webfinger 404).
9.3 hardening: deliver-activity now re-checks the OWNER's profile
visibility at send time, closing the enqueue→delivery flip window
(enqueue-side and inbound-side gates already existed).

Integration tests: the §8 audience-leak guard (A sees followers-only,
B does not), public remote attribution + outward link, pending
contributes nothing, mixed local/remote COALESCE ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-07 12:13:23 +02:00
parent f8b24f58ef
commit bf2d8bfbd4
6 changed files with 245 additions and 27 deletions

View file

@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { eq, desc, and, isNotNull, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/pg-core";
import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
import type { GpxData } from "./gpx-save.server.ts";
@ -195,14 +196,25 @@ export async function listPublicActivitiesForOwner(
}
/**
* Social feed: aggregated public activities from users that `followerId`
* follows (accepted only). Reverse-chronological. Joins users for owner
* attribution. Unlisted/private activities never appear, regardless of
* follow state.
* Social feed (spec: social-federation §8): aggregated activities from
* actors that `followerId` follows with an *accepted* follow local
* users and remote trails actors alike. Reverse-chronological on
* COALESCE(remote_published_at, created_at).
*
* Audience rules:
* - local rows: `visibility = 'public'` only (unlisted/private never
* appear regardless of follow state)
* - remote rows: `audience = 'public'` or `followers-only` the
* latter gated structurally by joining the *viewer's own* accepted
* follow against the originating actor (spec: "Followers-only remote
* content reaches only the right viewer")
* Pending follows contribute nothing (accepted_at IS NOT NULL on both
* branches previously missing on the local branch).
*/
export async function listSocialFeed(followerId: string, limit: number = 50) {
const db = getDb();
const rows = await db
const local = db
.select({
id: activities.id,
name: activities.name,
@ -211,8 +223,12 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"),
ownerUsername: sql<string | null>`${users.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${users.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${users.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`NULL`.as("external_url"),
remote: sql<boolean>`false`.as("remote"),
})
.from(activities)
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
@ -220,14 +236,46 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
eq(activities.visibility, "public"),
),
)
.orderBy(desc(activities.createdAt))
);
const remote = db
.select({
id: activities.id,
name: activities.name,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
sortTime: sql<Date>`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"),
ownerUsername: sql<string | null>`${remoteActors.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${remoteActors.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${remoteActors.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`${activities.remoteOriginIri}`.as("external_url"),
remote: sql<boolean>`true`.as("remote"),
})
.from(activities)
.innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri))
.leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri))
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
// public reaches every accepted follower; followers-only is
// already gated by joining the viewer's own accepted follow.
sql`${activities.audience} IN ('public', 'followers-only')`,
),
);
const rows = await unionAll(local, remote)
.orderBy(sql`sort_time DESC`)
.limit(limit);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
const localIds = rows.filter((r) => !r.remote).map((r) => r.id);
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}

View file

@ -0,0 +1,140 @@
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq, like } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
import { users, follows, activities, remoteActors } from "@trails-cool/db/schema/journal";
import { listSocialFeed } from "./activities.server.ts";
// Opt-in: real Postgres. Covers social-federation §8 — the audience
// leak guard is THE scenario that must never regress.
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
const RUN = Date.now();
const REMOTE_ACTOR = `https://feed-origin.example/users/x-${RUN}`;
const createdUserIds: string[] = [];
async function makeUser(suffix: string) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `feed-${suffix}-${RUN}@example.test`,
username: `feed-${suffix}-${RUN}`,
domain: "test.local",
profileVisibility: "public",
});
createdUserIds.push(id);
return id;
}
async function followRemote(followerId: string, accepted: boolean) {
const db = getDb();
await db.insert(follows).values({
id: randomUUID(),
followerId,
followedActorIri: REMOTE_ACTOR,
followedUserId: null,
acceptedAt: accepted ? new Date() : null,
});
}
async function remoteActivity(n: number, audience: "public" | "followers-only", publishedAt: Date) {
const db = getDb();
await db.insert(activities).values({
id: randomUUID(),
ownerId: null,
name: `Remote ${audience} ${n}`,
visibility: audience === "public" ? "public" : "private",
remoteOriginIri: `${REMOTE_ACTOR}/activities/${n}`,
remoteActorIri: REMOTE_ACTOR,
remotePublishedAt: publishedAt,
audience,
});
}
describe.runIf(runIntegration)("social feed with remote rows (§8, integration)", () => {
beforeAll(async () => {
process.env.ORIGIN ??= "http://localhost:3000";
const db = getDb();
await db.insert(remoteActors).values({
actorIri: REMOTE_ACTOR,
username: "x",
displayName: "Remote X",
domain: "feed-origin.example",
}).onConflictDoNothing();
});
afterEach(async () => {
const db = getDb();
await db.delete(activities).where(like(activities.remoteOriginIri, `${REMOTE_ACTOR}%`));
for (const id of createdUserIds.splice(0)) {
await db.delete(activities).where(eq(activities.ownerId, id));
await db.delete(follows).where(eq(follows.followerId, id));
await db.delete(users).where(eq(users.id, id));
}
});
it("followers-only remote content reaches only the viewer with the accepted follow", async () => {
const a = await makeUser("a");
const b = await makeUser("b");
await followRemote(a, true); // A follows X (accepted)
// B does NOT follow X at all — but the row exists in our DB.
await remoteActivity(1, "followers-only", new Date());
const feedA = await listSocialFeed(a);
const feedB = await listSocialFeed(b);
expect(feedA.map((r) => r.name)).toContain("Remote followers-only 1");
expect(feedB.map((r) => r.name)).not.toContain("Remote followers-only 1");
});
it("public remote content reaches accepted followers with remote attribution", async () => {
const a = await makeUser("pub");
await followRemote(a, true);
await remoteActivity(2, "public", new Date());
const feed = await listSocialFeed(a);
const entry = feed.find((r) => r.name === "Remote public 2");
expect(entry).toBeDefined();
expect(entry!.remote).toBe(true);
expect(entry!.externalUrl).toBe(`${REMOTE_ACTOR}/activities/2`);
expect(entry!.ownerUsername).toBe("x");
expect(entry!.ownerDomain).toBe("feed-origin.example");
expect(entry!.geojson).toBeNull();
});
it("pending follows contribute nothing", async () => {
const a = await makeUser("pend");
await followRemote(a, false); // Pending
await remoteActivity(3, "public", new Date());
expect(await listSocialFeed(a)).toHaveLength(0);
});
it("mixes local and remote rows sorted by origin publish time", async () => {
const viewer = await makeUser("viewer");
const author = await makeUser("author");
const db = getDb();
await db.insert(follows).values({
id: randomUUID(),
followerId: viewer,
followedActorIri: `http://localhost:3000/users/feed-author-${RUN}`,
followedUserId: author,
acceptedAt: new Date(),
});
await followRemote(viewer, true);
const now = Date.now();
// Local activity created "now" (createdAt defaults to now()).
await db.insert(activities).values({
id: randomUUID(),
ownerId: author,
name: `Local newest ${RUN}`,
visibility: "public",
});
// Remote activity published an hour ago.
await remoteActivity(4, "public", new Date(now - 3600_000));
const feed = await listSocialFeed(viewer);
const names = feed.map((r) => r.name);
expect(names.indexOf(`Local newest ${RUN}`)).toBeLessThan(names.indexOf("Remote public 4"));
});
});