CogniPrep has 51 direct dependencies, 36 runtime and 15 dev. Nine are pinned to an exact version and 42 carry a caret. There are no overrides, no resolutions and no patched packages, so those 51 lines are the whole version policy.
@aws-sdk/client-s3 3.712.0
@aws-sdk/s3-request-presigner 3.712.0
@trigger.dev/sdk 4.5.10
@trigger.dev/build 4.5.10
disposable-email-domains 1.0.62
drizzle-kit 0.31.10
next 16.2.4
openai 6.42.0
posthog-node 5.52.4
Eight of those nine never reach a browser. They are a storage client, a background job SDK, a data file, a migration tool, a model client and a server-side analytics client. The ninth is the framework.
Meanwhile the packages that decide what a visitor actually sees, which is React, the animation library, the icon set, the five Radix primitives, the toast library, the fonts and the client analytics, are all free to move on the next install.
That is the opposite of the advice I would give someone else. Lock what ships to users, let your internals drift. Writing it down made me look at how each pin got there, and every one has a different reason.
The nine reasons
The two AWS packages are pinned together because they must match. A presigner from one minor and a client from another is a signature mismatch at runtime, not a type error, and the pin is the cheapest way to keep them in step. The cost is visible: 3.712.0 against a current 3.1162.0 today. That is roughly 450 releases of a package that publishes several times a week, and I only noticed because I ran the numbers for this post.
The two Trigger.dev packages are pinned because the version is also a string. Two of our npm scripts call npx trigger.dev@4.5.10, so the number lives in four places: a dependency, a dev dependency and two command strings. A caret would make the first two drift away from the last two silently, and the failure mode of a CLI talking to a mismatched SDK is not a build error. Four hand-written copies of one number is bad, and the pin is what makes it safe rather than merely consistent.
next is pinned because a framework patch is a build change. 16.2.4 against a current 16.4.5.
drizzle-kit is pinned because it writes migration files that get committed, and a generator that changes its output format between installs is a diff nobody asked for. 0.31.10 against 0.31.11, so this one is nearly current by luck.
openai and posthog-node are pinned out of plain caution about server-side clients whose response shapes we parse. 6.42.0 against a current 7.51.0 is a whole major behind, which is a decision I should make deliberately rather than keep by default.
disposable-email-domains is pinned because it is data, not code. It is a list of throwaway email providers, and a new release can change who is allowed to register. Of all nine pins it is the only one currently sitting on the latest release, which is a nice accident: the package that matters most for behaviour is the one that happens to be up to date.
So the honest summary is that eight of the nine are behind, one by a major and one by hundreds of releases, and the pin that protects a user-facing decision is the only current one.
Two build-approval lists that disagree
pnpm-workspace.yaml is twelve lines and contains the same idea twice:
allowBuilds:
'@sentry/cli': true
core-js: true
esbuild: true
protobufjs: true
sharp: true
unrs-resolver: true
onlyBuiltDependencies:
- '@sentry/cli'
- esbuild
- sharp
- unrs-resolver
Six entries in the map, four in the list, and core-js and protobufjs appear in only one of them. Both keys exist because pnpm changed how build approval is recorded, and we are on pnpm 11.18.0.
The resolved state is written into node_modules/.modules.yaml after an install, which settles it: that file carries an allowBuilds block with exactly the six names and does not mention onlyBuiltDependencies at all. The newer key is the one in force, so the four-entry list is a fossil that reads like a contradiction.
One more detail from the same lines. protobufjs is approved to run its install scripts and has no directory in the virtual store at all, so it is not in the tree. core-js is in the tree at 3.49.0 as somebody else's transitive dependency, and its postinstall is a require wrapped in a try/catch that swallows everything. Two of the six approvals buy us nothing.
Approval lists are append-only in practice. Nobody removes a name once the install stops asking about it.
The scripts folder has the same shape
25 npm scripts, and scripts/ holds 11 TypeScript files and 13 directories. Nine of the 11 files are reachable by name through an npm script. The other two are run by hand with tsx, and the 10 generator directories are mentioned by no script at all.
That is not a bug either, it is just what happens when a folder collects one-off tools: the ones you run monthly get a name in package.json, the ones you ran once keep their path.
See it
The one dependency in that list of nine that a stranger can detect is the framework, and it says so on every response:
curl -sI https://cogniprep.app/ | grep -i 'x-powered-by\|x-nextjs'
x-powered-by: Next.js
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
poweredByHeader is one boolean in next.config.mjs away from gone, and it is not in ours, so the header is on across all 398 URLs in our sitemap.
For the other side of the split, ask for a dynamically rendered page and read its preload header:
curl -sD - -o /dev/null https://cogniprep.app/games/zzz | grep -i '^link:'
Three .woff2 files come back. Two of them, Geist_Variable and GeistMono_Variable, are shipped inside the geist package, which carries a caret, so the exact bytes a visitor downloads on the most visible part of the page are decided by whenever somebody last ran an install. The versions I was careful about are the ones nobody can observe.
Pick your pins by blast radius, not by how server-ish the package feels. I had it backwards and the audit is what told me.
Top comments (0)