@nage-api/core
v1.0.0-beta.4
Published
Cross-cutting primitives for @nage-api applications — context, response envelope, error catalog, bootstrap
Readme
@nage-api/core
The small mandatory layer every @nage-api application depends on (PLAN.md §7.2).
// apps/api/src/main.ts — the whole file
import 'reflect-metadata';
import { bootstrap } from '@nage-api/core';
import { AppModule } from './app.module.js';
import { EnvSchema } from './config/env.schema.js';
import { buildConfig } from './config/nage.config.js';
const env = EnvSchema.parse(process.env);
void bootstrap({ module: AppModule, config: buildConfig(env) });A generated main.ts calls @nage-api/config's loadEnvOrExit(EnvSchema) in place of
parse: same validation, but it reports every invalid variable at once and exits
non-zero instead of throwing on the first one.
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { NageCoreModule } from '@nage-api/core';
import { EnvSchema } from './config/env.schema.js';
import { buildConfig } from './config/nage.config.js';
import { WidgetController } from './widget/widget.controller.js';
const config = buildConfig(EnvSchema.parse(process.env));
@Module({ imports: [NageCoreModule.forRoot(config)], controllers: [WidgetController] })
export class AppModule {}forRoot is what registers the interceptor, the filter, the guards and the
validation pipe. Skip it and the application still starts — with none of them, so
the envelope, the error catalog and the request context are simply absent.
What it provides
| Area | Export | Notes |
| ----------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| Request context | RequestContextService, getActiveContext() | AsyncLocalStorage-backed; carries requestId, user, tenantId (§18) |
| Response envelope | ResponseInterceptor, successEnvelope | Controllers return data; no @Res() (§16.1) |
| Errors | AllExceptionsFilter, NageError + catalog | Stable codes, safe messages, detail logged (§17) |
| Decorators | @Public, @Owner, @OptionalOwner, @NoEnvelope | @Owner() returns a typed AuthUser (§13) |
| Versioning | @ForVersion, @FromVersion, @TillVersion, @BetweenVersions, ApiVersionGuard | Header x-application-version, retained from the legacy framework (§16.2) |
| Jobs | createJob, paginate, emptyPage | Typed Job<TEntity> for the Phase 5 data layer (§14.1) |
| Logging | JsonLogger, NestLoggerAdapter | NDJSON with redaction, behind LoggerPort (§18) |
| Security baseline | RateLimitGuard, @RateLimit, randomToken, redact, auditSecurity | CSPRNG, redaction, TLS policy, global throttle, boot-time audit (§12) |
| DI tokens | NAGE_CONFIG, NAGE_LOGGER, repositoryToken(), … | How feature packages collaborate without importing each other (§7.3) |
| Bootstrap | bootstrap(), createApplication() | helmet, CORS allow-list, compression, versioning (§21) |
| Lifecycle | installShutdown, installProcessGuards, LifecycleState, RequestDrain | Ordered drain, readiness phase, fatal-error policy (§21) |
| Request timeout | RequestTimeoutInterceptor, @RequestTimeout, @SkipRequestTimeout | 30 s by default; a handler that overruns is answered 504 (§21) |
Behaviour worth knowing
The envelope is a guarantee. Every HTTP response is
{ success, data | error, meta }. A repository page is lifted automatically:
records becomes data, pagination goes to meta.pagination. The single
opt-out is @NoEnvelope(), for downloads and webhook callbacks that need an
exact body.
Errors never leak. A thrown NageError returns its code and
safeMessage; its meta is logged, never serialised. Anything unrecognised
becomes INTERNAL_ERROR / "Internal server error" with the real message, stack
and cause in the log line — keyed by the same requestId the caller received.
ValidationPipe failures become VALIDATION_FAILED (422) with field details.
Correlation ids are settled once. A caller-supplied X-Request-Id is
validated against REQUEST_ID_PATTERN and replaced if it does not match — it is
echoed into responses and log lines, so it is untrusted input. The id is in the
response header, in meta.requestId, and on every log line, for successes and
failures alike.
Defaults are the safe ones. CORS is off unless an allow-list is
configured, because app.enableCors() with no arguments allowed every origin by
omission; a wildcard must be spelled out and is refused in production. Helmet is
on, HSTS in production. forbidNonWhitelisted makes an unexpected body property
an error rather than a silent drop. Throttling is global and opt-out: the
legacy framework imported a throttler and never registered a guard, so exactly
one endpoint was limited. A critical security finding at boot exits non-zero
instead of serving traffic insecurely.
Shutdown runs in an order, and the order is the point. bootstrap claims
SIGTERM and SIGINT itself rather than calling Nest's
app.enableShutdownHooks(), which destroys modules before it closes the HTTP
server: measured against a 600 ms handler signalled at 200 ms, the handler's query
returned pool is closed and the caller got that as a 200. installShutdown
fails readiness first (/health/ready → 503, /health/live still 200), stops
accepting connections, waits out the requests it already has under
drainTimeoutMs, and only then calls app.close() so modules can close their
pools. forceExitAfterMs bounds the whole thing and exits non-zero on expiry —
before that, one handler that never returned kept a signalled process alive
indefinitely.
Handlers have a deadline. http.requestTimeoutMs defaults to 30 seconds and
answers 504 in the error envelope with the code REQUEST_TIMEOUT. It bounds the
caller's wait, not the process's work: Node cannot abort a running promise, so put
a deadline on the dependency as well. @RequestTimeout(ms) raises it for one
route, @SkipRequestTimeout() removes it for a stream.
An uncaught exception or unhandled rejection is fatal. That is Node's verdict
and it is kept; what is added is one structured fatal line before the process
goes, and one bounded attempt at the graceful shutdown above, so the reason lands
in the same log stream as everything else and the pools still close.
Redaction fails closed. redact masks configured field names at any depth,
including inside arrays. Where it cannot see — past its depth bound, or into a
cycle — it substitutes a marker rather than passing the value through, and the
human-readable log format is scrubbed exactly like the JSON one. A log call never
throws: an unreadable field is reported as unreadable, and the line still carries
its level and message.
Dependency rule
@nage-api/core depends on @nage-api/contracts and nothing else — never a feature
package, never a project entity. Enforced by @nage-api/eslint-config and
pnpm check:boundaries.
Not yet implemented
- A shared rate-limit store. The counters are per process, so a limit of 100
across four pods is 400.
RateLimitStoreis the seam, but the tokenforRootbinds it to is not exported, so today there is no way to supply another one. Seedocs/packages/core.md. http.bodyLimit. Declared in the config type and read by nothing; body size is whatever the platform adapter defaults to.- URI versioning,
Idempotency-Key,Deprecation/Sunsetheaders — §16.2 asks for all three; only header versioning is built. - pino. §18 names it as the production logger;
JsonLoggercovers structured output today andLoggerPortis the seam it would arrive behind.
Health and readiness endpoints, metrics and tracing live in
@nage-api/observability; config loading and env validation in @nage-api/config.
The deeper guide is docs/packages/core.md.
