@setu-ts/decorator-plugin

Optional NestJS-style decorators as syntactic sugar over the kernel’s programmatic API.

Decorators capture metadata in a plain MetadataStore — no reflect-metadata, no reflection polyfill. DecoratorPlugin reads that store at registration time and registers routes, services, and middleware with the kernel. Decorators are inert unless the plugin is registered.

The store itself is registered under CAPABILITIES.METADATA_STORE ('metadata-store').

Schema compatibility

@ValidateBody, @ValidateQuery, and @ValidateParams preserve application-created schemas and hand them to the validation/OpenAPI boundaries; this package does not inspect Zod internals. The supported Zod range is therefore the shared validation contract: >=4.6.6 <5. pnpm run check:compat exercises Zod 4 through the validation and OpenAPI owners.

Installation

import { Controller, DecoratorPlugin, Get } from '@setu-ts/decorator-plugin';

Usage

import {
  Body,
  Controller,
  DecoratorPlugin,
  Get,
  Param,
  Params,
  Post,
} from '@setu-ts/decorator-plugin';

@Controller('/users')
class UsersController {
  @Get('/:id')
  @Params(Param('id'))
  findOne(id: string) {
    return { id };
  }

  @Post('/')
  @Params(Body())
  create(body: unknown) {
    return body;
  }
}

app.register(DecoratorPlugin({ controllers: [UsersController] }));

Non-HTTP ingress

The same explicit class list registers queue processors, scheduled jobs, domain-event and broker subscriptions, WebSocket gateways, and CQRS handlers. Put those classes in ingress; registration happens during onInit, after the corresponding provider plugin has registered its capability. A declared handler with no provider fails startup with the class, method, and missing plugin named.

controllers and ingress are independent lists: a class listed in only one has the OTHER half of its composition ignored, and register() warns naming the ignored family and the option that would register it. Listing a class in both is the correct composition — both families register. A controller contributed by a @Module or by autoDiscover counts as registered for this check; @Module has no ingress list, so a module controller with ingress decorators still needs ingress.

import { DecoratorPlugin, Processor } from '@setu-ts/decorator-plugin';
import type { IJob } from '@setu-ts/common';

class BackgroundWork {
  @Processor('email')
  process(_job: IJob): void {}
}

app.register(DecoratorPlugin({ ingress: [BackgroundWork] }));

Register the provider plugins a class uses (QueuePlugin, SchedulerPlugin, EventsPlugin, MessagingPlugin, WebSocketPlugin, or CqrsPlugin) alongside the decorator plugin.

Validation is enforced

@ValidateBody(schema) (and @ValidateQuery / @ValidateParams) do not merely describe a route for OpenAPI. When a CAPABILITIES.VALIDATION provider is registered — ValidationPlugin, or any replacement — and enforceSchemas is not false, the capability’s middleware is appended LAST in the route’s chain (innermost, after guards), so an invalid request is answered 400 before the handler runs while guard 401/403 precedence is preserved. @Body(), @Query() and @Param() then hand the handler the VALIDATED value — transforms, defaults and coercions included — falling back to the raw source when no validated value exists. Without a validation provider the schemas stay description-only and the plugin logs one warning per affected route naming ValidationPlugin.

import {
  Body,
  Controller,
  DecoratorPlugin,
  Params,
  Post,
  ValidateBody,
} from '@setu-ts/decorator-plugin';
import { ValidationPlugin } from '@setu-ts/validation-plugin';
import { z } from 'zod';

const CreateUser = z.object({ email: z.string().email(), age: z.number().int().default(18) });

@Controller('/users')
class UsersController {
  @Post('/')
  @ValidateBody(CreateUser)
  @Params(Body())
  create(body: { email: string; age: number }) {
    return body; // the TRANSFORMED value: `age` defaulted to 18 by the schema
  }
}

app.register(ValidationPlugin());
app.register(DecoratorPlugin({ controllers: [UsersController] }));

Rendered views

@Render(Component) marks a route as rendered: the handler returns the component’s PROPS bag and the framework answers with the component rendered to HTML (text/html; charset=utf-8), never JSON. The view is named by reference — a function the application already has — and the decorator type-checks that return against the component’s props, so a wrong props bag is a compile error naming the mismatch.

import { Controller, Get, Render } from '@setu-ts/decorator-plugin';

// JSX escapes every interpolation, as does hono's `html` tag. A plain
// `(props) => \`<li>${user}</li>\`` template would NOT: escaping belongs to the
// rendering runtime, and a plain string component is returned unchanged.
const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>
    {props.users.map((user) => (
      <li>{user}</li>
    ))}
  </ul>
);

@Controller('/pages')
class PagesController {
  @Render(UserList)
  @Get('/users')
  users() {
    return { users: ['ada', 'grace'] }; // the props bag — the framework answers HTML
  }
}

