Engineering · Guardrails

A Green Pipeline Will Ship the Same Mistake Forever

On a blueprint leak, a build that never failed, and teaching CI the decision it was never given.

I was reading a public website's JavaScript for an ordinary reason: I wanted to know what they used for video. I was looking at the files every visitor's browser downloads. I didn't use credentials, private endpoints or tricks.

What I found was more interesting than the video stack. One chunk of the client bundle carried the application's data model: every model name, every field on every model, and every enum with its values. From that alone I could describe how their system is built, table by table, from their own field names rather than from their marketing.

I scanned the same file for API keys, database URLs and cloud credentials, and found none. No rows and no customer records were in it either. It was a blueprint leak, not a data breach. That distinction matters, and I want to be fair about it. Nobody's data was exposed. What was exposed is how the system is built, which is worth something to a competitor and saves anyone probing the app a great deal of guessing.

How it got there

The application uses Prisma, version 5.22.0 according to the bundle itself. Somewhere in the frontend, code imported from the generated database client, most likely just to get an enum. The bundler followed the import and shipped what it found.

What it found was Prisma's browser stub. When @prisma/client is bundled for a browser, Prisma swaps in a small file whose only job is to let the import resolve. The file is easy to recognise: it contains the message "PrismaClient is unable to run in this browser environment." To make enums work, it also carries the Prisma namespace, and the namespace includes the list of models and a field list for each one.

My first instinct was to call this a mistake Prisma should have stopped. It isn't. The browser stub is deliberate, and Prisma 7 makes the idea explicit: its generator ships a dedicated browser entry that, in the documentation's words, "contains all model and enum types and values." Importing Prisma into browser code is a supported path.

So there's no bug to point at. Someone made a decision about disclosure, probably without noticing they were making one. The supported import is safe in the sense that it won't break anything. It isn't private.

Why the pipeline would never notice

I can't see their CI, so treat this section as a thought experiment rather than a report. It doesn't need to be more than that, because the outcome is the same for almost any healthy pipeline.

Lint passes, because the import is valid. Type checking passes, because the types are correct. Tests pass, because the enum has the right values. The build succeeds, the deploy succeeds, and the uptime checks come back green.

Every check answered the question it was built to ask. None of them was built to ask whether the data model belongs in public code. So the pipeline approved it, and it will approve it on every deploy after, with complete reliability.

CI/CD didn't fail here; it did exactly its job. A careless manual process makes a mistake once. A good automated one repeats it every time, because consistency is what automation provides. Judgment isn't part of the package. A pipeline knows only the rules someone wrote into it.

"Be more careful" is not a control

Someone will import an enum from the place it already exists, because that's the obvious move. Six months later someone will turn a server component into a client component and never look at what came along with it. That's ordinary engineering, and a system that depends on it not happening will fail.

The fix is to put the rule in the structure.

Mark server code as server code. In a Next.js app, one line makes the build fail if client code imports the module:

// lib/db.ts
import 'server-only';

Give shared values a home that says what it discloses. If the browser needs a status, define that status on purpose, in a module whose whole job is to be public:

// shared/order-status.ts — safe on both sides, and deliberately so
export const OrderStatus = { PENDING: 'PENDING', SHIPPED: 'SHIPPED' } as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

A type-only import from the generated client can keep the two in sync. import type is removed at build time, so it never ships.

Then make CI check the output, not just the source. The browser runs the bundle, not the source files. Prisma's browser stub leaves the same fingerprints in any schema, so one check covers every project. These are the patterns that matched the bundle I found:

# scripts/check-client-bundle.sh
STUB='PrismaClient is unable to run in this browser|prismaVersion|[A-Za-z]+ScalarFieldEnum *='
if grep -rqE "$STUB" .next/static; then
  echo "Prisma's data model is in the client bundle:"
  grep -rlE "$STUB" .next/static
  exit 1
fi

Before you trust that check, break it on purpose: import a Prisma enum into a client component and confirm the build goes red. A check that has never failed isn't known to work.

The tuition was already paid

Watching what breaks for other people is one of the cheapest engineering habits there is. You don't have to live through every leak or dead end yourself. Understand the mechanism, pull out the rule, and put the rule into the system.

The rule I took from this one:

What reaches the browser is a disclosure. Decide it on purpose, and have CI check the build output against that decision.

Someone else already paid for that lesson, so there's no reason to pay for it again. That may be the best use of CI/CD: it won't prove the software is right, but once you've learned a lesson, you only have to learn it once.

— fin —

✒️ caelum