Introduction

OverviewPhilosophyStructureUpdatesFAQ

Usage

Other

The standard

The standard

Every convention the audit enforces, and why each one exists.

These 34 rules are the conventions this template holds, as the audit enforces them. Run kreogen audit . against any Next.js repository to measure it against them, or kreogen standard explain <rule> for one in full.

Severity is calibrated rather than felt, because it drives the exit code: blocker means attacker-controlled input or another tenant's data crosses a trust boundary, high means a production incident or a choice that makes every later migration harder, medium a real cost with no incident attached, and low ergonomics.

kreogen audit runs the general profile by default: the rules that hold in any Next.js repository. This page lists every rule, including the few that encode a preference specific to kreogen — each of those says so under its own heading, and --profile kreogen is what turns them on.

Data and tenancy

RuleSeverityWhat it holds
data/tenant-queries-scopedblockerEvery model carrying a tenant column is registered for scoping
data/raw-queries-reviewedhighEvery raw query is reviewed for its own tenant filter
data/migrations-committedmediumMigrations are committed and applied to an empty database in CI

Every model carrying a tenant column is registered for scoping

data/tenant-queries-scoped

The scoping extension only scopes models it has been told about, and it fails open: a model that declares organizationId but is missing from TENANT_SCOPED_MODELS is queried across every organisation with no error, no type change and no failing test. That is the exact bug the helper exists to prevent, and it is invisible until one customer sees another's rows -- at which point it is a disclosure, not a defect. The unscoped database export returns every organisation's data and nothing in the signature of findMany() distinguishes the two clients, so the registry is the only place the guarantee lives.

Target. Every model declaring organizationId appears in TENANT_SCOPED_MODELS, or is excluded with a comment naming the library that filters it instead. forOrganization(orgId) forces the column into the filter of every read, write and delete and into the data of every create, so a request body cannot choose its own tenant, and callers read through the db returned by requireOrganization(). A test derives the expected list from the schema, so the next model to gain the column cannot be forgotten.

Reference. packages/database/index.ts

Every raw query is reviewed for its own tenant filter

data/raw-queries-reviewed

$queryRaw and $executeRaw bypass Prisma extensions entirely, so the one mechanism that guarantees a query cannot reach another organisation's rows is simply not in the path. A raw query is a legitimate thing to write -- a recursive CTE, a bulk update, SELECT 1 for a readiness probe -- and that is exactly why this is not a blocker: the finding is not that the query is wrong, it is that nothing except a person can tell whether it is. $queryRawUnsafe adds interpolation to that, so a raw query built from a request parameter is both an injection and a tenancy hole in one line.

Target. Each raw call site is either replaced with a scoped query or annotated with a // kreogen:raw-reviewed <reason> comment on the call's own line or in the comment block directly above it, naming the filter it carries -- the organisation id bound as a parameter, not interpolated -- or why it needs none. $queryRawUnsafe and $executeRawUnsafe do not appear with a value that came from a request. Health checks and migrations, which touch no tenant data, are called out as such. The annotation is the artefact of the review: without one, the next audit cannot tell a reviewed SELECT 1 from an unreviewed bulk update.

Reference. packages/database/CLAUDE.md

Migrations are committed and applied to an empty database in CI

data/migrations-committed

Without committed migrations there is no way to build the schema from nothing, so CI has no empty database to test against, a new machine cannot be brought up from the repository, and drift between the schema file and what is actually deployed becomes undetectable -- the schema says one thing, production says another, and no artefact records how production got there. A schema change without a migration should fail in CI, which it cannot do if migrations are not a thing the repository has.

Target. prisma/migrations/ (or drizzle/) is checked in, CI applies it to an empty database and then verifies the schema has not drifted from it. The generated SQL is read before it is committed -- the two that bite are a NOT NULL column with no default added to a populated table, and an index created without CONCURRENTLY on a large one.

Reference. packages/database/prisma/schema.prisma

Delivery

RuleSeverityWhat it holds
delivery/public-env-substituted-at-starthighNEXT_PUBLIC_* values are substituted when the container starts
delivery/container-runs-as-non-rootmediumThe runtime image drops to a non-root user
delivery/health-and-readymediumEvery deployable app answers /health and /ready separately
delivery/parameterised-dockerfilelowOne Dockerfile serves every app, selected by --build-arg APP

NEXT_PUBLIC_* values are substituted when the container starts

delivery/public-env-substituted-at-start

