Orientation

What you're building, and how to use this guide

By the end you'll have your HTML pitch deck and data room live on your own domain, gated by personal access codes, with named analytics telling you exactly who read what.

The finished system in one sentence: an investor opens deck.yourco.com, types the personal code you issued them, and from that moment every page they view, document they download, and minute they spend is attributed to them by name in your analytics dashboard — while your registry stays the single record of who holds which code.

What makes this worth building yourself

Vs. DocSend / data-room SaaS

No per-seat fees, no "powered by" branding, no forced email-gate friction. Your domain, your design, your data. The whole stack runs on free tiers.

Vs. a single shared password

Per-person codes mean per-person revocation and per-person analytics. One password shared with twelve funds tells you nothing and revokes everyone at once.

Vs. no gate at all

An open deck URL gets forwarded, scraped, and indexed. A gate keeps your numbers off Google and gives every send a paper trail.

Vs. building a "real" auth system

No user database, no password resets, no OAuth. One edge middleware file and one env var. There is nothing to maintain and almost nothing to break.

The three moving parts

PieceWhere it livesJob
Access gatemiddleware.js at your repo rootIntercepts every request, shows a branded unlock page, validates codes, sets cookies
Analyticsjs/analytics.js on every gated pagePostHog tracking that only activates for identified viewers — public pages are never touched
RegistryA Notion databaseThe human record: who holds which code, when issued, active or revoked

The map: how everything connects

logs every code adds / removes codes live list — checked on every request types their code 2 cookies · 30 days on every gated page named events joined by the 4-char ref — XK9P your Monday read You issue · revoke · read Investor holds one personal code Notion registry who holds which code → Step 3 Vercel env var DECK_ACCESS_CODES → Step 5 The gate — middleware.js validates codes · sets 2 cookies fails closed → Step 2 Your static HTML /deck · /dataroom · /files → Step 1 analytics.js self-gating tracker → Step 4 PostHog named engagement → Operations
Every box is clickable — it jumps to the section of this guide that builds it. Teal lines are the investor's live request path; the dashed line is the analytics join.

How to use this guide

The sections in the sidebar are ordered as a build sequence — Architecture explains why the design is what it is, then Steps 1–6 walk the build, then Operations covers running it day to day.

  • Every step has two tracks. A do-it-by-hand track (click-by-click, including the third-party dashboards), and an AI-assisted track — a ready prompt for Claude Code, plus a breakdown of exactly why the prompt is written that way so you can adapt it.
  • Copy buttons on every prompt and code block.
  • The templates do the heavy lifting. Download them now: middleware.template.js and analytics.template.js. You customize marked blocks; the security-sensitive logic stays untouched.
  • The Verify section is an interactive checklist — it remembers your progress in this browser.
Time budget: about an hour for the base build if your deck HTML already exists. Add 30 minutes the first time for the PostHog and Vercel account setup.
Orientation

Architecture: the request's journey

Click each stage to expand what happens there and why it's designed that way. Understanding this page makes every later step obvious.

1 · Investor opens yourco.com/deck — click to expand
The link you emailed is just a normal URL — safe to forward, safe to preview in email clients, because it opens nothing by itself. Vercel receives the request and, before serving any file, runs your middleware.js at the edge (a tiny function that executes in the CDN, before your static files). Why edge middleware? It's the only way to gate static files. Client-side JavaScript "gates" are decoration — anyone can View Source or fetch the file directly. The middleware runs server-side on every request, including direct requests to PDFs.
2 · No valid cookie → the branded gate page — click to expand
The middleware finds no valid access cookie and responds with a styled 401 page: your logo, your colors, one password field. Why status 401 with noindex? Search engines see an error and never index the gate or anything behind it. Why does the gate page load CSS and favicons? A short allowlist of static-asset prefixes (/css/, /img/…) stays open — the gate must render beautifully even to someone locked out, because it's the first brand impression every investor gets.
3 · They type their code → POST /__unlock — click to expand
The form posts to a special path the middleware handles itself. It checks the submitted code against the list in the DECK_ACCESS_CODES environment variable. Why an env var and not a file or database? Secrets never enter the repo (it could even be public), there's no database to run, and editing access = editing one setting in the Vercel dashboard. Why is there no rate limit? Codes are long (~10^12 combinations for two 4-char blocks); brute force through a CDN is impractical for this threat model. Add Vercel's WAF rules if a client insists.
4 · Match → two cookies, then redirect — click to expand
Cookie ① _access — a SHA-256 hash of (salt + code), HttpOnly and Secure, 30-day expiry. HttpOnly means page JavaScript can't read it; hashing means the cookie can't be reversed into the code, so a leaked cookie value can't be turned into a shareable code. Cookie ② _who — deliberately readable JSON: {"n":"Jane Smith — Blue Harbor","c":"XK9P"}. Only a display name and the code's last 4 characters. This is the bridge to analytics, and it's safe precisely because it contains nothing secret. Why two cookies instead of one? Separation of duties: the secret one proves access and is invisible to scripts; the readable one identifies, and can't unlock anything.
5 · Every later request: cookie re-validated against the live code list — click to expand
On each request the middleware re-hashes every code in the env var and compares against the cookie. This is what makes revocation instant: delete a code from the env var, redeploy, and the matching cookie stops validating — no expiry wait, no session store to purge. It also means a removed investor's 30-day cookie is worthless the moment you redeploy. The linear scan is fine at this scale (dozens of codes, a few string hashes per request at the edge).
6 · The page loads analytics.js → PostHog, attributed by name — click to expand
The script's first act is reading cookie ②. Absent → it exits before loading anything. Public pages send zero events, and the gate page (served by middleware, no scripts at all) can never capture a typed code. Present → it initializes PostHog, calls identify() with the name and 4-char ref, and stamps both onto every event. Autocapture handles clicks; pageleave gives time-on-page; named document_opened / slide_viewed events cover the moments you actually care about. Session replay stays off by default — attention data without surveillance vibes.
7 · You read it: PostHog joined to your Notion registry by "XK9P" — click to expand
PostHog shows a person named "Jane Smith — Blue Harbor" with code_ref: XK9P. Your Notion registry has a row where Ref = XK9P holding the full code, issue date, and status. Why join on last-4 instead of the full code? The full code is a secret and secrets don't belong in analytics events. Four characters are enough to be unique across the handful of codes one raise issues, and are meaningless to anyone who sees them.

The failure modes, designed in

  • Env var missing (bad deploy)? The gate returns 503 and explains which var is unset. Closed by default — misconfiguration can never mean an open door.
  • Someone shares their code? Both people show up on one PostHog profile — two device/location patterns on "Jane Smith" is a visible tell, and you revoke that one code.
  • Someone tries the PDF URL directly? Gated paths cover files too; the middleware doesn't care whether the request is for a page or a download.
  • Cookie stolen? It's a hash — it can't produce the code — and it dies the moment you rotate that code.
What this is not: DRM. A legitimate viewer can screenshot, download, and share what they can see. The design goal is attribution and revocability at zero investor friction — the right trade for a fundraise.
The build · Step 1

Structure the repo

Ten minutes of layout discipline now makes the gate configuration one line and prevents the classic leak — a "gated" page linking to an ungated file.

Target layout

your-site/
├── middleware.js          ← the gate (Step 2)
├── vercel.json            ← headers & URL behavior (Step 5)
├── index.html             ← public landing page (optional)
├── deck/
│   └── index.html         ← your HTML pitch deck
├── dataroom/
│   ├── index.html         ← document index page
│   └── docs/…             ← individual document pages
├── files/                 ← PDFs, XLSX models, downloads — GATED
├── js/
│   └── analytics.js       ← tracking (Step 4)
└── css/  img/  fonts/     ← design assets — OPEN (gate page needs them)

The three rules

1 · Private things under few prefixes

Everything secret lives under /deck, /dataroom, /files. The gate config becomes exactly that three-item list — easy to write, easy to audit.

2 · Downloadables inside the gate

A PDF at /brochure.pdf is public no matter what page links to it. Put every sensitive file under a gated prefix. This is the #1 leak in hand-rolled gates.

3 · Absolute asset paths

Write /css/deck.css, never ../css/deck.css. Vercel's cleanUrls serves /deck without a trailing slash, which silently breaks relative paths.

Already have a deck as PDF or PowerPoint only? This system shines with HTML decks (per-slide analytics, no download-to-view). Ask Claude Code to convert: "Convert deck.pdf into a single-page HTML deck — one full-viewport section per slide, keyboard and scroll navigation, assets extracted to /img." Or gate the PDF as-is under /files and skip slide tracking.

AI-assisted track

Prompt — restructure the repo
Reorganize this repo for private hosting: move the deck HTML under /deck, data-room pages under /dataroom, and every PDF/XLSX/downloadable under /files. Keep shared CSS/JS/images under /css, /js, /img. Rewrite every asset reference and internal link to absolute paths (starting with /). Show me a before→after tree and flag any file you weren't sure how to classify — don't guess on those.

What the run looks like

claude — your-site/
Reorganize this repo for private hosting: move the deck HTML under /deck, data-room pages under /dataroom…
Glob(**/*.html) — 14 files · Glob(**/*.{pdf,xlsx}) — 6 files
Bash(git mv deck.html deck/index.html) · +12 more moves
Edit(deck/index.html) — 23 asset refs → absolute · +9 files
Moved 20 files, rewrote 61 references. Before→after tree below.
Not sure about: brochure-2024.pdf — looks public, but it quotes revenue figures. Gate it under /files or keep it open?

The escape valve working as designed — the ambiguous file surfaces as a question instead of a silent guess.

Why the prompt is built this way

"Reorganize this repo for private hosting"
Goal + context first. The agent picks better defaults when it knows why — here it will keep gate-relevant grouping in mind rather than organizing alphabetically.
Explicit target folders
Never say "organize it sensibly." Name the exact prefixes, because Step 2's gate config must match them literally.
"Rewrite every asset reference…absolute"
The mechanical follow-through that's easy to forget. Moving files without rewriting links produces a deck that 404s its own CSS.
"Show me a before→after tree"
A verification artifact. You review one tree instead of clicking through every page.
"flag any file you weren't sure…don't guess"
An escape valve. Misclassifying one sensitive file as public is the exact failure this step exists to prevent, so uncertainty must surface, not resolve silently.
The build · Step 2

Install the access gate

One file — middleware.template.js — copied to your repo root as middleware.js. You customize three marked blocks and leave the security logic alone.

The three ★ blocks you customize

★ 1 — GATED_PREFIX: what's locked

const GATED_PREFIX = ['/deck', '/dataroom', '/files'];

Every path at or under these prefixes requires a code. Everything else stays public. To gate an entire site, use ['/'] — the open asset prefixes below it still stay reachable so the gate page renders.

★ 2 — BRAND: the gate page

const BRAND = {
  company: 'Acme Co',
  title:   'This deck is <em>private.</em>',
  body:    'Enter the access code you were issued.',
  placeholder: 'Access code',
  footer:  'Acme Co · Private materials',
  accent:  '#C8A24B',   // buttons + focus ring
  bg:      '#14203A',   // page background
  text:    '#F5F1E6',   // headline / body text
};

The gate is the investor's first impression — pull these three colors straight from the deck's own palette so the unlock moment feels like part of the experience, not a security speed bump. The title accepts an <em> for an italic accent word.

★ 3 — NS: the cookie namespace

const NS = 'acmedeck';

Cookie names become acmedeck_access / acmedeck_who. Make it unique per client site so two gated properties on sibling subdomains never read each other's cookies. Write this value down — Step 4's analytics config needs acmedeck_who exactly.

What you must not change

  • The /__unlock handler, hashing (sha/tokenFor), and cookie flags (HttpOnly; Secure; SameSite=None) — this is the security core.
  • The fail-closed 503 branch. It's what guarantees a misconfigured deploy locks rather than opens.
  • The safeNext() redirect guard — it prevents open-redirect tricks via the next field.
Optional tweaks that are safe

Cookie lifetime: Max-Age=2592000 is 30 days. Shorten for touchier material (e.g. 604800 = 7 days) — viewers just re-type their code more often.

SameSite: the template uses None so the site keeps working inside Notion embeds. If you'll never embed, switch both cookies to Lax for a slightly tighter posture.

Open prefixes: add /fonts/ or similar if your gate page needs them; never add a prefix that contains private content.

AI-assisted track

Prompt — install & brand the gate
Copy middleware.template.js to the repo root as middleware.js. Configure it: GATED_PREFIX = ['/deck', '/dataroom', '/files']; NS = 'acmedeck'. For the BRAND block, read deck/index.html and its CSS, and set bg, text, and accent to the deck's actual background, text, and accent colors; write gate-page copy in [Company]'s voice (headline, one-sentence body, footer line). Do not modify anything below the three ★ blocks — the unlock handler, hashing, cookie flags, and fail-closed logic stay exactly as-is. When done, list the three values you chose for the colors and where in the CSS you found them.

What the run looks like

claude — your-site/
Copy middleware.template.js to the repo root as middleware.js. Configure it: GATED_PREFIX = ['/deck', '/dataroom', '/files']…
Read(middleware.template.js)
Read(deck/index.html) · Read(css/deck.css)
Write(middleware.js) — ★ blocks configured, everything below untouched
Gate installed. Colors, as requested, from your own CSS:
bg #0E1B2E (body, deck.css:4) · text #EDF2F7 (--ink, :6) · accent #38B6C4 (--teal, :9)

The last two lines are the "show your work" clause paying off — you verify the branding in ten seconds without opening a file.

Why the prompt is built this way

