Conventions
These are not style preferences. Each rule below is enforced by a piece of code that is named with it, and a change that breaks the rule fails a guard, a spec or a request rather than a review. The recipes under this track assume all of them. Where a request runs through the API is on Repository layout.
Response and error envelopes
Section titled “Response and error envelopes”Every successful response is { data }, or { data, meta } when the service returned a page.
libs/shared/common/src/interceptors/api-response.interceptor.tsis a global interceptor that wraps whatever the controller returns. A controller returns the bare value and never builds the envelope itself;apps/api/src/health/health.controller.tsrecords what happened when one did ({ data: { data } }).- A service signals pagination by returning
{ data: T[], meta: { page, pageSize, total, totalPages } }. The interceptor recognises the shape by thetotalkey. - Money leaves the API as numbers.
libs/shared/common/src/interceptors/decimal-serialization.interceptor.tsconverts Prisma decimals before anything else touches the body.
Every error is { error: { code, message, details? } }. libs/shared/common/src/filters/http-exception.filter.ts catches everything:
- An
HttpExceptionwhose body is already{ error: { code, message } }is passed through as is. That is how a service throws a stable code the storefront can translate. - A plain Nest exception gets its code from the status:
BAD_REQUEST,UNAUTHORIZED,FORBIDDEN,NOT_FOUND,CONFLICT,RATE_LIMITED,UNPROCESSABLE_ENTITY,INTERNAL_SERVER_ERROR. - A Prisma
P2002orP2003becomes 409CONFLICT;P2025becomes 404NOT_FOUND. Any other error is a 500 that is also reported to Sentry. detailsis included outside production only. Every log line the filter writes carries the request’s correlation id and a redacted URL.
Translatable text and locale resolution
Section titled “Translatable text and locale resolution”Every user-facing text is a Translatable JSONB object with a required default key and one key per locale (libs/shared/common/src/interfaces/translatable.interface.ts). Slugs, SKUs, emails, prices and flags are not translatable. A row is stored whole; nothing resolves a locale inside a service.
libs/shared/common/src/interceptors/translatable.interceptor.ts runs on every response and replaces each Translatable with one string:
- The locale comes from
X-Localewhen present, else fromAccept-Language.X-Localeexists because a browser page cannot setAccept-Language. - Three rungs: the requested locale, then the store’s default locale, then
default. A locale the store has not enabled parses to a sentinel that matches no key, so it falls to the store default. No header at all resolves todefault. - The enabled set and the default are runtime configuration.
apps/api/src/modules/settings/store-locale.registry.tskeeps a process-wide snapshot, loaded at boot, replaced on every settings write and re-read every minute. X-Resolve-Locale: falsereturns the whole object, which is what an edit form needs. The admin sends it on every call:libs/admin-services/src/shared/http-options.tsdeclares theADMIN_RAW_LOCALEcontext token with a default oftrue, andapps/admin/src/app/core/interceptors/accept-language.interceptor.tsattaches the header whenever the token is set. The storefront never sends it.
Writes are checked against the same snapshot:
- A Translatable DTO extends
TranslatableLocaleKeysDto(apps/api/src/common/dto/translatable-locale-keys.dto.ts); itsIsEnabledLocalevalidator refuses a value for a locale the store does not run. apps/api/src/common/translatable-dto-parity.spec.tsfails a DTO that declares onlydefault, because the validation pipe’s whitelist would strip every other locale silently.test/e2e/locale-spine.e2e-spec.tsproves the chain under two store fixtures.
Permissions and ownership
Section titled “Permissions and ownership”apps/api/src/common/session-auth.guard.ts runs on every route that is not @Public(). It resolves the Better Auth session cookie, loads the user with its role and permissions fresh from the database on every request, and sets req.user = { id, role, permissions }. A permission change takes effect on the next call. A soft-deleted or deactivated user is refused even with a valid cookie. @OptionalAuth() lets an anonymous request through with no req.user.
Every admin endpoint carries @Permissions('<module>:<action>') from libs/shared/common/src/decorators/permissions.decorator.ts:
apps/api/src/modules/auth/guards/permissions.guard.tsanswers 403AUTH_PERMISSION_INSUFFICIENTunless the user holds one of the listed strings.SUPER_ADMINpasses everything.- A route with no decorator is open to any signed-in user, so a forgotten decorator is a real hole, not a default.
- The strings come from one catalogue,
libs/shared/permissions/src/index.ts: twenty-six modules times five actions (view,create,update,delete,manage). apps/api/src/permission-key-drift.spec.tsfails in both directions: a controller naming a key outside the catalogue, or a catalogue module that no route enforces. Adding a module means adding it there, mirroring it inprisma/permission-catalogue.ts(apps/api/src/permission-catalogue.parity.spec.tschecks the copy) and giving it at least one enforcing route.
A customer endpoint that reads or writes one customer’s data carries @OwnerGuard() (libs/shared/common/src/decorators/owner-guard.decorator.ts):
libs/shared/common/src/guards/owner-guard.guard.tsrefuses the request whenreq.user.idis missing, and refuses a route that carries both@OwnerGuard()and@Public().- The service then matches the entity’s owner against
req.user.id. That is where a 403 for someone else’s order comes from; the guard alone never proves ownership. - A route that also serves anonymous callers takes
@OptionalAuth()instead, never both.apps/api/src/modules/orders/orders.controller.tsexplains the swap above itsPOST.
Idempotency keys
Section titled “Idempotency keys”A sensitive write takes X-Idempotency-Key through @IdempotencyKey(IdempotencyKeyPipe) (apps/api/src/common/decorators/idempotency-key.decorator.ts). The pipe requires the header and validates it as a UUID v4 (apps/api/src/common/dto/idempotency-key-header.dto.ts). The routes that carry it:
- Customer and admin order creation,
apps/api/src/modules/orders/orders.controller.ts. - Inventory adjust and transfer,
apps/api/src/modules/inventory/inventory.controller.ts. - The return refund,
apps/api/src/modules/returns/returns.controller.ts. - The manual search-engine submission,
apps/api/src/modules/seo/seo.controller.ts. - The webhook test delivery,
apps/api/src/modules/webhooks/webhooks.controller.ts.
Gift-card redemption is not its own endpoint: redeem in apps/api/src/modules/gift-cards/gift-cards.service.ts runs inside order creation and rides the order’s key. No card payment provider exists, so no payment endpoint carries one yet.
apps/api/src/common/idempotency.service.ts owns the state machine:
- Each
(scope, key)is stored in Redis underidemp:<scope>:<hash>for twenty-four hours (IDEMPOTENCY_TTL_SECONDSinapps/api/src/common/idempotency.constants.ts), with a hash of the caller and a hash of the body. - A replay with the same body returns the stored response without running the handler. The same key with a different body is a 409. The same key from a different caller is a 403. A key whose first request is still running reports in-flight.
- When the handler throws, the entry is deleted so a retry can succeed. When Redis is down, the request goes through and the database’s unique constraints are the safety net.
apps/api/src/common/idempotency.module.tsis global, so a new module injects the service without importing anything.
Soft delete
Section titled “Soft delete”Every model in SOFT_DELETE_MODELS (apps/api/src/prisma/prisma.service.ts) has a deletedAt column and is never hard-deleted.
- The Prisma client is extended so that
findMany,findFirst,findUniqueandcountadddeletedAt: nullto thewhere, unless the caller already nameddeletedAt, which is how an admin recovery screen reads deleted rows. countis on the list because a paginated list whosemeta.totalcounted deleted rows rendered an empty last page.- Better Auth gets the same extended client, so a soft-deleted user cannot sign in.
- A delete endpoint sets
deletedAtor a status such asARCHIVED. The append-only tables (order items, inventory movements, gift-card transactions, promotion usage, the audit log) are never deleted at all.
Atomic decrements
Section titled “Atomic decrements”A contested number is never read, computed and written back. The write carries its own guard in the WHERE clause and the affected-row count is the answer:
- Stock:
decrementStockinapps/api/src/modules/inventory/inventory.service.tsrunsUPDATE "StockLevel" SET "onHand" = "onHand" - qty WHERE ... AND "onHand" >= qtyand returnsfalsewhen no row changed. - Gift-card balance:
redeeminapps/api/src/modules/gift-cards/gift-cards.service.tsusesupdateManywithcurrentBalance: { gte: amount }and throwsGiftCardInsufficientBalanceExceptionon a zero count. - Promotion usage:
recordUsageinapps/api/src/modules/promotions/promotions.service.tslocks the row withSELECT ... FOR UPDATEinside a transaction and re-checks the limits under the lock before inserting the usage row.
Sort allowlists
Section titled “Sort allowlists”A list endpoint sorts only by a column named in a constant the module owns, never by a string taken from the request:
VALID_STOCK_SORT_FIELDSandVALID_MOVEMENT_SORT_FIELDSinapps/api/src/modules/inventory/inventory.constants.ts; the query DTO also carries them in@IsEnum.GIFT_CARD_SORT_FIELDSinapps/api/src/modules/gift-cards/gift-cards.constants.ts, with a default field for anything else.AUDIT_LOG_SORT_FIELDSinapps/api/src/modules/audit-log/audit-log.constants.ts.
A sortBy outside the list falls back to the default field or fails validation. It is never interpolated into orderBy.
Validation and sanitisation
Section titled “Validation and sanitisation”apps/api/src/main.ts installs one ValidationPipe with whitelist: true, forbidNonWhitelisted: true and transform: true:
- A property without a class-validator decorator is stripped. A body carrying an unknown property is refused with 400
VALIDATION_ERROR. The body reaches the controller as an instance of its DTO class. - The failure
detailsare flattened bylibs/shared/common/src/utils/validation-errors.ts, so a nested Translatable error names its field. - Admin and customer DTOs are separate classes, even for the same entity.
apps/api/src/modules/users/dto/admin/andapps/api/src/modules/users/dto/customer/are the pattern, so a customer can never post a field only staff may set. - On the admin side every response is parsed by a Zod schema from
libs/admin-types/before a screen sees it.
Rich text is sanitised on write with sanitizeRichText or sanitizeTranslatableHtml from libs/shared/common/src/utils/sanitize-rich-text.ts: sanitize-html with an allowlist of p, br, strong, em, a, lists and h1 to h4, https, http and mailto links only, and a pass that unwraps a block the editor nested inside a heading. Products, categories, CMS pages, notification templates and campaigns all call it in their service, not in a DTO. A new rich-text field does the same.
Raw SQL
Section titled “Raw SQL”$queryRaw and $executeRaw are used where the typed client cannot express the query, and each use is marked as sanctioned in a comment beside it:
- The stock decrement and the
RETURNINGreads inapps/api/src/modules/inventory/inventory.service.ts, and the promotion lock, all bind their values through the tagged template. - The full-text search in
apps/api/src/modules/search/search.service.ts, where every value is aPrisma.sqlfragment. - The order and return number sequences in
apps/api/src/modules/orders/orders.service.tsandapps/api/src/modules/returns/returns.service.ts, the two places that use theUnsafevariants: the only interpolated value is a sequence name built from a fixed prefix and the current year.
Nothing builds SQL from request input. A new raw query needs a reviewer and the same comment.
Browser caching and 304
Section titled “Browser caching and 304”apps/api/src/bootstrap/cache-control-hardening.ts, applied first in main.ts, disables Express’s ETag and sets Cache-Control: no-store on every response before any handler runs.
- The only sanctioned way to cache is
@PublicCacheable(maxAgeSeconds, varyOn?)(libs/shared/common/src/decorators/public-cacheable.decorator.ts), which emitsCache-Control: public, max-age=Nand aVaryheader when the body depends on a request header. - It belongs on a public, read-only route whose body does not depend on the caller: the sitemaps,
robots.txt, thellms.txtfiles, the geo lookups. Never on a route that carries@Permissionsor returns customer data. - A raw
@Header('Cache-Control', ...)anywhere else is an unreviewed bypass.
Because nothing is cached, a browser should never send If-None-Match and a 304 should never arrive. Both clients still treat one as success rather than as an error: apps/admin/src/app/core/interceptors/error-normalizer.interceptor.ts turns a 304 into an empty success response before the error mapping, and libs/storefront-services/src/lib/shared/browser-api-client.ts returns from a 304 before its !response.ok branch. A new client, or a new error mapper, keeps that short-circuit.
Session bootstrap
Section titled “Session bootstrap”Both front ends restore a session with one getSession() call and commit it in one step or not at all.
apps/admin/src/app/core/session-bootstrap.tsis an app initializer. It callsgetSession()and, when a staff user comes back, callsAuthStore.setSession({ user })(apps/admin/src/app/core/stores/auth.store.ts) once. Onnull, which covers logged-out and transient failures alike, it leaves the store untouched. There is no access token, no/mefollow-up and noclearSession()on the boot path.boot()inapps/storefront/src/app/layout/layout-shell.component.tsdoes the same for the customer behind the browser-only gate, commits throughapps/storefront/src/app/services/auth-state.service.ts, and marks the boot settled in afinallyso consumers wait on the outcome instead of polling.- A transient 429 or 5xx never logs anyone out. The session is one signed
HttpOnlycookie; nothing about it is stored inlocalStorage.
Request ids, secrets and logs
Section titled “Request ids, secrets and logs”libs/shared/common/src/middleware/correlation-id.middleware.ts runs first on every route. It takes X-Request-Id from the request or mints a UUID, sets req.id and echoes the header back.
- The pino logger configured in
apps/api/src/app.module.tsuses the same value as the request id on every line, and the exception filter logs it beside every error. - The admin’s
correlation-id.interceptor.tssends a fresh id per call and surfaces it in error toasts. The storefront’s server client (apps/storefront/src/server/api-client.ts) sets one on every SSR call. - On an installed store the edge forwards the client’s id or mints one, so one id follows a request end to end.
Secrets come from the environment only: .env on a checkout, the installer-written .env.production on a box, never a file in the tree. The API refuses to boot in production without CORS_ORIGIN, without the Resend keys when mail is on, and without a license file.
Logs never carry a credential:
- The logger redacts the
authorizationandcookieheaders, and request and response bodies are never logged. libs/shared/common/src/utils/log-redaction.tsblanks any query parameter whose name looks like a secret or personal data (email,phone,token,password,secret,api_key,signatureand more) and any value shaped like an email address, whatever the parameter is called.- The idempotency service logs a sixteen-character hash of a key, never the key.
- A new log line follows the same rule: an id, a code, a count, and nothing a person typed.
Shared constants
Section titled “Shared constants”Two more single sources keep the API and the front ends in step:
- Every
/v1/admin/*path is a constant inlibs/shared/admin-routes/src/index.ts, used by the controller (@Controller(node.base),@Get(node.sub)) and by the Angular service that calls it, so a path is spelled once. - Every storefront route is in
apps/storefront/src/lib/routes.ts.
Adding an endpoint, a screen or a page starts by adding the constant; the recipes under this track show where.