The engine is resolved from CAPABILITIES.VIEW once at register() — which is why the token joins this plugin’s optionalDependencies (a real ordering edge, not priority luck). A rendered route with no provider FAILS at register(), naming the controller, the handler and both remedies (register ViewPlugin, or any other CAPABILITIES.VIEW provider); the check is per route, so an application with no @Render route needs no view plugin. A FIXED status or header alongside a rendered body is declared with @HttpCode / @ResponseHeader (see below), and a computed one goes through @Params(Ctx()); the return value IS the props bag, so it cannot also carry a status, which is why @Render grows no status argument of its own. Rendering itself — engines, escaping, the Suspense refusal — lives in @setu-ts/view-plugin; this package imports no rendering runtime.

Response shaping

A decorated handler returning a plain value is answered with ctx.response.json(result). Left undecorated that is the DEFAULT — 200, JSON, no headers — and the three decorators below change it, each writing to the response builder before the handler runs. @HttpCode(status), @ResponseHeader(name, value) (repeatable, for distinct names) and @Redirect(url, status?) let a route state something FIXED about its response in its declaration, instead of accepting a request context purely to say it.

import {
  Controller,
  Get,
  HttpCode,
  Post,
  Redirect,
  ResponseHeader,
} from '@setu-ts/decorator-plugin';

@Controller('/orders')
class OrderController {
  @HttpCode(201)
  @Post('/')
  create() {
    return { id: 'o-1' };
  }

  @ResponseHeader('Cache-Control', 'no-store')
  @Get('/latest')
  latest() {
    return { sku: 'a-1' };
  }

  @Redirect('/orders', 301)
  @Get('/v1')
  legacy() {
    return { moved: true };
  }
}

@Params(Ctx()) is neither replaced nor deprecated by these: it stays the way to compute a status or header PER REQUEST, and the only way to write a multi-valued header (ctx.response.appendHeader).

Three rules. A returned HandlerResult wins — the declared shaping is written to the response builder BEFORE the handler runs, so ctx.response.status(202).json(...) answers 202 even under @HttpCode(201), and the same ordering is why @Render composes with a declared status. @Redirect does not skip the handler — a decorator cannot decline to call the method, so the body still runs and a plain return is still serialised alongside Location. Every argument is refused at register(), never per request: @HttpCode takes an integer in [200, 599], @Redirect one in [300, 399] plus a non-blank target the runtime will carry as a Location header, a header pair must be one the runtime accepts, a name may not be declared twice, and one handler may not carry both @HttpCode and @Redirect. Each refusal names the controller, the method and the value — the alternative is a RangeError thrown inside the HTTP adapter after the pipeline has finished, or a TypeError while headers are written, answering 500 on every request.

@HttpCode(204) serves a bodiless response, which is what a DELETE handler wants.

@setu-ts/openapi-plugin derives an operation’s success status from @HttpCode/@Redirect (on by default, deriveResponseStatus), so a 201 route is documented under 201 rather than the assumed 200. @ResponseHeader is deliberately not derived: an OpenAPI response-header entry needs a schema and a description the declaration does not carry.

What it exports

  • Routing — @Controller, @Version, @Get/@Post/@Put/@Patch/@Delete/@Head/@Options
  • Parameters — @Params(...), taking the sources Body, Query, Param, Header, Cookie, CurrentUser, Ctx, Service (a DI service from the request’s scope), Custom
  • Modules — @Module({ controllers, providers, imports })
  • Non-HTTP ingress — @Processor, @Cron, @Every, @OnEvent, @Subscribe, @CommandHandler, @QueryHandler, @Gateway (+ @OnOpen/@OnMessage/@OnClose)
  • Injection — @Injectable, @Inject, @Optional
  • Security — @Roles, @Permissions (enforced — see below), @Public (unrestricted OpenAPI marking)
  • Authorization policies — @Policy, @Ability, @RequirePolicy (see Authorization policies below)
  • Pipeline — @UseGuards, @UseInterceptors, @UseFilters
  • Validation — @ValidateBody, @ValidateQuery, @ValidateParams
  • Idempotency — @Idempotent(options?), appended LAST in a route’s middleware (after guards, declarative authorization and validation), so a refused or invalid request consumes no key; it requires a CAPABILITIES.IDEMPOTENCY provider — see @setu-ts/idempotency-plugin
  • Views — @Render(Component) (renders through a CAPABILITIES.VIEW provider — see @setu-ts/view-plugin)
  • Response shaping — @HttpCode, @ResponseHeader, @Redirect
  • OpenAPI — @ApiTags, @ApiOperation, @ApiResponse (@ApiOperation forwards operationId, summary and description into the route schema; OpenAPI uses a declared id verbatim and derives one from method and path when it is absent.)
  • Extension — createDecorator, Custom, registerParameterResolver
  • Discovery — discoverControllers

Options

