feat(pages): public pages, and thumbnails in link unfurls
Links
- Fixes SUPER-2189 — Have pages show their thumbnails on opengraph
- Fixes SUPER-1998 — an anonymous fetch of a just_me page 307s to sign-in rather than 404ing
Summary
everyonebecomes a selectable visibility ("Anyone with the link"), on web, desktop, mobile, the CLI and the MCP tool.- A public page shares only the version its link resolves to. The rest of the history stays behind a ticket.
- Anonymous readers of a public page now get the page, read-only. Everyone else gets a 200 not-found screen instead of a redirect.
- Shared page links unfurl with the page's own thumbnail.
- Also bundled (not in the tickets): the page title menu is rebuilt around read-only version previews, plus rename and refresh.
Why / Context
The two tickets are the same bug from two sides.
apps/web/src/proxy.ts:45 redirects any unauthenticated request for a non-public route to /sign-in, and /page was not in otherPublicRoutes. A crawler asking for a shared page got a 307 and never reached generateMetadata — so no amount of og: tags would ever have unfurled (2189), and a signed-out human got bounced to a login wall instead of being told the page isn't available (1998).
The thumbnail machinery already existed: packages/trpc/src/router/page/thumbnail.ts captures a 1280×880 JPEG per published version, and apps/usercontent served it ticket-free when visibility is everyone. Nothing pointed at it from the web viewer, and no page could be everyone in the first place.
How It Works
everyone end to end. OFFERED_VISIBILITIES gains the third value — one constant feeding page.create, page.publish, setVisibility, the CLI and the MCP tool schemas. PageVisibility now has a single definition in @superset/shared/usercontent; the UI, mobile and CLI copies that had drifted are gone. Three read paths silently excluded everyone and had to be fixed with it:
| Site | Was | Effect |
|---|---|---|
page.ts visibilityFilter() | org + own just_me | public page vanished from page.list, pageNotFound() reported a bogus cross-org FORBIDDEN |
desktop matchesScope() | team was === "org" | public page fell out of both "team" and "mine" chips, reachable only under "all" |
mobile matchesScope() + PageRow | same, plus visibility === "org" ? Globe : Lock | public page dropped out of "team" and rendered with a Lock, telling the owner a live public page was private |
PageCard had already spent Globe on org, so the three states now read Lock / Building2 / Globe everywhere, matching the share popover. The mobile share sheet gains the third row, so "public" can be seen and undone from the phone.
A public page shares one version, not its history. The Worker's everyone branch returned "public" for any /versions/N/ request. The page id is not secret on a shared page — it is the host of the og:image and the iframe viewUrl — so anyone holding the link could walk /versions/1/, /2/, …, including a version the owner had deliberately moved away from and every version an agent published later, while the share popover promised "Everyone stays on v3 until you change this." publiclyReadable(manifest, version) now gates this on servedVersionOf(manifest); every other version needs the ticket the owner's own clients mint.
Because public readability is now revocable — it moves when the served version moves — public responses can no longer cache immutably, or a shared CDN would keep answering for a version the owner has already left. They carry public, max-age=300 instead.
page.publicView is the only page read with no session behind it. It answers for everyone-pages and returns null for everything else, so a private page stays indistinguishable from one that never existed. An R2 head check keeps og:image from pointing at a capture that never ran.
generateMetadata reads publicView first, so what it emits is by construction what a crawler may see — real title/description/thumbnail for a public page, generic Superset card otherwise. It falls back to the authenticated pull for the browser tab title only, so signed-in users keep page titles on org pages while unfurlers learn nothing. Every branch now returns robots: { index: false, follow: false }; unfurlers ignore it, so previews still work.
Anonymous rendering. Public pages render read-only (no share popover, version picker, or comments panel — comments stay protectedProcedure). A signed-in user from another org now sees a public page rather than "This page belongs to Acme.", with an "Open in Superset" affordance rather than a "Sign in" link that would bounce them home. Everything else gets PageUnavailable.
Version history (the bundled part). Picking a version from the title menu used to republish it as the shared version; it now opens a read-only preview — ?v=N on web, per-pane state on desktop — with the comment composer off, because a thread created against an unserved version would send a watching agent feedback about content nobody can see. Both surfaces show a "Back to current" banner. The menu also gains rename (via a new page.update) and refresh.
Design Decisions
PageUnavailablereuses the existing not-found copy. My first pass said "This page is shared with a Superset organization. Sign in with an account that has access." — which confirms to a stranger holding a guessed slug that the page exists and belongs to an org. That is precisely the disclosureassertPageReadableavoids by throwingNOT_FOUNDrather thanFORBIDDENforjust_mepages. Same screen for missing, private, and unauthorised; only the button differs (Sign in / Go to Superset). Both now render one sharedPageMissingScreenso the two copies cannot diverge.- 200, not 404, for the unavailable screen. A 404 suppresses unfurls entirely. 200 keeps the generic branded card.
- Comments stay org-only. Letting anonymous readers comment means inventing anonymous identity and spam controls; letting them read comments makes commenter names and avatars public. Neither belongs in this PR.
- The public-read rate limit lives in the web route, not in
page.publicView. On a public page the slug is the credential, and it is only six characters of[a-z0-9]— about 2.2×10⁹. Throttling moves enumeration of a guessable base (untitled-,report-) from roughly 12 days at 1000 req/s to roughly 34 years. It cannot sit inside the API the wayleaderboard.tsdoes it: the web app callspublicViewserver-side, so every reader would arrive from the web function's egress address and share one bucket — the same mistake the leaderboard hit from the marketing server./page/[slug]is the last hop that can see the real client IP, and the check sits in front of the call so a guessed slug costs no DB lookup and no R2 HEAD. It fails open. page.versionsemits thumbnail URLs unchecked. It is fetched on every web render, every desktop header and the mobile share sheet; a paginated R2 ListObjects per call bought nothing the client's ownonErrorplaceholder does not already handle. OnlypublicView'sog:imagestill does a server-side HEAD.
Manual QA Checklist
Visibility
- Share popover offers "Anyone with the link"; picking it copies the link
- Page stays listed in
page.listand in the desktop grid after going public - Public page appears under the "team" chip, not only "all"
- Card icon reads Lock / Building2 / Globe for just_me / org / everyone
- Mobile: same icon, same "team" filter, and the share sheet can set and undo "Anyone with the link"
-
superset pages publish --visibility everyoneis accepted
Version scoping
- On a public page,
/<pageId>.usercontent…/versions/<not-the-served-one>/does not serve without a ticket - The owner can still open every version from the title menu
- Moving the shared version stops the old one being publicly readable
Anonymous access (signed out, fresh profile)
- Public page renders read-only — no share popover, version picker or comments
- Private page shows "This page isn't here" + Sign in, HTTP 200, no redirect
- Nonexistent slug shows the identical screen (no existence signal)
- Signing in from that screen lands back on the page
Unfurls
- Paste a public page link into Slack/Linear — title, description and thumbnail appear
- Paste an
orgpage link — generic Superset card, no title or thumbnail leak - Public page whose thumbnail has not been captured yet degrades to
summary
Cross-org
- Signed-in user in another org sees a public page, with "Open in Superset"
- Signed-in user in another org still gets WrongOrganization for an
orgpage
Testing
bun run typecheck— 42/42bun run lint:fix— cleanbun run check:i18n— all 17 locales compile, 0 missing; 11 new strings translated into all 16 non-English catalogsbunx sherif— cleanbun test—@superset/web52,@superset/trpcpage + page-comment routers 67,@superset/sharedusercontent 56,@superset/ui71, desktopfilterPages15, all passing
Known red on bun test, none of it from this change's logic:
| Suite | Count | On main? |
|---|---|---|
desktop terminal-ws-transport | 2 | yes — pre-existing |
desktop Redirect | 3 | no — see below |
The three Redirect failures pass in isolation and fail in the full suite: Redirect.test.tsx shares process-wide happy-dom globals with 32 other desktop test files, and the one extra filterPages case this PR adds is enough to shift the ordering that exposes it. It is test-suite pollution, not a behaviour change — nothing in this PR touches Redirect or the router. A CI re-run will not clear it.
Known Limitations
- No automated test for
page.publicView. It needs DB + R2; the router's only integration file ispublish.integration.ts, whichbun testdoes not pick up. Covered by the QA checklist instead. The Worker's public branch has no live coverage either —publiclyReadableis unit-tested in@superset/shared, but the Worker itself has no test harness. - Going public has no confirm step. It is one click in the select, and
--visibility everyonefrom an agent is one flag. Worth a confirmation before this reaches users — see the open questions below. - Flipping
everyone→orgis not instant. Public responses cache for five minutes, so already-fetched bytes survive that long. Down from "indefinitely", which is whatIMMUTABLEmeant before this PR. X-Robots-Tag: noindex, nofollowstill applies to every page document on the usercontent origin, including public ones — "anyone with the link" is not obviously "index me in Google". Theapp.superset.sh/page/<slug>wrapper now says the same thing viagenerateMetadata. Thumbnails never carried that header, which is why unfurls work regardless.
Open Questions
- Should agents be able to make pages public? The MCP tool and
superset pages publish --visibility everyonepublish to the open internet with no human in the loop. That is a real blast radius for a prompt-injected agent. - Org policy. Should there be an org-level switch that disables public pages, and what happens to already-public pages when a plan lapses or the owner leaves the org? Today
publicViewand the Worker keep serving them. - Slug entropy. Six characters is thin for what is now a bearer credential. The rate limit compensates; a longer suffix for public pages would fix it properly, at the cost of changing a page's URL when it goes public.
Risks / Rollout
- Risk: the
/pageentry inotherPublicRoutesremoves the proxy auth gate for that route subtree. The route itself is the gate now —getPagesAccess()thenpublicView, which only ever answers foreveryone-pages. Worth the closest read in review. - Risk:
mintPageTicketnow issues a ticket whenever a version is named, including on public pages. Without it the version-scoping change above would have locked owners out of their own history. - Rollout: no migration, no feature flag change.
everyoneis opt-in per page and no existing page has it. - Rollback: revert the eight commits on this branch. No data to unwind — a page whose visibility was set to
everyonebefore a revert keeps that value and becomes unselectable again in the UI. Note that reverting also restores the pre-existing Worker behaviour where every version of such a page is publicly readable.
Summary by cubic
Adds opt-in “Anyone with the link” sharing and page thumbnails to link unfurls, addressing SUPER-2189 and SUPER-1998. Anonymous requests that previously redirected to sign-in now show public pages read-only or a 200 not-found screen, and SUPER-1990’s version-preview changes are included.
Public sharing
- Adds
everyonevisibility to web, desktop, mobile, CLI, and MCP sharing controls and page filters. - Public links expose only the currently served version; other versions still require tickets.
- Unfurls include a public page’s title, description, and thumbnail, while private pages keep generic metadata.
- Public reads are IP-rate-limited, cache for five minutes, and disable comments; failed manifest writes restore the database visibility.
- No migration or feature flag is required; existing pages remain unchanged until made public.
Version history
- Version selection now opens a read-only preview with a “Back to current” action and version-specific comment counts.
- The title menu now supports renaming without creating a version, refreshing page data, and showing version thumbnails with a file-icon fallback.
Summary by CodeRabbit
-
New Features
- Pages can be shared publicly with anyone who has the link, including public viewing, sign-in redirects, unavailable-page messaging, and sharing controls.
- Owners can copy public links, open sharing settings, rename pages, refresh content, and preview earlier read-only versions with version thumbnails.
- Added live-page and version-preview banners with clearer visibility indicators.
- Team filters and publishing tools now support public pages.
-
Bug Fixes
- Page visibility is handled consistently across the app.
-
Localization
- Added translations for sharing, navigation, renaming, and version-preview messages.