Next inlines NEXT_PUBLIC_* into the client bundle at build time, so an image built with staging's values is staging's image and can never be promoted: the artifact that passed CI is not the artifact that reaches production, which is the one property a registry exists to give you. Every environment then needs its own build, and every difference between them is a difference nothing tested. kreogen bakes __NAME__ sentinels at build time and rewrites them from docker/public-env.json when the container starts, so one image serves every environment. Two details of that were paid for: URL-valued placeholders are parseable URLs on the reserved .invalid TLD, because sitemap and robots build a URL at module scope during static generation and a bare token fails the build outright; and the rewrite walks .rsc, .body and .meta as well as .js and .html, because the App Router writes the inlined values into prerendered flight payloads too -- while it did not, every statically prerendered route served the .invalid placeholder to real users.

Target. A single docker/public-env.json naming each public variable and its build-time placeholder. The Dockerfile's ENV block and the build task's env list are generated from it, and an entrypoint runs a substitution script that rewrites the placeholders across the built output before the server starts. One image, promoted unchanged from staging to production.

Reference. docker/substitute-public-env.mjs

The runtime image drops to a non-root user

delivery/container-runs-as-non-root

The process in the container parses requests from anyone on the internet, and root inside a container is uid 0 to the kernel: a write that escapes the process through a bind mount escapes as root on the host, and container-breakout advisories overwhelmingly start from a root process. It is also an operational wall rather than a preference -- OpenShift and any cluster running the restricted Pod Security Standard refuse to start an image whose USER is root, and that refusal arrives at rollout, after the image has been built, tagged and released. The order of the fix is the part that bites: create the user in the runner stage and copy the standalone output with --chown, because files copied as root and then handed to a USER that cannot write them produce a container that starts, serves, and quietly cannot write its cache.

Target. The final stage creates a system user and group with a fixed uid, copies everything the process writes with --chown to it, and declares USER before ENTRYPOINT. Nothing in the running image is owned by root, and no step at runtime needs to write outside the paths that were chowned.

Reference. Dockerfile

Every deployable app answers /health and /ready separately

delivery/health-and-ready

Liveness and readiness answer different questions and an orchestrator does different things with the answers. /health says the process is serving; /ready says its dependencies are. Collapsing them into one endpoint that checks the database means a failover lasting seconds fails the container HEALTHCHECK, and the answer to a failed healthcheck is a restart -- so every replica of an application that was serving perfectly well is killed at the moment its database comes back, and the restart storm outlives the blip that caused it. The mistake in the other direction is quieter: a /ready that touches nothing is a load balancer sending traffic to a container whose connection pool has not opened yet, which arrives as a burst of 500s on every deploy and looks like a bad release. kreogen's readiness route is dynamic = "force-dynamic" for the same family of reasons -- a readiness answer computed at build time is a lie about the state of a running container.

Target. Each deployable app has app/health/route.ts returning 200 and importing nothing, and app/ready/route.ts marked dynamic = "force-dynamic" that checks the database and the cache and answers 503 when one is down. The container HEALTHCHECK points at /health; the load balancer and any deploy gate point at /ready.

Reference. apps/app/app/health/route.ts

One Dockerfile serves every app, selected by --build-arg APP

delivery/parameterised-dockerfile

The stages are identical for every app -- prune the workspace, install from the pruned manifests, build the one package, copy the standalone output into a slim runner -- and the only thing that differs is which package turbo builds. A file per app duplicates the layer ordering, the BuildKit cache mount, the tini entrypoint and the generated NEXT_PUBLIC_* ENV block, and those copies diverge one edit at a time. The divergence is invisible until an image built from the stale copy reaches production, because each file builds successfully on its own. kreogen generates the ENV block from docker/public-env.json and bun run verify fails when the generated copy has drifted -- a check worth having precisely because there is one copy to check rather than one per app. The parameterised file is also what makes building every image in parallel a single docker buildx bake.

Target. One Dockerfile declaring ARG APP in each stage that needs it, pruning and building $APP and running apps/$APP/server.js, built with docker build --build-arg APP=web .. A docker-bake.hcl or a compose file names the apps, so the list of images lives in one place.

Reference. Dockerfile

Environment

RuleSeverityWhat it holds
env/no-direct-process-envhighThe environment is read only through the schema modules
env/public-vars-declared-for-buildhighEvery public variable is declared on the build task
env/validated-keyshighEvery package declares its environment variables as a schema
env/example-paritymediumEvery declared variable is documented in an example file
env/optional-by-defaultmediumOnly the database URL and the auth secret are required

