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) <noreply@anthropic.com>
10 KiB
Context
trails.cool has the primitives for a social layer in place but nothing wired up:
- Users have public profiles at
/users/:username(postpublic-content-visibility). - Activities and routes have
public | unlisted | privatevisibility. - There is no
followsrelation today, no follower/following lists, and no aggregated feed. - No ActivityPub yet. The Journal does not currently speak ActivityPub. There are no actor objects, no inbox, no outbox, no HTTP-signing layer, no Fedify dependency. Federation is genuinely Phase 2 (per
CLAUDE.md) and is being scoped separately as thesocial-federationchange.
This change is local-only social: follows between users on the same Journal instance, and the /feed aggregation. The schema is shaped so federation can layer on without a migration when it arrives.
Goals / Non-Goals
Goals:
- A logged-in user can follow another local user with a public profile and see their public activities in a
/feedview. - Profile visibility is explicit (
users.profile_visibility), gates followability deterministically, and pre-pays the model for federation and locked accounts. - Follower and following counts + lists are public per local instance.
- Unfollow fully cleans up.
- Schema is forward-compatible:
followskeyed byactor_iri TEXTso when remote follows land insocial-federation, they slot in with no migration.
Non-Goals:
- All ActivityPub federation primitives — Fedify integration, actor objects, WebFinger, signed inbox/outbox, remote actor caching, audience-aware ingestion. Deferred to
social-federation. Thefollowsschema is shaped to accept remote IRIs but no remote IRIs will land in this change. - Blocking, muting, or report flows.
- Direct messages or any non-activity ActivityPub object.
- Real-time WebSocket feed updates. The feed is loader-driven and refreshes on page load.
- A "people you may know" / suggestions surface.
- A unified notification center. The follow-request count badge in the navbar covers this one signal; broader notifications (for new public activities from people you follow, replies, etc.) will be designed in a follow-up.
Decisions
Decision: Locked-account model — private means stub-with-request, not 404
users.profile_visibility: 'public' | 'private' (NOT NULL). New users default to 'private'; existing users were backfilled to 'public' on first migration so behavior didn't change for anyone already on the instance.
Behavior:
- Public profile:
/users/:usernamerenders fully to anyone. Follows auto-accept. - Private (locked) profile:
/users/:usernamerenders a stub for non-owner viewers without an accepted follow — header (display name, handle, follower/following counts) plus a 🔒 badge plus a "this profile is private" body and a Request-to-Follow button. Accepted followers (from earlier approval) see the full profile; the owner always sees their own profile. Follows against a private profile create a Pending row (accepted_at = NULL) which the owner approves or rejects from/follows/requests. - Approve/reject: dedicated endpoints + a navbar entry with a count badge. Approving sets
accepted_at = now(); rejecting deletes the row so the requester can re-request later.
This is the Mastodon "locked account" pattern, applied locally. It composes with federation cleanly: when social-federation lands, remote inbound Follow activities targeting a local private user will follow the same Pending → Accepted lifecycle.
Default 'private': matches trails.cool's privacy-first content defaults (activities and routes already default 'private'). The earlier proposal defaulted public to mirror Mastodon, but Mastodon's default-public is for discoverable + auto-accept; ours is now stronger ("locked"), so opt-in is the right inversion.
Existence is observable. Even on a private profile the URL returns 200 with a stub. We accept that "the username exists" is leaked — private is about gating content, not gating existence. Hiding existence entirely is a different feature (and not one we ship; if you want to be undiscoverable, don't share your handle).
Migration: backfill existing users to 'public' so accounts created before this change keep their open behavior; new accounts post-deploy default 'private'. No backfill of follow state needed because the social layer didn't exist before.
Why now (not deferred to a separate change): the 404-for-private model that an earlier draft of this spec described would have required a follow-up that re-implements the entire profile loader to switch to a stub. Doing the locked model from the start avoids that churn.
Alternative considered: add a 'public' | 'unlisted' | 'private' triple where unlisted means "profile page 404s but actor object exists for federation." Rejected for now; we don't need three states, and unlisted is a confusing UX surface to ship without user demand.
Decision: Pull-based feed, not fan-out-on-write
The feed query is:
SELECT a.* FROM journal.activities a
JOIN journal.follows f ON f.followed_user_id = a.owner_id
WHERE f.follower_id = :currentUser
AND f.accepted_at IS NOT NULL
AND a.visibility = 'public'
ORDER BY a.created_at DESC LIMIT 50;
We pull at read time rather than precomputing per-follower timelines at write time.
Why: our scale is tiny (hundreds of users, not millions), and pull keeps us free of the consistency+cleanup headaches that fan-out-on-write brings (deletes, visibility changes, unfollows, backfill on new follow). A (follower_id, created_at DESC) index on follows + the existing (created_at) index on activities makes the join O(k log n) for k follows, which is fine to well past the scale trails.cool cares about for years. The same query naturally extends to remote activities once social-federation adds the remote-content cache.
Alternative considered: write-side fan-out into a per-user timeline table. Rejected for the complexity vs. the scale we'll actually hit.
Decision: Store follows keyed by actor IRI even though all rows are local today
Schema:
follows(
id UUID PRIMARY KEY,
follower_id TEXT NOT NULL REFERENCES users(id), -- always a local user
followed_actor_iri TEXT NOT NULL, -- e.g. https://trails.cool/users/bruno; future: remote IRIs
followed_user_id TEXT REFERENCES users(id), -- non-null for local follows; FK for fast joins
accepted_at TIMESTAMPTZ, -- always set in this change (auto-accept for local public)
created_at TIMESTAMPTZ DEFAULT now()
)
UNIQUE (follower_id, followed_actor_iri)
INDEX (follower_id, created_at DESC)
INDEX (followed_actor_iri)
The follower is always a local user. The followed side is an actor IRI. In this change all IRIs are local (built as https://{DOMAIN}/users/{username}). followed_user_id is always populated, so queries that need the full user row do an indexed FK join.
Why keyed by IRI now: ActivityPub is IRI-first, and the social-federation change will need this column to point at remote actors. Adding it now (instead of just a followed_user_id FK) costs one extra column and a tiny IRI-builder helper today, and saves a real schema migration + backfill when federation lands. The denorm followed_user_id keeps query performance identical to a pure-FK design for local-only operation.
Why accepted_at even though it's always set in this change: the column models the eventual lifecycle (Pending → Accepted → Rejected/Undone) that arrives with federation. Marking the column nullable now means no migration later when remote follows can sit in Pending.
Decision: Feed lives at /feed (new route), not on /
The signed-in home already is the personal dashboard. Overloading it with tabs now adds product complexity without much payoff. A dedicated /feed is discoverable via the nav and mirrors how Mastodon separates home and local timelines.
Alternative: tabs on / (Personal / Following / Public). Rejected for this change to keep scope small; easy to move later.
Risks / Trade-offs
- Privacy of follow lists: follower/following lists are public on the instance. → Explicit, documented in the privacy manifest; matches Mastodon norms; users who don't want to be discoverable as a follower should set
profile_visibility = 'private', which 404s their profile and (when federation arrives) their actor object. - Empty feed on small instances: a typical local-only instance will have few people to follow. → Acknowledged; the empty-state copy points users to
/users/:usernameprofiles to find folks. Federation will widen the pool, but isn't part of this change. - Schema churn at federation time: even with the forward-compatible IRI column,
social-federationwill likely addremote_actors+ audience columns + per-user keypair storage. → Accepted; the federation change can iterate on its own schema. The local-only follows + feed survive untouched. - Feed query growth: at 50k activities × 1k follows, the join could degrade. → The
(follower_id, created_at DESC)index keeps this fine up to ~10k follows per user; above that we'd revisit fan-out-on-write. Punt. - Marketing copy currently overstates federation: the Journal home blurb says "Federated by design — your Journal instance talks to other trails.cool servers via ActivityPub. Follow friends anywhere." → Out of scope for this PR; soften copy as a separate one-liner change before public launch, or leave as forward-looking until
social-federationlands.
Migration Plan
- Land schema via
drizzle-kit push --force— addsfollowstable andusers.profile_visibilitycolumn. No row backfill needed (column default fills existing users with'public'). - Ship API + UI behind no flag — the feature is additive and a user with zero follows just sees an empty
/feed. - Document the new relation + setting in the privacy manifest before user-visible release.
- Rollback: revert the PR. The
followstable can be dropped without affecting other data; theprofile_visibilitycolumn can be dropped, returning the implicit "public iff has public content" behavior (loaders already check that).
Open Questions
- Should the initial nav entry be labeled "Feed" or "Following"? "Feed" is shorter and matches the route; "Following" is clearer about what's in it. Tasks phase can pick — lean "Feed" for now.
- Do we surface the instance's local public feed anywhere for signed-in users post-change? Today they see it only when signed out. A "Local" tab alongside the Following feed could be a later follow-up; not in this change.