Skip to content

feat(share-web): reader + tombstone + report; OG meta + CSP + noindex#364

Merged
graydawnc merged 13 commits into
mainfrom
feat/share-web-reader
Jun 5, 2026
Merged

feat(share-web): reader + tombstone + report; OG meta + CSP + noindex#364
graydawnc merged 13 commits into
mainfrom
feat/share-web-reader

Conversation

@graydawnc

Copy link
Copy Markdown
Collaborator

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 404

Plus 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 gymnastics
  • functions/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 fallback
  • og-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 a mailto: with subject/body pre-populated and the slug embedded; e2e-validates it can't be coerced into header injection or scheme switching

Plus 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 preview

Plus global safety:

  • CSP applied via _middleware.ts on every reader response (script-src 'self', style-src 'self', img-src 'self' data: blob:, frame-src 'none', no eval, no inline)
  • X-Robots-Tag: noindex on every reader page — published shares are unlisted by default, search engines do not get to surface them by guessing slugs
  • Referrer-Policy: strict-origin-when-cross-origin so an outbound click from a share doesn't leak the slug to the destination

Why one PR

These pieces don't make sense apart:

  • A reader without a tombstone path lands users on a generic 404 the moment a share is revoked, which looks like a bug
  • A reader without OG meta means every link preview is the spool.pro home card — defeats the point of "share something I made"
  • A reader without CSP + noindex starts leaking slugs to indexers the moment the first share publishes
  • The Pages Functions proxy is what makes the whole thing testable in dev — without it every spec hits CORS errors

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"
    end
Loading

OG 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 />]
Loading

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:

Header / behavior Where Why
Content-Security-Policy: script-src 'self' _middleware.ts No inline scripts; no eval; even a stored-XSS-in-snapshot-title can't run JS. Snapshot bodies render via <SnapshotReader>'s prose pipeline which sanitises markdown.
frame-src 'none' _middleware.ts A share can never iframe arbitrary content; defends against the snapshot containing a hidden <iframe>
X-Robots-Tag: noindex _middleware.ts Default share visibility is unlisted — Google does not get to enumerate slugs. profile-listed shares are surfaced via the Profile page which has its own index directive.
Referrer-Policy: strict-origin-when-cross-origin _middleware.ts Clicking an outbound link from a share doesn't reveal the slug
mailto: report link sanitises every input mailto.ts + safety.test.ts A snapshot title containing Subject: … cannot inject mail headers
Tombstone copy per reason Tombstone.tsx A user landing on a revoked share via a stale link sees "the author unpublished this", not a confusing 404

Verification

  • 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)
  • Manual: deploy preview, hit /s/<slug> for live + revoked + expired + 404; confirm OG meta in view-source matches the share; run a Lighthouse pass — no eval, no inline script warnings
  • Cross-browser: Safari, Firefox, Chrome render the paper-toned page identically (tested at the prior design-doc gate)

Risk

Pure additive — share-web on main was 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.

graydawnc added 13 commits June 6, 2026 03:39
…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.
@graydawnc
graydawnc added this pull request to the merge queue Jun 5, 2026
Merged via the queue into main with commit ccaa8d3 Jun 5, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-web-reader branch June 5, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant