# Build your own DocuSign

> Source: https://buildyourown.software/like/docusign
> Category: E-signature. Original vendor: Docusign, Inc.. Last updated 2026-09-10.
> This file is written for AI coding agents. It contains the context, the build prompt, and the test plan for a self-built replacement of DocuSign.

## How to use this file

1. Read the whole file first.
2. Build the app described in "Build prompt". Treat its acceptance criteria as the definition of done.
3. When the build works, follow "Test prompt" to write and run automated tests.
4. Ask the user before changing the data model, the stack, or the non-goals.
5. The app must run the "one box" way described in "Default deployment" below: one process, SQLite, local disk, no hosted services unless the build prompt names one.

## What DocuSign does

DocuSign lets you send a document to one or more people, have them sign it in a browser, and get a completed PDF back with a certificate that records who signed, when, and from where. That record is what makes the signature hold up.

The rest of the product is volume and enterprise: templates, bulk send, web forms, payments during signing, identity verification, CLM (contract lifecycle management), and integrations into Salesforce and the like.

Electronic signatures are legally valid in the US under the ESIGN Act and in the EU under eIDAS when you can show intent, consent, and a tamper-evident record. None of that requires DocuSign. It requires a PDF library, an email, and a log.

## What it costs

Typical spend: $2,700 per year. Business Pro for 5 users: $45 × 5 × 12 on annual billing, capped at 100 envelopes per user per year.

