diff --git a/apps/journal/app/jobs/deliver-activity.ts b/apps/journal/app/jobs/deliver-activity.ts index d158a3f..c40ab44 100644 --- a/apps/journal/app/jobs/deliver-activity.ts +++ b/apps/journal/app/jobs/deliver-activity.ts @@ -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 { 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); diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 7705225..6ad22b7 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -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`${activities.createdAt}`.as("sort_time"), + ownerUsername: sql`${users.username}`.as("owner_username"), + ownerDisplayName: sql`${users.displayName}`.as("owner_display_name"), + ownerDomain: sql`${users.domain}`.as("owner_domain"), + externalUrl: sql`NULL`.as("external_url"), + remote: sql`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`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"), + ownerUsername: sql`${remoteActors.username}`.as("owner_username"), + ownerDisplayName: sql`${remoteActors.displayName}`.as("owner_display_name"), + ownerDomain: sql`${remoteActors.domain}`.as("owner_domain"), + externalUrl: sql`${activities.remoteOriginIri}`.as("external_url"), + remote: sql`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 })); } diff --git a/apps/journal/app/lib/social-feed.integration.test.ts b/apps/journal/app/lib/social-feed.integration.test.ts new file mode 100644 index 0000000..f24774a --- /dev/null +++ b/apps/journal/app/lib/social-feed.integration.test.ts @@ -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")); + }); +}); diff --git a/apps/journal/app/routes/feed.server.ts b/apps/journal/app/routes/feed.server.ts index 1f5e7c8..81a013a 100644 --- a/apps/journal/app/routes/feed.server.ts +++ b/apps/journal/app/routes/feed.server.ts @@ -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, })), }; } diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index b6cf51d..a99578e 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -95,7 +95,11 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {activities.map((a) => (
  • @@ -112,13 +116,22 @@ export default function Feed({ loaderData }: Route.ComponentProps) {

    {a.name}

    - e.stopPropagation()} - > - {a.ownerDisplayName ?? a.ownerUsername} - + {a.remote ? ( + + {a.ownerDisplayName ?? a.ownerUsername ?? a.ownerDomain} + {a.ownerUsername && a.ownerDomain && ( + @{a.ownerUsername}@{a.ownerDomain} + )} + + ) : ( + e.stopPropagation()} + > + {a.ownerDisplayName ?? a.ownerUsername} + + )} {" · "}
    diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 16a6d92..0594e26 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -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