# adampowell.pro — Complete System Documentation

> # 🔴 LEGACY — this documents the DigitalOcean droplet, which is being retired
>
> **2026-08-02: the site was rebuilt on self-hosted hardware** — Proxmox LXC **CT 116**
> (`192.168.1.37`), public via **Cloudflare Tunnel**, no inbound port open. The droplet
> `198.211.114.12` this document describes is now **legacy + rollback only**.
>
> **Everything below is still accurate *about the droplet*** and is kept as the migration's source
> reference — but it is **no longer the current state of the website**. For how the site runs today
> read `c:\dev\homelab\HOMELAB_MASTER_INFRASTRUCTURE.md` (CT 116 row).
>
> **Cutover status: DONE — flipped 2026-08-03.** Apex + `www` now `CNAME` → the Cloudflare Tunnel and
> serve from CT 116. This droplet no longer receives production traffic; it is kept as instant
> rollback for ~2 weeks (apex + `www` back to `A 198.211.114.12`), then cancelled.
>
> **Differences on the new host that this document does NOT describe:** apps run as a non-root `app`
> user; secrets live in `/etc/app/secrets.env` (off-repo); units are `Restart=on-failure`; nginx is
> **split** into a public vhost (confidential apps structurally absent) + a tailnet-only vhost;
> Postgres 16 / Node 22 / PHP 8.3 replace the droplet's versions; mail relays via Proton SMTP; and
> deploy is a **5-min pull timer only** — no webhook, no CF cache purge, no deploy email
> (see the deploy-rule note in [`../CLAUDE.md`](../CLAUDE.md)).
>
> **Purpose of this document.** This is the single, self‑contained reference for the entire
> adampowell.pro setup — hardware, OS, software, network, services, every website/app, all
> URLs, file trees, databases, secrets layout, deploy model, and the hard‑won gotchas. Hand
> this file (plus its two companions) to a person or an AI and they should be able to operate
> the box without further context.
>
> **Companion docs (read together):**
> - [`SERVER_NGINX_ROUTES.md`](SERVER_NGINX_ROUTES.md) — the full live nginx config + every route.
> - [`WHEN CREATING A NEW APP.md`](WHEN%20CREATING%20A%20NEW%20APP.md) — the playbook for adding an app.
>
> **Last verified against the live server:** 2026-06-06 (full audit); **partial update 2026-06-13** for janda
> (restored from a Node‑22/`better-sqlite3` ABI crash + big feature build: persistent sessions, env session
> secret, gpt‑5.4‑mini tarot, 8 new feature views, 380 questions). pm2 `janda-app` is back ✅ online.
> **2026-06-21** — customizable per‑user landing dashboard added (auth `/api/layout` endpoints +
> `dashboard_layouts` table; see §8, §14, §19).
> **2026-06-26** — **Preop Risk Eval** (`/preop/`) added and iterated to **v1.8** (SW `preop-v8`): a static,
> offline PWA preop risk + facility‑appropriateness report builder (~115‑item comorbidity catalog + ~120‑surgery/
> ~150‑med type‑ahead pickers; report ends in a forced one‑line GO/NO‑GO/GO‑IF verdict), **plus on‑device voice
> dictation + paste‑a‑narrative** (in‑browser Whisper, Tiny/Base/Small selector, WASM‑q4→WebGPU‑fp32→WASM‑fp32;
> audio never leaves the device, weights browser‑cached not in repo). Pure client‑side, **no PHI stored or
> transmitted** (in‑memory only), no backend/port/nginx change — whole‑dir symlink + nav.json tile only (see §10, §11, §19).
> **2026-06-27** — **Preop Risk Eval → v1.9** (SW `preop-v9`): full audit‑driven hardening pass. Narrative
> parser now clause‑scoped with **negation + family‑history handling** ("denies stroke" / "father had MI" no
> longer add conditions; "rule out PE" → a separate "confirm" list); EF ranges + multi‑anticoagulant parsing;
> BMI unit‑sanity flag; a11y (radiogroup keyboard nav, mic recording state, larger targets); SW keeps the
> Whisper cache across version bumps + update‑prompt + guaranteed offline shell; clearer OpenEvidence‑egress
> disclosure. Decided to **stay 100 % on‑device** (rejected cloud‑LLM parsing — no BAA, PHI must not leave).
> Still no backend/port/nginx change. Follow‑up tracked: self‑host the transformers.js bundle + weights.
> **2026-06-27 — Preop Risk Eval → v2.0** (SW `preop-v10`): major OpenEvidence report‑template upgrade.
> Report now leads with an **INSTRUCTIONS block pinning named current guidelines** (2024 AHA/ACC perioperative;
> Nasr 2023 pediatric‑CHD statement; Dec‑2024 multisociety GLP‑1 consensus) and **auto‑routes adult vs
> pediatric** (age <18 → no RCRI/MICA). Emits the **full structured scaffold every time, marking blanks as
> `[not assessed]`/`[not specified]`** so the LLM flags gaps instead of imputing. ~15 new input sections added
> (snapshot: approach/duration/dependence/code; anesthetic hx; airway·sleep·aspiration + STOP‑BANG; structured
> cardiac‑workup + labs; high‑impact med detail; facility staffing/transfer). 6‑part question set. Still
> 100 % on‑device, no backend/port/nginx change. Source: operator‑researched template.
> **2026-07-02 — Preop Risk Eval → v2.1** (SW `preop-v11`): usability + privacy + polish release from a
> multi‑agent audit (24 confirmed findings). **UX:** sticky section jump‑strip (scroll‑spy + live per‑section
> entry counts), Selected‑problems list moved above the chip groups (detail fields appear where the eye is,
> add scrolls‑to + flashes), collapsed the once‑a‑month speech‑model/diagnostics chrome, comorbidity search
> Enter‑to‑add + clear button. **Safety:** report marked OUT‑OF‑DATE (amber) after edits so a stale report is
> never copied to OpenEvidence (Copy auto‑regenerates); dictation Apply now highlights + scrolls to the fields
> it filled; header ↻ update button confirms before wiping a half‑entered patient; errors show in a RED toast
> (not the green success pill). **Privacy:** added a browser **Content‑Security‑Policy** (patient data can only
> reach the origin; sole outbound = the on‑device speech download from jsDelivr/HF), `spellcheck="false"` on all
> clinical fields (blocks Chrome cloud spell‑jacking), an app‑switcher **privacy veil** on backgrounding, debug
> log no longer leaks BMI/sex/dx‑list in redacted mode + clears on Reset + double‑checks the include‑text guard
> at copy time, model‑reset now targets only `transformers-cache`. **UI:** light‑mode (`prefers-color-scheme`)
> + reduced‑motion support, larger labels/touch targets, 16px inputs (no iOS zoom). Report template + on‑device
> model unchanged. Still 100 % on‑device, no backend/port/nginx change. jsdom 47/47 + Playwright 16/16 verified.
> **2026-06-30 — NPA Signing (`/npa/`) added.** Self‑hosted CRNA Non‑Presentation & Non‑Disclosure
> Agreement e‑signing — no third‑party signing API, no recurring cost. New Node backend `npa.service`
> (port **3016**, localhost‑only) + a static frontend (`apps/npa/` index.html + sign.html). Adam fills
> recruiter + Exhibit A details behind its **own bcrypt password gate** (`NPA_PASSWORD_HASH`, 2h in‑memory
> session — NOT the main login), the app emails the recruiter a **single‑use, 7‑day, crypto‑random signing
> token** (stored hashed; same trust model as an email‑verify link), and the recruiter signs through a hosted
> consent→review→sign flow. On signing the server captures **IP + server‑side timestamp** (never a client
> time), renders the signature + audit footer onto the PDF with `pdf‑lib` (pure JS — no Chromium), computes a
> SHA‑256 of the final bytes, builds a certificate of completion, saves both PDFs, invalidates the token, and
> emails Adam both PDFs via the local postfix MTA. Storage is a single atomic‑write JSON file (no DB);
> `data/` + `records/` are gitignored signer PII. **nginx edit required** (`/npa/api/`, `/npa/sign/`, `/npa/`
> bypass the main session gate — Adam side has its own password, recruiter side is token‑gated). The audit
> certificate **explicitly states its own limitation**: it is the sender's own server attesting to its own
> record, not an independent third party (see `apps/server/npa/README.md`). See §3, §4, §10, §11, §14, §19.
> **2026-06-30 (same day) — NPA reworked to TWO‑PARTY signing + mail fixed.** Adam now **pre‑signs** at
> creation (signature + initials) and the recruiter **counter‑signs** (signature + initials); both render **on
> the actual §10 lines** and both initial **every page** (no separate signature page). The certificate gained a
> **Device & network evidence** block (User‑Agent → browser/OS/device + client hints), the audit IP is now
> **`CF‑Connecting‑IP`** (the real signer, not the Cloudflare edge), and the completed agreement emails **only to
> Adam, never the recruiter**. Outbound mail (which was spam‑foldered sending direct from the droplet IP) now
> **relays through Proton SMTP** so it passes SPF/DKIM. Full flow verified live. See §2, §9, §11, §19.
> **2026-06-30 (later) — Rosters (`/rosters/`) added.** AHA class roster + public student self‑signup for
> courses (ACLS/BLS/PALS) taught at Ace Medical. New Node backend `rosters.service` (port
> **3017**, localhost‑only, `apps/server/rosters/`) + a static PWA frontend (`apps/rosters/`: `index.html`
> admin panel + `signup.html` public form). **Auth model = npa's, NOT the main login:** the whole app bypasses
> the main adampowell.pro session gate — the **admin panel has its own bcrypt password** (`ROSTERS_PASSWORD_HASH`
> → 2h in‑memory bearer token in `sessionStorage`, so Adam can reach it from a device that isn't signed into
> the main site), and the **public student signup** is scoped to an unguessable per‑class slug (a bad slug
> returns a generic 404 — valid vs invalid indistinguishable). Adam creates classes (random slug + signup URL),
> reviews **Pending** self‑registrations vs the **Approved** roster, edits/approves/rejects/manually‑adds
> students, and uses per‑student **paste‑chain** copy helpers that put a tab‑separated string on the clipboard
> for Enrollware's Student Scores and AHA eCards' "Assign to Students" forms (paste into the first field + tab
> across — the realistic ceiling, since same‑origin policy forbids filling another site's form directly; no
> scripting of enrollware.com/heart.org). The course→AHA‑course‑name map and both paste‑chain field orders live
> in an editable server‑side config (admin panel editor → `PUT /api/config`), correctable with no code push.
> Storage is a single atomic‑write JSON store (`data/rosters.json` + rolling 50 backups); `data/` is
> **gitignored student PII**. **nginx edit required** (four blocks, all **ungated**, before the catch‑all:
> `/rosters/api/signup/`, `/rosters/api/`, `/rosters/signup/`, `/rosters/`). See §3, §4, §9, §10, §11, §14, §19.
> **2026-07-01 — Rosters payment build‑out + paid‑student follow‑up.** Post‑payment the app now drives students
> to **course evaluations** (`/rosters/eval/<payToken>`, AHA ACLS/BLS/PALS objectives, required per class bought)
> and an **offline‑friendly exam answer sheet** (`/rosters/answersheet/<payToken>`, student bubbles A–D — **no AHA
> exam content is stored or reproduced**, just their answers + score). Both **only open after Adam marks the
> request paid**. Marking paid now **auto‑emails the student** their two links (fire‑and‑forget via the local MTA),
> and the admin **Requests** row gained **copy‑eval‑link / copy‑answer‑sheet‑link buttons, per‑student completion
> status** (eval / sheet done vs purchased courses), and a **resend‑email** button (new admin route
> `POST /rosters/api/requests/:id/resend-followup`). Venmo note is the 🍎🍌🥜 emoji. Admin panel double‑gated
> (main login + own password). **No nginx change** — the new route is under the already‑gated admin `/rosters/api/`
> prefix and the eval/answer‑sheet public shells already existed. Verified with a local Playwright run against a
> seeded instance (avoids Cloudflare Bot Fight Mode). See §11, §19.
> **2026-07-01 (later) — Rosters eval + answer sheet consolidated to ONE combined sheet each.** For a student who
> bought multiple classes, both `eval.html` and `exam.html` now render **all** their courses on a single scrolling
> page (no per‑course picker) with **one submit button**; the client **hard‑blocks** submit until every field of
> every course is filled (removed the old "submit anyway" soft‑confirm on the answer sheet). Exam question counts are
> now **hard‑coded server‑side** (`EXAM_QUESTION_COUNTS` = BLS 25, ACLS 50, PALS 50, sent via the answer‑sheet GET as
> `numQuestions`) so there's no student‑editable count box; bubbles are A–D. Per‑course POST endpoints + admin
> views/records unchanged (submit loops them). Frontend‑only + one added GET field — **no nginx/port/service change**.
> Verified end‑to‑end via local Playwright (2 courses, hard‑block + one‑button submit + both records saved). See §11.
> **2026-07-01 (later²) — rosters eval quality‑of‑life + a DEPLOY BUG FIX.** (1) Eval gained an optional **⚡ auto‑fill
> button** that sets every rating to the most positive answer ("Strongly agree"/"Excellent", "True", "N/A" comment)
> to save students time; (2) **"Date of course" now prefills to today** on both the eval and the answer sheet (editable —
> the request has no confirmed class date, only free‑text preferred times, so today is the pragmatic default); (3) the
> answer sheet's **BLS count is 25** (was rendering 50 pre‑deploy from the old client default). **DEPLOY FIX:**
> `rebuild-symlinks.sh` now also **`systemctl restart rosters.service`** — it previously only restarted `remote.service`,
> so every rosters `server.js` change (e.g. the `resend-followup` route) landed on disk but **404'd in prod** because the
> running process never reloaded. This is why the "Email these to student" button was 404ing. All systemd node backends
> must have a restart line in that script now. See §2 (deploy), §11.
> **2026-07-01 (later³) — rosters admin "Class history" + student search + printable QR flyer.** (1) The admin panel
> gained a **Class history** section: one **collapsible card per request/student** that bundles that student's request +
> payment/follow‑up + their evaluation(s) + their answer sheet(s) (grouped client‑side by `requestId`; the flat
> Requests/Evaluations/Answer‑sheets lists are kept alongside, now all collapsible `<details>`). (2) A **search box**
> filters history by **name or phone**. (3) New **printable QR sign‑up flyer** (`apps/rosters/signup-flyer.html`, linked
> from Instructor tools + symlinked): fun US‑Letter flyer with an inline **static‑SVG QR** (qrcode‑generator, MIT — no
> external calls, prints offline) pointing to `adampowell.pro/requests`. Frontend‑only (no server/route/nginx change);
> flyer symlink added to `rebuild-symlinks.sh`. Verified via local Playwright (history grouping, search by name+phone,
> collapse, flat lists intact) + QR byte‑matched to the target URL. See §11.
> **2026-07-01 (later⁴) — DEPLOY PIPELINE FIX (this explains today's symptoms: flyer 404, BLS=50, resend 404).**
> `scripts/rebuild-symlinks.sh` was tracked in git as mode **100644 (non‑executable)**, so every checkout landed it
> non‑executable. **Both** deploy paths guarded the call with `[ -x <script> ]`, which then failed, and `|| true`
> swallowed it — so the symlink rebuild + `systemctl restart remote/rosters` **silently never ran** whenever a deploy
> landed via the **5‑min `git-auto-pull-adampowell.service`** (whose ExecStart was a bare `git fetch && git pull` with
> NO rebuild/restart at all). Net effect: HEAD advanced but new files (`signup-flyer.html`) had no symlink → 404, and
> systemd node services never reloaded → stale routes/counts. **Fixes:** (1) marked the script `100755` in git; (2)
> `github-webhook.js` now guards on `[ -f ]` and runs `bash <script>` (no exec bit needed); (3) **server‑side systemd**
> (not in repo): rewrote `git-auto-pull-adampowell.service` ExecStart → `git fetch && git pull && bash
> rebuild-symlinks.sh` so the timer deploys identically to the webhook (old unit backed up as `*.bak-20260701-201424`;
> webhook pm2 process restarted to load the fix). Verified end‑to‑end: broke a symlink → auto‑pull recreated it +
> restarted services. **Lesson: a bare `git pull` deploy path must ALSO run rebuild-symlinks + service restarts, and
> never gate the rebuild on the exec bit.** See §2 (deploy), §10 (rebuild-symlinks), §11.
> **2026-07-02 — Rosters data-safety + anti-abuse hardening** (`rosters.service`, no port/nginx change). Backend
> `server.js` only. **Data safety:** `loadStore()` no longer silently starts empty on a corrupt `rosters.json` (the
> next save would have snapshotted the corrupt file into the rolling‑50 backup pool and eventually lost the real
> data) — it now preserves the corrupt file as a non‑rotating `data/rosters.corrupt.<ts>.json`, recovers from the
> newest parseable rolling backup, and only starts fresh as a last resort; `normalizeStore()` extracted so the
> recovery path back‑fills config/buckets identically. A `saveStoreSafe()` (swallows a throwing save in
> fire‑and‑forget email callbacks) + a `process.on('unhandledRejection')` guard stop a failed async save (e.g.
> ENOSPC) from crash‑looping the service. **Anti‑abuse:** rate limits now key on `clientIp + X‑Real‑IP` (nginx's
> real TCP peer) so a bot on a direct‑to‑origin hit can't rotate a spoofed `CF‑Connecting‑IP` to dodge per‑IP
> limits; honeypot fields renamed `website`/`company` → `xq_alpha`/`xq_beta` (browser profile‑autofill was filling
> the old names and silently dropping real requests as bots; legacy keys still checked); min‑fill/min‑solve checks
> now require a non‑negative client‑clock delta (a device clock ahead of the server was permanently locking real
> people out); a per‑pay‑token coupon‑try cap (20) blocks brute‑forcing a short coupon code on a direct hit; eval
> submit is now **idempotent** (guarded by a new `MAX_EVALS` store cap) so the client's multi‑course retry loop is
> safe and a token holder can't grow the store by replaying. **Correctness:** `resend‑followup` awaits the send and
> returns 502 on failure (no phantom "Sent" for a bounced email); payment‑claim email states the coupon‑discounted
> amount Adam should expect in Venmo. Committed as pre‑existing WIP (added the missing `MAX_EVALS` constant it
> referenced — would've thrown on first eval submit); verified the service boots + the eval route resolves via a
> runtime smoke test. See §11.
> **2026-07-27 — Admin can create a request directly (no public form) + answer‑sheet correctness completed.**
> Prompted by a real need: Adam had 2 students to send Venmo pay links to who signed up via a class's signup link
> (roster‑only — signups have NO payment attached), not the public `/requests` captcha form. New **`POST
> /api/requests`** (admin‑only) creates a request record directly from name/email/phone/courses — no
> captcha/entry‑pass/rate‑limit needed (already authenticated), `status:'handled'`, no notify email (Adam is the
> creator). Admin panel: **"+ Add a student to send a pay link"** in the Class requests section — on save it
> jumps straight to the existing pay‑link UI. **Answer‑sheet route completed to match the 2026‑07‑02 eval
> pattern** (same file, finishing work the audit flagged but hadn't reached yet): `POST
> /api/pay/:token/answersheet` is now **idempotent** (guarded by a new `MAX_ANSWERSHEETS` cap), **numQuestions is
> enforced server‑side** from `examQuestionCount()` (never trusted from the client — closes a hole where a
> hand‑crafted POST could record an empty sheet as "done"), answer keys outside the valid range are rejected, and
> the server now **requires every question answered** before accepting a submission (matches the client's
> existing hard‑block — a modified/direct POST could previously record a partial sheet as complete). `GET
> /api/pay/:token/answersheet` compares `sheetsDone` case‑insensitively (mirrors the eval fix — sheetsDone always
> stores uppercase; a mixed‑case admin course type like "ACLS‑Instructor" was showing permanently incomplete after
> a real submit). Verified with a full local Playwright run: admin login → add student → set price → pay link →
> student pays → admin marks paid → student completes eval + exam (ACLS = 50 questions, server‑enforced) — all 17
> checks passed end‑to‑end. No port/nginx change. See §11.
> **2026-07-27 (later) — "Create pay link" button on approved students.** Adam tested the above by approving
> himself onto a class roster and asked why no pay link followed — surfaced that class‑signup (roster) and paid
> requests are separate tracks with no bridge between them. Each **approved** student in the class‑detail view
> (`renderStudent`, `apps/rosters/index.html`) now has a **"Create pay link"** button (`startPayLinkFor(s)`):
> jumps back to the list view, expands Class requests + the add‑student form, and **prefills name/email/phone/
> address from the student's roster record + pre‑checks the class's own course** — stops one click short of
> creating the request so Adam can glance it over first. Frontend‑only, reuses the existing `POST /api/requests`
> route (no backend change). Verified with local Playwright: button appears only on approved students, prefill
> matches exactly, only the class's own course is checked, and the resulting request is created correctly — 9/9
> checks passed. See §11.
> **2026-07-27 (later²) — FIX: rosters Venmo QR image gated, broken for every real student since launch.**
> `pay.html`'s `<img src="/rosters/venmo-qr.jpg">` sits at the root of `/rosters/` — outside the `/rosters/pay/`
> prefix — so nginx fell through to the **gated** admin catch-all and 302'd unauthenticated visitors to `/login`.
> The public pay page has no login, so the QR has likely never rendered for a real student (the tap-to-open Venmo
> button still worked; only scan-with-another-device was broken). Found from a student-reported screenshot;
> **nginx-only fix** — added `location = /rosters/venmo-qr.jpg` (ungated, exact match) to both site files,
> `nginx -t` clean, reloaded, verified via loopback (200/JPEG/266KB) + a full gated/ungated smoke test (nothing
> else changed). Swept every public rosters page for the same root-relative-asset pattern — this was the only
> instance. Backups `*.pre-qrfix.20260727-180851`. See docs/SERVER_NGINX_ROUTES.md for the block + full writeup.
> **2026-07-27 (later³) — Venmo deep-link was TWO REAL BUGS, both fixed + live-verified by Adam.** Found via
> live testing (button opened Venmo's profile page instead of prefilling): (1) `venmoLink()` built
> `venmo.com/u/ATP28?...` — the `/u/` path is Venmo's PROFILE page, which ignores `txn`/`amount`/`note`
> entirely; the documented deep-link format drops it (`venmo.com/ATP28?txn=pay&amount=..&note=..`), confirmed
> against two independent published sources before changing a live payment link. (2) Even after that fix, the
> Venmo app flashed "Open app" then bounced back to the browser — `money()` rounds to cents but returns a raw JS
> number, so a $234.50 charge serialized as `amount=234.5` (JS drops the trailing zero) and Venmo's app appears
> to strictly reject a non-2-decimal amount. `venmoLink()` now forces `Number(amount).toFixed(2)`. **Adam
> confirmed the app now opens with the amount pre-filled after both fixes.** Separately, **the Venmo QR code on
> the pay page is now hidden** (operator request — button-only flow for now; CSS `display:none`, markup kept,
> not deleted). No nginx/port change, frontend + `apps/server/rosters/server.js` only.
> **2026-07-27 (later⁴) — Reset evaluation(s)/exam answer sheet(s) buttons + a real "student can't resubmit"
> bug fixed.** Adam did a full live multi-course test (self-signed-up, paid, submitted 3 evals + 3 exams) and
> asked for a way to reset a student's eval/exam so they can redo it. Building it surfaced a real pre-existing
> bug: the single-record `DELETE /api/evals/:id` / `DELETE /api/answersheets/:id` routes deleted the record but
> never cleared the owning request's `evalsDone`/`sheetsDone` — so a student whose submission Adam deleted was
> **permanently** unable to resubmit (the pay-token page kept reporting it done, and the idempotent POST route
> — added 2026-07-02 — treats "already in evalsDone" as already-submitted and silently no-ops). Both DELETE
> routes now also strip the course key from the owning request. New **`POST /api/requests/:id/reset-evals`** /
> **`reset-answersheets`** delete ALL of a student's records of that type in one atomic call and clear the
> array entirely. Admin panel: **"Reset evaluation(s)"** / **"Reset exam answer sheet(s)"** buttons on each
> Class History card (only shown when there's something to reset; `confirm()` dialog, destructive). Verified
> with local Playwright: buttons appear/disappear correctly, reset clears server state, and — the real proof —
> **the student's eval/exam pages actually reopen fresh and accept a brand-new submission afterward** (new
> record IDs, not a mutation of the old ones) — 15/15 checks passed. No nginx/port change.
> **2026-07-27 (later⁵) — Admin session: sliding + localStorage (fixes "booted out every minute").** Root
> cause: the bearer token lived in `sessionStorage`, which mobile browsers routinely wipe on tab close/
> reopen or when a backgrounded tab's memory gets reclaimed — Adam's "booted every minute" was almost
> certainly this, not the actual 2h server-side TTL. Fixed both ends: (1) frontend now stores the token in
> `localStorage` (survives tab/app switching; still bearer-only, never a cookie, still behind the double
> gate). (2) `sessionValid()` is now **sliding** — every authenticated request pushes the server-side expiry
> forward another `SESSION_TTL_MS`, so continuous use never hits the ceiling; only true idle time logs him
> out. (3) Bumped the base TTL 2h → **12h** as headroom. Verified with local Playwright, including simulating
> a tab close/reopen (new browser context carrying only localStorage forward) — logs straight back in with
> no re-auth, and the server-side session confirmed still valid after a check (sliding). 8/8 checks passed.
> **2026-07-27 (later⁶) — Paid-confirmation email now attaches AHA 2025 algorithm/guideline PDFs, scoped to
> course.** `sendPaidFollowup()` (fires on mark-paid) now attaches `AHA_2025_Changes_Summary.pdf` (every
> paid student, always attachment #1) plus course-specific guideline PDFs — ACLS → ACLS Guidelines +
> Special Circumstances + Post Cardiac Arrest Care; BLS → Adult BLS + Pediatric BLS; PALS → PALS + Special
> Circumstances + Post Cardiac Arrest Care — deduped for multi-course purchases (`guidelineAttachmentsFor()`
> in server.js). 7 source PDFs live in new `apps/server/rosters/attachments/` (**committed to git** — the
> only way a file reaches the server; not gitignored, unlike `data/` which holds student PII). Verified with
> a real local SMTP round-trip (a minimal raw-socket SMTP catcher, no external service) — decoded every MIME
> attachment from an actual sent email and confirmed byte-for-byte identical to the source files (sizes:
> 4,347 / 651,189 / 172,874 / 110,167 / 1,870,009 — all matched exactly) with valid `%PDF-` headers, correct
> order (Changes Summary first), and correct per-course scoping/dedup verified separately in isolation.
> **2026-07-27 (later⁷) — REPO-WIDE FIX: added `.gitattributes`, binary assets were unprotected from CRLF
> corruption.** `git add`ing the guideline PDFs above (previous entry) warned *"LF will be replaced by CRLF"*
> on one of them — a real risk, not cosmetic: git's binary-detection is content-sniffing (a NUL byte in the
> first ~8000 bytes), which can misfire on small/simple binary files, and Windows checkouts with
> `autocrlf`-style conversion can silently corrupt such a file's bytes. Root repo had **zero** `.gitattributes`
> despite already tracking 119 `.jpg` + 64 `.png` + 12 `.pdf` + 5 `.JPG` + 3 `.ico` + 4 `.svg` files — every one
> of them was exposed to this same latent risk, not just the new PDFs. New root `.gitattributes` forces
> `binary` handling for pdf/jpg/jpeg/JPG/png/ico/gif/webp/woff/woff2/ttf/eot/zip/gz. Verified: re-staged the
> PDFs under the new file → warning gone; pulled the staged git blob back out with `git cat-file` and
> `cmp`'d it byte-for-byte against the source file on disk — **identical**, confirming nothing had already
> been corrupted and the fix is effective going forward.
> **2026-07-27 (later⁸) — "Send pay link to `<email>`" button.** Adam asked for a one-click way to email a
> student their Venmo pay link after setting price/coupon, instead of the copy-to-clipboard-then-paste-
> yourself flow. New `sendPayLinkEmail()` + `POST /api/requests/:id/send-pay-link` (mirrors the paid-followup
> email's shape/conventions — AWAITS the send so the button reports the true outcome). Email states the price,
> and if a coupon is set, the discounted price + code inline (`"$350 — or $315 if you enter the code SAVE10"`)
> so the student has everything in one message. Admin panel: button appears next to "Save & copy link" in
> each request's payment row, labeled with the actual email address; **hidden** (replaced by a "no email on
> file" hint) when the request has no email — e.g. an admin-created request left it blank. Clicking it
> re-saves whatever's currently in the amount/coupon fields first (shares the same save call as "Save & copy
> link"), so it always sends the price on screen, never a stale one. **Found + fixed a real UX bug while
> testing:** the first implementation called the list refresh immediately after showing "Sent to `<email>` ✓",
> which replaced the button (and the confirmation text with it) before a human could ever see it — fixed by
> delaying the refresh 1.2s, the same pattern the existing "resend eval/exam email" button already used.
> Verified with a full local Playwright + real local SMTP round-trip: confirmation text visible in the correct
> window, server-side `payLinkEmailedAt`/amount/coupon all persisted correctly, the "no email" case correctly
> hides the button, and the actual caught email decoded with the right subject/link/coupon math. 11/11 checks.
> **2026-07-27 (later⁹) — Student-facing emails now use a branded HTML template (were plain unstyled `<p>`
> tags).** Adam saw the pay-link email rendered as bare black-on-white paragraphs and asked for it to look
> more professional. New shared `emailShell()`/`emailPriceBox()`/`emailButton()` helpers in server.js — a
> table-based, fully inline-styled email template (the standard "email-safe HTML" pattern; a `<style>` block
> would be stripped by many clients). Navy header card matching the site's `#2e3473` accent, a styled price
> callout box (shows the coupon-discounted price inline when one's set), real button-styled CTAs (not plain
> blue links), footer with "Adam Powell / adampowell.pro" — **no "Ace Medical"/"Allied 100" branding**,
> consistent with every other public-facing rosters surface. Applied to both student-facing emails:
> send-pay-link (price callout + Pay Now button) and the paid-confirmation email (green "Payment confirmed"
> banner, two numbered action-step boxes for eval/exam, attachment list for the guideline PDFs). Verified by
> rendering the exact generated HTML in a headless browser first (catches broken tables/tags before a live
> send), then confirmed via a real send. Plain-text versions unchanged (already fine as plain text).
> **2026-07-27 (later¹⁰) — Rosters: a class can now offer MULTIPLE courses at once (e.g. one combined
> ACLS+BLS+PALS class), matching the multi-course pattern the request/payment/eval/exam side already had.**
> Adam asked for the class-detail "Edit class" course-type dropdown to allow picking all 3. Class records
> changed from a single `courseType` string to `courseTypes: []` (backend `pickCourses()`/`classCourseTypes()`
> helpers in `server.js`; legacy classes with the old singular field still read correctly via a fallback — no
> data migration needed, first edit of a legacy class silently upgrades it). Students gained their own
> `courses: []` field, since a student in a combined class may only take a subset of what's offered. Cascaded
> through: create/edit-class UI (checkbox multi-select, `apps/rosters/index.html` `#ccType`/`#dType`, reusing
> the existing `.coursechecks` style), the public signup page (`signup.html` shows a course-picker checkbox
> list when a class offers >1 course, mirrors `/requests`' `.chk` pattern; hidden entirely for single-course
> classes — zero UX change for the common case), roster display (per-student course chips, reusing the
> `.request .courses .c` pill style), manual-add-student (course checkboxes shown only when the class offers
> >1), student edit (same), "Create pay link" (now pre-checks the STUDENT's own courses, not the whole class's
> offered list), and the Enrollware/AHA paste-chain helper — `buildHelper()`/`ahaValue()` now render **one AHA
> eCards assignment block per course the student actually took** (each AHA eCard assignment is per-course), while
> the Enrollware scores block stays single (course-independent). Verified via 3 local Playwright suites against
> a scratch server instance (29/29 checks): a full combined-class flow (create → signup with partial course
> picks → approve → pay-link prefill → 2 separate AHA paste-chain blocks, no PALS block since the student didn't
> take it), a single-course class (confirms zero behavior change — no course picker anywhere), and a
> hand-injected legacy-format class record (old singular `courseType` field, no `courseTypes` array) to confirm
> the fallback reads it correctly end-to-end. No nginx/port change — `apps/server/rosters/server.js` +
> `apps/rosters/index.html` + `apps/rosters/signup.html` only.
> **2026-07-27 (later¹¹) — Rosters: Adam now gets emailed when a student finishes BOTH their eval and exam
> answer sheet(s).** Previously the only completion signal was checking the admin panel manually. New
> `studentFullyDone(r)` / `notifyIfFullyDone(r)` in `server.js` — fires a plain-text notify to
> `ROSTERS_NOTIFY_EMAIL` (same recipient/convention as the existing "payment claimed" email) the moment a
> request's evals AND answer sheets are BOTH fully submitted, for however many courses that request covers.
> Hooked into both `POST /api/pay/:token/evals` and `POST /api/pay/:token/answersheet` (whichever of the two
> finishes second is what triggers it — order-independent, submitting one when the other's already done fires
> it immediately). Guarded by a new `completionNotifiedAt` timestamp on the request so it fires **exactly
> once** — the existing idempotent-resubmit short-circuit in both POST routes already returns before reaching
> the notify call, and the guard itself no-ops if already set. All 4 admin-side reset paths (bulk
> `reset-evals`/`reset-answersheets`, and the single-record `DELETE /api/evals/:id`/`DELETE
> /api/answersheets/:id` that also clear the done-flags) now also clear `completionNotifiedAt`, so a genuine
> reset-and-resubmit correctly re-fires the notify rather than staying permanently silent. Verified against a
> scratch instance with a hand-rolled raw-socket SMTP catcher: eval-only submission correctly sent NO notify,
> completing the exam sheet second correctly sent exactly one, resubmitting either already-done piece sent no
> duplicate, and an admin reset-eval → resubmit correctly re-sent it — captured + decoded 3 real emails
> (1 paid-followup + 2 completion-notifies) across the sequence, all fired in the right order.
> **2026-07-27 (later¹²) — Rosters: "Close registration" button per class.** Adam finished teaching some
> classes and wanted the signup link to stop taking new students. New `cls.closed` boolean (default falsy —
> existing classes unaffected). Toggle button next to "Edit class" in the class-detail header
> (`#dCloseToggle` → `PATCH /api/classes/:slug` with `{closed}`), label flips Close/Reopen registration, and a
> red "Registration closed" pill shows on both the detail header and the class-list row when closed.
> **Deliberately narrow scope — closing ONLY blocks new public signups.** Everything else keeps working
> normally on a closed class: the roster, approve/reject, manual-add-student, edit fields, "Create pay link",
> the Enrollware/AHA paste-chain, evals/exams, Class history — none of it checks `closed`. `GET
> /api/signup/:slug` now returns `closed` so the public page shows a distinct "Registration closed" message
> (NOT the generic "link not found" — a closed link is valid, just not accepting new signups); `POST
> /api/signup/:slug` 409s server-side too (`{error:'closed', message:...}`) so a stale cached page or a direct
> POST can't slip a student in after closing. Fully reversible any time. Verified via local Playwright
> (17/17 checks): signup works before closing, blocked with the right message after, roster/approve/paste-chain
> unaffected while closed, class-list pill shows correctly, and reopening restores signups.
> **2026-07-27 (later¹³) — Rosters: trimmed the AHA eCards paste-chain to Date+Name+Email, added an
> instructor eCard-submission checklist to the admin panel.** Adam confirmed against the live
> ecards.heart.org "Assign to Students" form (screenshot) that Training Center/Training Site/Course/Instructor
> are ONE-TIME dropdowns set once per batch at the top of the page — NOT per-student row fields — so including
> them in the per-student paste-chain was always going to land in the wrong boxes. New default
> `ahaOrder: ['date', 'firstName', 'lastName', 'email']` (was the old 7-field order); added a brand-new `date`
> paste-chain key (`ahaValue()` in `index.html`, sourced from **the class's scheduled date**, not "today" —
> operator-confirmed) formatted `MM/DD/YYYY` via a new `fmtDateShort()` (the existing `fmtDate()` produces a
> long "Wed, Jan 1, 2026" string meant for reading, not for pasting into a date-parsing form field).
> `trainingCenter`/`trainingSite`/`course`/`instructor` remain valid **config options** (still in `AHA_KEYS`,
> can be re-added via Settings if the live form ever changes) — only the *default* order changed; since
> `DEFAULT_CONFIG` only seeds a fresh store, Adam's already-saved live config needed a one-time `PUT
> /api/config` push to actually pick up the new order (config precedence: saved value wins over the default).
> Also added a **"Submitting eCards — step by step" collapsible checklist** to the admin panel's Instructor
> tools card — the 4-step real-world workflow (buy eCards at acedmed.com → submit student cards at
> ecards.heart.org, using the "Copy for AHA eCards" button → submit roster + scores to Enrollware, using
> "Copy for Enrollware" → finalize the Enrollware roster), each step linked directly to the real external
> page. Verified via local Playwright (17/17 checks) including reading the ACTUAL clipboard content after
> clicking "Copy for AHA eCards": confirmed the TSV string pastes as `MM/DD/YYYY⇥FirstName⇥LastName⇥Email`
> with no training-center/site/course/instructor values mixed in.

---

## 0. The 30‑second mental model

- **One server.** A single DigitalOcean droplet (`198.211.114.12`, hostname `DNS`) runs everything.
- **One git monorepo** at `/var/www/adampowell.pro` is the source of truth and the deploy artifact.
  Local clone: `c:\ADAMANT\ADAMPOWELL PRO\adampowell.pro`. Remote: `git@github.com:t3h28/adampowell.pro.git`.
- **One login gate.** Almost every URL on `adampowell.pro` sits behind a single session login
  (`auth.service`, cookie `apsess`, nginx `auth_request`). A handful of apps add a second factor.
- **Deploy = edit locally → commit → push.** A GitHub webhook (and a 5‑minute git‑pull timer)
  pull the change, rebuild the `html/` symlinks, and the site updates. **Never edit/scp on the server.**
- **Apps are symlinked into the web root.** Sources live in `apps/<name>/` (frontends) and
  `apps/server/<name>/` (backends). `html/` is almost entirely symlinks into `apps/`.

---

## Table of contents

1. [Hardware & OS](#1-hardware--os)
2. [Installed software & versions](#2-installed-software--versions)
3. [Network: ports, bind addresses, firewall](#3-network-ports-bind-addresses-firewall)
4. [Services (systemd + pm2) and current status](#4-services-systemd--pm2-and-current-status)
5. [Deploy model](#5-deploy-model)
6. [Domains, DNS & Cloudflare](#6-domains-dns--cloudflare)
7. [TLS certificates](#7-tls-certificates)
8. [Authentication architecture](#8-authentication-architecture)
9. [Secrets & credentials](#9-secrets--credentials)
10. [URL / application index](#10-url--application-index)
11. [Application inventory (detailed)](#11-application-inventory-detailed)
12. [Repository file tree (local + server)](#12-repository-file-tree-local--server)
13. [Server filesystem layout](#13-server-filesystem-layout)
14. [Databases](#14-databases)
15. [Scheduled jobs (cron + timers)](#15-scheduled-jobs-cron--timers)
16. [Disk, backups & logs](#16-disk-backups--logs)
17. [Gotchas / hard‑won lessons](#17-gotchas--hard-won-lessons)
18. [Smoke tests & troubleshooting](#18-smoke-tests--troubleshooting)
19. [Condensed changelog](#19-condensed-changelog)

---

## 1. Hardware & OS

| Property | Value |
|---|---|
| Provider | DigitalOcean droplet (VPS) |
| Public IPv4 | `198.211.114.12` |
| Hostname | `DNS` |
| CPU | 1 vCPU (`DO-Regular`) |
| RAM | 989 MiB physical + **2 GB swap** (`/swapfile`, in `/etc/fstab`) |
| Disk | 25 GB (`/dev/vda1`), **~88% used (≈2.9 GB free as of 2026-06-06)** |
| OS | Debian GNU/Linux 10 (buster) |
| Kernel | `4.19.0-27-cloud-amd64` |
| Uptime | 277+ days (long‑lived; no reboots in normal ops) |

**SSH:** `ssh -i ~/.ssh/id_ed25519 root@198.211.114.12` (user `root`, key‑only, port 22 open to the world).
The `myserver` / `159.223.125.150` SSH alias in some configs is a **different, unrelated host** — never use it.

> ⚠️ The 2 GB swap is load‑bearing. Before it existed, the box ran out of RAM and `node` could not
> even `pthread_create` at startup, which is what made the old maps/aiomail services crash‑loop hundreds
> of thousands of times. Do not remove the swap file.

---

## 2. Installed software & versions

Verified live 2026-06-06:

| Software | Version | Notes |
|---|---|---|
| nginx | 1.14.2 | primary reverse proxy / TLS terminator at origin (Cloudflare in front) |
| Node.js | **v22.22.2** | upgraded from 18.x on 2026-05-18 (NodeSource) for EKG Tutor; older services keep running on open fds until restarted |
| npm | 10.9.7 | |
| pnpm | 10.33.0 | via corepack (used by the ekg‑tutor monorepo) |
| PHP | 7.3.31 (PHP‑FPM) | socket `unix:/run/php/php7.3-fpm.sock`; serves nda, purchase‑orders, bp, mickey, photography |
| PostgreSQL | 11.22 | localhost:5432; databases: `kvh_pulse`, `sharedtodo`, `ekg_tutor` |
| PM2 | 6.0.8 | runs the github webhook (+ a currently‑errored janda‑app); `pm2-root.service` resurrects on boot |
| Go | 1.25 (toolchain) | command‑center is cross‑compiled to a single binary; **no CGO toolchain on the droplet** (so DuckDB is built out via `-tags no_duckdb`) |
| Python | 3.7.3 (system) | + cpython 3.12.13 under `~/.local/share/uv/` (uv 0.11.15) for the ekg‑tutor interpret service |
| Apache | 2.4.x | port 8080, legacy/minimal |
| AdGuard Home | installed at `/root/AdGuardHome/` | port 8081 — **currently stopped** (service inactive) |
| certbot | snap + apt | Let's Encrypt cert renewal (multiple renewers, see §7/§15) |
| postfix | 3.4.23 | send‑only MTA on :25 (outbound deploy/NDA/npa email). **Relays through Proton SMTP** (`relayhost=[smtp.protonmail.ch]:587`, SASL + TLS) as of 2026-06-30 so mail leaves Proton's servers and passes the domain's SPF + Proton DKIM (was landing in spam when sending direct from the droplet IP). All envelope senders are rewritten to `noreply@adampowell.pro` (the Proton‑authorized address) via `sender_canonical`. Creds in `/etc/postfix/sasl_passwd` (mode 600). See §9 + apps/server/npa/README.md. |
| vsftpd | — | FTP on :21 (local users; anonymous disabled) |

---

## 3. Network: ports, bind addresses, firewall

**The host firewall (ufw) is INACTIVE.** There is no iptables/ufw filtering. Therefore every port
bound to `0.0.0.0`/`*` is reachable on the raw public IP, bypassing Cloudflare. Cloudflare only
protects what goes through `adampowell.pro` DNS (80/443).

Listening sockets (live `ss -tlnp`, 2026-06-06):

| Port | Bind | Process / service | Public? | Notes |
|---|---|---|---|---|
| 22 | 0.0.0.0 + [::] | sshd | **yes** | key‑only |
| 21 | * | vsftpd (FTP) | **yes** | local users, write enabled, anon off — plaintext FTP, undocumented historically |
| 25 | 0.0.0.0 + [::] | postfix `smtpd` | **yes** | send‑only MTA (`myhostname=adampowell.pro`, no relayhost) |
| 80 | 0.0.0.0 + [::] | nginx | yes | 301 → HTTPS |
| 443 | 0.0.0.0 + [::] | nginx | yes | all web apps |
| 5432 | 127.0.0.1 | postgres | no | local only |
| 3003 | * (all ifaces) | `auth.service` (node) | **yes** | auth server — *exposed on raw IP* |
| 3013 | * (all ifaces) | `atlas.service` (node) | **yes** | Atlas broker — *exposed on raw IP* |
| 3100 | * (all ifaces) | `case-tracker.service` (node) | **yes** | case‑tracker API — *exposed on raw IP* |
| 8080 | * | apache2 | **yes** | legacy |
| 3005 | 127.0.0.1 | pm2 `github-webhook-adampowell` | no | deploy webhook |
| 3008 | 127.0.0.1 | `todos.service` | no | todos sync (SSE) |
| 3009 | 127.0.0.1 | `randemail.service` | no | SimpleLogin proxy |
| 3010 | 127.0.0.1 | `remote.service` | no | FormFill relay (SSE) |
| 3015 | 127.0.0.1 | `command-center.service` (Go) | no | seedbox dashboard |
| 3016 | 127.0.0.1 | `npa.service` (node) | no | NPA/NDA e‑signing (added 2026-06-30) |
| 3017 | 127.0.0.1 | `rosters.service` (node) | no | AHA class rosters + public student signup (added 2026-06-30) |

> ⚠️ **Security note:** `auth` (3003), `atlas` (3013), and `case-tracker` (3100) bind to all interfaces
> with no firewall, so they answer directly on `198.211.114.12:<port>` from the internet. The web flow
> always goes through nginx (which adds the auth gate), but the raw ports are an attack surface worth
> closing (bind to 127.0.0.1, or enable ufw allowing only 22/80/443/25).

### Internal‑only ports for currently‑stopped services
These services are deployed but **inactive** right now, so the ports are not listening:
`3002` messenger · `3006`/`3007` kvh‑pulse + webhook · (janda `3004` is now ✅ up — see §4) ·
`3011` petfeed · `3014` sharedtodo · `3110`/`3111`/`3112` ekg‑tutor api/tutor/interpret ·
`8081` AdGuard Home. See §4 for status and how to bring them back up.

---

## 4. Services (systemd + pm2) and current status

Status verified live 2026-06-06. "Active" = currently running; "inactive/disabled" = deployed but
stopped (start with `systemctl start <name>` / `pm2 start`).

### Running now ✅

| Service | Manager | Port | Source | Purpose |
|---|---|---|---|---|
| `nginx` | systemd | 80/443 | `/etc/nginx/...` | reverse proxy + TLS |
| `postgresql` | systemd | 5432 | — | Postgres 11 (pulse, sharedtodo, ekg‑tutor DBs) |
| `php7.3-fpm` | systemd | socket | — | PHP for nda/po/bp/mickey/photography |
| `apache2` | systemd | 8080 | — | legacy |
| `auth.service` | systemd | 3003 | `apps/server/auth/` | login + nginx `auth_request` backend |
| `case-tracker.service` | systemd | 3100 | `apps/server/case-tracker/server/` | case tracker API |
| `atlas.service` | systemd | 3013 | `apps/Atlas/broker/` | Atlas broker (API + `/agent` WS) |
| `todos.service` | systemd | 3008 | `apps/server/todos/` | todos sync (SSE) |
| `randemail.service` | systemd | 3009 | `apps/server/RandEmail/` | SimpleLogin alias proxy |
| `remote.service` | systemd | 3010 | `apps/server/remote/` | FormFill macro relay (SSE) — restarted by `rebuild-symlinks.sh` on each deploy |
| `command-center.service` | systemd | 3015 | `apps/command-center/` (Go binary) | seedbox dashboard |
| `npa.service` | systemd | 3016 | `apps/server/npa/` | CRNA NPA/NDA e‑signing (added 2026-06-30) |
| `rosters.service` | systemd | 3017 | `apps/server/rosters/` | AHA class rosters + public student signup (added 2026-06-30) — **restarted by `rebuild-symlinks.sh` on each deploy** (2026-07-01; without it, new server.js routes 404 in prod) |
| pm2 `janda-app` | pm2 | 3004 | `apps/janda/` | J&A Relationship Builder (restored 2026-06-13 — see §11 janda gotcha) |
| `github-webhook-adampowell` | pm2 | 3005 | `github-webhook.js` | auto‑deploy on push |

### Deployed but currently inactive/disabled ⏸️

| Service | Port | Source | Effect while down | Revive |
|---|---|---|---|---|
| `messenger.service` | 3002 | `apps/server/messenger/` | `/chat/` → 502 | `systemctl enable --now messenger` |
| `kvh-pulse.service` | 3006 | `apps/server/pulse/server/` | `/pulse/` shell loads but chat/API/socket.io 502 | `systemctl enable --now kvh-pulse` |
| `kvh-pulse-webhook.service` | 3007 | `apps/server/pulse/webhook/` | pulse self‑deploy webhook off | `systemctl enable --now kvh-pulse-webhook` |
| `petfeed.service` | 3011 | `apps/server/petfeed/` | `/petfeed/` shell loads but weight/todo API + SSE 502 | `systemctl enable --now petfeed` |
| `sharedtodo.service` | 3014 | `apps/server/sharedtodo/` | `/sharedtodo/api/` 502 (iOS widgets stale) | `systemctl enable --now sharedtodo` |
| `ekg-tutor-api.service` | 3110 | `apps/ekg-tutor/api/` | `/ekg-tutor/api/` 502 | `systemctl enable --now ekg-tutor-api` |
| `ekg-tutor-tutor.service` | 3111 | `apps/ekg-tutor/tutor/` | tutor LLM service down | `systemctl enable --now ekg-tutor-tutor` |
| `ekg-tutor-interpret.service` | 3112 | `apps/ekg-tutor/interpret/` (Python venv) | interpret service down | `systemctl enable --now ekg-tutor-interpret` |
| `AdGuardHome.service` | 8081 | `/root/AdGuardHome/` | `dns.adampowell.pro` → 502 | `systemctl enable --now AdGuardHome` |

> Several backends are intentionally or incidentally stopped (resource pressure on a 1‑vCPU/989 MiB box,
> plus the EKG/pulse stacks were never set to auto‑start). The **static frontends still serve** for
> petfeed/pulse/sharedtodo/ekg‑tutor (you'll see the shell, but API calls 502). The ekg‑tutor stack
> additionally needs a real `ANTHROPIC_API_KEY` in `/root/secrets.env` and `web/dist` built before it's useful.

### Removed / stale units (do not rely on)
- **Removed:** `maps.service`, `aiomail.service` (and `/var/www/maps`, the maps source) — deleted.
- **Stale, still on disk (disabled):** `janda.service` (legacy, points at `html/janda`; janda actually runs via pm2),
  `git-auto-pull-adampowell.service`, `git-auto-pull-maps.service`.
- ⚠️ **`git-auto-pull-*.timer` are still ACTIVE** and fire every 5 min (see §5/§15). The maps one errors
  harmlessly (its dir is gone); the adampowell one is a *second* live deploy path alongside the webhook.

**systemd unit conventions** for app services: `Restart=on-failure`, `RestartSec=10`,
`StartLimitIntervalSec=60`, `StartLimitBurst=3` (a broken service fails loudly after 3 tries instead of
burning hundreds of thousands of restarts — never `Restart=always` on app code). Secrets come from
`EnvironmentFile=/root/secrets.env` (mode 600). Older units (`auth`, `atlas`, `messenger`, `kvh-pulse`)
still use `Restart=always` — a legacy wart, not the pattern for new services.

---

## 5. Deploy model

**Golden rule: edit locally → `git commit` → `git push origin main`. Never edit, scp, build, `git stash`,
or `git reset` on the server.** In‑place edits leave the server's tracked tree dirty, which makes the
webhook's `git merge --ff-only` fail and blocks deploys for the *whole* monorepo.

Two mechanisms keep the server in sync (both currently live):

1. **GitHub webhook** — `github-webhook.js` (pm2 `github-webhook-adampowell`, `127.0.0.1:3005`, public via
   `POST https://adampowell.pro/gitwebhook`, HMAC‑verified with `GITHUB_WEBHOOK_SECRET`). On push to `main` it:
   - `git fetch` + `git merge --ff-only` (fails loudly if the tree is dirty — by design, no stash),
   - runs `scripts/rebuild-symlinks.sh`,
   - `pm2 restart janda-app` (the **only** service the webhook restarts),
   - purges Cloudflare cache (`CF_API_TOKEN`, zone `b41bcfddcd002e9d2841aa6591abd66e`),
   - emails `adam@adamantanesthesia.com`.
   The webhook does **not** restart itself (that used to cause restart storms). After editing
   `github-webhook.js`, run `pm2 restart github-webhook-adampowell --update-env` manually.
2. **git‑auto‑pull timer** — `git-auto-pull-adampowell.timer` fires every 5 min and runs
   `git fetch origin && git pull origin main && bash scripts/rebuild-symlinks.sh` in the repo. Redundant with the
   webhook but means a push lands within 5 minutes even if the webhook misses it. (Uses a plain `git pull`, so keep the
   server tree clean.) **The `&& bash rebuild-symlinks.sh` was ADDED 2026-07-01** — before that the timer did a BARE
   `git pull` with no symlink rebuild + no service restarts, so any deploy that landed via the timer (rather than the
   webhook) advanced HEAD but left NEW files unlinked (404) and systemd node services stale. If you ever rewrite this
   unit, keep the rebuild call. Unit: `/etc/systemd/system/git-auto-pull-adampowell.service` (server‑only, not in repo).

### `scripts/rebuild-symlinks.sh`
Idempotent (`ln -sfn`), run after every pull. It:
- Creates repo‑root compatibility shims `nda → apps/nda` and `purchase-orders → apps/purchase-orders`
  so the nginx `root /var/www/adampowell.pro;` PHP blocks resolve into `apps/`.
- Rebuilds every `html/<app>` symlink into `apps/<app>` (POCUS, mac, bp, mickey, nav, photo,
  crna‑pay‑calculator, case‑tracker, infus; nested: `html/pulse → apps/pulse/client/dist`,
  `html/atlas → apps/Atlas/pwa/public`, `html/ekg-tutor → apps/ekg-tutor/web/dist`).
- For per‑file PWAs (investing, ANSReset, remote, RandEmail, todos, petfeed, rsvp, sharedtodo) it makes
  `html/<app>` a **real dir** and symlinks individual files so a server‑only `secrets.js` can sit alongside.
- Creates `case-tracker/index.html → case-tracker.html`.
- **Restarts `remote.service` and `rosters.service`** at the end (the systemd node units that get an explicit
  kick on deploy — the webhook only restarts pm2 `janda-app`, so any other systemd backend needs its own line
  here or new/changed `server.js` routes deploy to disk but never load, and 404 in prod). Add a
  `systemctl restart <name>.service || true` line here for every new systemd node backend.

A new static/PWA app under `html/<name>` (via a symlink line in this script + a `nav.json` tile) "just works"
auth‑gated with **zero nginx changes** — the catch‑all `location /` handles it.

See [`WHEN CREATING A NEW APP.md`](WHEN%20CREATING%20A%20NEW%20APP.md) for the full new‑app checklist.

---

## 6. Domains, DNS & Cloudflare

- **Primary:** `adampowell.pro` (Cloudflare‑proxied; Cloudflare terminates TLS at the edge and re‑originates to nginx).
- **Cloudflare zone:** `b41bcfddcd002e9d2841aa6591abd66e` · account `81b88c8d68c37dc6b1d3beae4e553363`.
  The API token in `/root/secrets.env` (`CF_API_TOKEN`, "AP.PRO Purge") is **cache‑purge scoped only** —
  it cannot read/modify zone Rules. Used by the webhook to purge cache on deploy.
- **Subdomains** (all 301‑redirect to a path on the main domain, except dns):

| Subdomain | Behavior | Cert |
|---|---|---|
| `bp.adampowell.pro` | 301 → `/bp` | own LE cert |
| `mac.adampowell.pro` | 301 → `/mac` | shares main cert |
| `mickey.adampowell.pro` | 301 → `/mickey` | own LE cert |
| `photography.adampowell.pro` | 301 → `/photo` (HTTP origin only; CF terminates TLS) | none at origin |
| `dns.adampowell.pro` | **proxies to AdGuard Home** (`127.0.0.1:8081`), auth‑gated by the main session; currently 502 because AdGuard is stopped | shares main cert |

> ⚠️ **Known Cloudflare‑side issue:** `photography.adampowell.pro` has a stale edge rule (302 → `/login`).
> The origin is correct (301 → `/photo`). It must be deleted by hand in the CF dashboard
> (Rules → Page/Redirect/Workers/Config rules) because the purge‑only token can't touch it.

---

## 7. TLS certificates

Let's Encrypt, auto‑renewed (multiple renewers — see §15). Live expiries (2026-06-06):

| Cert (`/etc/letsencrypt/live/<name>/`) | Expires | Covers |
|---|---|---|
| `adampowell.pro` | **2026-07-12** | main domain, all path‑based apps, mac + dns subdomains |
| `bp.adampowell.pro` | 2026-07-18 | bp subdomain redirect |
| `dns.adampowell.pro` | 2026-07-18 | (present; dns subdomain uses the main cert in its server block) |
| `mac.adampowell.pro` | 2026-07-18 | (present; mac subdomain block uses the main cert) |
| `mickey.adampowell.pro` | 2026-07-19 | mickey subdomain redirect |

Renewal: `certbot renew` (apt certbot via `/etc/cron.d/certbot` every 12h **and** `certbot.timer`),
plus `snap.certbot.renew.timer`. Check with `certbot certificates`; reload nginx after renewal.

---

## 8. Authentication architecture

**One global login** gates the whole site.

- **Service:** `auth.service` → `apps/server/auth/server.js` (Express; binds `:3003` on **all interfaces**
  — see the §3 security note). Uses `better-sqlite3` (users) + `connect-sqlite3` (session store), `bcrypt`,
  `helmet`, `express-rate-limit`.
- **Session cookie:** `apsess` (renamed from `connect.sid` on 2026-04-10 to invalidate old cookies),
  domain `.adampowell.pro` (works across subdomains), `secure`, `httpOnly`, `sameSite=lax`. Sessions
  persist in `apps/server/auth/sessions.db` across restarts.
- **nginx wiring:** the catch‑all `location /` runs `auth_request /_auth_check;` with
  `error_page 401 = @login_redirect;` (→ `302 /login`). `/_auth_check` (internal) proxies to
  `http://127.0.0.1:3003/api/auth/nginx-check` forwarding the cookie; 200 = ok, 401 = redirect.
  `/login` proxies to `:3003/login.html`.
- **Public (un‑gated) paths:** `/login`, `/auth/api/*`, `/_auth_check`, `/favicon.ico`, `/gitwebhook`,
  `/nav.json` is gated, `/dns-query` returns 444 (abuse sink).
- **Rate limit:** login is 5 attempts / 15 min / IP (`express-rate-limit`). If you lock yourself out
  during ops, wait 15 min or `systemctl restart auth.service` to reset the in‑memory limiter.
- **Login curl recipe:**
  ```bash
  COOKIE=$(curl -sD - --max-time 15 -H 'Content-Type: application/json' \
    -d '{"username":"adam","password":"<pw>"}' \
    https://adampowell.pro/auth/api/login | awk '/^[Ss]et-[Cc]ookie:.*apsess/ {sub(/^[Ss]et-[Cc]ookie: /,""); sub(/;.*/,""); print}')
  curl -s -H "Cookie: $COOKIE" -o /dev/null -w '%{http_code}\n' https://adampowell.pro/<app>/   # 200 auth, 302 not
  ```
- **Per‑user dashboard layout (2026-06-21):** the auth server also serves the landing dashboard's
  customizations — `GET`/`PUT`/`DELETE /api/layout` (public path `/auth/api/layout`), all behind `requireAuth`
  and keyed on `req.session.userId` (no cross‑user access). Stored in `dashboard_layouts` (one row/user) as a
  shape‑validated JSON overlay (order, hidden ids, length‑bounded renames, custom groups — **never hrefs**),
  64 KB cap. The dashboard merges this over the gated `nav.json` catalog at render time.

### Second factors (per‑app, beyond the main session)
- **Command Center** — its own **WebAuthn passkey** (`cc_session` cookie). API/SSE/WS bypass the outer
  gate; the HTML shell keeps it (see SERVER_NGINX_ROUTES.md "command‑center split").
- **NDA / Purchase Orders** — legacy **PHP password** gate (bcrypt hash in `login.php`, 2‑hour session).
- **Bearer‑token apps (no cookie possible)** — `/remote/api/daemon/*` (PC daemon), `/sharedtodo/api/*`
  (iOS Scriptable widgets): these bypass `auth_request` and validate a `Authorization: Bearer` token in
  the Node app instead. They sit *before* the catch‑all in nginx.
- **Client‑side URL key** — POCUS (`?key=3a8264c42cf08c757047f4d8`) and investing
  (`?key=ddd174fa370bab29e77d4ae6`, or legacy password `1337`): obscurity only, on top of the main login.
- **Atlas, KVH PULSE, EKG Tutor** — have their own in‑app auth (Atlas WebAuthn→JWT; pulse JWT + passkeys;
  ekg resolves the username from the main session via the auth server's `/api/auth/check`).

---

## 9. Secrets & credentials

### The vault — `/root/secrets.env` (mode 600, root‑only, NOT in git)
This is the single canonical place for production secrets. Services read it via
`EnvironmentFile=/root/secrets.env`; pm2 processes get values via `pm2 set`. Key names present (values omitted):

```
GITHUB_WEBHOOK_SECRET   CF_API_TOKEN            CF_ZONE_ID
OPENAI_API_KEY          ANTHROPIC_API_KEY       SESSION_SECRET
ATLAS_JWT_SECRET        ATLAS_PAIRING_SECRET    SIMPLELOGIN_API_KEY
REMOTE_DAEMON_TOKEN     SHAREDTODO_DATABASE_URL SHAREDTODO_BEARER_TOKEN
EKG_TUTOR_DATABASE_URL  EKG_TUTOR_UPLOAD_DIR    EKG_TUTOR_INTERPRET_URL
EKG_TUTOR_TUTOR_URL     EKG_TUTOR_AUTH_CHECK_URL EKG_TUTOR_INTERPRET_MODEL
EKG_TUTOR_INTERPRET_MAX_RETRIES  EKG_TUTOR_INTERPRET_TIMEOUT_S
CC_DISK_TOKEN  CC_PORTSYNC_TOKEN
CC_JELLYFIN_URL  CC_JELLYFIN_TOKEN  CC_JELLYFIN_X_NAME  CC_JELLYFIN_X_PATH
CC_RADARR_URL  CC_RADARR_TOKEN  CC_RADARR_ROOT  CC_RADARR_PROFILE
CC_FERAL_SSH_HOST  CC_FERAL_SSH_USER  CC_FERAL_SSH_KEY_FILE
NPA_PASSWORD_HASH  ROSTERS_PASSWORD_HASH  ROSTERS_CAPTCHA_SECRET
```

`NPA_PASSWORD_HASH` (added 2026-06-30) is a bcrypt hash of Adam's NPA create‑agreement‑form password —
not a reversible secret; `npa.service` refuses to start if it's unset. Rotate by regenerating the hash
(`node -e "console.log(require('bcrypt').hashSync(process.argv[1],10))" '<new pw>'`) and replacing the value.

`ROSTERS_PASSWORD_HASH` (added 2026-06-30) is the same shape — a bcrypt hash of the Rosters admin‑panel
password (the panel is public, so its own password is the gate, NOT the main site login). `rosters.service`
refuses to start if it's unset. Rotate the same way.

`ROSTERS_CAPTCHA_SECRET` (added 2026-06-30) is a random 32‑byte hex secret the Rosters app uses to
**HMAC‑sign the anti‑bot challenge tokens** for the public `/requests` form (so the puzzle answer never
reaches the browser and tokens can't be forged). Not required — if unset the app derives a key from
`ROSTERS_PASSWORD_HASH` — but set explicitly in the vault. Rotate by replacing with a new random hex
(in‑flight challenges invalidate, which is harmless).

### Client‑side secret files (server‑only, gitignored, `*/secrets.js`)
Loaded by `<script src="secrets.js">` on gated pages so the key reaches the browser but never git:
- `html/investing/secrets.js` → `window.REBALANCER_OPENAI_KEY` (OpenAI key for the GPT verify button)
- `html/ANSReset/secrets.js` → `window.ANSRESET_ANTHROPIC_KEY` (Anthropic key for the coach chat)

### Other secret stores
- **Command Center** keeps tracker cookies, qBit passwords, autobrr tokens, and the VAPID keypair
  **age‑encrypted** inside its SQLite (`secrets` table), keyed by an age identity at
  `/etc/command-center/age.key` (mode 600, auto‑generated first boot).
- **KVH PULSE** has its own `apps/server/pulse/server/.env` (DB creds, JWT, VAPID, GIPHY, VirusTotal).
- **Postgres roles:** `kvh_pulse_user` (db `kvh_pulse`), `sharedtodo_user` (db `sharedtodo`),
  plus the ekg‑tutor role/db.

### ⚠️ Security debt found in the 2026-06-06 source audit (remediation TODO)
These are **hardcoded secrets / weak gates still living in tracked source** — worth fixing:
- `apps/nda/submit.php` — a **live Gmail App Password** is a string literal in source. NDA records
  (with signer PII + IP) are auto‑committed to the repo by `sudo /usr/local/bin/nda-git-push`.
- `apps/bp/`, `apps/mickey/`, `apps/photography/` (all `index.php` + `admin.php`) — the same
  **VirusTotal API key** (`78e2de72…`) is hardcoded; admin password hashes hardcoded.
- ~~`apps/janda/server.js` — `express-session` secret hardcoded~~ **Fixed 2026-06-13:** now reads
  `JANDA_SESSION_SECRET || SESSION_SECRET` from env (the old literal remains only as a last‑ditch fallback so
  the process never crashes if the env var is unset); sessions now persist via connect‑sqlite3 (`sessions.db`).
- `apps/server/auth/server.js` — default seed credentials `adam` / `jenna` are plaintext in source
  (bcrypt‑hashed into the DB on first run); `SESSION_SECRET` falls back to a per‑process random if unset.
- `/etc/systemd/system/case-tracker.service` — `CASETRACKER_API_KEY` is a literal in the unit file
  (not used for the cookie‑gated web path, but still a literal).
- `apps/server/messenger/server.js` — hardcoded chat credentials (Adam/Jenna).
- `auto-port-forward/push.config.json` — contains a live `CC_PORTSYNC_TOKEN`; README says **don't commit it**.
- Sample data committed by accident: `apps/investment-calculators/Portfolio_Positions_Apr-10-2026.csv`,
  `apps/case-tracker/*.xlsx/*.pdf/*.csv` (real case logs).

### Email deliverability (2026-06-30) — RESOLVED: postfix relays through Proton SMTP
**Was:** the droplet's postfix sent **direct from `198.211.114.12`**, but `adampowell.pro`'s DNS authorizes
**Proton Mail only** (SPF `include:_spf.protonmail.ch`, Proton DKIM CNAMEs, DMARC `p=quarantine`), so all
app/webhook mail failed SPF + had no DKIM → failed DMARC → **quarantined to spam** (seen on the npa launch).
**Fix (live):** postfix now relays through **Proton SMTP** — `relayhost=[smtp.protonmail.ch]:587`,
`smtp_sasl_auth_enable=yes`, `smtp_tls_security_level=encrypt`, creds in `/etc/postfix/sasl_passwd` (mode
600), and a `sender_canonical` regexp rewrites **every** envelope sender to **`noreply@adampowell.pro`** (a
real Proton address created on the domain — Proton requires the From to be an address it hosts, and the
SMTP submission token is bound to it). Mail now leaves Proton's servers → passes the existing SPF + Proton
DKIM → DMARC aligns. Verified: `relay=smtp.protonmail.ch ... status=sent` for both the npa app and direct
sends. Applies to **ALL** droplet outbound mail (deploy webhook + nda + npa). Re‑run/rotate via
`/root/setup-proton-relay.sh` (Proton dashboard → IMAP/SMTP → regenerate token). Note: the webhook's old
From `adam@adamantanesthesia.com` is now rewritten to `noreply@adampowell.pro` by sender_canonical.
SMTP submission is available on Proton **Unlimited** (custom‑domain plans), not only Business.

> The OpenAI/Cloudflare/webhook secrets were stripped from source and moved to the vault on 2026-04-10,
> but **they remain in git history**. Treat anything ever committed as exposed; rotate at the provider.

---

## 10. URL / application index

Every routable path under `https://adampowell.pro/`. "Backend" port is `127.0.0.1` unless noted.
Status reflects the live 2026-06-06 backend state.

### Infrastructure / public (un‑gated) paths
| Path | Purpose |
|---|---|
| `/login` | login page (proxy → auth `:3003/login.html`) |
| `/auth/api/*` | login/logout/session‑check (→ auth `:3003/api/*`) |
| `/_auth_check` | internal nginx subrequest (→ `:3003/api/auth/nginx-check`) |
| `/favicon.ico` | public (prevents 401 redirect loops) |
| `/gitwebhook` | GitHub push webhook (→ pm2 webhook `:3005`, HMAC‑verified) |
| `/nav.json` | gated navigation manifest (tracked in git as `html/nav.json`) |
| `/dns-query` | returns 444 (drops DoH abuse bots) |

### Gated application paths
| Path | App | Type / backend | Source | Status |
|---|---|---|---|---|
| `/` | Landing (dynamic tiles from `/nav.json`) | static | `html/index.html` | ✅ |
| `/POCUS/cardiac-anes.html`, `/cardiac-anes.html` | Cardiac POCUS reference | static, URL key | `apps/POCUS/` | ✅ |
| `/investing/` (+ `/investing/secrets.js`) | Portfolio rebalancer + GPT (gpt‑5‑mini) | static, URL key/`1337` | `apps/investment-calculators/` | ✅ |
| `/ANSReset/` | Nervous System Reset (AI coach) | static PWA, Anthropic client‑side | `apps/ANSReset/` | ✅ |
| `/todos/` + `/todos/api/*` | ADAMANT Todo Lists (SSE sync) | static PWA + node `:3008` | `apps/todos/` + `apps/server/todos/` | ✅ |
| `/sharedtodo/` + `/sharedtodo/api/*` | Shared Todo (iOS Scriptable widgets) | static + node `:3014` (bearer) | `apps/sharedtodo/` + `apps/server/sharedtodo/` | ⏸️ API down |
| `/RandEmail/` + `/RandEmail/api/*` | SimpleLogin alias manager | static PWA + node `:3009` | `apps/RandEmail/` + `apps/server/RandEmail/` | ✅ |
| `/remote/` + `/remote/api/*` + `/remote/api/daemon/*` | FormFill macro relay | static PWA + node `:3010` (SSE; daemon bearer) | `apps/remote/` + `apps/server/remote/` | ✅ |
| `/petfeed/` + `/petfeed/api/*` | Pet meals + weight tracker | static PWA + node `:3011` (SSE) | `apps/petfeed/` + `apps/server/petfeed/` | ⏸️ API down |
| `/medcalc/` | Anesthesia Med Calc — bolus + infusions + dilutions + mixes + MAC | static PWA | `apps/medcalc/` | ✅ |
| `/preop/` | Preop Risk Eval — preop risk + facility‑appropriateness report builder; on‑device dictation (no PHI) | static PWA | `apps/preop/` | ✅ |
| `/infus/` | Infusion calculator (**merged into `/medcalc/`** — redirect stub) | static | `apps/infus/` | ➡️ 302→/medcalc/ |
| `/rsvp/` | Flux focus reader (RSVP) | static PWA | `apps/rsvp/` | ✅ |
| `/nav/` | Turn‑by‑turn navigation | static PWA | `apps/nav/` | ✅ |
| `/crna-pay-calculator/`, `/shift-calc/` | CRNA pay calculator | static PWA | `apps/crna-pay-calculator/` | ✅ |
| `/pulse/` + `/pulse/api/*` + `/pulse/socket.io/*` | KVH PULSE chat | static (Vite/React) + node `:3006` | `apps/pulse/client/dist` + `apps/server/pulse/` | ⏸️ backend down |
| `/case-tracker/` + `/api/casetracker` | ADAMANT Case Tracker | static PWA + node `:3100` | `apps/case-tracker/` + `apps/server/case-tracker/` | ✅ |
| `/ekg-tutor/` + `/ekg-tutor/api/*` | EKG Tutor (educational) | Vite/React PWA + Fastify `:3110` (+`:3111`/`:3112`) | `apps/ekg-tutor/` | ⏸️ backend down |
| `/command-center/` (+ `/api/`,`/sse/`,`/ws`) | Seedbox Command Center | Go binary `:3015` (split auth) | `apps/command-center/` | ✅ |
| `/janda/` | J&A Relationship Builder | node `:3004` (pm2) | `apps/janda/` | ✅ |
| `/chat/` | Messenger | node `:3002` | `apps/server/messenger/` | ⏸️ down |
| `/atlas/` + `/atlas/api/*` + `/atlas/agent` | Atlas AI OS | static PWA + node broker `:3013` (WS) | `apps/Atlas/` | ✅ |
| `/mac/` | MAC calculator | static (PHP block matches but it's HTML) | `apps/mac/` | ✅ |
| `/bp/` | Blake & Powell Photography | PHP | `apps/bp/` | ✅ |
| `/mickey/` | Mickey photo gallery | PHP | `apps/mickey/` | ✅ |
| `/photo/` | Photography gallery | PHP | `apps/photography/` | ✅ |
| `/nda/` | NDA signing | PHP + password | `apps/nda/` | ✅ |
| `/purchase-orders/mcgrath/` | PO signing | PHP + password | `apps/purchase-orders/mcgrath/` | ✅ |
| `/npa/` + `/npa/sign/<token>` + `/npa/api/*` | CRNA NPA/NDA e‑signing | static + node `:3016` (own password + signing token; **bypasses main gate**) | `apps/npa/` + `apps/server/npa/` | ✅ |
| `/rosters/` + `/rosters/api/*` (admin) | Class Rosters — admin panel | static PWA + node `:3017` | `apps/rosters/` + `apps/server/rosters/` | ✅ **MAIN login (`auth_request`) + own bcrypt password** (double‑gated 2026-07-01; was own‑password‑only) |
| `/rosters/signup/<slug>` + `/rosters/api/signup/<slug>` | Class Rosters — public student signup | static + node `:3017` (slug‑scoped, **bypasses main gate**) | `apps/rosters/` + `apps/server/rosters/` | ✅ fully public (slug‑scoped, like npa's `/npa/sign/`; bad slug → generic 404) |
| `/requests` + `/rosters/api/request*` | Class Rosters — public class‑request form | static + node `:3017` (**bypasses main gate**) | `apps/rosters/requests.html` + `apps/server/rosters/` | ✅ fully public (custom captcha + honeypot + rate‑limit; emails Adam + shows in admin Requests tab) |
| `/rosters/pay/<token>` + `/rosters/api/pay*` | Class Rosters — Venmo pay page | static + node `:3017` (**per‑request pay token**) | `apps/rosters/pay.html` + `apps/server/rosters/` | ✅ token‑gated per request (amount + optional coupon; Venmo deep‑link @ATP28 + QR; student marks "paid" → Adam verifies) |
| `/rosters/eval/<token>` + `/rosters/api/pay/<token>/evals` | Class Rosters — course evaluations | static + node `:3017` (**pay‑token, only after PAID**) | `apps/rosters/eval.html` + `apps/server/rosters/` | ✅ student fills the AHA course eval(s) for the class(es) they bought; stored + shown in admin |
| `/rosters/answersheet/<token>` + `/rosters/api/pay/<token>/answersheet` | Class Rosters — exam answer sheet | static + node `:3017` (**pay‑token, only after PAID**) | `apps/rosters/exam.html` + `apps/server/rosters/` | ✅ student bubbles their exam answers (saved to Adam's records; NO exam content stored); admin lists + records a score |
| `/rosters/answersheet.html` | Class Rosters — blank exam answer sheet | static (admin‑gated) | `apps/rosters/answersheet.html` | ✅ instructor tool — generic A–D bubble grid (NO exam content); tablet/print |
| `/maps/` | (retired) | nginx proxy → `:3001` (no backend) | — | ❌ 502 (block still present) |
| `dns.adampowell.pro` | AdGuard Home admin | proxy → `:8081` | `/root/AdGuardHome/` | ⏸️ 502 (AdGuard stopped) |

---

## 11. Application inventory (detailed)

Per‑app facts confirmed by the 2026-06-06 source audit. Each entry: what it is, how it runs, where its
data lives, and any gotchas. Status is the live backend state.

### Medical / clinical tools

**POCUS — Cardiac anesthesia reference** (`apps/POCUS/cardiac-anes.html`)
Single 829‑line, ~1 MB static HTML file. CL‑only rapid protocol: Subcostal‑4C, eyeball EF, IVC,
cIVC/dIVC + EPSS calculators, RUSH shock protocol. URL‑key gated client‑side (`?key=3a8264c42cf08c757047f4d8`),
sessionStorage persists offline. Inlined base64 PWA manifest, **no service worker**. Served at both
`/POCUS/cardiac-anes.html` and legacy `/cardiac-anes.html` (file symlink). ✅

**KVH PULSE — HIPAA anesthesia chat** (`apps/pulse/` frontend, `apps/server/pulse/` backend) ⏸️
Full‑stack: Vite/React PWA + Node/Express + **Socket.io** + PostgreSQL (`kvh_pulse`, user `kvh_pulse_user`).
Backend `kvh-pulse.service` (configured port 3006 via its `.env`; code default 3001) + a self‑deploy
`kvh-pulse-webhook.service` (3007). Features: channels/DMs, message edit/react, **GIPHY** picker, preop
charts + comments + voting, polls, handoffs, **WebAuthn passkeys** (@simplewebauthn), JWT, Web Push (VAPID),
**VirusTotal** upload scanning, helmet, rate‑limit, Winston audit logs, HIPAA audit trail. Many tables
(users, channels, messages, preop_charts, push_subscriptions, virustotal_scans, passkey_credentials, …);
schema auto‑migrates on boot. Build the React client (`npm run build`) before deploy. **Currently down.**

**Case Tracker** (`apps/case-tracker/` frontend, `apps/server/case-tracker/server/` API) ✅
Single‑file vanilla‑JS PWA (CDN: SheetJS, PapaParse, jsPDF+autotable, pdf.js) + an Express router
(`case-tracker.service`, 3100) persisting `data/casetracker.json` with rolling 50‑backup retention.
Cookie‑gated via nginx `auth_request` (no separate key); `manifest.json` served public. `import-data.py`
is a one‑shot historical importer (openpyxl). Default facility "Kittitas Valley Hospital, WA".

**CRNA Pay Calculator** (`apps/crna-pay-calculator/index.html`) ✅
Single‑file (3546‑line) client‑side PWA; manifest as base64 data‑URI, SW via Blob URL (cache `crna-calculator-v5`).
Shift tracking + payroll verification, **Tesseract.js v5 OCR** of paystubs, holiday bonuses, 4‑week rotation
(anchor Mon **Jan 19 2026**), unlogged‑shift detection, copy‑to‑clipboard discrepancy email. Owner dev tools:
`?owner=AP2026unlock`. Stale duplicate at `/shift-calc/`. Data in `localStorage` only.

**Infusion Calculator** (`apps/infus/`) ➡️ **RETIRED — merged into `/medcalc/` (2026-06-08).**
`apps/infus/index.html` is now a redirect stub → `/medcalc/`, and `apps/infus/sw.js` is a self‑retiring
service worker (clears caches, unregisters, reloads clients) so installed `/infus/` home‑screen PWAs forward
themselves to Med Calc instead of serving the old cached shell. The symlink `html/infus → apps/infus` stays
(serves the stub). Its full infusion engine + dilutions + pressor recipes live in Med Calc's Infusions/Reference
tabs. Original app preserved in git history.

**Med Calc — Anesthesia dosing & infusions** (`apps/medcalc/`) ✅
Static PWA (`medcalc-v2` SW), no backend, **zero external deps** (system fonts, fully offline). Whole‑dir
symlinked `html/medcalc → apps/medcalc` (index.html + manifest.json + sw.js + icon‑192/512). The unified
anesthesia calculator — **combines the former Med Calc + Infusion Calculator** (2026-06-08). Three sections via
a segmented control:
- **Bolus** — per‑kg/fixed push dosing (26 drugs: induction/opioids/relaxants/pressors/reversal/local) with
  **weight‑basis auto‑selection** (TBW/IBW Devine/LBW Janmahasatian/AdjBW) + elderly/peds **age adjustment**.
  Logic ported from KVH PULSE's `apps/pulse/client/src/utils/medcalc.js`.
- **Infusions** — 37‑drug ICU/OR infusion engine (concentration · dose‑mode · weight‑basis → live **mL/hr**
  with in‑range check, standard dilution, clinical notes). Ported from the old `/infus/`.
- **Reference** — Weights (TBW/IBW/LBW/AdjBW/BMI/BSA + Mosteller BSA + which‑weight guide), Special Mixes
  (McLott), Dilutions table + pressor recipes, and the Gas/MAC reference (inhalational agents, MAC levels,
  MAC factors, PK/PD).
LBW unified to **Janmahasatian** across the app. Built/tuned for iPhone 16 Pro Max (safe‑area insets, no‑zoom,
big tap targets). Added 2026-06-08; merged 2026-06-08.

**Preop Risk Eval** (`apps/preop/`) ✅ — **v2.1** (SW `preop-v11`)
Single‑file, self‑contained static PWA; the app shell is fully offline (no build step). Whole‑dir symlink
`html/preop → apps/preop` (index.html + manifest.json + sw.js + icon‑192/512). The only runtime external
dependency is voice dictation's one‑time transformers.js + Whisper‑weights download from CDN — everything else
runs offline. A preoperative screening aid for a critical access hospital: the clinician clicks/searches (or
dictates/pastes) patient data and the tool builds a formatted **copy‑paste report for OpenEvidence**
(perioperative risk + facility‑appropriateness). Inputs: demographics/case (age, sex, ht/wt with live BMI,
procedure, service, urgency, anesthesia, disposition, planned day/time with off‑hours awareness); a searchable
**~115‑item comorbidity catalog** grouped by system (Cardiac/Pulmonary/Renal/Endocrine/Heme‑coag/Other) with
one‑tap chips, custom free‑text add, and **inline detail fields** per condition (CHF→EF; CAD→MI/PCI/CABG+yr;
arrhythmia→type+pacer/ICD; valve→which+lesion+severity; COPD→home O₂/exac; OSA→CPAP; CKD→stage/dialysis/access;
DM→type/A1c/insulin; anticoag/antiplatelet→agent+last dose; bleeding→free text); a **~120‑surgery type‑ahead
datalist** on the procedure field and a **~150‑med type‑ahead quick‑add** (auto‑sets the matching perioperative
med flag); functional status (METs + 2‑flights + notes); meds free‑text with quick‑flag chips (anticoagulant/
antiplatelet/GLP‑1/insulin/beta‑blocker); allergies; structured recent‑workup rows (EKG/labs/echo/stress/imaging
+ value + date). A fixed critical‑access **facility‑capability block** is baked into every report (CRRT 24/7‑vs‑
coverage‑dependent toggle + an "other limits" field), and the report embeds **6 fixed OpenEvidence questions** +
a final facility‑appropriateness question and forces a decisive one‑line **BOTTOM LINE** verdict ("GO —
appropriate for surgery at this facility" / "NO‑GO — refer to a higher‑level center" / "GO IF <single
condition>"). **On‑device voice dictation:** a "Dictate patient history" panel + per‑field mic buttons run
**Whisper fully in‑browser** via transformers.js (dynamic ESM import from jsdelivr
`@huggingface/transformers@4.2.0`, default model `onnx-community/whisper-base.en` with a user‑switchable
**Tiny/Base/Small** selector, ~40/130/250 MB — switching re‑downloads on next dictation). Backend tries
**WASM‑q4 first, then WebGPU‑fp32 if a GPU adapter exists, then WASM‑fp32** as the universal fallback — **q8 is
never used** (the q8 QDQ decoder fails on ORT‑Web's WASM EP: `TransposeDQWeightsForMatMulNBits / Missing required
scale`). Mic audio is captured, resampled to 16 kHz mono, and transcribed **locally — the audio never leaves the
device**; the only network use is the one‑time weights download (no PHI), **cached in the browser, not committed
to the repo** (zero server‑disk impact). A **paste/type‑a‑narrative** box runs the same parser with no mic, and a
medical‑term post‑correction pass + rule‑based parser route input into demographics + case (age, sex, procedure,
anesthesia, urgency, disposition), comorbidities (with detail fields), perioperative med flags + a detected meds
list, allergies, and functional status — always via an explicit **review‑then‑apply** panel (never silently,
never overwriting filled fields). In‑app tooling: a **PHI‑redacted "Copy debug log"** (field text redacted to
lengths by default; opt‑in to include typed text for test data only), a **"Reset speech model"** button, a header
**force‑update** control (clears app‑shell caches + unregisters the SW), and an in‑app **version history**.
**HIPAA‑safe by design: no PHI is ever stored or transmitted** — no backend, no network calls with patient data,
no localStorage/cookies; all state is in memory and cleared on Reset/close (the SW caches only the empty app
shell). The on‑screen privacy banner was removed in v1.8 (a private single‑user app; the privacy guarantees still
live in the info modal + footer). Copy‑to‑clipboard works on iOS Safari + Android Chrome. Pure static — no
backend/port/nginx change. Added 2026-06-26. **v2.1 (2026-07-02):** a browser **Content‑Security‑Policy**
(`<meta http-equiv>`) now structurally enforces the no‑egress promise — patient data can only reach `'self'`;
the sole outbound allowance is the on‑device speech download (`script-src`/`connect-src` allow jsDelivr + the
HF weight hosts, `wasm-unsafe-eval` + `worker-src blob:` for ORT). Every clinical field carries
`spellcheck="false"` (blocks Chrome Enhanced Spell Check "spell‑jacking"); a **privacy veil** blanks the UI on
`visibilitychange`/`pagehide` so patient data isn't in the iOS app‑switcher snapshot; the debug log's redacted
mode reports segments/BMI/comorbidities as set/count (no values), clears the event log on Reset, and re‑checks
the include‑text guard at copy time; "Reset speech model" now deletes only `transformers-cache`. UX adds a
sticky **section jump‑strip** (scroll‑spy + per‑section entry counts), an out‑of‑date **stale‑report** guard
(Copy auto‑regenerates), dictation **Apply highlights + scrolls** to filled fields, **light‑mode** +
reduced‑motion support, and iOS‑zoom‑safe 16px inputs.

**MAC Calculator** (`apps/mac/index.html`) ✅
Static (spreadsheet‑to‑web export; huge minified `assets/js/obs.js`). Age‑adjusted SEVO/ISO/DES + N2O.
Two‑step client‑side gate (emoji `🫠` then password `1337`) = security theater; references a missing
AppCache + a devtool‑blocker that doesn't load. Pure static.

**EKG Tutor** (`apps/ekg-tutor/`, pnpm monorepo) ⏸️
Four layers: `contracts/` (Zod schemas) → `web/` (React 19 + Vite PWA, built to `web/dist`, symlinked at
`/ekg-tutor/`) → `api/` (Fastify + Drizzle + Postgres `ekg_tutor`, 3110) → `tutor/` (Fastify + Anthropic
SDK, 3111) → `interpret/` (Python FastAPI + Anthropic + OpenCV/Pillow/tesseract, 3112, runs in a `.venv`
via uvicorn). Educational 12‑lead practice: PTB‑XL case library, learner gap analysis, Socratic tutor
(`claude-sonnet-4-6`), PHI scanning at upload. Uploads at `/var/lib/ekg-tutor/uploads`. **All three services
currently inactive**; needs a real `ANTHROPIC_API_KEY` and `web/dist` built. Strictly educational — not a medical device.

### Finance

**Portfolio Rebalancer** (`apps/investment-calculators/index.html`) ✅
Client‑side, Fidelity 3‑fund (FSKAX/FTIHX/FXNAX 54/36/10). Parses CSV/PDF (pdf.js), tax‑aware trades,
optional **OpenAI gpt‑5‑mini** "verify" (key from server‑only `secrets.js`, fallback to a user‑entered
localStorage key). URL‑key gate (`?key=ddd174fa…`) or password `1337` (both plaintext in HTML). Not a PWA.

### Personal / household

**Nervous System Reset (ANSReset)** (`apps/ANSReset/index.html`) ✅
Static PWA, React 18 + Babel‑standalone (in‑browser JSX, no build). AI coach calls Anthropic directly
from the browser (`claude-sonnet-4-20250514`, `anthropic-dangerous-direct-browser-access`); key via
server‑only `secrets.js`. Journals/check‑ins in `localStorage` (`nsr_data_v1`). EFT tapping YouTube embeds.
SW `ansreset-v1` bypasses caching for the API/YouTube/secrets.js.

**Todo Lists (todos)** (`apps/todos/` + `apps/server/todos/`) ✅
Single‑file PWA + tiny Express/SSE sync server (3008, data `data/todos.json`, atomic writes). Real‑time
multi‑device sync (version counter + SSE broadcast, `clientId` to avoid echo). Carries dead pywebview
desktop‑wrapper code. SW `todos-v1` skips `/todos/api/*`.

**Shared Todo (sharedtodo)** (`apps/sharedtodo/` + `apps/server/sharedtodo/`) ⏸️
Static setup page + Express/Postgres API (3014, schema `sharedtodo`, tables `lists`+`todos`). The real
"frontend" is an iOS **Scriptable** widget script copy‑pasted from the page. Bearer token + `X-User`
(`adam`|`jenna`) auth, no cookie. Migration is manual (`node migrate.js`). **API currently down.**

**RandEmail** (`apps/RandEmail/` + `apps/server/RandEmail/`) ✅
PWA + Express proxy (3009) holding `SIMPLELOGIN_API_KEY` server‑side (SimpleLogin uses an
`Authentication:` header, not `Authorization: Bearer`). List/create/toggle/delete aliases; on delete it
also cascades a credential delete in the `/remote` vault (`REMOTE_URL`).

**Remote — FormFill macro relay** (`apps/remote/` + `apps/server/remote/`) ✅
PWA + Express SSE relay (3010) bridging the phone PWA and a PC FormFill daemon (Playwright). Two SSE
pools: `daemons` (PC automation) and `extensions` (Brave FormFill / Python daemon). Macros/flows/credentials/
history in `data/*.json` (gitignored, atomic). Flow step types resolve server‑side (`create_alias` via
RandEmail, `generate_password`, `save_credential`, `run_macro`, …). Deeply integrated with Spotify‑signup
automation. `REMOTE_DAEMON_TOKEN` required at startup (process exits if unset). Daemon source ships
separately (`c:\ADAMANT\ADAMPOWELL PRO\formfill-daemon\`, not in this repo).

**Pet Meals (petfeed)** (`apps/petfeed/` + `apps/server/petfeed/`) ⏸️
Single‑file PWA + Express/SQLite API (3011, `data/weights.db`). Meal check‑offs (localStorage per date),
weight tracker with **SSE** real‑time sync, shared todo. Cats hardcoded `mickey`/`nala`. SW `petfeed-v8`.
**API currently down** (shell still loads).

**Flux — Focus Reader (rsvp)** (`apps/rsvp/`) ✅
Pure client‑side RSVP reader PWA (`rsvp-v3-flux`). Parses EPUB/MOBI/PDF/DOCX/TXT/MD/HTML in‑browser
(pdf.js/JSZip/mammoth lazy‑loaded from cdnjs; hand‑rolled MOBI parser). Books + progress in IndexedDB
(`flux_reader_db`), settings in localStorage. Smart pacing, ORP highlight, themes. Calls itself "Flux".

**Nav** (`apps/nav/`) ✅
Client‑side turn‑by‑turn navigation PWA (`nav-v3`), **MapLibre GL 4.7.1** + OpenFreeMap tiles + OSRM
routing + Nominatim geocoding + ipwho.is/ipapi.co geolocation. Web Speech voice prompts, Wake Lock.
No backend, no keys.

**J&A Relationship Builder (janda)** (`apps/janda/`) ✅
Node/Express + better‑sqlite3 (`janda.db`) full‑stack (pm2 `janda-app`, 3004). Private 2‑person couple app
(users J="Jenna"/A="Adam"). Vanilla‑JS SPA (`public/index.html` shell + `spa-router.js` + `app.js` +
`style.css`) + a 3521‑line self‑contained `tarot.html` (78‑card deck, 10 spreads, audio/meditation, "Master
Eliana Moonweaver" **AI tarot**, OpenAI `/v1/responses`, model **`gpt-5.4-mini`** w/ reasoning effort, 380
questions, 39 conversation starters). Features wired to DB tables: daily questions (category/difficulty
filters), gratitude, love notes, mood, exercises, **goals, calendar, milestones, love‑languages, daily
check‑ins, conversation starters, nudges**, and a data‑driven **special‑occasion overlay** (generalized from
the old Christmas‑only card). Sessions persist via **connect‑sqlite3** (`sessions.db`); session secret from
`SESSION_SECRET` env (was hardcoded). Tarot uses `OPENAI_API_KEY` (ecosystem.config.js forwards `process.env`).
**Restored 2026-06-13 + big feature build.** ⚠️ **Native‑module gotcha** (Debian 10): `better-sqlite3` must be
built FROM SOURCE with Python 3.12 — `cd apps/janda && export npm_config_python=/root/.local/bin/python3.12 &&
npm rebuild better-sqlite3 --build-from-source` (system py3.7 breaks node‑gyp; glibc‑2.28 rejects prebuilts;
gcc8 only does c++17 so stay on better‑sqlite3 9.x). Any plain `npm install` here WITHOUT the python3.12 env
var re‑breaks it. After install: source `/root/secrets.env` then `pm2 start ecosystem.config.js --update-env;
pm2 save`. To add data: `node seed-extra.js` (idempotent).

**Messenger (chat)** (`apps/server/messenger/`) ⏸️
Two‑user WebSocket chat (`ws`), token auth (hardcoded Adam/Jenna), history in `chat_history.json`
(last 500). Code default port 3000; unit sets 3002. **Currently down.**

**Atlas — AI personal OS** (`apps/Atlas/`) ✅
Node/Express broker (`atlas.service`, **3013** in prod) + vanilla‑JS PWA (`html/atlas → apps/Atlas/pwa/public`)
+ a Windows PC agent over WebSocket (`/atlas/agent`). WebAuthn→JWT (user "Adam"), OpenAI Whisper/TTS,
3‑tier memory, SQLite `broker/data/atlas.db` + JSON context files in `apps/Atlas/context/`. (Repo default
port is 3001; prod overrides to 3013 via broker `.env`.)

### Utilities / business

**Command Center** (`apps/command-center/`, Go) ✅
Single Go 1.25 binary (chi, zerolog, go‑webauthn, age, modernc sqlite, webpush‑go) with an embedded
React/Vite/Tailwind PWA (`go:embed`). Port 3015. Seedbox ops: private‑tracker adapters (MAM, TorrentDay,
TorrentLeech, LST — config‑driven, scraping **disabled by default**), qBittorrent (Feral seedbox, polling),
autobrr, Radarr, Jellyfin, cross‑seed, **Ollama** LLM chat, intelligence/recommendations, budgets,
notifications (WebPush/Discord/ntfy), decisions, simulation. Data: `/var/lib/command-center/command-center.db`
(SQLite, 16 migrations), DuckDB built out (`-tags no_duckdb`), config YAML at `/etc/command-center/config/`,
age key at `/etc/command-center/age.key`. **Two‑layer auth:** outer main session (HTML shell only) + inner
WebAuthn passkey (API/SSE/WS). See SERVER_NGINX_ROUTES.md for the split‑auth nginx blocks.
Receives port‑sync pushes from the local `auto-port-forward` tool.

**NDA Signing (nda)** (`apps/nda/`) ✅
PHP + PHPMailer + TCPDF + signature_pad. Password gate (bcrypt of `NDA2025Secure!` in `login.php`,
2‑hour session). On submit: saves JSON/PNG/PDF in `records/`+`signatures/`, emails via **Gmail SMTP**
(app password hardcoded — debt), and auto‑commits records to git via `sudo /usr/local/bin/nda-git-push`.
Rendered exports also live in `apps/nda-exports/` (data‑only dir).

**Purchase Orders (purchase-orders/mcgrath)** (`apps/purchase-orders/mcgrath/`) ✅
Same architecture as NDA (PHP/PHPMailer/TCPDF, password gate, PDF + email + git auto‑commit). The McGrath
PO record set. Purple ADAMANT theme.

**NPA Signing (npa)** (`apps/npa/` frontend, `apps/server/npa/` backend) ✅
- **Domain:** https://adampowell.pro/npa/
- **Source:** /var/www/adampowell.pro/apps/npa/  · **Backend:** /var/www/adampowell.pro/apps/server/npa/
- **Type:** Node.js + static frontend · **Port:** 3016 (localhost only, proxied by nginx) · **Service:** systemd `npa.service`
- **Features:** Fully self‑hosted e‑signature flow (no third‑party signing API, no recurring cost) for the
  CRNA Non‑Presentation & Non‑Disclosure Agreement, travel/locum template. **Two‑party signing model
  (2026-06-30 rework):** Adam **PRE‑SIGNS at creation** — he draws/types his Provider signature + initials on
  the create form (optionally remembered on‑device via localStorage); these are applied to the §10 Provider
  line, the Exhibit A "Provider Initials" column + acknowledgment, and the Provider half of every page footer.
  The recruiter receives an **already‑Provider‑signed** PDF and **counter‑signs** via the link: consent →
  scroll‑through review (pdf.js) → draw/type **signature + initials** → submit; their signature fills the §10
  Recruiter line and their initials the Recruiter half of every page footer. **Both parties initial every
  page.** Signatures/initials are placed **on the actual lines** (the renderer draws each party's image on its
  line — there is no separate signature page). PDFs are built with **pdf‑lib** (pure JS — no Chromium, for the
  989 MB box). On signing the server captures **IP (`CF‑Connecting‑IP`) + a server‑side timestamp** (never a
  client time) + **device/browser evidence** (User‑Agent parsed to browser/OS/device + Sec‑CH‑UA hints +
  client‑reported platform/timezone/screen), rebuilds the fully‑signed PDF, computes a **SHA‑256** of it,
  generates a **certificate of completion with a full "Device & network evidence" block**, saves both PDFs to
  `records/<id>/`, invalidates the token, and emails **ONLY Adam** (`NPA_NOTIFY_EMAIL`/`apowell-llc@pm.me`)
  both PDFs via postfix→Proton relay. **The completed agreement is NEVER emailed to the recruiter** — they only
  ever see it in‑browser during signing. The certificate **explicitly names its own limitation** (sender's own
  server attesting, not an independent third party; device fields are self‑reported/spoofable while IP + server
  time are observed) — see `apps/server/npa/README.md`. The finalized agreement text is verbatim in
  `apps/server/npa/template/agreement-body.js` (single source of truth); Exhibit A renders on a clean page
  break; the 90‑day default expiry is consistent between the computed cell, the footnote, and `DEFAULT_AUTH_DAYS`.
  Provider signature images live in a per‑record sidecar (`records/<id>/provider-sig.json`, gitignored PII), not
  the JSON store. A shared `apps/npa/sigpad.js` draw/type component is used by both pages (loaded by **absolute**
  `/npa/sigpad.js` so it resolves from the `/npa/sign/<token>` path).
- **Auth:** Two‑tier and **structurally separate** — (1) Adam's create‑agreement form has its **own bcrypt
  password gate** (`NPA_PASSWORD_HASH` in `/root/secrets.env`, 2‑hour **in‑memory** bearer session in
  `sessionStorage`, NOT the main adampowell.pro `apsess` login); (2) recruiter signing uses a single‑use,
  7‑day‑expiring, `crypto.randomBytes(32)` token sent by email, **stored hashed at rest**, consumed on first
  signing — no password, no account. The two share no code path or storage.
- **Storage:** single atomic‑write JSON store `data/agreements.json` (+ rolling 50 backups); signed PDFs/certs
  in `records/<id>/`. Both `data/` and `records/` are **gitignored signer PII** (signatures, IPs, signed PDFs).
- **nginx:** needs `/npa/api/` (proxy → :3016, `client_max_body_size 8M` for the base64 signature), `/npa/sign/`
  (serves `sign.html` for the `/npa/sign/<token>` path), and `/npa/` — all **before** the catch‑all and **without**
  `auth_request` (Adam side has its own password; recruiter side is token‑gated). See SERVER_NGINX_ROUTES.md.
- **Gotchas (hardened during the launch E2E — see `apps/server/npa/README.md`):** (1) **pdf‑lib 1.17.1's PNG
  decoder busy‑loops on a malformed PNG**, pegging CPU and hanging the whole event loop — `pdf-template.js`
  `validatePng()` structurally validates the signature image (incl. that IDAT inflates to the exact expected
  size) BEFORE `embedPng`; a bad image is skipped, not embedded. Don't remove it. (2) The completion email to
  Adam is sent **fire‑and‑forget after** the response — never block the recruiter's signing POST on an outbound
  SMTP round‑trip or Cloudflare 504s it. (3) All drawn text passes a **WinAnsi sanitizer** so pasted emoji/CJK
  can't 500 the build (em‑dashes/curly quotes preserved). (4) The audit IP comes from **`CF‑Connecting‑IP`**
  (then leftmost `X‑Forwarded‑For`), NOT `X‑Real‑IP`/`$remote_addr` — behind Cloudflare the latter is a CF edge
  IP, not the signer's.
- **Status:** ✅ Fully operational. End‑to‑end verified live (create → email → consent → pdf.js scroll‑through →
  canvas/typed signature → server‑side IP+timestamp+SHA‑256 → certificate → both PDFs emailed; token single‑use
  410 on reuse). **Added:** 2026-06-30.

**Rosters — AHA class rosters + public student self-signup** (`apps/rosters/` frontend, `apps/server/rosters/` backend) ✅
- **Domain:** https://adampowell.pro/rosters/  (public signup: https://adampowell.pro/rosters/signup/&lt;slug&gt;)
- **Source:** /var/www/adampowell.pro/apps/rosters/  · **Backend:** /var/www/adampowell.pro/apps/server/rosters/
- **Type:** Node.js + static PWA · **Port:** 3017 (localhost only, proxied by nginx) · **Service:** systemd `rosters.service`
- **Features:** A class roster + student self‑signup tool for the AHA courses (ACLS / BLS / PALS —
  extensible list) Adam teaches at Ace Medical. **Three surfaces:** (1) an **admin panel** (`index.html`) where he
  creates classes (course type, date, location default "Ace Medical", instructor default "Adam Powell" — each gets a
  short **random alphanumeric slug**, not a sequential id, and a signup URL `/rosters/signup/<slug>`), sees a class
  list with pending/approved counts + a per‑class **copy‑signup‑link** button, and opens a class detail showing two
  groups — **Pending** (public sign‑ups awaiting review) and **Approved** (the real roster) — where he can edit any
  field, approve a pending student, reject/delete, or **manually add** a student straight to Approved (walk‑ins /
  phone). Per‑student fields: first/last name, email, phone, home/business address, precourse self‑assessment, test
  score, CCF% (optional). (2) A **public student signup form** (`signup.html`) at `/rosters/signup/<slug>` — no auth —
  that looks up the class by slug (**only course type + date shown** to confirm — no location/business name is exposed
  publicly), collects first/last/email/**phone/address — ALL required** (NOT scores), validates required fields + basic
  email format, **rate‑limits per IP** (in‑memory sliding window, ~5/min), and writes a **Pending** record (no email
  confirmation loop in v1). (2b) A **public class‑request form** (`requests.html`) at **`/requests`** — fully public — where
  a visitor enters name/email/phone/address, **multi‑selects class(es)** from the course list, **picks one of two
  course formats** (`opt`), proposes **free‑form preferred date(s)/time(s)** + optional notes, and on submit **emails
  Adam** (`ROSTERS_NOTIFY_EMAIL`, default `apowell-llc@pm.me`, fire‑and‑forget via postfix→Proton) and lands in a
  **Requests** section of the admin panel (new/handled status, mark or delete). **Two AHA course paths:** (1)
  **In‑person with the instructor** (`opt=inperson`, default) — the whole course in one session: instructor‑played
  videos, the AHA Provider Manual (each student needs their **own** current manual — a `manual` have/need/unsure
  question shows only for this path), the **written exam proctored in person**, and skills; no separate online
  purchase. (2) **Online first (HeartCode)** (`opt=online`) — the student buys the official HeartCode online course
  (precourse + videos + exam, on AHA's platform; the form links HeartCode ACLS/BLS/PALS) then books a skills session
  (manual question hidden). **No AHA content (videos/exam) is hosted on adampowell.pro** — a deliberate compliance
  decision (AHA copyright + exam‑security; the online exam lives inside each student's HeartCode purchase; the
  in‑person exam is the paper exam the instructor proctors + collects; a keyed exam PDF must never be student‑facing).
  `opt` (+ `manual` for in‑person) show in the admin card + email. **Defended in depth, all
  in‑app** (no reCAPTCHA/third‑party/tracking): **(i) an ENTRY GATE that blocks
  scrapers from the content itself** — the page loads showing ONLY an anesthesia question (no form, no class list);
  solving it (`POST /api/request/enter`) mints a short‑lived (20 min), IP‑bound, HMAC‑signed **entry pass**, and the
  server then **withholds the course list** (`/api/request/courses` → 401 without a pass) **and rejects submissions**
  without one, so a scraper that never answers gets an empty shell + no data. **(ii) USA‑only** — all request
  endpoints require the visitor to be US‑based via Cloudflare's **`CF‑IPCountry`** header (the droplet has no GeoIP DB;
  a *missing* header is allowed through since only the raw origin lacks it and nginx is the sole public path); non‑US →
  403 polite "US only". **(iii)** the challenge is a random **anesthesia board‑style question** from a **~60‑question
  pool** (`ANES_QA` in server.js — e.g. rocuronium reversal → sugammadex, anaphylaxis → epinephrine, MH → dantrolene),
  each with a **generous accepted‑answer set** (synonyms/brand↔generic/misspellings, normalized); the server returns
  only the question + an **HMAC‑signed opaque token** (`ROSTERS_CAPTCHA_SECRET`) carrying a hash of **each accepted
  answer** + a single‑use nonce + timestamp — **the answers never reach the browser**, tokens can't be forged/replayed
  (10‑min TTL), fully keyboard/screen‑reader answerable. **(iv)** layered under **honeypot fields** (`website`/`company`
  ⇒ fake 200, silently dropped), a **min‑fill‑time** check (<3 s ⇒ dropped), and **rate limits** (3/min submit,
  12/min entry, per IP). Strong against automated spam + scraping; not a claim of being unbeatable by a determined
  human. (2c) A **per‑request Venmo pay page** at **`/rosters/pay/<token>`** (`pay.html`). After confirming a class,
  Adam sets an **amount** (+ an optional **coupon** = code + % off) on the request in the admin panel → that mints an
  unguessable `payToken` and a pay link he sends the student. The page shows the amount, the **Venmo QR** (@ATP28,
  `apps/rosters/venmo-qr.jpg`), and a **"Pay with Venmo"** button deep‑linked with the amount + a note prefilled
  (`venmo.com/ATP28?txn=pay&amount=..&note=..` — **no `/u/`**, see 2026-07-27 fix below). If a coupon is attached, a **code box** applies the % off and
  updates the total + Venmo amount (the code is validated server‑side, never sent to the client; a wrong code just
  returns `valid:false`). The student then clicks **"I've sent it"** + enters their Venmo handle → the request is
  flagged **payment claimed** (emails Adam); Adam checks his Venmo app and clicks **"Mark paid"** in the admin panel.
  **A personal Venmo has NO API to auto‑confirm a payment** — this is deliberately a manual‑verify loop (the app
  tracks the student's claim + Adam's verification), documented as such. Venmo user is `ROSTERS_VENMO_USER` (env,
  default `ATP28`). (3) Per approved student, two **"Fill it out" paste‑chain** copy helpers — one button per
  section puts a single **tab‑separated** string on the clipboard so Adam pastes into the first field of the target form
  and tabs across: **Enrollware "Student Scores"** (Precourse → Test → CCF% — every key always included, blank CCF% kept
  so the tab count stays consistent) and **AHA eCards "Assign to Students"** (Training Center "Allied 100, LLC" →
  Training Site "Ace Medical" → Course [mapped from course type] → Instructor → First → Last → Email; `address` is a
  valid AHA key but **off by default** — add it in Settings once you know where heart.org wants it). Each button is labeled with its
  paste order, and every value also has an individual copy‑icon fallback. The **course→AHA‑course‑name map and both
  paste‑chain field orders are editable** from an admin‑panel Settings editor (`PUT /api/config`) — so AHA naming
  mismatches and field‑order shifts are corrected with no code push. **Constraint (documented in the UI + README):**
  the browser same‑origin policy means a button here **cannot** fill a field on enrollware.com or heart.org directly;
  the tab‑separated clipboard + manual paste‑and‑tab is the realistic ceiling — no userscript/extension/headless
  scripting of those third‑party logins. If a chain mis‑lands, the fix is to reorder the field config, not to automate
  the other site.
- **Auth (updated 2026-07-01 — admin now DOUBLE‑gated):** the **admin panel + admin API are gated by the MAIN
  adampowell.pro login (`auth_request`) ON TOP of** the app's **own bcrypt password** (`ROSTERS_PASSWORD_HASH` in
  `/root/secrets.env`; `rosters.service` refuses to start if unset). So Adam logs into adampowell.pro first, then the
  admin panel loads and asks for the rosters password → the server issues a **2‑hour in‑memory bearer token** held in
  `sessionStorage` (never a cookie), and every admin route checks it (`requireAdam`). _(Was own‑password‑only /
  main‑gate‑bypassing until 2026-07-01, when the operator added the main login for abuse prevention — trade‑off: the
  panel is no longer reachable from a device not signed into the main site.)_ The **rosters login screen is wordless**
  (blank password box, submit on Enter, red‑shake on a wrong password; header/footer render after unlock). nginx
  gating carve‑out: because the admin API + public request/pay APIs both live under `/rosters/api/`, the **public
  request API is split into `= /rosters/api/request` + `/rosters/api/request/`** (so the admin `/rosters/api/requests*`
  routes are NOT un‑gated by a bare `/request` prefix — a hole caught by adversarial verification), with
  `/rosters/api/pay` + `/rosters/api/signup/` the other ungated API prefixes; then `/rosters/api/` + `/rosters/` get
  `auth_request`. See SERVER_NGINX_ROUTES.md. The **public student/request/pay pages + their APIs stay fully ungated**,
  scoped only to an unguessable per‑class slug / captcha entry‑pass / per‑request pay token (trust model like npa's
  `/npa/sign/`). A bad slug/token returns a **generic 404**, indistinguishable from any other failure. The public
  request POST can only ever create a *pending* record and forces scores empty regardless of payload.
- **Storage:** single atomic‑write JSON store `data/rosters.json` (`.tmp`+rename) + rolling 50 backups in
  `data/backups/` — same pattern as case‑tracker / npa (§14). **Corrupt‑store recovery (2026-07-02):** if
  `rosters.json` won't parse, `loadStore()` does NOT silently start empty (which the next save would have
  snapshotted into the rolling backups, eventually destroying the real data) — it preserves the bad file as
  `data/rosters.corrupt.<ts>.json` (non‑rotating), recovers from the newest parseable rolling backup, and only
  starts fresh if none parse. Holds student PII (names/emails/phones/addresses) +
  **class requests** (requester name/email/phone + preferred times) → `data/` is **gitignored** by an explicit rule
  (NOT the global `*.json`). The editable `config` block (course list, AHA map, paste‑chain orders, Training
  Center/Site, defaults) lives inside the store; its *defaults* ship in `server.js` (`DEFAULT_CONFIG`) and seed on
  first boot, after which the admin editor owns them. No SQL DB, no paid API. Sends email (postfix→Proton) for the
  class‑request notification only.
- **nginx (rewritten 2026-07-01 for the admin gate):** the **admin** `/rosters/api/` + `/rosters/` blocks carry
  `auth_request` (main login); the **public** blocks are ungated — `/rosters/api/signup/`, **`= /rosters/api/request`
  + `/rosters/api/request/`** (the split that keeps admin `/requests*` gated), `/rosters/api/pay`, `/rosters/signup/`,
  `/rosters/pay/`, and `= /requests`/`= /requests/`. A `limit_req_zone` (60r/m per CF‑Connecting‑IP) in `http{}` +
  `limit_req` on the public API blocks is a server‑level rate backstop. **Search exclusion:** ungated
  `= /robots.txt` (`html/robots.txt` = `Disallow: /`) + a server‑level `X-Robots-Tag: noindex, nofollow` header (also
  on the public rosters page shells, since a location `add_header` drops inherited headers). See SERVER_NGINX_ROUTES.md.
- **Non‑goals (v1):** no login automation/scraping/API calls vs enrollware.com or heart.org (manual paste only); no
  student emails (only the class‑request notification to Adam); no multi‑instructor/multi‑user; no PDF/certificate
  handling. (The admin panel *does* have its own password — an intentional change from the initial main‑login‑gated
  build, so it's reachable off the main site.)
- **Status:** ✅ Operational. **Added:** 2026-06-30.

**bp / mickey / photography** (`apps/bp/`, `apps/mickey/`, `apps/photography/`) ✅
PHP photo galleries. Session password auth (hardcoded hashes), file upload with **VirusTotal** scanning,
ip‑api.com geo logging (`geo_cache/`), admin panels. `uploads/`, `logs.txt`, `error(s).log`, `geo_cache/`
are server‑only runtime state. Same hardcoded VT API key across all three (debt). bp = "Blake & Powell
Photography"; mickey + photography are "Mickey's Gallery" variants.

### Local tooling (not on the server)

**auto-port-forward** (`auto-port-forward/`, repo‑root) — Windows tooling
PowerShell + VBScript run by Windows Task Scheduler every 5 min ("ProtonVPN Port Sync"). Queries Proton
VPN's forwarded port live via **NAT‑PMP** (UDP 5351 @ 10.2.0.1), sets it on local qBittorrent (Web API,
DPAPI‑encrypted password file), and pushes status to Command Center
(`/command-center/api/portsync/{report,status}`, bearer `CC_PORTSYNC_TOKEN`). Logs/state in
`port-sync.log`/`port-sync.state.json`. Not a web service.

---

## 12. Repository file tree (local + server)

### Local clone — `c:\ADAMANT\ADAMPOWELL PRO\adampowell.pro\`
```
adampowell.pro/
├── .git/  .gitignore
├── github-webhook.js            ← pm2 github-webhook-adampowell (3005)
├── package.json / -lock.json    ← server-only (nodemailer for webhook), gitignored deps
├── node_modules/                ← server-only, gitignored
├── scripts/
│   └── rebuild-symlinks.sh      ← run by the webhook after every pull
├── docs/
│   ├── SYSTEM.md                ← this file
│   ├── SERVER_NGINX_ROUTES.md
│   ├── WHEN CREATING A NEW APP.md
│   └── "# PROMPT.MD"            ← scratch ops prompt (not part of the system)
├── html/                        ← nginx web root (mostly symlinks; see §13)
│   ├── index.html  favicon.ico  nav.json  shift-calc/   (real, tracked)
│   └── <app> → ../apps/<app>    (symlinks rebuilt on deploy)
├── apps/                        ← ALL application sources
│   ├── ANSReset/  Atlas/  bp/  case-tracker/  command-center/
│   ├── crna-pay-calculator/  ekg-tutor/  infus/  investment-calculators/
│   ├── janda/  mac/  medcalc/  mickey/  nav/  nda/  nda-exports/  npa/
│   ├── petfeed/  photography/  POCUS/  preop/  pulse/  purchase-orders/
│   ├── RandEmail/  remote/  rsvp/  sharedtodo/  todos/
│   └── server/                  ← backend services (systemd-managed)
│       ├── auth/  case-tracker/  messenger/  npa/  petfeed/  pulse/
│       └── RandEmail/  remote/  sharedtodo/  todos/
│       (npa/ also holds template/agreement-body.js + pdf-template.js;
│        data/ + records/ are gitignored signer PII)
├── auto-port-forward/           ← local Windows port-sync tool (also synced to server, unused there)
├── OLD BACKUPS/                 ← user-managed backups (gitignored cruft)
├── aha training/                ← legacy leftover dir (space in name) — cleanup candidate
└── tmp/                         ← scratch
```
> The local repo root historically also had stray files; `docs/INVENTORY_2026-04-10.md` was removed
> (its content is superseded by this doc). Keep the repo root clean — only the items above belong there.

### Server repo — `/var/www/adampowell.pro/`
Same as local, plus deploy‑time artifacts and a little cruft worth knowing:
- `nda → apps/nda`, `purchase-orders → apps/purchase-orders` (compat shims from rebuild-symlinks.sh)
- `janda.db` at the repo root (leftover; the live janda DB is `apps/janda/janda.db`)
- `Atlas/` at the repo root (**untracked leftover**; the real one is `apps/Atlas/`)
- untracked runtime: `apps/photography/geo_cache/`, `apps/server/*/data/`, `apps/ekg-tutor/*` build/data,
  package‑locks, `.env`/`.env.example` files.

---

## 13. Server filesystem layout

```
/var/www/
├── adampowell.pro/          ← the monorepo (see §12); ~3.8 GB
└── html/                    ← nginx default welcome page (fallback only)

/var/www/adampowell.pro/html/   (nginx web root — `ls -la` shows the symlink map)
  real files:  index.html  favicon.ico  nav.json  shift-calc/
  real dirs (per-file symlinks + room for secrets.js):
               ANSReset/  investing/  RandEmail/  remote/  todos/  petfeed/  rsvp/  sharedtodo/
  dir symlinks → apps/:  atlas  bp  case-tracker  crna-pay-calculator  ekg-tutor  infus
                          mac  mickey  nav  photo(→photography)  POCUS  pulse(→pulse/client/dist)
  file symlink:  cardiac-anes.html → apps/POCUS/cardiac-anes.html
  leftover real dir:  janda/  (janda is proxied, not served from here)

/etc/nginx/sites-{available,enabled}/adampowell.pro   ← SEPARATE FILE COPIES (not a symlink) — edit BOTH
/etc/nginx/sites-{available,enabled}/{bp,mac,mickey,dns,photography}.adampowell.pro[.conf]
/etc/systemd/system/*.service                         ← all app units (see §4)
/root/secrets.env                                     ← the vault (mode 600)
/root/AdGuardHome/                                    ← AdGuard install + AdGuardHome.yaml (service stopped)
/root/nginx-backups/                                  ← timestamped nginx backups
/var/lib/command-center/                              ← command-center.db, .duckdb, heartbeat.json, backups/
/etc/command-center/                                  ← config/ (YAML) + age.key
/var/lib/ekg-tutor/uploads/                           ← EKG image uploads (mode 750)
/var/log/                                             ← see §16
/usr/local/bin/                                       ← nda-git-push, cc-set-* helper binaries
```

---

## 14. Databases

| Engine | DB / file | Owner/role | Used by | Notes |
|---|---|---|---|---|
| SQLite | `apps/server/auth/users.db` | — | auth | users + login_attempts + `dashboard_layouts` (per‑user landing layout overlay, 2026-06-21) |
| SQLite | `apps/server/auth/sessions.db` | — | auth | session store (connect-sqlite3) |
| SQLite | `apps/janda/janda.db` | — | janda | relationship data (380 questions, 39 conversation starters, + the feature tables) |
| SQLite | `apps/janda/sessions.db` | — | janda | session store (connect‑sqlite3, added 2026-06-13) |
| SQLite | `apps/Atlas/broker/data/atlas.db` | — | atlas | tasks/memory/devices |
| SQLite | `apps/server/petfeed/data/weights.db` | — | petfeed | cat weights/todos (WAL) |
| SQLite | `apps/server/todos/data/todos.json` | — | todos | (JSON file, not SQLite) |
| SQLite | `/var/lib/command-center/command-center.db` | — | command-center | 16 migrations, age-encrypted `secrets` table |
| DuckDB | `/var/lib/command-center/command-center.duckdb` | — | command-center | analytics (built out via `no_duckdb` tag) |
| Postgres | `kvh_pulse` | `kvh_pulse_user` | KVH PULSE | auto-migrates on boot |
| Postgres | `sharedtodo` (schema `sharedtodo`) | `sharedtodo_user` | sharedtodo | `lists`+`todos`; `node migrate.js` |
| Postgres | `ekg_tutor` | ekg-tutor role | EKG Tutor | Drizzle migrations; pgcrypto/citext/uuid-ossp (no pgvector available on PG11) |
| JSON file | `apps/server/case-tracker/server/data/casetracker.json` | — | case-tracker | rolling 50 backups |
| JSON files | `apps/server/remote/data/{macros,flows,credentials,history}.json` | — | remote | atomic writes, gitignored |
| JSON file | `apps/server/npa/data/agreements.json` | — | npa | atomic writes + rolling 50 backups; tokens stored **hashed**; gitignored (signer PII). Signed PDFs + certs in `apps/server/npa/records/<id>/` (also gitignored) |
| JSON file | `apps/server/rosters/data/rosters.json` | — | rosters | atomic writes + rolling 50 backups; holds classes + student PII (names/emails/phones/addresses) + **class requests** (incl. payment/coupon/evalsDone/sheetsDone) + **course‑eval submissions** + **exam answer sheets** (students' own bubbled answers + score; no exam content) + the editable `config` block; gitignored |

---

## 15. Scheduled jobs (cron + timers)

- **`git-auto-pull-adampowell.timer`** — every 5 min, `git fetch && git pull origin main` in the repo
  (second live deploy path alongside the webhook).
- **`git-auto-pull-maps.timer`** — every 5 min, targets the deleted `/var/www/maps` (errors harmlessly).
- **`certbot.timer`** + **`snap.certbot.renew.timer`** + **`/etc/cron.d/certbot`** — Let's Encrypt renewals.
- **`/etc/cron.d/cc-snapshot-retention`** — daily 04:30, prunes Command Center `torrent_snapshots` older
  than 3 days from `command-center.db`.
- **`/etc/cron.d/php`, `/etc/cron.d/sysstat`** — distro defaults.
- **`pm2-root.service`** — resurrects pm2 processes on boot.
- (Disabled: an `aha-auto-sync.sh` NDA sync line in root crontab is commented out.)

---

## 16. Disk, backups & logs

**Disk:** `/dev/vda1` 25 GB, **~88% used, ~2.9 GB free** (2026-06-06). `/var/www/adampowell.pro` ≈ 3.8 GB.
Largest app dirs: `ekg-tutor` 870M · `bp` 504M · `apps/server` 290M (pulse node_modules ≈107M) ·
`command-center` 208M · `mickey` 160M · `pulse` 137M · `janda` 131M · `Atlas` 38M · `nda` 28M.
**Watch free space** — at 88% this box is one big upload/log from trouble. Cleanup candidates:
`OLD BACKUPS/`, `aha training/`, the committed CSV/XLSX/PDF sample data, old `/etc/nginx/sites-available/*.backup*`.

**Backups:** GitHub is the code backup. nginx config backups in `/root/nginx-backups/`. Command Center has
its own `/var/lib/command-center/backups/` + the daily snapshot‑retention cron. No automated DB‑dump cron
exists — Postgres/SQLite rely on DigitalOcean droplet snapshots if enabled.

**Logs:**
```
/var/log/nginx/{access,error}.log + bp.* photography.*
/var/log/github-webhook-adampowell.log         (deploy log — tail this after a push)
/var/log/command-center.log + -error.log
/var/log/npa-app.log + /var/log/npa-error.log  (npa.service stdout/stderr — append mode, so OLD entries persist)
journalctl -u <service>                         (auth, todos, remote, randemail, atlas, case-tracker, npa, …)
pm2 logs github-webhook-adampowell
/var/log/postgresql/                            (Postgres)
```

---

## 17. Gotchas / hard‑won lessons

1. **Never edit/scp/build/`git stash`/`git reset` on the server.** Edit local → commit → push. A dirty
   server tree breaks the webhook's `--ff-only` and blocks the whole monorepo's deploy.
2. **nginx `sites-enabled` is a file COPY, not a symlink.** Edit BOTH `sites-available` and `sites-enabled`,
   then `diff` them (should be empty), `nginx -t`, reload. Back up to `/root/nginx-backups/` first.
3. **Two locations with the same content always drift.** One canonical source per app; everything else a symlink.
4. **SSE through nginx+Cloudflare** needs `proxy_buffering off; proxy_cache off; chunked_transfer_encoding on;`
   + 24h read/send timeouts (todos, petfeed, remote daemon). The Node side also sends `X-Accel-Buffering: no`.
5. **Bearer‑token apps must bypass `auth_request`** (daemon/widgets can't hold a session cookie) and the
   block must forward `Authorization` (`proxy_set_header Authorization $http_authorization;`) and sit *before*
   the catch‑all. nginx picks the longest prefix regardless of order.
6. **Command Center** needs `proxy_cookie_path / /command-center/;` (so WebAuthn cookies scope to the
   subpath) and a split into inner‑auth API/SSE/WS vs outer‑gated HTML shell (so an expired outer session
   doesn't redirect a PWA `fetch()` cross‑scope → iOS `TypeError: Load failed`).
7. **`auth_basic` + `auth_request` on the same block = redirect loop** (both 401s map to `@login_redirect`,
   browser never sees `WWW-Authenticate`). The old per‑app HTTP basic‑auth second factor was removed 2026-04-18.
8. **DoH abuse sink:** `location = /dns-query { return 444; }` drops bot traffic that otherwise floods the
   auth session store.
9. **Don't use `Restart=always` on app code.** Use `Restart=on-failure` + `StartLimitBurst=3` (maps/aiomail
   once burned 800k+ restarts under OOM).
10. **The 2 GB swap is mandatory** on a 989 MiB box, or node fails to start.
11. **PHP consolidation:** `/bp/ /mickey/ /photo/` execute PHP via one regex block
    `location ~ ^/(bp|mickey|photo)/.*\.php$` against `root /var/www/adampowell.pro/html;` + the symlinks.
12. **New static/PWA app = zero nginx changes** — add a `rebuild-symlinks.sh` line + a `nav.json` tile.

---

## 18. Smoke tests & troubleshooting

```bash
# Auth + a quick gated check (expect 200 authed, 302 unauth)
COOKIE=$(curl -sD - -H 'Content-Type: application/json' \
  -d '{"username":"adam","password":"<pw>"}' https://adampowell.pro/auth/api/login \
  | awk '/^[Ss]et-[Cc]ookie:.*apsess/{sub(/^[Ss]et-[Cc]ookie: /,"");sub(/;.*/,"");print}')
for p in / /POCUS/cardiac-anes.html /investing/ /todos/ /case-tracker/ /command-center/ /mac/ /bp/; do
  printf '%-28s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' -H "Cookie: $COOKIE" "https://adampowell.pro$p"
done

# /npa/ intentionally BYPASSES the main gate (own password / signing token), so it
# returns 200 even UNauthenticated — verify it directly, not in the loop above:
curl -s -o /dev/null -w 'npa /npa/        %{http_code} (expect 200)\n' https://adampowell.pro/npa/
curl -s -o /dev/null -w 'npa /npa/api/... %{http_code} (expect 200)\n' https://adampowell.pro/npa/api/health

# Service / port health on the box
ss -tlnp                                  # what's actually listening
systemctl is-active auth todos remote command-center case-tracker atlas npa
pm2 list                                  # github-webhook (online), janda-app (online)
tail -f /var/log/github-webhook-adampowell.log   # watch a deploy
nginx -t && systemctl reload nginx        # after any nginx edit
df -h / ; free -h                         # disk (88%!) + memory
certbot certificates                      # TLS expiries
```

Common cases:
- **An app 502s** → its backend service is down; `systemctl status <svc>` / `pm2 list`, then start it (§4).
- **`/chat/` down** → messenger disabled (currently expected). **`/janda/` 502 after a Node upgrade or a
  plain `npm install` in apps/janda** → stale/rebuilt-wrong `better-sqlite3` native binary (ABI/glibc/c++std
  mismatch on Debian 10). Rebuild from source with Python 3.12: `cd apps/janda && export
  npm_config_python=/root/.local/bin/python3.12 && npm rebuild better-sqlite3 --build-from-source`, then
  source `/root/secrets.env` and `pm2 restart janda-app --update-env` (see §11 janda entry).
- **`auth.service` crash-loops on restart with `ERR_DLOPEN_FAILED` / `NODE_MODULE_VERSION 108 vs 127`** →
  same `better-sqlite3` ABI trap as janda. The auth process kept running across the Node 18→22 upgrade
  (it never re-`require`d the module), so the stale Node‑18 binary stayed latent until the **first restart
  after the upgrade** (hit 2026-06-21). Fix: `cd /var/www/adampowell.pro/apps/server/auth &&
  npm rebuild better-sqlite3` (auth's deps build with the system toolchain — no Python‑3.12 override needed),
  then `systemctl reset-failed auth.service && systemctl restart auth.service`; confirm `systemctl is-active`
  = `active` and `curl -s http://127.0.0.1:3003/health`. **Any other app that loads `better-sqlite3` and was
  started before the Node‑22 upgrade (e.g. atlas, todos, petfeed if long‑lived) carries the same latent
  crash on its next restart — rebuild before restarting.**
- **`dns.adampowell.pro` 502** → AdGuard stopped (`systemctl start AdGuardHome`).
- **Deploy didn't land** → check the webhook log; if `--ff-only` failed, the server tree is dirty
  (find the dirty/untracked file; never `git reset --hard` blindly).
- **Locked out of login** → 5/15min rate limit; wait or restart `auth.service`.

---

## 19. Condensed changelog

- **2026-07-01 (later) — Rosters: save students' exam answer sheets to records.** After payment, the student gets a
  per‑registration link (`/rosters/answersheet/<pay‑token>`, `apps/rosters/exam.html`) to bubble their exam answers
  (one A–D grid per purchased course), **saved to Adam's records** — storing ONLY the student's own answers + identity
  (name/date/version), **no AHA exam questions or answer keys**. Routes: `GET`/`POST /api/pay/:token/answersheet`
  (public, paid‑gated), admin `GET /api/answersheets` + `PATCH` (record a score after grading against Adam's own key)
  + `DELETE`; request tracks `sheetsDone[]`; `store.answersheets`. Admin panel gained an "Exam answer sheets" card
  (student, course, version, answered count, the bubbled answers) with an inline score box. The pay page's paid state
  now links both the evaluation and the answer sheet. nginx: ungated `/rosters/answersheet/` block. Verified live.
- **2026-07-01 (later) — Rosters: course evaluations + answer sheet + scrape hardening + coupon preview.**
  (a) **Course evaluations** — `apps/rosters/eval.html` + `EVAL_COURSES` (ACLS/PALS from Adam's own eval PDFs, BLS
  standard objectives). After Adam marks a request **PAID**, the pay page routes the student to `/rosters/eval/<pay‑token>`
  to complete an eval **for each class they bought** (required); submissions stored in `store.evals`, shown in a new
  admin "Course evaluations" card. Routes: `GET`/`POST /api/pay/:token/evals` (public, paid‑gated), `GET`/`DELETE
  /api/evals` (admin). nginx: ungated `/rosters/eval/` block. (b) **Blank answer sheet** — `answersheet.html`, a generic
  A–D/A–E bubble grid (configurable count, print/tablet) with **NO exam content** (an instructor tool, admin‑gated);
  deliberately did **not** reproduce any copyrighted AHA exam. (c) **Scrape hardening** — the `/requests` form HTML is no
  longer in the page source; it's fetched from a gated `GET /api/request/form` (requires the captcha entry‑pass) and
  injected only after the anesthesia question is solved (pricing moved to `data‑*` on that gated form), and the captcha
  question text is `user‑select:none`. (d) **Admin coupon preview** — live "→ $X with coupon" as you type. Cloudflare
  **Bot Fight Mode** turned on by the operator (dashboard). See §10, §14, and SERVER_NGINX_ROUTES.md.
- **2026-07-01 — Rosters: admin double‑gated (main login) + Venmo pay/coupon UX + bot‑hardening + search exclusion.**
  (a) **Admin gate:** the `/rosters/` admin panel + admin API now sit behind the **MAIN login (`auth_request`) on top
  of** the app's own bcrypt password (was own‑password‑only). Required a verified nginx carve‑out — the public request
  API is split into `= /rosters/api/request` + `/rosters/api/request/` so the admin `/rosters/api/requests*` routes
  aren't un‑gated by a bare `/request` prefix (a real hole a design→verify **workflow** caught before deploy);
  public request/signup/pay routes stay ungated. (b) **Venmo pay page** (`/rosters/pay/<token>`, added same window):
  per‑request amount + optional **coupon** (code + % off, student‑entered), @ATP28 QR + deep‑link, student‑claims →
  Adam‑verifies loop (no personal‑Venmo auto‑confirm API). Admin payment UI reworked to an **inline editor** (amount
  box + "Add coupon" checkbox + one "Save & copy link" button, no popups; one price covers all packages; shows the
  discounted total). (c) **Bot‑hardening:** entry‑captcha 12→5/min + an 8‑distinct‑questions/hour/network cap
  (anti‑bank‑harvest); added rate limits to the signup/pay lookup GETs; coupon 15→5/min; an nginx `limit_req_zone`
  backstop. (d) **Search exclusion:** ungated `/robots.txt` (`Disallow: /`) + `X-Robots-Tag: noindex, nofollow`
  header. All nginx changes verified (`diff` empty, `nginx -t`, full auth matrix + public E2E live). See §8, §10, §11,
  and SERVER_NGINX_ROUTES.md.
- **2026-06-30 (later, same day) — Rosters `/requests` hardening: anesthesia captcha + entry gate + USA‑only +
  address; ACLS‑Instructor removed.** (a) The request captcha became **anesthesia board‑style questions** (~60‑Q
  `ANES_QA` pool — rocuronium reversal→sugammadex, anaphylaxis→epinephrine, MH→dantrolene) with generous
  synonym/brand/misspelling matching; the token now hashes **each** accepted answer, still never shipping the answer
  to the browser. (b) Added an **ENTRY GATE**: the page loads showing ONLY the question — no form, no class list;
  solving it mints a short‑lived IP‑bound HMAC **entry pass**, and the server withholds the course list (401 without
  a pass) + rejects submissions without one, so **scrapers get an empty shell**. New endpoints
  `GET /api/request/entry` + `POST /api/request/enter` (old `/api/request/captcha` removed). (c) **USA‑only** via
  Cloudflare `CF‑IPCountry` (non‑US → 403; missing header allowed) — **nginx `/rosters/api/` now forwards
  `CF‑IPCountry` + `CF‑Connecting‑IP` set from Cloudflare's values** so a spoofed country header can't win (backups
  `*.pre-cfgeo.*`). (d) The request form also collects a **required address**, and **ACLS‑Instructor was removed**
  from the course list (code default + live config). Verified live E2E through Cloudflare (locked‑without‑pass 401,
  non‑US 403, spoof defeated, solve→pass→courses→submit→email). Dashboard tiles refreshed
  (admin vs public‑link labels). (See §9, §10, §11.)
- **2026-06-30 (later, same day) — Rosters: public class‑request form (`/requests`) + custom anti‑bot captcha.**
  New public page `adampowell.pro/requests` (`apps/rosters/requests.html`) where a visitor proposes preferred
  date(s)/time(s), multi‑selects class(es), and submits — it **emails Adam** (`ROSTERS_NOTIFY_EMAIL`, postfix→Proton,
  fire‑and‑forget) and lands in a new **Requests** section of the `/rosters/` admin panel (new/handled status, mark or
  delete). Guarded by a **custom, free, no‑third‑party anti‑bot challenge**: a server‑issued **anesthesia board‑style
  question** (~60‑question pool, generous synonym/brand matching) whose **answer never reaches the browser** (returned
  as an **HMAC‑signed opaque token** via new vault secret `ROSTERS_CAPTCHA_SECRET`; single‑use nonce, 10‑min TTL, can't
  be forged/replayed; fully keyboard/screen‑reader answerable), layered under **honeypot fields** (filled ⇒ fake 200 +
  silently dropped), a **min‑fill‑time** check (<3 s ⇒ dropped), and a **3/min/IP** rate limit. _(Questions were plain
  word/number puzzles at first, switched same‑day to the anesthesia bank per operator request.)_ Same backend/port/service (`rosters.service` gained `nodemailer` +
  the request/captcha routes); **nginx edit** added `= /requests` + `= /requests/` (the request API rides the existing
  ungated `/rosters/api/`). `requests` collection added to `data/rosters.json`. Backend defenses verified locally
  (valid solve stored; replay/wrong‑answer 400; honeypot + too‑fast silently dropped, 0 stored) and live E2E through
  Cloudflare. (See §9, §10, §11, §14.)
- **2026-06-30 (later) — Rosters (`/rosters/`) added.** New full‑stack app: AHA class roster + public student
  self‑signup for the ACLS/BLS/PALS/etc courses Adam teaches at Ace Medical. Node backend `rosters.service`
  (port **3017**, localhost‑only, `apps/server/rosters/`, express + bcrypt, atomic‑write JSON store +rolling‑50
  backups, no DB/email/paid‑API) + a static PWA frontend (`apps/rosters/`: `index.html` admin panel,
  `signup.html` public form). **Auth model = npa's (own password), NOT the main login:** the whole app bypasses
  the main session gate — the **admin panel has its own bcrypt password** (`ROSTERS_PASSWORD_HASH` → 2h in‑memory
  bearer token in `sessionStorage`, so it's reachable from a device not signed into the main site), and the
  **public student signup** (page `/rosters/signup/<slug>` + API `/rosters/api/signup/<slug>`) is slug‑scoped
  (bad slug → **generic 404**, no valid/invalid leak; public POST forces scores empty + can only create a
  *pending* student). _(Was initially built main‑login‑gated like case‑tracker, then switched **same day** to the
  own‑password model at the operator's request so the panel works off the main login — the backend gained a
  bcrypt gate + bearer sessions, the admin HTML gained a password screen, the nginx blocks dropped `auth_request`
  and added a dedicated ungated `/rosters/` block, and `ROSTERS_PASSWORD_HASH` was added to the vault.)_ Admin:
  create classes (random slug + signup URL), Pending‑vs‑Approved review, edit/approve/reject/manual‑add, per‑class
  copy‑link, and per‑approved‑student **paste‑chain** copy helpers (tab‑separated clipboard for Enrollware Student
  Scores + AHA eCards "Assign to Students"; paste‑and‑tab is the ceiling — same‑origin policy forbids filling
  those third‑party forms directly, **no** scripting of enrollware.com/heart.org). Course→AHA‑name map + both
  paste‑chain field orders are **editable server‑side config** (admin Settings → `PUT /api/config`), correctable
  with no code push. Required an **nginx edit** (four ungated blocks before the catch‑all: `/rosters/api/signup/`,
  `/rosters/api/`, `/rosters/signup/`, `/rosters/`) + a systemd unit + a new vault secret + a `rebuild-symlinks.sh`
  per‑file block + an admin‑only nav.json tile + an explicit `.gitignore` rule for `data/` (student PII). Backend
  smoke‑tested locally + live (public + admin surfaces, gate, rate limit, config sanitization, backups). (See §3,
  §4, §9, §10, §11, §14.)
- **2026-06-30 (later, same day) — Rosters post‑launch UI iterations** (operator‑requested, verified live via
  Playwright through Cloudflare): (a) **stripped every business name from public‑facing pages** — the signup page's
  static branding is now generic ("American Heart Association courses" / "powered by adampowell.pro"), the per‑class
  **location is no longer shown to students** (signup shows only course + date), and `GET /api/signup/:slug` no
  longer returns `location`; (b) **public signup now requires phone AND a home/business address** (both previously
  absent/optional) — new per‑student `address` field (stored, shown in the roster, editable; optional on the admin
  manual‑add); (c) **CCF% is always in the Enrollware paste‑chain** (even blank, so the tab count stays consistent),
  and `address` is a valid‑but‑off‑by‑default AHA paste‑chain key (add it in Settings once heart.org's field position
  is known); (d) the **admin login screen is now wordless** — just a blank password box (submit on Enter, red‑shake
  on a wrong password), header/footer hidden until unlock. Frontend‑only except (a)/(b) which touched `server.js`
  (redeploy + service restart). No port/nginx/secret change.
- **2026-06-30 (same day, later) — NPA two‑party signing rework + Proton mail relay.** (1) **Mail:** the
  droplet's outbound mail was spam‑foldered (sent direct from the droplet IP, failing the domain's Proton‑only
  SPF/DKIM/DMARC; Gmail even hard‑rejected it for missing PTR). Fixed by relaying postfix through **Proton SMTP**
  (`smtp.protonmail.ch:587`, SASL+TLS, `sender_canonical` → `noreply@adampowell.pro`, a real Proton address
  created on the domain; SMTP token from Proton Unlimited). Verified inbox delivery, SPF/DKIM pass. (2)
  **Signing:** reworked to a **two‑party model** — Adam pre‑signs (signature + initials) at creation; the
  recruiter counter‑signs (signature + initials); signatures/initials now render **on the actual §10 lines** and
  every page footer carries **both** parties' initials (no more separate signature page). The certificate gained
  a full **device/browser evidence** block (parsed User‑Agent + client hints). Confirmed the completed agreement
  emails **only to Adam, never the recruiter**. Fixed a sigpad.js relative‑path bug (must be absolute `/npa/...`
  to load on the `/npa/sign/<token>` path). Full two‑party flow verified live via Playwright through Cloudflare.
- **2026-06-30 — NPA Signing (`/npa/`) added.** New full‑stack app: self‑hosted CRNA Non‑Presentation &
  Non‑Disclosure Agreement e‑signing, no third‑party signing API / no recurring cost. Node backend
  `npa.service` (port **3016**, localhost‑only, `apps/server/npa/`) + a static frontend (`apps/npa/`:
  `index.html` create form, `sign.html` recruiter flow). **Two structurally‑separate auth mechanisms** — Adam's
  side is a bcrypt password gate (`NPA_PASSWORD_HASH`, 2h in‑memory session, NOT the main login); the recruiter
  side is a single‑use 7‑day `crypto.randomBytes(32)` token (stored hashed, consumed on first sign). Signing
  captures **IP + server‑side timestamp** (never client time), renders the signature + audit footer onto the PDF
  with **pdf‑lib** (pure JS, no Chromium), SHA‑256‑hashes the result, builds a **certificate of completion that
  explicitly states its own limitation** (sender's own server attesting, not an independent third party — see
  `apps/server/npa/README.md`), saves both PDFs, invalidates the token, and emails Adam both via the local
  postfix MTA. _(Same‑day rework superseded the single‑signer flow — see the two‑party entry above.)_ Storage is a single atomic‑write JSON file (no DB); `data/` + `records/` are gitignored signer
  PII. Required a real **nginx edit** (`/npa/api/`, `/npa/sign/`, `/npa/` before the catch‑all, all bypassing
  the main session gate) + a new secret + a systemd unit + a `rebuild-symlinks.sh` line + a nav.json tile.
  Finalized agreement text is verbatim in `template/agreement-body.js`; Exhibit A renders on a clean page break.
  Local PDF pipeline + HTTP flow validated before deploy, then **full E2E verified live** (Playwright‑driven
  recruiter flow through Cloudflare). Four robustness bugs were found+fixed during that E2E: a pdf‑lib PNG‑decoder
  hang (now guarded by `validatePng`), the signing POST blocking on the completion email (now fire‑and‑forget →
  fixed a Cloudflare 504), WinAnsi un‑encodable‑char 500s (now sanitized), and the audit IP capturing the
  Cloudflare edge instead of the signer (now `CF‑Connecting‑IP`). (See §3, §4, §9, §10, §11, §14.)
- **2026-06-26 — Preop Risk Eval (`/preop/`), v1.0 → v1.8.** Static, offline PWA preop risk + facility‑
  appropriateness report builder for a critical access hospital, with on‑device voice dictation. Over the arc:
  comorbidity catalog grown to **~115** searchable conditions with inline per‑condition detail fields; **~120‑
  surgery** and **~150‑med** type‑ahead pickers added (meds auto‑flag anticoagulant/antiplatelet/GLP‑1/insulin/
  beta‑blocker); parser scope widened to also pull procedure/anesthesia/urgency/disposition + a detected meds list
  (v1.7); report gained the forced one‑line **GO/NO‑GO/GO‑IF BOTTOM LINE** verdict (v1.4); **in‑browser Whisper
  dictation** + paste‑a‑narrative + per‑field mics, all feeding the same review‑then‑apply panel, with a **Tiny/
  Base/Small** model selector (default base.en) and the **WASM‑q4 → WebGPU‑fp32 → WASM‑fp32** backend chain that
  dropped the broken q8 WASM path (q8 QDQ decoder fails on ORT‑Web WASM: `TransposeDQWeightsForMatMulNBits`) (v1.5);
  PHI‑redacted debug‑log copy, reset‑speech‑model + force‑update controls, and an in‑app version history added;
  **v1.8** removed the on‑screen privacy banner (private single‑user app) and broadened anesthesia detection to
  match "anesthetic". SW bumped to `preop-v8`. Deploy is symlink + nav.json tile only — no backend/port/nginx
  change; model weights cache in the browser, never in the repo. Hardened across multi‑agent adversarial reviews.
- **2026-06-27 — Preop Risk Eval, v1.8 → v1.9** (SW `preop-v9`). Full audit‑driven hardening (8‑lens adversarial
  review → 30 verified findings, security dimension clean). Headline: the narrative/dictation parser was rewritten
  to be **clause‑scoped with negation + family‑history handling** — "denies stroke", "no history of diabetes",
  "father had an MI", "non‑smoker" no longer inject those conditions (was a confirmed false‑positive on every such
  phrasing); "rule out PE" / "possible CAD" surface in a separate **"confirm before applying"** list instead of
  auto‑adding; a *family history of* malignant hyperthermia is kept but labeled. EF ranges/qualifiers + multiple
  anticoagulants now parse; procedure capture tightened; the acute‑PE catalog entry got a real trigger (was
  dead). **BMI flags likely unit mix‑ups** instead of reporting a bogus value. a11y: arrow‑key radiogroup nav +
  correct SR state on all single‑choice groups, real recording state on every mic, ≥44 px mic targets, debounced
  BMI announce, stronger modal focus trap. **SW: keeps the Whisper model cache across version bumps** (was wiped
  every deploy), update‑prompt toast, mandatory offline shell. Privacy: discloses the copy→OpenEvidence egress;
  debug "include text" switch blocked while real data is loaded. **Architecture decision: stays 100 % on‑device**
  — cloud‑LLM (Haiku) parsing was considered for accuracy but rejected (no BAA; PHI must not leave the device).
  Follow‑up tracked: self‑host the transformers.js bundle + weights (currently a public CDN at runtime, no SRI).
- **2026-06-27 — Preop Risk Eval, v1.9 → v2.0** (SW `preop-v10`). Major OpenEvidence report‑template upgrade
  from operator research. The report now opens with an **INSTRUCTIONS block that pins the named current
  guidelines** (2024 AHA/ACC perioperative; Nasr 2023 pediatric‑CHD AHA statement w/ minor/major/severe
  stratification; Dec‑2024 multisociety GLP‑1 consensus) and **auto‑routes by population** — age <18 emits a
  pediatric NOTE and suppresses RCRI/MICA. It now emits the **full structured scaffold every time and marks
  blank fields `[not assessed]`/`[not specified]`** (distinguishes assessed‑absent from no‑data) so OpenEvidence
  flags every gap. ~15 new input sections added: patient snapshot (surgical approach, duration, dependence, code
  status), anesthetic/surgical history (MH personal/family, PONV, prior records), airway·sleep·aspiration
  (Mallampati, neck, STOP‑BANG 8‑factor, AHI/CPAP/aspiration), structured cardiac workup + key labs, high‑impact
  med detail (anticoag indication+last dose, antiplatelet+stent timing, GLP‑1 last dose+GI sx, SGLT2i), and
  facility staffing/transfer (staffing model, peds/congenital‑cardiac/after‑hours, blood bank, PICU, transfer
  pathway). Expanded to the 6‑part question set (ASA‑first, MINS troponin surveillance, consult‑as‑a‑question,
  facility‑capability mapping). Verified adult + pediatric renders via jsdom. Stays 100 % on‑device; symlink +
  nav.json only — no backend/port/nginx change.
- **2026-07-02 — Preop Risk Eval, v2.0 → v2.1** (SW `preop-v11`). Usability + privacy + polish release from a
  multi‑agent audit (24 confirmed findings; the review's verify/design phases were cut short by a session‑token
  cap, so findings were verified by hand‑tracing against the source + a jsdom/Playwright harness). **Privacy
  hardening:** added a browser **Content‑Security‑Policy** meta so the "no patient data leaves the device"
  promise is browser‑enforced (patient data → `'self'` only; sole outbound = the on‑device speech download from
  jsDelivr + HF weight hosts; `wasm-unsafe-eval` + `worker-src blob:` for ONNX‑Runtime); `spellcheck="false"` on
  every clinical field (blocks Chrome Enhanced‑Spell‑Check exfiltration); an **app‑switcher privacy veil** on
  background; the "redacted" debug log no longer emits BMI/sex/diagnosis‑list (set/count only), clears its event
  log on Reset, and re‑verifies the include‑text guard at copy time (was check‑then‑fill bypassable); model‑reset
  now deletes only `transformers-cache` (was a loose regex over the shared origin). **Safety/UX:** report is
  marked **out‑of‑date** after any edit and Copy auto‑regenerates (was silently copying stale text); dictation
  **Apply now highlights + scrolls to** the fields it filled; the header **↻ update** and SW **update‑toast**
  confirm before wiping a half‑entered patient (and the update toast is finally tappable — the base `.toast` had
  `pointer-events:none`); errors use a **red** toast (was the green success pill — the operator is
  hearing‑impaired, so color is the only channel). **Navigation/polish:** sticky **section jump‑strip**
  (scroll‑spy + per‑section entry counts), Selected‑problems moved above the chip groups, comorbidity search
  Enter‑to‑add + clear button, collapsed the once‑a‑month speech/diagnostics chrome, **light‑mode**
  (`prefers-color-scheme`) + reduced‑motion support, larger labels/touch targets, 16px inputs (no iOS zoom).
  Report template + on‑device model unchanged. jsdom 47/47 + Playwright 16/16 (dark+light) verified. Symlink +
  nav.json only — no backend/port/nginx change.
- **2026-06-21** (deploy) — Customizable‑dashboard feature deployed. **Surfaced a latent
  `better-sqlite3` ABI crash in `auth.service`:** the auth process had survived the Node 18→22 upgrade
  without re‑loading its native module, so its first restart (to pick up the new `/api/layout` endpoints)
  crash‑looped with `NODE_MODULE_VERSION 108 vs 127`. Fixed with `npm rebuild better-sqlite3` in
  `apps/server/auth` (no tree change — `node_modules` gitignored) + `reset-failed` + restart. auth.service
  now ✅ active; `dashboard_layouts` table created; `/auth/api/layout` returns 401 unauthed (gated, live).
  See §18 troubleshooting for the general rule (other pre‑upgrade `better-sqlite3` apps carry the same latent crash).
- **2026-06-21** — Customizable landing dashboard. `html/index.html` gained an **edit mode** (drag‑reorder
  tiles + categories, hide/show, rename tiles & groups, add custom groups) saved **per user**. Storage is a
  JSON *overlay* (order/hidden/renames/custom‑groups only — never hrefs) in a new `dashboard_layouts` table
  in the auth DB, served by new auth endpoints **`GET`/`PUT`/`DELETE /api/layout`** (reachable at
  `/auth/api/layout`; auth‑gated, keyed on `req.session.userId`, 64 KB cap, shape‑validated). `nav.json`
  stays the source of truth for the tile catalog (each item now carries a stable `id`, each category a `key`).
  **No nginx change** (the existing `/auth/api/` proxy covers it); deploys via the normal webhook.
- **2026-06-08** — Med Calc (`/medcalc/`) added: standalone static PWA for anesthesia dosing/drips/mixes/MAC,
  logic ported from KVH PULSE's MedCalc panel, iPhone 16 Pro Max‑tuned. Pure static — no backend/port/nginx
  change (whole‑dir symlink in `rebuild-symlinks.sh` + nav.json tile only).
- **2026-06-08** (later) — Merged the Infusion Calculator (`/infus/`) **into** Med Calc as one superset app
  (Bolus + Infusions + Reference). `/infus/` is now a self‑retiring redirect stub → `/medcalc/`; its nav tile
  was removed. LBW unified to Janmahasatian. No nginx/port change.
- **2026-06-06** — Full audit + docs rewrite (this version): corrected hardware/software (Node 22, disk 88%,
  certs → Jul 2026), live service status (many backends inactive), documented vsftpd/postfix, active
  git‑auto‑pull timers, command‑center snapshot cron, secrets‑vault key inventory, and the security‑debt list.
- **2026-05-18→19** — Command Center (Go) deployed at `/command-center/` (later split‑auth for iOS PWA);
  EKG Tutor 4‑layer stack deployed (3110/3111/3112); Node upgraded 18 → 22.
- **2026-05-03→08** — Flux RSVP reader (`/rsvp/`) and Nav (`/nav/`) added (pure static, zero nginx changes).
- **2026-04-18→19** — RandEmail, Remote, Pet Meals, Shared Todo added; per‑app HTTP basic‑auth second
  factor removed (single main‑login gate).
- **2026-04-11→15** — ANSReset, Todos, Infusion calculator added.
- **2026-04-10** — Big consolidation: site‑wide `auth_request` gate; subdomains collapsed to path redirects;
  dynamic gated `nav.json` landing; Tier 3 repo restructure (`apps/` + `apps/server/`); Tier 4 backend moves
  into the repo; maps + aiomail retired; 2 GB swap added; auth MemoryStore → SQLiteStore; secrets vault
  (`/root/secrets.env`) created and source secrets stripped; DoH abuse sink.

---

**Maintained by:** Adam Powell · **Contact:** adam@adamantanesthesia.com · **Server:** root@198.211.114.12
**Keep this current** — see the maintenance rule in [`WHEN CREATING A NEW APP.md`](WHEN%20CREATING%20A%20NEW%20APP.md) §11/§12.
