PART 7 OF 18

Hardening - Battle-Testing the API

January 4, 2026
4 min read

Previously: Built the generation infrastructure. Integration tests are passing. Flux.2 is generating beautiful, consistent assets.

Now: The "confident amateur" phase ends. It's time for a professional security audit.

The Reality Check (PR #8)

On Dec 28, I submitted a PR for the productionization work. Within minutes, my automated security auditors (Qodo and Gemini Code Assist) flagged a series of "Critical" and "High" priority issues.

As a solo dev moving at 10x speed with AI, it’s easy to get tunnel vision. I was so focused on the features that I left the screen door unlocked!

The Audit Findings

SeverityIssueImpact
CRITICALUnauthenticated GET endpointAnyone could read any project's memory files.
CRITICALRace Condition in Manual UpsertSimultaneous saves caused duplicate data and DB errors.
HIGHWeak Input ValidationNo schema enforcement on memory file uploads.
HIGHInformation LeakageAPI responses were returning raw Prisma error messages.

🛡️ Hardening Step 1: Authentication & Ownership

The first fix was securing the /api/projects/[id]/memory-files endpoint. Previously, it just checked if the project ID existed.

The Fix: Integrated auth() from Auth.js (NextAuth v5) to verify both the session AND project ownership.

// app/api/projects/[id]/memory-files/route.ts
export async function GET(req: NextRequest, { params }) {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const project = await prisma.project.findUnique({
    where: { id: projectId, userId: session.user.id }
  });

  if (!project) {
    return NextResponse.json({ error: "Project not found" }, { status: 404 });
  }

  // Now it's safe to fetch files...
}

🛡️ Hardening Step 2: Eliminating Race Conditions

The manual "find-then-create" logic I used for memory files was a classic race condition. If a user (or an agent) sent two rapid updates, the second one would try to create while the first was still processing, leading to unique constraint violations.

The Fix:

  1. Added @@unique([projectId, type]) to the Prisma schema.

  2. Switched to atomic prisma.memoryFile.upsert().

// The atomic approach
const memoryFile = await prisma.memoryFile.upsert({
  where: { 
    projectId_type: { projectId, type: validated.type } 
  },
  update: { content: validated.content },
  create: { 
    projectId, 
    type: validated.type, 
    content: validated.content 
  },
});

By making the operation atomic at the database level, the race condition simply vanishes.

🛡️ Hardening Step 3: Schema Enforcement with Zod

AI agents move fast, and sometimes they hallucinate payload structures. Without strict validation, the database becomes a junk drawer.

The Solution: Zod-guarded endpoints.

const MemoryFileSchema = z.object({
  type: z.enum(['plan', 'style', 'code', 'log']),
  content: z.string().max(100000), // Prevent DoS via huge payloads
});

const body = await request.json();
const validated = MemoryFileSchema.parse(body);

If the data doesn't match the schema perfectly, the request is rejected before it ever touches the database.

🛡️ Hardening Step 4: Masking Information Leaks

In development, seeing (error as any).message in the terminal is helpful. In production, returning that to the client is a security risk. It can leak table names, schema structure, or even snippets of user data.

The Fix: Catch Prisma errors and return generic, safe messages.

try {
  // ... db ops ...
} catch (error) {
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    // Log the detail server-side only
    console.error("DB Error:", error.code, error.message);
    // Return a sterile response to the client
    return NextResponse.json({ error: "Database operation failed" }, { status: 500 });
  }
}

The Verifier's Mindset

Hardening isn't a one-time task; it's a loop. After implementing these fixes, I had to:

  1. Reset: Wipe the dev database to apply the new unique constraints cleanly.

  2. Re-Migrate: Ensure the production schema matched the audit requirements.

  3. Audit Again: Verify that the security bots were now satisfied.

What I Learned

1. AI is an "Optimistic" Coder AI defaults to "Happy Path" logic. It writes the code that works first, but rarely the code that fails safely. You must prompt specifically for security.

2. Automated Audits are Non-Optional I missed the race condition. My AI missed the race condition. The bot found it in 30 seconds. Use the tools.

3. Atomic is Always Better If you ever find yourself writing if (exists) { update } else { create }, stop. Use upsert.


Coming Next

In Part 8: Completing the Cycle - Export Workflow, we reach the finish line… or so we think.

The build is finished. The API is hardened. But how do we get the assets out of the app and into the user's game engine? We deep dive into single-asset generation strategies and ZIP export architecture.

Final Stats Preview: 18 posts, 50+ files, 22 ADRs, and a complete production-ready export pipeline.


Commit References:

  • 27f9f0b - Security hardening: Auth, Zod, and Atomic Upserts

  • 68ab816 - Add unique constraint to MemoryFile model

Files Modified:

  • /app/api/projects/[id]/memory-files/route.ts - Auth & Race condition fixes

  • /prisma/schema.prisma - Unique constraints

  • /app/api/projects/route.ts - Error masking and user upserts



JH

Jordan Hindo

Full-stack Developer & AI Engineer building in public. Exploring the future of agentic coding and AI-generated assets.

Get in touch