Skip to content

feat(web): ungated guide reader with a scored CTEM readiness checklist - #163

Merged
ralyodio merged 1 commit into
masterfrom
feat/whitepaper-reader
Aug 29, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/whitepaper-reader

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

An on-site reader for the CTEM guide at /read/ctem-guide, built on the vendor "content viewer" pattern (Huntress et al.) — the guide is readable immediately, and the lead ask comes after engagement rather than in front of it.

Why

/get-whitepaper is a gate: form first, PDF download, visitor gone. Two problems with that:

  1. It is invisible to search. The page is "use client" at the top level with no layout.tsx, so it exports no metadata at all. Four thousand words of genuinely good content have never been indexable.
  2. A lead from it tells us nothing. We learn that someone wanted a PDF, not whether they read it or where they stand.

What's in it

  • /read/ctem-guide — statically prerendered (○ in the build output), with Article + BreadcrumbList JSON-LD, canonical, and OG tags.
  • Reading progress — sticky percentage indicator, section rail with scroll-spy, PDF download always one click away.
  • A 27-control CTEM readiness checklist that scores the reader live, assigns a band (Reactive → Emerging → Operating → Optimizing), and names the exact controls they did not tick.
  • The checklist is the conversion mechanic. The gap list is worth an email in a way an unread PDF is not. Leads carry checklist_score, checklist_band and checklist_answers, so sales sees the score rather than just an address.
  • Engagement beacons to /api/reader-events — scroll depth, engaged seconds, furthest section — via sendBeacon.

Notes for review

  • Progress ratchets upward in SQL. sendBeacon gives no ordering guarantee and a backgrounded tab flushes late, so a plain upsert would let a stale 25% overwrite a live 80%. record_reader_progress uses greatest() and a sticky completed.
  • Scores are recomputed server-side from the raw answer ids, and unknown ids are dropped. A client-supplied score would be the one number sales cares about and the easiest to forge.
  • RPC grants are revoked from anon and authenticated by name, not just PUBLIC — revoking from PUBLIC does not remove Supabase's explicit role grants, which would leave a security definer function callable at /rest/v1/rpc/<name>.
  • The checklist ships in the PDF too, as a print-ready Appendix A (14 → 17 pages). docs/ctem-checklist.json is the single source: build-whitepaper.mjs renders it into the PDF and emits apps/web/src/content/ctem-guide.generated.ts for the reader, so the two cannot drift.
  • Drive-by fix: the build script's Chrome detection only ever looked in /usr/bin and /snap/bin, so it silently skipped PDF rendering on any machine with Chrome via Puppeteer. It now honours $CHROME_PATH and falls back to the Puppeteer cache, newest version first.

⚠️ Requires a migration — merging is not enough

No CI applies migrations here. supabase/migrations/20260829120000_reader_sessions.sql must be applied to prod (odhaoehucfyrqhanthyq) or /api/reader-events and /api/checklist will 500 on a missing RPC. Not applied yet — needs a go-ahead.

Testing

  • npx tsc --noEmit clean; next build clean, /read/ctem-guide prerenders static.
  • 10 new tests for /api/checklist covering score recomputation, junk-id rejection, deduplication, anonymous vs lead paths, and that every score 0–100 maps to exactly one band.
  • Full web suite: 410 passed, 2 failed — both pre-existing at HEAD (expo workflow token, usage/topup message text), untouched here.
  • Rendered and driven in a real browser: ticking 14 of 27 gives 52% / "Emerging" with the correct 13-item gap list and lead form.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NEmhXFPa3HEJ3XeBWnC96Y

Adds /read/ctem-guide — an on-site reader for the CTEM whitepaper, modelled
on the vendor "content viewer" pattern used for lead capture.

The existing /get-whitepaper page is a gate: form first, PDF download, visitor
gone. Nothing about it is indexable and nothing about it tells us whether the
lead ever read the thing. The reader inverts that — the guide is readable
immediately, and the ask comes after engagement.

What lands:

- /read/ctem-guide, statically prerendered with Article + BreadcrumbList
  JSON-LD. The gated page is client-only and exports no metadata at all, so
  4,000 words of content were invisible to search until now.
- A sticky reading-progress indicator, a section rail with scroll-spy, and a
  27-control CTEM readiness checklist that scores the reader live and names
  the controls they did not tick.
- The checklist is the conversion mechanic: the gap list is worth an email in
  a way an unread PDF is not. Leads carry checklist_score, band and answers,
  so sales sees the score, not just an address.
- Engagement beacons to /api/reader-events (scroll depth, engaged seconds,
  furthest section) via sendBeacon. Progress ratchets upward in SQL because
  beacons have no ordering guarantee and a stale 25% must not overwrite 80%.
- Scores are recomputed server-side from the raw answers; a client-supplied
  score would be the one number sales cares about and the easiest to forge.

The checklist ships in the PDF too, as a print-ready Appendix A (14 -> 17
pages). docs/ctem-checklist.json is the single source: build-whitepaper.mjs
renders it into the PDF and emits apps/web/src/content/ctem-guide.generated.ts
for the reader, so the two cannot drift.

Also fixes the build script's Chrome detection, which only ever looked in
/usr/bin and /snap/bin and so silently skipped PDF rendering on any machine
that had Chrome via Puppeteer. It now honours $CHROME_PATH and falls back to
the Puppeteer cache, newest version first.

Nav, footer and landing-page CTAs point at the reader; /get-whitepaper keeps
the PDF download and links across to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEmhXFPa3HEJ3XeBWnC96Y
{GUIDE_SECTIONS.map((section) => (
<section key={section.id} id={section.id} className="scroll-mt-28">
{section.id !== "top" && <h2>{section.title}</h2>}
<div dangerouslySetInnerHTML={{ __html: section.html }} />
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

12 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 6 | LOW: 5

Severity Rule Location
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
MEDIUM js-open-redirect apps/web/src/app/auth/login/page.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:96
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:100
MEDIUM js-open-redirect apps/web/src/components/funding/FundingClient.tsx:97
MEDIUM js-unescaped-html-sink apps/web/src/components/GuideReader.tsx:246
MEDIUM js-uninitialized-buffer packages/scan/src/node-rules.ts:456
LOW secret-generic-credential PRD.md:268
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:121
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:122
LOW sh-remote-script-execution scripts/smoke-test.sh:47
LOW secret-aws-access-key scripts/smoke-test.sh:112

Snippets are redacted; ThreatCrush never prints matched credential material.

const [tocOpen, setTocOpen] = useState(false);

const sessionIdRef = useRef("");
const [sessionId, setSessionId] = useState("");
const beacon = useCallback((completed = false) => {
if (!sessionIdRef.current) return;
sendProgress({
session_id: sessionIdRef.current,
@ralyodio
ralyodio merged commit e5d8b6d into master Aug 29, 2026
9 of 11 checks passed
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.

2 participants