OptionTypeDefaultDescription
controllersConstructor[][]Controller classes to register explicitly.
ingressConstructor[][]Classes carrying non-HTTP ingress decorators (@Processor, @Cron, @OnEvent, …); registered during onInit. Independent of controllers — see Non-HTTP ingress.
servicesConstructor[][]Service classes to register explicitly.
modulesConstructor[][]Root @Module classes to flatten depth-first; imported module providers register before controllers.
autoDiscoverbooleanfalseScan controllersPath for decorated classes.
controllersPathstring—Glob path used when autoDiscover is true.
enforceSchemasbooleantrueAppend the validation capability’s middleware for each present @ValidateXxx target. false keeps schemas description-only (OpenAPI) and silences the missing-provider warning.
enforceRolesbooleantrueAppend enforcing authorization middleware for each route carrying @Roles/@Permissions. false keeps the metadata description-only and silences the missing-provider warning.
policiesConstructor[][]@Policy classes to construct (with constructor injection) and register with AuthPlugin’s policy service before any controller. Refused without a CAPABILITIES.AUTHORIZATION_POLICIES provider.

Discovery failures are logged as warnings and never crash the application.

Node and Bun: the Symbol.metadata carrier

Node 24 and Bun 1.4 do not define Symbol.metadata, which TC39 decorators store their metadata under. Importing @setu-ts/decorator-plugin installs it once, write-if-absent: Symbol.metadata ??= Symbol.for('Symbol.metadata'). This is a deliberate global mutation at module load; on Deno, which defines the symbol, it does nothing. Import the package before any module that defines a decorated class — an import { Controller } from '@setu-ts/decorator-plugin' in that module already guarantees it. A class evaluated first, or a bundle that dropped the import as side-effect-free, fails at definition with DecoratorMetadataUnavailableError, whose message names both remedies.

Security

@Roles and @Permissions are enforced by default: each decorated route’s chain gets middleware that resolves CAPABILITIES.AUTHORIZATION per request and answers 401 without a principal and 403 when the check fails — the same statuses and bodies the equivalent @UseGuards(requireRole(...)) spelling produces. A route carrying both decorators requires any of the roles AND any of the permissions; method-level declarations override class-level ones.

When no authorization provider is registered, a decorated route fails closed: it answers 501 and the handler never runs — it is never served unguarded — and register() warns once per affected route naming both remedies (register a provider under CAPABILITIES.AUTHORIZATION, or set enforceRoles: false).

@Public contributes security: [] to the OpenAPI document only when the route has no enforced @Roles/@Permissions restriction. It does not exempt a route from a guard or from that enforcement; a restricted route keeps its derived OpenAPI security requirement.

Authorization policies

@Policy(name) marks a class as an authorization policy and @Ability() marks each ability method; a method named before is the policy’s before hook. List the class in policies, and require an ability on a route with @RequirePolicy(policy, ability, target?) — the target is a value or an extractor called per request:

import type { IPrincipal } from '@setu-ts/common';
import {
  Ability,
  Controller,
  DecoratorPlugin,
  Patch,
  Policy,
  RequirePolicy,
} from '@setu-ts/decorator-plugin';

interface Post {
  readonly id: string;
  readonly authorId: string;
}

@Policy('post')
class PostPolicy {
  @Ability()
  update(principal: IPrincipal, post: Post | undefined): boolean {
    return post?.authorId === principal.id;
  }
}

@Controller('/posts')
class PostController {
  @Patch('/:id')
  @RequirePolicy(PostPolicy, 'update', (ctx) => ({ id: ctx.params.id ?? '', authorId: 'ann' }))
  update(): { readonly saved: boolean } {
    return { saved: true };
  }
}

export const decorators = DecoratorPlugin({
  policies: [PostPolicy],
  controllers: [PostController],
});

The policy service comes from AuthPlugin (@setu-ts/auth-plugin), which must be registered: register() fails, naming the route, when it is missing or when a @RequirePolicy names a policy or ability that is not registered. @RequirePolicy also accepts a definePolicy definition, may be repeated (every one must allow, top to bottom), and runs after guards and @Roles/@Permissions but before validation — so an extractor reading the body sees the unvalidated body. Refusals are 401 (anonymous) and 403 (signed in), the same bodies as AuthPlugin’s requirePolicy guard; enforceRoles does not affect @RequirePolicy. See Authorization.

Scoped roles

@ScopedRoles(roles, scope?) (any-of) and @ScopedPermissions(permissions, scope?) (all-of) require a catalogue role or permission in the request’s scope — the class form of AuthPlugin’s requireScopedRole / requireScopedPermission. The scope defaults to the resolved request tenant; pass a ScopeSource from @setu-ts/common to read it elsewhere:

import { scopeFromParam } from '@setu-ts/common';
import {
  Controller,
  DecoratorPlugin,
  Get,
  Post,
  ScopedPermissions,
  ScopedRoles,
} from '@setu-ts/decorator-plugin';

@Controller('/invoices')
@ScopedRoles(['viewer'])
class InvoiceController {
  @Get('/')
  list(): string[] {
    return [];
  }

  // A method decorator overrides the class default.
  @Post('/:id/approve')
  @ScopedPermissions(['invoices:approve'])
  approve(): { readonly approved: boolean } {
    return { approved: true };
  }

  @Get('/orgs/:orgId')
  @ScopedRoles(['org-admin'], scopeFromParam('orgId', 'organisation'))
  forOrg(): string[] {
    return [];
  }
}

export const decorators = DecoratorPlugin({ controllers: [InvoiceController] });