Exact file operations first
"Copy X to Y as Z" leaves no room for the agent to 'improve' by rewriting the template from scratch — which is how subtle security bugs get introduced.
Config values inline
GATED_PREFIX and NS are decisions you make; putting them in the prompt keeps the agent from inventing its own.
"read deck/index.html and its CSS"
Points the agent at the source of truth for branding instead of letting it guess hex codes. Grounded inputs → grounded outputs.
"Do not modify anything below the ★ blocks"
The single most important line. It draws a hard boundary around the security-critical code and names specifics (unlock handler, hashing, cookie flags) so the constraint is checkable.
"list the values…and where you found them"
Forces the agent to show its work, so you can verify the colors came from your CSS in ten seconds.
Can't test locally: edge middleware needs Vercel's runtime — there's nothing to run on your machine. You'll prove the gate works on a preview deploy in Step 5, then run the full checklist in Step 6.
The build · Step 3

Generate codes & build the registry

Codes are the product here: each one is a person. The registry is the ledger that keeps you honest about who holds what.

Code format

ACME-7Q2F-XK9P — a brand prefix plus two 4-character random blocks. The alphabet drops I L O 0 1 so a code survives being read over the phone or squinted at in an email.

# one code per run — repeat per recipient
node -e "const a='ABCDEFGHJKMNPQRSTUVWXYZ23456789';const b=n=>[...Array(n)].map(()=>a[Math.floor(Math.random()*a.length)]).join('');console.log('ACME-'+b(4)+'-'+b(4))"

The last 4 characters are the join key. "XK9P" is what appears in analytics and in the registry's Ref column. If two generated codes ever share a last-4 (rare), regenerate one.

Interface guide: the Notion registry, click by click

Create the database.

In Notion: new page → type /database → choose Database — Full page. Title it "[Company] — Access Registry".

Rename the title property to "Recipient".

Click the Name column header → Rename. This holds the person's name.

Add the remaining properties.

Click + at the end of the header row for each: Firm (Text) · Code (Text) · Ref (Text) · Status (Select, with options Active and Revoked) · Issued (Date) · Sent via (Select: Email / Text / In person) · Notes (Text).

Color the Status options.

Open the Status property → click each option's → green for Active, red for Revoked. Makes the board scannable at a glance.

Add a "Currently active" view.

Click + Add view → Table → filter Status is Active. This filtered view is your at-a-glance answer to "who can see the deck right now?"

Lock down sharing.

This database holds live secrets — the full codes. Share it with your team only; never with anyone who receives a code.

Wire the codes to the site: the env var

The middleware reads one environment variable. Format: comma-separated entries, each CODE or CODE:Display Name. Always include the name — it's what turns analytics from hashes into people:

DECK_ACCESS_CODES = ACME-7Q2F-XK9P:Jane Smith — Blue Harbor, ACME-M3RV-T8LW:Meridian Capital

You'll paste this into Vercel in Step 5. The env var is the enforcement; the Notion registry is the record. Keep them in sync — every issue and every revoke touches both.

AI-assisted track

Prompt — registry + first batch of codes (Notion connected)
Create a Notion database called "[Company] — Access Registry" with properties: Recipient (title), Firm (text), Code (text), Ref (text), Status (select: Active, Revoked), Issued (date), Sent via (select: Email, Text, In person), Notes (text). Then generate 5 access codes with prefix ACME- : two blocks of 4 characters from the alphabet ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no I, L, O, 0, 1), rejecting any duplicate last-4. Add each as a row: Recipient "Unassigned", Status Active, Issued today, Ref = the code's last 4 characters. Finally, print the complete DECK_ACCESS_CODES env-var value on one line so I can paste it into Vercel, using "CODE:Unassigned" for each until I rename recipients.

What the run looks like

claude — Notion connected
Create a Notion database called "Acme — Access Registry" with properties: Recipient (title), Firm (text)…
notion-create-database("Acme — Access Registry") — 8 properties
Generated 5 codes — verified all last-4 refs unique
notion-create-pages(5 rows · Status: Active · Issued: today)
Registry live. Paste this into Vercel → Settings → Environment Variables:
DECK_ACCESS_CODES=ACME-7Q2F-XK9P:Unassigned, ACME-M3RV-T8LW:Unassigned, ACME-9WCH-R4NB:Unassigned, …

The run ends exactly where your next manual action begins — one line, ready to paste.

Why the prompt is built this way

Full schema spelled out
Property names and types are an interface other steps depend on ("Ref" is the analytics join key). Naming them exactly beats "make a tracking database."
The alphabet, written out
"No ambiguous characters" is interpretable; the literal 31-character string isn't. When a detail has a correctness consequence, inline it.
"rejecting any duplicate last-4"
Encodes the join-key uniqueness rule so the agent enforces it at generation time instead of you discovering a collision in analytics later.
"print the…value on one line"
Ends the task with the exact artifact you need next (paste into Vercel). A prompt should finish where your next manual action begins.
Issuing etiquette: send the link and the code in separate channels — link by email, code by text or voice. A forwarded email then contains nothing that opens the deck by itself.
The build · Step 4

Wire the analytics

One script — analytics.template.js — that does nothing on public pages and everything on gated ones.

Interface guide: PostHog account & project, click by click

Create the account.

Go to posthog.com → Get started free. Pick US Cloud (or EU if the client needs EU data residency — note which one, the script config differs).

Create a project per client site.

One PostHog project = one deck site. In the top-left project switcher → New project, name it after the domain (e.g. "acme-deck").

Skip the install wizard.

PostHog will offer snippets and SDKs — skip it all. The template already contains the loader; you only need the key.

Copy the Project API key.

Settings → Project → "Project API key" — starts with phc_. This key is meant to be public in page source; it can only send events, not read them.

Optional hygiene: filter your own traffic.

Settings → Project → Filter out internal users — add your own person profile once you've tested, so your rehearsal clicks don't pollute investor stats.

Configure the template

Copy analytics.template.js to js/analytics.js and set the placeholders at the top:

var WHO_COOKIE   = 'acmedeck_who';   // = NS + '_who' from Step 2 — must match exactly
var POSTHOG_KEY  = 'phc_XXXXXXXX';   // from the PostHog project settings
var ID_PREFIX    = 'acme-';          // distinct-id prefix, unique per client

EU cloud? Also change api_host to https://eu.i.posthog.com. Then include the script on every gated page, just before </body>:

<script src="/js/analytics.js" defer></script>

Tag your content for named events

data-slide → slide_viewed

On each slide's container in a single-page deck: <section data-slide="03 — Market">. Fires once per slide per visit when 60% of it is on screen. This is your drop-off curve.

data-doc → document_opened

On any link or button: <a data-doc="Financial Model v2">. Direct links to .pdf/.xlsx/.pptx/.zip fire automatically; the attribute just gives a clean label.

Everything else is free: pageviews, time-on-page (via pageleave), and every click (autocapture) — all stamped with viewer and code_ref.

AI-assisted track

Prompt — install analytics & tag content
Copy analytics.template.js to js/analytics.js. Set WHO_COOKIE to 'acmedeck_who', POSTHOG_KEY to 'phc_XXXX', ID_PREFIX to 'acme-'. Keep the api_host as US cloud. Then: (1) add <script src="/js/analytics.js" defer></script> before </body> on every HTML page under /deck and /dataroom — list each file you touched; (2) in deck/index.html, add data-slide to each slide's top-level section, labeled "NN — Title" using the slide's number and visible heading; (3) in the data room, add data-doc to every document link using its visible title. Don't add tracking to any page outside the gated paths, and don't modify the template's self-gating check at the top.

