From 6a2f0bf08982d8a586002e85138c73c31ad5bf06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 25 Apr 2026 23:11:46 +0200 Subject: [PATCH 001/396] Drop has-public-content gate from profile loader A public profile (`profile_visibility = 'public'`) with zero public routes or activities now returns 200 and renders the empty profile shell instead of 404. The earlier change kept the legacy "AND has public content" gate to preserve a no-existence-leak guarantee, but that conflated the implicit behavior with the new explicit setting. With profile_visibility now a deliberate toggle, the cleaner contract is: visibility = public means the profile renders, period. Followability already only depends on profile_visibility, so this brings the public-page contract in line with that. The `private` toggle remains the way to hide a profile from visitors. Updates social-feed change spec + design to match (drops the "AND has at least one public" predicate and the matching 404 scenario; adds an explicit "empty public profile renders an empty shell" scenario). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes/users.$username.tsx | 13 +++++------ openspec/changes/social-feed/design.md | 6 ++--- .../social-feed/specs/public-profiles/spec.md | 23 +++++++++++-------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index 380f2bf..447de43 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -31,17 +31,16 @@ export async function loader({ params, request }: Route.LoaderArgs) { const isOwn = currentUser?.id === user.id; // Profile-visibility gate: a `private` profile 404s for everyone but - // the owner, regardless of how much public content they have. + // the owner, regardless of how much public content they have. With + // explicit `profile_visibility` we no longer 404 public profiles that + // happen to have zero public items — the page renders with empty + // sections, matching Mastodon-style "discoverable account, no posts + // yet" behavior. Existence-leak risk is accepted: explicit visibility + // is the contract. if (!isOwn && user.profileVisibility !== "public") { throw data({ error: "User not found" }, { status: 404 }); } - // 404 for public-but-empty profiles to prevent account enumeration. - // Owners still see their own profile even when empty. - if (!isOwn && publicRoutes.length === 0 && publicActivities.length === 0) { - throw data({ error: "User not found" }, { status: 404 }); - } - // Follow state for non-owner viewers (null when anonymous). const followState = !isOwn && currentUser ? await getFollowState(currentUser.id, user.username) diff --git a/openspec/changes/social-feed/design.md b/openspec/changes/social-feed/design.md index c858648..3a8ce20 100644 --- a/openspec/changes/social-feed/design.md +++ b/openspec/changes/social-feed/design.md @@ -36,14 +36,14 @@ Add `users.profile_visibility: 'public' | 'private'` (NOT NULL, default `'public The new rules: -- `/users/:username` returns 200 iff `profile_visibility = 'public'` **AND** the user has at least one `public` route or activity. The "has public content" gate is preserved so a brand-new public-by-default account doesn't expose a 200 page that says "no posts yet" — that would leak existence. -- A user is followable iff `profile_visibility = 'public'`, regardless of whether they have content yet. (Following someone before they post is reasonable; the follower's feed just stays empty for that follow until the user posts.) +- `/users/:username` returns 200 iff `profile_visibility = 'public'`. A public profile with zero `public` items renders an empty shell (header + empty-state copy in the routes/activities sections), matching Mastodon-style "discoverable account, no posts yet" behavior. The explicit `profile_visibility` toggle is the contract for hiding the profile; existence of a public-but-empty account is observable, and that's accepted. +- A user is followable iff `profile_visibility = 'public'`, regardless of whether they have content yet. Following someone before they post is reasonable; the follower's feed just stays empty for that follow until the user posts. When `social-federation` lands, the local user's ActivityPub actor object will gate on the same `profile_visibility = 'public'` check — private profiles will return 404 to federation lookups too. **Default `'public'`:** matches fediverse convention (Mastodon defaults to discoverable; "lock" is opt-in), aligns with the existing implicit behavior where any user *could* be public, and keeps onboarding smooth (post a public route → it's listed on your profile, no extra toggle). Activity-level privacy still defaults to `'private'`, so content stays private by default; *being findable on the network* is the part we default open. -**Migration:** backfill all existing users to `'public'`. Their effective profile reachability is unchanged (still gated on having public content). Operators can flip themselves to `'private'` post-migration if desired. +**Migration:** backfill all existing users to `'public'`. Operators can flip themselves to `'private'` post-migration if desired. **Why explicit, why now:** with follows landing, the question "can someone follow you?" needs a deterministic answer. Deriving it from "do you have any public content?" is fragile (toggling content visibility silently flips followability). The toggle also pre-pays for `locked-local-accounts`, which will extend this enum or add a `users.locked` flag. diff --git a/openspec/changes/social-feed/specs/public-profiles/spec.md b/openspec/changes/social-feed/specs/public-profiles/spec.md index 1df71cc..a2e9444 100644 --- a/openspec/changes/social-feed/specs/public-profiles/spec.md +++ b/openspec/changes/social-feed/specs/public-profiles/spec.md @@ -17,27 +17,32 @@ Every user SHALL have an explicit `profile_visibility` of `public` or `private`. #### Scenario: User toggles profile back to public - **WHEN** a previously-private user switches `profile_visibility` to `public` and saves -- **THEN** their `/users/:username` becomes reachable again (subject to the existing "has public content" gate) and Follow buttons reappear for visitors +- **THEN** their `/users/:username` becomes reachable again and Follow buttons reappear for visitors ## MODIFIED Requirements ### Requirement: Public profile page -The Journal SHALL serve a public profile page at `/users/:username` that lists the user's public routes and activities in reverse chronological order, viewable without authentication. The page SHALL render only when the user's `profile_visibility` is `public` AND they have at least one `public` route or activity. For signed-in viewers other than the owner, the page SHALL display a Follow / Unfollow toggle that mirrors the current follow relation (see `social-follows` spec). The page SHALL also display follower and following counts with links to the respective collections. +The Journal SHALL serve a public profile page at `/users/:username` that lists the user's public routes and activities in reverse chronological order, viewable without authentication. The page SHALL render whenever the user's `profile_visibility` is `public`, even if they have no public routes or activities yet — the empty case shows the profile shell with empty section copy. For signed-in viewers other than the owner, the page SHALL display a Follow / Unfollow toggle that mirrors the current follow relation (see `social-follows` spec). The page SHALL also display follower and following counts with links to the respective collections. -#### Scenario: Logged-out visitor views a public profile with public content -- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user whose `profile_visibility` is `public` and who has at least one `public` route or activity +#### Scenario: Logged-out visitor views a public profile +- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user whose `profile_visibility` is `public` - **THEN** the page renders that user's display name (falling back to username), the `@username@domain` handle, follower and following counts, and a reverse-chronological list of their `public` routes and `public` activities - **AND** items marked `unlisted` or `private` do NOT appear in the list -#### Scenario: Profile 404 cases are indistinguishable -- **WHEN** a visitor navigates to `/users/:username` for any of: a user with `profile_visibility = 'private'`, a user with `profile_visibility = 'public'` but zero public items, a user whose content is all `private` or `unlisted`, or a username that does not exist +#### Scenario: Empty public profile renders an empty shell +- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user with `profile_visibility = 'public'` but no `public` routes or activities +- **THEN** the page returns HTTP 200 and renders the profile header (display name, handle, counts) plus empty-state copy in the routes and activities sections +- **AND** the user is followable: the Follow button (for signed-in viewers other than the owner) is rendered + +#### Scenario: Profile 404 cases +- **WHEN** a visitor navigates to `/users/:username` for a user with `profile_visibility = 'private'` OR a username that does not exist - **THEN** the server responds with HTTP 404 -- **AND** the response does NOT distinguish the cases, so existence of a private account is not leaked +- **AND** the response does NOT distinguish the two cases, so existence of a private account is not leaked #### Scenario: Owner sees their own profile - **WHEN** a user navigates to their own `/users/:username` while logged in -- **THEN** if their `profile_visibility = 'public'` and they have at least one public item, the page renders exactly the same as for a logged-out visitor, plus a small owner-only control strip linking to settings -- **AND** if their profile would 404 for visitors (private or no public content), they are redirected to settings or shown an owner-only "your profile isn't public yet" view (implementation detail) +- **THEN** if their `profile_visibility = 'public'`, the page renders exactly the same as for a logged-out visitor, plus a small owner-only control strip linking to settings +- **AND** if their `profile_visibility = 'private'`, the page renders for them with an amber explainer banner noting that visitors see a 404 - **AND** no Follow button is shown (users cannot follow themselves) #### Scenario: Signed-in viewer sees a Follow control on a public profile From 5da7ffa03705ff569fc5c8d3bad9e25193dcc8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 25 Apr 2026 23:38:26 +0200 Subject: [PATCH 002/396] Locked-account profiles: private = stub + Pending follow flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier 404-for-private model with Mastodon-style locked accounts. A private profile now returns 200 with a stub layout and gates content behind follow approval. Default for new users flips from 'public' to 'private' to align with trails.cool's privacy-first content defaults. Schema: - users.profile_visibility default flipped to 'private'. Existing rows remain 'public' (backfill on first migration handled them). Follow API (follow.server.ts): - followUser now creates Pending (accepted_at = NULL) against private targets and Accepted against public targets — no more refusal. - New: countPendingFollowRequests, listPendingFollowRequests, approveFollowRequest, rejectFollowRequest. Approve/reject are owner-bound: only the followed user can act on their own incoming requests. - countFollowers / countFollowing / listFollowers / listFollowing now filter to accepted-only relations. Loader (users.$username.tsx): - Drops the 404 paths. New canSeeContent flag = isOwn || profile_visibility='public' || (followState.following === true). - When canSeeContent=false, render a stub: header + 🔒 badge + body copy + Request-to-follow / sign-in CTA. Routes/activities sections are not rendered. UI: - FollowButton gains a "Request to follow" / "Requested" state for private targets via a new isPrivateTarget prop. Cancel-request reuses the unfollow endpoint. - New /follows/requests page lists incoming Pending requests with Approve / Reject buttons. - New API routes: POST /api/follows/:id/approve and /reject. - Navbar shows a count badge linking to /follows/requests when pending > 0. Privacy manifest already documents the follows relation; no changes needed (the locked-account semantics don't add new data — same row, different lifecycle). Specs / design (social-feed change): - public-profiles delta rewritten around the four-mode locked model (public, private+anon, private+pending, private+accepted) with scenarios for each. - social-follows delta gains Pending lifecycle requirements (auto vs. manual accept, approve/reject endpoints, pending request management, Pending follows do not contribute to feed). - design.md decision section reflects the new model and rationale for default-private; non-goal "locked-local-accounts as a follow-up" is removed since this change ships it. Tests: - follow.integration.test.ts: pending-against-private, approve flips to accepted, reject deletes, owner-bound enforcement. - e2e/social.test.ts: full Request → Pending → Approve → full-view flow, plus stub-for-anonymous and /follows/requests auth gate. Supersedes PR #309 (closed): the empty-public-profile 200 is now a side-effect of the new render path. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/components/FollowButton.tsx | 49 +++++-- .../app/lib/follow.integration.test.ts | 55 +++++++- apps/journal/app/lib/follow.server.ts | 123 +++++++++++++++--- apps/journal/app/root.tsx | 39 +++++- apps/journal/app/routes.ts | 3 + .../app/routes/api.follows.$id.approve.ts | 19 +++ .../app/routes/api.follows.$id.reject.ts | 19 +++ apps/journal/app/routes/follows.requests.tsx | 99 ++++++++++++++ apps/journal/app/routes/users.$username.tsx | 83 ++++++++---- e2e/social.test.ts | 123 ++++++++++-------- openspec/changes/social-feed/design.md | 25 ++-- openspec/changes/social-feed/proposal.md | 13 +- .../social-feed/specs/public-profiles/spec.md | 53 ++++---- .../social-feed/specs/social-follows/spec.md | 73 ++++++++--- packages/db/src/schema/journal.ts | 13 +- packages/i18n/src/locales/de.ts | 24 +++- packages/i18n/src/locales/en.ts | 24 +++- 17 files changed, 656 insertions(+), 181 deletions(-) create mode 100644 apps/journal/app/routes/api.follows.$id.approve.ts create mode 100644 apps/journal/app/routes/api.follows.$id.reject.ts create mode 100644 apps/journal/app/routes/follows.requests.tsx diff --git a/apps/journal/app/components/FollowButton.tsx b/apps/journal/app/components/FollowButton.tsx index 1a0d466..78b40be 100644 --- a/apps/journal/app/components/FollowButton.tsx +++ b/apps/journal/app/components/FollowButton.tsx @@ -8,21 +8,36 @@ interface FollowState { interface Props { username: string; + // Whether the followed profile is private/locked. Drives the "Request to + // follow" label vs. plain "Follow" before any click happens. + isPrivateTarget: boolean; initialState: FollowState | null; } -export function FollowButton({ username, initialState }: Props) { +type Display = "follow" | "request" | "pending" | "unfollow"; + +function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display { + if (state?.following) return "unfollow"; + if (state?.pending) return "pending"; + return isPrivateTarget ? "request" : "follow"; +} + +export function FollowButton({ username, isPrivateTarget, initialState }: Props) { const { t } = useTranslation("journal"); const [state, setState] = useState( initialState ?? { following: false, pending: false }, ); - const [isPending, startTransition] = useTransition(); + const [isInFlight, startTransition] = useTransition(); const [error, setError] = useState(null); + const display = displayFor(state, isPrivateTarget); + const onClick = () => { setError(null); startTransition(async () => { - const path = state.following + // For "pending" we treat the click as cancel-request: same /unfollow + // endpoint deletes the row whether it's accepted or pending. + const path = state.following || state.pending ? `/api/users/${username}/unfollow` : `/api/users/${username}/follow`; try { @@ -40,21 +55,33 @@ export function FollowButton({ username, initialState }: Props) { }); }; - const label = state.following ? t("social.unfollow") : t("social.follow"); + const label = (() => { + switch (display) { + case "unfollow": + return t("social.unfollow"); + case "pending": + return t("social.pendingCancel"); + case "request": + return t("social.requestToFollow"); + case "follow": + default: + return t("social.follow"); + } + })(); + + const baseClass = display === "follow" || display === "request" + ? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" + : "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"; return (
{error &&

{error}

}
diff --git a/apps/journal/app/lib/follow.integration.test.ts b/apps/journal/app/lib/follow.integration.test.ts index 9ae2c11..2030eba 100644 --- a/apps/journal/app/lib/follow.integration.test.ts +++ b/apps/journal/app/lib/follow.integration.test.ts @@ -9,7 +9,10 @@ import { getFollowState, countFollowers, countFollowing, - FollowError, + countPendingFollowRequests, + listPendingFollowRequests, + approveFollowRequest, + rejectFollowRequest, } from "./follow.server.ts"; // Opt-in: these talk to real Postgres. Gated by an env flag so laptop @@ -74,12 +77,14 @@ describe.skipIf(!runIntegration)("follow.server integration", () => { expect(aRow.username.startsWith("f_a_")).toBe(true); }); - it("refuses to follow a private profile", async () => { + it("creates a Pending follow against a private profile (not a refusal)", async () => { const a = await makeUser({ username: `f_pa_${Date.now()}` }); const b = await makeUser({ username: `f_pb_${Date.now()}`, profileVisibility: "private" }); const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; - await expect(followUser(a, bRow.username)).rejects.toBeInstanceOf(FollowError); - await expect(followUser(a, bRow.username)).rejects.toMatchObject({ code: "private_profile" }); + const s = await followUser(a, bRow.username); + expect(s).toEqual({ following: false, pending: true }); + // Pending is excluded from accepted-only counts. + expect(await countFollowers(b)).toBe(0); expect(await countFollowing(a)).toBe(0); }); @@ -89,6 +94,48 @@ describe.skipIf(!runIntegration)("follow.server integration", () => { await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" }); }); + it("approve flips Pending → Accepted; reject deletes the request", async () => { + const a = await makeUser({ username: `f_apr_${Date.now()}` }); + const b = await makeUser({ username: `f_apb_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + await followUser(a, bRow.username); + expect(await countPendingFollowRequests(b)).toBe(1); + + const reqs = await listPendingFollowRequests(b); + expect(reqs.length).toBe(1); + const reqId = reqs[0]!.id; + + const approved = await approveFollowRequest(b, reqId); + expect(approved).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await countFollowers(b)).toBe(1); + expect(await getFollowState(a, bRow.username)).toEqual({ following: true, pending: false }); + + // Idempotent: approving again is a no-op. + expect(await approveFollowRequest(b, reqId)).toBe(false); + + // Reject path: a fresh request from a 3rd user, B rejects. + const c = await makeUser({ username: `f_apc_${Date.now()}` }); + await followUser(c, bRow.username); + const reqs2 = await listPendingFollowRequests(b); + expect(reqs2.length).toBe(1); + expect(await rejectFollowRequest(b, reqs2[0]!.id)).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await getFollowState(c, bRow.username)).toBeNull(); + }); + + it("approve/reject is owner-bound (other users can't approve someone else's request)", async () => { + const a = await makeUser({ username: `f_obA_${Date.now()}` }); + const b = await makeUser({ username: `f_obB_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + const c = await makeUser({ username: `f_obC_${Date.now()}` }); + await followUser(a, bRow.username); + const reqs = await listPendingFollowRequests(b); + // C tries to approve B's incoming request — should be a no-op. + expect(await approveFollowRequest(c, reqs[0]!.id)).toBe(false); + expect(await countPendingFollowRequests(b)).toBe(1); + }); + it("404s on unknown username", async () => { const a = await makeUser({ username: `f_404_${Date.now()}` }); await expect(followUser(a, "no_such_user_xyz")).rejects.toMatchObject({ code: "user_not_found" }); diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index cc198b0..5165e2c 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; -import { eq, and, count, desc } from "drizzle-orm"; +import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { users, follows } from "@trails-cool/db/schema/journal"; import { localActorIri } from "./actor-iri.ts"; export class FollowError extends Error { - readonly code: "self_follow" | "private_profile" | "user_not_found" | "not_found"; + readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden"; constructor(code: FollowError["code"], message: string) { super(message); this.name = "FollowError"; @@ -34,18 +34,16 @@ async function loadFollowableTarget(targetUsername: string) { /** * Create a follow row from `followerId` to the local user with username - * `targetUsername`. Auto-accepted because the target is local + public. - * Idempotent: re-following an already-followed user returns the same state - * without creating a duplicate row. + * `targetUsername`. Public targets auto-accept (`accepted_at = now()`), + * private (locked) targets land Pending (`accepted_at = NULL`) and + * appear in the target's /follows/requests list for manual approval. + * Idempotent: re-following keeps the existing row's state. */ export async function followUser(followerId: string, targetUsername: string): Promise { const target = await loadFollowableTarget(targetUsername); if (target.id === followerId) { throw new FollowError("self_follow", "Users cannot follow themselves"); } - if (target.profileVisibility !== "public") { - throw new FollowError("private_profile", "This profile is not followable"); - } const db = getDb(); const followedActorIri = localActorIri(target.username); @@ -57,15 +55,19 @@ export async function followUser(followerId: string, targetUsername: string): Pr return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; } + const acceptedAt = target.profileVisibility === "public" ? new Date() : null; await db.insert(follows).values({ id: randomUUID(), followerId, followedActorIri, followedUserId: target.id, - acceptedAt: new Date(), + acceptedAt, }); - return { following: true, pending: false }; + return { + following: acceptedAt !== null, + pending: acceptedAt === null, + }; } /** @@ -101,12 +103,16 @@ export async function getFollowState( return { following: row.acceptedAt !== null, pending: row.acceptedAt === null }; } +// Counts include only accepted relations — Pending requests don't count +// toward the public follower/following tallies (a request not yet +// approved isn't a real follow). + export async function countFollowers(userId: string): Promise { const db = getDb(); const [row] = await db .select({ n: count() }) .from(follows) - .where(eq(follows.followedUserId, userId)); + .where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt))); return row?.n ?? 0; } @@ -115,10 +121,91 @@ export async function countFollowing(userId: string): Promise { const [row] = await db .select({ n: count() }) .from(follows) - .where(eq(follows.followerId, userId)); + .where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt))); return row?.n ?? 0; } +/** + * Count of incoming Pending follow requests for `userId`. Drives the + * navbar badge. Distinct from countFollowers (which is accepted-only). + */ +export async function countPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))); + return row?.n ?? 0; +} + +export interface FollowRequest { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: Date; +} + +/** + * Pending incoming follow requests for `userId`. Used by /follows/requests. + * Reverse-chronological by request creation time. + */ +export async function listPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + id: follows.id, + followerUsername: users.username, + followerDisplayName: users.displayName, + followerDomain: users.domain, + createdAt: follows.createdAt, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))) + .orderBy(desc(follows.createdAt)); + return rows; +} + +/** + * Approve a Pending follow request. Owner-bound: `ownerId` must equal + * `follows.followedUserId` for the row, otherwise the call is a no-op. + */ +export async function approveFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .update(follows) + .set({ acceptedAt: new Date() }) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + +/** + * Reject a Pending follow request. Deletes the row entirely so the + * follower can re-request later if they want. + */ +export async function rejectFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .delete(follows) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + export interface CollectionEntry { username: string; displayName: string | null; @@ -128,7 +215,8 @@ export interface CollectionEntry { const COLLECTION_PAGE_SIZE = 50; /** - * Paginated list of users who follow `userId`. Newest acceptance first. + * Paginated list of accepted followers of `userId`. Newest acceptance first. + * Pending requests are excluded — they live in /follows/requests. */ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); @@ -141,7 +229,7 @@ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); @@ -164,7 +251,7 @@ export async function listFollowing(userId: string, page: number = 1): Promise