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:
parent
f8b24f58ef
commit
bf2d8bfbd4
6 changed files with 245 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
||||
|
|
|
|||
140
apps/journal/app/lib/social-feed.integration.test.ts
Normal file
140
apps/journal/app/lib/social-feed.integration.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue