Skip to content

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 14 Oct 17:06
· 21 commits to main since this release

ExisJS Release v0.9.0

Release Date: 2026-10-14
Type: Minor (breaking before 1.0): Correctness, Security & Production Readiness
Affected Packages: exisjs, create-exisjs, @exisjs/fetch (all aligned to 0.9.0)


Highlights

This release works through two field reports from a production ERP backend built on 0.8.1 (BUG-01 to BUG-10, T-01 to T-26). Every fixed finding has a regression test in packages/exisjs/tests/conformance.test.ts or next to the code it covers.

  • No more cross-user leaks. Request-scoped providers were shared between requests, and @Idempotent replayed one user's response to another.
  • Every request gets an answer. A handler that returns nothing answers 204 instead of leaving the connection open; a rejection with null is a 500, not a 404.
  • Class controllers behave like functional routes. Returning res.json(), res.send(), download() or a Buffer works; @UseInterceptors actually wraps the handler.
  • Safer authorization. No magic isSuperAdmin bypass, 401 for anonymous callers, permissions are all-of, and the same rules apply to both paradigms.
  • One error envelope everywhere, reporting every failing field.
  • Production defaults: a Content-Security-Policy by default, no public /docs in the template, readiness endpoint, shared stores for rate limits and cron locks.

⚠️ Breaking Changes

1. @Permissions requires all listed permissions

@Permissions('report:read', 'report:export') (and functional permissions: [...]) now needs both. It used to pass with either one in class controllers. A role is no longer counted as a permission: permissions come from req.user.permissions only. @Roles still needs one of the listed roles.

2. Anonymous callers get 401

A route with roles or permissions answers 401 UNAUTHORIZED when there is no req.user, and 403 FORBIDDEN when the user lacks them. Both use the standard error envelope (they used to lack statusCode).

3. req.user.isSuperAdmin no longer bypasses checks

The undocumented bypass is gone: any app that fills req.user from client-controlled data could be fully opened with one field. Declare super users explicitly:

// exis.config.ts
authorization: { superUser: (user) => user.role === 'owner' }

4. One error envelope; legacy fields removed

Error bodies are { success: false, statusCode, error: { code, message, details? } } on every path. The duplicate top-level message, validationErrors, errors, details and timestamp fields are gone, and HttpError details are no longer copied to the top level (a details key named success or statusCode could overwrite the real values). Read error.details instead. To keep the old fields while clients migrate: errors: { legacyFields: true } (deprecated, removed in 1.0).

5. Cron jobs do not start under NODE_ENV=test

Jobs and timeout tasks are registered but not scheduled during tests, so a test run cannot fire real jobs. Run one with app.cron.trigger(name), or opt in with createTestContext(app, { cron: true }) / app.cron.autoStart = true.

6. Helmet sends a Content-Security-Policy by default

default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'. Apps that serve HTML with inline scripts or third-party assets set their own contentSecurityPolicy, or false to send none. The /docs page and the development error page send their own policies. A policy already set by your own middleware is kept.

7. What a returned value becomes is the same in both paradigms

  • undefined with nothing sent: 204 (or the @HttpCode status) instead of an open connection.
  • null: JSON null.
  • A string: text/plain (class controllers used to send it as a JSON string).
  • A number or boolean: JSON (functional routes used to send it as text).

8. Malformed JSON is always 400 INVALID_JSON

Class controllers (@Body()) and functional routes used to treat a body that failed to parse as {}.

9. Smaller changes

  • ValidationPipe and DTO validate() failures are VALIDATION_ERROR with a per-field error.details list, and errorHttpStatusCode is honoured (it was ignored).
  • The /docs route is still controlled by docs.enabled; the template now sets it to process.env.NODE_ENV !== 'production'.
  • exisjs dev locks per port: the lock file is .exis/dev-<port>.pid instead of .exis/dev.pid.

Security

  • Request-scoped providers were shared between requests (T-01). @Injectable classes are registered as { useClass } at boot, and that path ignored @Injectable({ scope }), so every provider became a singleton. Controllers were also built once. Scopes now work, and propagate: anything that depends on a request-scoped provider is itself built per request. transient gives a new instance per injection (T-12). inject() and ctx.resolve() share one cache per request.
  • @Idempotent leaked responses across users (T-04). Keys are now scoped to the user (req.user.id/sub, or a scope function) plus method and path. Reusing a key with a different body answers 422 IDEMPOTENCY_KEY_REUSED; a repeat while the first is running answers 409 IDEMPOTENCY_IN_PROGRESS.
  • Connection strings in validation errors (BUG-04). Fields named *_URL, *_URI, *_DSN, *CONNECTION* are redacted, and the password of any scheme://user:password@host value is masked in logs, responses and tex.env startup errors.
  • Authorization bypass via isSuperAdmin (T-07), see Breaking Changes.
  • beforeHandle responses were overwritten (T-09). A boundary hook that wrote a 401 and returned false sent a 403 instead; the hook's response is now kept.

Fixed

  • Class controllers sent internal objects (T-03 / BUG-10). download() results, Buffers (T-13) and response objects returned from handlers are handled by one shared function for both paradigms.
  • @UseInterceptors (T-05). The interceptor's result is now the route's result, and the handler runs once (it used to run twice, once inside the interceptor with its result discarded).
  • Hung requests (T-02). See Breaking Changes 7. The test client also fails a request after 5 s (.timeout(ms), EXIS_TEST_TIMEOUT), naming the route, instead of hanging the run.
  • HEAD (T-11) is answered by the GET route without a body; a known path with the wrong method answers 405 with an Allow header (T-14).
  • Validation reports every failing field (T-17), capped at 100.
  • Cron jobs registered twice (T-22 / BUG-05). Discovery scans either the source or the compiled tree, never both.
  • Validation logs showed /user instead of /fn/user (T-23) for functional controllers mounted under a prefix.
  • Dev error page (BUG-03). API clients get JSON 503 BUILD_ERROR, browsers the HTML page; ANSI codes are stripped; it binds the configured host; edits to .env files (including ones created later) reload the app.
  • Dev lock (BUG-06). One lock per port, so a second instance on another port can run; a lock left by a crashed process whose PID was reused no longer blocks; --force skips the check.
  • tex.enum accepts readonly arrays (BUG-01), so as const lists work directly.
  • Fields with a default are typed as present after parsing (BUG-02). New ResolveInput / ResolveOutput types (like z.input / z.output).
  • exisjs build fails on mixed paradigms (T-25), listing the files of each kind, instead of failing at startup.
  • exisjs build output layout no longer depends on which folders hold entry files (esbuild outbase is the project root).
  • listen({ port: 0 }) reports the port the OS assigned.
  • Template lint (T-21). A fresh project passes npm run lint; the generated exis.d.ts is excluded and marked eslint-disable. The functional guard template read req.headers from an execution context and threw; both guard templates now use context.switchToHttp().getRequest().
  • ExisUser augmentation (SEC-2). exisjs exported a second, unrelated ExisUser, so declare module 'exisjs' { interface ExisUser {...} } never reached req.user. It now does.

Added

  • authorization.defaultPolicy: 'authenticated': deny by default, so a controller route without @Roles / @Permissions / @Public requires a signed-in user (SEC-1).
  • authorization.superUser config option.
  • errors.legacyFields config option (deprecated on arrival).
  • rateLimit({ store }) with RateLimitStore / MemoryRateLimitStore, for one limit across instances (SEC-3). A failing store lets requests through and logs a warning.
  • app.cron.lock (CronLock) so only one instance runs each scheduled job (ENT-1); lockTtlMs job option.
  • /_ready readiness endpoint (healthcheck.readyPath): 503 unless checks pass, registered databases are healthy and the app is not shutting down (ENT-4).
  • Every line logged through req.log (handlers, the error handler, validation diagnostics) carries the request's requestId (ENT-3).
  • exisjs routes shows each route's access rule (ACCESS column, and access in --json) (DEV-3).
  • Test harness (T-26): .query({...}), .timeout(ms), startTestServer(app) for real-socket tests.
  • Inject, Optional and forwardRef are also exported from exisjs/decorators, as the reference documents.
  • exisjs dev --force.
  • Bench target exis-class (class controllers, same routes as exis-node).

Changed

  • Per-route work in class controllers (access rule, guards, interceptors, parameter metadata) is computed once at registration instead of on every request (T-20).
  • The generated AGENTS.md no longer points assistants to a built-in migrations/query-builder/OAuth layer that does not exist.

Documentation

  • New: route authorization semantics, security baseline, what a return value becomes, input/output types, readiness, rate-limit and cron stores, cron in tests, real-socket tests.
  • Corrected (T-19 / BUG-09): @Sse / @Ws removed from the references (SSE uses res.sse() in a @Get), @Guards → @UseGuards, @Filters / @Interceptors → @UseFilters / @UseInterceptors, @Dedupe options, @Roles / @Permissions wording, CSRNF_SECRET typo, the "feature parity" claim in the introduction. A test now checks that every decorator named in the references is exported.
  • SECURITY.md lists the supported version and how to report privately.

Tooling

  • Packages are published with npm provenance.
  • CI runs npm audit --omit=dev --audit-level=high.
  • npm run verify:examples lints freshly scaffolded projects and checks that /docs is not served in production.

Not in this release

  • FP-1 (splitting the CLI tooling out of the runtime package) and FP-2 (making pino-pretty optional) change how the package is installed; planned for a later release.
  • TS-2 (tex.string({ pattern })) and TS-3 (inferring @Query() types from the route schema).
  • ResolveInput describes nested objects by their parsed type.

Upgrade Guide

npm install exisjs@0.9.0
  1. Search for @Permissions( with more than one permission and check each user has all of them (or split the route).
  2. Clients that check for 403 on anonymous requests: expect 401.
  3. If you relied on req.user.isSuperAdmin, add authorization.superUser.
  4. Clients reading body.message, body.validationErrors, body.errors or body.details on errors: read body.error.message / body.error.details, or set errors: { legacyFields: true } for now.
  5. Tests that waited for a cron job to fire: call app.cron.trigger(name).
  6. Apps serving HTML pages: check the default Content-Security-Policy.

Full Changelog: v0.8.1...v0.9.0