# Private Pitch Deck Hosting Kit

**Host your HTML pitch deck or data room on your own domain, behind personal access codes, and know exactly who opened it, what they looked at, and for how long.**

This guide turns a folder of static HTML into an investor-grade private site:

- **Personal access codes** — each recipient gets their own code. Revoking one person takes seconds and never disturbs anyone else.
- **No login accounts, no email verification** — one code, typed once, remembered for 30 days. Zero friction for the investor.
- **Named analytics** — "Blue Harbor Capital spent 11 minutes on the deck Tuesday and downloaded the financial model" — not "someone visited."
- **An access registry** — a simple Notion database (or spreadsheet) that records who holds which code, joined to the analytics by a short reference.

Everything runs on free tiers: Vercel (hosting + edge middleware) and PostHog (analytics, 1M events/month free). No backend, no database, no servers to maintain.

---

## 1 · Architecture

```
                                   ┌─────────────────────────────┐
  Investor opens                   │  VERCEL EDGE MIDDLEWARE     │
  yourco.com/deck  ──────────────► │  middleware.js              │
                                   │                             │
   no valid cookie?                │  codes live in one env var: │
   ◄── branded gate page ──        │  DECK_ACCESS_CODES =        │
                                   │   "CODE:Name, CODE:Name"    │
   types their code ──► POST /__unlock                           │
                                   │  sets 2 cookies:            │
   ◄── redirect to deck ──         │  ① access (HttpOnly hash)   │
                                   │  ② who (name + last-4 ref)  │
                                   └──────────────┬──────────────┘
                                                  │ cookie valid → serve
                                                  ▼
                                   ┌─────────────────────────────┐
                                   │  YOUR STATIC HTML           │
                                   │  /deck, /dataroom, ...      │
                                   │  + analytics.js on each page│
                                   └──────────────┬──────────────┘
                                                  │ reads cookie ② only
                                                  ▼
   ┌───────────────────┐  code_ref ┌─────────────────────────────┐
   │  ACCESS REGISTRY  │ ◄─joins─► │  POSTHOG                    │
   │  (Notion DB)      │  "XK9P"   │  pageviews · time-on-page · │
   │  who has which    │           │  clicks · document_opened · │
   │  code, status     │           │  slide_viewed               │
   └───────────────────┘           └─────────────────────────────┘
```

**The three pieces:**

| Piece | File | What it does |
|---|---|---|
| Access gate | `middleware.js` (project root) | Vercel Edge Middleware. Intercepts every request to gated paths, shows a branded unlock page, validates codes, sets cookies. |
| Analytics | `js/analytics.js` | PostHog snippet that runs **only** for identified viewers (it self-gates on the identity cookie). Public pages are never tracked. |
| Registry | Notion database | Human-readable log of every issued code: recipient, firm, full code, 4-char ref, status, date. The single place you look to answer "who is XK9P?" |

