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,6 +116,14 @@ 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.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"
|
||||
|
|
@ -119,6 +131,7 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
|
|||
>
|
||||
{a.ownerDisplayName ?? a.ownerUsername}
|
||||
</a>
|
||||
)}
|
||||
{" · "}
|
||||
<ClientDate iso={a.startedAt ?? a.createdAt} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue