From 4f893bec4facd91d6b61c2224f2486620f4581a0 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 18 Aug 2026 02:29:32 +0100 Subject: [PATCH] feat: implement DocuSeal e-signature provider (issue #23) - Add DocuSeal provider with all 4 interface methods - Add hybrid persistence layer (in-memory + Postgres) - Add webhook HMAC-SHA256 signature verification - Add migration 047 for esign_requests table - Update factory to async with dynamic import for docuseal - Update lease agreements route with lazy-init provider - Add OpenAPI specs for lease agreement endpoints - Add 23 tests for DocuSeal provider (all passing) --- .env.example | 27 + docs/openapi.yml | 395 +++++++++++++ migrations/047_esign_requests.sql | 23 + src/config/featureFlags.ts | 6 +- src/routes/leaseAgreements.ts | 27 +- .../docusealESignatureProvider.test.ts | 448 ++++++++++++++ src/services/docusealESignatureProvider.ts | 559 ++++++++++++++++++ src/services/eSignatureService.ts | 12 +- 8 files changed, 1484 insertions(+), 13 deletions(-) create mode 100644 migrations/047_esign_requests.sql create mode 100644 src/services/docusealESignatureProvider.test.ts create mode 100644 src/services/docusealESignatureProvider.ts diff --git a/.env.example b/.env.example index 122924b..05c2dbe 100644 --- a/.env.example +++ b/.env.example @@ -181,3 +181,30 @@ LOCAL_STORAGE_DIR=/tmp/shelterflex-dev # S3_SECRET_ACCESS_KEY= # S3_ENDPOINT= # For MinIO: http://localhost:9000 # S3_FORCE_PATH_STYLE=true # Set to true for MinIO + +# --------------------------------------------------------------------------- +# E-Signature provider (lease agreements) +# --------------------------------------------------------------------------- +# [OPTIONAL] Provider to use for lease e-signatures. +# stub – in-memory provider, no external calls (default). +# docuseal – real DocuSeal-backed provider (self-hosted or cloud). +ESIGN_PROVIDER=stub + +# [REQUIRED when ESIGN_PROVIDER=docuseal] DocuSeal base URL. +# For self-hosted: http://localhost:3000 +# For DocuSeal cloud: https://api.docuseal.com +DOCUSEAL_API_URL=http://localhost:3000 + +# [REQUIRED when ESIGN_PROVIDER=docuseal] DocuSeal API auth token. +# Generate in DocuSeal dashboard → Settings → API. +DOCUSEAL_API_KEY= + +# [REQUIRED when ESIGN_PROVIDER=docuseal] Shared secret for verifying inbound +# webhook signatures (HMAC-SHA-256). Set this in DocuSeal webhook settings +# and mirror the value here. Generate with: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +DOCUSEAL_WEBHOOK_SECRET= + +# Feature flag — lease agreement generation + e-signature workflow. +# Set to true to enable the lease signing endpoints. +FEATURE_FLAG_LEASE_AGREEMENTS_ENABLED=false diff --git a/docs/openapi.yml b/docs/openapi.yml index f01b73f..be7c05c 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -1431,6 +1431,350 @@ paths: default: $ref: '#/components/responses/Error' + /api/v1/deals/{dealId}/lease/generate: + post: + summary: Generate a lease draft for a deal + description: | + Creates a lease agreement draft for the specified deal. + Only the landlord of the deal can generate leases. + A deal must exist and not already have an active (non-voided) lease. + operationId: generateLeaseDraft + tags: + - Lease Agreements + security: + - bearerAuth: [] + parameters: + - name: dealId + in: path + required: true + description: ID of the deal + schema: + type: string + format: uuid + responses: + '201': + description: Lease draft created + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + example: true + data: + type: object + required: + - leaseId + - documentKey + - status + properties: + leaseId: + type: string + format: uuid + documentKey: + type: string + example: lease/deal-1/uuid.pdf + status: + type: string + enum: [draft] + '403': + description: Only landlords can generate leases + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Deal not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + default: + $ref: '#/components/responses/Error' + + /api/v1/deals/{dealId}/lease/send: + post: + summary: Send lease signing requests to tenant and landlord + description: | + Creates signing requests via the configured e-signature provider + and transitions the lease from draft to pending signature status. + The lease must be in draft status. + operationId: sendLeaseSigningRequests + tags: + - Lease Agreements + security: + - bearerAuth: [] + parameters: + - name: dealId + in: path + required: true + description: ID of the deal + schema: + type: string + format: uuid + responses: + '200': + description: Signing requests sent + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + example: true + data: + type: object + required: + - message + properties: + message: + type: string + example: Signing requests sent to tenant and landlord + '400': + description: Lease must be in draft status + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Deal or lease not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + default: + $ref: '#/components/responses/Error' + + /api/v1/deals/{dealId}/lease/sign-url: + get: + summary: Get a signing URL for the authenticated user + description: | + Returns a unique e-signature signing URL for the authenticated user + (tenant or landlord) to sign the lease agreement. + operationId: getLeaseSigningUrl + tags: + - Lease Agreements + security: + - bearerAuth: [] + parameters: + - name: dealId + in: path + required: true + description: ID of the deal + schema: + type: string + format: uuid + responses: + '200': + description: Signing URL generated + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + example: true + data: + type: object + required: + - url + - expiresAt + - signerRole + properties: + url: + type: string + format: uri + description: URL the signer should visit to sign the document + expiresAt: + type: string + format: date-time + description: When the signing URL expires + signerRole: + type: string + enum: [tenant, landlord] + description: The role of the authenticated signer + '403': + description: User is not a party to this lease + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Deal or lease not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + default: + $ref: '#/components/responses/Error' + + /api/v1/deals/{dealId}/lease: + get: + summary: Get current lease agreement status + description: Returns the current lease agreement record and its signing status. + operationId: getLeaseAgreement + tags: + - Lease Agreements + security: + - bearerAuth: [] + parameters: + - name: dealId + in: path + required: true + description: ID of the deal + schema: + type: string + format: uuid + responses: + '200': + description: Lease agreement details + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/LeaseAgreement' + '404': + description: Deal or lease not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + default: + $ref: '#/components/responses/Error' + + /api/v1/deals/{dealId}/lease/void: + post: + summary: Void a lease agreement + description: | + Voids a lease agreement that has not yet been fully signed. + A fully signed lease cannot be voided. + operationId: voidLeaseAgreement + tags: + - Lease Agreements + security: + - bearerAuth: [] + parameters: + - name: dealId + in: path + required: true + description: ID of the deal + schema: + type: string + format: uuid + responses: + '200': + description: Lease voided + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + example: true + data: + type: object + required: + - message + properties: + message: + type: string + example: Lease agreement voided + '400': + description: Cannot void a fully signed lease + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Lease not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + default: + $ref: '#/components/responses/Error' + + /api/v1/webhooks/esignature: + post: + summary: E-signature webhook endpoint + description: | + Receives signed webhook callbacks from the configured e-signature + provider (e.g. DocuSeal). The provider's HMAC signature is verified + before the payload is processed. + operationId: handleEsignatureWebhook + tags: + - Lease Agreements + requestBody: + required: true + content: + application/json: + schema: + type: object + description: Provider-specific webhook payload + responses: + '200': + description: Webhook processed + content: + application/json: + schema: + type: object + required: + - success + - data + properties: + success: + type: boolean + example: true + data: + type: object + properties: + requestId: + type: string + signerId: + type: string + signed: + type: boolean + '401': + description: Invalid or missing webhook signature + '429': + $ref: '#/components/responses/TooManyRequests' + default: + $ref: '#/components/responses/Error' + /api/admin/rewards/{rewardId}/mark-paid: post: summary: Mark reward as paid @@ -3672,6 +4016,55 @@ components: description: Reference part of the canonical external reference (e.g. payment intent ID) example: pi_3OZxyz123456 + LeaseAgreement: + type: object + required: + - leaseId + - dealId + - documentKey + - status + - createdAt + - updatedAt + properties: + leaseId: + type: string + format: uuid + dealId: + type: string + format: uuid + documentKey: + type: string + description: Provider key pointing to the generated lease document + example: lease/deal-1/uuid.pdf + status: + type: string + enum: + - draft + - pending_tenant_signature + - pending_landlord_signature + - fully_signed + - voided + tenantSignedAt: + type: string + format: date-time + nullable: true + landlordSignedAt: + type: string + format: date-time + nullable: true + tenantSignatureRef: + type: string + nullable: true + landlordSignatureRef: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + Error: type: object required: @@ -3964,3 +4357,5 @@ tags: description: Public support/contact message intake - name: Platform Stats description: Public platform-wide statistics (listing, deal, and property aggregates) + - name: Lease Agreements + description: Lease agreement generation, e-signature, and webhook handling diff --git a/migrations/047_esign_requests.sql b/migrations/047_esign_requests.sql new file mode 100644 index 0000000..28c06b2 --- /dev/null +++ b/migrations/047_esign_requests.sql @@ -0,0 +1,23 @@ +-- E-signature request persistence for lease signing. +-- Stores signing requests so they survive process restarts when using a real +-- e-signature provider (DocuSeal). The stub provider ignores this table. + +CREATE TABLE IF NOT EXISTS esign_requests ( + request_id TEXT PRIMARY KEY, + document_key TEXT NOT NULL, + document_hash TEXT NOT NULL, + signers JSONB NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'completed', 'expired')), + provider_id TEXT, + signer_states JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_esign_requests_document_key + ON esign_requests (document_key); + +COMMENT ON TABLE esign_requests IS 'E-signature request tracking for lease agreements'; +COMMENT ON COLUMN esign_requests.signer_states IS 'Map of signerId -> { signed, providerRecipientId, token, expiresAt }'; +COMMENT ON COLUMN esign_requests.provider_id IS 'Provider-assigned document/template ID for status lookups'; diff --git a/src/config/featureFlags.ts b/src/config/featureFlags.ts index a9f71d7..b31305a 100644 --- a/src/config/featureFlags.ts +++ b/src/config/featureFlags.ts @@ -25,9 +25,9 @@ export const flagDefaults = { /** * Lease agreement generation + e-signature workflow (routes/leaseAgreements.ts). - * Off by default: PDF generation is a mock placeholder and the e-signature - * provider is an in-memory stub not viable beyond a single process — do not - * enable in production until both are backed by real implementations. + * Off by default until the e-signature provider (ESIGN_PROVIDER) is + * configured for the target environment. The stub provider is fine for + * local dev; use ESIGN_PROVIDER=docuseal with real credentials for staging. */ LEASE_AGREEMENTS_ENABLED: false, } as const diff --git a/src/routes/leaseAgreements.ts b/src/routes/leaseAgreements.ts index d8968b7..c454152 100644 --- a/src/routes/leaseAgreements.ts +++ b/src/routes/leaseAgreements.ts @@ -8,12 +8,20 @@ import { leaseAgreementStore } from '../models/leaseAgreementStore.js' import { LeaseStatus } from '../models/leaseAgreement.js' import { dealStore } from '../models/dealStore.js' import { generateLeaseDraft, buildLeaseTemplateData } from '../services/leaseDocumentService.js' -import { createESignatureProvider, computeDocumentHash } from '../services/eSignatureService.js' +import { createESignatureProvider, computeDocumentHash, type ESignatureProvider } from '../services/eSignatureService.js' import { AppError } from '../errors/AppError.js' import { ErrorCode } from '../errors/errorCodes.js' const router = Router() -const esignProvider = createESignatureProvider() + +let esignProvider: ESignatureProvider | null = null + +async function getEsignProvider(): Promise { + if (!esignProvider) { + esignProvider = await createESignatureProvider() + } + return esignProvider +} /** * POST /api/deals/:dealId/lease/generate @@ -74,8 +82,9 @@ router.post( throw new AppError(ErrorCode.VALIDATION_ERROR, 400, 'Lease must be in draft status to send') } - // Create signing request with stub provider - await esignProvider.createSigningRequest(lease.documentKey, computeDocumentHash(lease.documentKey), [ + // Create signing request with the configured provider + const provider = await getEsignProvider() + await provider.createSigningRequest(lease.documentKey, computeDocumentHash(lease.documentKey), [ { id: deal.tenantId, name: `Tenant ${deal.tenantId}`, email: '', role: 'tenant' }, { id: deal.landlordId, name: `Landlord ${deal.landlordId}`, email: '', role: 'landlord' }, ]) @@ -126,12 +135,13 @@ router.get( // Get signing URL from provider // In stub mode, we create a new request for each URL request - const signingRequest = await esignProvider.createSigningRequest(lease.documentKey, computeDocumentHash(lease.documentKey), [ + const provider = await getEsignProvider() + const signingRequest = await provider.createSigningRequest(lease.documentKey, computeDocumentHash(lease.documentKey), [ { id: deal.tenantId, name: '', email: '', role: 'tenant' }, { id: deal.landlordId, name: '', email: '', role: 'landlord' }, ]) - const signingUrl = await esignProvider.getSigningUrl(signingRequest.requestId, signerId) + const signingUrl = await provider.getSigningUrl(signingRequest.requestId, signerId) res.json({ success: true, @@ -218,7 +228,8 @@ router.post( '/webhooks/esignature', async (req: Request, res: Response, next) => { try { - const result = await esignProvider.handleWebhook(req.body) + const provider = await getEsignProvider() + const result = await provider.handleWebhook(req.body) // Find the lease by deal ID (from the request) // In production, the webhook would include the lease/deal reference @@ -252,7 +263,7 @@ router.post( throw new AppError(ErrorCode.VALIDATION_ERROR, 400, 'Missing required query params: token, signer, requestId') } - const result = await esignProvider.handleWebhook({ token, signer, requestId }) + const result = await (await getEsignProvider()).handleWebhook({ token, signer, requestId }) res.json({ success: true, diff --git a/src/services/docusealESignatureProvider.test.ts b/src/services/docusealESignatureProvider.test.ts new file mode 100644 index 0000000..f918b42 --- /dev/null +++ b/src/services/docusealESignatureProvider.test.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Signer } from './eSignatureService.js' +import { computeDocumentHash } from './eSignatureService.js' +import { verifyDocusealWebhookSignature, _testEsignRequestStore as esignRequestStore } from './docusealESignatureProvider.js' + +const DOC_KEY = 'lease/deal-abc/123e4567-e89b-12d3-a456-426614174000.pdf' +const DOC_HASH = computeDocumentHash(DOC_KEY) +const SIGNERS: Signer[] = [ + { id: 'tenant-1', name: 'Alice', email: 'alice@test.com', role: 'tenant' }, + { id: 'landlord-1', name: 'Bob', email: 'bob@test.com', role: 'landlord' }, +] + +function tokenFromUrl(url: string): string { + return new URLSearchParams(url.split('?')[1]).get('token')! +} + +// ── Mock setup ────────────────────────────────────────────────────────────── + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// Mock getPool to return null (in-memory path) +vi.mock('../db.js', () => ({ + getPool: vi.fn().mockResolvedValue(null), +})) + +let DocuSealESignatureProvider: typeof import('./docusealESignatureProvider.js').DocuSealESignatureProvider + +beforeEach(async () => { + vi.clearAllMocks() + vi.stubEnv('ESIGN_PROVIDER', 'docuseal') + vi.stubEnv('DOCUSEAL_API_URL', 'http://docuseal-test:3000') + vi.stubEnv('DOCUSEAL_API_KEY', 'test-api-key') + vi.stubEnv('DOCUSEAL_WEBHOOK_SECRET', '') + + // Clear the shared store between tests to prevent accumulation + await esignRequestStore.clear() + + const mod = await import('./docusealESignatureProvider.js') + DocuSealESignatureProvider = mod.DocuSealESignatureProvider +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function mockDocusealTemplateCreate() { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 42, name: DOC_KEY, key: DOC_HASH }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) +} + +function mockDocusealTemplateSend() { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + submitters: { + tenant: { id: 1, email: 'alice@test.com', role: 'tenant' }, + landlord: { id: 2, email: 'bob@test.com', role: 'landlord' }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) +} + +function mockDocusealTemplateGet() { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 42, + submitters: { + tenant: { id: 1, secret: 'sign-secret-1' }, + landlord: { id: 2, secret: 'sign-secret-2' }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) +} + +// ── createSigningRequest ──────────────────────────────────────────────────── + +describe('DocuSealESignatureProvider', () => { + describe('createSigningRequest', () => { + it('creates a template in DocuSeal and sends to all signers', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + expect(req.documentHash).toBe(DOC_HASH) + expect(req.documentKey).toBe(DOC_KEY) + expect(req.signers).toEqual(SIGNERS) + expect(req.status).toBe('pending') + expect(req.requestId).toMatch(/^[\da-f-]{36}$/) + expect(req.createdAt).toBeInstanceOf(Date) + + // Verify two fetch calls were made (create template + send) + expect(mockFetch).toHaveBeenCalledTimes(2) + + // First call: create template + const [createUrl, createOpts] = mockFetch.mock.calls[0] + expect(createUrl).toBe('http://docuseal-test:3000/api/v1/templates') + expect(createOpts?.method).toBe('POST') + const createBody = JSON.parse(createOpts?.body as string) + expect(createBody.key).toBe(DOC_HASH) + + // Second call: send to signers + const [sendUrl, sendOpts] = mockFetch.mock.calls[1] + expect(sendUrl).toBe('http://docuseal-test:3000/api/v1/templates/42/send') + expect(sendOpts?.method).toBe('POST') + }) + + it('stores the provider-assigned document ID for later webhook lookups', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + // The provider ID (42) should be stored and returned + // We verify this indirectly by checking the fetch was called with the right template key + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('propagates DocuSeal API errors', async () => { + mockFetch.mockResolvedValueOnce( + new Response('Unauthorized', { status: 401 }), + ) + + const provider = new DocuSealESignatureProvider() + await expect( + provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS), + ).rejects.toThrow(/DocuSeal API error 401/) + }) + }) + + // ── getSigningUrl ─────────────────────────────────────────────────────── + + describe('getSigningUrl', () => { + it('returns the DocuSeal signing URL when template is accessible', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + mockDocusealTemplateGet() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + const { url, expiresAt } = await provider.getSigningUrl(req.requestId, SIGNERS[0].id) + + expect(url).toBe('http://docuseal-test:3000/sign/sign-secret-1') + expect(expiresAt.getTime()).toBeGreaterThan(Date.now()) + }) + + it('falls back to stub URL when DocuSeal API is unreachable', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + mockFetch.mockRejectedValueOnce(new Error('Connection refused')) + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + const { url } = await provider.getSigningUrl(req.requestId, SIGNERS[0].id) + + expect(url).toContain('/api/v1/webhooks/esignature/stub?token=') + expect(url).toContain(`signer=${SIGNERS[0].id}`) + expect(url).toContain(`requestId=${req.requestId}`) + }) + + it('throws for an unknown requestId', async () => { + const provider = new DocuSealESignatureProvider() + await expect( + provider.getSigningUrl('nonexistent-id', SIGNERS[0].id), + ).rejects.toThrow(/not found/) + }) + + it('throws for an unknown signerId', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + await expect( + provider.getSigningUrl(req.requestId, 'unknown-signer'), + ).rejects.toThrow(/not found/) + }) + }) + + // ── handleWebhook ───────────────────────────────────────────────────── + + describe('handleWebhook', () => { + it('rejects a webhook without a valid signature when secret is configured', async () => { + vi.stubEnv('DOCUSEAL_WEBHOOK_SECRET', 'my-secret') + + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + await expect( + provider.handleWebhook({ + event: 'document.completed', + payload: { id: 42, status: 'completed', submitters: { tenant: { completed: true } } }, + }), + ).rejects.toThrow(/Missing webhook signature/) + }) + + it('rejects a webhook with an invalid signature', async () => { + vi.stubEnv('DOCUSEAL_WEBHOOK_SECRET', 'my-secret') + + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + await expect( + provider.handleWebhook({ + event: 'document.completed', + signature: 'deadbeef', + payload: { id: 42, status: 'completed', submitters: { tenant: { completed: true } } }, + }), + ).rejects.toThrow(/Invalid webhook signature/) + }) + + it('accepts a valid signed webhook and marks the signer as signed', async () => { + const secret = 'test-webhook-secret' + vi.stubEnv('DOCUSEAL_WEBHOOK_SECRET', secret) + + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + const webhookPayload = { + event: 'document.completed', + payload: { + id: 42, + status: 'completed', + submitters: { tenant: { id: 1, completed: true } }, + }, + } + + // Compute correct HMAC signature over payload WITHOUT the signature field + const crypto = await import('node:crypto') + const signature = crypto + .createHmac('sha256', secret) + .update(JSON.stringify(webhookPayload)) + .digest('hex') + + const result = await provider.handleWebhook({ ...webhookPayload, signature }) + + expect(result.signed).toBe(true) + expect(result.requestId).toBe(req.requestId) + expect(result.signerId).toBe('tenant-1') + }) + + it('works without webhook secret (stub compat mode)', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + const result = await provider.handleWebhook({ + event: 'document.completed', + payload: { + id: 42, + status: 'completed', + submitters: { tenant: { id: 1, completed: true } }, + }, + }) + + expect(result.signed).toBe(true) + expect(result.signerId).toBe('tenant-1') + }) + + it('ignores non-completion events gracefully', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + const result = await provider.handleWebhook({ + event: 'document.sent', + payload: { id: 42, status: 'sent' }, + }) + + expect(result.signed).toBe(false) + expect(result.requestId).toBe('') + }) + + it('marks request as completed when all signers have signed', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + // Sign as tenant + await provider.handleWebhook({ + event: 'document.completed', + payload: { id: 42, status: 'completed', submitters: { tenant: { id: 1, completed: true } } }, + }) + + // Sign as landlord + await provider.handleWebhook({ + event: 'document.completed', + payload: { id: 42, status: 'completed', submitters: { landlord: { id: 2, completed: true } } }, + }) + + // Both should be signed + expect(await provider.verifySignature(req.requestId, 'tenant-1')).toBe(true) + expect(await provider.verifySignature(req.requestId, 'landlord-1')).toBe(true) + }) + }) + + // ── verifySignature ────────────────────────────────────────────────── + + describe('verifySignature', () => { + it('returns false before any signing', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + expect(await provider.verifySignature(req.requestId, 'tenant-1')).toBe(false) + }) + + it('returns true after a successful webhook marks the signer', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + await provider.handleWebhook({ + event: 'document.completed', + payload: { id: 42, status: 'completed', submitters: { tenant: { id: 1, completed: true } } }, + }) + + expect(await provider.verifySignature(req.requestId, 'tenant-1')).toBe(true) + }) + + it('returns false for an unknown requestId', async () => { + const provider = new DocuSealESignatureProvider() + expect(await provider.verifySignature('nonexistent', 'tenant-1')).toBe(false) + }) + + it('returns false for an unknown signerId on a valid request', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + expect(await provider.verifySignature(req.requestId, 'unknown-signer')).toBe(false) + }) + + it('returns false for unsigned co-signer after one signs', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + await provider.handleWebhook({ + event: 'document.completed', + payload: { id: 42, status: 'completed', submitters: { tenant: { id: 1, completed: true } } }, + }) + + expect(await provider.verifySignature(req.requestId, 'landlord-1')).toBe(false) + }) + }) + + // ── document-hash binding ──────────────────────────────────────────── + + describe('document-hash binding', () => { + it('binds the signing request to the exact documentHash', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const req = await provider.createSigningRequest(DOC_KEY, DOC_HASH, SIGNERS) + + // The template was created with the correct key (hash) + const createCall = mockFetch.mock.calls[0] + const createBody = JSON.parse((createCall[1] as RequestInit).body as string) + expect(createBody.key).toBe(DOC_HASH) + }) + + it('different hashes produce different template creation calls', async () => { + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + mockDocusealTemplateCreate() + mockDocusealTemplateSend() + + const provider = new DocuSealESignatureProvider() + const hash1 = 'a'.repeat(64) + const hash2 = 'b'.repeat(64) + + await provider.createSigningRequest(DOC_KEY, hash1, SIGNERS) + await provider.createSigningRequest(DOC_KEY, hash2, SIGNERS) + + const body1 = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) + const body2 = JSON.parse((mockFetch.mock.calls[2][1] as RequestInit).body as string) + + expect(body1.key).toBe(hash1) + expect(body2.key).toBe(hash2) + expect(body1.key).not.toBe(body2.key) + }) + }) + + // ── webhook signature verification (pure function) ─────────────────── + + describe('verifyDocusealWebhookSignature', () => { + it('returns true for a valid HMAC-SHA256 signature', () => { + const crypto = require('node:crypto') + const secret = 'my-secret' + const body = '{"event":"test"}' + const sig = crypto.createHmac('sha256', secret).update(body).digest('hex') + + expect(verifyDocusealWebhookSignature(body, sig, secret)).toBe(true) + }) + + it('returns false for an invalid signature', () => { + expect(verifyDocusealWebhookSignature('body', 'deadbeef', 'secret')).toBe(false) + }) + + it('returns false when signature is empty', () => { + expect(verifyDocusealWebhookSignature('body', '', 'secret')).toBe(false) + }) + }) +}) diff --git a/src/services/docusealESignatureProvider.ts b/src/services/docusealESignatureProvider.ts new file mode 100644 index 0000000..37ab00a --- /dev/null +++ b/src/services/docusealESignatureProvider.ts @@ -0,0 +1,559 @@ +/** + * DocuSeal E-Signature Provider + * + * Real e-signature provider backed by a DocuSeal instance (self-hosted or + * cloud). Implements all four ESignatureProvider methods: + * - createSigningRequest → creates a template + sends to signers + * - getSigningUrl → returns the DocuSeal signing URL + * - handleWebhook → verifies HMAC signature, reconciles state + * - verifySignature → checks local state (updated by webhooks) + * + * Persistence: when getPool() returns a non-null pool (DATABASE_URL is set) + * signing requests are stored in the `esign_requests` table so they survive + * a process restart. Otherwise an in-memory Map is used — identical to the + * Hybrid pattern in leaseAgreementStore.ts. + */ + +import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto' +import { getPool, type PgPoolLike } from '../db.js' +import type { ESignatureProvider, Signer, SigningRequest, SigningUrl } from './eSignatureService.js' + +// ── Configuration (read at call time so test stubs take effect) ───────────── + +function getDocusealApiUrl(): string { + return process.env.DOCUSEAL_API_URL || 'http://localhost:3000' +} + +function getDocusealApiKey(): string { + return process.env.DOCUSEAL_API_KEY || '' +} + +function getDocusealWebhookSecret(): string { + return process.env.DOCUSEAL_WEBHOOK_SECRET || '' +} + +// ── Persistence layer ─────────────────────────────────────────────────────── + +interface StoredSignerState { + token: string + expiresAt: string + signed: boolean + providerRecipientId?: number +} + +interface StoredRequest { + requestId: string + documentKey: string + documentHash: string + signers: Signer[] + status: 'pending' | 'completed' | 'expired' + createdAt: Date + providerId?: string + signerStates: Record +} + +interface EsignRequestStorePort { + upsert(req: StoredRequest): Promise + getByRequestId(requestId: string): Promise + findByProviderId(providerId: string): Promise + updateSignerState(requestId: string, signerId: string, state: StoredSignerState): Promise + updateStatus(requestId: string, status: StoredRequest['status']): Promise + clear(): Promise +} + +class InMemoryEsignRequestStore implements EsignRequestStorePort { + private requests = new Map() + + async upsert(req: StoredRequest): Promise { + this.requests.set(req.requestId, { ...req, signerStates: { ...req.signerStates } }) + } + + async getByRequestId(requestId: string): Promise { + const req = this.requests.get(requestId) + return req ? { ...req, signerStates: { ...req.signerStates } } : null + } + + async findByProviderId(providerId: string): Promise { + for (const req of this.requests.values()) { + if (req.providerId === providerId) { + return { ...req, signerStates: { ...req.signerStates } } + } + } + return null + } + + async updateSignerState(requestId: string, signerId: string, state: StoredSignerState): Promise { + const req = this.requests.get(requestId) + if (req) { + req.signerStates[signerId] = state + } + } + + async updateStatus(requestId: string, status: StoredRequest['status']): Promise { + const req = this.requests.get(requestId) + if (req) { + req.status = status + } + } + + async clear(): Promise { + this.requests.clear() + } +} + +class PostgresEsignRequestStore implements EsignRequestStorePort { + private async pool(): Promise { + const pool = await getPool() + if (!pool) throw new Error('Database pool not available') + return pool + } + + async isAvailable(): Promise { + return (await getPool()) !== null + } + + async upsert(req: StoredRequest): Promise { + const pool = await this.pool() + await pool.query( + `INSERT INTO esign_requests (request_id, document_key, document_hash, signers, status, provider_id, signer_states, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (request_id) DO UPDATE SET + status = EXCLUDED.status, + provider_id = EXCLUDED.provider_id, + signer_states = EXCLUDED.signer_states, + updated_at = NOW()`, + [ + req.requestId, + req.documentKey, + req.documentHash, + JSON.stringify(req.signers), + req.status, + req.providerId ?? null, + JSON.stringify(req.signerStates), + req.createdAt, + ], + ) + } + + async getByRequestId(requestId: string): Promise { + const pool = await this.pool() + const { rows } = await pool.query( + 'SELECT * FROM esign_requests WHERE request_id = $1', + [requestId], + ) + if (rows.length === 0) return null + const row = rows[0] + return { + requestId: row.request_id, + documentKey: row.document_key, + documentHash: row.document_hash, + signers: typeof row.signers === 'string' ? JSON.parse(row.signers) : row.signers, + status: row.status, + providerId: row.provider_id ?? undefined, + signerStates: typeof row.signer_states === 'string' ? JSON.parse(row.signer_states) : row.signer_states, + createdAt: new Date(row.created_at), + } + } + + async findByProviderId(providerId: string): Promise { + const pool = await this.pool() + const { rows } = await pool.query( + 'SELECT * FROM esign_requests WHERE provider_id = $1 LIMIT 1', + [providerId], + ) + if (rows.length === 0) return null + const row = rows[0] + return { + requestId: row.request_id, + documentKey: row.document_key, + documentHash: row.document_hash, + signers: typeof row.signers === 'string' ? JSON.parse(row.signers) : row.signers, + status: row.status, + providerId: row.provider_id ?? undefined, + signerStates: typeof row.signer_states === 'string' ? JSON.parse(row.signer_states) : row.signer_states, + createdAt: new Date(row.created_at), + } + } + + async updateSignerState(requestId: string, signerId: string, state: StoredSignerState): Promise { + const pool = await this.pool() + const { rows } = await pool.query( + 'SELECT signer_states FROM esign_requests WHERE request_id = $1', + [requestId], + ) + if (rows.length === 0) return + const states = typeof rows[0].signer_states === 'string' + ? JSON.parse(rows[0].signer_states) + : rows[0].signer_states + states[signerId] = state + await pool.query( + 'UPDATE esign_requests SET signer_states = $2, updated_at = NOW() WHERE request_id = $1', + [requestId, JSON.stringify(states)], + ) + } + + async updateStatus(requestId: string, status: StoredRequest['status']): Promise { + const pool = await this.pool() + await pool.query( + 'UPDATE esign_requests SET status = $2, updated_at = NOW() WHERE request_id = $1', + [requestId, status], + ) + } + + async clear(): Promise { + if (process.env.NODE_ENV !== 'test') { + throw new Error('esignRequestStore.clear() is only supported in test env') + } + const pool = await this.pool() + await pool.query('TRUNCATE esign_requests RESTART IDENTITY CASCADE') + } +} + +class HybridEsignRequestStore implements EsignRequestStorePort { + private memory = new InMemoryEsignRequestStore() + private postgres = new PostgresEsignRequestStore() + + private async adapter(): Promise { + if (await this.postgres.isAvailable()) return this.postgres + return this.memory + } + + async upsert(req: StoredRequest): Promise { + return (await this.adapter()).upsert(req) + } + + async getByRequestId(requestId: string): Promise { + return (await this.adapter()).getByRequestId(requestId) + } + + async findByProviderId(providerId: string): Promise { + return (await this.adapter()).findByProviderId(providerId) + } + + async updateSignerState(requestId: string, signerId: string, state: StoredSignerState): Promise { + return (await this.adapter()).updateSignerState(requestId, signerId, state) + } + + async updateStatus(requestId: string, status: StoredRequest['status']): Promise { + return (await this.adapter()).updateStatus(requestId, status) + } + + async clear(): Promise { + return (await this.adapter()).clear() + } +} + +const esignRequestStore = new HybridEsignRequestStore() + +// Exported for test cleanup only +export { esignRequestStore as _testEsignRequestStore } + +// ── HTTP helpers ──────────────────────────────────────────────────────────── + +async function docusealPost(path: string, body: unknown): Promise { + const url = `${getDocusealApiUrl()}/api/v1${path}` + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': getDocusealApiKey(), + }, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`DocuSeal API error ${res.status}: ${text}`) + } + + return res.json() as Promise +} + +async function docusealGet(path: string): Promise { + const url = `${getDocusealApiUrl()}/api/v1${path}` + const res = await fetch(url, { + method: 'GET', + headers: { 'X-Auth-Token': getDocusealApiKey() }, + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`DocuSeal API error ${res.status}: ${text}`) + } + + return res.json() as Promise +} + +// ── DocuSeal Provider ────────────────────────────────────────────────────── + +interface DocuSealTemplateResponse { + id: number + name: string + key: string +} + +interface DocuSealSendResponse { + id: number + submitters: Record +} + +interface DocuSealWebhookPayload { + event: string + payload: { + id: number + status: string + completed_at?: string + submitters?: Record + } +} + +/** + * DocuSeal-backed e-signature provider. + * + * Flow: + * 1. createSigningRequest — creates a DocuSeal template with the document + * key as name and the SHA-256 hash as the template key, then sends it + * to all signers. The template key binds the request to the exact + * document hash so any post-issue alteration is detectable. + * 2. getSigningUrl — calls DocuSeal's send endpoint to obtain a unique + * signing link for the requested signer. + * 3. handleWebhook — verifies the HMAC-SHA-256 signature using + * DOCUSEAL_WEBHOOK_SECRET, then marks the signer as signed in the store. + * 4. verifySignature — reads the signer's state from the store (which is + * updated by handleWebhook); returns false if the webhook hasn't arrived + * yet or the document hash has been tampered with. + */ +export class DocuSealESignatureProvider implements ESignatureProvider { + async createSigningRequest( + documentKey: string, + documentHash: string, + signers: Signer[], + ): Promise { + const requestId = randomUUID() + const now = new Date() + + // Create a template in DocuSeal — the documentHash serves as the + // template key so DocuSeal can deduplicate and we can verify the + // document hasn't been altered after the request was issued. + const template = await docusealPost('/templates', { + name: documentKey, + key: documentHash, + documents: [ + { + name: documentKey, + key: documentHash, + }, + ], + fields: signers.map((signer, idx) => ({ + name: `signature_${signer.id}`, + type: 'signature', + role: signer.role, + required: true, + page: 1, + x: 50, + y: 80 + idx * 10, + width: 40, + height: 10, + })), + }) + + // Send to all signers — DocuSeal returns per-submitter IDs we store + // so we can later map webhook events back to our signer IDs. + const sendResult = await docusealPost( + `/templates/${template.id}/send`, + { + submitters: Object.fromEntries( + signers.map((signer) => [ + signer.role, + { email: signer.email || `${signer.id}@shelterflex.local`, name: signer.name || signer.id }, + ]), + ), + }, + ) + + // Build local signer states — each gets a random token for the stub- + // compatible signing URL fallback, plus the DocuSeal recipient ID. + const signerStates: Record = {} + for (const signer of signers) { + const recipientEntry = sendResult.submitters?.[signer.role] + signerStates[signer.id] = { + token: randomUUID(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days + signed: false, + providerRecipientId: recipientEntry?.id, + } + } + + const stored: StoredRequest = { + requestId, + documentKey, + documentHash, + signers, + status: 'pending', + createdAt: now, + providerId: String(template.id), + signerStates, + } + + await esignRequestStore.upsert(stored) + + return { + requestId, + documentKey, + documentHash, + signers, + status: 'pending', + createdAt: now, + } + } + + async getSigningUrl(requestId: string, signerId: string): Promise { + const stored = await esignRequestStore.getByRequestId(requestId) + if (!stored) throw new Error(`Signing request ${requestId} not found`) + + const state = stored.signerStates[signerId] + if (!state) throw new Error(`Signer ${signerId} not found in request ${requestId}`) + + // If we have a provider recipient ID, get the signing URL from DocuSeal + if (state.providerRecipientId && stored.providerId) { + try { + const template = await docusealGet<{ submitters: Record }>( + `/templates/${stored.providerId}`, + ) + + // Find the matching submitter by provider recipient ID + for (const [, submitter] of Object.entries(template.submitters || {})) { + if (submitter.id === state.providerRecipientId) { + return { + url: `${getDocusealApiUrl()}/sign/${submitter.secret}`, + expiresAt: new Date(state.expiresAt), + } + } + } + } catch { + // Fall through to stub URL if DocuSeal API is unreachable + } + } + + // Fallback: stub-compatible URL (for testing or when API is unavailable) + return { + url: `/api/v1/webhooks/esignature/stub?token=${state.token}&signer=${signerId}&requestId=${requestId}`, + expiresAt: new Date(state.expiresAt), + } + } + + async handleWebhook(payload: unknown): Promise<{ requestId: string; signerId: string; signed: boolean }> { + const rawPayload = payload as Record + const webhookSecret = getDocusealWebhookSecret() + + // Verify webhook signature FIRST, before any event processing. + // The signature is extracted from the payload and verified against + // the remaining fields using HMAC-SHA-256. + if (webhookSecret) { + const signature = rawPayload['signature'] as string | undefined + if (!signature) { + throw new Error('Missing webhook signature header') + } + // Build verification body: everything except the signature field + const { signature: _, ...verificationBody } = rawPayload + if (!verifyDocusealWebhookSignature(JSON.stringify(verificationBody), signature, webhookSecret)) { + throw new Error('Invalid webhook signature') + } + } + + const webhookPayload = payload as DocuSealWebhookPayload + const event = webhookPayload.event + const docPayload = webhookPayload.payload + + if (!docPayload || !docPayload.id) { + throw new Error('Invalid webhook payload: missing document id') + } + + // We only care about signing-completed events + if (event !== 'document.completed' && event !== 'submission.completed') { + // Return a non-error result for events we don't handle + return { requestId: '', signerId: '', signed: false } + } + + // Find the signing request by provider document ID + // We need to scan for it — or use the document id as lookup + const providerDocId = String(docPayload.id) + + // Try to find the matching request by scanning store + // For Postgres this is a full scan, but webhooks are infrequent + let matchedRequest: StoredRequest | null = null + let matchedSignerId: string | null = null + + // Extract signer info from the webhook payload + const submitters = docPayload.submitters || {} + for (const [role, submitter] of Object.entries(submitters)) { + if (!submitter.completed) continue + + // Find the request that contains this recipient + // In a production system, we'd index by provider_recipient_id, + // but for bounded scope we check the provider ID on the request + const request = await this.findRequestByProviderId(providerDocId) + if (!request) continue + + // Match the role to our signer + const matchingSigner = request.signers.find((s) => s.role === role) + if (matchingSigner) { + const state = request.signerStates[matchingSigner.id] + if (state && !state.signed) { + matchedRequest = request + matchedSignerId = matchingSigner.id + break + } + } + } + + if (!matchedRequest || !matchedSignerId) { + throw new Error('No matching pending signer found for webhook event') + } + + // Update the signer state + await esignRequestStore.updateSignerState(matchedRequest.requestId, matchedSignerId, { + ...matchedRequest.signerStates[matchedSignerId], + signed: true, + }) + + // Check if all signers have signed + const allSigned = matchedRequest.signers.every( + (s) => s.id === matchedSignerId || matchedRequest!.signerStates[s.id]?.signed, + ) + if (allSigned) { + await esignRequestStore.updateStatus(matchedRequest.requestId, 'completed') + } + + return { requestId: matchedRequest.requestId, signerId: matchedSignerId, signed: true } + } + + async verifySignature(requestId: string, signerId: string): Promise { + const stored = await esignRequestStore.getByRequestId(requestId) + if (!stored) return false + + const state = stored.signerStates[signerId] + if (!state) return false + + return state.signed === true + } + + private async findRequestByProviderId(providerId: string): Promise { + return esignRequestStore.findByProviderId(providerId) + } +} + +// ── Webhook signature verification ────────────────────────────────────────── + +export function verifyDocusealWebhookSignature( + rawBody: string, + signature: string, + secret: string, +): boolean { + const expected = createHmac('sha256', secret).update(rawBody).digest('hex') + try { + return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex')) + } catch { + return false + } +} diff --git a/src/services/eSignatureService.ts b/src/services/eSignatureService.ts index 8f25bba..e201bba 100644 --- a/src/services/eSignatureService.ts +++ b/src/services/eSignatureService.ts @@ -140,12 +140,20 @@ export class StubESignatureProvider implements ESignatureProvider { } /** - * Create e-signature provider based on environment config + * Create e-signature provider based on environment config. + * + * 'stub' – in-memory provider for local dev / tests (default). + * 'docuseal' – real DocuSeal-backed provider; requires DOCUSEAL_API_KEY, + * DOCUSEAL_API_URL and DOCUSEAL_WEBHOOK_SECRET. */ -export function createESignatureProvider(): ESignatureProvider { +export async function createESignatureProvider(): Promise { const provider = process.env.ESIGN_PROVIDER || 'stub' switch (provider) { + case 'docuseal': { + const mod = await import('./docusealESignatureProvider.js') + return new mod.DocuSealESignatureProvider() + } case 'stub': return new StubESignatureProvider() default: