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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-25 23:11:46 +02:00
parent ede68712a3
commit 6a2f0bf089
3 changed files with 23 additions and 19 deletions

View file

@ -31,17 +31,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const isOwn = currentUser?.id === user.id; const isOwn = currentUser?.id === user.id;
// Profile-visibility gate: a `private` profile 404s for everyone but // 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") { if (!isOwn && user.profileVisibility !== "public") {
throw data({ error: "User not found" }, { status: 404 }); 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). // Follow state for non-owner viewers (null when anonymous).
const followState = !isOwn && currentUser const followState = !isOwn && currentUser
? await getFollowState(currentUser.id, user.username) ? await getFollowState(currentUser.id, user.username)

View file

@ -36,14 +36,14 @@ Add `users.profile_visibility: 'public' | 'private'` (NOT NULL, default `'public
The new rules: 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. - `/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.) - 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. 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. **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. **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.

View file

@ -17,27 +17,32 @@ Every user SHALL have an explicit `profile_visibility` of `public` or `private`.
#### Scenario: User toggles profile back to public #### Scenario: User toggles profile back to public
- **WHEN** a previously-private user switches `profile_visibility` to `public` and saves - **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 ## MODIFIED Requirements
### Requirement: Public profile page ### 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 #### Scenario: Logged-out visitor views a public profile
- **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 - **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 - **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 - **AND** items marked `unlisted` or `private` do NOT appear in the list
#### Scenario: Profile 404 cases are indistinguishable #### Scenario: Empty public profile renders an empty shell
- **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 - **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 - **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 #### Scenario: Owner sees their own profile
- **WHEN** a user navigates to their own `/users/:username` while logged in - **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 - **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 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) - **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) - **AND** no Follow button is shown (users cannot follow themselves)
#### Scenario: Signed-in viewer sees a Follow control on a public profile #### Scenario: Signed-in viewer sees a Follow control on a public profile