← Back to project room
Featured projectshipped & live

ZeniStudy // dev log

A full-stack AI study platform that turns your own sources into study guides, flashcards and quizzes. Built security-first: an append-only credit ledger the database itself refuses to mutate, an atomic hold/settle/refund pattern around every LLM call, and server-authoritative grading the client can never forge.

independent projectTBC
Next.jsFastAPIPostgreSQLSupabase AuthAirwallexDeepSeek
1,194automated tests
43tracked security findings
11Alembic migrations
127files under mypy strict
01

overview & stack

The frontend is Next.js 16.2.10 on the App Router, React 19.2.7 and TypeScript 5, styled with Tailwind CSS v4 against a custom dark-first design system called Luminous. Server state runs through TanStack React Query v5 — no Redux, no Zustand. The UI primitives are hand-rolled rather than shadcn, with GSAP for motion, Floating UI for popovers, and the Supabase JS client for identity.

The backend is Python 3.12 + FastAPI, managed with uv, talking to PostgreSQL in production and SQLite in dev/test via asyncpg and async SQLAlchemy 2.0. Vercel hosts the frontend, Render hosts the backend and the database. No Docker — it leans on the native Vercel and Render build pipelines.

Two build guards run on every frontend build: one blocks secrets from leaking into NEXT_PUBLIC_* env vars, the other scans the client bundle for accidental server-code exposure. Both fail the build rather than warn.

02

the backend — feature-sliced, not MVC

Rather than the usual monolithic MVC split, each domain owns its own router, service, models and schemas: auth, billing, ai, sources, studyguide, flashcards, quiz, streaks, onboarding, and the rest. A feature lives in one folder, end to end.

That matters more than it sounds. When the billing rules changed, the blast radius was one directory. When the AI layer needed a kill switch, there was exactly one place to put it. Async SQLAlchemy 2.0 handles the ORM, with Alembic covering 11 migration revisions to date.

03

the money layer — an append-only ledger

All money is stored as integer USD micros. No floats touch currency anywhere in the system, so rounding drift is structurally impossible rather than merely unlikely.

The credit ledger is append-only, and that's enforced by the database, not by application code. DB-level triggers reject UPDATE and DELETE outright — so even a compromised service account, a bad migration or a panicked manual query can't rewrite history. Correcting a mistake means appending a compensating entry, which is exactly what an audit trail is supposed to force you to do.

Credits (real money), gems (points) and streaks live in separate schemas. Mixing them was never an option: one is a financial liability, the others are gameplay.

04

auth & the ownership model

Supabase Auth is the identity provider, wrapped in custom layers on both ends. The frontend sets an HttpOnly cookie so the raw token never touches browser JavaScript. The backend verifies JWTs via JWKS, rejects anon and service-role tokens outright, and supports server-side session revocation.

The ownership rule is strict and short: every resource is scoped to the owner derived from the verified token, never from the request body. That single discipline is what makes the whole API IDOR-safe — there is no code path where a client-supplied ID decides what a client-supplied ID is allowed to read.

Abuse detection runs on Redis with progressive backoff, backed by Cloudflare Turnstile when a client crosses the line.

05

payments — hold, settle, refund

Billing runs on Airwallex Hosted Checkout, recently migrated off Stripe with no Stripe code left behind. Credit grants only ever happen from a verified webhook — never from a client redirect, because a redirect is just a URL a user can visit twice. Signatures are HMAC-verified and event processing is idempotent.

AI spend uses an atomic hold → settle → refund pattern: credits are held before any LLM call goes out, settled on success, and refunded automatically on failure. A user never pays for a generation that didn't arrive, and the platform never eats the cost of one that did. Refund and clawback logic can auto-flag accounts that abuse the gap.

06

AI — DeepSeek, injection hardening, sealed quizzes

DeepSeek (OpenAI-compatible API) powers study guide, flashcard and quiz generation and grading. A kill-switch env var halts all AI spend instantly, per-day provider rate-limit fuses cap the damage, and every call tracks cost-versus-charge so margin is auditable per request rather than per month.

Prompt-injection hardening is real, not decorative. Untrusted content is fenced as data. Model output is Pydantic-validated and depth-bounded. Citations are re-derived server-side rather than trusted from the model — because a model that can be talked into citing anything cannot be the thing that decides what it cited.

Quiz answers are sealed with Fernet (AES + HMAC) and grading is server-authoritative. The client never computes its own score, so there is nothing to tamper with in the first place.

07

testing & CI

818 pytest tests on the backend, with mypy strict-mode across 127 files. 376 Vitest tests across 79 frontend files, plus Playwright end-to-end and v8 coverage.

The unusual part is how much of that is security regression testing. Auth abuse, IDOR, webhook forgery, prompt injection and quiz-grading integrity each have their own dedicated suite — so a fix that closed a hole once stays closed after the next refactor.

GitHub Actions runs ruff, mypy, pytest and pip-audit on the backend; lint, typecheck, vitest, Playwright and pnpm-audit on the frontend. Every push.

08

small details worth knowing about

The parts I'd point a reviewer at first:

18-phase security hardening program
Tracked findings SF-SEC-001 through 043, including replacing an unsafe pickle-based IPC channel in the PDF extraction subprocess with a custom non-executable, schema-validated binary codec.
Durable job pattern
Paid AI generation is idempotent and replayable, with an hourly maintenance job that recovers stuck jobs and issues refunds without anyone opening a ticket.
SSRF hardening
Custom outbound URL validation — DNS-pinned sockets and public-address checks — for any provider-supplied redirect URL.
Nonce-based CSP
Forbids inline scripts and eval, applied consistently across both Next.js and FastAPI responses rather than only where it was convenient.
Fail-safe rate limiting
The in-memory rate limiter refuses to boot in multi-worker mode until it's backed by shared Redis — a deliberate crash instead of a silent correctness gap.