trails/openspec/changes/public-content-visibility/design.md
Ullrich Schäfer dcbd703f47
Propose social MVP: public-content-visibility + demo-activity-bot
Two stacked OpenSpec change proposals for a demoable social layer,
with scope deliberately minimal ("enough to send a URL and have it
open to real-looking content without signup"):

1. public-content-visibility
   - Adds visibility enum {private, unlisted, public} on routes +
     activities, defaulting to private for every existing row.
   - Detail pages (/routes/:id, /activities/:id) become accessible to
     logged-out visitors when content is public or unlisted; private
     → 404 (not 403) to avoid existence leaks.
   - Broadens /users/:username into a public profile listing only
     public routes + activities; 404s when there's no public content
     to prevent account enumeration.
   - Open Graph / Twitter Card meta on public detail + profile pages.
   - Visibility selector in the owner's edit flow.
   - Out of scope: follow/follower, cross-user feed, reactions,
     federation.

2. demo-activity-bot  (depends on #1)
   - A single bot user, Bruno the trail dog, seeded on worker startup
     when DEMO_BOT_ENABLED=true. Reserved username, sentinel email,
     no credentials.
   - pg-boss recurring job fires every 90 min, decides-to-walk with
     p=0.12 during 07:00-21:00 local, yielding ~2-3 walks/day at
     organic times.
   - Each walk: random start + end within inner-Berlin bbox, trekking
     only (dogs don't ride bikes), 2-12 km crow. BRouter plans the
     route; the route GPX is also attached as the activity's trace.
   - Everything inserted with visibility=public, synthetic=true.
   - Daily prune deletes synthetic rows > DEMO_BOT_RETENTION_DAYS old
     (default 14). Hard cap of 40 items/14d protects against runaway
     growth.
   - Small "🐕 demo account" badge on /users/bruno for honesty.

Both changes are artifacts only — no code lands with this PR. Apply
in order after merge: public-content-visibility first, then
demo-activity-bot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:49:59 +02:00

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-bot that 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 robots override).
  • 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 default
  • og: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 canView helper already supports it. Adding an index boolean or "discoverable" flag is additive.
  • unlisted is 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 riskroute-management and activity-feed both gain modified requirements; keep the delta specs tight so the archive survives review.

Migration Plan

  1. Merge schema change; drizzle-kit push --force on deploy adds the column with default 'private'.
  2. Logged-out access to public pages is a new code path — nothing existing regresses since all rows remain 'private' until a user changes them.
  3. demo-activity-bot is the first caller that will create rows with visibility: 'public' at insert time.
  4. 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.