What the run looks like

claude — your-site/
Copy analytics.template.js to js/analytics.js. Set WHO_COOKIE to 'acmedeck_who'…
Write(js/analytics.js) — 3 placeholders set, self-gating check untouched
Edit(deck/index.html) — 12 sections tagged data-slide="01 — Vision" … "12 — The Ask"
Edit(dataroom/index.html) + 8 doc pages — script tag + data-doc labels
10 files touched: deck/index.html, dataroom/index.html, dataroom/docs/… (full list below)
Skipped index.html (public landing) and privacy.html — outside gated paths, per instructions.

The file list is your coverage check — diff it against the sitemap and an analytics hole becomes impossible to miss.

Why the prompt is built this way

Placeholder values up front
The three config values are the only degrees of freedom; pinning them makes the rest mechanical.
"list each file you touched"
Coverage verification. A gated page missing the script is an invisible analytics hole — the file list lets you diff against your sitemap in seconds.
Label format specified ("NN — Title")
Slide labels sort correctly in PostHog only if they're consistently prefixed. Format decisions belong in the prompt, not the agent's imagination.
"Don't add tracking outside the gated paths"
Preserves the privacy architecture. The script self-gates anyway (defense in depth), but the tag shouldn't even exist on public pages.
"don't modify the self-gating check"
Same principle as Step 2: name the untouchable code explicitly.
The build · Step 5

Deploy on Vercel

GitHub in, live site out. The two parts people fumble are the env var and the redeploy rule — both covered below.

First: vercel.json

Drop this at the repo root (adjust the gated-path regex to your prefixes):

{
  "cleanUrls": true,
  "trailingSlash": false,
  "headers": [
    {
      "source": "/(deck|dataroom|files)/(.*)",
      "headers": [{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }]
    },
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Content-Security-Policy", "value": "frame-ancestors 'self' https://*.notion.so https://*.notion.site" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
      ]
    }
  ]
}

