Wanile. Engineering Assessment
Independent Code & Security Assessment

saas-magnet-mvp

An AI lead-magnet platform — Next.js 15, React 19, Prisma & PostgreSQL, Clerk, Stripe and OpenAI.

A complete engineering and security review of what is built, what is broken, and the exact order in which to fix it before the product takes real money.

Package
saas-magnet-mvp-main.zip
Snapshot
2026-07-03
Surface
36 routes · 38.6k LOC
Method
Manual + 4 deep-dives
Verdict A functional MVP — but not production-safe for paying customers today.
Immediate action

The delivered archive contains a live .env with real credentials

.gitignore lists .env, but the folder was zipped straight from disk — there is no .git in the archive — so the secret file shipped with the code. Every value is live:

  • OPENAI_API_KEYFull sk-proj-… key — directly cost-bearing.
  • DATABASE_URLPostgres / Neon string, including username and password.
  • STRIPE · CLERKSecret keys and webhook signing secrets for both.

Rotate every one of these now. Treat them as compromised the moment the archive left the client's machine, then remove .env from all deliverables and move the secrets to the host's environment store.

01

Executive summary

The concept and the happy path are real and demoable — prompt to GPT-4 generation, publish at /p/[slug], embed, capture leads, take a Stripe checkout, and view analytics all work end to end. What is missing is the layer that makes a SaaS safe to charge for. Billing can be bypassed by any logged-in user in three separate ways, roughly a dozen debug endpoints ship live — several unauthenticated and leaking data — and the public embed widget carries stored XSS and raw eval() that run on every third-party site. The fixes are well understood and mostly mechanical, but they are blocking. Budget a focused Phase 0 hardening of about two to three engineering weeks before the product takes real money.

3
No-payment paths to an active subscription
~12
Debug & test routes live in production
~56
Unescaped output sinks in the embed widget
0
Automated tests, and no CI pipeline
263
any usages, with type checks disabled
1,510
console.* calls, several logging PII
02

Assessment scorecard

Critical 5
High 7
Medium 8
Low 10+
Secrets & credential hygiene
Live OpenAI key, database URL and Stripe/Clerk secrets shipped in the delivered archive.
F
Billing integrity
Three no-payment paths to an active plan; quotas defined but never enforced.
F
API authorization
Around a dozen debug and test routes live; middleware runs but protects nothing.
F
Public widget & XSS
~56 unescaped output sinks plus eval() executing on every embedding host.
F
Data model & migrations
Schema drift, silently dropped unique constraints, unbounded table scans.
D
Build & type safety
Type and lint checks disabled at build; 263 any usages.
D
Tests & CI
No automated tests, no pipeline, no continuous integration.
F
Core feature completeness
The actual product loop is genuinely built and works — the strongest area.
B
03

Findings

CriticalVerified in codeC1

Any logged-in user can grant themselves a paid subscription with no payment

Three separate routes provision an active subscription without ever charging a card:

  • stripe/create-subscription/route.ts:44 — writes status:"active" with a fabricated stripeSubscriptionId.
  • debug/create-subscription/route.ts:19 — the same bypass, with the caller choosing plan and status.
  • debug/test-webhook/route.ts:5 — calls the real provisioning handler with mock data, skipping Stripe signature verification.
ImpactDirect revenue loss — the entire paywall is optional.
RemediationDelete all three paths. Subscriptions may be created only by the signature-verified Stripe webhook.
CriticalVerified in codeC2

A debug and test API surface of ~12 endpoints ships live, several unauthenticated

  • api/debug/magnets/route.ts — no authentication; returns every user's magnets including owner emails, surfaced on a public page.
  • api/test-env/route.ts, api/test-db/route.ts — report which secrets are set, the environment, and database schema to anonymous callers.
