Command: cat projects/study-platform.md
A study platform with payments, access control and timed exams
My own product: a sales site, a student app and an operations panel, three apps in one monorepo. The rules that touch money, access and personal data live on the server, in packages the three share, tested against a real Postgres. It is live and in commercial pre-launch; this case is about the engineering, not traction.
open the demoa simulation of the system with fictional data · 6 screens
- Role
- Technical founder and architect
- Period
- May 2026 – present
- What I did
Me: Architecture, data model, business invariants, trade-off decisions, review of critical changes and operations.
Private project, described without identifying the client, product or company.
Diagram
The sales site, the student app and the operations panel, with mandatory 2FA, call the same server-side packages through Server Actions; the pricing, access, billing, security, question-generation and tax rules live there. The packages write to PostgreSQL with RLS, receive the signed webhook of the checkout hosted by the payment provider and ask a language model for questions with schema-checked output. Every generated question stays inactive and reaches students only after human review.
Context and problem
People preparing for Brazilian civil-service exams buy generic material and can’t tell whether they’re ready for their specific exam. The product sells access per exam and runs timed mock exams that are comparable across students. To do that it has to charge without trusting the browser, grant and revoke access without money mistakes, and use AI to produce questions without publishing anything a person hasn’t reviewed.
Architecture
A monorepo with three Next.js apps (sales and content site, student app, operations panel) and shared packages. Critical rules (pricing, access, billing, security, content generation, tax) live in the packages, so all three surfaces run exactly the same logic.
- Data: PostgreSQL with versioned migrations. New sensitive tables ship with RLS and with anonymous-role privileges revoked.
- Identity: separate authentication instances for students and admins, with distinct secrets and cookies, lockout after failed attempts and mandatory 2FA for admins.
- Money: prices computed on the server, provider-hosted checkout, signed and idempotent webhooks, and refunds or chargebacks revoking access in the same transaction.
- Timed exams: timer, question draw and grading on the server, with a transactional lock so two simultaneous exams can’t award two ranking credits.
- Question generation and exam reading: question generation and PDF exam extraction with structured output re-validated against a schema; PDF text is treated as untrusted data; the maximum cost is reserved before every call.
- Jobs: idempotent routines for LGPD (Brazil’s data protection law), batched campaigns and a tax queue with leases, each with a heartbeat that alerts on absence.
Challenges
- Optimistic documentation. The docs said all nine critical business rules held. A line-by-line audit showed only two held without caveats. The four money and access gaps were fixed in six days, with an ADR for each.
- A green CI that checked little. Type-checking didn’t run on the apps and the e2e suite executed nothing. At the same time the compiler overflowed its stack on an ORM-generated type and hid 45 errors; the cause was isolated by testing variants one by one.
- Latency no code change would fix. The measurement pointed at the distance between function and database, not at the queries.
- Selling without delivering. Checkout accepted an item with no published material. The fix was a single “deliverable offer” predicate used by publishing, checkout and the catalog.
Solution
Money, access and personal-data rules moved out of the screens into packages tested against a real Postgres. Each became an invariant with an owner, a test and a mandatory review gate that can block the change. Where the failure was a race, the fix used a database primitive (conditional write, transactional lock, re-read inside the transaction) instead of an external lock. Delivery is now controlled by CI and by a local gate that requires checks on the exact commit and a review kept separate from the implementation.
What I would do differently
I’d write the concurrency tests before the screens, not after the audit. And I’d treat status documentation as something that needs proof and a date from day one: it was what hid the gaps the longest.
Constraints
- One person, a small budget and managed services.
- Serverless functions with one database connection each, so every extra query adds serial latency.
- Paid access with no money mistakes in either direction.
- Deleting personal data without deleting the tax record of the sale.
- No automatically generated question reaches a student without human review.
Decisions
Functions pinned to the database region
- Context
- A trivial query took hundreds of milliseconds because the functions ran on another continent, and every screen added several round trips.
- Choice
- Pin the execution region next to the database before optimizing any query.
- Gains
- Fixes the measured cause without rewriting code.
- Costs
- Depends on a single region.
Atomic claim for webhook events
- Context
- Two simultaneous deliveries of the same payment event could grant or revoke access twice.
- Choice
- Record the event with a conditional database operation before processing it, instead of read-then-write.
- Gains
- Idempotency enforced by the database itself, no external lock.
- Costs
- An unfinished claim needs expiry and reprocessing.
Rate limiting by criticality
- Context
- If the rate-limit infrastructure goes down, failing open is dangerous and failing closed takes the product down.
- Choice
- Redis first, a Postgres backstop second, and fail-closed only for credentials and money.
- Gains
- A partial outage neither opens brute force nor takes down the common routes.
- Costs
- Expensive generation routes stay on the permissive side until a new decision.
Enforced CSP with a per-request nonce
- Context
- The old policy was report-only and blocked nothing.
- Choice
- A nonce-based CSP with strict-dynamic, set in middleware.
- Gains
- Injected scripts do not run.
- Costs
- Pages became dynamically rendered.
Stack and why
- Next.js (App Router) in all 3 apps
- Server Components and Server Actions keep the rules on the server.
- Turborepo and pnpm
- Pricing, access, billing and security live in packages shared by the three apps.
- PostgreSQL and Prisma
- Versioned migrations and database primitives for concurrency.
- Auth.js with TOTP
- Two isolated instances (student and admin) and mandatory 2FA on the panel.
- LLM SDK with schema-validated output
- Generated questions start inactive and go through human review.
- Vitest, Playwright and GitHub Actions
- CI with 8 jobs: lint, types, regression guards, dependency audit, tests against Postgres, build, e2e and a gate.
Results
3 apps, more than ten internal packages, about 109k lines of TypeScript, 59 models and 64 migrations.
audited private repositoryRepository count(Audit of the private repository, Sep 2026)167 unit and integration test files and 24 end-to-end specs.
audited private repositoryRepository count(Audit of the private repository, Sep 2026)A trivial query dropped from 578 ms to 21–34 ms after pinning the region.
audited private repositoryMeasurement recorded in an ADR(Project ADR (not re-run in this audit), Sep 2026)Live site with an enforced CSP (nonce and strict-dynamic), HSTS preload and security headers.
audited private repositoryHeaders checked in production(Passive header inspection, Sep 2026)
Security angle
Attack surface
- Server Actions treated as public endpoints.
- Payment webhooks and cron jobs.
- Signed links to paid content.
- The admin panel and account flows (2FA, password reset).
Controls in place
- Prices computed on the server; checkout hosted by the provider.
- Webhook signature verified on the raw body, plus an atomic claim.
- Paid content served through short-lived signed links, checked on every request.
- 2FA enforced at the Server Action boundary, not only in the layout.
What I would test today
- Races on credits, ranking and single-use tokens with parallel requests.
- Swapping identifiers between accounts (IDOR) in Server Actions.
- End-to-end access revocation on refunds and chargebacks.
Evidence
- audited private repositoryAudited private repository(Audit of the private repository, Sep 2026)