cleanUrls serves /deck instead of /deck/index.html; the noindex header keeps private paths out of search results even if a gate ever misfires; frame-ancestors permits Notion embeds (tighten to 'none' if you don't embed).

Interface guide: Vercel, click by click

Push the repo to GitHub.

Private repo is fine (and typical). Everything Vercel needs comes through the Git integration.

Import into Vercel.

vercel.com → Add New… → Project → Import your repo. Framework preset: Other. Build command: empty. Output directory: empty (static files at root). Click Deploy.

Add the env var.

Project → Settings → Environment Variables. Key: DECK_ACCESS_CODES. Value: the one-line list from Step 3. Environments: check all three (Production, Preview, Development). Save.

Redeploy to activate it.

Deployments tab → latest deployment → ⋯ → Redeploy. Env-var changes never apply to already-built deployments — every add/remove of a code ends with this click. Takes ~30 seconds for a static site.

Attach the domain.

Settings → Domains → add deck.yourco.com. Vercel shows the DNS record to create — usually a CNAME from deck to cname.vercel-dns.com at your DNS host. Propagation is typically minutes.

Sanity-check the gate.

Open the domain in a private window — you should hit your branded gate, not the deck. If you see the deck, the middleware isn't running: confirm middleware.js is at the repo root (not in a subfolder) and redeploy.

Windows/PowerShell users: if you ever script the env var with the Vercel CLI, do it from bash — printf '%s' "$CODES" | vercel env add DECK_ACCESS_CODES production. Piping from PowerShell prepends an invisible BOM character that corrupts the value and produces codes that "look right" but never match. The dashboard paste is immune; when in doubt, use the dashboard.

AI-assisted track

Prompt — pre-deploy review
Before I import this repo into Vercel, review it for deploy-readiness: (1) confirm middleware.js is at the repo root and its GATED_PREFIX list matches the actual folders containing private content — list any HTML/PDF/XLSX file with sensitive content that is NOT under a gated prefix; (2) create vercel.json with cleanUrls, trailingSlash false, X-Robots-Tag noindex on the gated paths, and Referrer-Policy strict-origin-when-cross-origin; (3) check every gated page includes /js/analytics.js and every asset reference is absolute; (4) give me the exact Vercel import settings (framework preset, build command, output dir) and the env var I need to set. Report as a checklist with pass/fail per item.

What the run looks like

claude — pre-deploy review
Before I import this repo into Vercel, review it for deploy-readiness…
Read(middleware.js) — GATED_PREFIX vs. actual folder tree
Grep(\.pdf|\.xlsx across all HTML) · Grep(analytics.js includes)
PASS — gate covers /deck /dataroom /files · PASS — analytics on 10/10 gated pages · PASS — all asset paths absolute
FAIL — files/one-pager.pdf is linked from the public landing page. Move the link behind the gate, or accept that this one file is public.
Import settings: preset Other · build command empty · output dir empty · env DECK_ACCESS_CODES

A review prompt earns its keep on the FAIL line — this is the leak you'd otherwise find out about from an investor.

Why the prompt is built this way

Framed as a review, not a build
By this step the pieces exist; the risk is inconsistency between them. A review prompt catches cross-step drift (gate list vs. real folders) that no single build prompt could.
"list any…file NOT under a gated prefix"
Inverts the check to hunt for the dangerous case directly. "Confirm it's fine" invites a yes; "find what's exposed" invites a search.
"Report as a checklist with pass/fail"
Structured output you can act on line by line — and a natural place for the agent to attach evidence per item.
The build · Step 6

Verify before the first send

Run every check in a private/incognito window against the live domain. This list saves your progress in this browser — check items off as you prove them.

0 of 10 verified
All ten green? The system is live. Issue your first real code (Operations has the runbook) and send it. If one fails, the matching build step names the fix — most failures are a path mismatch between GATED_PREFIX, the real folders, and vercel.json.
Operations

Day-to-day runbooks

Three procedures cover the entire operating life of the system. Each takes a minute or two.

Runbook: issue a code

Generate.

Run the Step 3 one-liner (or pull an "Unassigned" row from the registry batch).

Record.

Registry row: Recipient, Firm, full Code, Ref (last 4), Status Active, Issued today, Sent via, any context in Notes.

Enable.

Vercel → Settings → Environment Variables → edit DECK_ACCESS_CODES → append , CODE:Name — Firm → Save → Deployments → Redeploy.

Send — in two channels.

Link by email ("our materials are at deck.yourco.com/deck"), code by text or voice. A forwarded email alone opens nothing.

Runbook: revoke a code

Remove.

Delete that one entry from DECK_ACCESS_CODES in Vercel. Leave everyone else's untouched.

Redeploy.

Deployments → ⋯ → Redeploy. ~30 seconds later the code and its existing cookies are dead — validation happens against the live list on every request.

Record.

Flip the registry row to Revoked with a note. The analytics history stays — often useful later.

Runbook: read the analytics

PostHog's Persons tab is the front door — each viewer is a named profile with their full history. Beyond that, build these four saved insights once (New insight → Trends, then configure):

InsightConfigurationQuestion it answers
Engagement this weekSeries: Pageviews · breakdown by viewer · last 7 daysWho's active right now — and who went quiet after the partner meeting
Hottest documentsSeries: document_opened · breakdown by documentWhich materials actually get read (spoiler: the model, always the model)
Deck drop-offFunnel: slide_viewed steps in slide orderWhere attention dies — the slide to fix before the next send
Depth per viewerSeries: session duration (average) · breakdown by viewerSkimmers vs. divers — calibrates your follow-up
Prompt — weekly engagement digest
Using my PostHog project, summarize investor engagement for the past 7 days: for each viewer — sessions, total time, pages, documents opened (by name), and deepest slide reached. Flag anyone whose activity spiked or stopped versus the prior week, and anyone who opened the financial model more than once. Format as a short brief I can skim before my Monday pipeline review.
Why it works: it names the exact per-viewer metrics (so nothing is averaged into mush), asks for week-over-week changes (the signal), and specifies the delivery format and audience — a skimmable Monday brief, not a data dump.
Escalation heuristic: a second visit to the financial model within 48 hours of a partner meeting is the highest-intent signal this system produces. That's a call, not an email.
Operations

How every prompt in this guide is built

The per-step prompts follow one repeatable pattern. Learn it once and you can write the prompt for any variation of this system — different host, different analytics tool, different registry.

The five-part pattern

1 · Goal + context
One sentence on what you're making and why: "…for private hosting", "Before I import this repo into Vercel…". Agents choose better defaults when they know the purpose, not just the mechanics.
2 · Grounded inputs
Point at exact files and sources of truth: "read deck/index.html and its CSS". Never let the agent guess something it could read.
3 · Pinned decisions
Every value that is your call goes in literally: folder names, namespaces, alphabets, label formats. Freedom left in a prompt is a decision delegated — delegate style, never interfaces.
4 · Hard boundaries
Name what must not change, specifically: "Do not modify…the unlock handler, hashing, cookie flags." Boundaries are most valuable around security code, where an agent's helpful 'improvement' is your next vulnerability.
5 · Verifiable output
End with an artifact that lets you check the work fast: a before→after tree, a list of files touched, a pass/fail checklist, a one-line env value. If you can't verify it in under a minute, the prompt didn't finish its job.

Worked example: adapting to a different stack

Suppose a client hosts on Netlify instead of Vercel. Walk the pattern:

  1. Goal + context: same system, different host — say so: "Port this Vercel Edge Middleware gate to a Netlify Edge Function."
  2. Grounded inputs: "The existing gate is middleware.js — preserve its behavior exactly."
  3. Pinned decisions: "Config lives in netlify.toml; the env var stays DECK_ACCESS_CODES; gated paths stay /deck, /dataroom, /files."
  4. Hard boundaries: "Keep the hashing, cookie flags, fail-closed 503, and safeNext guard byte-for-byte in logic."
  5. Verifiable output: "List every behavioral difference between the two platforms you had to account for."

The one-shot prompt (full build)

When the repo already has the two templates in it, the whole build compresses into one prompt — it's the five-part pattern applied at project scale:

Prompt — build everything
I have a static HTML pitch deck in this repo to host privately on Vercel with per-recipient access codes and named analytics. The two templates (middleware.template.js, analytics.template.js) are in the repo root. 1. Install middleware.template.js as middleware.js at the root. GATED_PREFIX = ['/deck','/dataroom','/files']; NS = '[name]deck'. Brand the gate from my deck's actual CSS colors. 2. Install analytics.template.js as js/analytics.js with WHO_COOKIE '[name]deck_who', POSTHOG_KEY '[phc_…]', ID_PREFIX '[name]-'. Add the script tag to every gated page; tag slides with data-slide ("NN — Title") and data-room links with data-doc. 3. Create vercel.json: cleanUrls, trailingSlash false, noindex on gated paths, Referrer-Policy strict-origin-when-cross-origin. 4. Generate 5 codes with prefix [NAME]- (alphabet ABCDEFGHJKMNPQRSTUVWXYZ23456789, unique last-4) and print the DECK_ACCESS_CODES value on one line. 5. Do not modify the templates' security logic: unlock handler, hashing, cookie flags, fail-closed branch, self-gating check. 6. Finish with: files created/modified, the env value, my exact Vercel import settings, and the 10-point verification checklist I should run on the preview deploy.
Why it still works at this size: every line is one of the five parts. Items 1–4 are pinned decisions with grounded inputs; item 5 is the boundary; item 6 is the verifiable output. Nothing is left to taste except gate-page copy — which is the one place taste belongs.

When a prompt goes sideways

  • The agent rewrote something you didn't ask about → your boundary was implied, not stated. Add a "do not modify" naming the specific functions.
  • Output looks right but breaks → you skipped part 5. Re-run asking only for the verification artifact and check it against reality.
  • It asked you questions you'd already decided → pinned decisions were missing. Fold your answers back into the prompt so the next run is one-shot.
  • It guessed a color/name/path → no grounded input. Point at the file that holds the truth.
Operations

The ready-to-paste setup script

Everything in this guide, compressed into a single message. Hand this to anyone with Claude Code and an HTML deck — they paste it once, answer six questions, and Claude builds, deploys, and verifies the whole system with them.

How to use it

Open Claude Code in the deck repo.

The client runs claude from the root of the repo that contains (or will contain) their HTML deck. Empty repo is fine — the script handles that.

Paste the entire script as one message.

Copy button below grabs all of it — both file templates are embedded verbatim, so nothing else needs to be downloaded or attached.

Answer the Phase 0 interview.

Six questions: company/slug, gated paths, gate colors, code prefix, PostHog key (or skip), Notion available or not. Claude waits for the answers before touching anything.

Ride along through deploy and verify.

Phases 5–6 are interactive by design — Claude walks the Vercel dashboard steps and the 10-point checklist one at a time, waiting for confirmation, and fixes failures as they appear.

Why the script is shaped this way

Interview before action (Phase 0)
All six decisions that are the client's to make get pinned in one round-trip. Nothing gets built on a guess, and the answers parameterize every later phase.
Templates embedded verbatim
The receiving Claude Code has none of our files, so the script is the file transfer. "Reproduce exactly, substitute only marked values" turns generation — where security bugs creep in — into transcription.
Ground rules up top
The hard boundaries (don't touch the security core, codes never enter committed files, ask instead of guessing) apply to every phase, so they're stated once, first, where they govern everything after.
Phase gates with summaries
"End every phase with a 2–4 line summary" keeps the human in the loop at each seam — the places where a misunderstanding would otherwise compound silently.
"Done means" at the end
An explicit definition of done stops the agent from declaring victory at "files created" when the actual finish line is "all ten checks green on the live domain, plus an ops cheat-sheet."

The script

Full setup script — copy everything
# Private Deck Hosting — Claude Code setup script

*(To the human: open Claude Code in the root of the repo that contains — or will contain — your HTML pitch deck, paste this entire document as one message, and answer the interview questions. Everything below is addressed to Claude Code.)*

---

You are setting up a **private pitch-deck hosting system** in this repo: static HTML served on Vercel behind per-recipient access codes (edge middleware), with viewer-attributed analytics (PostHog) and an access registry. Work through the phases below **in order**. Complete the Phase 0 interview before creating or modifying anything.

## Ground rules (apply to every phase)

- The two file templates embedded below are security-reviewed and battle-tested. Reproduce them **verbatim except the clearly marked config values**. Do not improve, reformat, refactor, or "modernize" anything else in them — especially the `/__unlock` handler, the SHA-256 hashing, the cookie flags (`HttpOnly; Secure; SameSite=None`), the fail-closed 503 branch, the `safeNext()` redirect guard, and the analytics self-gating check.
- **Access codes are secrets.** Print them in chat, but never write them into any file that will be committed to this repo — no examples in READMEs, no seed files. If you produce a registry CSV, either place it outside the repo or add it to `.gitignore` in the same change.
- If the repo's actual structure doesn't match an assumption in this script, **ask me — don't guess**. Same for any file you can't confidently classify as public or private.
- End every phase with a 2–4 line summary of what you did before starting the next.

## Phase 0 — Interview

Ask me all of the following in **one message**, then wait for my answers:

1. **Company name and a short lowercase slug** (example: "Acme Co" / `acme`).
2. **Which paths hold private content?** First list this repo's top-level folders and files as you see them, then propose a gated-path plan (deck? data room? downloadable files?). If the repo is empty, propose `/deck`, `/dataroom`, `/files`.
3. **Gate-page colors** — three hex values (background, text, accent) — or tell me you'll pull them from the deck's own CSS (say which file you'd read).
4. **Code prefix and batch size** — default: the slug uppercased, 5 codes.
5. **PostHog project API key** (starts `phc_`) and cloud (US or EU) — or "skip analytics for now".
6. Whether you can reach **Notion** from this session (check your available tools and say so) — if yes, you'll create the registry database there; if no, you'll output a CSV I can import anywhere.

## Phase 1 — The access gate

Create `middleware.js` at the repo **root** with exactly the content below, substituting only:
- `GATED_PREFIX` — from my answer to Q2
- the `BRAND` block — company name and copy from Q1, colors from Q3
- `NS` — my slug + `deck` (e.g. `acmedeck`)

```js
// ============================================================================
// PRIVATE DECK GATE — Vercel Edge Middleware
// ----------------------------------------------------------------------------
// How it works:
//   • Viewers unlock with a personal access code you issue them.
//   • Codes live ONLY in one env var (never in source):
//       DECK_ACCESS_CODES = "ACME-7Q2F-XK9P:Jane Smith, ACME-M3RV-T8LW:Blue Harbor Capital"
//     Each entry is CODE or CODE:Display Name. Any listed code unlocks;
//     deleting a code from the list (and redeploying) revokes that viewer.
//   • On unlock we set TWO cookies:
//       1. `<NS>_access` — an HttpOnly SHA-256 token proving access (JS can't read it)
//       2. `<NS>_who`    — a READABLE identity cookie carrying only a display
//          name + the last 4 chars of the code (never the full code), so the
//          analytics script can attribute activity to a person.
//   • The gate fails CLOSED (503) if the env var is missing.
//   • Static assets stay open so the gate page and link previews render.
// ============================================================================

function env(k){ return (typeof process !== 'undefined' && process.env && process.env[k]) || null; }

// ★ 1. WHAT IS GATED — every path under these prefixes requires a code.
const GATED_PREFIX = ['/deck', '/dataroom', '/files'];

// Open: link-preview files + static-asset prefixes (needed by the gate page itself).
const OPEN_EXACT  = new Set(['/favicon.ico', '/favicon.svg', '/apple-touch-icon.png', '/robots.txt']);
const OPEN_PREFIX = ['/css/', '/js/', '/img/', '/fonts/', '/assets/'];

// ★ 2. BRANDING for the gate page.
const BRAND = {
  company: 'Acme Co',
  title:   'This deck is <em>private.</em>',
  body:    'Enter the access code you were issued. Each code is unique to one recipient.',
  placeholder: 'Access code',
  footer:  'Acme Co · Private materials',
  accent:  '#C8A24B',   // buttons + focus ring
  bg:      '#14203A',   // page background
  text:    '#F5F1E6',   // headline / body text
};

// ★ 3. COOKIE + TOKEN NAMESPACE — unique per site so gated sites never collide.
const NS = 'deckgate';
const COOKIE = { access: NS + '_access', who: NS + '_who' };
const SALT = NS + '|v1|';

// ---------------------------------------------------------------------------
// Parse DECK_ACCESS_CODES → [{code, name}]
const ENTRIES = (env('DECK_ACCESS_CODES') || '')
  .split(',').map(s => s.trim()).filter(Boolean)
  .map(entry => {
    const i = entry.indexOf(':');
    const code = (i === -1 ? entry : entry.slice(0, i)).trim();
    const name = (i === -1 ? '' : entry.slice(i + 1)).trim();
    return { code, name };
  })
  .filter(x => x.code);
const CODES = ENTRIES.map(x => x.code);
const NAME_OF = {};
ENTRIES.forEach(x => { NAME_OF[x.code] = x.name; });

// Readable identity payload: display name + non-secret last-4 ref. Never the code.
function whoFor(code){
  const last4 = code.slice(-4);
  return JSON.stringify({ n: NAME_OF[code] || ('Viewer ' + last4), c: last4 });
}

async function sha(s){
  const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
  return [...new Uint8Array(d)].map(b => b.toString(16).padStart(2, '0')).join('');
}
const tokenFor = (code) => sha(SALT + code);

function getCookie(req, name){
  const m = (req.headers.get('cookie') || '').match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
  return m ? m[1] : null;
}
function safeNext(v){ return (typeof v === 'string' && v.startsWith('/') && !v.startsWith('//')) ? v : '/'; }
function normalize(p){ return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p; }
function underAny(path, prefixes){ return prefixes.some(p => path === p || path.startsWith(p + '/')); }

// 30-day cookies. SameSite=None keeps embeds (e.g. Notion) working; requires Secure.
const COOKIE_TAIL = 'Path=/; Max-Age=2592000; Secure; SameSite=None';
const accessCookie = (token) => `${COOKIE.access}=${token}; ${COOKIE_TAIL}; HttpOnly`;
const whoCookie    = (code)  => `${COOKIE.who}=${encodeURIComponent(whoFor(code))}; ${COOKIE_TAIL}`;

// Returns the code whose token matches the access cookie, or null.
async function matchedCode(req){
  if (!CODES.length) return null;
  const c = getCookie(req, COOKIE.access);
  if (!c) return null;
  for (const code of CODES) if (c === await tokenFor(code)) return code;
  return null;
}

export default async function middleware(req){
  const url = new URL(req.url);
  const path = normalize(url.pathname);

  // 1 · open assets / previews
  if (OPEN_EXACT.has(path) || OPEN_PREFIX.some(p => path.startsWith(p))) return;

  // 2 · unlock endpoint (must work with no cookie)
  if (req.method === 'POST' && path === '/__unlock'){
    const form = await req.formData().catch(() => null);
    const secret = ((form && form.get('password')) || '').trim();
    const next = safeNext(form && form.get('next'));
    if (CODES.includes(secret)){
      const headers = new Headers({ 'Cache-Control': 'no-store', 'Location': next });
      headers.append('Set-Cookie', accessCookie(await tokenFor(secret)));
      headers.append('Set-Cookie', whoCookie(secret));
      return new Response(null, { status: 303, headers });
    }
    return gatePage(next, true);
  }

  // 3 · only gate the configured paths
  if (!underAny(path, GATED_PREFIX)) return;

  // 4 · valid cookie → in. Self-heal the identity cookie if it's missing.
  const code = await matchedCode(req);
  if (code){
    if (req.method === 'GET' && !getCookie(req, COOKIE.who)){
      const headers = new Headers({ 'Cache-Control': 'no-store', 'Location': path + url.search });
      headers.append('Set-Cookie', whoCookie(code));
      return new Response(null, { status: 303, headers });
    }
    return;
  }

  // 5 · fail closed if unconfigured, else show the gate
  if (!CODES.length){
    return new Response('Access gate not configured (DECK_ACCESS_CODES env var is missing). Set it in your Vercel project settings and redeploy.', {
      status: 503,
      headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' },
    });
  }
  return gatePage(path + url.search, false);
}

function gatePage(next, failed){
  const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
  const html = `<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(BRAND.company)} — Private access</title>
<link rel="icon" href="/favicon.ico">
<style>
  :root{--accent:${BRAND.accent};--bg:${BRAND.bg};--text:${BRAND.text};
        --line:color-mix(in srgb, var(--text) 14%, transparent);
        --muted:color-mix(in srgb, var(--text) 56%, transparent)}
  *{box-sizing:border-box;margin:0;padding:0}
  body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);
       font-family:system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--text);padding:24px}
  .card{width:100%;max-width:420px;text-align:center;border:1px solid var(--line);
       border-radius:6px;padding:52px 40px 44px;background:color-mix(in srgb, var(--text) 4%, transparent)}
  h1{font-weight:500;font-size:1.6rem;line-height:1.25}
  h1 em{font-style:italic;color:var(--accent)}
  p{color:var(--muted);font-weight:300;font-size:.95rem;margin-top:14px}
  form{margin-top:30px;display:flex;flex-direction:column;gap:12px}
  input{background:color-mix(in srgb, var(--text) 5%, transparent);border:1px solid var(--line);color:var(--text);
       padding:14px 16px;border-radius:4px;font-size:15px;text-align:center;letter-spacing:.08em}
  input:focus{outline:none;border-color:var(--accent)}
  button{font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;font-weight:600;color:var(--bg);
       background:var(--accent);border:none;padding:14px;border-radius:4px;cursor:pointer}
  button:hover{filter:brightness(1.1)}
  .err{color:var(--accent);font-size:.85rem;margin-top:4px;${failed ? '' : 'display:none'}}
  .foot{margin-top:30px;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
</style></head>
<body>
  <main class="card">
    <h1>${BRAND.title}</h1>
    <p>${BRAND.body}</p>
    <form method="POST" action="/__unlock">
      <input type="hidden" name="next" value="${esc(next)}">
      <input type="password" name="password" placeholder="${esc(BRAND.placeholder)}" autofocus autocomplete="current-password" aria-label="${esc(BRAND.placeholder)}">
      <button type="submit">Enter</button>
      <div class="err">That didn&rsquo;t match &mdash; try again.</div>
    </form>
    <div class="foot">${BRAND.footer}</div>
  </main>
</body></html>`;
  return new Response(html, {
    status: 401,
    headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' },
  });
}
```

Also confirm the repo layout matches the plan from Q2: private HTML and every downloadable PDF/XLSX/PPTX under gated prefixes, shared assets under the open prefixes, all internal references using **absolute** paths (`/css/...`, not `../css/...`). Move files and rewrite references if needed, showing me a before→after tree.

## Phase 2 — Analytics (skip cleanly if I said "skip": leave a `TODO-analytics.md` note at the repo root and move on)

Create `js/analytics.js` with exactly the content below, substituting only:
- `WHO_COOKIE` — my NS + `_who` (must match Phase 1 exactly)
- `POSTHOG_KEY` — my key from Q5
- `ID_PREFIX` — my slug + `-`
- `api_host` — change to `https://eu.i.posthog.com` only if I said EU

```js
/* ============================================================================
   VIEWER ANALYTICS (PostHog)
   ----------------------------------------------------------------------------
   SELF-GATING: runs ONLY when the readable identity cookie (set by the access
   gate on a valid code unlock) is present. Public pages are never tracked.
   PRIVACY: the identity cookie carries only a display name + the last 4 chars
   of the code — never the full code. Session replay is OFF. The password gate
   itself carries no analytics, so an access code can never be captured.
   ========================================================================== */
(function () {
  var WHO_COOKIE = '__COOKIE_NAME__';
  var POSTHOG_KEY = '__POSTHOG_PROJECT_KEY__';
  var ID_PREFIX = 'dg-';

  function cookie(name) {
    var m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
    return m ? decodeURIComponent(m[1]) : null;
  }

  var raw = cookie(WHO_COOKIE);
  if (!raw) return; // not an identified viewer → do nothing

  var who;
  try { who = JSON.parse(raw); } catch (e) { who = { n: raw, c: '' }; }
  var name = (who && who.n) || 'Viewer';
  var ref  = (who && who.c) || '';
  var distinctId = ID_PREFIX + String(ref || name).toLowerCase().replace(/[^a-z0-9]+/g, '-');

  /* ---- PostHog loader (official snippet) ---- */
  !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document, window.posthog || []);

  posthog.init(POSTHOG_KEY, {
    api_host: 'https://us.i.posthog.com',
    person_profiles: 'identified_only',
    capture_pageview: true,
    capture_pageleave: true,
    autocapture: true,
    disable_session_recording: true
  });

  posthog.identify(distinctId, { name: name, code_ref: ref });
  posthog.register({ viewer: name, code_ref: ref });

  /* ---- Named document-open / download events ---- */
  document.addEventListener('click', function (ev) {
    var sel = '[data-doc], a[download], a[href$=".pdf"], a[href$=".xlsx"], ' +
              'a[href$=".csv"], a[href$=".pptx"], a[href$=".docx"], a[href$=".zip"]';
    var el = ev.target.closest(sel);
    if (!el) return;
    var href = el.getAttribute('href') || '';
    var kind = /\.xlsx|\.csv/i.test(href) ? 'spreadsheet'
             : /\.pptx/i.test(href) ? 'deck'
             : /\.docx/i.test(href) ? 'document'
             : /\.zip/i.test(href) ? 'archive'
             : /\.pdf/i.test(href) ? 'pdf' : 'page';
    posthog.capture('document_opened', {
      document: String(el.getAttribute('data-doc') || el.textContent || href).replace(/\s+/g, ' ').trim(),
      href: href,
      kind: kind,
      area: location.pathname
    });
  }, true);

  /* ---- OPTIONAL: per-slide view tracking for single-page decks ---- */
  var slides = document.querySelectorAll('[data-slide]');
  if (slides.length && 'IntersectionObserver' in window) {
    var seen = {};
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (en) {
        if (!en.isIntersecting) return;
        var label = en.target.getAttribute('data-slide');
        if (seen[label]) return;
        seen[label] = true;
        posthog.capture('slide_viewed', { slide: label, area: location.pathname });
      });
    }, { threshold: 0.6 });
    slides.forEach(function (el) { io.observe(el); });
  }
})();
```

Then:
1. Add `<script src="/js/analytics.js" defer></script>` immediately before `</body>` on **every** HTML page under the gated paths — list each file you touched, and confirm no page outside the gated paths got the tag.
2. If the deck is a single page, add `data-slide="NN — Title"` to each slide's top-level container, using the slide number and its visible heading.
3. In the data room, add `data-doc="Document Title"` to every document link, using the visible title.

## Phase 3 — vercel.json

Create `vercel.json` at the root (merge carefully if one already exists), adapting the gated-path pattern to my prefixes:

```json
{
  "cleanUrls": true,
  "trailingSlash": false,
  "headers": [
    {
      "source": "/(deck|dataroom|files)/(.*)",
      "headers": [{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }]
    },
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
      ]
    }
  ]
}
```

## Phase 4 — Codes and the registry

1. Generate the batch from Q4: format `PREFIX-XXXX-XXXX`, each X drawn from the alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (no I, L, O, 0, 1). Regenerate any code whose last-4 duplicates another — the last 4 characters are the analytics join key and must be unique.
2. **If Notion is available:** create a database named "«Company» — Access Registry" with properties: Recipient (title), Firm (text), Code (text), Ref (text), Status (select: Active, Revoked), Issued (date), Sent via (select: Email, Text, In person), Notes (text). Add one row per code: Recipient "Unassigned", Ref = the code's last 4, Status Active, Issued today.
   **If not:** print the same as CSV for me to import into Notion/Sheets/Excel — and do not leave the CSV in the repo unless you gitignore it.
3. Print the env value on one line, ready to paste:
   `DECK_ACCESS_CODES=CODE1:Unassigned, CODE2:Unassigned, ...`

## Phase 5 — Deploy walkthrough

Guide me interactively, one step at a time, waiting for my confirmation after each:

1. Commit and push to GitHub (private repo is fine).
2. Vercel → Add New → Project → import the repo. Framework preset **Other**, build command **empty**, output directory **empty**. Deploy.
3. Project → Settings → Environment Variables → add `DECK_ACCESS_CODES` with the Phase 4 value, all environments.
4. Deployments → ⋯ → **Redeploy** — env-var changes never apply to existing builds. (If I'm on Windows and want to script this instead: warn me that piping values into `vercel env add` from PowerShell adds an invisible BOM that corrupts them — use the dashboard or bash `printf`.)
5. Settings → Domains → attach my subdomain; give me the DNS record to create.

## Phase 6 — Verification

Walk me through these one at a time against the live domain (I'll use an incognito window) and record pass/fail. Diagnose and fix anything that fails before moving on:

1. Gated path shows the branded gate, not content.
2. A wrong code shows the inline error and stays locked.
3. A valid code lands me on the page I originally requested.
4. Access survives closing and reopening the tab.
5. A direct URL to a gated PDF is gated too.
6. The gate page renders fully (CSS, favicon) while locked out.
7. My test visit appears in PostHog with the right name and code_ref (skip if analytics skipped).
8. The public landing page sends zero PostHog events.
9. Tagged document/slide events fire with my labels (skip if analytics skipped).
10. Removing my test code from the env var + redeploy locks me out despite the old cookie.

## Done means

Gate live and branded · every private file gated · analytics attributed (or cleanly skipped with a TODO) · registry populated · env var set · all applicable checks green. Finish by giving me a short ops cheat-sheet in chat: how to issue a code, how to revoke one (remove from env var → redeploy → mark Revoked in registry), and where to read the analytics (PostHog → Persons).

Also in the kit as SETUP-SCRIPT.md if you'd rather email the file.

What the client still does by hand: creating their Vercel / PostHog / Notion accounts and clicking through their own dashboards (Phases 5–6 guide them). The script deliberately never asks them to paste account passwords or handle secrets outside their own browser.
Operations

Extensions, and what to promise clients

Where the base system goes next, and the honest boundaries to state up front.

The security model in one paragraph

This is investor-grade access control, not DRM. It defends against forwarded links, search indexing, ex-prospects with indefinite access, and "did they even read it?" It does not stop a legitimate viewer from screenshotting, downloading, or sharing what they can see — nothing that requires zero-install viewing can. Shared codes leave a fingerprint (two device patterns on one profile) and are killed by rotating one code. State this plainly to clients; the confident version of this pitch beats the overclaimed one every time it's tested.

One more line worth adding to the gate page for identified-analytics jurisdictions: "This data room logs access." Cheap insurance, and it reads as diligence-grade seriousness rather than surveillance.

Extensions, in the order clients ask for them

Slack alert when a hot document opens

PostHog → Data pipelines → Destinations → Slack: trigger on document_opened, filter document = Financial Model, template the message with {person.properties.name}. Five minutes, no code, and it's the feature clients show their partners.

Per-code expiry dates

Extend the env format to CODE:Name:2026-09-01 and have the middleware's entry parser compare a third segment against today's date, treating expired codes as absent. ~10 lines in the parsing block (safe to modify — it's above the security core). Prompt: "Add optional expiry to the env format CODE:Name:YYYY-MM-DD — expired codes behave exactly as if removed from the list. Change only the ENTRIES parsing block; show me the diff."

Viewer-name watermark on deck pages

A tiny script reads the (readable) identity cookie and fixes a faint diagonal name overlay on each slide. Honest deterrent — a screenshot now says who leaked it. Keep opacity ~0.06 so it never fights the design.

Multiple zones (site / calculator / investors)

Separate code pools per audience with separate cookies, where an investor code is the master key that opens everything. The production system this kit is distilled from runs exactly this — three zones, one middleware. It's the same architecture with a zoneOf(path) router in front; ask us and we'll layer it in.

Session replay (scroll-level recordings)

One flag in analytics.js (disable_session_recording: false). Off by default deliberately — attention analytics reads as diligence, replay can read as surveillance. Turn it on only with a disclosure line on the gate.

Ready to unlock this for your raise? Side Quest Strategies sets this up with clients end to end — gate, registry, analytics, and the operating rhythm around them. sidequeststrategies.com · danny@sidequeststrategies.com

Side Quest Strategies · Private Pitch Deck Hosting — Builder's Guide · Companion files: TUTORIAL.md · SETUP-SCRIPT.md · middleware.template.js · analytics.template.js · One-page overview