The environment is read only through the schema modules

env/no-direct-process-env

A process.env read at a call site is invisible to everything built to keep the environment honest. It is not validated at boot, so the failure moves from container start to whenever that line first runs in production. It cannot be documented by any mechanical process, so it never reaches a .env.example. If it is a NEXT_PUBLIC_* read it never reaches docker/public-env.json either, and under turbo's strict envMode the value is stripped and reads undefined in the browser only. It also breaks the test suite's guarantees: @kreogen/testing supplies placeholder values so keys() validation does not fail a suite that never touches the service, and a raw read is outside that -- the test then depends on what happens to be set on the machine running it.

Target. process.env appears only in the modules that own it: each package's keys.ts, each app's env.ts, next.config.* and instrumentation*. Everything else imports env or the owning package's keys(), so every read is typed, defaulted and validated in one place.

Reference. apps/app/env.ts

Every public variable is declared on the build task

env/public-vars-declared-for-build

Next inlines NEXT_PUBLIC_* values into the client bundle at build time, and under turbo's strict envMode a variable the build task does not declare is stripped from that build's environment before Next ever sees it. The result is a value that is correct on the server and undefined in the browser. The build succeeds, typecheck succeeds, every server-side test succeeds, and the defect exists only in a built artefact -- this is exactly how kreogen's own documentation site shipped an export whose every internal link had lost its base path. In this repository the list is generated: the names are written once in docker/public-env.json, and bun run generate:public-env produces both turbo.json's build env list and the Dockerfile's ENV block from it, so the two copies cannot drift and bun run verify fails when they have.

Target. Every NEXT_PUBLIC_* key any client schema declares also appears in the build task's env list in turbo.json, written there by bun run generate:public-env from docker/public-env.json rather than by hand.

Reference. docker/public-env.json

Every package declares its environment variables as a schema

env/validated-keys

process.env.RESEND_TOKEN is typed string | undefined, and a value that is undefined at boot does not announce itself: the client is constructed with nothing, the container starts, the health check passes and the service behaves exactly as though the feature were configured until the first user waits for an email that was never sent. kreogen's house style depends on that absence being a declared state rather than an accident -- integrations degrade, they do not fail, and keys() is what makes the difference between a key that is deliberately optional and one somebody forgot to deploy. The two flags are not decoration. emptyStringAsUndefined is what stops a blank line in a .env satisfying a z.string() the author believed would reject it, so the app boots with an empty secret instead of refusing to start; skipValidation is what lets CI and the Docker builder stage run next build with no variables set at all, which is the only way an image can be environment-agnostic.

Target. Every package that reads the environment exports keys() built with @t3-oss/env-nextjs, declaring each variable's schema and its optionality, with skipValidation: process.env.SKIP_ENV_VALIDATION === "true" and emptyStringAsUndefined: true. Each app's env.ts composes the packages it uses through extends, so an app validates exactly the variables it can actually reach.

Reference. packages/auth/keys.ts

Every declared variable is documented in an example file

env/example-parity

A variable nobody documented is one a new engineer cannot set, and because integrations here degrade rather than fail there is no moment at which they find out: the app boots, the client is undefined, and the feature is simply absent with nothing in the logs to connect it to a missing key. The example files are the only place the full set is written down for a human, which is why adding a variable means editing the owning package's keys.ts and its .env.example. The reverse direction matters as much: a key documented but declared by no schema is a line every new environment dutifully copies and nothing reads, and it outlives whatever used to read it by years.

Target. The set of keys across .env.example files and the set declared across keys() schemas are the same set. Values in the example files are placeholders, never live credentials.

Reference. apps/app/.env.example

Only the database URL and the auth secret are required

env/optional-by-default

Every required variable is a variable that has to be set in every environment before anything runs at all -- a fresh clone, each CI job, every preview deployment and the Docker builder stage included. Making one key of one leaf integration required therefore stops the entire repository booting for everybody who has not been told, and the error names a package most of them have never opened. kreogen keeps exactly two: DATABASE_URL, because there is no application without it, and BETTER_AUTH_SECRET, because a signing secret that silently defaults is worse than a boot failure. Everything else is optional, its client is undefined when the key is absent, and the feature is absent with it.

Target. At most two required keys across every schema. Every other server key is .optional() or carries a .default(), and its client is constructed as undefined when the key is missing rather than throwing at import.

Reference. packages/email/keys.ts

Next.js

RuleSeverityWhat it holds
next/proxy-not-middlewarehighMiddleware lives in proxy.ts, with no middleware.ts beside it
next/app-routermediumRoutes are served from the App Router
next/server-components-defaultmediumRoute entrypoints stay server components
next/error-boundarieslowEvery serving app renders its own error and not-found boundaries
next/standalone-outputlowContainer builds emit a standalone bundle at a pinned tracing root

Middleware lives in proxy.ts, with no middleware.ts beside it

next/proxy-not-middleware

Next 16 renamed middleware.ts to proxy.ts. A directory holding both does not run both: one is silently ignored, and which one depends on the Next minor the deploy happened to install, so the same commit can behave differently in CI and in production with nothing in either log to say so. What is lost is not decoration. All three deployable apps here put real work in that file -- apps/app sets the security headers and then redirects a request with no session cookie to sign-in, carrying the headers onto the redirect; apps/web composes headers with i18n; apps/api answers the CORS preflight itself, because Next would 405 it and the browser reports that as an opaque CORS failure. When the wrong file is the one being read, all of that stops at once and every request still returns 200.

Target. Exactly one middleware entrypoint per Next app, named proxy.ts, and no middleware.ts left anywhere in the repository. The rename lands in the same commit as the Next 16 upgrade, because Next 15 does not read proxy.ts at all -- renaming ahead of the upgrade removes every middleware in the repository simultaneously.

Reference. apps/app/proxy.ts

Routes are served from the App Router

next/app-router

Every shared package here is written for the App Router. @kreogen/auth/session is awaited inside a layout, @kreogen/seo returns a metadata export, and the /health and /ready endpoints the production stack gates rollout on are route handlers under app/. Under the pages router none of that composes: the session lookup one layout does once has to be repeated in every page's getServerSideProps, and each one serialises its result through the page props into the browser. A hybrid repository is a legitimate state -- a migration in progress is how anyone gets here -- but only while it is moving, because two routers mean two data-fetching models, two error boundaries, and two places to remember the session check.

Target. One router per Next app, app/, with page.tsx and layout.tsx as server components and route handlers rather than pages/api. Any pages/ directory is deleted rather than emptied: a surviving pages/index.tsx still registers a route.

Reference. apps/app/app/layout.tsx

Route entrypoints stay server components

next/server-components-default

page.tsx and layout.tsx are a boundary rather than a file. Marking one "use client" does not make that file interactive -- it puts everything the file imports, transitively, into the client graph. In this repository that graph reaches @kreogen/auth/session and through it @kreogen/database, which imports server-only, and server-only is enforced by the bundler rather than by convention: a dynamic import of a server-only module inside parseError broke a production build here while passing typecheck, because the bundler traces dynamic imports too. A client entrypoint also cannot be async, so the awaited session check that redirects an anonymous request has nowhere left to live.

Target. No page.tsx or layout.tsx carries "use client". Interactivity lives in a sibling file that does, imported by the server entrypoint -- apps/app/app/(authenticated)/layout.tsx awaits the session and renders ./components/sidebar, which is the file holding the directive.

Reference. apps/app/app/(authenticated)/layout.tsx

Every serving app renders its own error and not-found boundaries

next/error-boundaries

The framework defaults are a bare browser page with none of the app around it: an unhandled render error becomes an unstyled 'Application error' in production, and a URL matching no segment becomes a page with no navigation on it. A user who reaches either has the back button and nothing else. The quieter half is the reporting -- error.tsx is where the error reporter is called, and it is what puts a digest in the logs beside the one shown on screen, which is the only thing connecting a support message to a stack trace. Without it a production error is visible to exactly one person and recorded nowhere.

Target. Each serving app has an error.tsx and a not-found.tsx covering the routes it serves -- at the router root, or at the route group that owns them, so a failing page keeps the layout around it. A global-error.tsx beside them covers the one case they cannot, a throw in the root layout.

Reference. apps/app/app/(authenticated)/error.tsx

Container builds emit a standalone bundle at a pinned tracing root

next/standalone-output

The Dockerfile's runner stage copies .next/standalone, and the builder stage asserts it exists rather than deferring that failure to container start, so a missing output: "standalone" fails loudly and early. outputFileTracingRoot is the quiet half: without it Next infers the root from the nearest lockfile, which in a monorepo can resolve to the app rather than the workspace root, and the standalone bundle lands at a path the Dockerfile does not look in. Both belong in the shared @kreogen/next-config rather than in each app's config, and the two opt-outs there are deliberate -- Vercel builds and serves its own output format and warns about standalone, and next start refuses to run against one at all, which is how Playwright's webServer once sat there until it timed out.