They need AuthPlugin({ rbac, scopedRbac }): register() fails, naming the route, when no policy service is registered or a name is outside the catalogue (a role a tenant defines at runtime is checked through the permissions it bundles). They run after @Roles/@Permissions and @RequirePolicy, answer 401/403 with the same bodies as the guards, and are not governed by enforceRoles. See Scoped Roles.

Exports

ExportKind
Abilityfunction
ApiOperationfunction
ApiResponsefunction
ApiTagsfunction
Bodyfunction
clearParameterResolversfunction
CommandHandlerfunction
Controllerfunction
Cookiefunction
createDecoratorfunction
Cronfunction
Ctxfunction
CurrentUserfunction
Customfunction
DecoratorPluginfunction
discoverControllersfunction
Everyfunction
Gatewayfunction
getParameterResolverfunction
Headerfunction
HttpCodefunction
Idempotentfunction
Injectfunction
Injectablefunction
Modulefunction
OnEventfunction
Optionalfunction
Paramfunction
Paramsfunction
parseCookiesfunction
Permissionsfunction
Policyfunction
Processorfunction
Publicfunction
Queryfunction
QueryHandlerfunction
Redirectfunction
registerParameterResolverfunction
Renderfunction
RequirePolicyfunction
resolveParameterfunction
resolveParametersfunction
ResponseHeaderfunction
Rolesfunction
ScopedPermissionsfunction
ScopedRolesfunction
Servicefunction
Subscribefunction
UseFiltersfunction
UseGuardsfunction
UseIngressBehaviorsfunction
UseInterceptorsfunction
UsePipelineBehaviorsfunction
ValidateBodyfunction
ValidateParamsfunction
ValidateQueryfunction
Versionfunction
MetadataStoreclass
Deleteconst
Getconst
Headconst
metadataStoreconst
OnCloseconst
OnMessageconst
OnOpenconst
Optionsconst
Patchconst
Postconst
Putconst
AbilityOptionsinterface
ApiOperationConfiginterface
ApiResponseConfiginterface
DecoratorPluginOptionsinterface
DiscoveryOptionsinterface
DiscoveryResultinterface
InjectableOptionsinterface
ModuleOptionsinterface
OptionalTokeninterface
ParameterMetadatainterface
ParamSourceinterface
CustomParameterResolvertype
HttpMethodDecoratortype
InjectTokentype
MiddlewareLiketype
ModuleImportertype
ParameterTypetype
PolicyClassAbilitytype
PolicyClassTargettype
RenderDecoratortype
SetuClassDecoratortype
SetuClassOrMethodDecoratortype
SetuMethodDecoratortype
SourceValuestype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

API Reference: @setu-ts/decorator-plugin

Optional decorator and metadata system plugin. Provides NestJS-style decorators as syntactic sugar over the kernel’s programmatic API. Decorators capture metadata in a plain MetadataStore (no reflect-metadata); the DecoratorPlugin reads that store at registration and registers routes, services, and middleware with the kernel. The store is published under CAPABILITIES.METADATA_STORE so ctx.metadata resolves to it. Decorators are inert unless the DecoratorPlugin is registered — they write to the shared singleton regardless, but only the plugin reads it. Implemented in Milestone 9; this section is the authoritative export list (AI_GUIDELINES §10.5). All exports carry full JSDoc.

TC39 standard decorators — no compiler option required. The surface needs no experimentalDecorators, no emitDecoratorMetadata, and no compilerOptions entry of any kind: Deno and Bun parse standard decorators unconfigured. Declaring an option leaves Deno’s other defaults in force (measured on Deno 2.9.6), so an existing compilerOptions block needs no edit either. Node needs a transform (tsx) because V8 has not shipped decorators.

The standard proposal has no parameter position, so handler arguments are declared at the method level with @Params(...) and constructor dependencies at the class level with @Inject(...); both are positional, the Nth entry binding the Nth argument. @Params is checked against the handler’s own signature, which the legacy parameter decorators never were.

Importing the package installs Symbol.metadata when the runtime lacks it. Node 24 and Bun 1.4 do not define the well-known symbol, so TypeScript’s decorator emit hands every decorator context.metadata === undefined. Evaluating @setu-ts/decorator-plugin sets Symbol.metadata ??= Symbol.for('Symbol.metadata') once, at module load — a write-if-absent global mutation, the one module-level exception to AI_GUIDELINES §11.5 (maintainer-approved for M111a). On Deno, which defines the symbol, it is a no-op. The registered key is the one tsx, esbuild and Bun’s compiler already use, so two copies of the package and those transforms agree on one carrier. A decorated class evaluated before the package is imported — or a bundle that dropped the import as side-effect-free — fails at class definition with a named DecoratorMetadataUnavailableError whose message gives both remedies, rather than a bare TypeError; the package manifest declares no sideEffects: false, and the Node manifest generator refuses one that would let a bundler drop the installing module.

Values (decorator-plugin exports)

