# NPA — self-hosted CRNA Non-Presentation & Non-Disclosure Agreement e-signing

A fully self-hosted e-signing flow at `adampowell.pro/npa` for the CRNA Non-Presentation &
Non-Disclosure Agreement (travel/locum assignment template). No third-party signing API, no
recurring cost — everything runs on the existing droplet (port **3016**, localhost-only, proxied
by nginx).

## What this audit trail is and isn't

This app logs IP address, timestamp, and a SHA-256 hash of the signed document at the moment
of signing, and generates a certificate of completion. This is meaningfully better than no
audit trail, but it is NOT the same legal weight as a third-party signing platform (DocuSign,
HelloSign, etc.) — there, an independent company's servers attest to the signing event. Here,
this server (controlled by the sender) is attesting to its own claim. If an agreement is ever
genuinely contested, that distinction matters. This tool is appropriate for routine business
use where both parties are acting in good faith; it is not a substitute for independent
notarization or a paid signing platform if a dispute is likely.

## How it works — two-party signing

1. **Adam** opens `/npa/`, enters the password (its own gate, not the main site login), fills in the
   recruiter/agency, the agreement date, the Exhibit A facility rows, and **pre-signs**: he draws or
   types his Provider signature + initials (optionally remembered on-device). His signature lands on
   the §10 Provider line, his initials on the Exhibit A "Provider Initials" column + acknowledgment and
   the Provider half of every page footer.
2. The backend builds the **Provider-pre-signed** PDF with `pdf-lib`, mints a single-use signing token,
   stores the record (provider signature images in a `records/<id>/provider-sig.json` sidecar), and
   emails the recruiter a one-time link `https://adampowell.pro/npa/sign/<token>`.
3. The **recruiter** opens the link and goes through: **consent** (always before the document) →
   **document review** (must scroll the already-Provider-signed PDF) → **signature + initials** (draw or
   type) → submit. Both parties' signatures/initials render **on the actual lines**; both initial every page.
4. On submit the backend captures the IP (`CF-Connecting-IP`), the **server-side** timestamp (never a
   client-supplied one), and **device/browser evidence** (User-Agent → browser/OS/device + client hints).
   It rebuilds the fully-signed PDF (both parties on the lines), computes a SHA-256, builds a certificate
   of completion **with a Device & network evidence block**, saves both PDFs, invalidates the token
   (single-use), and emails **ONLY Adam** both PDFs. **The completed agreement is never emailed to the
   recruiter** — they only ever see it in-browser during signing.

## Auth — two structurally separate mechanisms

- **Adam's side** (`/npa/api/auth`, `/npa/api/agreements`, status): a bcrypt password
  (`NPA_PASSWORD_HASH` in `/root/secrets.env`) issuing an in-memory bearer **session token**,
  2-hour expiry. The frontend stores it in `sessionStorage` (never a cookie), so it cannot be
  confused with the main site `apsess` cookie or the recruiter token.
- **Recruiter's side** (`/npa/api/sign/:token`): a `crypto.randomBytes(32)` hex **token**, sent by
  email, **stored hashed at rest** (SHA-256), single-use, expiring after 7 days or on first signing.
  No password, no session — knowing the token IS the authorization (same trust model as an
  email-verification link).

These share no code path and no storage by design.

## Email deliverability — RESOLVED (postfix relays through Proton SMTP)

The app sends via the droplet's local postfix (`localhost:25`). Sending **direct from the droplet IP**
landed mail in spam, because `adampowell.pro`'s SPF (`include:_spf.protonmail.ch`), DKIM
(`protonmail._domainkey`), and DMARC (`p=quarantine`) authorize **Proton only**.

**Fixed 2026-06-30:** postfix now relays through **Proton SMTP** (`smtp.protonmail.ch:587`, SASL + TLS), so
mail leaves Proton's servers and passes the existing SPF + Proton DKIM. A `sender_canonical` rule rewrites
every envelope sender to `noreply@adampowell.pro` (a real Proton address on the domain; the SMTP token is
bound to it). Verified live: `relay=smtp.protonmail.ch ... status=sent`.

