Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.
Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.
Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
centralises the rule: public → anyone; unlisted → anyone on direct
link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
content only.
Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.
Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
for cross-user queries. Existing owner-scoped listRoutes/listActivities
stay — owners see their own content regardless of visibility.
Public profile:
- /users/:username is now truly public. Renders the user's public
routes + activities, 404s when no public content exists AND viewer
isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.
Privacy manifest:
- Added a bullet noting public content is world-visible on profile
and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
snapshot.
Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
- Private route → 404 for logged-out visitor
- Public route → reachable + OG tags present (og:title, og:type,
og:site_name)
- Owner still sees own private content
- Profile 404 when no public content
- Profile renders when at least one public route exists
- Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
WebAuthn virtual-authenticator races under Playwright's default
parallel workers.
- New public-content Playwright project added to config.
Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
7.2 KiB
TypeScript
179 lines
7.2 KiB
TypeScript
import { test, expect, type CDPSession, type Page } from "@playwright/test";
|
|
|
|
// Reuses the virtual-authenticator + register helpers from auth.test.ts.
|
|
// Inlined rather than factored out to keep this file independently runnable.
|
|
async function setupVirtualAuthenticator(cdp: CDPSession) {
|
|
await cdp.send("WebAuthn.enable");
|
|
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
|
options: {
|
|
protocol: "ctap2",
|
|
transport: "internal",
|
|
hasResidentKey: true,
|
|
hasUserVerification: true,
|
|
isUserVerified: true,
|
|
},
|
|
});
|
|
return authenticatorId;
|
|
}
|
|
|
|
async function registerUser(page: Page, email: string, username: string) {
|
|
await page.goto("/auth/register");
|
|
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
|
|
await page.getByLabel("Email").fill(email);
|
|
await page.getByLabel("Username").fill(username);
|
|
await page.getByRole("checkbox").check();
|
|
await page.getByRole("button", { name: /Register with Passkey/ }).click();
|
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
|
}
|
|
|
|
async function createRoute(page: Page, name: string): Promise<string> {
|
|
await page.goto("/routes/new");
|
|
await page.getByLabel("Name").fill(name);
|
|
await page.getByRole("button", { name: "Create Route" }).click();
|
|
await page.waitForURL(/\/routes\/[0-9a-f-]+$/, { timeout: 10000 });
|
|
const url = page.url();
|
|
const id = url.split("/").pop()!;
|
|
return id;
|
|
}
|
|
|
|
async function setRouteVisibility(
|
|
page: Page,
|
|
id: string,
|
|
visibility: "private" | "unlisted" | "public",
|
|
) {
|
|
await page.goto(`/routes/${id}/edit`);
|
|
await page.getByLabel("Visibility").selectOption(visibility);
|
|
await page.getByRole("button", { name: "Save Changes" }).click();
|
|
await page.waitForURL(new RegExp(`/routes/${id}$`), { timeout: 10000 });
|
|
}
|
|
|
|
// Registration + WebAuthn virtual authenticator can race under parallel
|
|
// Playwright workers on a shared local Postgres; run this file serially.
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.describe("Public content visibility", () => {
|
|
test("private route is 404 for a logged-out visitor", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `pcv-priv-${Date.now()}@example.com`;
|
|
const username = `pcvpriv${Date.now()}`;
|
|
await registerUser(page, email, username);
|
|
const id = await createRoute(page, "My private draft");
|
|
|
|
// Open a second browser context (no cookies) and try the same URL.
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
const resp = await anon.goto(`/routes/${id}`);
|
|
// Loader throws 404; the response status reflects that.
|
|
expect(resp?.status()).toBe(404);
|
|
await anonCtx.close();
|
|
});
|
|
|
|
test("public route is reachable by a logged-out visitor and emits OG tags", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `pcv-pub-${Date.now()}@example.com`;
|
|
const username = `pcvpub${Date.now()}`;
|
|
await registerUser(page, email, username);
|
|
const id = await createRoute(page, "Public ride");
|
|
await setRouteVisibility(page, id, "public");
|
|
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
const resp = await anon.goto(`/routes/${id}`);
|
|
expect(resp?.status()).toBe(200);
|
|
await expect(anon.getByRole("heading", { name: "Public ride" })).toBeVisible();
|
|
|
|
// OG tags are present in the document head on public pages.
|
|
const ogTitle = await anon.locator('meta[property="og:title"]').getAttribute("content");
|
|
expect(ogTitle).toContain("Public ride");
|
|
const ogType = await anon.locator('meta[property="og:type"]').getAttribute("content");
|
|
expect(ogType).toBe("article");
|
|
const ogSite = await anon.locator('meta[property="og:site_name"]').getAttribute("content");
|
|
expect(ogSite).toBe("trails.cool");
|
|
|
|
await anonCtx.close();
|
|
});
|
|
|
|
test("owner can still view their own private content", async ({ page }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `pcv-owner-${Date.now()}@example.com`;
|
|
const username = `pcvowner${Date.now()}`;
|
|
await registerUser(page, email, username);
|
|
const id = await createRoute(page, "My only route");
|
|
|
|
// Same session reload: owner still sees it even though default is private.
|
|
await page.goto(`/routes/${id}`);
|
|
await expect(page.getByRole("heading", { name: "My only route" })).toBeVisible();
|
|
});
|
|
|
|
test("profile 404 when user has no public content", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `pcv-empty-${Date.now()}@example.com`;
|
|
const username = `pcvempty${Date.now()}`;
|
|
await registerUser(page, email, username);
|
|
await createRoute(page, "Private thoughts"); // left private
|
|
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
const resp = await anon.goto(`/users/${username}`);
|
|
expect(resp?.status()).toBe(404);
|
|
await anonCtx.close();
|
|
});
|
|
|
|
test("profile renders when user has at least one public route", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `pcv-prof-${Date.now()}@example.com`;
|
|
const username = `pcvprof${Date.now()}`;
|
|
await registerUser(page, email, username);
|
|
const id = await createRoute(page, "Bikepacking loop");
|
|
await setRouteVisibility(page, id, "public");
|
|
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
const resp = await anon.goto(`/users/${username}`);
|
|
expect(resp?.status()).toBe(200);
|
|
|
|
await expect(anon.getByRole("heading", { name: username })).toBeVisible();
|
|
await expect(anon.getByRole("link", { name: /Bikepacking loop/ })).toBeVisible();
|
|
await anonCtx.close();
|
|
});
|
|
|
|
test("unlisted route is reachable by direct URL but does not appear on profile", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `pcv-unl-${Date.now()}@example.com`;
|
|
const username = `pcvunl${Date.now()}`;
|
|
await registerUser(page, email, username);
|
|
|
|
// Two routes: one unlisted, one public — profile shows only the public one.
|
|
const publicId = await createRoute(page, "Public one");
|
|
await setRouteVisibility(page, publicId, "public");
|
|
const unlistedId = await createRoute(page, "Unlisted secret");
|
|
await setRouteVisibility(page, unlistedId, "unlisted");
|
|
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
|
|
// Direct URL to unlisted → 200
|
|
const direct = await anon.goto(`/routes/${unlistedId}`);
|
|
expect(direct?.status()).toBe(200);
|
|
await expect(anon.getByRole("heading", { name: "Unlisted secret" })).toBeVisible();
|
|
|
|
// Profile lists only the public route
|
|
await anon.goto(`/users/${username}`);
|
|
await expect(anon.getByRole("link", { name: /Public one/ })).toBeVisible();
|
|
await expect(anon.getByRole("link", { name: /Unlisted secret/ })).not.toBeVisible();
|
|
|
|
await anonCtx.close();
|
|
});
|
|
});
|