ExportKindPurpose
DecoratorPluginfunctionPlugin factory — registers MetadataStore, routes/services, explicitly activated @Module trees, and classes explicitly listed in ingress during onInit
MetadataStoreclassIMetadataStore implementation (the concrete store)
metadataStorevalueThe process-wide singleton decorators write to and the plugin reads
ControllerfunctionClass decorator — base path prefix
VersionfunctionClass decorator — API version prefix
Get/Post/Put/Patch/Delete/Head/OptionsfunctionHTTP method decorators
ProcessorfunctionMethod decorator — registers a bound handler with IQueue.process(name, handler, options) during onInit
Cron/EveryfunctionMethod decorators — schedule the bound handler by its method name through IScheduler.cron() / every() during onInit
OnEventfunctionMethod decorator — subscribes the bound handler to an IEventBus event type during onInit
SubscribefunctionMethod decorator — subscribes the bound handler to an IMessageBroker topic during onInit
Gateway with OnOpen/OnMessage/OnClosefunctionClass and method decorators — assemble one IWebSocketService.route() declaration from a gateway class during onInit
CommandHandler/QueryHandlerfunctionMethod decorators — register bound ICommandHandler / IQueryHandler objects during onInit
UseIngressBehaviors/UsePipelineBehaviorsfunctionMethod decorators — scope IIngressBehavior to one supported non-HTTP ingress handler, or IPipelineBehavior to one command/query handler; wrong decorator category is refused at startup
ParamsfunctionMethod decorator binding handler arguments to sources, positionally; type-checked against the handler’s signature
ServicefunctionParameter source for @Params(...) (M125): Service<T>(token) resolves token from the request’s DI scope (container.forRequest(ctx)) on every call, so a singleton controller reaches scoped services per request. token is any ContainerToken<T>. Without a registered DI container it is reported at startup as unresolvable. A controller constructor that reaches a scoped service fails the boot, and its error names this source
InjectablefunctionClass decorator — marks a class for DI registration
InjectfunctionClass decorator declaring constructor injection tokens, one per argument in argument order
OptionalfunctionWraps a token inside @Inject(...); that argument receives undefined when the token has no provider
Roles/PermissionsfunctionClass/method decorator — authorization requirements. ENFORCED when enforceRoles is not false: each decorated route gets middleware resolving CAPABILITIES.AUTHORIZATION per request, answering 401 without a principal and 403 on a failed check with the same bodies the equivalent @UseGuards(requireRole(...)) spelling produces. With no authorization provider the route FAILS CLOSED — 501, never served unguarded — and DecoratorPlugin warns once per route. Method-level declarations override class-level; a route carrying both @Roles and @Permissions requires (any role) AND (any permission)
PublicfunctionMethod decorator — contributes security: [] only for a route without an enforced @Roles/@Permissions restriction. It does NOT exempt a route from a guard or from enforcement; a restricted route keeps its derived OpenAPI security requirement
UseGuards/UseInterceptors/UseFiltersfunctionClass/method pipeline decorators
ValidateBody/ValidateQuery/ValidateParamsfunctionMethod decorators — attach validation schemas. ENFORCED when a CAPABILITIES.VALIDATION provider is registered and enforceSchemas is not false: the capability’s middleware is appended LAST in the route’s chain (after guards), answering 400 before the handler while preserving guard 401/403 precedence. Without such a provider the schemas stay description-only and DecoratorPlugin logs one warning per affected route
RenderfunctionMethod decorator — attaches a view component to the route; the handler returns the component’s PROPS bag and the framework answers text/html; charset=utf-8, never JSON. The handler’s return is type-checked against the component’s props. The engine is resolved from CAPABILITIES.VIEW once at register() (the token joins optionalDependencies), and a rendered route with no provider FAILS at register() naming the controller, the handler and both remedies. A FIXED status or header alongside a rendered body is declared with @HttpCode/@ResponseHeader; a computed one goes through @Params(Ctx()) (M92, M97b)
HttpCodefunctionMethod decorator — the success status a handler answers with when it returns a plain value. Written to the response builder BEFORE the method runs, so a returned HandlerResult (ctx.response.status(202).json(...)) still wins and a @Render route composes with it. Must be an integer in [200, 599], refused at register() otherwise; NOT narrowed to 2xx. 204/205/304 serve bodiless. Derived into the OpenAPI document via deriveResponseStatus (M97b)
ResponseHeaderfunctionMethod decorator — one fixed response header; repeatable for DISTINCT names. The same name twice, an invalid name or value, or Location alongside @Redirect are each refused at register(). A multi-valued header wants ctx.response.appendHeader(...) through @Params(Ctx()). Deliberately NOT derived into the document: an OpenAPI response-header entry needs a schema and description the declaration does not carry (M97b)
RedirectfunctionMethod decorator — sets the status and Location; 302 by default. The status must be an integer in [300, 399], and the target must be non-blank and a value the runtime will carry as a Location header — both refused at register(), because an invalid header value throws while the response headers are written (500 on every request) and a blank one serves a redirect no client can follow. It does NOT short-circuit: a decorator cannot decline to call the method, so the handler still runs and a plain return is still serialised. One handler may not carry both @Redirect and @HttpCode, since both set the status. Derived into the document as its 3xx (M97b)
IdempotentfunctionMethod decorator — marks an UNSAFE route idempotent. Its middleware is appended LAST in the route’s chain, after guards, declarative authorization and the validation band, so a refused or invalid request consumes no key. Requires a CAPABILITIES.IDEMPOTENCY provider: without one register() fails naming the controller and handler, and @Idempotent on a safe method (GET/HEAD/OPTIONS) is refused too. The stored response is replayed by whichever store the provider serves (M109a)
Policy / Ability / RequirePolicyfunctionSince M110a. @Policy(name) marks a class as an authorization policy; @Ability({ anonymous? }) marks each ability method (a method named before is the policy’s before hook). DecoratorPlugin({ policies }) constructs each class like a controller — with constructor injection — and registers it with AuthPlugin’s IAuthorizationPolicyService before any controller. @RequirePolicy(policy, ability, target?) (repeatable, all-of, top to bottom) takes a @Policy class or a definePolicy definition and requires the ability on a route: 501 with no policy service, 401 / 403 on a denial, the same bodies as AuthPlugin’s requirePolicy
ScopedRoles / ScopedPermissionsfunctionSince M110b. Class/method decorators requiring catalogue roles (any-of) or permissions (all-of) in the request’s scope, over AuthPlugin’s built-in scoped-rbac policy; the optional second argument is a ScopeSource (default scopeFromTenant()). Validated at register(); 501 / 401 / 403 like requireScopedRole / requireScopedPermission
RenderDecoratortypeThe type Render returns: a standard method decorator whose value parameter is narrowed to (...args: never[]) => P | HandlerResult | Promise<P | HandlerResult>
ApiTagsfunctionClass decorator — OpenAPI tags
ApiOperation/ApiResponsefunctionMethod decorators — OpenAPI operation metadata
ModulefunctionClass decorator grouping controllers, providers, and imported modules for DecoratorPlugin({ modules })
createDecoratorfunctionCustom class/method decorator factory
Body/Query/Param/Header/Cookie/CurrentUser/CtxfunctionBuilt-in parameter SOURCES, declared inside @Params(...); Ctx yields the active IRequestContext without reserving the application custom type name context
CustomfunctionDeclares a parameter source resolved by a resolver registered under the same name
resolveParametersfunctionResolves an ordered argument array from parameter metadata
resolveParameterfunctionResolves a single parameter value
registerParameterResolverfunctionRegisters a resolver for a custom parameter type
getParameterResolverfunctionLooks up a custom parameter resolver
clearParameterResolversfunctionClears the custom resolver registry (tests)
parseCookiesfunctionParses a Cookie header into a name→value record
discoverControllersfunctionAuto-discovers decorated classes from a directory

