704 lines
37 KiB
Markdown
704 lines
37 KiB
Markdown
# 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
|
||
|
||
```prisma
|
||
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.
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
```typescript
|
||
// 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](https://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
|
||
|
||
```typescript
|
||
// 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 48–72 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
|
||
|
||
- [Next.js Authentication Guide (WorkOS) 2026](https://workos.com/blog/nextjs-app-router-authentication-guide-2026)
|
||
- [Next.js App Router Route Groups](https://nextjs.org/docs/app/api-reference/file-conventions/route-groups)
|
||
- [Next.js Official Authentication Guide](https://nextjs.org/docs/app/building-your-application/authentication)
|
||
- [Auth.js — Protecting Routes](https://authjs.dev/getting-started/session-management/protecting)
|
||
- [CVE-2025-29927 — Next.js Middleware Bypass (Vercel Postmortem)](https://nextjs.org/blog/cve-2025-29927)
|
||
- [CVE-2025-29927 Technical Analysis (ProjectDiscovery)](https://projectdiscovery.io/blog/nextjs-middleware-authorization-bypass)
|
||
- [Datadog Security Labs — Next.js Middleware Auth Bypass](https://securitylabs.datadoghq.com/articles/nextjs-middleware-auth-bypass/)
|
||
- [pdf-lib — Create and Modify PDF Documents](https://pdf-lib.js.org/)
|
||
- [pdfjs-dist on npm](https://www.npmjs.com/package/pdfjs-dist)
|
||
- [Read and Parse PDFs with pdfjs, Create with pdf-lib (Liran Tal)](https://lirantal.com/blog/how-to-read-and-parse-pdfs-pdfjs-create-pdfs-pdf-lib-nodejs)
|
||
- [PDF.js Annotations and Forms (DeepWiki)](https://deepwiki.com/mozilla/pdfjs-dist/5.4-annotations-and-forms)
|
||
- [Prisma Schema Data Models](https://www.prisma.io/docs/orm/prisma-schema/data-model/models)
|
||
- [Implementing Entity Audit Log with Prisma](https://medium.com/@gayanper/implementing-entity-audit-log-with-prisma-9cd3c15f6b8e)
|
||
- [JWT Best Practices (Curity)](https://curity.io/resources/learn/jwt-best-practices/)
|
||
- [JWT Introduction — jwt.io](https://www.jwt.io/introduction)
|
||
- [AWS S3 + Next.js — Upload and Save References in Postgres (Neon)](https://neon.com/guides/next-upload-aws-s3)
|
||
- [Vercel Blob Storage](https://vercel.com/kb/guide/how-can-i-use-aws-s3-with-vercel)
|
||
- [BullMQ with Next.js](https://medium.com/@asanka_l/integrating-bullmq-with-nextjs-typescript-f41cca347ef8)
|
||
- [UtahRealEstate.com Vendor / Developer Portal](https://vendor.utahrealestate.com/)
|
||
- [WFRMLS RESO OData API Examples](https://www.reso.org/web-api-examples/mls/utah-mls/)
|
||
- [Next.js Middleware Authentication — HashBuilds](https://www.hashbuilds.com/articles/next-js-middleware-authentication-protecting-routes-in-2025)
|
||
- [Next.js 15 Folder Structure Guide (jigz.dev)](https://www.jigz.dev/blogs/how-to-organize-next-js-15-app-router-folder-structure)
|
||
- [Secure Routes in Next.js (freeCodeCamp)](https://www.freecodecamp.org/news/secure-routes-in-next-js/)
|
||
- [Next.js Route Groups — Medium](https://medium.com/@shrutishende11/next-js-route-groups-organizing-your-app-router-like-a-pro-aa58ca11f973)
|
||
- [PDF Page Coordinates (pdfscripting.com)](https://www.pdfscripting.com/public/PDF-Page-Coordinates.cfm)
|
||
|
||
---
|
||
*Architecture research for: Real estate agent website + document signing*
|
||
*Researched: 2026-03-19*
|