**Note on `unpdf`:** The v1.1 research recommended `unpdf` as a safer serverless wrapper around PDF.js, but the implemented code uses `pdfjs-dist/legacy/build/pdf.mjs` directly with `GlobalWorkerOptions.workerSrc` pointing to the local worker file. `unpdf` is NOT in `package.json` and was not installed. Do not add it — the existing `pdfjs-dist` integration is working.
| `openai` | ^6.32.0 | OpenAI API client for GPT calls | Official SDK, current latest, TypeScript-native. Provides `client.chat.completions.create()` for structured JSON output via manual `json_schema` response format. Required for AI field placement and pre-fill. |
**No other new core dependencies are needed.** The remaining v1.1 features extend capabilities already in `@cantoo/pdf-lib`, `signature_pad`, and `react-pdf`.
**Why `gpt-4.1` (not `gpt-4o`):** The implemented code uses `gpt-4.1` which was released after the v1.1 research was written. Use whatever model is set in the existing `field-placement.ts` implementation.
**Why manual JSON schema (not `zodResponseFormat`):** The project uses `zod` v4.3.6. The `zodResponseFormat` helper in `openai/helpers/zod` uses vendored `zod-to-json-schema` that still expects `ZodFirstPartyTypeKind` — removed in Zod v4. This is a confirmed open bug as of late 2025. Using `zodResponseFormat` with Zod v4 throws runtime exceptions. Use `response_format: { type: "json_schema", json_schema: { name: "...", strict: true, schema: { ... } } }` directly with plain TypeScript types instead.
**Coordinate system note:** `@cantoo/pdf-lib` uses PDF coordinate space where y=0 is the bottom of the page. If field positions come from `pdfjs-dist` (which uses y=0 at top), you must transform: `pdfY = pageHeight - sourceY - fieldHeight`.
**Do NOT add `react-signature-canvas`.** It wraps `signature_pad` at v1.1.0-alpha.2 (alpha status) and the project already has `signature_pad` directly. Use the raw library with a `useRef`.
1. Server Action: load original PDF from Vercel Blob, apply all field values (text, checkboxes, embedded signature image) using `@cantoo/pdf-lib`, return `pdfDoc.save()` bytes
2. API route returns the bytes as `application/pdf`; client receives as `ArrayBuffer`
3. Pass `ArrayBuffer` directly to `react-pdf`'s `<Document file={arrayBuffer}>` — no upload required
**react-pdf renders the flattened PDF accurately** — all filled text fields, checked checkboxes, and embedded signature images will appear correctly because they are baked into the PDF bytes by `@cantoo/pdf-lib` before rendering.
| `pdfjs-dist/legacy/build/pdf.mjs` directly | `unpdf` wrapper | `unpdf` was recommended in research but not actually installed; the legacy build path works correctly with Node 20 LTS. |
| Manual JSON schema for OpenAI | `zodResponseFormat` helper | Broken with Zod v4 — open bug in `openai-node` as of Nov 2025. Manual schema avoids the dependency entirely. |
| `gpt-4.1` | `gpt-4o` | Real estate form field extraction is a structured extraction task on templated documents. Upgrade only if accuracy on unusual forms is unacceptable. |
| `page.drawImage()` for agent signature | `PDFSignature` AcroForm field | `@cantoo/pdf-lib` has no `createSignature()` API — `PDFSignature` only reads existing signature fields and provides no image embedding. The correct approach is `embedPng()` + `drawImage()` at the field coordinates. |
| `zodResponseFormat` from `openai/helpers/zod` | Broken at runtime with Zod v4.x (throws exceptions). Open bug, no fix merged as of 2026-03-21. | Plain `response_format: { type: "json_schema", ... }` with hand-written schema |
| `react-signature-canvas` | Alpha version (1.1.0-alpha.2); project already has `signature_pad` v5 directly — the wrapper adds nothing | `signature_pad` + `useRef<HTMLCanvasElement>` directly |
| `@signpdf/placeholder-pdf-lib` | For cryptographic PKCS#7 digital signatures (DocuSign-style). This project needs visual e-signatures (image embedded in PDF), not cryptographic signing. | `@cantoo/pdf-lib``embedPng()` + `drawImage()` |
| `pdf2json` | Extracts spatial text data; useful for arbitrary document analysis. Overkill here — we only need raw text content to feed OpenAI. | `pdfjs-dist` legacy build |
| `unpdf` | Was in the v1.1 research recommendation but not installed. The existing `pdfjs-dist/legacy/build/pdf.mjs` usage works correctly in Node 20 — do not add `unpdf` retroactively. | `pdfjs-dist` legacy build (already in use) |
| `langchain` / Vercel AI SDK | Heavy abstractions for the simple use case of one structured extraction call per document. Adds bundle size and abstraction layers with no benefit here. | `openai` SDK directly |
| A separate image processing library (`sharp`, `jimp`) | Not needed — signature PNGs from `signature_pad.toDataURL()` are already correctly sized canvas exports. `@cantoo/pdf-lib` handles embedding without pre-processing. | N/A |
| `openai@6.32.0` | `zod@4.x` (manual schema only) | Do NOT use `zodResponseFormat` helper — use raw `json_schema` response_format. The helper is broken with Zod v4. |
| `@cantoo/pdf-lib@2.6.3` | `react-pdf@10.4.1` | These do not interact at runtime — `@cantoo/pdf-lib` runs server-side, `react-pdf` runs client-side. No conflict. |
| `signature_pad@5.1.3` | React 19 | Use as a plain class instantiated in `useEffect` with a `useRef<HTMLCanvasElement>`. No React wrapper needed. |
2.**New field on `SignatureFieldData` interface** — `signerEmail?: string`. Fields without
`signerEmail` are agent-only fields (already handled). Fields with `signerEmail` route to
that signer's session.
3.**Extend `documentStatusEnum`** — add `'PartialSigned'` (some but not all signers complete).
`'Signed'` continues to mean all signers have completed.
4.**Extend `auditEventTypeEnum`** — add `'all_signers_complete'` for the completion notification
trigger.
5.**Extend `signingTokens` table** — add `signerEmail text NOT NULL` column so each token is
scoped to one signer and the signing page can filter fields correctly.
#### Docker: No New Application Packages
The Docker setup is infrastructure only (Dockerfile + docker-compose.yml). No npm packages are
added to the application.
**One optional dev-only Docker image for local email testing:**
| Tool | What It Is | When to Use |
|---|---|---|
| `maildev/maildev:latest` | Lightweight SMTP trap that catches all outbound mail and shows it in a web UI | Add to a `docker-compose.override.yml` for local development only. Never deploy to production. |
---
### Docker Stack
#### Image Versions
| Service | Image | Rationale |
|---|---|---|
| Next.js app | `node:20-alpine` | LTS, small. Do NOT use node:24 — @napi-rs/canvas ships prebuilt `.node` binaries and the build for node:24-alpine may not exist yet. Verify before upgrading. |
| PostgreSQL | `postgres:16-alpine` | Current stable, alpine keeps it small. Pin to `16-alpine` explicitly — never use `postgres:latest` which silently upgrades major versions on `docker pull`. |
#### Dockerfile Pattern — Three-Stage Standalone
Next.js `output: 'standalone'` in `next.config.ts` must be enabled. This generates
`.next/standalone/` with a minimal self-contained Node.js server. Reduces image from ~7 GB
(naive) to ~300 MB (verified across multiple production reports).
**Stage 1 — deps:** `npm ci --omit=dev` with cache mounts. This layer is cached until
`package-lock.json` changes, making subsequent builds fast.
**Stage 2 — builder:** Copy deps from Stage 1, copy source, run `next build`. Set
`NEXT_TELEMETRY_DISABLED=1` and `NODE_ENV=production`.
**Stage 3 — runner:** Copy `.next/standalone/`, `.next/static/`, and `public/` from builder
only. Set `HOSTNAME=0.0.0.0` and `PORT=3000`. Run as non-root user. The `uploads/`
directory must be a named Docker volume — never baked into the image.
**@napi-rs/canvas native binding note:** This package includes a compiled `.node` binary for a
specific OS and CPU architecture. The build stage and runner stage must use the same OS
(both `node:20-alpine`) and the Docker build must run on the same CPU architecture as the
deployment server (arm64 for Apple Silicon servers, amd64 for x86). Use
`docker buildx --platform linux/arm64` or `linux/amd64` explicitly if building cross-platform.
Cross-architecture builds will produce a binary that silently fails at runtime.
**Database migrations on startup:** Add an `entrypoint.sh` that runs
`npx drizzle-kit migrate && exec node server.js`. This ensures schema migrations run before
the application accepts traffic on every container start.
#### Docker Compose Structure
```yaml
services:
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file: .env.production # SMTP creds, AUTH_SECRET, OPENAI_API_KEY etc.
3. All variables in that file are injected as `process.env.*` inside the container.
nodemailer reads `process.env.CONTACT_EMAIL_PASS` exactly as in development. Zero code changes.
**Why not Docker Swarm secrets for SMTP?** Plain Compose secrets have no encryption — they are
just bind-mounted plaintext files. The security profile is identical to a chmod 600
`.env.production` file. The complexity cost (code that reads from `/run/secrets/`) is not
justified on a single-server home deployment. Use Docker secrets for PostgreSQL password only
because PostgreSQL natively reads from `POSTGRES_PASSWORD_FILE` — no code change required.
#### DNS Fix for EAI_AGAIN
If SMTP resolves correctly in dev but fails in Docker, add to the app service:
```yaml
services:
app:
dns:
- 8.8.8.8
- 1.1.1.1
environment:
NODE_OPTIONS: --dns-result-order=ipv4first
```
The `dns:` keys bypass Docker's internal resolver for external lookups. `--dns-result-order=ipv4first`
tells Node.js to try IPv4 DNS results before IPv6, which resolves the most common Docker DNS
timeout pattern (IPv6 path unreachable, long timeout before IPv4 fallback).
#### SMTP Provider
Gmail with an App Password (not the account password) is the recommended choice for a solo
agent at low volume. Requires 2FA enabled on the Google account. The signing mailer already
uses port logic: port 465 → `secure: true`; any other port → `secure: false`. Port 587 with
STARTTLS is more reliable than port 465 implicit TLS in Docker environments — use
`CONTACT_SMTP_PORT=587`.
---
### What NOT to Add (v1.2)
| Temptation | Why to Avoid |
|---|---|
| Redis / BullMQ for email queuing | Overkill. This app sends at most 5 emails per document. `Promise.all([sendMail(...)])` is sufficient. Redis adds a third container, more ops burden, and more failure modes. |
| Resend / SendGrid / Postmark | Adds a paid external dependency. nodemailer + Gmail App Password is free, already implemented, and reliable when env vars are correctly passed. Switch only if Gmail SMTP becomes a persistent problem. |
| Docker Swarm secrets for SMTP | Requires code changes to read from file paths. No security benefit over a permission-restricted `env_file` on single-server non-Swarm setup. |
| `postgres:latest` image | Will silently upgrade major versions on `docker pull`. Always pin to `postgres:16-alpine`. |
| Node.js 22 or 24 as base image | @napi-rs/canvas ships prebuilt `.node` binaries. Verify the binding exists for the target node/alpine version before upgrading. Node 20 LTS is verified. |
| Sequential signing enforcement | The PROJECT.md specifies parallel signing only ("any order"). Do not add sequencing logic. |
| WebSockets for real-time signing status | Polling the agent dashboard every 30 seconds is sufficient for one agent monitoring a handful of documents. No WebSocket infrastructure needed. |
| Separate migration container | A `depends_on: condition: service_completed_successfully` init container is architecturally cleaner but adds complexity. An `entrypoint.sh` in the same `app` container is simpler and sufficient at this scale. |
| HelloSign / DocuSign integration | Explicitly out of scope per PROJECT.md. Custom e-signature is the intentional choice. |
| `unpdf` | Already documented in v1.1 "What NOT to Add" — the existing `pdfjs-dist` legacy build is in use and working. |
---
### OpenSign Architecture Reference
OpenSign (React + Node.js + MongoDB) implements multi-recipient signing as a signers array
embedded in each document record. Each signer object holds its own status, token reference, and
completed-at timestamp. All signing links are sent simultaneously (parallel) by default. Their
MongoDB document array maps directly to a PostgreSQL `document_signers` junction table in
relational terms. The core insight confirmed by OpenSign's design: multi-signer needs no
specialized packages — it is a data model and routing concern.
---
### Sources (v1.2)
- Code audit: `src/lib/signing/signing-mailer.tsx` — env var names `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, `CONTACT_EMAIL_PASS`, `AGENT_EMAIL` confirmed — HIGH confidence
- Code audit: `src/lib/db/schema.ts` — current `SignatureFieldData`, `signingTokens`, `documentStatusEnum`, `auditEventTypeEnum` confirmed — HIGH confidence
- Code audit: `package.json` — nodemailer v7.0.13, @react-email installed, unpdf NOT installed — HIGH confidence
- [Docker Official Next.js Containerize Guide](https://docs.docker.com/guides/nextjs/containerize/) — HIGH confidence
- [Docker Compose Secrets — Official Docs](https://docs.docker.com/compose/how-tos/use-secrets/) — HIGH confidence
- [Docker Compose Secrets: What Works, What Doesn't](https://www.bitdoze.com/docker-compose-secrets/) — MEDIUM confidence
- [Docker Compose Secrets: Export /run/secrets to Env Vars (Dec 2025)](https://phoenixtrap.com/2025/12/22/10-lines-to-better-docker-compose-secrets/) — MEDIUM confidence
- [Nodemailer Docker EAI_AGAIN — Docker Forums](https://forums.docker.com/t/not-able-to-send-email-using-nodemailer-within-docker-container-due-to-eai-again/40649) — HIGH confidence (root cause confirmed)
- [Nodemailer works local, fails in Docker — GitHub Issue #1495](https://github.com/nodemailer/nodemailer/issues/1495) — HIGH confidence (issue confirmed unresolved = env problem, not library)
- [OpenSign GitHub Repository](https://github.com/OpenSignLabs/OpenSign) — HIGH confidence
- [Next.js Standalone Docker Mode — DEV Community (2025)](https://dev.to/angojay/optimizing-nextjs-docker-images-with-standalone-mode-2nnh) — MEDIUM confidence
- [DNS EAI_AGAIN in Docker — Beyond 'It Works on My Machine'](https://dev.to/ameer-pk/beyond-it-works-on-my-machine-solving-docker-networking-dns-bottlenecks-4f3m) — MEDIUM confidence
*Last updated: 2026-04-03 — v1.2 multi-signer and Docker production additions; corrected unpdf status, env var names, and added missing packages to existing stack table*