Types

ExportKindPurpose
DecoratorPluginOptionstypeOptions for DecoratorPlugin() (autoDiscover?, controllersPath?, controllers?, services?, ingress?, modules?, enforceSchemas?, enforceRoles?, policies?)
AbilityOptions / PolicyClassAbility / PolicyClassTargettypeSince M110a. @Ability’s options, and the helpers typing @RequirePolicy’s ability (a policy class’s method names) and target (read off the method’s second parameter)
InjectableOptionstypeOptions for @Injectable() (scope?, token?)
ModuleOptionstypeOptions for @Module() (controllers?, providers?, imports?; no exports)
ApiOperationConfigtypeConfig for @ApiOperation() (operationId?, summary?, description?); all three fields reach RouteSchema; OpenAPI uses a declared id verbatim and emits the description
ApiResponseConfigtypeConfig for @ApiResponse() (status, description?, schema?)
HttpMethodDecoratortype(path?: string) => SetuMethodDecorator
ParamSourcetypeOne entry in a @Params(...) declaration; carries the resolved value type
SourceValuestypeMaps a source tuple onto the handler parameter tuple it binds
InjectTokentypestring | OptionalToken — one entry in an @Inject(...) list
OptionalTokentypeA token wrapped by Optional(...), marking that argument absent-tolerant
SetuClassDecoratortypeA standard class decorator that records metadata and leaves the class unchanged
SetuMethodDecoratortypeA standard method decorator that records metadata and leaves the method unchanged
SetuClassOrMethodDecoratortypeA standard decorator valid in either position, discriminating on context.kind
MiddlewareLiketypeMiddlewareFunction | (new () => IMiddleware) — accepted by pipeline decorators
CustomParameterResolvertype(ctx, metadata?) => unknown | Promise<unknown>
ParameterMetadatatypeParameter metadata captured by a @Params(...) source
ParameterTypetype'body' | 'query' | 'param' | 'header' | 'cookie' | 'custom'
DiscoveryOptionstypeConfig for discoverControllers() (path, extensions?, exclude?)
DiscoveryResulttypeResult of discovery (controllers, services, errors)
ModuleImportertype(specifier: string) => Promise<unknown> — injectable module loader

Contract notes:

  • Inert without the plugin: decorators write to the metadataStore singleton at class-definition time regardless of whether the plugin is registered. Only DecoratorPlugin.register() reads the store and calls the kernel APIs; without it, no routes/services/middleware are registered.

  • Modules are activation groups, not DI boundaries: DecoratorPlugin({ modules }) flattens imports depth-first, registers imported providers before the importing module’s controllers, and deduplicates classes by identity. A class passed as a module without @Module metadata logs a warning and contributes nothing. There is no exports option because application service visibility is not module-scoped.

  • Validation schemas are enforced, not just described (enforceSchemas, default true): for each of schema.body/query/params present on a route, registerController resolves CAPABILITIES.VALIDATION and appends that capability’s middleware LAST in the route’s chain — innermost, after guards, so an unauthenticated request is refused by its guard rather than told 400 with a body that names the schema’s field paths. With no validation provider registered, a decorated schema stays description-only (OpenAPI) and ONE warning per route names the controller, handler, affected targets and ValidationPlugin; nothing throws. enforceSchemas: false keeps schemas description-only and silences the warning.

  • @Roles/@Permissions are enforced, not just described (enforceRoles, default true): registerController appends one middleware per present restriction — roles first, so a route carrying both kinds is refused by the one that failed — after guards and BEFORE interceptors, ordinary middleware, filters, and validation, so no later stage can short-circuit a declared restriction. The middleware resolves CAPABILITIES.AUTHORIZATION PER REQUEST, so a provider registered after register() is honoured; with none, the route answers 501 Not Implemented / "Authorization is not configured" and register() warns once per affected route naming the controller, handler, restriction and both remedies (register a provider, or enforceRoles: false). enforceRoles: false keeps the metadata description-only and silences the warning — the pre-M89a behaviour. The appended middleware is M57-branded, so deriveSecurity documents decorated routes.

  • @RequirePolicy is validated at register() and always enforced (M110a). Each @RequirePolicy appends one middleware AFTER @Roles/@Permissions and BEFORE interceptors, ordinary middleware, filters and validation — so a target extractor reading the body sees the UNVALIDATED body. register() throws, naming the route, when no CAPABILITIES.AUTHORIZATION_POLICIES provider is registered or when the named policy or ability is not registered (an ordinary method of a policy class type-checks and is refused here), or when the referenced class or definition does not declare that ability with the registered policy’s anonymous flag (a different policy sharing the registered one’s name — the same refusal requirePolicy meets at startup). An anonymous request to a non-anonymous ability is refused before the target extractor runs. enforceRoles does not govern it. Listing policies with no provider, a class without @Policy, or one without an @Ability() method throws too. The middleware is branded authenticated unless the ability is anonymous, and a route’s @Public OpenAPI marker is omitted when any @RequirePolicy requires a principal. The two packages may not import each other, so @RequirePolicy and requirePolicy are two thin middlewares over one service, pinned together by a parity test.

  • @ScopedRoles / @ScopedPermissions are validated at register() and always enforced (M110b). @ScopedRoles(roles, scope?) (any-of) and @ScopedPermissions(permissions, scope?) (all-of) are the class form of AuthPlugin’s requireScopedRole / requireScopedPermission; scope is a ScopeSource defaulting to scopeFromTenant(). Class level sets a default, method level overrides it, per decorator. Each appends one middleware — roles, then permissions — AFTER @Roles/@Permissions and @RequirePolicy. An empty list or a non-string name throws at decoration time; register() throws, naming the route, when no CAPABILITIES.AUTHORIZATION_POLICIES provider is registered or a name is outside the scoped RBAC catalogue (so AuthPlugin({ rbac, scopedRbac }) is required, and a per-scope custom role cannot be named). Per request: 501 with no policy service, 401 for an anonymous request before the scope source runs, 403 on a deny; an unresolved scope denies. The middleware is branded authenticated, and the route’s @Public OpenAPI marker is omitted. Refusal bodies are byte-identical with the guards’, pinned by a parity test under errorHandler({ format: 'rfc9457' }).

  • Body()/Query()/Param() read the VALIDATED value when one exists. Each checks ctx.state under validatedStateKey(target) first — presence-tested with has, so a validated null or 0 is honoured — and falls back to the raw source when absent. A Zod transform or default therefore reaches the handler instead of being discarded. Declare that parsed type on the source — Body<T>(), Query<T>() — since the source cannot infer it from the schema. Header() and Cookie() deliberately read their raw sources: headers resolve case-insensitively through headers.get(name), which the validated record would break, and no schema key exists for cookies. An absent header or cookie resolves to undefined, matching their string | undefined type.

  • No reflection: metadata is stored in plain Maps keyed by class reference, not via Reflect.getMetadata(). No reflect-metadata dependency.

  • Decorator composition: cross-cutting decorators (@Params, @ValidateBody, @Roles, …) run before the HTTP-verb decorator; the store accumulates per-method and derives one RouteMetadata per (method, HTTP verb) at read time, so metadata is correct regardless of application order. Class-level guards/interceptors/middleware run before method-level; method-level @Roles/@Permissions override class-level; @Public sets the isPublic flag (OpenAPI security: [] only for unrestricted routes — no guard bypass).

  • Handler return values: a controller method either returns a value (serialized as JSON by the plugin’s handler wrapper) or returns a HandlerResult from ctx.response.*.

  • Discovery: discoverControllers walks via IRuntimeServices.fs (absent on edge platforms → empty result with a warning) and loads modules with await import() (no require/eval). Snapshot-diff against the store attributes newly-decorated classes to each file. Discovery failures never crash the application.

  • Ctx() response control: Ctx resolves the live IRequestContext, so a decorated handler can configure ctx.response (status, headers, or a stream) and return its HandlerResult; it is a built-in custom parameter type and needs no resolver registration. It is recognised by a marker registered with Symbol.for, so it keeps working if two copies of the package share a process, and an application’s own Custom('context') still reaches its own resolver.

  • Controllers and the DI container: controllers are built once, during register(), and are registered in the container as singleton whatever its defaultScope (M125). A scoped service reaches a handler through @Params(Service(token)); an @Injectable class is also registered under the class itself, so resolve(UserService) returns the same instance as its string token.

  • Startup diagnostics: when a logger is registered, DecoratorPlugin.register() warns (never throws) about two silent misconfigurations. A class passed in controllers that carries no @Controller metadata registers no routes — the usual cause is two copies of the package, where decorators populate one copy’s metadata store while the plugin reads the other’s, so every route 404s. And a custom parameter with no matching resolver reaches the handler as undefined; the warning names the controller, handler, and parameter index. The parameter check reflects the resolvers registered when the plugin registers, so call registerParameterResolver before app.start().

  • Custom decorators: createDecorator records class/method metadata replayed against DecoratorHandlers registered via ctx.decorators.register() (collected under CAPABILITIES.DECORATOR_HANDLER). Custom(name, metadata?) declares a parameter source resolved by resolveParameters via registerParameterResolver; the context and current-user built-ins resolve directly to ctx and ctx.request.user, respectively.

  • @Inject is a class decorator, and a token is always required. The list is positional: the Nth entry names the Nth constructor argument. The TC39 proposal has no parameter position, so there is nowhere else to put it.

    @Injectable({ token: 'user-repository' })
    @Inject(CAPABILITIES.DATABASE, CAPABILITIES.LOGGER)
    class UserRepository {
      constructor(
        private db: IDatabase,
        private logger: ILogger,
      ) {}
    }

    Wrap an entry in Optional(...) to let that argument receive undefined when the token has no provider. Optional means the dependency is absent, not that construction may fail: an error raised while building a token that IS provided propagates rather than being swallowed.

    @Injectable()
    @Inject(CAPABILITIES.DATABASE, Optional(CAPABILITIES.CACHE))
    class ReportService {
      constructor(
        private db: IDatabase,
        private cache?: ICacheStore,
      ) {}
    }

    A token cannot be inferred from the parameter’s type: that needs emitDecoratorMetadata, which Deno does not support, and no source in this repo reads design:paramtypes. A list shorter than the constructor leaves the trailing arguments undefined — a positional list cannot have gaps, so the legacy “parameter N has no @Inject token” and “declares both @Inject forms” refusals no longer have any reachable input and are gone. Method parameters bind with @Params(...).

    The list is read in declaration order and reaches the constructor in that order. The legacy index-keyed assembly is gone with the parameter form it existed for: parameter decorators evaluated in REVERSE argument order, so tokens had to be stored by index and re-sorted.

  • The container is preferred whenever the class is registered in it, with or without @Injectable. A @Controller carries no @Injectable, so a constructor-injected controller in a DiPlugin application resolves through the container — where its dependencies live.

  • Optional(token) marks an injected dependency absent-tolerant, not construction fallible. It wraps an entry inside the class-level @Inject(...) list, in the position of the argument it describes, and never replaces the token — one is still required, for the same emitDecoratorMetadata reason above:

    @Injectable({ token: 'report-service' })
    @Inject(CAPABILITIES.DATABASE, Optional(CAPABILITIES.CACHE))
    class ReportService {
      constructor(
        private db: IDatabase,
        private cache?: ICacheService,
      ) {}
    }

    When the token has no provider the argument receives undefined; when it HAS one it is resolved normally, so an error raised while building it — a circular dependency, a throwing factory — propagates rather than being masked as absence. Optional cannot be misplaced any more — it is an argument to @Inject(...) rather than a decorator, so the three legacy refusals it needed have no reachable input and are gone. One throw remains, for a caller writing to the store directly: an optional index that the @Inject(...) list names no token for is refused at register(), since it would otherwise pass undefined for an argument nothing declares.

    Both construction paths honor it identically — the DI container when one is registered, the kernel’s service registry otherwise. On the container path a class carrying @Optional registers as a useClass provider whose inject entries mark the optional positions ({ token, optional: true }, M125), so its dependencies resolve from the scope that resolves it — a request scope included — and boot validation sees them.

  • No runtime-specific APIs: the package uses no Date.now(), Deno, process, or fs — all file/time operations go through IRuntimeServices.


Agent card · TypeDoc API reference