# 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.
