Skip to content
You are reading the unreleased documentation. No version is released yet, and these pages describe code that is not in a release.

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.

Every successful response is { data }, or { data, meta } when the service returned a page.

  • libs/shared/common/src/interceptors/api-response.interceptor.ts is 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.ts records 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 the total key.
  • Money leaves the API as numbers. libs/shared/common/src/interceptors/decimal-serialization.interceptor.ts converts 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 HttpException whose 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 P2002 or P2003 becomes 409 CONFLICT; P2025 becomes 404 NOT_FOUND. Any other error is a 500 that is also reported to Sentry.
  • details is included outside production only. Every log line the filter writes carries the request’s correlation id and a redacted URL.

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-Locale when present, else from Accept-Language. X-Locale exists because a browser page cannot set Accept-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 to default.
  • The enabled set and the default are runtime configuration. apps/api/src/modules/settings/store-locale.registry.ts keeps a process-wide snapshot, loaded at boot, replaced on every settings write and re-read every minute.
  • X-Resolve-Locale: false returns the whole object, which is what an edit form needs. The admin sends it on every call: libs/admin-services/src/shared/http-options.ts declares the ADMIN_RAW_LOCALE context token with a default of true, and apps/admin/src/app/core/interceptors/accept-language.interceptor.ts attaches 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); its IsEnabledLocale validator refuses a value for a locale the store does not run.
  • apps/api/src/common/translatable-dto-parity.spec.ts fails a DTO that declares only default, because the validation pipe’s whitelist would strip every other locale silently.
  • test/e2e/locale-spine.e2e-spec.ts proves the chain under two store fixtures.

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.ts answers 403 AUTH_PERMISSION_INSUFFICIENT unless the user holds one of the listed strings. SUPER_ADMIN passes 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.ts fails 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 in prisma/permission-catalogue.ts (apps/api/src/permission-catalogue.parity.spec.ts checks 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.ts refuses the request when req.user.id is 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.ts explains the swap above its POST.

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 under idemp:<scope>:<hash> for twenty-four hours (IDEMPOTENCY_TTL_SECONDS in apps/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.ts is global, so a new module injects the service without importing anything.

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, findUnique and count add deletedAt: null to the where, unless the caller already named deletedAt, which is how an admin recovery screen reads deleted rows.
  • count is on the list because a paginated list whose meta.total counted 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 deletedAt or a status such as ARCHIVED. The append-only tables (order items, inventory movements, gift-card transactions, promotion usage, the audit log) are never deleted at all.

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: decrementStock in apps/api/src/modules/inventory/inventory.service.ts runs UPDATE "StockLevel" SET "onHand" = "onHand" - qty WHERE ... AND "onHand" >= qty and returns false when no row changed.
  • Gift-card balance: redeem in apps/api/src/modules/gift-cards/gift-cards.service.ts uses updateMany with currentBalance: { gte: amount } and throws GiftCardInsufficientBalanceException on a zero count.
  • Promotion usage: recordUsage in apps/api/src/modules/promotions/promotions.service.ts locks the row with SELECT ... FOR UPDATE inside a transaction and re-checks the limits under the lock before inserting the usage row.

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_FIELDS and VALID_MOVEMENT_SORT_FIELDS in apps/api/src/modules/inventory/inventory.constants.ts; the query DTO also carries them in @IsEnum.
  • GIFT_CARD_SORT_FIELDS in apps/api/src/modules/gift-cards/gift-cards.constants.ts, with a default field for anything else.
  • AUDIT_LOG_SORT_FIELDS in apps/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.

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 details are flattened by libs/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/ and apps/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.

$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 RETURNING reads in apps/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 a Prisma.sql fragment.
  • The order and return number sequences in apps/api/src/modules/orders/orders.service.ts and apps/api/src/modules/returns/returns.service.ts, the two places that use the Unsafe variants: 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.

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 emits Cache-Control: public, max-age=N and a Vary header 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, the llms.txt files, the geo lookups. Never on a route that carries @Permissions or 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.

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.ts is an app initializer. It calls getSession() and, when a staff user comes back, calls AuthStore.setSession({ user }) (apps/admin/src/app/core/stores/auth.store.ts) once. On null, which covers logged-out and transient failures alike, it leaves the store untouched. There is no access token, no /me follow-up and no clearSession() on the boot path.
  • boot() in apps/storefront/src/app/layout/layout-shell.component.ts does the same for the customer behind the browser-only gate, commits through apps/storefront/src/app/services/auth-state.service.ts, and marks the boot settled in a finally so consumers wait on the outcome instead of polling.
  • A transient 429 or 5xx never logs anyone out. The session is one signed HttpOnly cookie; nothing about it is stored in localStorage.

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.ts uses 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.ts sends 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 authorization and cookie headers, and request and response bodies are never logged.
  • libs/shared/common/src/utils/log-redaction.ts blanks any query parameter whose name looks like a secret or personal data (email, phone, token, password, secret, api_key, signature and 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.

Two more single sources keep the API and the front ends in step:

  • Every /v1/admin/* path is a constant in libs/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.