SurePact
AI legal-contract analysis SaaS using Gemini 2.5 Pro's 2M-token window for full-document risk classification.
- Role
- Full Stack Engineer
- Timeline
- 2024 – present
- Team
- Solo build
- Year
- 2024
- React
- Vite
- Firebase
- Google Gemini
- TypeScript
- PayU
Problem
Reviewing a contract is expensive, slow, and gated behind lawyers most small businesses cannot afford on every agreement. The dangerous clauses — auto-renew traps, unlimited liability, one-sided indemnity — hide in dense legalese that non-lawyers skim past. Existing "AI contract" tools chunk documents and lose cross-references, so a clause on page 2 that is modified on page 40 gets analyzed in isolation.
Target user: founders, freelancers, and small-business owners who sign contracts without in-house legal.
Constraints:
- Analysis must consider the whole contract at once — legal risk is contextual and cross-referential.
- Payments must be secure and tamper-proof (PayU hash signing).
- It has to be operable serverlessly by a tiny team.
Business goal: give non-lawyers a fast, plain-language read on where a contract is risky, and let them edit it in place.
Architecture
SurePact is a serverless Firebase application with a targeted Express surface for payments.
- React + Vite frontend for a fast SPA.
- Firestore as the document/data store; Cloud Functions (Node 20) for backend logic; a small Express app inside functions handles PayU flows that need custom routing and server-side hashing.
- Gemini 2.5 Pro with its 2M-token context window analyzes the entire contract in one pass — no chunking, no lost cross-references.
- Risk classification buckets findings into Low / Medium / High.
- A coupon + affiliate/referral engine drives growth.
- An AI PDF editor built on TipTap lets users apply suggested edits in a rich document view.
Engineering Decisions
Whole-document analysis via a 2M-token window instead of RAG chunking. The central bet: legal risk is contextual, so chunking a contract and embedding pieces loses exactly the cross-references that matter (defined terms, amendments, exhibits). Gemini 2.5 Pro's 2M-token window lets the model see the entire agreement at once. Tradeoff: higher per-analysis token cost and latency that scales with document size, accepted because analysis quality is the product. It is worth being precise about why RAG is the wrong tool here rather than just more expensive: retrieval optimizes for "find the passages relevant to a query," but contract risk is an emergent property of the whole — a benign liability clause on page 2 becomes dangerous only because an amendment on page 40 strikes its cap. A chunker retrieves each in isolation and the interaction, which is the actual risk, is never in the same context window. So this is not a cost/quality dial; for this problem, chunking changes the answer. The honest limit is that a contract genuinely larger than 2M tokens would force a fallback to hierarchical summarization, and I'd rather cap supported document size than quietly degrade to chunking that misses cross-references.
Serverless Firebase to minimize ops. Firestore + Cloud Functions means no servers to run for a two-person team. Tradeoff: Firestore's query model is less flexible than SQL, fine for document-centric data. The deeper tradeoff is where authorization lives: with no server tier in the request path, access control is Firestore security rules, not middleware. That is a genuine mental shift — the rules are the auth layer, so they have to be treated as code (tested, reviewed, versioned) rather than as configuration. The upside is that access control sits right next to the data and cannot be bypassed by a forgotten check in an endpoint; the cost is that the rules language is limited and complex policies get awkward, which is exactly why they are covered by an emulator test suite rather than trusted by inspection.
Express inside Cloud Functions for PayU. Payment flows need custom routing and, critically, server-side hash signing — the payment signature is computed with the secret salt on the server so the client can never forge or tamper with an amount. A thin Express app inside a function gives that control without standing up a separate service.
TipTap for the AI PDF editor. Users need to act on findings, not just read them. TipTap's structured document model made it possible to apply AI-suggested edits inline while preserving document structure.
Source Code Walkthrough
Server-side PayU hash signing
The payment hash is computed on the server with the secret salt, so amounts and order details cannot be tampered with client-side.
// functions/payments/payu.ts
// Why: the payment signature MUST be computed server-side. If the client could
// build the hash, it could alter the amount. The salt never leaves the server.
export function signPayuRequest(p: PayuRequest, salt: string): string {
// PayU's canonical field order — deviating breaks verification.
const sequence = [
p.key, p.txnid, p.amount, p.productinfo,
p.firstname, p.email,
"", "", "", "", "", "", "", "", "", "",
].join("|");
return sha512(`${sequence}|${salt}`);
}Why this way: the exact field ordering is dictated by PayU; the salt is a server-only secret. Improvements: move to PayU's newer hash spec and add replay protection on the response callback.
One-pass full-contract analysis
// functions/analyze/contract.ts
// Why: send the ENTIRE contract in one request so the model reasons across
// cross-references, defined terms, and amendments — no chunking.
export async function analyzeContract(fullText: string) {
const res = await gemini.generateContent({
model: "gemini-2.5-pro",
contents: buildRiskPrompt(fullText), // whole document, up to 2M tokens
generationConfig: { responseMimeType: "application/json" },
});
// Model returns structured findings; we bucket each into Low/Med/High.
const findings = JSON.parse(res.text()) as Finding[];
return findings.map((f) => ({ ...f, risk: classifyRisk(f.severity) }));
}Performance considerations: latency scales with document length (~9s for 10 pages, ~74s for 200); we stream progress to the UI so long docs do not feel frozen. Improvements: cache analysis by document hash so re-opens are instant.
Validating model output before it ever reaches the UI
JSON.parse on an LLM response is optimism, not validation. A model can return
syntactically valid JSON that is semantically wrong — a severity of "very bad", a missing clause, an extra field — and the three-tier risk classifier
downstream depends on that shape. Every response is parsed through a Zod
schema so a malformed finding is rejected at the boundary, not rendered as a
broken risk card.
// functions/analyze/schema.ts
// Why: the model is a probabilistic system returning JSON. Zod turns "trust the
// model" into "verify the model" — anything off-shape is caught here, before
// classifyRisk() or the editor ever sees it.
const Finding = z.object({
clause: z.string().min(1),
quote: z.string().min(1), // the exact text the finding refers to
severity: z.enum(["low", "medium", "high"]),
rationale: z.string().min(1),
// Gemini occasionally invents fields; .strip() drops unknown keys rather
// than failing, but the enum above is strict so a bad severity DOES fail.
});
const AnalysisResult = z.object({ findings: z.array(Finding) });
export function parseAnalysis(raw: string): AnalysisResult {
const parsed = AnalysisResult.safeParse(JSON.parse(raw));
if (!parsed.success) {
// One bounded retry with the validation error fed back to the model
// outperforms hand-repairing JSON, and is cheaper than failing the run.
throw new SchemaValidationError(parsed.error, raw);
}
return parsed.data;
}Why this way: the severity enum is intentionally strict — that field maps
directly onto the Low/Medium/High UI and the classifier, so a hallucinated value
must fail loudly rather than silently bucket into a default. Unknown keys are
stripped rather than rejected because Gemini occasionally adds commentary fields,
and those are noise we can drop safely. Edge cases / improvements: the caught
SchemaValidationError drives a single bounded retry (re-prompt with the schema
error) before giving up — feeding the validator's message back to the model fixes
most shape errors in one round and is cheaper and more reliable than regex
repair. The quote field lets the UI anchor each finding to real document text,
which also makes model hallucination detectable: if the quote isn't in the
contract, the finding is discarded.
Verifying the PayU response callback before granting entitlements
Signing the outbound request is only half the job — the inbound response from PayU must be verified with the reverse hash before any paid feature is unlocked, or a forged callback could grant access for free.
// functions/payments/verify.ts
// Why: the request hash proves WE built the request; the response hash proves
// PAYU built the response. Skipping this check means a spoofed success callback
// unlocks paid features. The reverse hash uses the salt + status + reversed
// field order per PayU's spec.
export function verifyPayuResponse(r: PayuResponse, salt: string): boolean {
const sequence = [
salt, r.status,
"", "", "", "", "", "", "", "", // udf10..udf1 (empty)
r.email, r.firstname, r.productinfo, r.amount, r.txnid, r.key,
].join("|");
const expected = sha512(sequence);
// Constant-time compare: a plain === leaks timing information about how many
// leading bytes matched, which is enough to mount a forgery over many tries.
return timingSafeEqual(expected, r.hash);
}Why this way: the field order is PayU's reverse-hash spec and is exact —
one field out of place and every legitimate payment is rejected. The
constant-time comparison closes a timing side-channel that a naive === opens.
Edge cases / improvements: this is where response replay protection belongs
— today a captured valid callback could in principle be replayed, so the next
hardening step is to reject a txnid that has already been marked paid
(idempotency on the transaction ID) inside a Firestore transaction.
Technical Challenges
- Payment integrity. Server-side hash signing with a secret salt makes amount tampering impossible; response callbacks are verified before granting entitlements.
- Large-document latency. A 200-page contract is a ~74s analysis; the fix is UX (progress streaming) plus caching by document hash rather than fighting the model.
- Structured, actionable output. Forcing JSON output and mapping severity to a three-tier risk model keeps findings consistent and machine-usable by the editor.
- In-place editing. The TipTap AI PDF editor had to apply suggested changes without destroying document structure.
Code Quality & Testing
Type safety. The frontend is TypeScript in strict mode; the interesting
boundary is the untrusted one — Gemini's output — which is validated with Zod
(see the walkthrough) so the model's probabilistic JSON becomes a typed
AnalysisResult before any code touches it. Zod's inferred types are the single
source of truth shared between the Cloud Function and the React client, so the
risk-card component and the analyzer cannot disagree on the shape of a finding.
PayU request/response objects are typed and their field order is centralized in
one module, because a stray field is the difference between "payments work" and
"every payment is rejected."
Testing strategy. The tests concentrate on the two places where being wrong is expensive — security rules and money:
- Firestore security-rules tests run against the emulator with the official
@firebase/rules-unit-testingharness. They assert the rules directly: a user can read/write only their own contracts, an unauthenticated request is denied, and a paid-tier document is unreachable without the entitlement flag. These are written as explicit allow/deny cases because a security rule with no test is a security rule you don't actually know the behavior of. - PayU hash tests verify both directions against PayU's published reference
vectors — the request signature and the response reverse-hash — including the
negative cases (tampered amount, wrong salt, forged status) that must return
false. This is the suite that protects revenue. - Schema-validation tests feed the Zod parser recorded good and deliberately
malformed Gemini responses (bad
severity, missingquote, extra fields) to prove the good ones pass, the bad ones throwSchemaValidationError, and the bounded retry path fires exactly once. - Classifier unit tests pin the severity → Low/Medium/High mapping so the risk taxonomy can't drift silently.
Error handling & observability. Cloud Functions run inside a typed wrapper
that turns thrown errors into structured HttpsErrors with stable codes, so the
client can distinguish "invalid document" from "analysis failed, retry" from
"payment not verified" and react appropriately rather than showing one generic
failure. Analysis runs log document token count, model latency, and retry count;
payment flows log verification outcome (never the salt or full hash). Long
analyses stream progress events to the SPA so a 74s run reports movement instead
of appearing hung.
Code organization. Clear module boundaries inside functions/: analyze/
(prompt building, Gemini call, Zod schema, classifier), payments/ (signing,
verification, the Express router), and shared lib/ for Firebase admin and
typed wrappers. Secrets (Gemini key, PayU salt) live only in Cloud Functions
config and are never referenced from client code — enforced by keeping them out
of any module the Vite build can import.
Enforced in CI. tsc --noEmit (strict), ESLint, the Vitest unit suites
(hash, schema, classifier), and the Firestore rules tests against the emulator
all run on every push. A rules test failure blocks deploy, because shipping a
broken security rule is the highest-severity failure this app has.
Benchmarks
Full-contract analysis latency scales with size: ~9s for 10 pages, ~28s for 50, ~74s for 200 — the tradeoff for whole-document reasoning across Gemini 2.5 Pro's 2M-token window. Findings are classified into 3 severity tiers (Low/Medium/ High). See the charts above.
Lessons Learned
- What succeeded: whole-document analysis. Reviewers immediately noticed it caught cross-referenced risks that chunk-based tools miss.
- What failed first: unstructured model output was hard to render and act on; forcing JSON + a fixed risk taxonomy fixed it.
- What I would redesign: add analysis caching by document hash from the start — long docs get re-opened constantly.
- Remaining tech debt: PayU response replay protection and the older hash spec need upgrading.
- Future improvements: clause-library benchmarking (compare a clause against market-standard language).
Media
- Recording of a contract being analyzed with risk highlights.
- Screenshots of the Low/Medium/High findings panel and the TipTap editor.
- Diagram of the Firebase + Cloud Functions + Gemini analysis flow.
Documentation Links
- Product: surepact.app
- Internal docs cover the whole-document analysis pipeline, the risk taxonomy, and the server-side PayU hash-signing flow.
Build Logs
- Requirements & planning. Core insight: legal risk is contextual, so the model must see the whole contract. That drove the Gemini 2.5 Pro choice.
- Architecture. Serverless Firebase; Express-in-functions for PayU with server-side hashing; TipTap for the editor.
- Implementation. Built one-pass analysis with structured JSON output and a three-tier risk classifier; added coupon/affiliate engine.
- Testing. Verified hash signing against PayU references; validated risk classification on a corpus of known-risky clauses.
- Deployment. Firebase Hosting + Cloud Functions (Node 20).
- Monitoring. Analysis latency by document size and token usage per run.
- Retrospective. The 2M-token bet defined the product; caching and payment hardening are the next priorities.