Code โบ tail-villain
Hardening the Path to v1
Establishing identity, request, and deployment boundaries before the v1 launch
The last stretch before v1 was less about adding one more feature and more about deciding where the product needed hard boundaries.
Tail Villain calls AI often. Roadmap generation, interview turns, study sessions, and reports are not cheap read requests. If those endpoints are open too loosely, cost and abuse become the same problem.
So the work around April 30 and May 1 was mostly about control surfaces: who is sending the request, how often they can do it, whether their account is verified, and whether the deployed system behaves the same way the local system did.
The first boundary was throttling.
A simple IP-based limiter is easy to build, but it is not precise enough for a product with logged-in users. People share networks. A school, office, cafe, or apartment building can put unrelated users behind the same IP. If one person burns through the limit, another person should not lose access just because they share the same network.
So I moved the limiter toward a user-aware shape. Authenticated requests are tracked by user ID. Anonymous requests still fall back to IP. Policies are split by weight instead of pretending every endpoint has the same cost.
That distinction matters for AI endpoints. Roadmap generation and interview turns directly consume model budget. Auth endpoints have a different risk profile, but they are still exposed to brute-force and abuse. Both need limits, just not the same limit.
There was also a guard-ordering problem. A user-aware throttler only works if user context exists when it runs. If the global guard executes before authentication, the request has no user attached yet. Security code often fails in the spaces between layers, not inside the condition itself.
That pushed the request flow into a clearer sequence: IP-level protection, authentication, email verification, and then user or credit-level checks.
The ID boundary needed work too.
Internal database IDs should not be the public language of a product. I moved toward prefixed public IDs so URLs and API responses could expose stable resource identifiers without leaking raw primary keys. A user, roadmap, or session can have an external ID that tells the product story without exposing storage details.
This was not only a security cleanup. Public IDs also make logs, support, and future integrations easier to reason about. The database gets to keep its internal shape. The product gets a safer external shape.
LLM usage logging followed the same pattern. The point was not to stare at raw usage rows. The point was to turn expensive behavior into visible behavior. If I cannot see which operations cost the most, rate limits and credit rules become guesswork.
Cost has to be observable before it can be governed.
The next boundary was identity.
An account can exist before it is trustworthy enough to access sensitive product flows. That is why I added OTP-based email verification and a guard that blocks unverified users from protected endpoints when verification is required.
The UI had to explain that state without turning it into punishment. A verification banner and modal gave the user a direct next step instead of letting requests fail silently.
I also added admin settings for global flags. Some policies should not require a full deploy every time they change. Email verification requirements and similar system switches need an operational surface, especially when a product is moving from local testing into public use.
Deployment exposed a different kind of boundary.
The monorepo worked locally, but production had stricter opinions about build order, output paths, module formats, and runtime entrypoints. Shared types and Prisma generation had to happen before the backend and web builds. The backend output had to land where Vercel expected it. A package format mismatch could pass unnoticed until the compiled NestJS app tried to load it in production.
None of that feels like product work when you are staring at it. But for v1, it is product work. Users do not care whether the failure came from TypeScript configuration, a module format mismatch, or a deployment setting. They only experience the product not starting.
I also aligned the local database version with the production target. That kind of change is boring in the best way. The less difference between local and production, the fewer surprises appear in the path that handles auth, sessions, credits, and user data.
Listed separately, the work sounds scattered: public IDs, LLM usage logs, throttling, email verification, admin settings, Vercel builds, module compatibility, and database version alignment.
It was one question from different angles.
Can the product identify who is making this request? Can it decide whether the account is allowed to proceed? Can it slow down expensive or risky behavior? Can the deployed server run the same assumptions that were verified locally?
That was the real path to v1. Not a launch celebration, but a minimum operational posture.
Before Tail Villain could invite more users in, it had to know how to protect itself while they were there.