Files
red/.planning/research/ARCHITECTURE.md
2026-03-19 11:50:51 -06:00

37 KiB
Raw Blame History

Architecture Research

Domain: Real estate agent website + PDF document signing web app Researched: 2026-03-19 Confidence: HIGH

System Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                        teressacopelandhomes.com                              │
│                                                                               │
│  ┌──────────────────────────┐     ┌──────────────────────────────────────┐  │
│  │   PUBLIC SITE             │     │   AGENT PORTAL (/agent/*)            │  │
│  │   (unauthenticated)       │     │   (authenticated)                    │  │
│  │                           │     │                                      │  │
│  │  - Hero / Bio             │     │  - Dashboard                         │  │
│  │  - Active Listings        │     │  - Client Management                 │  │
│  │  - Contact Form           │     │  - Document Templates                │  │
│  │                           │     │  - PDF Ingest & Field Mapping        │  │
│  └──────────────────────────┘     │  - Signing Request Management        │  │
│                                    └──────────────────────────────────────┘  │
│                                                                               │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │                     PDF PIPELINE                                         │ │
│  │                                                                           │ │
│  │  utahrealestate.com                                                       │ │
│  │       │                                                                   │ │
│  │       ▼                                                                   │ │
│  │  [on-demand fetch / manual upload]                                        │ │
│  │       │                                                                   │ │
│  │       ▼                                                                   │ │
│  │  [pdfjs-dist: parse → detect form fields / annotation positions]         │ │
│  │       │                                                                   │ │
│  │       ▼                                                                   │ │
│  │  [agent fills fields → pdf-lib writes text into PDF]                     │ │
│  │       │                                                                   │ │
│  │       ▼                                                                   │ │
│  │  [agent sends signing link → email with JWT token]                       │ │
│  │       │                                                                   │ │
│  │       ▼                                                                   │ │
│  │  [client opens /sign/[token] → draws on canvas → PNG embedded]           │ │
│  │       │                                                                   │ │
│  │       ▼                                                                   │ │
│  │  [signed PDF stored in S3/Vercel Blob → accessible to agent]             │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
│                                                                               │
│  ┌──────────────────────┐    ┌─────────────────────┐    ┌─────────────────┐ │
│  │   PostgreSQL          │    │   S3 / Vercel Blob   │    │  Email (Resend) │ │
│  │   (via Prisma)        │    │   (PDF storage)      │    │  (signing links)│ │
│  └──────────────────────┘    └─────────────────────┘    └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘

Next.js App Structure

src/
├── app/
│   ├── layout.tsx                    # Root layout (fonts, global styles)
│   ├── (public)/                     # Route group — no URL segment added
│   │   ├── page.tsx                  # Home / hero / bio
│   │   ├── listings/
│   │   │   └── page.tsx              # Active listings (WFRMLS data)
│   │   └── contact/
│   │       └── page.tsx              # Contact form
│   ├── (agent)/                      # Route group — protected portal
│   │   ├── layout.tsx                # Auth guard + agent shell layout
│   │   ├── agent/
│   │   │   ├── dashboard/
│   │   │   │   └── page.tsx
│   │   │   ├── clients/
│   │   │   │   ├── page.tsx          # Client list
│   │   │   │   └── [id]/
│   │   │   │       └── page.tsx      # Client detail + their documents
│   │   │   └── documents/
│   │   │       ├── page.tsx          # Document template list
│   │   │       ├── new/
│   │   │       │   └── page.tsx      # Ingest / upload PDF
│   │   │       └── [id]/
│   │   │           ├── page.tsx      # Document detail / field mapping
│   │   │           └── send/
│   │   │               └── page.tsx  # Send signing request to client
│   ├── sign/
│   │   └── [token]/
│   │       └── page.tsx              # Public signing page (client link)
│   ├── auth/
│   │   └── login/
│   │       └── page.tsx              # Agent login page
│   └── api/
│       ├── auth/
│       │   └── [...nextauth]/
│       │       └── route.ts          # NextAuth.js handler
│       ├── documents/
│       │   ├── route.ts              # List / create documents
│       │   ├── [id]/
│       │   │   └── route.ts          # Get / update / delete document
│       │   ├── [id]/ingest/
│       │   │   └── route.ts          # Trigger PDF fetch + parse
│       │   └── [id]/fill/
│       │       └── route.ts          # Write field values into PDF
│       ├── sign/
│       │   ├── [token]/
│       │   │   └── route.ts          # Validate token, serve signing metadata
│       │   └── [token]/submit/
│       │       └── route.ts          # Accept canvas PNG, embed, finalize
│       ├── clients/
│       │   └── route.ts              # CRUD for clients
│       └── listings/
│           └── route.ts              # WFRMLS proxy / cache endpoint
├── lib/
│   ├── pdf/
│   │   ├── parse.ts                  # pdfjs-dist: extract fields + coordinates
│   │   ├── fill.ts                   # pdf-lib: write field values into PDF bytes
│   │   └── sign.ts                   # pdf-lib: embed PNG signature at coordinates
│   ├── email/
│   │   └── send-signing-link.ts      # Resend / Nodemailer: send token link
│   ├── db/
│   │   └── prisma.ts                 # Singleton Prisma client
│   ├── storage/
│   │   └── s3.ts                     # Upload / download / presigned URL helpers
│   ├── auth/
│   │   └── session.ts                # verifySession() — used in DAL and server actions
│   └── wfrmls/
│       └── client.ts                 # WFRMLS RESO OData API client
├── components/
│   ├── public/                       # Marketing UI components
│   ├── agent/                        # Agent portal UI components
│   │   ├── PDFFieldMapper.tsx        # Canvas overlay for manual field placement
│   │   └── DocumentEditor.tsx        # Field fill form
│   └── sign/
│       └── SignatureCanvas.tsx        # react-signature-canvas wrapper (client)
├── middleware.ts                      # Edge: check auth cookie, redirect /agent/* to login
└── prisma/
    └── schema.prisma

Component Responsibilities

Component Responsibility Communicates With
middleware.ts Edge-level auth gate — redirects unauthenticated requests on /agent/* to /auth/login. Runs on every request. NOT the only auth layer. Cookie / session token
(agent)/layout.tsx Server Component auth check via verifySession() — second auth layer for all agent pages lib/auth/session.ts, redirects to login
app/api/auth/[...nextauth]/route.ts Handles login, session creation, token management via NextAuth.js PostgreSQL (user table), cookies
app/api/documents/[id]/ingest/route.ts On-demand: fetches PDF from utahrealestate.com or accepts upload, runs parse, stores raw PDF bytes in S3, saves metadata to DB lib/pdf/parse.ts, lib/storage/s3.ts, Prisma
app/api/documents/[id]/fill/route.ts Accepts field values from agent, calls fill.ts, stores filled PDF in S3 lib/pdf/fill.ts, lib/storage/s3.ts, Prisma
app/api/sign/[token]/route.ts Validates JWT token (signature + expiry + jti not used), returns document metadata for rendering Prisma (SigningRequest), lib/auth/session.ts (JWT verify)
app/api/sign/[token]/submit/route.ts Accepts canvas PNG dataURL, calls sign.ts, stores signed PDF, marks token as used, writes audit log lib/pdf/sign.ts, lib/storage/s3.ts, Prisma
app/sign/[token]/page.tsx Public page — renders PDF via pdfjs-dist, overlays signature fields, sends canvas PNG on submit api/sign/[token], SignatureCanvas.tsx
lib/pdf/parse.ts Extracts existing AcroForm fields and their [x1, y1, x2, y2] bounding boxes from PDF bytes using pdfjs-dist. Falls back to empty field list if no form fields exist (manual placement flow). pdfjs-dist
lib/pdf/fill.ts Loads PDF bytes with pdf-lib, writes agent-supplied text into named form fields (or at coordinates), returns modified PDF bytes pdf-lib
lib/pdf/sign.ts Embeds signature PNG at specified coordinates on the correct page using pdfDoc.embedPng(). Flattens or locks the form. Returns signed PDF bytes. pdf-lib
components/agent/PDFFieldMapper.tsx Client component — renders PDF page via canvas, lets agent click-drag to define signature area rectangles. Saves field coordinate metadata to DB. pdfjs-dist (client), api/documents/[id]
lib/wfrmls/client.ts Wraps WFRMLS RESO OData API calls. Caches results. Called on-demand from the listings route. resoapi.wfrmls.com/reso/odata

PDF Processing Pipeline

Step-by-step: how a PDF goes from utahrealestate.com to a signed document.

utahrealestate.com (or manual upload)
    │
    │  [on-demand fetch via lib/wfrmls/client.ts
    │   OR file upload from agent browser]
    ▼
raw PDF bytes
    │
    │  [lib/pdf/parse.ts — pdfjs-dist server-side]
    │  → page.getAnnotations() to find AcroForm widget annotations
    │  → extract: field name, type, rect [x1, y1, x2, y2], page index
    │  → if no fields found → flag as "manual placement mode"
    ▼
field metadata (JSON stored in Document.fieldMap)
    │
    │  [agent fills fields in DocumentEditor.tsx]
    │  → agent types: property address, client name, price, dates
    │  → if manual placement mode: agent uses PDFFieldMapper.tsx
    │    to drag signature areas onto the PDF preview canvas
    ▼
field values + coordinates stored in SigningRequest.fieldValues
    │
    │  [lib/pdf/fill.ts — pdf-lib server-side]
    │  → load raw PDF bytes from S3
    │  → for each field: pdfDoc.getForm().getTextField(name).setText(value)
    │  → OR: pdfDoc.getPage(n).drawText(value, { x, y, size, font })
    ▼
filled PDF bytes → uploaded to S3 as "prepared-{id}.pdf"
    │
    │  [agent sends signing request]
    │  → lib/email/send-signing-link.ts generates JWT token
    │  → email sent to client with: https://teressacopelandhomes.com/sign/{token}
    ▼
client opens /sign/[token]
    │
    │  → token validated server-side (api/sign/[token]/route.ts)
    │  → prepared PDF streamed from S3 and rendered with pdfjs-dist
    │  → signature field overlays rendered at stored coordinates
    │  → client draws signature using react-signature-canvas
    ▼
client clicks "Submit Signature"
    │
    │  → canvas.toDataURL("image/png") sent to api/sign/[token]/submit
    │  → lib/pdf/sign.ts:
    │    → pdfDoc.embedPng(signatureBytes)
    │    → page.drawImage(embeddedPng, { x, y, width, height })
    │    → pdfDoc.save() → signed PDF bytes
    │  → signed PDF uploaded to S3 as "signed-{signingRequestId}.pdf"
    │  → SigningRequest.status → SIGNED, .signedAt → now()
    │  → SignatureAuditLog entry written (IP, userAgent, timestamp)
    │  → token's jti marked as used in DB
    ▼
signed PDF stored in S3
    │
    │  [agent downloads via presigned S3 URL from api/documents/[id]]
    ▼
agent receives completed signed document

Coordinate System Note

PDF.js uses PDF User Space coordinates: origin (0,0) at bottom-left, Y increasing upward, units in points (72 pt/inch). When storing field coordinates from PDFFieldMapper.tsx (which uses screen/canvas coordinates), coordinates must be converted from viewport space to PDF user space before storage. pdf-lib uses the same coordinate system as PDF user space.

Signing Flow (Token-Based)

1. Agent clicks "Send for Signing" on a SigningRequest
   └─ POST /api/documents/[id]/signing-requests

2. Server generates a JWT with these claims:
   {
     sub: signingRequest.id,          // the specific request
     email: client.email,             // client who should sign
     jti: crypto.randomUUID(),        // unique token ID for one-time use tracking
     exp: now + 72 hours,             // short TTL
     iat: now
   }
   Signed with HMAC-SHA256 (HS256) using SIGNING_SECRET env var

3. JWT stored as SigningRequest.token (for lookup) and
   SigningRequest.tokenJti (for one-time use tracking)
   SigningRequest.expiresAt = now + 72 hours

4. Email sent to client:
   Subject: "You have a document to sign — Teressa Copeland Homes"
   Body: https://teressacopelandhomes.com/sign/{encoded-jwt}

5. Client opens link → GET /sign/[token] (public page)

6. Server validates token (api/sign/[token]/route.ts):
   a. Decode JWT → verify HS256 signature
   b. Check exp → reject if expired (return 410 Gone)
   c. Query DB: SigningRequest where token = token AND status = PENDING
   d. Check SigningRequest.usedAt IS NULL → reject if already used (return 409)
   e. If valid → return signing metadata (field coordinates, doc info)

7. Client draws signature, clicks submit → POST /api/sign/[token]/submit
   a. Re-validate token (same checks as step 6)
   b. Accept PNG dataURL in request body
   c. Fetch prepared PDF from S3
   d. Embed PNG at stored coordinates with pdf-lib
   e. Upload signed PDF to S3
   f. Update DB: SigningRequest.status = SIGNED, .usedAt = now()
   g. Write SignatureAuditLog row
   h. Return 200 → client sees "Thank you, your document has been signed"

Why not pure database tokens instead of JWT? JWTs allow the server to validate token authenticity without a DB round-trip in step 6a. The DB lookup in 6c still happens to enforce one-time use, but the signature check prevents forged tokens from ever hitting the DB.

Token URL format: https://teressacopelandhomes.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... The full JWT is the path segment — no query string, no additional encoding needed.

Key Data Models

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

// ─────────────────────────────
// Auth
// ─────────────────────────────

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  passwordHash  String
  name          String
  role          UserRole  @default(AGENT)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // NextAuth.js required relations
  accounts      Account[]
  sessions      Session[]
}

enum UserRole {
  AGENT
  ADMIN
}

// NextAuth.js adapter models (required)
model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

// ─────────────────────────────
// Core Domain
// ─────────────────────────────

model Client {
  id              String           @id @default(cuid())
  name            String
  email           String           @unique
  phone           String?
  notes           String?          @db.Text
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @updatedAt

  signingRequests SigningRequest[]
}

// A Document is a PDF template — the base form from utahrealestate.com
// before it is associated with a specific client/transaction.
model Document {
  id              String           @id @default(cuid())
  name            String           // e.g. "REPC - Residential"
  sourceUrl       String?          // original URL on utahrealestate.com
  rawS3Key        String           // S3 key for the original PDF
  fieldMap        Json             // parsed form fields: [{ name, type, rect, page }]
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @updatedAt

  signingRequests SigningRequest[]
}

// A SigningRequest is a Document sent to a specific Client.
// The agent fills fields and the client signs.
model SigningRequest {
  id              String              @id @default(cuid())
  documentId      String
  document        Document            @relation(fields: [documentId], references: [id])
  clientId        String
  client          Client              @relation(fields: [clientId], references: [id])

  // Field values the agent typed in (JSON map of fieldName → value)
  fieldValues     Json                @default("{}")

  // Signature field placement (array of { page, x, y, width, height })
  signatureFields Json                @default("[]")

  // S3 keys for each stage of the PDF
  preparedS3Key   String?             // after agent fills fields
  signedS3Key     String?             // after client signs

  // Signing link (JWT stored for lookup; jti for one-time enforcement)
  token           String?             @unique
  tokenJti        String?             @unique
  expiresAt       DateTime?

  status          SigningStatus       @default(DRAFT)
  sentAt          DateTime?           // when email was sent
  viewedAt        DateTime?           // when client first opened the link
  signedAt        DateTime?           // when client completed signing
  usedAt          DateTime?           // token consumed (same as signedAt in most cases)

  createdAt       DateTime            @default(now())
  updatedAt       DateTime            @updatedAt

  auditLogs       SignatureAuditLog[]
}

enum SigningStatus {
  DRAFT       // agent building it, not sent
  SENT        // email sent, awaiting client
  VIEWED      // client opened the link
  SIGNED      // client completed signing
  EXPIRED     // token TTL passed without signing
  CANCELLED   // agent cancelled
}

// Immutable audit trail for compliance
model SignatureAuditLog {
  id               String         @id @default(cuid())
  signingRequestId String
  signingRequest   SigningRequest  @relation(fields: [signingRequestId], references: [id])

  event            AuditEvent
  actorEmail       String         // who performed the action
  ipAddress        String?
  userAgent        String?
  metadata         Json?          // e.g. { "page": 3, "fieldName": "signature_buyer" }

  createdAt        DateTime       @default(now())

  @@index([signingRequestId])
}

enum AuditEvent {
  REQUEST_CREATED
  EMAIL_SENT
  LINK_OPENED
  SIGNATURE_SUBMITTED
  SIGNING_COMPLETED
  TOKEN_EXPIRED
  REQUEST_CANCELLED
}

Authentication Pattern

Middleware (First Layer — Edge)

middleware.ts at the project root runs on every request to /agent/*. It checks for the NextAuth.js session cookie (a JWT signed by NEXTAUTH_SECRET). If absent or invalid, it redirects to /auth/login. This runs at the edge — fast, lightweight, no DB call.

// middleware.ts
import { withAuth } from "next-auth/middleware";

export default withAuth({
  pages: { signIn: "/auth/login" },
});

export const config = {
  matcher: ["/agent/:path*"],
};

IMPORTANT — CVE-2025-29927: In March 2025, a critical vulnerability was disclosed showing that the x-middleware-subrequest header can bypass all Next.js middleware checks. This means middleware alone is NEVER sufficient for auth. Use Next.js 15.2.3+ (patched) AND implement defense-in-depth.

Layout Auth Guard (Second Layer — Server)

app/(agent)/layout.tsx is a Server Component that calls verifySession() from lib/auth/session.ts on every render. This is the critical second layer that middleware cannot replace. Due to partial rendering, layouts do not re-run on every navigation, but this catch-all guard ensures any direct deep link is still protected.

// app/(agent)/layout.tsx
import { verifySession } from "@/lib/auth/session";
import { redirect } from "next/navigation";

export default async function AgentLayout({ children }) {
  const session = await verifySession(); // throws / returns null if invalid
  if (!session) redirect("/auth/login");
  return <AgentShell user={session.user}>{children}</AgentShell>;
}

Data Access Layer (Third Layer — Per Data Query)

lib/auth/session.ts exports verifySession() which calls getServerSession(authOptions). This is called in each Server Component and Route Handler that accesses sensitive data. This ensures auth is checked as close to the data as possible, not just at the routing layer.

// lib/auth/session.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";

export async function verifySession() {
  const session = await getServerSession(authOptions);
  if (!session) redirect("/auth/login");
  return session;
}

Anti-Pattern to Avoid

Do NOT return null from a layout to hide protected content. Next.js has multiple entry points — a direct URL to a nested route will not necessarily re-run the layout. Always redirect rather than conditionally render.

File Storage Strategy

Storage Backend: AWS S3 (or Vercel Blob for simpler setup)

PDFs must NOT be stored on the local filesystem. Vercel (and most serverless platforms) have ephemeral filesystems that are wiped on each deployment. The recommended approach is AWS S3 or Vercel Blob (which is S3-backed).

S3 Bucket Structure:

bucket: teressa-copeland-homes-docs/
├── raw/
│   └── {documentId}/original.pdf       # untouched PDF from utahrealestate.com
├── prepared/
│   └── {signingRequestId}/filled.pdf   # agent-filled PDF, awaiting signature
└── signed/
    └── {signingRequestId}/signed.pdf   # final signed PDF with embedded signature

Access Pattern — PDFs are NEVER publicly accessible:

All three PDF stages (raw, prepared, signed) use private S3 ACLs. Access is always mediated through Next.js API routes that verify authentication before generating a time-limited presigned URL.

// lib/storage/s3.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export async function getPresignedDownloadUrl(s3Key: string): Promise<string> {
  const command = new GetObjectCommand({ Bucket: BUCKET, Key: s3Key });
  return getSignedUrl(s3Client, command, { expiresIn: 300 }); // 5 min
}

The signing page (/sign/[token]) is special: after the signing token is validated, the Route Handler fetches the prepared PDF from S3 directly and streams it to the client (or returns a short-lived presigned URL for the PDF.js renderer). The S3 URL itself is never exposed to the client.

For the signing page client-side PDF render: The prepared PDF bytes are fetched server-side and returned as a base64 blob OR the Route Handler returns a short-lived presigned URL that is only passed to the PDF.js worker in memory, not stored in the DOM.

utahrealestate.com Integration

What Data Is Available

UtahRealEstate.com (WFRMLS) provides:

  1. RESO Web API (OData) at resoapi.wfrmls.com/reso/odata/ for MLS listing data — property details, photos, prices, status. This requires a licensed vendor account via vendor.utahrealestate.com.
  2. PDF forms — the real estate transaction forms (REPC, addenda, etc.) are available for download by licensed agents.

Integration Architecture

                    ┌──────────────────────────────────────────┐
                    │          Two Integration Points           │
                    └──────────────────────────────────────────┘

1. LISTINGS (public page)            2. PDF FORMS (agent portal)
   ─────────────────────────            ────────────────────────────
   /api/listings/route.ts               Manual or agent-triggered

   On-demand with ISR caching:          Agent pastes/enters a URL
   revalidate = 3600 (1 hour)           to a form PDF or uploads
                                        one directly from their
   WFRMLS RESO OData API:               computer.
   → Filter: ListAgentEmail = agent
   → Returns: address, price,           The /api/documents/[id]/ingest
     photos, status, beds/baths         route handler:
                                        1. Fetches the PDF (if URL)
   Cached in Next.js fetch cache         2. Stores raw bytes in S3
   or Redis for 1 hour.                  3. Runs pdfjs-dist parse
                                        4. Stores field map in DB

Why On-Demand (Not Scheduled Batch) for Forms

The agent workflow is: "I have a specific transaction, I need this specific form." The PDF forms are pulled once per document template, not on a schedule. There is no need for a job queue or cron for the PDF ingest path — it is a standard HTTP request triggered by the agent clicking "Import Form."

For listings, Next.js ISR (Incremental Static Regeneration) with revalidate on the fetch call is the right tool: the public listings page rebuilds at most once per hour, keeping WFRMLS API calls minimal.

No background job queue (BullMQ/Redis) is needed for this use case. A job queue would be warranted if PDFs required multi-minute processing (OCR, AI field detection), but pdf-lib + pdfjs-dist parsing completes in under 2 seconds for typical real estate forms. If OCR is added later (for scanned PDFs), that is the right time to introduce BullMQ.

WFRMLS API Client

// lib/wfrmls/client.ts
const BASE = "https://resoapi.wfrmls.com/reso/odata";

export async function fetchAgentListings(agentEmail: string) {
  const res = await fetch(
    `${BASE}/Property?$filter=ListAgentEmail eq '${agentEmail}' and StandardStatus eq 'Active'&$select=ListingKey,UnparsedAddress,ListPrice,BedroomsTotal,BathroomsTotalInteger,Media`,
    {
      headers: { Authorization: `Bearer ${process.env.WFRMLS_API_TOKEN}` },
      next: { revalidate: 3600 }, // ISR — rebuild at most hourly
    }
  );
  return res.json();
}

Anti-Patterns

1. Middleware-only auth (CVE-2025-29927) Relying solely on middleware.ts for authentication was demonstrated to be exploitable in March 2025 via the x-middleware-subrequest header bypass. Always add a second auth check in the layout Server Component and a third in every Route Handler or Server Action that accesses private data.

2. Storing PDFs on the local filesystem /tmp on serverless platforms is ephemeral and size-limited. Even in self-hosted setups, local storage creates scaling and durability problems. All PDFs must go through S3/Blob from day one.

3. Sending the S3 presigned URL to the browser for the signed PDF The presigned URL for the agent-accessible signed PDF should be generated server-side and returned only to authenticated agent sessions. Never include presigned URLs in publicly accessible API responses.

4. Client-side-only auth checks Hiding UI based on useSession() in a client component is cosmetic only. The user can open DevTools and call the API directly. Every API Route Handler and Server Action must independently verify the session.

5. Using null-return in layouts to block content Due to partial rendering, a layout only re-runs when its segment changes. A user navigating within the agent portal may skip the layout re-run. Always redirect() to the login page instead of returning null.

6. Storing signed PDF URLs in the SigningRequest and serving them publicly The signed PDF is a legal document. It must never be served without authentication. Use the S3 key in the DB and generate presigned URLs only in authenticated route handlers.

7. Treating the signing token as a long-lived credential Signing tokens should expire in 4872 hours and be invalidated after first use. A client who loses the email should request a new link from the agent, not be able to reuse an old token.

8. Embedding signature as a text glyph instead of an image Some implementations write the signature as a font character. This is legally weaker than embedding the actual PNG image drawn by the client's hand. Use pdfDoc.embedPng() with pdf-lib to embed the real canvas output.

9. Running pdfjs-dist without SSR disabled pdfjs-dist relies on canvas and Web Workers. In Next.js, any component that uses it for rendering must be a Client Component with dynamic(() => import(...), { ssr: false }). The server-side parse in lib/pdf/parse.ts uses the Node.js legacy build (pdfjs-dist/legacy/build/pdf.mjs) which does not require a worker.

10. Skipping the audit log Real estate document signing has legal weight. Every event — link sent, link opened, signature submitted — must be recorded with IP address, user agent, and timestamp in SignatureAuditLog. This table is append-only and should never be updated or deleted.

Build Order

Components must be built in dependency order. Each phase unblocks the next.

Phase 1 — Foundation (no deps)
├── Prisma schema + migrations
├── PostgreSQL setup
├── S3 bucket + IAM policy
├── NextAuth.js credentials provider
└── Middleware + layout auth guard

Phase 2 — Public Site (depends on Phase 1)
├── Public layout + home page (hero, bio)
├── Contact form + API route
└── WFRMLS client + listings page (ISR)

Phase 3 — Agent Shell (depends on Phase 1)
├── Agent dashboard (skeleton)
├── Client CRUD (list, create, edit)
└── Agent login page

Phase 4 — PDF Ingest (depends on Phase 1, 3)
├── lib/storage/s3.ts (upload/download/presign)
├── lib/pdf/parse.ts (pdfjs-dist field extraction)
├── Document create + ingest API route
└── Document list + detail pages

Phase 5 — PDF Fill + Field Mapping (depends on Phase 4)
├── lib/pdf/fill.ts (pdf-lib field writing)
├── PDFFieldMapper.tsx (canvas overlay, manual placement)
├── DocumentEditor.tsx (field value form)
└── Fill API route

Phase 6 — Signing Flow (depends on Phase 5)
├── JWT token generation + storage in SigningRequest
├── lib/email/send-signing-link.ts
├── Send signing request UI + API
├── /sign/[token] public page
├── SignatureCanvas.tsx (react-signature-canvas)
├── lib/pdf/sign.ts (pdf-lib PNG embed)
├── Submit signature API route
└── Token validation + one-time use enforcement

Phase 7 — Audit + Download (depends on Phase 6)
├── SignatureAuditLog writes in all sign endpoints
├── Agent document download (presigned S3 URL)
└── Signing status tracking in agent portal

Sources


Architecture research for: Real estate agent website + document signing Researched: 2026-03-19