Merge pull request #487 from trails-cool/federation/feed-remote

feat(journal): audience-aware social feed with remote activities (§8+§9)
This commit is contained in:
Ullrich Schäfer 2026-06-07 12:35:27 +02:00 committed by GitHub
commit 60afacc69e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 245 additions and 27 deletions

View file

@ -1,6 +1,6 @@
import type { JobDefinition } from "@trails-cool/jobs";
import { and, eq } from "drizzle-orm";
import { activities } from "@trails-cool/db/schema/journal";
import { activities, users } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { getOrigin } from "../lib/config.server.ts";
import { getFederation } from "../lib/federation.server.ts";
@ -77,6 +77,17 @@ async function deliverOne(p: DeliveryPayload): Promise<void> {
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
return;
}
// Spec 9.3: flipping the profile to private stops federation — also
// for deliveries already enqueued when the flip happened.
const [owner] = await db
.select({ profileVisibility: users.profileVisibility })
.from(users)
.where(eq(users.username, p.ownerUsername))
.limit(1);
if (!owner || owner.profileVisibility !== "public") {
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
return;
}
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
} else {
activity = activityToDelete(p.objectIri, p.ownerUsername);

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"));
});
});

View file

@ -31,6 +31,11 @@ export async function loadFeed(request: Request) {
geojson: a.geojson ?? null,
ownerUsername: a.ownerUsername,
ownerDisplayName: a.ownerDisplayName,
// Remote (federated) attribution — only the followed view can
// contain remote rows; the public view is local-only.
ownerDomain: "ownerDomain" in a ? a.ownerDomain : null,
externalUrl: "externalUrl" in a ? a.externalUrl : null,
remote: "remote" in a ? a.remote : false,
})),
};
}

View file

@ -95,7 +95,11 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
{activities.map((a) => (
<li key={a.id}>
<a
href={`/activities/${a.id}`}
// Remote (federated) activities link to their canonical
// page on the origin instance — there is no local detail
// page for them.
href={a.remote && a.externalUrl ? a.externalUrl : `/activities/${a.id}`}
{...(a.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
>
<div className="flex gap-4">
@ -112,13 +116,22 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
<div>
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
<div className="mt-1 text-sm text-gray-500">
<a
href={`/users/${a.ownerUsername}`}
className="hover:text-gray-700 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{a.ownerDisplayName ?? a.ownerUsername}
</a>
{a.remote ? (
<span>
{a.ownerDisplayName ?? a.ownerUsername ?? a.ownerDomain}
{a.ownerUsername && a.ownerDomain && (
<span className="text-gray-400"> @{a.ownerUsername}@{a.ownerDomain}</span>
)}
</span>
) : (
<a
href={`/users/${a.ownerUsername}`}
className="hover:text-gray-700 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{a.ownerDisplayName ?? a.ownerUsername}
</a>
)}
{" · "}
<ClientDate iso={a.startedAt ?? a.createdAt} />
</div>

View file

@ -66,14 +66,15 @@
## 8. Feed query update
- [ ] 8.1 Update `listSocialFeed` to include audience-aware filtering: include `(audience='public' OR (audience='followers-only' AND f.followed_actor_iri = a.remote_actor_iri))` predicate
- [ ] 8.2 Update feed sort to use `COALESCE(created_at, remote_created_at)` for mixed local/remote rows
- [x] 8.1 Update `listSocialFeed` to include audience-aware filtering: include `(audience='public' OR (audience='followers-only' AND f.followed_actor_iri = a.remote_actor_iri))` predicate
- [x] 8.2 Update feed sort to use `COALESCE(created_at, remote_created_at)` for mixed local/remote rows
## 9. Profile page updates
- [ ] 9.1 Pending state on Follow button (distinct from Follow/Unfollow)
- [ ] 9.2 Federation gate: actor object endpoint returns 404 if `profile_visibility = 'private'`, mirroring the human profile gate
- [ ] 9.3 When a user flips to `private`, federation must stop: outgoing `Accept(Follow)` rows where they're the followed party are no longer honored, push delivery is suppressed, and inbound Follows return 404. (Existing accepted follow rows on the remote side are not actively reaped — remotes will discover via their next outbox poll returning 404 / via a broadcast `Update` of the actor — pick the right approach during implementation)
- [x] 9.1 Pending state on Follow button (distinct from Follow/Unfollow)
> Shipped with locked accounts for local profiles; remote Pending lives on /follows/outgoing (no local profile page for remote actors).
- [x] 9.2 Federation gate: actor object endpoint returns 404 if `profile_visibility = 'private'`, mirroring the human profile gate
- [x] 9.3 When a user flips to `private`, federation must stop: outgoing `Accept(Follow)` rows where they're the followed party are no longer honored, push delivery is suppressed, and inbound Follows return 404. (Existing accepted follow rows on the remote side are not actively reaped — remotes will discover via their next outbox poll returning 404 / via a broadcast `Update` of the actor — pick the right approach during implementation)
## 10. Privacy + docs