ImpactCross-tenant data and PII exposure, plus infrastructure reconnaissance. None are gated by environment.
RemediationDelete the entire debug/* and test-* surface, or gate it behind a non-production check and an admin role.
CriticalVerified in codeC3

Stored XSS in the embeddable widget — around 56 sinks, no escaping

The 6,843-line widget.js/route.ts interpolates magnet.title, description and field.label straight into innerHTML in roughly 56 places. A search for any escaping or sanitisation helper returns nothing.

AttackA magnet titled with an onerror image runs attacker JavaScript on every third-party site that embeds the widget — persistent, and hitting every visitor.
RemediationEscape every interpolated value (or use textContent / DOMPurify); add a Content-Security-Policy header on the widget response.
CriticalVerified in codeC4

Lead capture allows identity spoofing and an unbounded table scan

  • api/leads/route.ts:30userId is read from the request body and trusted, so leads can be attributed to any user.
  • api/leads/route.ts:337 — the update fallback runs findMany() with no filter, loading the whole table into memory.
  • The public endpoint has wildcard CORS, no rate limit and no validation, and auto-creates rows.
ImpactCross-tenant lead poisoning, plus a denial-of-service risk as the table grows.
RemediationDerive the owner from the magnet, never the body; replace the scan with a filtered query; add validation and rate limiting.
CriticalVerified in codeC5

Plan quotas are cosmetic — every plan is effectively unlimited

canCaptureLeads() is defined at lib/subscription.ts:45 and is never called anywhere, so lead capture is unlimited on every plan. Magnet limits are enforced on only some paths, and read a plan the user can set on themselves.

RemediationEnforce the quota checks at every creation path, and collapse to a single source of truth for plan limits.
HighVerified in codeH1

Raw eval() in the public widget

widget.js/route.ts:6421 and :6441 run eval() on owner- and AI-authored strings. The React renderer uses mathjs and is largely safe — the genuine code-execution risk is the widget's raw eval.

RemediationReplace with a restricted mathjs instance; pass inputs through a scope object, not string substitution.
HighVerified in codeH2

Middleware runs but enforces nothing

middleware.ts is simply clerkMiddleware() with no auth.protect(). Every route is public unless it individually calls auth() — trivial to forget, and the root cause behind several exposures above.

RemediationProtect authenticated route groups in the middleware rather than relying on per-route checks.
HighH3

No rate limit or prompt cap on the OpenAI routes

The generate and customise routes only check authentication. A single leaked token means unbounded OpenAI spend, and the AI response is only shallowly validated before being stored and later evaluated.

RemediationAdd per-user rate limiting and prompt-length caps, and validate AI output against a schema before persisting.
HighVerified in codeH4

Subscription schema drift — unique constraints silently dropped

Migration 20250904084932 drops all three unique indexes on the Subscription table despite an unrelated name, so Stripe retries create duplicate rows and the schema disagrees with the migration history.

RemediationRestore the unique key on stripeSubscriptionId; make webhook handlers idempotent by event id; reconcile schema against migrations.
HighVerified in codeH5

Deploys never apply database migrations

scripts/vercel-build.js runs generate and build but never prisma migrate deploy, so the production database drifts from the schema and a runtime "repair" script papers over the gap.

RemediationAdd prisma migrate deploy to the build step and delete the runtime repair script.
HighVerified in codeH6

The build ships with type and lint checks turned off

next.config.ts sets ignoreBuildErrors and ignoreDuringBuilds, on top of 263 any usages, so type errors reach runtime silently. It also sets an invalid minifier flag and a build id that breaks caching.

RemediationRe-enable both checks, resolve the errors they surface, and remove the invalid options.
HighH7

Conflicting plan definitions and an unauthenticated magnet read

  • Two files disagree on plan limits (5 vs 20 magnets); enforcement and checkout can diverge, and the checkout price is hardcoded.
  • api/magnets/[id]/route.ts — an unauthenticated read returns full content plus owner email for any public magnet.
RemediationUse one plan-limits module, drive prices from Stripe price ids, and strip owner email from public responses.
MediumM1 – M8

Robustness and correctness gaps

PII in logs. 1,510 console.* calls; the leads route logs full bodies, emails and lead records into host logs — a GDPR exposure.
Error-detail leakage. Fifteen routes return internal error messages and stack detail to the client.
Expiry ignored. The active-subscription check never verifies the current period end, so lapsed subscriptions stay active.
No webhook idempotency by event id — Stripe retries duplicate state.
Model duplication. Magnet and UserMagnet overlap; different routes treat different models as the source of truth.
Deletion & indexes. Missing foreign-key indexes and no cascade — user deletion is blocked and data orphans.
Dead auth surface. A User.password field persists despite Clerk being the provider.
Cross-tenant admin write. An admin can change visibility on any user's private template.
Low · HygieneLOW

Cleanup and maintainability

No tests and no CI; four (prisma as any) casts where the client is out of sync; leftover template boilerplate and unused heavy dependencies; an invalid package version and a mismatched start port; flat route folders patched by rewrites instead of route groups; and a duplicated, deprecated server-packages declaration.

04

What is already done right

  • The real Stripe webhook verifies signatures, and the Clerk webhook verifies its svix signature.
  • Admin management is properly role-gated, and role derives from a fixed admin email — so there is no self-escalation path.
  • Magnet delete, publish and unpublish, and all private-template queries, are correctly owner-scoped.
  • The core product loop — generation, publish, embed, lead capture, checkout, analytics — is genuinely built and works. The foundation is real.
05

Where this differs from the prior review

A previous review already existed. This independent pass confirms most of it, with three corrections worth surfacing.

1

It missed the most serious issue

The prior report states no committed secrets were found. The delivered archive contains a live .env — its worst exposure is absent from that report entirely.

2

It overstated the formula-evaluation finding

The React renderer's mathjs use is largely safe. The real code-execution risk is the widget's raw eval(); the prior report treated the two as equal.

3

The migration problem is worse than "duplicates"

The two subscription migrations are not duplicates. The real issue is a later migration silently dropping the unique indexes.

06

Remediation roadmap — Phase 0

Complete these before any new features. Several later phases in the product roadmap depend directly on the things broken today, so Phase 0 is the foundation the roadmap assumes already exists.

01
Rotate every secret and scrub .env
New keys and database password; move to the host environment store.
Hours
02
Delete the debug and test surface
Closes the two headline exposures together, with the non-webhook subscription path.
Hours
03
Enforce quotas and unify plan limits
Quota checks at every creation path; one plan module; prices from Stripe ids.
1–2 days
04
Fix lead capture
Owner from the magnet; filtered lookup; validation, rate limit and tighter CORS.
1–2 days
05
Escape widget output and remove eval()
Escape every interpolation; restricted mathjs; a CSP header.
2–3 days
06
Make middleware protect, and throttle AI
Protect route groups centrally; add rate limits and prompt caps.
1–2 days
07
Subscription and migration integrity
Restore the unique key, idempotency and expiry check; add migrate-on-deploy.
1–2 days
08
Turn build checks back on, add minimal CI
Re-enable type and lint, stop logging PII; lint, typecheck and a smoke test.
Ongoing
07

Methodology & scope

Codebase at a glance

Source files
171
Lines of code
38,595
API routes
36
Debug / test routes
~12
any usages
263
console.* calls
1,510
Widget innerHTML sinks
~56
Automated tests
0

How this was assessed

A manual read of configuration, schema and middleware, followed by four parallel deep-dives across authentication and billing, the embed widget and expression evaluation, the data model, and build and code quality. Every finding is confirmed in code with file and line references.

Scope and limitations. This was a static code and security review. The application was not run, the live database was not queried, and endpoints were not dynamically tested. Proof-of-concept exploitation and a live build can be commissioned as a follow-on.