Files
red/.planning/phases/15-multi-signer-backend/15-VERIFICATION.md

167 lines
14 KiB
Markdown
Raw Normal View History

---
phase: 15-multi-signer-backend
verified: 2026-04-03T00:00:00Z
status: passed
score: 17/17 must-haves verified
re_verification: false
---
# Phase 15: Multi-Signer Backend Verification Report
**Phase Goal:** The server correctly creates one signing token per signer, filters each signer's signing page to their own fields, and detects completion atomically — all signers receive their links at once, and when the last signer signs all parties get the final PDF.
**Verified:** 2026-04-03
**Status:** passed
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `createSigningToken` accepts optional `signerEmail` and writes it to DB | VERIFIED | `token.ts:7` signature `(documentId: string, signerEmail?: string)`, `token.ts:22` `signerEmail: signerEmail ?? null` in DB insert |
| 2 | `createSignerDownloadToken` produces a 72h JWT with purpose `signer-download` | VERIFIED | `token.ts:69-75``setExpirationTime('72h')`, `purpose: 'signer-download'` |
| 3 | `verifySignerDownloadToken` validates purpose claim and returns `documentId` | VERIFIED | `token.ts:77-81` — checks `payload['purpose'] !== 'signer-download'`, returns `{ documentId }` |
| 4 | `sendSignerCompletionEmail` sends plain-text email with download link | VERIFIED | `signing-mailer.tsx:69-86` — correct opts shape, subject "Signed copy ready: {name}", text with downloadUrl |
| 5 | `GET /api/sign/download/[token]` serves signed PDF for valid signer-download tokens | VERIFIED | `download/[token]/route.ts:12-46` — public GET, verifies token, queries DB, returns PDF bytes |
| 6 | Download route rejects expired tokens, non-Signed documents, and path traversal | VERIFIED | `route.ts:21-23` 401 on invalid token; `30-32` 404 on non-Signed/no-path; `35-37` 403 on path traversal |
| 7 | When `documents.signers` is populated, one signing token is created per signer with that signer's email | VERIFIED | `send/route.ts:31-50` — branches on `doc.signers.length > 0`, calls `createSigningToken(doc.id, signer.email)` per signer |
| 8 | All signing request emails are dispatched in parallel via `Promise.all` | VERIFIED | `send/route.ts:34-50``Promise.all(signers.map(async (signer) => { ... }))` |
| 9 | When `documents.signers` is null/empty, existing single-signer behavior is preserved | VERIFIED | `send/route.ts:51-67` — else branch: legacy `assignedClientId ?? clientId` path, `createSigningToken(doc.id)` without signerEmail |
| 10 | The signing URL uses `APP_BASE_URL` (not `NEXT_PUBLIC_BASE_URL`) | VERIFIED | `send/route.ts:29` `process.env.APP_BASE_URL ?? 'http://localhost:3000'`; no `NEXT_PUBLIC_BASE_URL` reference in file |
| 11 | Document status is set to `Sent` after all emails dispatch successfully | VERIFIED | `send/route.ts:70-75``if (doc.status === 'Draft')` updates to `'Sent'` after both paths |
| 12 | Each signer gets an audit event `email_sent` with `metadata.signerEmail` | VERIFIED | `send/route.ts:44-48``logAuditEvent({ eventType: 'email_sent', metadata: { signerEmail: signer.email } })` |
| 13 | GET handler returns only fields where `field.signerEmail` matches `tokenRow.signerEmail` | VERIFIED | `sign/[token]/route.ts:90-98` — two-branch filter: `tokenRow.signerEmail !== null` gates `field.signerEmail === tokenRow.signerEmail` |
| 14 | GET handler returns all `isClientVisibleField` fields for legacy null-signer tokens | VERIFIED | `sign/[token]/route.ts:97``return true` when `tokenRow.signerEmail === null` |
| 15 | POST handler scopes date field stamping and signable fields to this signer only | VERIFIED | `sign/[token]/route.ts:199-204` date scope, `235-240` signable fields scope — both check `signerEmail !== null` before filtering by `f.signerEmail === signerEmail` |
| 16 | POST handler accumulates partial PDFs (JTI-keyed) and detects completion atomically | VERIFIED | `route.ts:187` `_partial_${payload.jti}.pdf`; `route.ts:303-315` `UPDATE … WHERE completionTriggeredAt IS NULL RETURNING` |
| 17 | Completion winner sends agent notification and all signer completion emails with download links | VERIFIED | `route.ts:332-388` — fire-and-forget blocks gated behind `won.length > 0`; `sendAgentNotificationEmail` + `sendSignerCompletionEmail` to all signers; `createSignerDownloadToken` constructs download URL |
**Score:** 17/17 truths verified
---
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `teressa-copeland-homes/src/lib/signing/token.ts` | Extended `createSigningToken` + signer-download token pair | VERIFIED | All 6 exported functions present: `createSigningToken`, `verifySigningToken`, `createDownloadToken`, `verifyDownloadToken`, `createAgentDownloadToken`, `verifyAgentDownloadToken`, `createSignerDownloadToken`, `verifySignerDownloadToken` |
| `teressa-copeland-homes/src/lib/signing/signing-mailer.tsx` | Signer completion email | VERIFIED | 3 email functions: `sendSigningRequestEmail`, `sendAgentNotificationEmail`, `sendSignerCompletionEmail` |
| `teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts` | Public signer download route | VERIFIED | File exists, exports `GET`, no auth import, correct guards |
| `teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts` | Multi-signer send route with legacy fallback | VERIFIED | Multi-signer branch + legacy branch, `DocumentSigner` typed, `APP_BASE_URL` |
| `teressa-copeland-homes/src/app/api/sign/[token]/route.ts` | Signer-aware GET + POST handlers with accumulate PDF and atomic completion | VERIFIED | Signer-aware field filter in GET; accumulate PDF, scoped date/sig fields, atomic `completionTriggeredAt` guard, completion notifications in POST |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `sign/download/[token]/route.ts` | `src/lib/signing/token.ts` | `verifySignerDownloadToken` import | WIRED | `route.ts:2` imports `verifySignerDownloadToken`, called at `route.ts:20` |
| `sign/download/[token]/route.ts` | `documents` table | `db.query.documents.findFirst` | WIRED | `route.ts:25-28`, guards `status !== 'Signed'` at `route.ts:30` |
| `send/route.ts` | `token.ts` | `createSigningToken(doc.id, signer.email)` | WIRED | `send/route.ts:6` import, `36` call with signerEmail |
| `send/route.ts` | `signing-mailer.tsx` | `Promise.all` with `sendSigningRequestEmail` | WIRED | `send/route.ts:7` import, `34-50` Promise.all loop |
| `sign/[token]/route.ts GET` | `documents.signatureFields` | filter by `tokenRow.signerEmail` | WIRED | `route.ts:46-48` fetches `tokenRow`, `90-98` filters on `tokenRow.signerEmail` |
| `sign/[token]/route.ts POST` | `documents.completionTriggeredAt` | `UPDATE WHERE IS NULL RETURNING` atomic guard | WIRED | `route.ts:303-315``isNull(documents.completionTriggeredAt)` guard with `.returning()` |
| `sign/[token]/route.ts POST` | `signing-mailer.tsx` | `sendSignerCompletionEmail` + `sendAgentNotificationEmail` | WIRED | `route.ts:12` imports both, called in winner block at `344`, `373` |
| `sign/[token]/route.ts POST` | `token.ts` | `createSignerDownloadToken` | WIRED | `route.ts:3` import, `368` call in winner block |
---
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `send/route.ts` | `doc.signers` | `db.query.documents.findFirst` | Yes — JSONB from DB | FLOWING |
| `sign/[token]/route.ts GET` | `tokenRow.signerEmail` | `db.query.signingTokens.findFirst` | Yes — DB row | FLOWING |
| `sign/[token]/route.ts POST` | `freshDoc.signedFilePath` | Re-fetch from DB at step 7 | Yes — DB row | FLOWING |
| `sign/[token]/route.ts POST` | `remaining[0].cnt` | SQL `count(*)` on `signingTokens WHERE usedAt IS NULL` | Yes — live DB count | FLOWING |
| `sign/[token]/route.ts POST` | `won` | `UPDATE … RETURNING` | Yes — returns row or empty array | FLOWING |
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `token.ts` exports all 8 functions | `grep -c "^export async function" token.ts` | 8 | PASS |
| `signer-download` purpose used in create and verify | `grep "signer-download" token.ts` | 3 lines (comment + create + verify) | PASS |
| No auth import in download route (public) | `grep "import.*auth" download/[token]/route.ts` | 0 matches | PASS |
| `status: 'Signed'` set exactly once in sign route | `grep -c "status: 'Signed'" sign/[token]/route.ts` | 1 | PASS |
| `APP_BASE_URL` used; no `NEXT_PUBLIC_BASE_URL` in send route | `grep NEXT_PUBLIC_BASE_URL send/route.ts` | 0 matches | PASS |
| `completionTriggeredAt` atomic guard present | `grep "isNull(documents.completionTriggeredAt)"` | Found at line 308 | PASS |
| Signer completion emails fire only in winner block | Pattern gated behind `won.length === 0` check | Lines 357-388 inside `if (won.length === 0)` is NOT it — see note | PASS (see note) |
**Note on winner block:** The `sendSignerCompletionEmail` and `sendAgentNotificationEmail` calls appear after line 315 (the `if (won.length === 0) return` early exit). Any code reached past that guard is winner-only. Code correctly gated.
---
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|---------|
| MSIGN-05 | 15-02 | Document recipients list built automatically from unique signer emails on placed fields | SATISFIED | Send route reads `doc.signers` (JSONB written from field signerEmails in Phase 14); loops over each — no separate manual entry needed |
| MSIGN-06 | 15-02 | All signers receive their unique signing links simultaneously when agent sends | SATISFIED | `Promise.all` in send route dispatches all signing emails concurrently |
| MSIGN-07 | 15-03 | Each signer's signing page shows only their own assigned fields | SATISFIED | GET handler filters `signatureFields` by `tokenRow.signerEmail` |
| MSIGN-10 | 15-01, 15-03 | When all signers complete, agent receives a notification email | SATISFIED | `sendAgentNotificationEmail` fires in completion winner block only |
| MSIGN-11 | 15-01, 15-03 | When all signers complete, all parties receive the final merged PDF via email link | SATISFIED | `sendSignerCompletionEmail` fires for each `doc.signers` entry with `createSignerDownloadToken` URL; download route serves signed PDF |
**Orphaned requirements check:** REQUIREMENTS.md traceability table maps exactly MSIGN-05, MSIGN-06, MSIGN-07, MSIGN-10, MSIGN-11 to Phase 15 — matches plan frontmatter declarations exactly. No orphaned requirements.
**Out-of-scope for Phase 15 (correctly deferred to Phase 16):** MSIGN-01, MSIGN-02, MSIGN-03, MSIGN-04, MSIGN-08 (Phase 14), MSIGN-09.
---
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `sign/[token]/route.ts` | 263 | `console.error` | Info | Debug logging — non-blocking, appropriate error handler |
| `send/route.ts` | 79 | `console.error` | Info | Error boundary logging — appropriate |
| `sign/[token]/route.ts` | 329 | `process.env.APP_BASE_URL ?? 'http://localhost:3000'` | Info | Fallback to localhost acceptable for dev; production env must set `APP_BASE_URL` |
No blockers or warnings. All `console.error` calls are in error handlers, not substituting for real implementation. No stubs, no placeholder returns, no hardcoded empty arrays passed to rendering.
---
### Human Verification Required
#### 1. Multi-signer full ceremony end-to-end
**Test:** Create a document with two signers in `documents.signers`. Call `POST /api/documents/[id]/send`. Open both signing links in separate browser sessions. Have signer A sign, then signer B sign.
**Expected:** Agent receives one notification email. Both signer A and signer B receive a completion email with a download link. Downloading the PDF returns a document with both signers' signatures embedded.
**Why human:** Requires live SMTP delivery, real browser signing sessions, and PDF visual inspection.
#### 2. Atomic completion race condition
**Test:** Simulate two concurrent POST submissions to `/api/sign/[token]` for the last two signers arriving within milliseconds of each other.
**Expected:** Exactly one winner sets `completionTriggeredAt` and fires notifications; the other returns `{ ok: true }` without double-sending emails.
**Why human:** Requires concurrent load-testing tooling (e.g., k6 or two concurrent requests); cannot be verified with grep or static analysis.
#### 3. Accumulate PDF correctness
**Test:** Have signer A sign fields on page 1; have signer B sign fields on page 2. Inspect the final signed PDF.
**Expected:** Final PDF contains signer A's signature on page 1 AND signer B's signature on page 2 — accumulated correctly via the JTI-keyed partial path chain.
**Why human:** PDF content inspection requires visual review of the output file.
---
### Gaps Summary
No gaps found. All 17 must-haves are verified at all four levels (exists, substantive, wired, data-flowing). All 5 required requirement IDs (MSIGN-05, MSIGN-06, MSIGN-07, MSIGN-10, MSIGN-11) have clear implementation evidence in the codebase. The phase goal is achieved:
- One signing token per signer is created with that signer's email stored in the DB
- Each signer's GET returns only their own fields (filtered by `tokenRow.signerEmail`)
- Completion is detected atomically via the `completionTriggeredAt` UPDATE-WHERE-IS-NULL-RETURNING race guard
- All signers receive links simultaneously via `Promise.all`
- When the last signer signs, the completion winner sends agent notification + signer download links
Three items are flagged for human verification (live email delivery, concurrent race, PDF accumulation correctness) but no automated check can substitute for them.
---
_Verified: 2026-04-03_
_Verifier: Claude (gsd-verifier)_