# 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).
