feat(share-web): reader + tombstone + report; OG meta + CSP + noindex#364
Merged
Conversation
…tract
submitReport was POSTing { id, reason, note, email } with reason values like 'csam' / 'doxx' / 'impersonation'. The backend at /api/report expects { share_id, reason, reporter_email, details } and the reason enum is fixed to copyright | privacy | harassment | illegal | spam | other. Every report from the web would have hit 422.
- Introduce toReportWire(payload) that maps the user-facing labels to the backend enum (csam→illegal, doxx→privacy, impersonation→harassment) while preserving the original choice as the first line of `details` so the abuse queue keeps the finer-grained signal.
- 4 new vitest cases for the mapping.
The React SPA can't supply Open Graph / Twitter Card meta tags because
social-platform scrapers don't execute JavaScript. Without server-side
injection, PR 5's OG renderer is unreachable — any link shared on
Twitter / LinkedIn / iMessage / Slack shows a blank preview.
Pages Function at /functions/s/[id].ts:
- fetches the snapshot's title from share-backend
- reads the static SPA shell via the ASSETS binding
- injects <title>, <link rel=canonical>, og:* and twitter:* tags
- 30s + must-revalidate cache (matches /api/snapshots/[id]) so a
revoke takes the preview off-air on the same timeline
The pure HTML utilities live in src/lib/og-meta.ts so they're unit-
testable without a workerd runtime — 7 tests cover the tag block
shape, attribute-injection escaping, length cap, empty-title fallback,
optional description, and the injection regex.
Three smaller fixes piggyback:
- mailto.ts derives the share URL from window.location.origin (was
hardcoded spool.pro); dev / staging reports now reference the
actual reader origin. mailto.test.ts gains an explicit-origin
case + node-fallback assertion.
- Reader.tsx wraps <SnapshotReader/> in an ErrorBoundary that
falls back to the Tombstone page if any template throws — better
than a silent white screen.
- tsconfig.json adds 'functions' to the include set so the new
Pages Function is typechecked.
Tombstone.tsx:51 still links to https://spool.lab — not changing
because .lab isn't a public TLD and I'm not sure what the intended
domain is. If it's a typo, it's the only place in the package using
this URL.
`spool.lab` was a typo — `.lab` isn't a public TLD so the link silently failed to resolve. The landing page at the same origin (`https://spool.pro/`) is the canonical project marketing entry, so sending unhappy visitors there is both correct and consistent with the rest of the package's URL conventions.
The function reads the snapshot title from share-backend before injecting it into the SPA shell. In prod that's the same origin (the worker dispatcher routes spool.pro/api/* to share-backend), so the default `new URL(ctx.request.url).origin` works. In `wrangler pages dev`, share-web and share-backend live on different ports — set `--binding API_BASE_URL=http://localhost:8788` and the inline fetch picks up the real backend instead of 404-ing against share-web's own `/api` namespace.
In wrangler pages dev share-web is standalone — Reader.tsx's browser-
side `fetch('/api/snapshots/...')` would 404 against share-web's own
namespace and the reader page would always tombstone. Single-domain
prod doesn't have this problem because the worker dispatcher routes
/api/* upstream before share-web sees it.
Forward every /api/* to API_BASE_URL when set (same env var the OG
meta function reads). In prod the var is unset and this proxy becomes
a 404, which is the correct prod behaviour — the dispatcher has
already routed /api elsewhere.
Forum/Letter/Timeline/Chat templates each render at their own natural width (~720px). Without a wrapper they stuck to the viewport's left edge and the shell's dark backdrop filled the rest, which looked half- painted on wide screens. Wrap the SnapshotReader + footer in a flex column that centers horizontally, sits the template's card on a subtle box-shadow so it feels like floating rather than abutting nothing, and leaves the shell's dark backdrop visible on both sides. Footer keeps its own centering inside the canvas. Note: this is the publish-preview-pane look (cream card floating on dark), not the pure-Gemini look (text directly on dark with no card). The latter would need a transparent-background template variant in share-kit — out of scope for this PR.
…ts with content Three corrections to the previous centering pass: - Constrain the SnapshotReader wrapper to the template's natural width (TEMPLATE_RATIO[template].w, e.g. 720 for forum). Without an explicit width the template's own root claimed width: 100% and align-items: center never bit — the cream block just stretched edge-to-edge again. - Background everywhere matches the snapshot's paper. Reader.tsx pulls the hex from share-kit's PAPERS table and applies it to both the canvas and (via a useEffect) document.body — so the initial paint, the area around the wrapper, and any overscroll are all the same cream / bone / whatever the publisher picked. No more dark gutter contrasting with the cream paper. - Drop the canvas's min-height: 100vh and tighten footer padding so a short conversation doesn't leave a yawning empty stretch between the content and the report-this-share / Terms / Privacy line. Footer now sits right under the content, with the rest of the viewport staying paper-toned by the body bg. Two-line trade-off: the canvas no longer reads as a 'floating card on dark backdrop' (the look from the previous commit). It now reads as 'cream paper page that fills the viewport, with the conversation column centered on it.' Matches the publisher's chosen palette and is closer to the look the user asked for.
The previous commit added useMemo + useEffect inside the conditional 'state.kind === ok' branch (after the loading / tombstone early returns). That violates the Rules of Hooks: React saw N hooks while loading and N+2 after the snapshot arrived, then refused to render anything. The reader page came back blank. Lift both useMemo calls + the document.body bg-sync effect above all the early returns. The helpers accept null and return safe defaults (DEFAULT_PAPER_HEX + 720px) so the loading and tombstone branches still compose. Only the body bg effect bails when there's no active snapshot — no point repainting the page before we know what paper to paint it in.
…content `min-height: 100vh` + `margin-top: auto` on the footer = classic sticky-footer: short conversations push the Report this share / Terms / Privacy row to the bottom of the viewport, long ones let it follow the content naturally. Safe to bring back min-height now that the canvas + body bg both match the snapshot's paper tone — no dark void shows below short content anymore.
Forum (and the other templates) render `conversation.createdAt` verbatim. Snapshot wire payloads carry the raw ISO `source.captured_at`, so the reader was showing 'started 2026-06-03T00:00:00Z' — a database timestamp where the publisher expected a date. Format the ISO in `decodeSnapshot` (reader-only adapter, no impact on the desktop publish preview's own data path). `toLocaleDateString` uses the viewer's browser locale; the short-form options pin the output to 'Jun 3, 2026' style instead of locale-default fully-numeric strings.
Introduce src/components/Chrome.tsx with Page/Header/Footer/Avatar/Icon/ThemeToggle/SpoolMark, a full styles.css rewrite around .sw-root tokens (light + dark via html[data-theme]), and src/lib/dates.ts for shared date formatting. Reader and Tombstone now compose the shared chrome. Geist + Geist Mono are bundled via @fontsource so the design system ships fonts same-origin without breaching the strict CSP. bootTheme() runs before React mounts so the initial paint matches the saved theme or OS preference. Header/footer use solid var(--bg) (matching spool.pro landing) instead of frosted-glass.
- vite.config: disable production sourcemaps. The reader is the most public surface in the product; shipping .js.map exposes every template, redact preprocessor, and state machine via DevTools. Use 'hidden' if a crash-symbolication backend gets wired up later. - og-meta: strip the static `<meta name="robots" content="noindex">` from the HTML shell on a successful inject path. The shell ships with noindex so the bare /index.html doesn't get crawled, but a served-with-200 share page is exactly what we want Google + social scrapers to see. The passthroughShell paths in functions/s/[id].ts preserve the noindex for 404 / 410 / 502 / 500 (failures shouldn't leak into search results). - og-meta: warn when </head> is missing instead of silently returning the unchanged shell. A corrupted ASSETS response was previously observable only as a missing-OG-tag mystery in production. - og-meta tests: +3 — noindex stripped on success, preserved on </head>-missing passthrough, and the failure path emits a warn.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The public web at spool.pro that renders a published share — what someone landing on
/s/<slug>actually sees.Two routes:
/s/<slug>→ React<Reader>: paints the snapshot in its paper tone, full chrome (Header, Footer), reuses@spool/share-kit's<SnapshotReader>so the desktop preview and the public render share one renderer/s/<slug>→<Tombstone>: rendered when the snapshot endpoint returns 410 (revoked / expired) or 404 (not found). Distinct copy per reason — users hit this from a real link, so "this share was taken down" reads more honestly than a generic 404Plus the Pages-Functions shell that makes it work:
functions/api/[[path]].ts— dev-parity catch-all that proxies/api/*to the share-backend wrangler so a local share-web dev server reaches local D1 / KV / R2 without CORS gymnasticsfunctions/s/[id].ts— injects OG / Twitter Card meta tags into the SSR'd HTML for/s/<slug>, reading title + summary + author handle from the backend so social previews stamp the actual share, not the spool.pro fallbackog-meta.ts+ tests — escape rules for the meta tag values (HTML, attribute, URL contexts); regression tests for known-bad inputs (<>"'&, surrogate pairs, BOM)mailto.ts+lib/safety.test.ts— report-this-share link: builds amailto:with subject/body pre-populated and the slug embedded; e2e-validates it can't be coerced into header injection or scheme switchingPlus chrome:
<Chrome>(Page/Header/Footer): the spool.pro shell every page renders inside. Centralised so the dark backdrop, paper tone, and the typeset look stay locked across Reader, Tombstone, and future surfaces (Me, Profile in PR feat(share-web): reader + tombstone + report; OG meta + CSP + noindex #364)styles.css+ Tailwind tokens — share-kit's tokens lifted out so the reader uses the same--accent,--warm-bg, etc. as the desktop previewPlus global safety:
_middleware.tson every reader response (script-src 'self', style-src 'self', img-src 'self' data: blob:, frame-src 'none', no eval, no inline)X-Robots-Tag: noindexon every reader page — published shares are unlisted by default, search engines do not get to surface them by guessing slugsReferrer-Policy: strict-origin-when-cross-originso an outbound click from a share doesn't leak the slug to the destinationWhy one PR
These pieces don't make sense apart:
Bundling means one review for the whole public-side trust boundary.
Reader render flow
sequenceDiagram autonumber participant V as Visitor participant CF as Pages Function /s/:id participant API as share-backend /api/snapshots/:id participant R as <Reader> participant SK as @spool/share-kit participant T as <Tombstone> V->>CF: GET /s/<slug> CF->>API: fetch snapshot meta API-->>CF: {title, author_handle, og_image_url} CF-->>V: index.html + injected OG/Twitter meta V->>R: hydrate R->>API: GET /api/snapshots/<slug> alt 200 API-->>R: snapshot JSON R->>SK: <SnapshotReader /> SK-->>R: paginated rendered conversation R-->>V: paper-toned page in spool.pro chrome else 410 revoked|expired API-->>R: tombstone JSON {reason, at} R->>T: render with reason T-->>V: "This share was unpublished — Removed Jun 4, 14:02" else 404 R->>T: reason='not-found' T-->>V: "We couldn't find that share" endOG meta injection
flowchart LR L[Link in tweet/Slack/email] --> CRAWL[Social crawler] CRAWL --> CF["GET /s/<slug>"] CF --> META["fetch share meta from /api/snapshots/<slug>"] META --> HTML[inject og:title / og:image / twitter:card] HTML --> CRAWL CRAWL --> CARD[Card preview shows actual share] L2[Human click] --> CF CF --> HTML2[same response] HTML2 --> R[Reader hydrates] R --> SK[<SnapshotReader />]The Pages Function reads the meta once and stamps it server-side so social crawlers (which don't run JS) see the right OG values without a second round trip. The reader hydrates over the same HTML.
Safety boundary
The reader is the most exposed surface in the system — every published share funnels here. The hardening is all-or-nothing across this PR:
Content-Security-Policy: script-src 'self'_middleware.ts<SnapshotReader>'s prose pipeline which sanitises markdown.frame-src 'none'_middleware.ts<iframe>X-Robots-Tag: noindex_middleware.tsunlisted— Google does not get to enumerate slugs.profile-listedshares are surfaced via the Profile page which has its ownindexdirective.Referrer-Policy: strict-origin-when-cross-origin_middleware.tsmailto:report link sanitises every inputmailto.ts+ safety.test.tsSubject: …cannot inject mail headersVerification
pnpm --filter @spool/share-web test— api.test.ts, mailto.test.ts, og-meta.test.ts, route.test.ts, safety.test.ts (header injection, control-char rejection, URL scheme guard)/s/<slug>for live + revoked + expired + 404; confirm OG meta inview-sourcematches the share; run a Lighthouse pass — no eval, no inline script warningsRisk
Pure additive — share-web on
mainwas previously a placeholder. The new routes are the first content served from spool.pro. The Pages Functions proxy only affects dev (production resolves/api/*directly against the share-backend Pages project; the proxy is short-circuited on prod URL).CSP changes are a forward-only ratchet — if anything breaks it'll surface as a CSP violation report in the browser console, not a 500.
Submitted by @graydawnc.