Target. Every containerised Next app resolves to output: "standalone" with outputFileTracingRoot pinned to the workspace root, from one shared config, with explicit opt-outs for Vercel (process.env.VERCEL) and for next start (NEXT_DISABLE_STANDALONE).

Reference. packages/next-config/index.ts

Security

RuleSeverityWhat it holds
security/no-committed-secretsblockerNo credential-shaped file is tracked by git
security/content-security-policymediumA Content-Security-Policy is served and extended per integration
security/rate-limitingmediumPublicly callable endpoints are behind a rate limiter
security/response-headersmediumSecurity response headers come from one shared middleware

No credential-shaped file is tracked by git

security/no-committed-secrets

A credential in a commit is compromised the moment it is pushed, and everything after that is damage control: the blob is in every clone, every fork, every CI cache and every mirror, and on a public remote it has already been read by the scanners that watch the push event stream. So the first step is rotating the credential at its provider, not removing the file from history -- a repository whose history has been rewritten while the key is still live has fixed the paperwork and none of the exposure. The name alone is not the finding, though: an .npmrc holding legacy-peer-deps=true and an .npmrc holding an _authToken are the same path, and grading the first as the second is how an audit spends its credibility. So the audit asks each of these files exactly one derived question -- does it contain a credential-shaped assignment -- and carries back the answer rather than the file, which is why no value from them reaches the fingerprint even now. Where the file cannot be opened at all, the finding says so instead of guessing. kreogen ships .env.example files that document variable names and never values, and the CLI writes .env.local from them at generation time precisely so that the only file holding real values is one .gitignore already covers.

Target. No tracked file holds a credential. A credential-shaped path -- an .env or dotted variant of it that is not an .example, .template or .sample, an .npmrc, .netrc, .pgpass, credentials.json or service-account.json, or .pem, .key or .p12 key material -- may be tracked only when it carries none: install flags, registry hosts and a public certificate are all legitimately committed. Names live in .env.example and in each package's keys(); values arrive from the environment, and .env and .env*.local are ignored.

Reference. .gitignore

A Content-Security-Policy is served and extended per integration

security/content-security-policy

The CSP is the only one of these headers that has to know what the application talks to, so it is the only one that breaks the application when an integration is added and the policy is not. The failure is entirely client-side: the script or the XHR is blocked by the browser, the server returns 200, nothing reaches the error reporter, and the feature is simply dead in production while every dashboard stays green. kreogen's answer is to group directives by the integration that needs them in packages/security/proxy.ts -- Stripe's frame sources are a separate group from its script sources precisely so that removing payments is deleting two consts rather than reading every directive to find the hosts hiding in one -- and to make extending the CSP a numbered step of adding an integration rather than something discovered from a console error a week later.

Target. contentSecurityPolicy.directives set on the shared options with objectSrc: ['none'], baseUri and formAction at 'self', frameAncestors: ['none'] and upgradeInsecureRequests outside development. Every third-party origin appears under the directive that needs it, grouped by the integration it belongs to, and localhost and websocket sources are added only in development.

Reference. packages/security/proxy.ts

Publicly callable endpoints are behind a rate limiter

security/rate-limiting

Every server action and every route handler is reachable by anyone who can reach the app, so without a limiter the only thing bounding an attacker's attempts is your own bandwidth: credential stuffing against the sign-in endpoint, enumeration of an invitation id, or an expensive report generated a thousand times. Better Auth's built-in limiter is in-memory and per-replica, so scaling to two containers doubles the attempts an attacker gets -- which is why kreogen backs the limiter with Redis. Two details decide whether it is a limiter at all. It must key on an identifier the infrastructure appended rather than on the raw leftmost x-forwarded-for, because that entry is whatever the client sent: a client sending a fresh one on every request lands in a fresh bucket every time and never hits the limit, while the limiter looks entirely configured. And it must fail open, because a limiter whose Redis is unreachable must not take down the feature it guards -- with ioredis that means the offline queue off, so a command issued while the socket is down is rejected immediately instead of hanging for as long as the outage lasts.

