@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 sourcesBody,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 aCAPABILITIES.IDEMPOTENCYprovider — see@setu-ts/idempotency-plugin - Views —
@Render(Component)(renders through aCAPABILITIES.VIEWprovider — see@setu-ts/view-plugin) - Response shaping —
@HttpCode,@ResponseHeader,@Redirect - OpenAPI —
@ApiTags,@ApiOperation,@ApiResponse(@ApiOperationforwardsoperationId,summaryanddescriptioninto 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
| Option | Type | Default | Description |
|---|---|---|---|
controllers | Constructor[] | [] | Controller classes to register explicitly. |
ingress | Constructor[] | [] | Classes carrying non-HTTP ingress decorators (@Processor, @Cron, @OnEvent, …); registered during onInit. Independent of controllers — see Non-HTTP ingress. |
services | Constructor[] | [] | Service classes to register explicitly. |
modules | Constructor[] | [] | Root @Module classes to flatten depth-first; imported module providers register before controllers. |
autoDiscover | boolean | false | Scan controllersPath for decorated classes. |
controllersPath | string | — | Glob path used when autoDiscover is true. |
enforceSchemas | boolean | true | Append the validation capability’s middleware for each present @ValidateXxx target. false keeps schemas description-only (OpenAPI) and silences the missing-provider warning. |
enforceRoles | boolean | true | Append enforcing authorization middleware for each route carrying @Roles/@Permissions. false keeps the metadata description-only and silences the missing-provider warning. |
policies | Constructor[] | [] | @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
| Export | Kind |
|---|---|
Ability | function |
ApiOperation | function |
ApiResponse | function |
ApiTags | function |
Body | function |
clearParameterResolvers | function |
CommandHandler | function |
Controller | function |
Cookie | function |
createDecorator | function |
Cron | function |
Ctx | function |
CurrentUser | function |
Custom | function |
DecoratorPlugin | function |
discoverControllers | function |
Every | function |
Gateway | function |
getParameterResolver | function |
Header | function |
HttpCode | function |
Idempotent | function |
Inject | function |
Injectable | function |
Module | function |
OnEvent | function |
Optional | function |
Param | function |
Params | function |
parseCookies | function |
Permissions | function |
Policy | function |
Processor | function |
Public | function |
Query | function |
QueryHandler | function |
Redirect | function |
registerParameterResolver | function |
Render | function |
RequirePolicy | function |
resolveParameter | function |
resolveParameters | function |
ResponseHeader | function |
Roles | function |
ScopedPermissions | function |
ScopedRoles | function |
Service | function |
Subscribe | function |
UseFilters | function |
UseGuards | function |
UseIngressBehaviors | function |
UseInterceptors | function |
UsePipelineBehaviors | function |
ValidateBody | function |
ValidateParams | function |
ValidateQuery | function |
Version | function |
MetadataStore | class |
Delete | const |
Get | const |
Head | const |
metadataStore | const |
OnClose | const |
OnMessage | const |
OnOpen | const |
Options | const |
Patch | const |
Post | const |
Put | const |
AbilityOptions | interface |
ApiOperationConfig | interface |
ApiResponseConfig | interface |
DecoratorPluginOptions | interface |
DiscoveryOptions | interface |
DiscoveryResult | interface |
InjectableOptions | interface |
ModuleOptions | interface |
OptionalToken | interface |
ParameterMetadata | interface |
ParamSource | interface |
CustomParameterResolver | type |
HttpMethodDecorator | type |
InjectToken | type |
MiddlewareLike | type |
ModuleImporter | type |
ParameterType | type |
PolicyClassAbility | type |
PolicyClassTarget | type |
RenderDecorator | type |
SetuClassDecorator | type |
SetuClassOrMethodDecorator | type |
SetuMethodDecorator | type |
SourceValues | type |
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, noemitDecoratorMetadata, and nocompilerOptionsentry 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 existingcompilerOptionsblock 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.@Paramsis checked against the handler’s own signature, which the legacy parameter decorators never were.
Importing the package installs
Symbol.metadatawhen the runtime lacks it. Node 24 and Bun 1.4 do not define the well-known symbol, so TypeScript’s decorator emit hands every decoratorcontext.metadata === undefined. Evaluating@setu-ts/decorator-pluginsetsSymbol.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 onetsx, 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 namedDecoratorMetadataUnavailableErrorwhose message gives both remedies, rather than a bareTypeError; the package manifest declares nosideEffects: false, and the Node manifest generator refuses one that would let a bundler drop the installing module.
Values (decorator-plugin exports)
| Export | Kind | Purpose |
|---|---|---|
DecoratorPlugin | function | Plugin factory — registers MetadataStore, routes/services, explicitly activated @Module trees, and classes explicitly listed in ingress during onInit |
MetadataStore | class | IMetadataStore implementation (the concrete store) |
metadataStore | value | The process-wide singleton decorators write to and the plugin reads |
Controller | function | Class decorator — base path prefix |
Version | function | Class decorator — API version prefix |
Get/Post/Put/Patch/Delete/Head/Options | function | HTTP method decorators |
Processor | function | Method decorator — registers a bound handler with IQueue.process(name, handler, options) during onInit |
Cron/Every | function | Method decorators — schedule the bound handler by its method name through IScheduler.cron() / every() during onInit |
OnEvent | function | Method decorator — subscribes the bound handler to an IEventBus event type during onInit |
Subscribe | function | Method decorator — subscribes the bound handler to an IMessageBroker topic during onInit |
Gateway with OnOpen/OnMessage/OnClose | function | Class and method decorators — assemble one IWebSocketService.route() declaration from a gateway class during onInit |
CommandHandler/QueryHandler | function | Method decorators — register bound ICommandHandler / IQueryHandler objects during onInit |
UseIngressBehaviors/UsePipelineBehaviors | function | Method decorators — scope IIngressBehavior to one supported non-HTTP ingress handler, or IPipelineBehavior to one command/query handler; wrong decorator category is refused at startup |
Params | function | Method decorator binding handler arguments to sources, positionally; type-checked against the handler’s signature |
Service | function | Parameter 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 |
Injectable | function | Class decorator — marks a class for DI registration |
Inject | function | Class decorator declaring constructor injection tokens, one per argument in argument order |
Optional | function | Wraps a token inside @Inject(...); that argument receives undefined when the token has no provider |
Roles/Permissions | function | Class/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) |
Public | function | Method 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/UseFilters | function | Class/method pipeline decorators |
ValidateBody/ValidateQuery/ValidateParams | function | Method 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 |
Render | function | Method 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) |
HttpCode | function | Method 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) |
ResponseHeader | function | Method 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) |
Redirect | function | Method 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) |
Idempotent | function | Method 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 / RequirePolicy | function | Since 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 / ScopedPermissions | function | Since 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 |
RenderDecorator | type | The type Render returns: a standard method decorator whose value parameter is narrowed to (...args: never[]) => P | HandlerResult | Promise<P | HandlerResult> |
ApiTags | function | Class decorator — OpenAPI tags |
ApiOperation/ApiResponse | function | Method decorators — OpenAPI operation metadata |
Module | function | Class decorator grouping controllers, providers, and imported modules for DecoratorPlugin({ modules }) |
createDecorator | function | Custom class/method decorator factory |
Body/Query/Param/Header/Cookie/CurrentUser/Ctx | function | Built-in parameter SOURCES, declared inside @Params(...); Ctx yields the active IRequestContext without reserving the application custom type name context |
Custom | function | Declares a parameter source resolved by a resolver registered under the same name |
resolveParameters | function | Resolves an ordered argument array from parameter metadata |
resolveParameter | function | Resolves a single parameter value |
registerParameterResolver | function | Registers a resolver for a custom parameter type |
getParameterResolver | function | Looks up a custom parameter resolver |
clearParameterResolvers | function | Clears the custom resolver registry (tests) |
parseCookies | function | Parses a Cookie header into a name→value record |
discoverControllers | function | Auto-discovers decorated classes from a directory |
Types
| Export | Kind | Purpose |
|---|---|---|
DecoratorPluginOptions | type | Options for DecoratorPlugin() (autoDiscover?, controllersPath?, controllers?, services?, ingress?, modules?, enforceSchemas?, enforceRoles?, policies?) |
AbilityOptions / PolicyClassAbility / PolicyClassTarget | type | Since 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) |
InjectableOptions | type | Options for @Injectable() (scope?, token?) |
ModuleOptions | type | Options for @Module() (controllers?, providers?, imports?; no exports) |
ApiOperationConfig | type | Config for @ApiOperation() (operationId?, summary?, description?); all three fields reach RouteSchema; OpenAPI uses a declared id verbatim and emits the description |
ApiResponseConfig | type | Config for @ApiResponse() (status, description?, schema?) |
HttpMethodDecorator | type | (path?: string) => SetuMethodDecorator |
ParamSource | type | One entry in a @Params(...) declaration; carries the resolved value type |
SourceValues | type | Maps a source tuple onto the handler parameter tuple it binds |
InjectToken | type | string | OptionalToken — one entry in an @Inject(...) list |
OptionalToken | type | A token wrapped by Optional(...), marking that argument absent-tolerant |
SetuClassDecorator | type | A standard class decorator that records metadata and leaves the class unchanged |
SetuMethodDecorator | type | A standard method decorator that records metadata and leaves the method unchanged |
SetuClassOrMethodDecorator | type | A standard decorator valid in either position, discriminating on context.kind |
MiddlewareLike | type | MiddlewareFunction | (new () => IMiddleware) — accepted by pipeline decorators |
CustomParameterResolver | type | (ctx, metadata?) => unknown | Promise<unknown> |
ParameterMetadata | type | Parameter metadata captured by a @Params(...) source |
ParameterType | type | 'body' | 'query' | 'param' | 'header' | 'cookie' | 'custom' |
DiscoveryOptions | type | Config for discoverControllers() (path, extensions?, exclude?) |
DiscoveryResult | type | Result of discovery (controllers, services, errors) |
ModuleImporter | type | (specifier: string) => Promise<unknown> — injectable module loader |
Contract notes:
-
Inert without the plugin: decorators write to the
metadataStoresingleton at class-definition time regardless of whether the plugin is registered. OnlyDecoratorPlugin.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 })flattensimportsdepth-first, registers imported providers before the importing module’s controllers, and deduplicates classes by identity. A class passed as a module without@Modulemetadata logs a warning and contributes nothing. There is noexportsoption because application service visibility is not module-scoped. -
Validation schemas are enforced, not just described (
enforceSchemas, defaulttrue): for each ofschema.body/query/paramspresent on a route,registerControllerresolvesCAPABILITIES.VALIDATIONand 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 told400with 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 andValidationPlugin; nothing throws.enforceSchemas: falsekeeps schemas description-only and silences the warning. -
@Roles/@Permissionsare enforced, not just described (enforceRoles, defaulttrue):registerControllerappends 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 resolvesCAPABILITIES.AUTHORIZATIONPER REQUEST, so a provider registered afterregister()is honoured; with none, the route answers501 Not Implemented / "Authorization is not configured"andregister()warns once per affected route naming the controller, handler, restriction and both remedies (register a provider, orenforceRoles: false).enforceRoles: falsekeeps the metadata description-only and silences the warning — the pre-M89a behaviour. The appended middleware is M57-branded, soderiveSecuritydocuments decorated routes. -
@RequirePolicyis validated atregister()and always enforced (M110a). Each@RequirePolicyappends one middleware AFTER@Roles/@Permissionsand BEFORE interceptors, ordinary middleware, filters and validation — so a target extractor reading the body sees the UNVALIDATED body.register()throws, naming the route, when noCAPABILITIES.AUTHORIZATION_POLICIESprovider 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’sanonymousflag (a different policy sharing the registered one’s name — the same refusalrequirePolicymeets at startup). An anonymous request to a non-anonymous ability is refused before the target extractor runs.enforceRolesdoes not govern it. Listingpolicieswith 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@PublicOpenAPI marker is omitted when any@RequirePolicyrequires a principal. The two packages may not import each other, so@RequirePolicyandrequirePolicyare two thin middlewares over one service, pinned together by a parity test. -
@ScopedRoles/@ScopedPermissionsare validated atregister()and always enforced (M110b).@ScopedRoles(roles, scope?)(any-of) and@ScopedPermissions(permissions, scope?)(all-of) are the class form of AuthPlugin’srequireScopedRole/requireScopedPermission;scopeis aScopeSourcedefaulting toscopeFromTenant(). Class level sets a default, method level overrides it, per decorator. Each appends one middleware — roles, then permissions — AFTER@Roles/@Permissionsand@RequirePolicy. An empty list or a non-string name throws at decoration time;register()throws, naming the route, when noCAPABILITIES.AUTHORIZATION_POLICIESprovider is registered or a name is outside the scoped RBAC catalogue (soAuthPlugin({ rbac, scopedRbac })is required, and a per-scope custom role cannot be named). Per request:501with no policy service,401for an anonymous request before the scope source runs,403on a deny; an unresolved scope denies. The middleware is branded authenticated, and the route’s@PublicOpenAPI marker is omitted. Refusal bodies are byte-identical with the guards’, pinned by a parity test undererrorHandler({ format: 'rfc9457' }). -
Body()/Query()/Param()read the VALIDATED value when one exists. Each checksctx.stateundervalidatedStateKey(target)first — presence-tested withhas, so a validatednullor0is honoured — and falls back to the raw source when absent. A Zodtransformordefaulttherefore 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()andCookie()deliberately read their raw sources: headers resolve case-insensitively throughheaders.get(name), which the validated record would break, and no schema key exists for cookies. An absent header or cookie resolves toundefined, matching theirstring | undefinedtype. -
No reflection: metadata is stored in plain
Maps keyed by class reference, not viaReflect.getMetadata(). Noreflect-metadatadependency. -
Decorator composition: cross-cutting decorators (
@Params,@ValidateBody,@Roles, …) run before the HTTP-verb decorator; the store accumulates per-method and derives oneRouteMetadataper (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/@Permissionsoverride class-level;@Publicsets theisPublicflag (OpenAPIsecurity: []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
HandlerResultfromctx.response.*. -
Discovery:
discoverControllerswalks viaIRuntimeServices.fs(absent on edge platforms → empty result with a warning) and loads modules withawait import()(norequire/eval). Snapshot-diff against the store attributes newly-decorated classes to each file. Discovery failures never crash the application. -
Ctx()response control:Ctxresolves the liveIRequestContext, so a decorated handler can configurectx.response(status, headers, or a stream) and return itsHandlerResult; it is a built-in custom parameter type and needs no resolver registration. It is recognised by a marker registered withSymbol.for, so it keeps working if two copies of the package share a process, and an application’s ownCustom('context')still reaches its own resolver. -
Controllers and the DI container: controllers are built once, during
register(), and are registered in the container assingletonwhatever itsdefaultScope(M125). Ascopedservice reaches a handler through@Params(Service(token)); an@Injectableclass is also registered under the class itself, soresolve(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 incontrollersthat carries no@Controllermetadata 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 asundefined; the warning names the controller, handler, and parameter index. The parameter check reflects the resolvers registered when the plugin registers, so callregisterParameterResolverbeforeapp.start(). -
Custom decorators:
createDecoratorrecords class/method metadata replayed againstDecoratorHandlers registered viactx.decorators.register()(collected underCAPABILITIES.DECORATOR_HANDLER).Custom(name, metadata?)declares a parameter source resolved byresolveParametersviaregisterParameterResolver; thecontextandcurrent-userbuilt-ins resolve directly toctxandctx.request.user, respectively. -
@Injectis 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 receiveundefinedwhen the token has no provider.Optionalmeans 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 readsdesign:paramtypes. A list shorter than the constructor leaves the trailing argumentsundefined— a positional list cannot have gaps, so the legacy “parameter N has no@Injecttoken” and “declares both@Injectforms” 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@Controllercarries no@Injectable, so a constructor-injected controller in aDiPluginapplication 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 sameemitDecoratorMetadatareason 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.Optionalcannot 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 atregister(), since it would otherwise passundefinedfor 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
@Optionalregisters as auseClassprovider whoseinjectentries 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, orfs— all file/time operations go throughIRuntimeServices.