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
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.
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.
An open deck URL gets forwarded, scraped, and indexed. A gate keeps your numbers off Google and gives every send a paper trail.
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
| Piece | Where it lives | Job |
|---|---|---|
| Access gate | middleware.js at your repo root | Intercepts every request, shows a branded unlock page, validates codes, sets cookies |
| Analytics | js/analytics.js on every gated page | PostHog tracking that only activates for identified viewers — public pages are never touched |
| Registry | A Notion database | The human record: who holds which code, when issued, active or revoked |
The map: how everything connects
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.
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.
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.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.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._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.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.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.
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
Everything secret lives under /deck, /dataroom, /files. The gate config becomes exactly that three-item list — easy to write, easy to audit.
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.
Write /css/deck.css, never ../css/deck.css. Vercel's cleanUrls serves /deck without a trailing slash, which silently breaks relative paths.
/files and skip slide tracking.AI-assisted track
What the run looks like
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
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
/__unlockhandler, 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 thenextfield.
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
What the run looks like
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
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
In Notion: new page → type /database → choose Database — Full page. Title it "[Company] — Access Registry".
Click the Name column header → Rename. This holds the person's name.
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).
Open the Status property → click each option's ⋮ → green for Active, red for Revoked. Makes the board scannable at a glance.
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?"
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
What the run looks like
The run ends exactly where your next manual action begins — one line, ready to paste.
Why the prompt is built this way
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
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).
One PostHog project = one deck site. In the top-left project switcher → New project, name it after the domain (e.g. "acme-deck").
PostHog will offer snippets and SDKs — skip it all. The template already contains the loader; you only need the 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.
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_viewedOn 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_openedOn 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
What the run looks like
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
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
Private repo is fine (and typical). Everything Vercel needs comes through the Git integration.
vercel.com → Add New… → Project → Import your repo. Framework preset: Other. Build command: empty. Output directory: empty (static files at root). Click Deploy.
Project → Settings → Environment Variables. Key: DECK_ACCESS_CODES. Value: the one-line list from Step 3. Environments: check all three (Production, Preview, Development). Save.
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.
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.
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.
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
What the run looks like
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
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.
Day-to-day runbooks
Three procedures cover the entire operating life of the system. Each takes a minute or two.
Runbook: issue a code
Run the Step 3 one-liner (or pull an "Unassigned" row from the registry batch).
Registry row: Recipient, Firm, full Code, Ref (last 4), Status Active, Issued today, Sent via, any context in Notes.
Vercel → Settings → Environment Variables → edit DECK_ACCESS_CODES → append , CODE:Name — Firm → Save → Deployments → Redeploy.
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
Delete that one entry from DECK_ACCESS_CODES in Vercel. Leave everyone else's untouched.
Deployments → ⋯ → Redeploy. ~30 seconds later the code and its existing cookies are dead — validation happens against the live list on every request.
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):
| Insight | Configuration | Question it answers |
|---|---|---|
| Engagement this week | Series: Pageviews · breakdown by viewer · last 7 days | Who's active right now — and who went quiet after the partner meeting |
| Hottest documents | Series: document_opened · breakdown by document | Which materials actually get read (spoiler: the model, always the model) |
| Deck drop-off | Funnel: slide_viewed steps in slide order | Where attention dies — the slide to fix before the next send |
| Depth per viewer | Series: session duration (average) · breakdown by viewer | Skimmers vs. divers — calibrates your follow-up |
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
Worked example: adapting to a different stack
Suppose a client hosts on Netlify instead of Vercel. Walk the pattern:
- Goal + context: same system, different host — say so: "Port this Vercel Edge Middleware gate to a Netlify Edge Function."
- Grounded inputs: "The existing gate is middleware.js — preserve its behavior exactly."
- Pinned decisions: "Config lives in netlify.toml; the env var stays DECK_ACCESS_CODES; gated paths stay /deck, /dataroom, /files."
- Hard boundaries: "Keep the hashing, cookie flags, fail-closed 503, and safeNext guard byte-for-byte in logic."
- 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:
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.
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
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.
Copy button below grabs all of it — both file templates are embedded verbatim, so nothing else needs to be downloaded or attached.
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.
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
The script
# 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, '&').replace(/</g, '<').replace(/"/g, '"');
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’t match — 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.
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