# adampowell.pro — nginx configuration & routes reference

> # 🔴 LEGACY — the new host does NOT run this config; it was deliberately SPLIT IN TWO
>
> **2026-08-02:** on **CT 116** (the self-hosted replacement, `192.168.1.35`) this single monolithic
> vhost was transformed into **two** by `homelab/scripts/adampowell-nginx-split.py`:
>
> - **`ap-public.conf`** — `server_name adampowell.pro www.adampowell.pro`, reachable **only** through
>   the Cloudflare Tunnel. The confidential apps (`/remote/*`, `/command-center/*`, `/atlas/*`,
>   `/api/casetracker`, `/case-tracker/*`) are **structurally absent — not merely auth-gated**. The
>   dead droplet upstreams (`/maps/`, `/api/`, `/chat/`, `/pulse/`, `/sharedtodo/`, `/ekg-tutor/`) and
>   the droplet-only **`/gitwebhook`** route were dropped entirely.
> - **`ap-tailnet.conf`** — `default_server`, carries the **full** app set including the confidential
>   ones, reachable only over Tailscale (`app-ct.tail94c6c8.ts.net`) or `app.lan` on the LAN.
>
> Two things that lived in the droplet's `http{}` block had to be re-created on CT 116 rather than
> copied from the vhost: the **`limit_req_zone` definitions** (now `/etc/nginx/conf.d/ap-zones.conf`) —
> a vhost referencing an undefined zone fails `nginx -t`. There is also **no TLS listener** on the new
> host by design: HTTPS terminates at Cloudflare and the tunnel dials outbound.
>
> Everything below remains an accurate record of the **droplet's** config and is the source the split
> was derived from. Current-state hosting truth: `c:\dev\homelab\HOMELAB_MASTER_INFRASTRUCTURE.md`.
>
> The authoritative, **live** nginx config for `198.211.114.12` (adampowell.pro), pulled fresh and
> embedded verbatim below. Read with [`SYSTEM.md`](SYSTEM.md) (the rest of the system) and
> [`WHEN CREATING A NEW APP.md`](WHEN%20CREATING%20A%20NEW%20APP.md) (how to add a route).
>
> **Last pulled from the server:** 2026-06-06 (`cat /etc/nginx/sites-enabled/adampowell.pro`, 734 lines);
> **rosters blocks added live 2026-06-30** (verified via `diff` of both files + `nginx -t`); **`= /requests`
> + `= /requests/` blocks added live 2026-06-30** for the public class‑request form (same verify); **the
> `/rosters/api/` block gained `CF‑Connecting‑IP` + `CF‑IPCountry` `proxy_set_header` lines 2026-06-30** (set
> from Cloudflare's values) so the app can do per‑IP rate limiting + USA‑only geo‑gating on `/requests` and a
> spoofed country header on a direct‑origin hit can't win. Backups `*.pre-cfgeo.*`. **`/rosters/pay/` block added
> live 2026-06-30** (static `pay.html` for the per‑request Venmo pay page; backups `*.pre-pay.*`).
> **2026-07-01 — MAJOR rosters auth change + search exclusion.** (1) The rosters **ADMIN panel + admin API are now
> gated by the MAIN login** (`auth_request`) **on top of** the app's own bcrypt password — a verified carve‑out so
> the **public** request/signup/pay routes stay ungated. Because the admin API and the public request/pay APIs both
> live under `/rosters/api/`, the public request API is split into an **exact `= /rosters/api/request` + a
> trailing‑slash `/rosters/api/request/`** pair so the admin `/rosters/api/requests*` routes are NOT accidentally
> un‑gated by a bare `/request` prefix (a real hole that adversarial verification caught); `/rosters/api/pay` +
> `/rosters/api/signup/` are the other ungated API prefixes; then `/rosters/api/` and `/rosters/` get `auth_request`.
> Result: admin → 302 to /login when not main‑logged‑in; public request/signup/pay → still 200/open. (2) A
> `limit_req_zone $http_cf_connecting_ip zone=rosters_req:10m rate=60r/m;` added to `http{}` in **nginx.conf**, with
> `limit_req zone=rosters_req burst=20 nodelay;` on the public rosters API blocks (a server‑level rate backstop that
> survives app restarts). (3) **Search exclusion:** an ungated `location = /robots.txt` (serves `html/robots.txt` =
> `Disallow: /`) + a server‑level `add_header X-Robots-Tag "noindex, nofollow" always;` (also added to the public
> rosters page shells since `add_header` in a location block drops inherited headers). Backups `*.pre-gate.*` +
> `*.pre-robotshdr.*`. Both site files + nginx.conf edited; `diff` empty; `nginx -t` clean; reloaded; full auth
> matrix + public E2E verified live.
> **2026-07-01 (later) — rosters paid-student follow-up.** Marking a request paid now auto-emails the student
> their eval + answer-sheet links, plus a new admin route `POST /rosters/api/requests/:id/resend-followup` and
> admin-panel copy-link/completion-status UI. **No nginx change** — the new route lives under the already-gated
> admin `/rosters/api/` prefix, and `/rosters/eval/` + `/rosters/answersheet/` public shells already exist.
> Verified with a local Playwright run against a seeded instance (no Cloudflare).
> **2026-07-01 (later):** rosters eval + answer sheet consolidated to one combined page each (all courses, one submit,
> hard-block until complete; exam counts hard-coded server-side). **Frontend-only + one added GET field — no nginx change.**
> **2026-07-27 — FIX: rosters Venmo QR image was gated (broken for every real student).** `pay.html` requests
> `/rosters/venmo-qr.jpg` at the ROOT of `/rosters/` — outside the `/rosters/pay/` prefix — so it fell through to
> the GATED admin catch-all and 302'd to `/login` for anyone not signed into the main site. Since the public pay
> page has no login, the QR has likely never rendered for a real student (they could still tap the Venmo deep-link
> button; only the scan-with-another-device path was broken). Found live via a student-reported screenshot; curl
> from off-box got a Cloudflare Bot Fight Mode 403 (red herring) but a loopback hit with the real Host header
> showed nginx itself 302'ing — confirmed the bug was server-side, not Cloudflare. **Fix:** added an ungated exact
> `location = /rosters/venmo-qr.jpg` block (before the gated catch-all in file order, though exact-match wins
> regardless). Swept every other public rosters page for similar root-relative `/rosters/<file>` references —
> this was the only one. Both `sites-available` + `sites-enabled` edited identically (confirmed byte-identical
> before and after), backups `*.pre-qrfix.20260727-180851`, `nginx -t` clean, reloaded. Verified via loopback
> (200, correct JPEG, 266KB) and a full gated/ungated smoke test (admin panel + manifest still gated; pay/eval/
> answersheet pages still ungated — nothing else changed).
> **Port-map status touched 2026-06-13:** janda (`:3004`) is back ✅ up (nginx config itself unchanged).
> **2026-06-26:** `/preop/` (Preop Risk Eval, now **v2.1**) added as a static PWA — served by the catch‑all
> `location /` from `html/preop` (whole‑dir symlink), auth‑gated like every other static app. **No nginx change**
> (no proxy_pass, no new server/location block, no port). Its on‑device voice dictation pulls transformers.js +
> Whisper weights straight from the public jsdelivr/HF CDN in the browser — no server‑side proxying.
> **v2.1 (2026-07-02)** ships a page‑level `<meta http-equiv="Content-Security-Policy">` inside index.html; it is
> a document‑only CSP (no nginx `add_header` needed) whose `connect-src`/`script-src` allow only `'self'` +
> jsDelivr + the HF weight hosts, so the dictation CDN fetch keeps working while any other egress is blocked. **No
> nginx change.**
> **2026-06-30:** `/npa/` (NPA/NDA e‑signing) added — this one **did** need an nginx edit. Three new HTTPS
> location blocks inserted **before** the gated catch‑all, all **without** `auth_request`: `/npa/api/`
> (proxy → `127.0.0.1:3016`, `client_max_body_size 8M` for the base64 signature image), `/npa/sign/` (serves the
> static `sign.html` shell for the `/npa/sign/<token>` path so the token URL resolves), and `/npa/` (static).
> The recruiter side is token‑gated and the Adam side has its own password, so both intentionally bypass the
> main session gate. Both `sites-available` + `sites-enabled` edited identically; backups in `/root/nginx-backups/`
> as `*.pre-npa.*`. `nginx -t` clean, reloaded.
> **2026-06-30 (later):** `/rosters/` (AHA class rosters + public student self-signup) added — needed an nginx
> edit. **Like npa, the WHOLE app bypasses the main session gate** (four HTTPS location blocks, all **without**
> `auth_request`, before the catch‑all): the admin panel has its **own bcrypt password** (`ROSTERS_PASSWORD_HASH`
> → in‑app bearer token, so Adam can reach it from a device that isn't logged into adampowell.pro), and the
> public student signup is slug‑scoped. Blocks: `/rosters/api/signup/` (ungated public submit), `/rosters/api/`
> (ungated — the app's own token is the gate; forwards `Authorization`), `/rosters/signup/` (ungated static
> `signup.html`), and `/rosters/` (ungated static admin panel — it can't fall through the gated catch‑all).
> nginx longest‑prefix match orders `/rosters/api/signup/` > `/rosters/api/` and `/rosters/signup/` > `/rosters/`.
> _(Initially built main‑login‑gated like case‑tracker, then switched same‑day to the own‑password model so it
> works off the main login.)_ Both files edited identically; backups `*.pre-rosters.*`. `nginx -t` clean, reloaded.

---

## 1. Files & edit rules

- **Main config:** `/etc/nginx/sites-available/adampowell.pro` **and** `/etc/nginx/sites-enabled/adampowell.pro`.
  These are **separate file copies, NOT a symlink.** Any edit must be applied to **both**, then verified:
  ```bash
  diff /etc/nginx/sites-{available,enabled}/adampowell.pro   # must be empty
  nginx -t && systemctl reload nginx                          # rollback from backup if -t fails
  ```
  As of 2026-06-06 the two were identical (28150 bytes / 734 lines each); the 2026-06-30 NPA edit added the
  same 1306‑byte block to both (now 29456 bytes / 763 lines each, still identical — verified by `diff`).
- **Subdomain configs:** `bp.adampowell.pro`, `mac.adampowell.pro.conf`, `mickey.adampowell.pro.conf`,
  `dns.adampowell.pro.conf`, `photography.adampowell.pro.conf` (mostly 301 redirects; dns proxies AdGuard).
- **Backups:** always back up before editing —
  `cp /etc/nginx/sites-enabled/adampowell.pro /root/nginx-backups/adampowell.pro.pre-<change>.$(date +%Y%m%d_%H%M%S)`
  (do the same for `sites-available`). Existing backups live in `/root/nginx-backups/` and as
  `*.bak-*` / `*.backup*` next to the configs.
- **Not in git.** The nginx config is server‑side only. The repo can't change it — you SSH in to edit it
  (the one exception to "never touch the server": nginx is not under the git‑deploy model). But the change
  still has to be made carefully and backed up.

---

## 2. Port map (current, 2026-06-06)

| Port | Service | Bind | Status | nginx route |
|---|---|---|---|---|
| 80 | nginx | 0.0.0.0 | ✅ | 301 → HTTPS |
| 443 | nginx | 0.0.0.0 | ✅ | all apps |
| 3003 | auth.service | * | ✅ | `/login`, `/auth/api/`, `/_auth_check` |
| 3005 | github webhook (pm2) | 127.0.0.1 | ✅ | `/gitwebhook` |
| 3008 | todos.service | 127.0.0.1 | ✅ | `/todos/api/` |
| 3009 | randemail.service | 127.0.0.1 | ✅ | `/RandEmail/api/` |
| 3010 | remote.service | 127.0.0.1 | ✅ | `/remote/api/`, `/remote/api/daemon/` |
| 3013 | atlas.service | * | ✅ | `/atlas/api/`, `/atlas/agent` |
| 3015 | command-center | 127.0.0.1 | ✅ | `/command-center/{,api/,sse/,ws}` |
| 3016 | npa.service | 127.0.0.1 | ✅ | `/npa/api/` (+ static `/npa/`, `/npa/sign/`) — no `auth_request` |
| 3017 | rosters.service | 127.0.0.1 | ✅ | **ADMIN gated** (`/rosters/`, `/rosters/api/` — main login + app password) · **PUBLIC ungated** (slug/captcha/pay‑token): `= /rosters/api/request`+`/request/`, `/rosters/api/pay`, `/rosters/api/signup/`, `/rosters/signup/`, `/rosters/pay/`, `= /requests` |
| 3100 | case-tracker.service | * | ✅ | `/api/casetracker` |
| 3002 | messenger.service | — | ⏸️ down | `/chat/` (→ 502) |
| 3004 | janda (pm2) | 127.0.0.1 | ✅ | `/janda/` (400s timeout for tarot) |
| 3006 | kvh-pulse.service | — | ⏸️ down | `/pulse/api/`, `/pulse/socket.io/` (→ 502) |
| 3011 | petfeed.service | — | ⏸️ down | `/petfeed/api/` (→ 502) |
| 3014 | sharedtodo.service | — | ⏸️ down | `/sharedtodo/api/` (→ 502) |
| 3110 | ekg-tutor-api | — | ⏸️ down | `/ekg-tutor/api/` (→ 502) |
| 8081 | AdGuard Home | — | ⏸️ down | `dns.adampowell.pro` (→ 502) |
| 3001 | (retired maps) | — | ❌ gone | `/maps/`, `/api/` blocks still present (→ 502) |
| 8080 | apache2 | * | ✅ | legacy, not fronted by this config |

> The nginx config still contains `/maps/`, `/chat/`, `/pulse/…`, `/petfeed/api/`,
> `/sharedtodo/api/`, `/ekg-tutor/api/` proxy blocks whose backends are currently down — they return 502
> rather than 404. That's expected; bring the service up (see SYSTEM.md §4) and the route works again.
> (`/janda/` → `:3004` is back **up** as of 2026-06-13.)

---

## 3. Live nginx config (verbatim)

`/etc/nginx/sites-enabled/adampowell.pro` (identical to `sites-available`), as of 2026-06-06:

```nginx
# HTTP
server {
    listen 80;
    server_name adampowell.pro;

    # If certs exist, redirect everything to HTTPS; otherwise serve HTTP directly
    return 301 https://$host$request_uri;

    # Fallback HTTP serving (used only if no HTTPS)
    root /var/www/adampowell.pro/html;
    index index.html index.htm;

    # ===== Case Tracker API =====
    location /api/casetracker {
        proxy_pass         http://127.0.0.1:3100/api/casetracker;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto http;
        proxy_read_timeout 60s;
    }

    # ===== Case Tracker PWA =====
    location /case-tracker/ {
        alias /var/www/adampowell.pro/html/case-tracker/;
        index index.html;
        try_files $uri $uri/ =404;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Redirect bare /maps to login page
    location = /maps { return 302 /maps/login.html; }

    # Proxy API calls to Node app
    location /api/ {
        proxy_pass         http://127.0.0.1:3001/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto http;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 60s;
    }

    # Proxy all /maps/ to Node app
    location /maps/ {
        proxy_pass         http://127.0.0.1:3001/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto http;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 60s;
    }

    # Redirect bare /chat to /chat/
    location = /chat { return 302 /chat/; }

    # Proxy all /chat/ to Messenger app
    location /chat/ {
        proxy_pass         http://127.0.0.1:3002/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto http;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 60s;
    }

    # NDA directory - PHP support
    location /nda/ {
        root /var/www/adampowell.pro;
        index index.php index.html;

        location ~ \.php$ {
            include snippets/fastcgi-php.conf;
            fastcgi_pass unix:/run/php/php7.3-fpm.sock;
        }

        location ~ ^/nda/(records|signatures)/ {
            deny all;
            return 403;
        }
    }

    location /purchase-orders/ {
        root /var/www/adampowell.pro;
        index index.php index.html;

        location ~ \.php$ {
            include snippets/fastcgi-php.conf;
            fastcgi_pass unix:/run/php/php7.3-fpm.sock;
        }

        location ~ ^/purchase-orders/.*/(?:records|signatures)/ {
            deny all;
            return 403;
        }
    }


    location / {
        try_files $uri $uri/ =404;
    }


    # ===== Atlas PWA - No caching for HTML/JS =====
    location /atlas/ {
        alias /var/www/adampowell.pro/html/atlas/;
        try_files $uri $uri/ /atlas/index.html;

        # Disable caching for HTML and JS files
        location ~ \.(html|js)$ {
            add_header Cache-Control "no-cache, no-store, must-revalidate";
            add_header Pragma "no-cache";
            add_header Expires "0";
        }

        # Cache CSS, images, and other assets for 1 hour
        location ~ \.(css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
            add_header Cache-Control "public, max-age=3600";
        }
    }
    # ===== Atlas API and WebSocket =====
    location /atlas/api/ {
        rewrite ^/atlas/api/(.*) /api/$1 break;
        proxy_pass http://localhost:3013;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_read_timeout 300;
    }

    location /atlas/agent {
        proxy_pass http://localhost:3013/agent;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400;
    }

}

# HTTPS
server {
    listen 443 ssl http2;
    server_name adampowell.pro;

    ssl_certificate     /etc/letsencrypt/live/adampowell.pro/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/adampowell.pro/privkey.pem;

    root /var/www/adampowell.pro/html;
    index index.html index.php index.htm;

    # ===== Consolidated PHP apps (2026-04-10) =====
    # /bp/, /mickey/, /photo/ are now served from html/{bp,mickey,photo}
    # via symlinks into the repo's PHP app directories. This location
    # matches any .php file under those prefixes and hands it to PHP-FPM
    # with the correct SCRIPT_FILENAME. Static assets (.css, .js, images)
    # fall through to the `location /` gate below, which also covers auth.
    location ~ ^/(bp|mickey|photo)/.*\.php$ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php7.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # Login page route
    location = /login {
        proxy_pass         http://127.0.0.1:3003/login.html;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
    }

    # Auth API routes - proxy to auth server
    location /auth/api/ {
        proxy_pass         http://127.0.0.1:3003/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_read_timeout 60s;
    }

    # ===== Auth subrequest (internal) =====
    location = /_auth_check {
        internal;
        proxy_pass              http://127.0.0.1:3003/api/auth/nginx-check;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length "";
        proxy_set_header        X-Original-URI $request_uri;
        proxy_set_header        Cookie $http_cookie;
    }

    # ===== Case Tracker API =====
    location /api/casetracker {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3100/api/casetracker;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_read_timeout 60s;
    }

    # ===== Case Tracker PWA (public assets) =====
    location = /case-tracker/manifest.json {
        alias /var/www/adampowell.pro/html/case-tracker/manifest.json;
        add_header Content-Type "application/manifest+json";
    }

    # ===== Case Tracker PWA =====
    location /case-tracker/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        alias /var/www/adampowell.pro/html/case-tracker/;
        index index.html;
        try_files $uri $uri/ =404;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # ===== Login redirect for auth failures =====
    location @login_redirect {
        return 302 /login;
    }

    # ===== Investing secrets.js — prevent caching =====
    location = /investing/secrets.js {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;
        alias /var/www/adampowell.pro/html/investing/secrets.js;
        default_type application/javascript;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
        add_header Pragma "no-cache" always;
    }

    # ===== Remote — daemon SSE + ack (NO auth_request, bearer token instead) =====
    location /remote/api/daemon/ {
        proxy_pass         http://127.0.0.1:3010/api/daemon/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Connection "";
        proxy_set_header   Authorization $http_authorization;

        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 24h;
        proxy_send_timeout 24h;
        chunked_transfer_encoding on;

        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== Remote — PWA API (auth_request, session cookie) =====
    location /remote/api/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3010/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;

        proxy_read_timeout 30s;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== Remote PWA (static) — auth_request gated =====
    location /remote/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;
        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== RandEmail API proxy =====
    location /RandEmail/api/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3009/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;

        proxy_read_timeout 60s;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== RandEmail PWA (static) =====
    location /RandEmail/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    location /todos/api/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3008/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Connection "";

        # SSE: never buffer, never cache, never time out the long poll.
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 24h;
        proxy_send_timeout 24h;
        chunked_transfer_encoding on;

        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    location /todos/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }


    # ===== Pet Feed API (weight tracker) =====
    location /petfeed/api/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3011/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Connection "";

        # SSE: never buffer, never cache, never time out the long poll.
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 24h;
        proxy_send_timeout 24h;
        chunked_transfer_encoding on;

        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== Pet Feed PWA (static) =====
    location /petfeed/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }
    location /ANSReset/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        # Single-gate: the URL is only discoverable via the session-gated
        # nav.json, so the main auth_request is the gate.
        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }



    # ===== /dns-query abuse sink (2026-04-10) =====
    location = /dns-query {
        access_log off;
        return 444;
    }

    # ===== Gated navigation manifest (2026-04-10) =====
    location = /nav.json {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;
        alias /var/www/adampowell.pro/html/nav.json;
        default_type application/json;
        add_header Cache-Control "no-store";
    }

    # Favicon stays public so the login page and 401 error pages don't
    # trigger a redirect loop when the browser tries to fetch it.
    location = /favicon.ico {
        alias /var/www/adampowell.pro/html/favicon.ico;
        access_log off;
        log_not_found off;
    }

    # Redirect bare /maps to login page
    location = /maps { return 302 /maps/login.html; }

    # Proxy API calls to Maps Node app
    location /api/ {
        proxy_pass         http://127.0.0.1:3001/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 60s;
    }

    # Proxy all /maps/ to Node app (auth-gated 2026-04-10)
    location /maps/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3001/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 60s;
    }

    # Redirect bare /chat to /chat/
    location = /chat { return 302 /chat/; }

    # Proxy all /chat/ to Messenger app (auth-gated 2026-04-10)
    location /chat/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3002/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 60s;
    }

    # ===== JANDA (J&A Relationship Builder) =====
    location = /janda { return 302 /janda/; }

    location /janda/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3004/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 400s;  # 6+ minutes for gpt-5-nano tarot readings
    }

    # NDA directory - PHP support
    location /nda/ {
        root /var/www/adampowell.pro;
        index index.php index.html;

        location ~ \.php$ {
            include snippets/fastcgi-php.conf;
            fastcgi_pass unix:/run/php/php7.3-fpm.sock;
        }

        location ~ ^/nda/(records|signatures)/ {
            deny all;
            return 403;
        }
    }

    location /purchase-orders/ {
        root /var/www/adampowell.pro;
        index index.php index.html;

        location ~ \.php$ {
            include snippets/fastcgi-php.conf;
            fastcgi_pass unix:/run/php/php7.3-fpm.sock;
        }

        location ~ ^/purchase-orders/.*/(?:records|signatures)/ {
            deny all;
            return 403;
        }
    }


    # ===== Shared Todo API — bearer token, NO auth_request =====
    location /sharedtodo/api/ {
        proxy_pass         http://127.0.0.1:3014/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Authorization $http_authorization;
        proxy_read_timeout 30s;
        client_max_body_size 256k;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== Shared Todo landing page (static, session-gated) =====
    location /sharedtodo/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;
        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== EKG Tutor API — auth-gated proxy =====
    # auth_request validates the session; the Cookie is forwarded so the api
    # can resolve the username via the auth server's /api/auth/check. The
    # static frontend at /ekg-tutor/ is served by the catch-all from
    # html/ekg-tutor (symlinked via rebuild-symlinks.sh).
    location /ekg-tutor/api/ {
        auth_request /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3110/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Cookie $http_cookie;
        proxy_read_timeout 60s;     # tutor latency up to 12s; budget headroom
        client_max_body_size 16M;   # EKG strip uploads
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== Command Center (Go binary, port 3015) — split auth =====
    # # CC-SPLIT v1 2026-05-19
    # API / SSE / WS use inner WebAuthn only (cc_session cookie). The outer
    # auth_request was redirecting expired XHRs to /login, which iOS PWAs
    # surface as TypeError: Load failed (cannot follow cross-scope redirect
    # from fetch()). HTML shell + static assets keep the outer gate.
    # See DECISIONS.md D48 (dev_mode bind), D49 (BASE_URL prefix), D51 (this).
    location = /command-center { return 302 /command-center/; }

    # --- API: inner WebAuthn only ---
    location /command-center/api/ {
        proxy_pass         http://127.0.0.1:3015/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Cookie $http_cookie;
        proxy_buffering    off;
        proxy_read_timeout 60s;
        client_max_body_size 16M;
        proxy_cookie_path  / /command-center/;
        proxy_redirect     / /command-center/;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # --- SSE: inner WebAuthn only, long-lived stream ---
    location /command-center/sse/ {
        proxy_pass         http://127.0.0.1:3015/sse/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Cookie $http_cookie;
        proxy_buffering    off;
        proxy_read_timeout 86400s;
        proxy_cookie_path  / /command-center/;
    }

    # --- WebSocket: inner WebAuthn only, Upgrade headers ---
    location = /command-center/ws {
        proxy_pass         http://127.0.0.1:3015/ws;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Cookie $http_cookie;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_buffering    off;
        proxy_read_timeout 86400s;
    }

    # --- HTML shell + static assets: outer gate stays ---
    location /command-center/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3015/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Cookie $http_cookie;
        proxy_buffering    off;
        proxy_read_timeout 60s;
        client_max_body_size 16M;
        proxy_cookie_path  / /command-center/;
        proxy_redirect     / /command-center/;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # ===== NPA — self-hosted CRNA Non-Presentation Agreement e-signing (2026-06-30) =====
    # Recruiter-facing /npa/sign/ and its API skip the main session gate
    # (token-based instead). Adam-facing /npa/ (the create-agreement form) also
    # skips it, since it has its own password layer per the public-link
    # requirement — but only Adam knows that password.
    location /npa/api/ {
        proxy_pass         http://127.0.0.1:3016/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_read_timeout 30s;
        client_max_body_size 8M;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # Recruiter signing page: /npa/sign/<token> is a PATH, not a file. Serve the
    # static sign.html shell for any token; the page reads the token from the
    # URL and calls /npa/api/sign/<token>. No auth_request (token is the gate).
    location /npa/sign/ {
        try_files /npa/sign.html =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    location /npa/ {
        try_files $uri $uri/ =404;
    }

    # ===== Rosters (AHA class rosters) — added 2026-06-30, ADMIN gated 2026-07-01 =====
    # nginx matches by LONGEST PREFIX (not file order). Public routes carry the
    # app's own gate (slug / captcha / pay-token); the ADMIN panel + admin API
    # get the MAIN login (auth_request) ON TOP of the app's own bcrypt password.
    # Verified carve-out (the /request exact+slash pair is REQUIRED so the admin
    # /requests* API is NOT accidentally un-gated by a bare /request prefix):
    #   /rosters/api/signup/  → UNGATED (public signup API)
    #   = /rosters/api/request + /rosters/api/request/ → UNGATED (public request API)
    #   /rosters/api/pay      → UNGATED (public pay API; no admin route starts with 'pay')
    #   /rosters/api/         → GATED  (admin API: classes/config/requests*/auth/session/health)
    #   /rosters/signup/, /rosters/pay/ → UNGATED (public page shells)
    #   /rosters/             → GATED  (admin panel static)

    # (a) UNGATED — public student signup API (slug-scoped).
    location /rosters/api/signup/ {
        proxy_pass         http://127.0.0.1:3017/api/signup/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   CF-Connecting-IP $http_cf_connecting_ip;
        proxy_read_timeout 30s;
        client_max_body_size 64k;
        limit_req zone=rosters_req burst=20 nodelay;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (a2) UNGATED — public class-request API. EXACT bare POST + trailing-slash
    #      sub-paths (entry/enter/courses). The trailing-slash form canNOT match
    #      the admin /rosters/api/requests* routes (next char is 's', not '/'),
    #      so those correctly fall through to the GATED /rosters/api/ below.
    location = /rosters/api/request {
        proxy_pass         http://127.0.0.1:3017/api/request;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   CF-Connecting-IP $http_cf_connecting_ip;
        proxy_set_header   CF-IPCountry $http_cf_ipcountry;
        proxy_read_timeout 30s;
        client_max_body_size 1M;
        limit_req zone=rosters_req burst=20 nodelay;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }
    location /rosters/api/request/ {
        proxy_pass         http://127.0.0.1:3017/api/request/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   CF-Connecting-IP $http_cf_connecting_ip;
        proxy_set_header   CF-IPCountry $http_cf_ipcountry;
        proxy_read_timeout 30s;
        client_max_body_size 1M;
        limit_req zone=rosters_req burst=20 nodelay;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (a3) UNGATED — public per-request pay API (/api/pay/<token>[/coupon]).
    location /rosters/api/pay {
        proxy_pass         http://127.0.0.1:3017/api/pay;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   CF-Connecting-IP $http_cf_connecting_ip;
        proxy_read_timeout 30s;
        client_max_body_size 64k;
        limit_req zone=rosters_req burst=20 nodelay;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (b) GATED — ADMIN API (main login on top of the app's own password/bearer).
    location /rosters/api/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        proxy_pass         http://127.0.0.1:3017/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Authorization $http_authorization;
        proxy_set_header   CF-Connecting-IP $http_cf_connecting_ip;
        proxy_set_header   CF-IPCountry $http_cf_ipcountry;
        proxy_read_timeout 30s;
        client_max_body_size 1M;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (c) UNGATED — public signup page shell (/rosters/signup/<slug>).
    location /rosters/signup/ {
        try_files /rosters/signup.html =404;
        add_header X-Robots-Tag "noindex, nofollow" always;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (d) UNGATED — public pay page shell (/rosters/pay/<token>).
    location /rosters/pay/ {
        try_files /rosters/pay.html =404;
        add_header X-Robots-Tag "noindex, nofollow" always;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (d2) UNGATED — the Venmo QR image referenced by pay.html. It sits at the
    # ROOT of /rosters/ (not under /rosters/pay/<token>/), so without this exact
    # match it fell through to the GATED /rosters/ catch-all below and 302d to
    # /login for any unauthenticated student -- the QR was broken for every real
    # visitor to the public pay page since launch. Exact match wins regardless
    # of block order, but placed here for readability next to the pay block.
    location = /rosters/venmo-qr.jpg {
        try_files /rosters/venmo-qr.jpg =404;
        add_header Cache-Control "public, max-age=3600" always;
        add_header X-Robots-Tag "noindex, nofollow" always;
    }

    # (e) GATED — ADMIN PANEL static (main login on top of the app's own password).
    location /rosters/ {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;

        try_files $uri $uri/ =404;
        add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
    }

    # (f) UNGATED — public class-request form page (exact). API rides (a2) above.
    location = /requests  { try_files /rosters/requests.html =404; add_header Cache-Control "no-store, no-cache, must-revalidate, private" always; add_header X-Robots-Tag "noindex, nofollow" always; }
    location = /requests/ { try_files /rosters/requests.html =404; add_header Cache-Control "no-store, no-cache, must-revalidate, private" always; add_header X-Robots-Tag "noindex, nofollow" always; }

    # ===== Gated static root (2026-04-10) =====
    # Everything served from html/ that doesn't match a more specific
    # location above now requires authentication: the landing page, POCUS/,
    # investing/, crna-pay-calculator/, shift-calc/, pulse/, infus/, nav/,
    # rsvp/, mac/, cardiac-anes.html, ekg-tutor/, secrets.js, etc.
    location / {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;
        try_files $uri $uri/ =404;
    }

    # ===== Atlas API and WebSocket =====
    location /atlas/api/ {
        rewrite ^/atlas/api/(.*) /api/$1 break;
        proxy_pass http://localhost:3013;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_read_timeout 300;
    }

    location /atlas/agent {
        proxy_pass http://localhost:3013/agent;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400;
    }

    # ===== GitHub Webhook (auto-deploy adampowell.pro) =====
    location = /gitwebhook {
        proxy_pass         http://127.0.0.1:3005/gitwebhook;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_read_timeout 180s;
    }
}
```

> Note the HTTPS server lists several specific `location` blocks **after** the catch‑all `location /`
> (atlas, gitwebhook). nginx evaluates prefix locations by longest‑match regardless of source order, so
> this works — `/atlas/api/` and `= /gitwebhook` still win over `/`. The `/atlas/` *static* PWA has no
> dedicated HTTPS block; it falls through the catch‑all (auth‑gated) and is served from `html/atlas`.

---

## 4. Subdomain configs

All in `/etc/nginx/sites-{available,enabled}/`. Summary (full files on the box):

```nginx
# bp.adampowell.pro  — 80 + 443 (own LE cert) → 301 https://adampowell.pro/bp$request_uri
# mac.adampowell.pro.conf — 80 + 443 (MAIN cert) → 301 https://adampowell.pro/mac$request_uri
# mickey.adampowell.pro.conf — 80 + 443 (own LE cert) → 301 https://adampowell.pro/mickey$request_uri
# photography.adampowell.pro.conf — 80 only (HTTP origin; CF TLS) → 301 https://adampowell.pro/photo$request_uri
```

`dns.adampowell.pro.conf` is the only subdomain that serves content — it auth‑gates with the main session
cookie and proxies AdGuard Home (currently 502 because AdGuard is stopped):

```nginx
server { listen 80; server_name dns.adampowell.pro; return 301 https://$host$request_uri; }
server {
    listen 443 ssl http2;
    server_name dns.adampowell.pro;
    ssl_certificate     /etc/letsencrypt/live/adampowell.pro/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/adampowell.pro/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    location = /_auth_check {
        internal;
        proxy_pass              http://127.0.0.1:3003/api/auth/nginx-check;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length '';
        proxy_set_header        X-Original-URI $request_uri;
        proxy_set_header        Cookie $http_cookie;
    }
    location @login_redirect { return 302 https://adampowell.pro/login; }
    location / {
        auth_request     /_auth_check;
        error_page 401 = @login_redirect;
        proxy_pass http://127.0.0.1:8081/;        # AdGuard Home
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;     # AdGuard live query log WS
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}
```

---

## 5. Route reference (what each path does)

| Location | Auth | Target | Purpose |
|---|---|---|---|
| `= /login` | public | `:3003/login.html` | login page |
| `/auth/api/` | public | `:3003/api/` | login/logout/session |
| `= /_auth_check` | internal | `:3003/api/auth/nginx-check` | the gate subrequest |
| `@login_redirect` | — | `302 /login` | named error handler for 401s |
| `= /favicon.ico` | public | `html/favicon.ico` | avoids 401 loops |
| `= /gitwebhook` | HMAC | `:3005/gitwebhook` | deploy webhook |
| `= /nav.json` | gate | `html/nav.json` | gated landing tiles |
| `= /dns-query` | — | `444` | DoH abuse sink |
| `= /investing/secrets.js` | gate | `html/investing/secrets.js` | no‑store OpenAI key file |
| `/case-tracker/` + `= /case-tracker/manifest.json` + `/api/casetracker` | gate (manifest public) | `:3100` | case tracker |
| `/todos/` + `/todos/api/` | gate | `:3008` | todos (SSE) |
| `/petfeed/` + `/petfeed/api/` | gate | `:3011` | pet meals (SSE) |
| `/RandEmail/` + `/RandEmail/api/` | gate | `:3009` | alias manager |
| `/remote/` + `/remote/api/` | gate | `:3010` | FormFill PWA |
| `/remote/api/daemon/` | **bearer** | `:3010` | PC daemon SSE (no cookie) |
| `/sharedtodo/api/` | **bearer + X-User** | `:3014` | iOS widget sync |
| `/sharedtodo/` | gate | static | setup page |
| `/ANSReset/` | gate | static | NSR PWA |
| `/ekg-tutor/api/` | gate (+ in‑app) | `:3110` | EKG API |
| `/command-center/` | outer gate | `:3015` | CC HTML shell |
| `/command-center/{api,sse,ws}` | **inner WebAuthn** | `:3015` | CC API/SSE/WS |
| `/janda/` | gate | `:3004` | janda (400s timeout) |
| `/chat/` | gate | `:3002` | messenger |
| `/atlas/` | gate | static `html/atlas` | Atlas PWA |
| `/atlas/api/` + `/atlas/agent` | (no `auth_request`) | `:3013` | Atlas broker + WS |
| `~ ^/(bp\|mickey\|photo)/.*\.php$` | gate | PHP‑FPM | consolidated PHP |
| `/nda/`, `/purchase-orders/` | PHP password | PHP‑FPM (`root /var/www/adampowell.pro`) | signing apps; `records/`+`signatures/` denied |
| `/npa/api/` | **none** (own password / token in‑app) | `:3016` | NPA/NDA e‑signing API; `client_max_body_size 8M` |
| `/npa/sign/` | **none** (signing token) | static `sign.html` | recruiter signing page (`/npa/sign/<token>` → sign.html) |
| `/npa/` | **none** (own password in‑app) | static `html/npa` | Adam's create‑agreement form |
| `/rosters/api/signup/` | **none** (slug‑scoped) | `:3017` | public student signup lookup + submit; `limit_req` |
| `= /rosters/api/request` + `/rosters/api/request/` | **none** (captcha + entry‑pass in‑app) | `:3017` | public class‑request API (exact POST + `/entry`,`/enter`,`/courses`); split so admin `/requests*` stays gated; forwards `CF‑Connecting‑IP`+`CF‑IPCountry`; `limit_req` |
| `/rosters/api/pay` | **none** (per‑request pay token) | `:3017` | public pay/coupon API (`/api/pay/<token>[/coupon]`); `limit_req` |
| `/rosters/api/` | **MAIN login (`auth_request`)** + app bearer | `:3017` | **admin API** (classes/students/config/requests*/pay‑link/mark‑paid/auth/session/health); forwards `Authorization`+`CF‑Connecting‑IP`+`CF‑IPCountry`; catches everything under `/rosters/api/` not matched above |
| `/rosters/signup/` | **none** (slug‑scoped) | static `html/rosters/signup.html` | public signup page (`/rosters/signup/<slug>`) |
| `/rosters/pay/` | **none** (per‑request pay token) | static `html/rosters/pay.html` | public Venmo pay page (`/rosters/pay/<token>`) |
| `/rosters/eval/` | **none** (pay token; API also requires PAID) | static `html/rosters/eval.html` | public course‑eval page (`/rosters/eval/<token>`; API rides `/rosters/api/pay/<token>/evals`) |
| `/rosters/answersheet/` | **none** (pay token; API also requires PAID) | static `html/rosters/exam.html` | public exam answer‑sheet page (`/rosters/answersheet/<token>`; API rides `/rosters/api/pay/<token>/answersheet`) |
| `/rosters/` | **MAIN login (`auth_request`)** + app password | static `html/rosters` | **admin panel** (index + assets; incl. `answersheet.html` instructor tool) |
| `= /requests`, `= /requests/` | **none** (captcha + honeypot + rate‑limit) | static `html/rosters/requests.html` | public class‑request form (API rides `= /rosters/api/request`) |
| `= /robots.txt` | **none** | static `html/robots.txt` | `Disallow: /` (kept ungated so crawlers can read it) |
| `/maps/`, `/api/` | gate | `:3001` (gone) | retired → 502 |
| `/` (catch‑all) | gate | `html/` static | landing, POCUS, investing, crna, medcalc, preop, nav, rsvp, mac, ekg shell, pulse shell. `/infus/` = client‑side redirect stub → `/medcalc/` (merged 2026-06-08; no nginx redirect — the stub clears the old SW/caches and forwards) |

---

## 6. Patterns & gotchas (nginx)

- **Edit BOTH files.** `sites-available` and `sites-enabled` are copies; a one‑file edit silently no‑ops.
- **Trailing slash on `proxy_pass`** strips the location prefix (e.g. `proxy_pass http://127.0.0.1:3015/;`
  makes the Go binary see `/`, `/api/…`). This is load‑bearing for command‑center, ekg, sharedtodo.
- **SSE blocks** need `proxy_buffering off; proxy_cache off; chunked_transfer_encoding on;` + 24h timeouts.
- **Bearer endpoints** (`/remote/api/daemon/`, `/sharedtodo/api/`) drop `auth_request` and forward
  `Authorization`; they sit before the catch‑all and win by longest‑prefix.
- **Command Center split** (`# CC-SPLIT v1`): inner‑auth API/SSE/WS + outer‑gated HTML shell, plus
  `proxy_cookie_path / /command-center/` so WebAuthn/session cookies scope to the subpath. Fixes iOS PWA
  `TypeError: Load failed` on expired outer sessions. (Side effect: `/command-center/api/system/health`
  is publicly readable.)
- **`auth_basic` + `auth_request` together = redirect loop.** Don't combine them on one block; the main
  session is the single gate (per‑app basic‑auth second factors were removed 2026-04-18; the leftover
  `/etc/nginx/.htpasswd-*` files are unused).
- **PHP consolidation:** one regex block handles `/bp /mickey /photo` PHP; `root` + symlinks give PHP‑FPM
  the right `SCRIPT_FILENAME`. `/nda` and `/purchase-orders` use `root /var/www/adampowell.pro;` + the
  repo‑root compat symlinks.
- **Adding a route:** back up both files → edit both → `nginx -t` → reload → smoke test (302 unauth, 200
  auth). For a static/PWA app you usually need **no** nginx edit — just `rebuild-symlinks.sh` + `nav.json`.

---

**Maintained by:** Adam Powell · **Server:** root@198.211.114.12 · keep in sync with [`SYSTEM.md`](SYSTEM.md).
