Repository navigation
v0.9.0
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
@Idempotentreplayed one user's response to another. - Every request gets an answer. A handler that returns nothing answers
204instead of leaving the connection open; a rejection withnullis a500, not a404. - Class controllers behave like functional routes. Returning
res.json(),res.send(),download()or aBufferworks;@UseInterceptorsactually wraps the handler. - Safer authorization. No magic
isSuperAdminbypass,401for 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
/docsin 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
undefinedwith nothing sent:204(or the@HttpCodestatus) instead of an open connection.null: JSONnull.- 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
ValidationPipeand DTOvalidate()failures areVALIDATION_ERRORwith a per-fielderror.detailslist, anderrorHttpStatusCodeis honoured (it was ignored).- The
/docsroute is still controlled bydocs.enabled; the template now sets it toprocess.env.NODE_ENV !== 'production'. exisjs devlocks per port: the lock file is.exis/dev-<port>.pidinstead of.exis/dev.pid.
Security
- Request-scoped providers were shared between requests (T-01).
@Injectableclasses 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.transientgives a new instance per injection (T-12).inject()andctx.resolve()share one cache per request. @Idempotentleaked responses across users (T-04). Keys are now scoped to the user (req.user.id/sub, or ascopefunction) plus method and path. Reusing a key with a different body answers422 IDEMPOTENCY_KEY_REUSED; a repeat while the first is running answers409 IDEMPOTENCY_IN_PROGRESS.- Connection strings in validation errors (BUG-04). Fields named
*_URL,*_URI,*_DSN,*CONNECTION*are redacted, and the password of anyscheme://user:password@hostvalue is masked in logs, responses andtex.envstartup errors. - Authorization bypass via
isSuperAdmin(T-07), see Breaking Changes. beforeHandleresponses were overwritten (T-09). A boundary hook that wrote a401and returnedfalsesent a403instead; 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 theGETroute without a body; a known path with the wrong method answers405with anAllowheader (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
/userinstead 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.envfiles (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;
--forceskips the check. tex.enumaccepts readonly arrays (BUG-01), soas constlists work directly.- Fields with a
defaultare typed as present after parsing (BUG-02). NewResolveInput/ResolveOutputtypes (likez.input/z.output). exisjs buildfails on mixed paradigms (T-25), listing the files of each kind, instead of failing at startup.exisjs buildoutput layout no longer depends on which folders hold entry files (esbuildoutbaseis the project root).listen({ port: 0 })reports the port the OS assigned.- Template lint (T-21). A fresh project passes
npm run lint; the generatedexis.d.tsis excluded and markedeslint-disable. The functional guard template readreq.headersfrom an execution context and threw; both guard templates now usecontext.switchToHttp().getRequest(). ExisUseraugmentation (SEC-2).exisjsexported a second, unrelatedExisUser, sodeclare module 'exisjs' { interface ExisUser {...} }never reachedreq.user. It now does.
Added
authorization.defaultPolicy: 'authenticated': deny by default, so a controller route without@Roles/@Permissions/@Publicrequires a signed-in user (SEC-1).authorization.superUserconfig option.errors.legacyFieldsconfig option (deprecated on arrival).rateLimit({ store })withRateLimitStore/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);lockTtlMsjob option./_readyreadiness endpoint (healthcheck.readyPath):503unless 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'srequestId(ENT-3). exisjs routesshows each route's access rule (ACCESS column, andaccessin--json) (DEV-3).- Test harness (T-26):
.query({...}),.timeout(ms),startTestServer(app)for real-socket tests. Inject,OptionalandforwardRefare also exported fromexisjs/decorators, as the reference documents.exisjs dev --force.- Bench target
exis-class(class controllers, same routes asexis-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.mdno 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/@Wsremoved from the references (SSE usesres.sse()in a@Get),@Guards→@UseGuards,@Filters/@Interceptors→@UseFilters/@UseInterceptors,@Dedupeoptions,@Roles/@Permissionswording,CSRNF_SECRETtypo, the "feature parity" claim in the introduction. A test now checks that every decorator named in the references is exported. SECURITY.mdlists 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:exampleslints freshly scaffolded projects and checks that/docsis not served in production.
Not in this release
- FP-1 (splitting the CLI tooling out of the runtime package) and FP-2 (making
pino-prettyoptional) 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). ResolveInputdescribes nested objects by their parsed type.
Upgrade Guide
npm install exisjs@0.9.0- Search for
@Permissions(with more than one permission and check each user has all of them (or split the route). - Clients that check for
403on anonymous requests: expect401. - If you relied on
req.user.isSuperAdmin, addauthorization.superUser. - Clients reading
body.message,body.validationErrors,body.errorsorbody.detailson errors: readbody.error.message/body.error.details, or seterrors: { legacyFields: true }for now. - Tests that waited for a cron job to fire: call
app.cron.trigger(name). - Apps serving HTML pages: check the default Content-Security-Policy.
Full Changelog: v0.8.1...v0.9.0