Final tasks ticked post-merge: - 10.2: verified on prod that journal.routes + journal.activities still default to 'private' NOT NULL, with the only public rows being the 15-each demo-bot seeded content - 10.3: demo-activity-bot already inserts with visibility='public' directly in demo-bot.server.ts Syncs the three delta specs into main: + activity-feed: 2 added, 1 modified + public-profiles: new spec (1 added) + route-management: 2 added, 1 modified Moves change to openspec/changes/archive/2026-04-24-public-content-visibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.2 KiB
Context
The Journal uses a straightforward "logged-in or you're not seeing anything" access model today. Routes and activities are owned by a single user via owner_id foreign keys, and every detail-page loader calls getSessionUser(request) then either serves the page or (implicitly) returns the "route not found" error. There is no notion of cross-user visibility.
The Planner is separately anonymous, so that's not a concern here — this change is Journal-only.
Two near-term pressures shape the design:
- The demo plan (send a URL to a prospective user or contributor and have them see real content without signing up). Anything that requires a friction step on arrival defeats the demo.
- Upcoming
demo-activity-botthat generates synthetic activities. That content has to be publicly viewable or the bot is pointless.
What this design deliberately does not touch:
- Followers / subscriptions / per-user feeds
- Reactions / comments / any write surface for non-owners
- ActivityPub / Fedify / federation
- Per-segment privacy on a single route
Those are all future chapters. Keeping the first step narrow means less to get wrong and less to re-design later.
Goals / Non-Goals
Goals:
- A route or activity owner can mark a piece of content as public.
- Logged-out visitors can open that content at its permanent URL.
- A logged-out visitor can find the rest of that user's public content via
/users/:username. - Sharing a public URL on Slack / Twitter / Bluesky / email produces a decent preview.
- Default stays private — nothing already in the database becomes visible just because this change ships.
Non-Goals:
- Discovery (there is no "browse public routes" page on the instance yet).
- Indexing control beyond the default (no per-route
robotsoverride). - Changing the Planner's anonymous model.
- Any write access for non-owners on public content.
Decisions
Visibility model: three values, not two
Decision: visibility enum 'private' | 'unlisted' | 'public'. Stored as a text column for simplicity; can be migrated to a native enum later if worth it.
private(default) — only the owner can fetch the detail page; listings never show it.unlisted— anyone with the URL can view; excluded from the owner's public profile and any future discovery pages.public— anyone can view; appears on the owner's public profile.
Alternatives:
- Just
private | public— simpler, but kills the useful "I want to share with someone without putting it on my profile" case. Every other sharing-oriented service has an unlisted tier; it's cheap to add now and irritating to retrofit later. - Sharing via time-limited tokens (Google-Docs-style) — more powerful but out of scope for the demo goal. Parked.
Default is private for all existing rows
Decision: The new column ships with DEFAULT 'private' and every existing row — two real routes and zero activities — remains private. Users opt in to publication by explicitly changing the value.
Why: This change cannot silently un-privatise data anyone uploaded under the old rules. That principle outweighs the demo inconvenience (we'll mark the demo user's seeded content public at insert time).
Access check lives in route loaders
Decision: Each detail-page loader (routes.$id.tsx, activities.$id.tsx) resolves the content row, compares visibility + owner_id against the session user:
visibility === 'public'→ serve.visibility === 'unlisted'AND the request is a direct detail page URL → serve.- otherwise → serve only if the requester is the owner, else 404 (not 403 — leaking existence is a privacy bug).
A small helper canView(content, user) in ~/lib/auth.server centralises the rule so list endpoints use the same logic.
Alternatives:
- Route-level middleware (before loaders) — cleaner in theory but React Router 7 loaders are the right place for content-specific auth, and centralising via a helper avoids coupling the router tree to visibility semantics.
Public profile page reuses the existing /users/:username route
Decision: That route exists today but is auth-gated (and renders the current user's own routes/activities). Broaden it:
- Publicly accessible.
- Shows public routes + public activities of the requested username, most recent first.
- 404 if the user has no public content at all. This hides the existence of private-only accounts; an attacker can't enumerate accounts by username.
- A "This is your profile" control strip visible only to the logged-in owner, with a quick link to settings.
Open Graph / Twitter Card meta tags
Decision: Each public route / activity detail page emits OG tags via React Router's meta export:
og:title— route or activity name + "· trails.cool"og:description— user description (truncated) or a sensible defaultog:type—"article"og:site_name—"trails.cool"twitter:card—"summary"(summary, not summary_large_image; we don't have social cards yet)
A future social-preview-images follow-up can add rendered static map PNGs. Explicitly out of scope here.
robots control
Decision: Public route / activity pages emit no robots meta (defaults to indexable). The legal pages keep their existing noindex. No sitemap is generated; discovery via search is passive.
Risks / Trade-offs
- Username enumeration via profile 404 → Mitigation: 404 both for "no such user" and "user exists but has no public content", so the two are indistinguishable. Timing-side-channel on the DB lookup is not defended against in v1; acceptable at this scale.
- Future listing pages will want more data → The
canViewhelper already supports it. Adding anindexboolean or "discoverable" flag is additive. unlistedis only private-ish → If the URL leaks it's effectively public. That's the Internet; same as every other unlisted-sharing feature. Documented in the settings UI copy.- Content previously private becomes linkable → Once a user makes a route public and shares it, they can't fully retract — archives exist. Same as any web publish. No new warning for v1; the visibility selector itself is unambiguous.
- Spec drift risk →
route-managementandactivity-feedboth gain modified requirements; keep the delta specs tight so the archive survives review.
Migration Plan
- Merge schema change;
drizzle-kit push --forceon deploy adds the column with default'private'. - Logged-out access to public pages is a new code path — nothing existing regresses since all rows remain
'private'until a user changes them. demo-activity-botis the first caller that will create rows withvisibility: 'public'at insert time.- Rollback:
UPDATE routes SET visibility = 'private'; UPDATE activities SET visibility = 'private';restores the prior behaviour without any code change; the public code path becomes dead but harmless.
Open Questions
- Should the settings page add a default-visibility preference per user (e.g. "make new routes public by default")? Probably nice but not in this spec; revisit after observing real usage.
- Do we want to emit
<link rel="canonical">on unlisted pages to discourage indexing of the URL if it leaks? Low-cost, worth considering during implementation.