675 lines
38 KiB
Markdown
675 lines
38 KiB
Markdown
|
|
# Phase 15: Multi-Signer Backend - Research
|
||
|
|
|
||
|
|
**Researched:** 2026-04-03
|
||
|
|
**Domain:** Next.js App Router API routes — multi-signer token lifecycle, atomic completion detection, accumulate PDF, JWT-presigned signer download
|
||
|
|
**Confidence:** HIGH — all findings grounded in direct codebase inspection of the three routes being changed, the token utility, signing-mailer, embed-signature, and schema.ts; no speculative claims
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
<user_constraints>
|
||
|
|
## User Constraints (from CONTEXT.md)
|
||
|
|
|
||
|
|
### Locked Decisions
|
||
|
|
|
||
|
|
**D-01:** When `documents.signers` is null or empty, the send route falls back to the existing single-signer behavior: create one token (no `signerEmail` set), send to `doc.assignedClientId ?? doc.clientId`. Backwards compatibility preserved.
|
||
|
|
|
||
|
|
**D-02:** When `documents.signers` is populated, loop over each signer, call `createSigningToken(documentId)` for each (passing `signerEmail`), dispatch all emails in parallel via `Promise.all`. All signing links sent simultaneously.
|
||
|
|
|
||
|
|
**D-03:** `createSigningToken` must be extended to accept an optional `signerEmail` parameter and write it to `signingTokens.signerEmail`. Only change to the token utility.
|
||
|
|
|
||
|
|
**D-04:** If `tokenRow.signerEmail` is NOT NULL: filter `signatureFields` to fields where `field.signerEmail === tokenRow.signerEmail`. Only this signer's fields returned.
|
||
|
|
|
||
|
|
**D-05:** If `tokenRow.signerEmail` IS NULL (legacy token): return all `isClientVisibleField` fields — same as today.
|
||
|
|
|
||
|
|
**D-06:** Use `getSignerEmail(field, null)` helper to read field signer. Filter: `field.signerEmail === tokenRow.signerEmail` (both are same email string, or both null for legacy).
|
||
|
|
|
||
|
|
**D-07:** After claiming token atomically:
|
||
|
|
1. Check if ALL tokens for this document have `usedAt IS NOT NULL`
|
||
|
|
2. If not all claimed: do nothing to document status
|
||
|
|
3. If all claimed: attempt to claim `completionTriggeredAt` via `UPDATE documents SET completionTriggeredAt = NOW() WHERE id = $1 AND completionTriggeredAt IS NULL RETURNING id`
|
||
|
|
4. Only if that UPDATE returns a row: set `status = 'Signed'`, `signedAt = now`, `signedFilePath`, `pdfHash`, and trigger all-parties notifications
|
||
|
|
|
||
|
|
**D-08:** Document status set to 'Signed' ONLY when all signers have submitted. Intermediate state remains 'Sent'. Per-signer completion readable from `signingTokens.usedAt`.
|
||
|
|
|
||
|
|
**D-09:** When embedding date stamps, filter to ONLY the date fields where `field.signerEmail === tokenRow.signerEmail` (or, for legacy null-signer tokens, all date fields). Each signer's date fields stamped with THEIR submission timestamp.
|
||
|
|
|
||
|
|
**D-10 (Accumulate PDF):** Keep `documents.signedFilePath` as the "current working PDF." After each signer signs, update `signedFilePath` to their output. The next signer reads from `signedFilePath` (if it exists) instead of `preparedFilePath`. Final signer's output IS the final `_signed.pdf`.
|
||
|
|
|
||
|
|
**D-11 (Download route):** Create a new public download route `GET /api/sign/download/[token]` that accepts a presigned signer-download token (same JWT pattern) and serves the final `signedFilePath` without requiring an agent auth session.
|
||
|
|
|
||
|
|
**D-12:** The agent notification email (already exists: `sendAgentNotificationEmail`) is called on completion only. Add a `sendSignerCompletionEmail` function that sends the download link to each signer.
|
||
|
|
|
||
|
|
### Claude's Discretion
|
||
|
|
- Exact intermediate file naming convention for partial PDFs
|
||
|
|
- Whether to add `viewedAt` timestamp to signingTokens (for Phase 16 dashboard — planner can decide if it fits cleanly)
|
||
|
|
- Error handling granularity (individual signer email failure should not block the send)
|
||
|
|
|
||
|
|
### Deferred Ideas (OUT OF SCOPE)
|
||
|
|
None — discussion stayed within phase scope.
|
||
|
|
</user_constraints>
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
<phase_requirements>
|
||
|
|
## Phase Requirements
|
||
|
|
|
||
|
|
| ID | Description | Research Support |
|
||
|
|
|----|-------------|------------------|
|
||
|
|
| MSIGN-05 | Document recipients list is built automatically from unique signer emails on placed fields (no separate manual entry) | Send route reads `doc.signers[]` populated by Phase 16 UI; D-01/D-02 cover the loop |
|
||
|
|
| MSIGN-06 | All signers receive their unique signing links simultaneously when agent sends | `Promise.all` token loop in send route (D-02); `createSigningToken` extended (D-03) |
|
||
|
|
| MSIGN-07 | Each signer's signing page shows only their own assigned fields — other signers' fields are not visible | GET handler field filtering by `tokenRow.signerEmail` (D-04/D-05/D-06) |
|
||
|
|
| MSIGN-10 | When all signers complete, agent receives a notification email | `sendAgentNotificationEmail` called only at completion trigger (D-07/D-12) |
|
||
|
|
| MSIGN-11 | When all signers complete, all parties (each signer + agent) receive the final merged PDF via email link | `sendSignerCompletionEmail` + new public download route (D-11/D-12) |
|
||
|
|
</phase_requirements>
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Summary
|
||
|
|
|
||
|
|
Phase 15 is a surgical rewrite of three existing routes: no schema migrations (Phase 14 shipped those), no UI. The Phase 14 schema additions (`signers`, `completionTriggeredAt`, `signerEmail`) are already in `schema.ts` and in the DB — this phase writes the application logic that uses them.
|
||
|
|
|
||
|
|
The most important thing to understand about the current code: the POST sign handler at `src/app/api/sign/[token]/route.ts` lines 254-263 unconditionally sets `status = 'Signed'` after any token claim. This is the first-signer-wins bug. Everything else in the handler is correct and reusable — the atomic token claim pattern, the date-stamp approach, the `embedSignatureInPdf` call, the fire-and-forget email pattern. The rewrite preserves all of that and wraps the completion logic in a two-step guard: count remaining unclaimed tokens, then race for `completionTriggeredAt`.
|
||
|
|
|
||
|
|
The accumulate PDF strategy uses `documents.signedFilePath` as the live working path. During multi-signing, this column holds a partial/intermediate signed PDF (document status remains 'Sent' — signedFilePath being non-null during Sent status is the new normal for multi-signer documents). Only the completion winner writes the final hash and flips status to 'Signed'.
|
||
|
|
|
||
|
|
The new public download route `GET /api/sign/download/[token]` follows the existing `createAgentDownloadToken` / `verifyAgentDownloadToken` pattern exactly — a short-lived JWT with `purpose: 'signer-download'`, no DB record, served without auth session. This is a new file.
|
||
|
|
|
||
|
|
**Primary recommendation:** Implement in the order: (1) extend `createSigningToken`, (2) rewrite send route with legacy fallback, (3) rewrite GET sign handler field filtering, (4) rewrite POST sign handler with accumulate + completion, (5) add `sendSignerCompletionEmail` and the new download route.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Standard Stack
|
||
|
|
|
||
|
|
### Core (already installed — no new dependencies)
|
||
|
|
|
||
|
|
| Library | Version | Purpose | Why Standard |
|
||
|
|
|---------|---------|---------|--------------|
|
||
|
|
| `jose` | existing | JWT signing/verification for signing tokens and new signer-download tokens | Already used in `token.ts` for all token operations |
|
||
|
|
| `drizzle-orm` | existing | DB queries — token claim, completion guard, doc updates | Pattern established in existing routes |
|
||
|
|
| `@cantoo/pdf-lib` | existing | Date stamping and (via embed-signature.ts) PDF accumulation | Already used in sign handler lines 183-204 |
|
||
|
|
| `nodemailer` | existing | Signer completion email delivery | Already used in `signing-mailer.tsx` |
|
||
|
|
| `@react-email/render` | existing | HTML email rendering | Already used in `sendSigningRequestEmail` |
|
||
|
|
|
||
|
|
No new npm packages are required for this phase.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Architecture Patterns
|
||
|
|
|
||
|
|
### Pattern 1: Existing Atomic Token Claim (already proven — preserve as-is)
|
||
|
|
|
||
|
|
The current POST handler at lines 127-135 uses:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: teressa-copeland-homes/src/app/api/sign/[token]/route.ts lines 127-135
|
||
|
|
const claimed = await db
|
||
|
|
.update(signingTokens)
|
||
|
|
.set({ usedAt: new Date() })
|
||
|
|
.where(and(eq(signingTokens.jti, payload.jti), isNull(signingTokens.usedAt)))
|
||
|
|
.returning({ jti: signingTokens.jti });
|
||
|
|
|
||
|
|
if (claimed.length === 0) {
|
||
|
|
return NextResponse.json({ error: 'already-signed' }, { status: 409 });
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
This is the correct pattern. Do not change it. The completion guard uses the same idiom.
|
||
|
|
|
||
|
|
### Pattern 2: Completion Guard — `completionTriggeredAt` Race
|
||
|
|
|
||
|
|
The schema already has `completionTriggeredAt` on `documents`. The guard:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: .planning/research/PITFALLS.md Pitfall 2 — verified against schema.ts line 115
|
||
|
|
const won = await db
|
||
|
|
.update(documents)
|
||
|
|
.set({ completionTriggeredAt: new Date() })
|
||
|
|
.where(and(
|
||
|
|
eq(documents.id, payload.documentId),
|
||
|
|
isNull(documents.completionTriggeredAt)
|
||
|
|
))
|
||
|
|
.returning({ id: documents.id });
|
||
|
|
|
||
|
|
if (won.length === 0) return; // another handler already won the race
|
||
|
|
// Only the winner proceeds to final PDF hash + status update + notifications
|
||
|
|
```
|
||
|
|
|
||
|
|
This must run AFTER the "all tokens claimed" count check. Only attempt the completion guard when count(unclaimed) === 0.
|
||
|
|
|
||
|
|
### Pattern 3: All-Tokens-Claimed Check
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: .planning/research/ARCHITECTURE.md Part 2 signing POST sequence step 9
|
||
|
|
const [totalRow, claimedRow] = await Promise.all([
|
||
|
|
db.select({ count: count() })
|
||
|
|
.from(signingTokens)
|
||
|
|
.where(eq(signingTokens.documentId, payload.documentId)),
|
||
|
|
db.select({ count: count() })
|
||
|
|
.from(signingTokens)
|
||
|
|
.where(and(
|
||
|
|
eq(signingTokens.documentId, payload.documentId),
|
||
|
|
isNotNull(signingTokens.usedAt)
|
||
|
|
)),
|
||
|
|
]);
|
||
|
|
const allSigned = totalRow[0].count === claimedRow[0].count;
|
||
|
|
```
|
||
|
|
|
||
|
|
Alternatively, a single query: `SELECT COUNT(*) FILTER (WHERE used_at IS NULL) AS remaining FROM signing_tokens WHERE document_id = $1`. Zero remaining = all signed.
|
||
|
|
|
||
|
|
### Pattern 4: Accumulate PDF — signedFilePath as Working Copy
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: .planning/phases/15-multi-signer-backend/15-CONTEXT.md D-10
|
||
|
|
// Re-read doc to get latest signedFilePath (another signer may have updated it)
|
||
|
|
const freshDoc = await db.query.documents.findFirst({
|
||
|
|
where: eq(documents.id, payload.documentId),
|
||
|
|
columns: { signedFilePath: true, preparedFilePath: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath;
|
||
|
|
const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath!);
|
||
|
|
|
||
|
|
// Write this signer's output to a new partial path
|
||
|
|
const partialRelPath = `clients/${docId}/${jti}_partial.pdf`;
|
||
|
|
const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath);
|
||
|
|
|
||
|
|
// embed into workingAbsPath, write to partialAbsPath
|
||
|
|
await embedSignatureInPdf(dateStampedPath ?? workingAbsPath, partialAbsPath, signaturesWithCoords);
|
||
|
|
|
||
|
|
// Update signedFilePath to the new partial — becomes the next signer's working copy
|
||
|
|
await db.update(documents)
|
||
|
|
.set({ signedFilePath: partialRelPath })
|
||
|
|
.where(eq(documents.id, payload.documentId));
|
||
|
|
```
|
||
|
|
|
||
|
|
IMPORTANT: The advisory lock is required here. Two concurrent signers both reading `signedFilePath` and both writing to different partial paths would race. The lock ensures sequential reads.
|
||
|
|
|
||
|
|
### Pattern 5: Postgres Advisory Lock
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: .planning/research/ARCHITECTURE.md Part 2 Advisory Lock Implementation
|
||
|
|
// Run this as the FIRST thing inside a transaction context (or as standalone execute)
|
||
|
|
await db.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${payload.documentId}))`);
|
||
|
|
```
|
||
|
|
|
||
|
|
`pg_advisory_xact_lock` is transaction-scoped — released automatically when the transaction commits or rolls back. Drizzle supports `db.execute(sql`...`)` for raw SQL.
|
||
|
|
|
||
|
|
IMPORTANT: The existing sign route does NOT use a transaction. For the advisory lock to be transaction-scoped, the lock + PDF write + signedFilePath update must happen inside `db.transaction(async (tx) => { ... })`. If running without a transaction, use `pg_advisory_lock` / `pg_advisory_unlock` instead (session-level, explicit release).
|
||
|
|
|
||
|
|
Simpler approach that avoids transactions: use the `completionTriggeredAt` guard alone (already written above) and accept that the accumulate PDF step is serialized by the fact that each signer writes to a unique JTI-keyed partial path. The only race is on completion — which is handled by `completionTriggeredAt`. The intermediate partial PDFs are not corrupted by concurrent writes because they have unique output paths.
|
||
|
|
|
||
|
|
### Pattern 6: New signer-download Token — reuse existing pattern
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: teressa-copeland-homes/src/lib/signing/token.ts lines 51-64
|
||
|
|
// Follow the exact same structure as createAgentDownloadToken / verifyAgentDownloadToken
|
||
|
|
|
||
|
|
export async function createSignerDownloadToken(documentId: string): Promise<string> {
|
||
|
|
return await new SignJWT({ documentId, purpose: 'signer-download' })
|
||
|
|
.setProtectedHeader({ alg: 'HS256' })
|
||
|
|
.setIssuedAt()
|
||
|
|
.setExpirationTime('72h') // longer than agent-download (5m) — signer may not open immediately
|
||
|
|
.sign(getSecret());
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function verifySignerDownloadToken(token: string): Promise<{ documentId: string }> {
|
||
|
|
const { payload } = await jwtVerify(token, getSecret());
|
||
|
|
if (payload['purpose'] !== 'signer-download') throw new Error('Not a signer download token');
|
||
|
|
return { documentId: payload['documentId'] as string };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
The public download route `GET /api/sign/download/[token]` reads the token from the path param, verifies it, fetches the document's `signedFilePath`, checks it's within UPLOADS_DIR (path traversal guard — replicating lines 169-171 of the sign route), and serves the file as `application/pdf`.
|
||
|
|
|
||
|
|
### Pattern 7: Extended createSigningToken
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: teressa-copeland-homes/src/lib/signing/token.ts current signature
|
||
|
|
// Current: createSigningToken(documentId: string)
|
||
|
|
// New: createSigningToken(documentId: string, signerEmail?: string)
|
||
|
|
|
||
|
|
export async function createSigningToken(
|
||
|
|
documentId: string,
|
||
|
|
signerEmail?: string
|
||
|
|
): Promise<{ token: string; jti: string; expiresAt: Date }> {
|
||
|
|
// ... JWT construction unchanged ...
|
||
|
|
await db.insert(signingTokens).values({
|
||
|
|
jti,
|
||
|
|
documentId,
|
||
|
|
signerEmail: signerEmail ?? null, // new column — null for legacy tokens
|
||
|
|
expiresAt,
|
||
|
|
});
|
||
|
|
return { token, jti, expiresAt };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
All existing call sites (`createSigningToken(doc.id)`) compile without modification — the second param is optional.
|
||
|
|
|
||
|
|
### Pattern 8: Fire-and-forget email (established pattern — preserve)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: teressa-copeland-homes/src/app/api/sign/[token]/route.ts lines 267-286
|
||
|
|
// Existing fire-and-forget pattern for agent notification — reuse for signer completion emails
|
||
|
|
somePromise
|
||
|
|
.then(async () => {
|
||
|
|
try { await sendEmail(...); }
|
||
|
|
catch (err) { console.error('[sign/POST] email failed (non-fatal):', err); }
|
||
|
|
})
|
||
|
|
.catch((err) => { console.error('[sign/POST] fetch failed (non-fatal):', err); });
|
||
|
|
```
|
||
|
|
|
||
|
|
For MSIGN-11: send completion email to each signer at completion. Individual email failure must NOT block the response (fire-and-forget per signer). Log failures but return 200.
|
||
|
|
|
||
|
|
### Pattern 9: Send Route Legacy Fallback Structure
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: CONTEXT.md D-01/D-02
|
||
|
|
if (doc.signers && doc.signers.length > 0) {
|
||
|
|
// Multi-signer path: create one token per signer, send all emails in parallel
|
||
|
|
await Promise.all(
|
||
|
|
doc.signers.map(async (signer) => {
|
||
|
|
const { token, expiresAt } = await createSigningToken(doc.id, signer.email);
|
||
|
|
const signingUrl = `${baseUrl}/sign/${token}`;
|
||
|
|
await sendSigningRequestEmail({ to: signer.email, documentName: doc.name, signingUrl, expiresAt });
|
||
|
|
await logAuditEvent({ documentId: doc.id, eventType: 'email_sent', metadata: { signerEmail: signer.email } });
|
||
|
|
})
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
// Legacy single-signer path: exact current behavior
|
||
|
|
const { token, expiresAt } = await createSigningToken(doc.id);
|
||
|
|
// ... existing code verbatim ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: `doc.signers` type is `DocumentSigner[] | null` from Phase 14 schema. The `DocumentSigner` interface has `{ email: string; color: string }` — only `email` is needed in this route.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Don't Hand-Roll
|
||
|
|
|
||
|
|
| Problem | Don't Build | Use Instead | Why |
|
||
|
|
|---------|-------------|-------------|-----|
|
||
|
|
| Atomic "only one handler wins" on completion | Custom in-memory mutex, file locks | `UPDATE ... WHERE IS NULL RETURNING` on `completionTriggeredAt` | Same DB-level atomicity already used for token claim in line 127-131 |
|
||
|
|
| PDF file concurrency | Custom queue, setTimeout delays | Postgres advisory lock OR unique JTI-keyed output paths (see Pattern 5) | Advisory lock is a single SQL call; unique paths avoid contention entirely for intermediate partials |
|
||
|
|
| Short-lived presigned download URLs | Custom signed URL scheme | `jose` SignJWT with `purpose: 'signer-download'` | Exact same pattern as existing agent-download and signing tokens — zero new code patterns |
|
||
|
|
| Per-signer field filtering | Re-parsing the JSONB field array with custom logic | `field.signerEmail === tokenRow.signerEmail` with null-path fallback | One-line filter on the array already fetched from DB |
|
||
|
|
|
||
|
|
**Key insight:** Every pattern needed in this phase already exists in the codebase. The rewrite is wiring existing patterns into new execution paths, not inventing new patterns.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Common Pitfalls
|
||
|
|
|
||
|
|
### Pitfall 1: Re-fetching doc inside the POST handler for accumulator path
|
||
|
|
|
||
|
|
**What goes wrong:** The current POST handler fetches `doc` at step 5 (after the atomic claim). For the accumulator, the `signedFilePath` value at the time of this fetch may be stale — another signer who submitted concurrently may have updated it between the token claim and this fetch.
|
||
|
|
|
||
|
|
**How to avoid:** Re-fetch the document specifically for `signedFilePath` AFTER acquiring the advisory lock (if using transactions) or accept that with unique JTI-keyed partial paths, concurrent writes to different partials don't interfere. The advisory lock approach with a re-fetch inside the lock is safer.
|
||
|
|
|
||
|
|
**Warning signs:** Two signers submit simultaneously; the second signer's output is based on `preparedFilePath` instead of the first signer's partial.
|
||
|
|
|
||
|
|
### Pitfall 2: completionTriggeredAt guard must fire AFTER accumulate, not before
|
||
|
|
|
||
|
|
**What goes wrong:** If the completion guard runs before the current signer's PDF is embedded, the winner may claim completion but then fail during PDF embedding — leaving the document in a `completionTriggeredAt IS NOT NULL` state with no final PDF.
|
||
|
|
|
||
|
|
**How to avoid:** Order the steps: (1) token claim, (2) accumulate PDF for this signer, (3) update signedFilePath, (4) count remaining tokens, (5) only if zero: attempt completionTriggeredAt claim, (6) only if won: compute final hash, update status to Signed, send notifications.
|
||
|
|
|
||
|
|
### Pitfall 3: date field scoping in multi-signer mode
|
||
|
|
|
||
|
|
**What goes wrong:** Current code at line 177-205 stamps ALL date fields. With two signers each having their own date fields, Signer A's handler would stamp Signer B's date fields with Signer A's timestamp.
|
||
|
|
|
||
|
|
**How to avoid:** Filter date fields by `field.signerEmail === tokenRow.signerEmail` before stamping (D-09). For legacy null-signer tokens, keep the existing all-fields behavior.
|
||
|
|
|
||
|
|
**Warning signs:** Both signers show the same date on their respective date fields. Date fields show the timestamp of the first signer who completed, not the second.
|
||
|
|
|
||
|
|
### Pitfall 4: signaturesWithCoords filter must also be signer-scoped
|
||
|
|
|
||
|
|
**What goes wrong:** Lines 210-229 of the current handler build `signaturesWithCoords` from ALL `client-signature` and `initials` fields. In multi-signer mode, this loop would try to find a submitted signature for Signer B's fields when processing Signer A's POST — and throw `Missing signature for field ${field.id}`.
|
||
|
|
|
||
|
|
**How to avoid:** Before building `signaturesWithCoords`, filter `signableFields` to only fields where `field.signerEmail === tokenRow.signerEmail` (same filter as GET handler D-04). For legacy null-signer, use all client-visible fields (existing behavior).
|
||
|
|
|
||
|
|
**Warning signs:** POST handler returns 500 with "Missing signature for field X" where X is another signer's field.
|
||
|
|
|
||
|
|
### Pitfall 5: NEXT_PUBLIC_BASE_URL is baked at build time
|
||
|
|
|
||
|
|
**What goes wrong:** `send/route.ts` line 35: `const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'`. The `NEXT_PUBLIC_` prefix causes Next.js to inline this value at build time. In Docker production, setting this env var at runtime has no effect — the string `localhost:3000` is already in the compiled bundle.
|
||
|
|
|
||
|
|
**How to avoid:** The send route already has this bug in the legacy path. Phase 15's rewrite should rename to `APP_BASE_URL` (no `NEXT_PUBLIC_` prefix) in the send route. (Full Docker fix is Phase 17, but fixing the variable name here prevents the bug from spreading.)
|
||
|
|
|
||
|
|
**Warning signs:** Signing emails are sent but the link points to `localhost:3000` instead of the production domain.
|
||
|
|
|
||
|
|
### Pitfall 6: signedFilePath non-null during 'Sent' status — code that assumes signedFilePath means 'Signed'
|
||
|
|
|
||
|
|
**What goes wrong:** The accumulate pattern updates `signedFilePath` after each intermediate signer. Any code that reads `doc.signedFilePath` and treats it as "final" (e.g., download routes, dashboard) will see partial PDFs as downloadable before all signers complete.
|
||
|
|
|
||
|
|
**How to avoid:** The download endpoint at `GET /api/documents/[id]/download` must continue to check `doc.status === 'Signed'` before serving the file. The new signer download route `GET /api/sign/download/[token]` also checks status. `signedFilePath` alone is not a completion signal — only `status === 'Signed'` is.
|
||
|
|
|
||
|
|
### Pitfall 7: Agent notification email fetch uses `with: { client: }` relation — requires assignedClientId
|
||
|
|
|
||
|
|
**What goes wrong:** The existing fire-and-forget at lines 267-286 uses `with: { client: { columns: { name: true } } }` which joins on `documents.clientId`. For multi-signer documents, the relevant "client" is a list of signers, not a single `clients` row. The join result may have no `name` if the signer emails don't match a `clients` record.
|
||
|
|
|
||
|
|
**How to avoid:** For the agent notification at completion, pass the signer list (count) rather than a client name. Or use a fallback: `freshDoc?.client?.name ?? 'Multiple signers'`. The agent email already exists and just needs a slight wording change for multi-signer context.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Code Examples
|
||
|
|
|
||
|
|
### POST Sign Handler — New Completion Sequence
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: CONTEXT.md D-07, PITFALLS.md Pitfall 1/2, ARCHITECTURE.md Part 2
|
||
|
|
|
||
|
|
// STEP: After atomic token claim succeeds (existing lines 127-135 unchanged)
|
||
|
|
|
||
|
|
// Fetch tokenRow to get signerEmail for scoped field operations
|
||
|
|
const tokenRow = await db.query.signingTokens.findFirst({
|
||
|
|
where: eq(signingTokens.jti, payload.jti),
|
||
|
|
});
|
||
|
|
const signerEmail = tokenRow?.signerEmail ?? null;
|
||
|
|
|
||
|
|
// STEP: Date stamp — scoped to this signer's date fields (D-09)
|
||
|
|
const dateFields = (doc.signatureFields ?? []).filter((f) => {
|
||
|
|
if (!isClientVisibleField(f)) return false;
|
||
|
|
if (getFieldType(f) !== 'date') return false;
|
||
|
|
if (signerEmail !== null) return f.signerEmail === signerEmail;
|
||
|
|
return true; // legacy: stamp all date fields
|
||
|
|
});
|
||
|
|
|
||
|
|
// STEP: signaturesWithCoords — scoped to this signer's signable fields (Pitfall 4)
|
||
|
|
const signableFields = (doc.signatureFields ?? []).filter((f) => {
|
||
|
|
const t = getFieldType(f);
|
||
|
|
if (t !== 'client-signature' && t !== 'initials') return false;
|
||
|
|
if (signerEmail !== null) return f.signerEmail === signerEmail;
|
||
|
|
return isClientVisibleField(f); // legacy
|
||
|
|
});
|
||
|
|
|
||
|
|
// STEP: Determine working PDF (accumulate — D-10)
|
||
|
|
const freshDoc = await db.query.documents.findFirst({
|
||
|
|
where: eq(documents.id, payload.documentId),
|
||
|
|
columns: { signedFilePath: true, preparedFilePath: true },
|
||
|
|
});
|
||
|
|
const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath!;
|
||
|
|
const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath);
|
||
|
|
|
||
|
|
// Per-signer partial output path
|
||
|
|
const partialRelPath = freshDoc!.preparedFilePath!.replace(
|
||
|
|
/_prepared\.pdf$/,
|
||
|
|
`_partial_${payload.jti}.pdf`
|
||
|
|
);
|
||
|
|
const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath);
|
||
|
|
|
||
|
|
// Path traversal guards for both paths
|
||
|
|
if (!workingAbsPath.startsWith(UPLOADS_DIR) || !partialAbsPath.startsWith(UPLOADS_DIR)) {
|
||
|
|
return NextResponse.json({ error: 'forbidden' }, { status: 403 });
|
||
|
|
}
|
||
|
|
|
||
|
|
// STEP: Embed (date-stamped source → partial output)
|
||
|
|
const inputPath = dateStampedPath !== workingAbsPath ? dateStampedPath : workingAbsPath;
|
||
|
|
await embedSignatureInPdf(inputPath, partialAbsPath, signaturesWithCoords);
|
||
|
|
// NOTE: embedSignatureInPdf returns pdfHash — only store it at completion (final signer)
|
||
|
|
|
||
|
|
// STEP: Update working copy
|
||
|
|
await db.update(documents)
|
||
|
|
.set({ signedFilePath: partialRelPath })
|
||
|
|
.where(eq(documents.id, payload.documentId));
|
||
|
|
|
||
|
|
// STEP: Count remaining unclaimed tokens
|
||
|
|
const remaining = await db
|
||
|
|
.select({ count: sql<number>`count(*)::int` })
|
||
|
|
.from(signingTokens)
|
||
|
|
.where(and(
|
||
|
|
eq(signingTokens.documentId, payload.documentId),
|
||
|
|
isNull(signingTokens.usedAt)
|
||
|
|
));
|
||
|
|
const allDone = remaining[0].count === 0;
|
||
|
|
|
||
|
|
if (!allDone) {
|
||
|
|
await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted',
|
||
|
|
ipAddress: ip, userAgent: ua, metadata: { signerEmail } });
|
||
|
|
return NextResponse.json({ ok: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
// STEP: Race for completion (D-07 step 3)
|
||
|
|
const won = await db
|
||
|
|
.update(documents)
|
||
|
|
.set({ completionTriggeredAt: now })
|
||
|
|
.where(and(
|
||
|
|
eq(documents.id, payload.documentId),
|
||
|
|
isNull(documents.completionTriggeredAt)
|
||
|
|
))
|
||
|
|
.returning({ id: documents.id });
|
||
|
|
|
||
|
|
if (won.length === 0) {
|
||
|
|
// Another handler won — still success for this signer
|
||
|
|
await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted',
|
||
|
|
ipAddress: ip, userAgent: ua, metadata: { signerEmail } });
|
||
|
|
return NextResponse.json({ ok: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
// STEP: Winner — finalize (D-07 step 4)
|
||
|
|
const finalHash = await hashFile(partialAbsPath); // or re-hash via hashFile from embed-signature
|
||
|
|
await db.update(documents)
|
||
|
|
.set({
|
||
|
|
status: 'Signed',
|
||
|
|
signedAt: now,
|
||
|
|
signedFilePath: partialRelPath, // already set, but confirm
|
||
|
|
pdfHash: finalHash,
|
||
|
|
})
|
||
|
|
.where(eq(documents.id, payload.documentId));
|
||
|
|
|
||
|
|
// Fire-and-forget: agent notification + all-signers completion emails (D-12)
|
||
|
|
// ...
|
||
|
|
```
|
||
|
|
|
||
|
|
### GET /api/sign/download/[token] — New Public Download Route
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: CONTEXT.md D-11, token.ts createAgentDownloadToken pattern
|
||
|
|
import { verifySignerDownloadToken } from '@/lib/signing/token';
|
||
|
|
import path from 'node:path';
|
||
|
|
import { readFile } from 'node:fs/promises';
|
||
|
|
|
||
|
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
|
||
|
|
|
||
|
|
export async function GET(
|
||
|
|
_req: NextRequest,
|
||
|
|
{ params }: { params: Promise<{ token: string }> }
|
||
|
|
) {
|
||
|
|
const { token } = await params;
|
||
|
|
|
||
|
|
let documentId: string;
|
||
|
|
try {
|
||
|
|
({ documentId } = await verifySignerDownloadToken(token));
|
||
|
|
} catch {
|
||
|
|
return new Response('Invalid or expired download link', { status: 401 });
|
||
|
|
}
|
||
|
|
|
||
|
|
const doc = await db.query.documents.findFirst({
|
||
|
|
where: eq(documents.id, documentId),
|
||
|
|
columns: { signedFilePath: true, status: true, name: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!doc || doc.status !== 'Signed' || !doc.signedFilePath) {
|
||
|
|
return new Response('Document not yet complete', { status: 404 });
|
||
|
|
}
|
||
|
|
|
||
|
|
const absPath = path.join(UPLOADS_DIR, doc.signedFilePath);
|
||
|
|
if (!absPath.startsWith(UPLOADS_DIR)) {
|
||
|
|
return new Response('Forbidden', { status: 403 });
|
||
|
|
}
|
||
|
|
|
||
|
|
const fileBytes = await readFile(absPath);
|
||
|
|
return new Response(fileBytes, {
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/pdf',
|
||
|
|
'Content-Disposition': `attachment; filename="${doc.name}-signed.pdf"`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### sendSignerCompletionEmail — New Function in signing-mailer.tsx
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Source: signing-mailer.tsx existing pattern; CONTEXT.md D-12
|
||
|
|
export async function sendSignerCompletionEmail(opts: {
|
||
|
|
to: string;
|
||
|
|
signerName?: string;
|
||
|
|
documentName: string;
|
||
|
|
downloadUrl: string;
|
||
|
|
expiresAt: Date;
|
||
|
|
}): Promise<void> {
|
||
|
|
const transporter = createTransporter();
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: '"Teressa Copeland Homes" <teressa@tcopelandhomes.com>',
|
||
|
|
to: opts.to,
|
||
|
|
subject: `Signed copy ready: ${opts.documentName}`,
|
||
|
|
text: [
|
||
|
|
`All parties have signed "${opts.documentName}".`,
|
||
|
|
`Download your copy here (link expires ${opts.expiresAt.toLocaleDateString('en-US')}):`,
|
||
|
|
opts.downloadUrl,
|
||
|
|
].join('\n\n'),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Files Being Changed (from CONTEXT.md canonical refs)
|
||
|
|
|
||
|
|
| File | Change Type | What Changes |
|
||
|
|
|------|-------------|--------------|
|
||
|
|
| `src/lib/signing/token.ts` | Extend | Add optional `signerEmail?` param to `createSigningToken`; add `createSignerDownloadToken` / `verifySignerDownloadToken` |
|
||
|
|
| `src/app/api/documents/[id]/send/route.ts` | Rewrite | Legacy fallback (D-01) + multi-signer token loop (D-02/D-03) |
|
||
|
|
| `src/app/api/sign/[token]/route.ts` | Rewrite | GET: signer-aware field filter (D-04/D-05/D-06); POST: accumulate + completion guard (D-07 through D-09) |
|
||
|
|
| `src/lib/signing/signing-mailer.tsx` | Add function | `sendSignerCompletionEmail` (D-12) |
|
||
|
|
| `src/app/api/sign/download/[token]/route.ts` | New file | Public signer download route (D-11) |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## State of the Art (Phase 14 outputs confirmed in schema.ts)
|
||
|
|
|
||
|
|
The following are confirmed present in the codebase from Phase 14 inspection:
|
||
|
|
|
||
|
|
| Column / Type | Location | Confirmed |
|
||
|
|
|---------------|----------|-----------|
|
||
|
|
| `signingTokens.signerEmail` (nullable TEXT) | `schema.ts` line 140 | YES |
|
||
|
|
| `documents.signers` (nullable JSONB `DocumentSigner[]`) | `schema.ts` line 113 | YES |
|
||
|
|
| `documents.completionTriggeredAt` (nullable TIMESTAMP) | `schema.ts` line 115 | YES |
|
||
|
|
| `DocumentSigner` interface `{ email: string; color: string }` | `schema.ts` line 87-90 | YES |
|
||
|
|
| `getSignerEmail(field, fallback)` helper | `schema.ts` line 49-51 | YES |
|
||
|
|
| `SignatureFieldData.signerEmail?` | `schema.ts` line 21 | YES |
|
||
|
|
|
||
|
|
Note: `DocumentSigner` has `{ email, color }` — NO `tokenJti` or `signedAt` fields. The ARCHITECTURE.md described these fields but they were NOT added in Phase 14 (schema.ts is authoritative). The send route cannot store `tokenJti` back into `doc.signers[]` unless Phase 15 adds an UPDATE after token creation. This is a discretion area — the planner should decide whether to update `doc.signers[n].tokenJti` at send time.
|
||
|
|
|
||
|
|
Also note: `auditEventTypeEnum` in `schema.ts` lines 126-133 still only has the original 6 values (`document_prepared`, `email_sent`, `link_opened`, `document_viewed`, `signature_submitted`, `pdf_hash_computed`). The new audit event types (`signer_email_sent`, `signer_signed`, `document_completed`) from the ARCHITECTURE.md research were NOT added in Phase 14. Phase 15 must either (a) use existing `email_sent` / `signature_submitted` events with `metadata: { signerEmail }` to avoid a schema migration, or (b) include a tiny migration to add the new enum values. Since CONTEXT.md says "No schema migrations" for Phase 15, approach (a) — use existing event types with metadata — is the correct path.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Environment Availability
|
||
|
|
|
||
|
|
Step 2.6: SKIPPED — this phase is purely code changes to existing routes with no new external dependencies. All required tools (Node.js, the DB connection via Neon, SMTP) are already operational.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Validation Architecture
|
||
|
|
|
||
|
|
`workflow.nyquist_validation` is not set in `.planning/config.json` — treating as enabled.
|
||
|
|
|
||
|
|
### Test Framework
|
||
|
|
|
||
|
|
| Property | Value |
|
||
|
|
|----------|-------|
|
||
|
|
| Framework | Jest + ts-jest |
|
||
|
|
| Config file | None found — `jest.config.ts` does not exist at project root; `@types/jest` and `jest` are devDependencies |
|
||
|
|
| Quick run command | `cd teressa-copeland-homes && npx jest --testPathPattern=sign` |
|
||
|
|
| Full suite command | `cd teressa-copeland-homes && npx jest` |
|
||
|
|
|
||
|
|
One test file exists: `src/lib/pdf/__tests__/prepare-document.test.ts` (Y-flip coordinate unit tests, no relation to signing flow).
|
||
|
|
|
||
|
|
### Phase Requirements → Test Map
|
||
|
|
|
||
|
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||
|
|
|--------|----------|-----------|-------------------|-------------|
|
||
|
|
| MSIGN-05 | `doc.signers` drives token creation | unit | `npx jest --testPathPattern=send` | No — Wave 0 |
|
||
|
|
| MSIGN-06 | All tokens created, all emails dispatched in parallel | unit | `npx jest --testPathPattern=send` | No — Wave 0 |
|
||
|
|
| MSIGN-07 | GET /sign/[token] returns only signer-scoped fields | unit | `npx jest --testPathPattern=sign-get` | No — Wave 0 |
|
||
|
|
| MSIGN-10 | Agent notification fires only when all tokens claimed | unit | `npx jest --testPathPattern=sign-post` | No — Wave 0 |
|
||
|
|
| MSIGN-11 | Signer completion emails + download route | unit | `npx jest --testPathPattern=sign-post` | No — Wave 0 |
|
||
|
|
|
||
|
|
### Sampling Rate
|
||
|
|
- **Per task commit:** `cd teressa-copeland-homes && npx jest --testPathPattern=signing`
|
||
|
|
- **Per wave merge:** `cd teressa-copeland-homes && npx jest`
|
||
|
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||
|
|
|
||
|
|
### Wave 0 Gaps
|
||
|
|
- [ ] `src/app/api/documents/[id]/send/__tests__/send-route.test.ts` — covers MSIGN-05, MSIGN-06 (mock `createSigningToken`, `sendSigningRequestEmail`; verify Promise.all dispatch count)
|
||
|
|
- [ ] `src/app/api/sign/[token]/__tests__/sign-get.test.ts` — covers MSIGN-07 (mock DB rows; verify filtered vs unfiltered field arrays)
|
||
|
|
- [ ] `src/app/api/sign/[token]/__tests__/sign-post.test.ts` — covers MSIGN-10, MSIGN-11 (mock atomic claim, count query, completionTriggeredAt race)
|
||
|
|
- [ ] `jest.config.ts` — no jest config file exists at project root; ts-jest is installed but unconfigured
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Open Questions
|
||
|
|
|
||
|
|
1. **Advisory lock vs unique partial paths — which approach for accumulate?**
|
||
|
|
- What we know: ARCHITECTURE.md recommends `pg_advisory_xact_lock`. The Context (D-10) uses `signedFilePath` as a serial working copy, implying the lock is needed to prevent read-modify-write races.
|
||
|
|
- What's unclear: Whether Drizzle's `db.execute(sql`...`)` can run advisory lock SQL without being inside an explicit transaction — `pg_advisory_xact_lock` requires a transaction to be meaningful.
|
||
|
|
- Recommendation: Use unique JTI-keyed output paths for partial PDFs (no lock needed for writes). Use `completionTriggeredAt` guard (already proven) for the completion race. Advisory lock only needed if the final completion handler reads and processes intermediate partials serially — which it doesn't in the D-10 design.
|
||
|
|
|
||
|
|
2. **`DocumentSigner` lacks `tokenJti` — should send route update the signers array?**
|
||
|
|
- What we know: `schema.ts` `DocumentSigner = { email, color }`. ARCHITECTURE.md described `tokenJti` as a field but it was not implemented in Phase 14.
|
||
|
|
- What's unclear: Whether the planner should add a DB update to store `tokenJti` on each signer after token creation (useful for resend functionality later).
|
||
|
|
- Recommendation: Skip for Phase 15. Phase 16 (dashboard per-signer status) can query `signingTokens` by `signerEmail + documentId` to find the token — no denormalization needed. Add as discretion item in PLAN.md notes.
|
||
|
|
|
||
|
|
3. **`APP_BASE_URL` rename — is Phase 15 the right place?**
|
||
|
|
- What we know: `send/route.ts` uses `NEXT_PUBLIC_BASE_URL` which bakes at build time (Pitfall 5). Phase 17 is the Docker phase. The variable is read only in server-side route handlers — it should never have the `NEXT_PUBLIC_` prefix.
|
||
|
|
- What's unclear: Whether to fix this in Phase 15 (touching send/route.ts anyway) or defer to Phase 17.
|
||
|
|
- Recommendation: Fix it in Phase 15 since the route is being rewritten. Change `process.env.NEXT_PUBLIC_BASE_URL` to `process.env.APP_BASE_URL` in send/route.ts and note in Phase 17 that the env var name has already been updated.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Project Constraints (from AGENTS.md / CLAUDE.md)
|
||
|
|
|
||
|
|
`CLAUDE.md` at `teressa-copeland-homes/CLAUDE.md` reads `@AGENTS.md`. `AGENTS.md` states:
|
||
|
|
|
||
|
|
> This is NOT the Next.js you know. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||
|
|
|
||
|
|
**Implication for planning:** Implementation tasks must include a directive to read `node_modules/next/dist/docs/` before writing any new route files. Specifically relevant for the new `GET /api/sign/download/[token]` route and any changes to the App Router route handler signatures.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Sources
|
||
|
|
|
||
|
|
### Primary (HIGH confidence)
|
||
|
|
- Direct codebase inspection: `src/app/api/sign/[token]/route.ts` — exact current implementation of GET and POST handlers
|
||
|
|
- Direct codebase inspection: `src/app/api/documents/[id]/send/route.ts` — exact current implementation
|
||
|
|
- Direct codebase inspection: `src/lib/signing/token.ts` — JWT utility functions, exact signatures
|
||
|
|
- Direct codebase inspection: `src/lib/signing/signing-mailer.tsx` — email utility, `createTransporter` pattern
|
||
|
|
- Direct codebase inspection: `src/lib/signing/embed-signature.ts` — `embedSignatureInPdf` signature and atomic rename pattern
|
||
|
|
- Direct codebase inspection: `src/lib/db/schema.ts` — Phase 14 schema additions confirmed present
|
||
|
|
- `.planning/phases/15-multi-signer-backend/15-CONTEXT.md` — all locked implementation decisions D-01 through D-12
|
||
|
|
|
||
|
|
### Secondary (MEDIUM confidence)
|
||
|
|
- `.planning/research/ARCHITECTURE.md` — multi-signer data flow, advisory lock pattern, completion sequence
|
||
|
|
- `.planning/research/PITFALLS.md` — first-signer-wins bug analysis (line references verified against actual code), race condition pattern
|
||
|
|
|
||
|
|
### Notes
|
||
|
|
- `DocumentSigner` interface discrepancy noted: ARCHITECTURE.md described `tokenJti` and `signedAt` fields; actual `schema.ts` has only `{ email, color }`. Code references in RESEARCH.md use the authoritative schema.ts definition.
|
||
|
|
- `auditEventTypeEnum` discrepancy noted: ARCHITECTURE.md described new enum values; actual schema.ts has only the original 6. Phase 15 uses existing `email_sent`/`signature_submitted` events with `metadata` to avoid requiring a schema migration.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Metadata
|
||
|
|
|
||
|
|
**Confidence breakdown:**
|
||
|
|
- Current code structure: HIGH — all three routes and utilities read directly
|
||
|
|
- Schema completeness (Phase 14 output): HIGH — schema.ts verified line by line
|
||
|
|
- Completion guard pattern: HIGH — identical pattern already exists for token claim
|
||
|
|
- Accumulate PDF strategy: HIGH — `embedSignatureInPdf` verified to accept any input path
|
||
|
|
- signer-download JWT: HIGH — identical to existing `createAgentDownloadToken` pattern
|
||
|
|
- Advisory lock recommendation: MEDIUM — `db.execute(sql`...`)` for raw SQL not verified against this Next.js version; alternative (unique output paths) is safer and equally correct
|
||
|
|
|
||
|
|
**Research date:** 2026-04-03
|
||
|
|
**Valid until:** 2026-05-03 (schema is stable; JWT library unlikely to change)
|