Target. A shared limiter over Redis -- a sliding window evaluated in one script so the trim, count and insert cannot interleave -- keyed on a client identifier read from the right-hand end of x-forwarded-for by the number of proxies actually in front of the deployment. Authentication endpoints are limited tightly, everything else by a global default, and every failure path allows the request and logs a warning.

Reference. packages/rate-limit/index.ts

Security response headers come from one shared middleware

security/response-headers

Next serves exactly the headers the application sets and no others, so an app without header middleware ships with no HSTS, no X-Frame-Options, no Referrer-Policy and no policy of any kind -- there is no framework default filling in behind it, and nothing in a log or a health check ever says so. The headers are one middleware away and nothing else in the stack provides them. kreogen keeps the set in packages/security/proxy.ts and every deployable app's proxy.ts applies it before anything that can return early: the app proxy runs the headers ahead of its session-cookie check specifically so an unauthenticated request is redirected with those headers still on the 302 rather than without them. Getting that order wrong leaves the sign-in path -- the one page an attacker is most interested in framing -- uncovered while every authenticated page looks perfectly fine.

Target. A proxy.ts in each deployable app applying a header middleware built from one shared options object, ahead of any branch that can return a response. One module owns the set; an app that has to differ extends the shared options rather than restating them -- the api app tightens default-src to 'none', because nothing it returns is a document.

Reference. packages/security/proxy.ts

Repository shape

RuleSeverityWhat it holds
structure/single-lockfilemediumThe repository has exactly one lockfile
structure/typescript-strictmediumTypeScript runs in strict mode
structure/workspace-owns-tsconfigmediumEvery workspace owns a tsconfig.json
structure/package-manager-pinnedlowThe root manifest pins the package manager to an exact version

The repository has exactly one lockfile

structure/single-lockfile

Two lockfiles are two dependency trees, and whichever tool runs first wins. A repository that installs with bun in CI while a stale package-lock.json still drives the update bot resolves two different sets of versions and only finds out at deploy -- and the Docker deps stage is exactly where it lands, because turbo prune --docker copies the lockfiles into the image and the install picks one. Zero lockfiles is the other half of the same failure: with nothing to freeze, every build re-resolves the declared ranges, so the image that passed CI and the image that ships are built from different code.

Target. Exactly one lockfile at the repository root, matching the pinned package manager, committed and installed with --frozen-lockfile in CI and in the image build.

Reference. bun.lock

TypeScript runs in strict mode

structure/typescript-strict

Strict mode is what makes the house style checkable rather than aspirational. Every integration client here is undefined when its key is absent -- resend?.emails.send(...) -- and that optionality is a compile-time fact only under strictNullChecks. Without it the same code typechecks as though every client were present, and the first deployment missing a key finds out at runtime, in the request that needed it, rather than at build. Turning strict on later is also strictly more expensive: the errors it reports are proportional to the code written without it.

Target. strict: true in the shared preset every workspace extends, alongside isolatedModules and moduleDetection: force. No workspace overrides it back to false, and no file carries a blanket @ts-nocheck.

Reference. packages/typescript-config/base.json

Every workspace owns a tsconfig.json

structure/workspace-owns-tsconfig

The root tsconfig.json is scoped to root-level tooling -- it excludes apps and packages outright. A workspace with no config of its own is not therefore unchecked, which would at least be obvious: tsc walks up to the root one and compiles the entire monorepo with none of that workspace's settings, so the typecheck runs green while checking the wrong thing -- the wrong jsx, the wrong module resolution, and none of the @kreogen/* path aliases that make an import resolve to source. It also inherits declaration emit, which apps and source-only packages turn off deliberately: leaving it on made tsc run portability checks that fail with TS2742 on transitive types reached through the package manager's hoisted store.

Target. Every workspace directory holds a tsconfig.json extending the shared preset -- @kreogen/typescript-config/nextjs.json for anything with JSX, base.json otherwise -- and states only what is genuinely local to it, which is usually baseUrl, include and exclude.

Reference. packages/typescript-config/base.json

The root manifest pins the package manager to an exact version

structure/package-manager-pinned

The lockfile records what was resolved; it does not record what resolved it. packageManager is the only declaration that pins a version, so without it CI, a laptop and the Docker deps stage can each run a different release against the same lockfile and produce a different tree -- and the newer one rewrites the lockfile format, which is discovered by bun install --frozen-lockfile failing in an image build rather than by a test. A range in the field is the worse version of no field at all: it reads as pinned in review and still resolves differently on every machine.