- Personal: $11 per month billed annually ($132/yr), single user (Five sends a month. Send a sixth and you're upgrading.)
- Standard: $30 per user / month billed annually ($360/user/yr)
- Business Pro: $45 per user / month billed annually ($540/user/yr)
- Enhanced plans: Custom 50+ users, contact sales

Prices checked 2026-09-10 at https://ecom.docusign.com/plans-and-pricing/esignature.

## Features and whether to build them

- [build] Upload a document and place fields: Drag signature, initials, date, text, and checkbox fields onto PDF pages for each signer. This is the sender's whole experience. A PDF viewer with absolutely positioned boxes.
- [build] Send to signers by email: Each signer gets a unique link. Optional signing order. A tokenized link per signer and one email. Sequential order is a status field.
- [build] Sign in the browser: Draw, type, or upload a signature; fill fields; agree to e-sign consent. Canvas for drawing, a cursive font for typing. The consent checkbox is what makes it legal.
- [build] Completed PDF: Signatures and field values burned into the PDF, sent to everyone. pdf-lib draws images and text at coordinates. This is the deliverable.
- [build] Certificate of completion and audit trail: Who signed, when, IP address, email, document hash, every event. This is what makes the signature defensible. Append it as the last page and store the log.
- [build] Reminders and expiration: Nudge signers who haven't signed; void after a deadline. A daily job in the same process and two columns.
- [build] Templates: Save a document with its fields to reuse with new signers. If you send the same NDA weekly, this is most of the value. It's the same table with a flag.
- [build] Status dashboard: See what's out, who's signed, what's overdue. One list view.
- [maybe] Bulk send: Send one template to hundreds of recipients from a CSV. Useful for HR or annual renewals. It's a loop over the send function; add it when you need it.
- [skip] Web forms and payments during signing: Public forms that generate documents; collect a card while signing. Two different products bolted on. Use Stripe Checkout separately if you need to charge.
- [skip] Identity verification and notarization: ID scans, knowledge-based auth, remote online notary. Regulated and expensive. If your documents need this, keep paying for it.
- [skip] CLM, Salesforce, and enterprise admin: Contract lifecycle management, CRM-triggered sends, SSO. Not a small-team problem. A webhook on completion covers the integration case.

## Under the hood

### Data model

- Document (envelope): id, owner_id, title, original_path, signed_path, status (draft | sent | completed | voided | expired), signing_order (parallel | sequential), message, expires_at, completed_at, sha256_original, sha256_signed. Store PDFs on local disk under ./data/files, never in the database. The app mints its own 10-minute signed download URLs.
- Signer: id, document_id, name, email, order, status (pending | viewed | signed | declined), token, signed_at, ip, user_agent, consent_at, signature_image_path. token is a long random secret; the signing link is /sign/<token>.
- Field: id, document_id, signer_id, type (signature | initials | date | text | checkbox), page, x, y, width, height, required, value, label. Coordinates in PDF points from the bottom-left, matching pdf-lib.
- Audit event: id, document_id, signer_id (nullable), type (created | sent | viewed | consented | field_filled | signed | declined | reminded | completed | voided | downloaded), at, ip, user_agent, meta (json). Append-only. SQLite BEFORE UPDATE and BEFORE DELETE triggers that RAISE(ABORT).
- Template: id, owner_id, title, pdf_path, roles (json: [{ name, order }]), fields (json, keyed by role)

### Key flows

**Prepare and send**
1. Owner uploads a PDF. Server writes it to ./data/files, computes SHA-256, renders page thumbnails with pdf.js.
2. Owner adds signers (name, email, order) and drags fields onto pages for each signer.
3. On send: status → sent, a token per signer, audit 'sent'.
4. Email each signer in the first order group with their link. Later groups wait.

**Sign**
1. Signer opens /sign/<token>. Audit 'viewed' with IP and user agent.
2. Show the PDF with only their fields highlighted. Require the e-sign consent checkbox before any field is active.
3. Signer draws, types, or uploads a signature once; it's applied to every signature field. Fill remaining fields.
4. On submit: validate required fields, save values and the signature PNG to disk, audit 'signed', status → signed. Push the change to the owner's dashboard over SSE.
5. If sequential and more signers remain, email the next group. If everyone has signed, run completion.

**Complete**
1. Load the original PDF with pdf-lib. Draw each signature image and field value at its coordinates.
2. Append a certificate page: document title, original hash, each signer's name, email, IP, timestamps, and the full audit log.
3. Save to ./data/files, compute SHA-256 of the signed PDF, store both hashes, status → completed.
4. Email the signed PDF to the owner and every signer (to ./data/outbox when no SMTP_URL is set). Fire the completion webhook if configured.

**Remind, expire, void**
1. Daily croner job inside the app process, guarded by a job_locks row: for sent documents, email pending signers every N days (owner setting), audit 'reminded'.
2. If expires_at has passed, status → expired, notify the owner.
3. Owner can void a sent document at any time; signing links stop working, audit 'voided'.

### Integrations

- pdf-lib (required): Draw signatures and text into the PDF, append the certificate page.
- pdf.js (pdfjs-dist) (required): Render pages in the browser for field placement and signing.
- SQLite on local disk (required): Documents, signers, fields, audit log, and job locks via better-sqlite3 and Drizzle.
- Local disk for files (required): Original and signed PDFs, signature images under ./data/files, served through the app's own signed URLs.
- SMTP relay (optional; outbox in dev) (optional): Signing invitations, reminders, completed copies through nodemailer. Without SMTP_URL, mail goes to ./data/outbox and the owner copies the signing link by hand.
- Self-issued sessions (required): Owner sign-in with signed cookies; first-run admin, invite links for teammates. Signers never need an account.
- croner (in-process) (required): Reminders and expiration.
- Outbound webhook (optional): Notify your CRM or Slack on completion.
- Stripe (optional): If you must collect payment alongside a signature, do it as a separate Checkout step.

## Where to build it

- Claude Code (https://claude.com/claude-code): Best fit. The PDF coordinate work and the audit trail need an agent that will write tests and open the output PDF to check it.
- Replit (https://replit.com): Fine for building and trying it. SQLite and ./data/files work in the workspace. When it works, move the folder to a VPS and run docker compose.
- Lovable (https://lovable.dev): Skip, or UI only for the dashboard and signer screens. It's built around Supabase, and this app runs on SQLite and local disk with pdf-lib in the one Node process.
- ChatGPT / Codex (https://chatgpt.com): Have ChatGPT adjust the field types and certificate wording to your jurisdiction, then build with Codex.
- Stripe (https://stripe.com): Only if you take payment with the signature. Do it as a separate step, not inside the signing page.

---

## Default deployment

Also at https://buildyourown.software/deploy.md. The build prompt below assumes this.

Every web app on buildyourown.software is written to run the same way. Learn it once and every tool's build prompt makes sense.

## The shape

- **One process.** Node 22 running the app. A second process only when the tool genuinely needs one (a job worker, a probe runner). Never a serverless function.
- **SQLite.** `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations checked into the repo and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- **Local disk for files.** Uploads live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself. No bucket.
- **In-process scheduler.** `croner` inside the app process for reminders, retention, digests, and polling. A row in a `job_locks` table stops two instances from running the same job.
- **Sessions the app issues itself.** On first run the app creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` (or prompts in the browser if unset). Teammates join through invite links. Sessions are signed, httpOnly cookies. Google sign-in is an optional add-on, never a requirement.
- **Realtime from the process.** Server-sent events fed by an in-memory event bus. No database change feeds, no websocket service.
- **Email through any SMTP server.** `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and printed to the log, so every flow works on a laptop with no account. When you go public, point `SMTP_URL` at the one relay you choose. Sending straight from a VPS lands in spam, so this is the one hosted service most tools keep.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.

## Running it

- **Laptop:** `pnpm install && pnpm dev`. That's the whole install. No env vars are required; every setting has a default that works locally.
- **Public:** a VPS with 1 CPU and 1 GB RAM, one DNS record, and `docker compose up -d`. The compose file has two services: `app` and `caddy`. Caddy terminates TLS with a certificate it gets on its own. `./data` is a named volume.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. A `backup` service in compose runs it nightly. Restoring is copying one file.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## What this rules out

No Vercel, Supabase, Neon, Clerk, Auth0, R2, S3, Fly, Cloudflare Workers, Upstash, or any service that needs an account to run the app. If a tool can't avoid an outside service (a calendar OAuth app, a GitHub webhook, an SMTP relay, a second probe location), the build prompt says so in one line and makes it optional or last.

## Acceptance criteria every tool inherits

- A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
- `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
- Killing the process mid-job and restarting it doesn't double-run or lose the job.
- `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
- Every outbound email in dev shows up in `./data/outbox`.

---

## Build prompt

Also available on its own at https://buildyourown.software/like/docusign/build-prompt.md

# Build an e-signature app (replacing DocuSign eSignature)

You are building a self-hosted e-signature app for a small team. It replaces DocuSign for sending PDFs to be signed, collecting signatures in the browser, and producing a completed PDF with a certificate of completion and a tamper-evident audit trail. Build it end to end. The audit trail and the final PDF must be correct; everything else can be plain.

## Stack

- One Node 22 process running Next.js (App Router) with TypeScript and Tailwind. No serverless functions.
- SQLite through `better-sqlite3` and Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked in and run on startup. Keep the schema portable so it can move to Postgres later, but don't build for that now.
- Files on local disk under `./data/files/<table>/<id>/`: original PDFs, signed PDFs, signature PNGs. The app serves them through an authenticated route with signed URLs it mints itself. No bucket.
- `pdf-lib` for writing into PDFs; `pdfjs-dist` for rendering pages in the browser.
- `croner` inside the app process for reminders and expiration. A `job_locks` table stops two instances from running the same job.
- Sessions the app issues itself as signed httpOnly cookies. On first run the app creates the admin owner from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts for them in the browser if unset. Teammates join through invite links. Signers never create accounts.
- Server-sent events from an in-memory event bus for live status on the dashboard and the signer's done page.
- Email through `nodemailer` with `SMTP_URL`. Without it, every message is written to `./data/outbox/*.eml` and printed to the log. An SMTP relay is the one outside service this tool keeps, because mail sent straight from a VPS lands in spam. On a laptop, copy the signing link from the document page and the whole flow works with no mail at all.
- UTC in the database, local time in the UI, `date-fns-tz` for every conversion.

Full profile: https://buildyourown.software/deploy.md

## Running it

- Laptop: `pnpm install && pnpm dev`. No env vars needed; every setting has a default that works locally.
- VPS: 1 CPU, 1 GB RAM, one A record, then `docker compose up -d`. The compose file has `app`, `caddy` (gets its own TLS certificate), and `backup`. `./data` is a named volume.
- Backups: `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db`, copies `./data/files` alongside it, and prunes to the last 14. The `backup` service runs it nightly. Restoring is copying the files back.
- Updates: `git pull && docker compose up -d --build`. Migrations run on boot.

## Data model

SQLite types only: `text`, `integer`, `real`. All tables: `id` text (uuid), `created_at`, `updated_at`. Every timestamp is an ISO 8601 UTC string like `2026-09-10T14:03:00.000Z`, so string comparison sorts correctly and `expires_at < now` works as a plain `<`. JSON columns are `text` holding JSON; parse in the app, never in SQL. Booleans are `integer` 0/1. Nothing here assumes `timestamptz` or `jsonb`.

- `owners`: `email` (unique), `name`, `company_name`, `password_hash`, `role` (`admin` | `member`), `reminder_every_days` (integer, default 3), `webhook_url` (nullable), `webhook_secret`.
- `sessions`: `owner_id`, `expires_at`. `invites`: `email`, `token` (unique), `invited_by`, `expires_at`, `accepted_at` (nullable).
- `documents`: `owner_id`, `title`, `message` (text to signers), `original_path` (relative to `./data/files`), `signed_path` (nullable), `original_sha256`, `signed_sha256` (nullable), `page_count` (integer), `page_sizes` (json text: `[{ width, height }]` in PDF points), `status` (`draft` | `sent` | `completed` | `voided` | `expired`), `signing_order` (`parallel` | `sequential`), `expires_at` (nullable), `sent_at`, `completed_at`, `template_id` (nullable).
- `signers`: `document_id`, `name`, `email`, `order` (integer, 1-based), `status` (`pending` | `viewed` | `signed` | `declined`), `token` (32 random bytes base64url, unique), `signature_image_path` (nullable), `signature_kind` (`drawn` | `typed` | `uploaded`), `consent_at`, `signed_at`, `declined_reason`, `last_ip`, `last_user_agent`.
- `fields`: `document_id`, `signer_id`, `type` (`signature` | `initials` | `date_signed` | `text` | `checkbox`), `page` (integer, 1-based), `x`, `y`, `width`, `height` (real, PDF points, origin bottom-left, matching pdf-lib), `required` (integer 0/1), `label`, `value` (nullable text), `font_size` (real, default 11).
- `audit_events`: `document_id`, `signer_id` (nullable), `type` (`created` | `uploaded` | `sent` | `viewed` | `consented` | `field_filled` | `signed` | `declined` | `reminded` | `completed` | `voided` | `expired` | `downloaded`), `at`, `ip`, `user_agent`, `meta` (json text). Append-only: no update or delete endpoints, plus two SQLite triggers in the migration: `CREATE TRIGGER audit_events_no_update BEFORE UPDATE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END;` and the same for `BEFORE DELETE`.
- `templates`: `owner_id`, `title`, `pdf_path`, `page_count`, `page_sizes`, `roles` (json text `[{ name, order }]`), `fields` (json text, same shape as `fields` but with `role` instead of `signer_id`).
- `job_locks`: `name` (primary key), `locked_by`, `locked_until`, `last_run_at`.

## Screens

### Owner (authenticated)

1. **First run and sign-in**: if `owners` is empty, `/` shows a one-time form that creates the admin (or uses `ADMIN_EMAIL`/`ADMIN_PASSWORD` when set and skips the form). `/login` is email and password. Settings has "Invite a teammate", which emails a `/join/[token]` link good for 7 days.
2. **Dashboard (`/app`)**: tabs for Action needed (sent, with any signer pending), Completed, Drafts, Voided/Expired. Each row: title, signers with status dots, sent date, expires date. Actions: remind, void, download signed PDF, view audit log. Subscribes to `/api/events` (SSE) so status dots update when a signer views or signs without a refresh.
3. **New document (`/app/new`)**: upload a PDF (max 25 MB). Server writes it to `./data/files/documents/<id>/original.pdf`, computes SHA-256, reads page count and sizes with pdf-lib, and creates a draft.
4. **Prepare (`/app/documents/[id]/prepare`)**: three-pane editor. Left: signers list (add name, email, drag to reorder, choose parallel or sequential). Center: pages rendered with pdf.js, scrollable. Right: field palette. Click a field type then click on a page to place it, assigned to the selected signer. Fields are draggable and resizable. Store coordinates converted to PDF points with the bottom-left origin. Show a "Send" button that validates every signer has at least one field and every signature field is assigned.
5. **Send modal**: subject, message, expiration (none, 7, 14, 30 days). On send: status → sent, generate tokens, write `sent` audit, email the first order group.
6. **Document detail (`/app/documents/[id]`)**: status, signers, full audit log table, download original and signed PDF, remind, void. Each pending signer's row has a "Copy signing link" button so the owner can hand the link over directly when no SMTP relay is set up.
7. **Templates (`/app/templates`)**: create from a PDF with the same prepare editor but with roles instead of signers; "Use template" asks for a name and email per role and creates a prepared draft.
8. **Settings**: name, company, reminder cadence, webhook URL, invite a teammate.

### Signer (public, no auth)

9. **Signing page (`/sign/[token]`)**: 
   - Reject with a clear page if the token is unknown, the document is voided or expired, or this signer already signed or declined.
   - Log a `viewed` audit event with IP and user agent on load; set signer status to `viewed`.
   - Show the document title, who sent it, and the message. Show the PDF pages with only this signer's fields overlaid, in page order, with a "next field" button.
   - Before any field is interactive, require a checkbox: "I agree to use electronic records and signatures" linking to a short consent page. Record `consented` with timestamp.
   - Signature capture modal: tabs for Draw (canvas, pointer events, works on touch), Type (name rendered in a cursive font bundled with the app, rendered to PNG), Upload (PNG/JPG). Once captured, the same image is applied to every signature field; initials fields get a separate capture.
   - `date_signed` fields auto-fill with today's date in the signer's locale at submit time.
   - "Finish" validates required fields, writes the signature PNG(s) to `./data/files/signers/<id>/`, saves field values, writes `field_filled` events (one per field, in `meta`) and a `signed` event, sets signer status to `signed`. Then run the "advance" logic below.
   - Also offer "Decline to sign" with a reason; records `declined`, emails the owner, and stops the document (status stays `sent` with a declined signer; owner can void).
10. **Done page**: confirmation, and once the document completes (pushed over SSE), a link to download the completed PDF (signed URL, logs `downloaded`).

## Advance and completion logic (server, `lib/documents/advance.ts`)

After any signer signs:
1. If `signing_order = sequential`, find the lowest `order` with any pending signer. If it's greater than the one that just signed, email that group and stop.
2. If any signer is still pending, stop.
3. Otherwise complete: 
   - Load the original PDF with pdf-lib.
   - For every field: `signature`/`initials` → `drawImage` of the PNG scaled to fit the box preserving aspect ratio, bottom-left aligned to (x, y). `text`/`date_signed` → `drawText` with Helvetica at `font_size`, clipped to width. `checkbox` → draw a check mark if true.
   - Append a certificate page (Letter or A4 matching page 1): title "Certificate of Completion", document title, document id, original SHA-256, sent date, completed date, then a table per signer with name, email, IP, consent time, signed time, signature kind; then the full audit log (event, who, when, IP), continuing onto more pages if needed.
   - Save, compute SHA-256, write to `./data/files/documents/<id>/signed.pdf`, set `status = completed`, `completed_at`, write `completed` audit, publish `document.completed` on the event bus.
   - Email the completed PDF (attachment if under 10 MB, otherwise a signed link) to the owner and every signer.
   - If `webhook_url` is set, POST `{ event: "document.completed", document_id, title, signers, signed_sha256, download_url }` with an HMAC-SHA256 signature header using `webhook_secret`. Retry 3 times with backoff. This is optional and the only outbound call besides mail.

## Jobs (`lib/jobs/daily.ts`, croner in the app process)

- One job, `daily`, scheduled at 08:00 UTC and also run once on boot if `last_run_at` is more than 24 hours old. Before running, take the `job_locks` row for `daily`: set `locked_by` to this process id and `locked_until` to now + 10 minutes in one `UPDATE ... WHERE locked_until IS NULL OR locked_until < now`. If the update touches zero rows, another instance has it; skip. Clear the lock and set `last_run_at` when done. A crashed run leaves a lock that expires on its own.
- Reminders: for each `sent` document with pending signers and `reminder_every_days` elapsed since the last `sent` or `reminded` event for that signer, email a reminder and write `reminded`. Commit per document so a kill mid-run never re-reminds a signer already handled.
- Expiration: for each `sent` document with `expires_at < now`, set `expired`, write `expired`, email the owner.

## Security and integrity

- Signing tokens are unguessable (32 random bytes) and single-purpose. Never expose signer tokens to other signers or in owner-facing URLs.
- `./data/files` is never served statically. All downloads go through `/files/[token]`, where the token is an HMAC-SHA256 of path and expiry using `APP_SECRET` (generated into `./data/secret` on first boot if unset). Links expire in 10 minutes and log a `downloaded` event.
- Rate-limit `/sign/[token]`, `/login`, and the signature upload endpoint with an in-memory counter.
- Every state change writes an audit event in the same SQLite transaction.
- Store `x-forwarded-for` (first IP, set by Caddy) and user agent on every signer action.
- Never mutate a completed document. Re-sending creates a new document.

## Non-goals

No bulk send, web forms, payments, identity verification, notarization, in-person signing, SMS delivery, Salesforce/CRM integration, or SSO. No hosted services beyond an optional SMTP relay and the optional completion webhook target. Keep the schema ready for `team_id` on owners.

## Acceptance criteria

1. Uploading a 3-page PDF creates a draft with `page_count = 3`, correct `page_sizes`, and a matching `original_sha256`.
2. A field placed at the visual center of page 2 in the editor is stored as PDF points within 2 pt of `(width/2 - w/2, height/2 - h/2)` for that page.
3. Sending a sequential document with signers A (order 1) and B (order 2) emails only A. After A signs, B is emailed. A parallel document emails both immediately.
4. Opening a signing link writes a `viewed` event with the request IP and user agent.
5. Fields are disabled until consent is checked; checking it writes `consented`.
6. Submitting with a required text field empty returns a validation error and writes nothing.
7. A drawn signature submitted from a touch device (Playwright mobile emulation) produces a non-empty PNG under `./data/files/signers/<id>/` referenced by the signer.
8. After the last signer signs, the completed PDF has `page_count + 1` (or more) pages, contains the signature image at the field's coordinates, and its SHA-256 equals `signed_sha256`.
9. The certificate page text includes every signer's name, email, IP, and the `signed` timestamp, and the audit table lists every event in order.
10. A voided document's signing links return the rejection page and write no `viewed` event.
11. The daily job sends a reminder to a signer 3 days after `sent` and does not send again the next day; a document past `expires_at` becomes `expired` and the owner is emailed.
12. `UPDATE` or `DELETE` on `audit_events` fails at the database level.
13. Completion webhook is delivered with a valid HMAC signature and retried on a 500.
14. Creating a template from a PDF and using it with two named signers produces a draft with all fields assigned to the right signer.
15. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
16. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
17. Killing the process mid-job and restarting it doesn't double-run or lose the job.
18. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
19. Every outbound email in dev shows up in `./data/outbox`.

## Deliverables

- The app with migrations, the SQLite audit triggers, and a seed script creating one owner, one template, one sent document with two signers, and one completed document.
- A `scripts/verify-pdf.ts` that takes a signed PDF and a document id and checks the hash and the certificate text.
- `docker-compose.yml` (`app`, `caddy`, `backup`), a `Caddyfile` that takes the domain from `DOMAIN`, and `scripts/backup.sh`.
- README: running on a laptop with no env vars, running on a VPS with compose, backup and restore, setting `SMTP_URL` for a relay and where the outbox lives without one, and the optional completion webhook.

Build in this order: first-run admin and sessions, upload and hashing, the prepare editor with correct coordinate conversion, sending and the signing page, completion with pdf-lib, the certificate page, the croner job with its lock, templates, then compose and backup. Open the produced PDFs and check them visually after the completion step.

---

## Manual test checklist

- [ ] Upload a 3-page PDF and confirm all pages render with correct aspect ratio.
- [ ] Place a signature field on page 2 near the bottom and confirm it lands in the same spot in the final PDF.
- [ ] Send to two signers in sequential order with no SMTP_URL set and confirm only the first signer's .eml appears in ./data/outbox until the first signs.
- [ ] Open a signing link and confirm no field is interactive until the consent box is checked.
- [ ] Draw a signature on a phone and confirm it looks right in the completed PDF.
- [ ] Type a signature and confirm the cursive rendering is applied to every signature field.
- [ ] Leave a required field empty and confirm submission is blocked with a clear message.
- [ ] Complete a document and confirm the certificate page lists every signer with IP and timestamps.
- [ ] Confirm the SHA-256 of the downloaded signed PDF matches the value stored on the document.
- [ ] Open a signing link after the document was voided and confirm it's rejected.
- [ ] Set a 1-day expiration, advance the clock, and confirm the document expires and the owner is notified.
- [ ] Decline as a signer and confirm the owner is emailed and the audit log records it.
- [ ] Create a template from a completed document and send it to a new signer.
- [ ] Run `docker compose up -d` on a fresh VPS and open the domain over HTTPS.

## Test prompt

Also available on its own at https://buildyourown.software/like/docusign/test-prompt.md

Write automated tests for the e-signature app in this repo, using the acceptance criteria below as the spec. Use Vitest for unit and integration tests, and Playwright (including a mobile emulation project) for signer flows. Each test file gets its own on-disk temp SQLite database (`mkdtemp` a directory, set `DATA_DIR` to it, run the migrations, delete it after), so the real triggers, `job_locks`, `./data/files`, and `./data/outbox` are exercised. Mock only the two allowed outside services: the nodemailer transport (when `SMTP_URL` is set) and the webhook target. Use `pdf-lib` and `pdf-parse` (or `pdfjs-dist`) in tests to inspect produced PDFs.

## Acceptance criteria to cover

1. Uploading `fixtures/three-pages.pdf` creates a draft with `page_count = 3`, correct `page_sizes`, and `original_sha256` equal to the file's hash.
2. A field placed at the visual center of page 2 is stored within 2 pt of the expected bottom-left PDF coordinates. Test the conversion function directly with a Letter page and an A4 landscape page.
3. Sequential send emails only the order-1 signer; after they sign, the order-2 signer is emailed. Parallel send emails all signers at once.
4. Opening `/sign/[token]` writes a `viewed` event with the IP from `x-forwarded-for` and the user agent.
5. Consent must be checked before fields are enabled; checking it writes `consented`.
6. Submitting with a required field empty returns 422 and writes no `signed` event and no field values.
7. A drawn signature on Playwright's mobile project results in a non-empty PNG under `DATA_DIR/files/signers/<id>/` referenced by the signer.
8. After the final signer signs, the signed PDF has at least `page_count + 1` pages, contains an image XObject on page 2 near the field's coordinates, and its SHA-256 matches `signed_sha256`.
9. The certificate page text contains every signer's name, email, IP, and `signed` timestamp, and the audit table lists events in chronological order.
10. A voided document's signing link renders the rejection page and writes no `viewed` event.
11. Daily job: call `runDaily()` directly with a frozen clock. A reminder is sent 3 days after `sent`, not again the next day; a document past `expires_at` becomes `expired` and the owner is emailed once.
12. `UPDATE` and `DELETE` on `audit_events` throw at the database level (the SQLite `RAISE(ABORT)` triggers).
13. The completion webhook body carries a valid HMAC-SHA256 header and is retried when the endpoint returns 500 then 200.
14. Using a template with two roles produces a draft with fields assigned to the correct signers.
15. Starting the app with an empty `DATA_DIR` and no env vars serves the first-run form at `/`, and submitting it creates the admin owner and a session cookie.
16. `docker compose config` parses and lists `app`, `caddy`, and `backup`; the Caddyfile references `DOMAIN`. (The real HTTPS check is manual.)
17. Job lock: with the `daily` lock row held by another process id and `locked_until` in the future, `runDaily()` returns without sending anything. With `locked_until` in the past, it takes the lock, runs, and clears it. Killing the run after the first document's reminder commits and calling `runDaily()` again reminds only the remaining documents.
18. Run `scripts/backup.sh` against a seeded database, delete `DATA_DIR/app.db`, restore the newest backup, and confirm every row count and every file under `DATA_DIR/files` matches.
19. With `SMTP_URL` unset, sending a document writes one `.eml` per signer into `DATA_DIR/outbox/` containing the signing link, and nothing is sent through nodemailer.
20. A signing token for a signer who already signed shows "already signed" and cannot re-submit.

## Layout

- `tests/unit/`: coordinate conversion, HMAC signing, signed download URL minting and expiry, certificate text builder, reminder-due logic (criteria 2, 11 logic, 13 signature).
- `tests/integration/`: first run, upload, send, sign, advance, complete, daily job, job lock, audit triggers, webhook, outbox, backup (criteria 1, 3, 4, 6, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20).
- `tests/e2e/`: Playwright desktop and mobile projects for 5 and 7, plus a full happy path from first-run form to download.

## Rules

- Name tests after criteria: `test("AC8: completed PDF hash matches signed_sha256")`.
- Put fixture PDFs in `fixtures/` (generate them with pdf-lib in a setup script if they don't exist: a 3-page Letter PDF and an A4 landscape PDF).
- Freeze time with fake timers for the daily job tests; never wait on the real croner schedule.
- Add `pnpm test` and a GitHub Actions workflow. No services needed: SQLite and the outbox are on disk, so the workflow is checkout, `pnpm install`, `pnpm test`.
- Run everything. Fix the app where it is wrong and the tests where they are wrong. Report per-criterion pass/fail and every change made.
