# Architecture: Multi-Signer Extension + Docker Deployment **Project:** teressa-copeland-homes **Milestone:** v1.2 — Multi-Signer Support + Deployment Hardening **Researched:** 2026-04-03 **Confidence:** HIGH — based on direct codebase inspection + official Docker/Next.js documentation --- ## Summary The existing system is a clean, well-factored single-signer flow. Every document has exactly one signing token, one recipient, and one atomic "mark used" operation. Multi-signer requires four categories of change: 1. **Schema:** Tag fields to signers, expand signingTokens to identify whose token it is, replace the single-recipient model with a per-signer recipients structure. 2. **Completion detection:** Replace the single `usedAt` → `status = 'Signed'` trigger with a "all tokens claimed" check after each signing submission. 3. **Final PDF assembly:** The per-signer merge model (each signer embeds into the same prepared PDF, sequentially via advisory lock) accumulates signatures into `signedFilePath`. A completion pass fires after the last signer claims their token. 4. **Migration:** Existing signed documents must remain intact — achieved by treating the absence of `signerEmail` on a field as "legacy single-signer" (same as the existing `type` coalescing pattern already used in `getFieldType`). Docker deployment has a distinct failure mode from local dev: **env vars that exist in `.env.local` are absent in the Docker container unless explicitly provided at runtime.** The email-sending failure in production Docker is caused by `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, and `CONTACT_EMAIL_PASS` never reaching the container. The fix is `env_file` injection at `docker compose up` time, not Docker Secrets (which mount as files, not env vars, and require app-side entrypoint shim code that adds no security benefit for a single-server deployment). --- ## Part 1: Multi-Signer Schema Changes ### 1. `SignatureFieldData` JSONB — add optional `signerEmail` **Current shape (from `src/lib/db/schema.ts`):** ```typescript interface SignatureFieldData { id: string; page: number; x: number; y: number; width: number; height: number; type?: SignatureFieldType; // optional — v1.0 had no type } ``` **New shape:** ```typescript interface SignatureFieldData { id: string; page: number; x: number; y: number; width: number; height: number; type?: SignatureFieldType; signerEmail?: string; // NEW — optional; absent = legacy single-signer or agent-owned field } ``` **Backward compatibility:** The `signerEmail` field is optional. Existing documents stored in `signature_fields` JSONB have no `signerEmail`. The signing page already filters fields via `isClientVisibleField()`. A new `getSignerEmail(field, fallbackEmail)` helper mirrors `getFieldType()` and returns `field.signerEmail ?? fallbackEmail` — where `fallbackEmail` is the document's legacy single-recipient email. This keeps existing signed documents working without a data backfill. **No SQL migration needed** for the JSONB column itself — it is already `jsonb`, schema-less at the DB level. --- ### 2. `signingTokens` table — add `signerEmail` column **Current:** ```sql CREATE TABLE signing_tokens ( jti text PRIMARY KEY, document_id text NOT NULL REFERENCES documents(id) ON DELETE CASCADE, created_at timestamp DEFAULT now() NOT NULL, expires_at timestamp NOT NULL, used_at timestamp ); ``` **New column to add:** ```sql ALTER TABLE signing_tokens ADD COLUMN signer_email text; ``` **Drizzle schema change:** ```typescript export const signingTokens = pgTable('signing_tokens', { jti: text('jti').primaryKey(), documentId: text('document_id').notNull() .references(() => documents.id, { onDelete: 'cascade' }), signerEmail: text('signer_email'), // NEW — null for legacy tokens createdAt: timestamp('created_at').defaultNow().notNull(), expiresAt: timestamp('expires_at').notNull(), usedAt: timestamp('used_at'), }); ``` **Why not store field IDs in the token?** Field filtering should happen server-side by matching `field.signerEmail === tokenRow.signerEmail`. Storing field IDs in the token creates a second source of truth and complicates migration. The signing GET endpoint already fetches the document's `signatureFields` and filters them — adding a `signerEmail` comparison is a one-line change. **Backward compatibility:** `signer_email` is nullable. Existing tokens have `null`. The signing endpoint uses `tokenRow.signerEmail` to filter fields; `null` falls back to `isClientVisibleField()` (current behavior). --- ### 3. `documents` table — add `signers` JSONB column **Current problem:** `assignedClientId` is a single-value text column. Signers in multi-signer are identified by email, not necessarily by a `clients` row (requirement: "signers may not be in clients table"). The current `emailAddresses` JSONB column holds email strings but lacks per-signer identity (name, signing status, token linkage). **Decision: add a `signers` JSONB column; leave `assignedClientId` in place for legacy** ```sql ALTER TABLE documents ADD COLUMN signers jsonb; ``` **New TypeScript type:** ```typescript export interface DocumentSigner { email: string; name?: string; // display name for email greeting, optional tokenJti?: string; // populated at send time — links token back to signer record signedAt?: string; // ISO timestamp — populated when their token is claimed } ``` **Drizzle:** ```typescript // In documents table: signers: jsonb('signers').$type(), ``` **Why JSONB array instead of a new table?** A `document_signers` join table would be cleanest long-term, but for a solo-agent app with document-level granularity and no need to query "all documents this email signed across the system", JSONB avoids an extra join on every document fetch. The `tokenJti` field on each signer record gives the bidirectional link without the join table. **Why keep `assignedClientId`?** It is still used by the send route to resolve the `clients` row for `client.email` and `client.name`. For multi-signer, the agent provides emails directly in the `signers` array. The two flows coexist: - Legacy: `assignedClientId` is set, `signers` is null → single-signer behavior - New: `signers` is set (non-null, non-empty) → multi-signer behavior The send route checks `if (doc.signers?.length) { /* multi-signer path */ } else { /* legacy path */ }`. **`emailAddresses` column:** Currently stores `[client.email, ...ccAddresses]`. In multi-signer this is superseded by `signers[].email`. The column can remain and be ignored for new documents, or populated with all signer emails for audit reading consistency. --- ### 4. `auditEvents` — new event types **Current enum values:** ``` document_prepared | email_sent | link_opened | document_viewed | signature_submitted | pdf_hash_computed ``` **New values to add:** | Event Type | When Fired | Metadata | |---|---|---| | `signer_email_sent` | Per-signer email sent (supplements `email_sent` for multi-signer) | `{ signerEmail, tokenJti }` | | `signer_signed` | Per-signer token claimed | `{ signerEmail }` | | `document_completed` | All signers have signed — triggers final notification | `{ signerCount, mergedFilePath }` | **Backward compatibility:** Postgres enums cannot have values removed, only added. The existing `email_sent` and `signature_submitted` events stay in the enum and continue to be fired for legacy single-signer documents. New multi-signer documents fire the new, more specific events. Adding values to a Postgres enum requires raw SQL that Drizzle cannot auto-generate: ```sql ALTER TYPE audit_event_type ADD VALUE 'signer_email_sent'; ALTER TYPE audit_event_type ADD VALUE 'signer_signed'; ALTER TYPE audit_event_type ADD VALUE 'document_completed'; ``` **Important:** In Postgres 12+, `ALTER TYPE ... ADD VALUE` can run inside a transaction. In Postgres < 12 it cannot. Write migration with `-- statement-breakpoint` between each ALTER to prevent Drizzle from wrapping them in a single transaction. --- ### 5. `documents` table — completion tracking columns unchanged Multi-signer "completion" is no longer a single event. The existing columns serve all needs: | Column | Current use | Multi-signer use | |---|---|---| | `status` | Draft → Sent → Viewed → Signed | "Signed" now means ALL signers complete. Status transitions to Signed only after `document_completed` fires. | | `signedAt` | Timestamp of the single signing | Timestamp of completion (last signer claimed) — same semantic, set later. | | `signedFilePath` | Path to the merged signed PDF | Accumulator path — updated by each signer as they embed; final value = completed PDF. | | `pdfHash` | SHA-256 of signed PDF | Same — hash of the final merged PDF. | Per-signer completion is tracked in `signers[].signedAt` (the JSONB array). No new columns required. --- ## Part 2: Multi-Signer Data Flow ### Field Tagging (Agent UI) ``` Agent places field on PDF canvas ↓ FieldPlacer shows signer email selector (from doc.signers[]) ↓ SignatureFieldData.signerEmail = "buyer@example.com" ↓ PUT /api/documents/[id]/fields persists to signatureFields JSONB ``` **Chicken-and-egg consideration:** The agent must know the signer list before tagging fields. Resolution: the PreparePanel collects signer emails first (a new multi-signer entry UI replaces the single email textarea). These are saved to `documents.signers` via `PUT /api/documents/[id]/signers`. The FieldPlacer palette then offers a signer email selector when placing a client-visible field. **Unassigned client fields:** If `signerEmail` is absent on a client-visible field in a multi-signer document, behavior must be defined. Recommended: block sending until all client-signature and initials fields have a `signerEmail`. The UI shows a warning. Text, checkbox, and date fields do not require a signer tag (they are embedded at prepare time and never shown to signers). ### Token Creation (Send) ``` Agent clicks "Prepare and Send" ↓ POST /api/documents/[id]/prepare - embeds agent signatures, text fills → preparedFilePath - reads doc.signers[] to confirm signer list exists ↓ POST /api/documents/[id]/send - if doc.signers?.length: multi-signer path Promise.all(doc.signers.map(signer => { createSigningToken(documentId, signer.email) → INSERT signing_tokens (jti, document_id, signer_email, expires_at) sendSigningRequestEmail({ to: signer.email, signingUrl: /sign/[token] }) logAuditEvent('signer_email_sent', { signerEmail: signer.email, tokenJti: jti }) })) update doc.signers[*].tokenJti set documents.status = 'Sent' - else: legacy single-signer path (unchanged) ``` ### Signing Page (Per Signer) ``` Signer opens /sign/[token] ↓ GET /api/sign/[token] - verifySigningToken(token) → { documentId, jti } - fetch tokenRow (jti) → tokenRow.signerEmail - fetch doc.signatureFields - if tokenRow.signerEmail: filter fields where field.signerEmail === tokenRow.signerEmail AND isClientVisibleField else: filter fields with isClientVisibleField (legacy path — unchanged) - return { status: 'pending', document: { ...doc, signatureFields: filteredFields } } ↓ Signer sees only their fields; draws signatures; submits ↓ POST /api/sign/[token] 1. Verify JWT 2. Atomic claim: UPDATE signing_tokens SET used_at = NOW() WHERE jti = ? AND used_at IS NULL → 0 rows = 409 already-signed 3. Acquire Postgres advisory lock on document ID (prevents concurrent PDF writes) 4. Read current accumulatorPath = doc.signedFilePath ?? doc.preparedFilePath 5. Embed this signer's signatures into accumulatorPath → write to new path (clients/{id}/{uuid}_partial.pdf, updated with atomic rename) 6. Update doc.signedFilePath = new path 7. Update doc.signers[signerEmail].signedAt = now 8. Release advisory lock 9. Check completion: COUNT(signing_tokens WHERE document_id = ? AND used_at IS NOT NULL) vs COUNT(signing_tokens WHERE document_id = ?) 10a. Not all signed: logAuditEvent('signer_signed'); return 200 10b. All signed (completion): - Compute pdfHash of final signedFilePath - UPDATE documents SET status='Signed', signedAt=now, pdfHash=hash - logAuditEvent('signer_signed') - logAuditEvent('document_completed', { signerCount, mergedFilePath }) - sendAgentNotificationEmail (all signed) - sendAllSignersCompletionEmail (each signer receives final PDF link) - return 200 ``` ### Advisory Lock Implementation ```typescript // Within the signing POST, wrap the PDF write in an advisory lock: await db.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${documentId}))`); // All subsequent DB operations in this transaction hold the lock. // Lock released automatically when transaction commits or rolls back. ``` Drizzle's `db.execute(sql`...`)` supports raw SQL. `pg_advisory_xact_lock` is a session-level transaction lock — safe for this use case. --- ## Part 3: Migration Strategy ### Existing signed documents — no action required The `signerEmail` field is absent from all existing `signatureFields` JSONB. For existing tokens, `signer_email = null`. The signing endpoint's null path falls through to `isClientVisibleField()` — identical to current behavior. Existing documents never enter multi-signer code paths. ### Migration file (single file, order matters) Write as `drizzle/0010_multi_signer.sql`: ```sql -- 1. Expand signing_tokens ALTER TABLE "signing_tokens" ADD COLUMN "signer_email" text; -- statement-breakpoint -- 2. Add signers JSONB to documents ALTER TABLE "documents" ADD COLUMN "signers" jsonb; -- statement-breakpoint -- 3. Expand audit event enum -- Must be outside a transaction in Postgres < 12 — use statement-breakpoint ALTER TYPE "audit_event_type" ADD VALUE 'signer_email_sent'; -- statement-breakpoint ALTER TYPE "audit_event_type" ADD VALUE 'signer_signed'; -- statement-breakpoint ALTER TYPE "audit_event_type" ADD VALUE 'document_completed'; ``` **No backfill required.** Existing rows have `null` for new columns, which is the correct legacy sentinel value at every call site. ### TypeScript changes after migration 1. Add `signerEmail?: string` to `SignatureFieldData` interface 2. Add `DocumentSigner` interface 3. Add `signers` column to `documents` Drizzle table definition 4. Add `signerEmail` to `signingTokens` Drizzle table definition 5. Add three values to `auditEventTypeEnum` array in schema 6. Add `getSignerEmail(field, fallback)` helper function All changes are additive. No existing function signatures break. --- ## Part 4: Multi-Signer Build Order Each step is independently deployable. Deploy schema migration first, then backend changes, then UI. ``` Step 1: DB migration (0010_multi_signer.sql) → System: DB ready. App unchanged. No user impact. Step 2: Schema TypeScript + token layer - Add DocumentSigner type, signerEmail to SignatureFieldData - Update signingTokens and documents Drizzle definitions - Update createSigningToken(documentId, signerEmail?) - Add auditEventTypeEnum new values → System: Token creation accepts signer email. All existing behavior unchanged. Step 3: Signing GET endpoint — field filtering - Read tokenRow.signerEmail - Filter signatureFields by signerEmail (null → legacy) → System: Signing page shows correct fields per signer. Legacy tokens unaffected. Step 4: Signing POST endpoint — accumulator + completion - Add advisory lock - Add accumulator path logic - Add completion check - Add document_completed event + notifications → System: Multi-signer signing flow complete end-to-end. Single-signer legacy unchanged. Step 5: Send route — per-signer token loop - Detect doc.signers vs legacy - Loop: create token + send email per signer - Log signer_email_sent per signer → System: New documents get per-signer tokens. Old documents still use legacy path. Step 6: New endpoint — PUT /api/documents/[id]/signers - Validate email array - Update documents.signers JSONB → System: Agent can set signer list from UI. Step 7: UI — PreparePanel signer list - Replace single email textarea with name+email rows (add/remove) - Call PUT /api/documents/[id]/signers on change - Warn if client-visible fields lack signerEmail → System: Agent can define signers before placing fields. Step 8: UI — FieldPlacer signer tagging - Add signerEmail selector per client-visible field - Color-code placed fields by signer - Pass signerEmail through persistFields → System: Full multi-signer field placement. Step 9: Email — completion notifications - sendAllSignersCompletionEmail in signing-mailer.tsx - Update sendAgentNotificationEmail for completion context → System: All parties notified and receive final PDF link. Step 10: End-to-end verification - Test with two signers on a real Utah form - Verify field isolation, sequential PDF accumulation, final hash ``` --- ## Part 5: Multi-Signer Components — New vs Modified ### Modified | Component | File | Nature of Change | |---|---|---| | `SignatureFieldData` interface | `src/lib/db/schema.ts` | Add `signerEmail?: string` | | `auditEventTypeEnum` | `src/lib/db/schema.ts` | Add 3 new values | | `signingTokens` Drizzle table | `src/lib/db/schema.ts` | Add `signerEmail` column | | `documents` Drizzle table | `src/lib/db/schema.ts` | Add `signers` column | | `createSigningToken()` | `src/lib/signing/token.ts` | Add `signerEmail?` param; INSERT includes it | | GET `/api/sign/[token]` | `src/app/api/sign/[token]/route.ts` | Signer-aware field filtering (null path = legacy) | | POST `/api/sign/[token]` | `src/app/api/sign/[token]/route.ts` | Accumulator PDF logic, advisory lock, completion check, completion notifications | | POST `/api/documents/[id]/send` | `src/app/api/documents/[id]/send/route.ts` | Per-signer token + email loop; legacy path preserved | | `PreparePanel` | `src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` | Multi-signer list entry UI (replaces single textarea) | | `FieldPlacer` | `src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` | Signer email selector on field place; per-signer color coding | | `signing-mailer.tsx` | `src/lib/signing/signing-mailer.tsx` | Add `sendAllSignersCompletionEmail` function | ### New | Component | File | Purpose | |---|---|---| | `DocumentSigner` interface | `src/lib/db/schema.ts` | Shape of `documents.signers[]` JSONB entries | | `getSignerEmail()` helper | `src/lib/db/schema.ts` | Returns `field.signerEmail ?? fallback`; mirrors `getFieldType()` pattern | | PUT `/api/documents/[id]/signers` | `src/app/api/documents/[id]/signers/route.ts` | Save/update signer list on the document | | Migration file | `drizzle/0010_multi_signer.sql` | All DB schema changes in one file | ### Not Changed | Component | Reason | |---|---| | `embedSignatureInPdf()` | Works on any path; accumulator pattern reuses it as-is | | `verifySigningToken()` | JWT payload unchanged; `signerEmail` is DB-only, not a JWT claim | | `logAuditEvent()` | Accepts any enum value; new values are additive | | `isClientVisibleField()` | Logic unchanged; still used for legacy null-signer tokens | | GET `/api/sign/[token]/pdf` | Serves prepared PDF; no signer-specific logic needed | | `clients` table | Signers are email-identified, not FK-linked | | `preparePdf()` / prepare endpoint | Unchanged; accumulation happens during signing, not preparation | --- ## Part 6: Docker Compose — Secrets and Environment Variables ### The Core Problem The existing email failure in Docker production is a **runtime env var injection gap**: `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, `CONTACT_EMAIL_PASS` (and `CONTACT_SMTP_PORT`) exist in `.env.local` during development but are never passed to the Docker container at runtime. The nodemailer transporter in `src/lib/signing/signing-mailer.tsx` reads these directly from `process.env`. When they are undefined, `nodemailer.createTransport()` silently creates a transporter with no credentials, and `sendMail()` fails at send time. ### Why Not Docker Secrets (file-based)? Docker Compose's `secrets:` block mounts secret values as files at `/run/secrets/` inside the container. This is designed for Docker Swarm and serves a specific security model (encrypted transport in Swarm, no env var exposure in `docker inspect`). It requires the application to read from the filesystem instead of `process.env`. Bridging this to `process.env` requires an entrypoint shell script that reads each `/run/secrets/` file and exports its value before starting `node server.js`. For this deployment (single VPS, secrets are SSH-managed files on the server, not Swarm), the file-based secrets approach adds complexity with no meaningful security benefit over a properly permissioned `.env.production` file. **Use `env_file` injection, not `secrets:`.** **Decision:** Confirmed approach is `env_file:` with a server-side `.env.production` file (not committed to git, permissions 600, owned by deploy user). ### Environment Variable Classification Not all env vars are equal. Next.js has two distinct categories: | Category | Prefix | When Evaluated | Who Can Read | Example | |---|---|---|---|---| | Server-only runtime | (none) | At request time, via `process.env` | API routes, Server Components, Route Handlers | `DATABASE_URL`, `CONTACT_SMTP_HOST`, `OPENAI_API_KEY` | | Public build-time | `NEXT_PUBLIC_` | At `next build` — inlined into JS bundle | Client-side code | (none in this app) | This app has **no `NEXT_PUBLIC_` variables.** All secrets are server-only and evaluated at request time. They do not need to be present at `docker build` time — only at `docker run` / `docker compose up` time. This is the ideal case: the same Docker image can run in any environment by providing different `env_file` values. **Verified:** Next.js App Router server-side code (`process.env.X` in API routes, Server Components) reads env vars at request time when the route is dynamically rendered. Source: [Next.js deploying docs](https://nextjs.org/docs/app/getting-started/deploying), [vercel/next.js docker-compose example](https://github.com/vercel/next.js/tree/canary/examples/with-docker-compose). ### Required Secrets for Production Derived from `.env.local` inspection — all server-only, none `NEXT_PUBLIC_`: ``` DATABASE_URL — Neon PostgreSQL connection string SIGNING_JWT_SECRET — JWT signing key for signing tokens AUTH_SECRET — Next Auth / Iron Session secret AGENT_EMAIL — Agent login email AGENT_PASSWORD — Agent login password hash seed BLOB_READ_WRITE_TOKEN — Vercel Blob storage token CONTACT_EMAIL_USER — SMTP username (fixes email delivery bug) CONTACT_EMAIL_PASS — SMTP password (fixes email delivery bug) CONTACT_SMTP_HOST — SMTP host (fixes email delivery bug) CONTACT_SMTP_PORT — SMTP port (fixes email delivery bug) OPENAI_API_KEY — GPT-4.1 for AI field placement ``` `SKYSLOPE_*` and `URE_*` credentials are script-only (seed/scrape scripts), not needed in the production container. ### Compose File Structure **`docker-compose.yml`** (production): ```yaml services: app: build: context: ./teressa-copeland-homes dockerfile: Dockerfile restart: unless-stopped ports: - "3000:3000" env_file: - .env.production # server-side secrets — NOT committed to git environment: NODE_ENV: production healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] interval: 30s timeout: 10s retries: 3 start_period: 15s ``` **`.env.production`** (on server, never committed): ```bash DATABASE_URL=postgres://... SIGNING_JWT_SECRET=... AUTH_SECRET=... AGENT_EMAIL=teressa@... AGENT_PASSWORD=... BLOB_READ_WRITE_TOKEN=... CONTACT_EMAIL_USER=... CONTACT_EMAIL_PASS=... CONTACT_SMTP_HOST=smtp.fastmail.com CONTACT_SMTP_PORT=465 OPENAI_API_KEY=sk-... ``` **`.gitignore`** must include: ``` .env.production .env.production.local .env.local ``` ### Dockerfile — next.config.ts Change Required The current `next.config.ts` does not set `output: 'standalone'`. The standalone output is required for the official multi-stage Docker pattern — it produces a self-contained `server.js` with only necessary files, yielding a ~60-80% smaller production image compared to copying all of `node_modules`. **Change needed in `next.config.ts`:** ```typescript const nextConfig: NextConfig = { output: 'standalone', // ADD THIS transpilePackages: ['react-pdf', 'pdfjs-dist'], serverExternalPackages: ['@napi-rs/canvas'], }; ``` **Caution:** `@napi-rs/canvas` is a native addon. Verify the production base image (Debian slim recommended, not Alpine) has the required glibc version. Alpine uses musl libc which is incompatible with pre-built `@napi-rs/canvas` binaries. The official canary Dockerfile uses `node:24-slim` (Debian). ### Dockerfile — Recommended Pattern Three-stage build based on the [official Next.js canary example](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile): ```dockerfile ARG NODE_VERSION=20-slim # Stage 1: Install dependencies FROM node:${NODE_VERSION} AS dependencies WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci --no-audit --no-fund # Stage 2: Build FROM node:${NODE_VERSION} AS builder WORKDIR /app COPY --from=dependencies /app/node_modules ./node_modules COPY . . ENV NODE_ENV=production # No NEXT_PUBLIC_ vars needed — all secrets are server-only runtime vars RUN npm run build # Stage 3: Production runner FROM node:${NODE_VERSION} AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME="0.0.0.0" RUN mkdir .next && chown node:node .next COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static USER node EXPOSE 3000 CMD ["node", "server.js"] ``` **No `ARG`/`ENV` lines for secrets in the Dockerfile.** Secrets are never baked into the image. They arrive exclusively at runtime via `env_file:` in the Compose file. ### Health Check Endpoint Required for the Compose `healthcheck:` to work. Create at `src/app/api/health/route.ts`: ```typescript export async function GET() { return Response.json({ status: 'ok', uptime: process.uptime() }); } ``` `wget` is available in the `node:20-slim` (Debian) image. `curl` is not installed by default in slim images; `wget` is more reliable without a separate `RUN apt-get install curl`. ### `.dockerignore` Prevents secrets and large dirs from entering the build context: ``` node_modules .next .env* uploads/ seeds/ scripts/ drizzle/ *.png *.pdf ``` ### Deployment Procedure (First Time) ``` 1. SSH into VPS 2. git clone (or git pull) the repo 3. Create .env.production with all secrets (chmod 600 .env.production) 4. Run database migration: docker compose run --rm app npm run db:migrate (or run migration against Neon directly before starting) 5. docker compose build 6. docker compose up -d 7. docker compose logs -f app (verify email sends on first signing test) ``` ### Common Pitfall: `db:migrate` in Container Drizzle `db:migrate` reads `DATABASE_URL` from env. In the container, this is provided via `env_file:`. Run migration as a one-off: ```bash docker compose run --rm app node -e " const { drizzle } = require('drizzle-orm/neon-http'); const { migrate } = require('drizzle-orm/neon-http/migrator'); // ... " ``` Or more practically: run `npx drizzle-kit migrate` from the host with `DATABASE_URL` set in the shell, pointing at the production Neon database, before deploying the new container. This avoids needing `drizzle-kit` inside the production image. --- ## Part 7: Multi-Signer Key Risks and Mitigations | Risk | Severity | Mitigation | |---|---|---| | Two signers submit simultaneously, both read same PDF | HIGH | Postgres advisory lock `pg_advisory_xact_lock(hashtext(documentId))` on signing POST | | Accumulator path tracking lost between signers | MEDIUM | `documents.signedFilePath` always tracks current accumulator; null = use preparedFilePath | | Agent sends before all fields tagged to signers | MEDIUM | PreparePanel validates: block send if any client-visible field has no `signerEmail` in a multi-signer document | | `ALTER TYPE ADD VALUE` in Postgres < 12 fails in transaction | MEDIUM | Use `-- statement-breakpoint` between each ALTER; verify Postgres version | | Resending to a signer (token expired) | LOW | Issue new token via a resend endpoint; existing tokens remain valid | | Legacy documents break | LOW | `signerEmail` optional at every layer; null path = unchanged behavior throughout | ## Part 8: Docker Key Risks and Mitigations | Risk | Severity | Mitigation | |---|---|---| | `.env.production` committed to git | HIGH | `.gitignore` entry required; never add to repo | | `@napi-rs/canvas` binary incompatible with Alpine | HIGH | Use `node:20-slim` (Debian), not `node:20-alpine` | | Secrets baked into Docker image layer | MEDIUM | Zero `ARG`/`ENV` secret lines in Dockerfile; all secrets via `env_file:` at compose up | | `standalone` output omits required files | MEDIUM | Test locally with `output: 'standalone'` before pushing; watch for missing static assets | | Health check uses `curl` (not in slim image) | LOW | Use `wget` in healthcheck command — present in Debian slim | | Migration runs against wrong DB | LOW | Run `drizzle-kit migrate` from host against Neon URL before container start; never inside production image | --- ## Sources - [Docker Compose Secrets — Official Docs](https://docs.docker.com/compose/how-tos/use-secrets/) — HIGH confidence - [Next.js with-docker official example — Dockerfile (canary)](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile) — HIGH confidence - [Next.js with-docker-compose official example (canary)](https://github.com/vercel/next.js/tree/canary/examples/with-docker-compose) — HIGH confidence - [Next.js env var classification (runtime vs build-time)](https://nextjs.org/docs/pages/guides/environment-variables) — HIGH confidence - Direct codebase inspection: `src/lib/signing/signing-mailer.tsx`, `src/lib/db/schema.ts`, `.env.local` key names, `next.config.ts` — HIGH confidence --- *Architecture research for: teressa-copeland-homes v1.2* *Researched: 2026-04-03*