Target. A root package.json with "packageManager": "bun@1.3.14" -- an exact version, not a range -- alongside an engines block naming the Node and Bun versions the repository is supported on.

Reference. package.json

Testing

RuleSeverityWhat it holds
testing/runner-presentmediumA test runner is configured and something actually runs under it
testing/coverage-providerlowCoverage is instrumented rather than read from the inspector
testing/e2e-presentlowA browser suite exercises the paths unit tests cannot reach
testing/shared-presetlowEvery workspace tests through one shared preset

A test runner is configured and something actually runs under it

testing/runner-present

Every convention in this repository is enforced by something that runs. The alias that has to exist in two places, the strip list that decides what ships to a client, the auth invariants that present as a permanent 404 rather than as an auth error -- each of those is a test, and none of them can be a review comment instead. A repository with no runner has no place to put the next one of those, so the drift is not caught late, it is caught by a client. A repository with a runner configured and no test files is the worse half of the same problem: verify is green, CI reports a passing test stage, and the stage asserted nothing.

Target. A runner declared in the manifests -- vitest, in kreogen -- with a test script that runs it, and at least one test per package carrying an invariant that a reviewer cannot see by reading the diff.

Reference. packages/testing/__tests__

Coverage is instrumented rather than read from the inspector

testing/coverage-provider

The v8 coverage provider reads coverage through Node's inspector, and a repository on bun runs its suite under a runtime that does not implement those APIs -- so every coverage run failed with "Coverage APIs are not supported" while passing locally, where bunx hands off to node. The failure is therefore invisible until CI, and it looks like a broken pipeline rather than a config choice. istanbul instruments the source instead and works under either runtime, which is the only reason it is the default here rather than the faster provider.

Target. provider: "istanbul" in the shared preset, so the choice is made once and a package added later cannot reintroduce v8 by copying an older config.

Reference. packages/testing/index.ts

A browser suite exercises the paths unit tests cannot reach

testing/e2e-present

Sign-up, session creation, organization provisioning and the middleware redirect are four separate pieces that each pass their own tests and can still combine into an account that 404s on every page it visits. That is the incident apps/e2e was written for, and no unit test could have seen it: each piece was correct, and the composition was not. The suite is also the only thing that runs against the artefact that actually ships -- in CI the apps are built and started in production mode, which is where standalone output, the proxy and the real cookie domain first exist.

Target. A Playwright project with its own workspace, whose config starts the apps it tests, takes both hosts from the environment so the same suite can run against a deployed stack, and pipes server output so a server that dies on boot says why instead of timing out.

Reference. apps/e2e/playwright.config.ts

Every workspace tests through one shared preset

testing/shared-preset