**Design decisions worth understanding (they're what make this trustworthy):**

1. **Codes never appear in source code.** They live in a single Vercel environment variable. The repo can be public without leaking anything.
2. **The access cookie is a SHA-256 hash, HttpOnly.** JavaScript on the page cannot read it, and the cookie value cannot be reversed into the code. Stealing the cookie doesn't reveal the code.
3. **The identity cookie is deliberately non-secret.** It carries only a display name and the last 4 characters of the code — enough for analytics attribution, useless for gaining access.
4. **The gate fails closed.** If the env var is missing (misconfigured deploy), visitors get a 503 — never an open door.
5. **The gate page itself carries zero analytics.** The code an investor types can never end up in a tracking event.
6. **Revocation is instant and surgical.** Remove one entry from the env var, redeploy (~30 seconds), and that person's code — and their existing cookie — both stop working, because cookies are validated against the current code list on every request.

---

## 2 · Prerequisites

- Your pitch deck / data room as **static HTML** (single page or multi-page — both work).
- A **GitHub** account and a **Vercel** account (free) with your domain or a subdomain pointed at Vercel (e.g. `deck.yourco.com`).
- A **PostHog** account (free) — [posthog.com](https://posthog.com), US or EU cloud.
- A **Notion** account for the registry (a spreadsheet works too).
- **Claude Code** (or any AI coding agent) if you want to use the prompts below instead of hand-editing — every step includes a copy-paste prompt.

---

## 3 · Step-by-step setup

### Step 0 — Project structure

Arrange your repo so the private material lives under a clear prefix:

```
your-site/
├── middleware.js          ← the gate (Step 1)
├── vercel.json            ← headers (Step 4)
├── index.html             ← public landing page (optional)
├── deck/
│   └── index.html         ← your HTML pitch deck
├── dataroom/
│   ├── index.html         ← document index
│   └── docs/…             ← individual documents
├── js/
│   └── analytics.js       ← tracking (Step 3)
├── css/ img/ fonts/       ← assets (left open so the gate page renders)
└── files/                 ← PDFs, models, downloads (gate this too)
```

Two rules that save pain later:

- **Use absolute asset paths** (`/css/style.css`, not `../css/style.css`) so pages resolve correctly with Vercel's `cleanUrls`.
- **Keep downloadable files under a gated prefix.** A PDF at an open path is an open PDF, regardless of the gate on the page linking to it.

### Step 1 — Add the access gate

Copy `middleware.template.js` from this kit to the root of your project as `middleware.js`, then customize the three ★ blocks at the top:

1. **`GATED_PREFIX`** — which paths require a code (e.g. `['/deck', '/dataroom', '/files']`).
2. **`BRAND`** — company name, gate-page copy, and three colors. The gate page is the first thing every investor sees; make it match your deck.
3. **`NS`** — a short namespace (e.g. `'acmedeck'`) so cookie names are unique to this site.

> **Prompt (Claude Code):**
> "Copy `middleware.template.js` into my project root as `middleware.js`. Gate the paths `/deck`, `/dataroom`, and `/files`. Brand the gate page for **[Company]**: pull the background, text, and accent colors and the font from `deck/index.html` so the gate page matches the deck. Set the namespace to `[companyname]deck`. Don't change the auth logic."

**Test locally is not possible for middleware** (it needs Vercel's edge runtime) — you'll verify in Step 5 on a preview deploy.

### Step 2 — Generate codes and build the registry

**Code format.** Use a recognizable prefix + two random blocks, e.g. `ACME-7Q2F-XK9P`. Generate them so the last 4 characters are unique per code (that's the join key for analytics). One-liner:

```bash
# generates one code; run once 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 alphabet skips `I/L/O/0/1` so codes survive being read over the phone.)

**Registry.** Create a Notion database — call it **"[Company] — Access Registry"** — with these properties:

| Property | Type | Notes |
|---|---|---|
| Recipient | Title | Person's name |
| Firm | Text | Company/fund |
| Code | Text | The full code (this DB is the only place it's written down) |
| Ref | Text | Last 4 chars — matches `code_ref` in PostHog |
| Status | Select | Active / Revoked |
| Issued | Date | |
| Sent via | Select | Email / Text / In person |
| Notes | Text | Context, e.g. "intro from Sarah" |

> **Prompt (Claude Code, with 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- using the crockford-style alphabet (no I, L, O, 0, 1), add them as rows with Status=Active and today's date, and give me the matching DECK_ACCESS_CODES env var value."

**Wire codes to the site.** The env var format is `CODE` or `CODE:Display Name`, comma-separated. Always include the name — it's what makes the analytics readable:

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

### Step 3 — Add analytics

1. Create a PostHog project; copy the **project API key** (`phc_…`).
2. Copy `analytics.template.js` from this kit to `js/analytics.js` and replace the two placeholders: the PostHog key, and the cookie name (`NS + '_who'`, e.g. `acmedeck_who`). If your PostHog project is on EU cloud, also change `api_host` to `https://eu.i.posthog.com`.
3. Include it on **every gated page**, just before `</body>`:
   ```html
   <script src="/js/analytics.js" defer></script>
   ```
4. Optional but recommended for single-page decks: tag each slide container with `data-slide="03 — Market"` to get per-slide `slide_viewed` events. Tag key links with `data-doc="Financial Model v2"` for clean `document_opened` labels.

> **Prompt (Claude Code):**
> "Copy `analytics.template.js` to `js/analytics.js`. Set the PostHog key to `phc_XXXX` and the cookie name to `acmedeck_who`. Then add `<script src="/js/analytics.js" defer></script>` before `</body>` on every HTML page under `/deck` and `/dataroom`. In `deck/index.html`, add a `data-slide` attribute to each slide section using the slide's heading as the label. Add `data-doc` attributes to every document link in the data room using the visible document title."

What you get in PostHog with zero extra configuration: pageviews, time on page (via pageleave), every click (autocapture), plus the named `document_opened` and `slide_viewed` events — all stamped with the viewer's name and `code_ref`.

### Step 4 — Headers (`vercel.json`)

Keep private pages out of search engines and (optionally) allow embedding in Notion:

```json
{
  "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" }
      ]
    }
  ]
}
```

If you don't need Notion embeds, tighten `frame-ancestors` to `'none'`. (The middleware's cookies use `SameSite=None` specifically so embedded access keeps working — if you drop embeds, you can switch those to `SameSite=Lax` in `middleware.js` for extra safety.)

### Step 5 — Deploy

1. Push the repo to GitHub; import it into Vercel (framework preset: **Other**, no build step for plain HTML).
2. Add the env var — **Vercel dashboard → Project → Settings → Environment Variables** → `DECK_ACCESS_CODES`, all environments. The dashboard is the reliable way to paste it; if you script it, use bash (`printf '%s' "$CODES" | vercel env add …`) — **never pipe values in from PowerShell**, which prepends an invisible BOM that silently corrupts the value.
3. Point your domain/subdomain at the project.
4. **Every env-var change requires a redeploy** to take effect (Deployments → ⋯ → Redeploy). Bake this into your revoke routine.

### Step 6 — Verify before sending anything

Run through this list in a private/incognito window:

- [ ] `/deck` shows the branded gate, not the deck.
- [ ] A wrong code shows the error state and stays locked.
- [ ] A valid code unlocks and lands on the page you asked for.
- [ ] Close the tab, reopen `/deck` → still unlocked (cookie persisted).
- [ ] A direct URL to a PDF under `/files/...` is gated too.
- [ ] `/css/...` assets load on the gate page (open prefixes working).
- [ ] In PostHog → Activity: your test visit appears **with the right name and code_ref**.
- [ ] Visit the public landing page in a fresh incognito window → **no** PostHog events (self-gating works).
- [ ] Remove your test code from the env var, redeploy, reload → you're locked out again (revocation works, even with the old cookie).

---

## 4 · Day-to-day operations

**Issue a code** (2 minutes):
1. Generate a code (Step 2 one-liner).
2. Add a row to the registry: recipient, firm, full code, last-4 ref, Active, today.
3. Append `, CODE:Name — Firm` to `DECK_ACCESS_CODES` in Vercel; redeploy.
4. Send the code in a **separate channel from the link** (link by email, code by text) — a forwarded email then leaks nothing by itself.

**Revoke a code** (1 minute): delete the entry from the env var, redeploy, flip the registry row to Revoked. Their cookie dies with the code — no grace period.

**Read the analytics.** In PostHog, the person's profile (named after the registry entry) shows their full history. Useful saved insights:
- *Who's engaged this week* — pageviews grouped by `viewer`, last 7 days.
- *Hottest documents* — `document_opened` grouped by `document`.
- *Deck drop-off* — `slide_viewed` funnel in slide order (where does attention die?).
- *Session depth* — average session duration per viewer.

> **Prompt (Claude Code):** "In my PostHog project, what did [Firm] look at in the last two weeks? Summarize pages, time spent, documents opened, and which deck slides they viewed."

---

## 5 · Security model — what this is and isn't

**What it defends against:** casual forwarding of links, search-engine indexing, ex-prospects retaining indefinite access, and any question of "did they actually read it."

**What it doesn't:** a determined recipient can still screenshot, download, or share files they can access, and can share their code (though you'd see two IP/device patterns on one profile in PostHog — a tell). This is investor-grade access control, not DRM. For most fundraises that's exactly the right trade: friction stays near zero and you keep full visibility.

Also worth stating to clients plainly:
- Cookies last 30 days; a revoked code overrides the cookie immediately.
- The env var is the single source of truth for access; the Notion registry is documentation. Keep them in sync — the verify checklist's revoke test is worth repeating whenever it matters.
- Identified analytics on named individuals may carry privacy-notice obligations depending on jurisdiction — a one-line "this data room logs access" notice on the gate page is cheap insurance.

---

## 6 · Extensions

Each of these is a small, well-scoped follow-on once the base kit works:

- **Multiple zones** — separate code pools for separate audiences (site preview vs. calculator vs. investors), with the investor code as a master key. The production system this kit is distilled from runs three zones; ask and we'll layer it in.
- **Slack/email alerts** — PostHog webhook on `document_opened` → "Jane Smith just opened the financial model."
- **Per-code expiry** — encode an expiry date in the env entry (`CODE:Name:2026-09-01`) and check it in the middleware.
- **Watermarking** — stamp the viewer's name (from the identity cookie) faintly on deck pages; honest deterrent against screenshots.
- **Session replay** — PostHog can record scroll-level replays; it's off by default in this kit for privacy, and turning it on is one flag.

---

## Appendix A — Full prompt to build the whole thing at once

If you'd rather have Claude Code do the entire setup in one pass, put `middleware.template.js` and `analytics.template.js` in the repo root and run:

> "I have a static HTML pitch deck in this repo that I want to host privately on Vercel with per-recipient access codes and named analytics.
>
> 1. Install `middleware.template.js` as `middleware.js` in the root. Gate `[/deck, /dataroom, /files]`. Set the namespace to `[name]deck` and brand the gate page using the colors and typography from my deck's CSS.
> 2. Install `analytics.template.js` as `js/analytics.js` with PostHog key `[phc_…]` and cookie name `[name]deck_who`. Add the script tag to every gated page. Tag deck slides with `data-slide` and data-room document links with `data-doc`.
> 3. Create `vercel.json` with cleanUrls, noindex on the gated paths, and Referrer-Policy strict-origin-when-cross-origin.
> 4. Generate 5 access codes with prefix `[NAME]-` (alphabet without I/L/O/0/1), output the `DECK_ACCESS_CODES` value for me to paste into Vercel, and create a Notion database '[Company] — Access Registry' (Recipient, Firm, Code, Ref, Status, Issued, Sent via, Notes) with the 5 codes as Active rows.
> 5. Walk me through the Vercel import and env-var setup, then give me the Step 6 verification checklist from TUTORIAL.md to run together."

## Appendix B — Files in this kit

| File | Purpose |
|---|---|
| `TUTORIAL.md` | This guide |
| `middleware.template.js` | The access gate — copy to project root as `middleware.js`, customize the 3 ★ blocks |
| `analytics.template.js` | The tracking script — copy to `js/analytics.js`, fill 2 placeholders |

---

*Distilled from a production investor data room (multi-zone gate, per-investor codes, PostHog attribution, Notion registry) built and operated by Sidequest Strategies.*