To re-run or rotate the token (Proton dashboard → IMAP/SMTP → regenerate):
`PROTON_USER='noreply@adampowell.pro' PROTON_TOKEN='<token>' bash /root/setup-proton-relay.sh`
(backs up `main.cf`, writes `/etc/postfix/sasl_passwd` mode 600, reloads postfix). Applies to ALL droplet
outbound mail (deploy webhook + nda + npa), not just npa. SMTP submission is available on Proton **Unlimited**,
not only Business. The From address (`NPA_MAIL_FROM`, default `noreply@adampowell.pro`) **must** stay an
address Proton hosts, or Proton rejects the submission.

## The agreement text

The finalized contract text lives in `template/agreement-body.js` — that is the **single source of
truth** and the actual approved wording (ported verbatim from `CRNA_Travel_Assignment_NPA_NDA_Template.docx`).
To change the contract terms, edit only that file; `template/pdf-template.js` lays the text out
without interpreting it. The Exhibit A 90-day default is expressed in two places that must stay
consistent — the computed "Authorization Expires" cell and the `EXHIBIT_A.expiryFootnote` text —
plus `DEFAULT_AUTH_DAYS` in `server.js`.

## Storage (gitignored — signer PII)

- `data/agreements.json` — JSON store (atomic writes + rolling backups in `data/backups/`). Tokens
  are stored hashed; on completion it holds the signer's IP, timestamp, and document hash.
- `records/<id>/` — `agreement-unsigned.pdf`, `agreement-signed-<id>.pdf`, `certificate-<id>.pdf`.

**Both `data/` and `records/` are gitignored and must NEVER be committed** (signer PII —
signatures, IPs, signed PDFs). See the repo `.gitignore`.

## Gotchas

- **pdf-lib PNG decoder can hang on a malformed PNG.** pdf-lib 1.17.1 embeds PNGs with a pure-JS
  decoder that busy-loops (pegging CPU, blocking the whole event loop) on a corrupt/truncated PNG.
  A normal browser `canvas.toDataURL('image/png')` is well-formed and embeds fine, but signature
  bytes arrive over the network and aren't trusted — `template/pdf-template.js` validates the PNG
  structurally (signature, IHDR, non-interlaced, and that IDAT inflates to exactly the expected raw
  size) **before** calling `embedPng`. A PNG that fails validation is skipped (a placeholder is
  rendered) rather than risking the hang. Don't remove that guard.
- **Don't block the recruiter's signing response on email.** The completion email to Adam is sent
  fire-and-forget AFTER the response — a slow SMTP relay (ProtonMail/Gmail can take seconds) must
  never hold the request open past the Cloudflare proxy timeout (which would 504 the recruiter even
  though signing succeeded). The transport also has hard connection/socket timeouts.
- **WinAnsi only.** Helvetica encodes CP-1252; all drawn text goes through a `winAnsi()` sanitizer so
  a pasted emoji/CJK char can't throw and 500 the build. Em-dashes and curly quotes are kept.

## Run / deploy

- Service: systemd `npa.service` → `node server.js`, port 3016, bound to `127.0.0.1`.
- Dependencies: `express`, `bcrypt`, `nodemailer`, `pdf-lib` (all pure-JS or system-toolchain;
  no Chromium, no puppeteer/playwright — deliberately, for the 989 MB / 88%-full droplet).
- Email: nodemailer → the existing send-only postfix MTA on `localhost:25` (no SMTP auth needed).
- nginx: `/npa/api/` proxies to `:3016`; `/npa/` and `/npa/sign/` bypass the main session gate
  (Adam's side has its own password; the recruiter side is token-gated). `client_max_body_size 8M`
  on the API block for the base64 signature image.

## Environment variables (read from `/root/secrets.env` via the systemd unit)

| Var | Required | Default | Purpose |
|---|---|---|---|
| `NPA_PASSWORD_HASH` | **yes** | — | bcrypt hash of Adam's password (process refuses to start without it) |
| `PORT` | no | `3016` | listen port |
| `NPA_BASE_URL` | no | `https://adampowell.pro/npa` | used to build the signing link in emails |
| `NPA_NOTIFY_EMAIL` | no | `apowell-llc@pm.me` | completion-notice recipient |
| `NPA_MAIL_FROM` | no | `noreply@adampowell.pro` | From: header |
| `NPA_SMTP_HOST` / `NPA_SMTP_PORT` | no | `localhost` / `25` | local postfix |

## Generating the password hash

Never type the plaintext into a tracked file. Generate the bcrypt hash locally and put only the
hash into `/root/secrets.env`:

```bash
node -e "console.log(require('bcrypt').hashSync(process.argv[1], 10))" '<the actual password>'
```