A config per workspace is not duplication so much as divergence with a delay. The junit reporter and the coverage directory are the concrete case: .gitlab/ci/verify.gitlab-ci.yml collects both, and before packages/testing existed neither was produced by anything, so the pipeline published an empty report and an absent coverage artifact on every run while looking green. The same file also carries the @kreogen/* alias, the server-only stub -- that module throws outside an RSC graph, and a unit test of a server module is not one -- and the placeholder environment that stops keys() failing a suite that never touches the service. A new package copied from an old one misses at least one of those, and the symptom is a suite that cannot import the module it exists to test.

Target. One package exporting definePreset, and a vitest.config.ts per workspace that calls it with &#123; root: import.meta.dirname &#125; and nothing else but the options the preset takes. Reporters, coverage provider, aliases and setup files are decided once, in the preset.

Reference. packages/testing/index.ts

Toolchain

RuleSeverityWhat it holds
tooling/formatter-is-ultracitemediumFormatting and linting run through Ultracite over Biome
tooling/git-hooks-managedmediumGit hooks are managed by lefthook
tooling/conventional-commitslowCommit messages are linted against Conventional Commits
tooling/dependency-updateslowDependency updates arrive automatically

Formatting and linting run through Ultracite over Biome

tooling/formatter-is-ultracite

Profile. kreogen — this one encodes a house preference rather than a general convention, so kreogen audit does not evaluate it unless you pass --profile kreogen.

ESLint plus Prettier is two tools, two configs, two passes and a resolution order the two disagree about on JSX. Biome does both in one pass over the whole repository in about a second, and that speed is what makes a whole-repository pre-commit hook viable at all -- which matters here, because kreogen's lefthook config deliberately does not pass {staged_files}: Next route paths contain brackets and parentheses, and lefthook's quoting of those breaks file resolution on Windows. Ultracite is the preset rather than a hand-written config, so the React and Next rule sets stay three lines instead of three hundred.

Target. A root biome.jsonc extending ultracite/core, ultracite/react and ultracite/next, with lint and format scripts calling ultracite check and ultracite fix. No ESLint or Prettier config file, and neither package in any manifest.

Reference. biome.jsonc

Git hooks are managed by lefthook

tooling/git-hooks-managed

Without a hook manager, formatting and commit conventions are enforced only in CI, so every contributor discovers them after pushing. lefthook rather than husky specifically: it is a single binary with no postinstall script, where husky installs its hooks from a lifecycle script. pnpm blocks lifecycle scripts by default, so a husky repository that moves to pnpm silently stops having hooks at all, and nobody notices until a badly formatted commit lands on the default branch.

Target. A root lefthook.yml with a pre-commit running the formatter with stage_fixed: true, a commit-msg running commitlint, and a pre-push running typecheck. A root prepare script of lefthook install || true. No .husky/ directory and no lint-staged configuration.

Reference. lefthook.yml

Commit messages are linted against Conventional Commits

tooling/conventional-commits

The commit history is the input to release automation, so an unparseable subject is not untidiness -- it is a release that does not happen, or one that picks the wrong version. Linting locally as well as in CI is what stops the failure landing at the end of a pipeline rather than at the moment the message was typed. Merge requests are squash-merged, so the MR title becomes the commit that automation parses and needs linting too.

Target. A commitlint.config.js extending @commitlint/config-conventional, with a scope-enum naming the areas of the repository, wired into a commit-msg hook and into CI.

Reference. commitlint.config.js

Dependency updates arrive automatically

tooling/dependency-updates

Nothing about a stale dependency announces itself. Without an automated updater a repository drifts until an upgrade is a project rather than a merge request, and the security advisories that matter are the ones nobody was looking for.

Target. A renovate.json grouping related updates, holding non-zerover minor and patch updates for a few days before automerging, and refusing to automerge a vulnerability alert.

Reference. renovate.json

Boundaries

RuleSeverityWhat it holds
validation/server-actions-validatedblockerEvery server action parses its input before it runs

Every server action parses its input before it runs

validation/server-actions-validated

A server action is a public HTTP endpoint. Next gives each one a stable id and the browser POSTs to it directly, so anyone who has loaded the page can call it with arguments of their choosing. The TypeScript signature constrains the callers you wrote and nobody else -- from an attacker's position it is a compile-time fiction. An action taking (id: string) and passing it to a database call is an unauthenticated primitive over that table.

Target. Every exported function in a module carrying "use server", and every function with an inline "use server" prologue, is produced by defineAction(&#123; name, input, handler &#125;). The schema is the entry point: the handler cannot run until the input parses, and its argument type is derived from the schema rather than annotated by hand.

Reference. packages/validation/action.ts

On this page

Data and tenancy
Every model carrying a tenant column is registered for scoping
Every raw query is reviewed for its own tenant filter
Migrations are committed and applied to an empty database in CI
Delivery
NEXT_PUBLIC_* values are substituted when the container starts
The runtime image drops to a non-root user
Every deployable app answers /health and /ready separately
One Dockerfile serves every app, selected by --build-arg APP
Environment
The environment is read only through the schema modules
Every public variable is declared on the build task
Every package declares its environment variables as a schema
Every declared variable is documented in an example file
Only the database URL and the auth secret are required
Next.js
Middleware lives in proxy.ts, with no middleware.ts beside it
Routes are served from the App Router
Route entrypoints stay server components
Every serving app renders its own error and not-found boundaries
Container builds emit a standalone bundle at a pinned tracing root
Security
No credential-shaped file is tracked by git
A Content-Security-Policy is served and extended per integration
Publicly callable endpoints are behind a rate limiter
Security response headers come from one shared middleware
Repository shape
The repository has exactly one lockfile
TypeScript runs in strict mode
Every workspace owns a tsconfig.json
The root manifest pins the package manager to an exact version
Testing
A test runner is configured and something actually runs under it
Coverage is instrumented rather than read from the inspector
A browser suite exercises the paths unit tests cannot reach
Every workspace tests through one shared preset
Toolchain
Formatting and linting run through Ultracite over Biome
Git hooks are managed by lefthook
Commit messages are linted against Conventional Commits
Dependency updates arrive automatically
Boundaries
Every server action parses its input before it runs
GitLabEdit this page on GitLab