@aventara/nest
Hosts an Aventara AvFramework in a NestJS application. It mounts the framework's HTTP protocol
(AvProtocol.bind(framework) from @aventara/core/protocol) on Nest's HTTP platform, and puts the AvFramework into
Nest's dependency injection. Every rule of the protocol (routes, decoding, statuses, envelopes, headers, absence) is
core's; this package only translates a Nest request into the protocol's request and writes the protocol's answer back.
It runs on Nest 12 (@nestjs/common and @nestjs/core ^12.0.0), on Express (Nest's default) or Fastify.
@aventara/core is its one dependency.
Mounting it
AventaraModule is a global module, imported once in your root module. It takes the AvFramework in one of four forms.
// src/app.module.ts — the shape `aventara init` writes (`@aventara/cli`)
import { Module } from "@nestjs/common";
import { AventaraModule } from "@aventara/nest";
import { aventaraConfig } from "./aventara.config.js";
import { PrismaModule, PrismaService } from "./prisma.service.js";
@Module({
imports: [
PrismaModule,
AventaraModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: async (prisma: PrismaService) => ({
config: await aventaraConfig(prisma),
}),
}),
],
})
export class AppModule {}
// src/aventara.config.ts — the framework's configuration: api, exposure, pipelines, resources
import type { AvDiagnostic } from "@aventara/core";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import { Logger } from "@nestjs/common";
import { discovery } from "./generated/aventara/discovery.artifact.js";
import type { PrismaService } from "./prisma.service.js";
const logger = new Logger("Aventara");
export async function aventaraConfig(prisma: PrismaService) {
return {
api: "/api",
exposed: true,
// The raw error of a server failure, with its stack, in Nest's log.
diagnostics: (diagnostic: AvDiagnostic) => {
const { error } = diagnostic;
logger.error(
`${diagnostic.code} (request ${diagnostic.requestId}): raw error`,
(error instanceof Error && error.stack) || String(error),
);
},
adapter: await createPrismaAdapter({
client: prisma,
discovery,
provider: "postgresql",
driver: "@prisma/adapter-pg",
}),
} as const;
}
| Form | When |
|---|---|
AventaraModule.forRoot({ framework }) | You called createFramework(config) yourself and want your own typed AvFramework<M, Cfg> in code. |
AventaraModule.forRoot({ config }) | The module calls createFramework(config) for you. |
AventaraModule.forRootAsync({ imports, inject, useFactory }) resolving { framework } or { config } | What builds the Framework (a Prisma client, a configuration service) is itself a provider. |
Each form also takes state, which seeds every protocol request's ctx.$state from the platform request (see
Request state).
The config form is typed exactly like createFramework's parameter: an annotated AvConfig<M>, an inline
literal, an inferred constant and a satisfies check are all accepted, and checked against your Adapter's model.
@InjectFramework() (or @Inject(AVENTARA_FRAMEWORK)) injects the AvFramework. DI carries no type argument, so type
the parameter with your own alias:
export type AppFramework = Awaited<ReturnType<typeof buildFramework>>;
@Injectable()
export class ReportsService {
constructor(@InjectFramework() private readonly framework: AppFramework) {}
}
These reject while Nest builds the application, before it listens: both or neither of framework and config; a
framework value that is not a Framework; a state option that is not a function; the module mounted twice in one application; a platform other than Express
or Fastify (or none, as in createApplicationContext); an AvConstructionError, yours or the module's own, which
propagates unchanged. Nest's default abortOnError: true exits the process on a bootstrap error instead of rejecting;
pass abortOnError: false to NestFactory.create to catch it.
Nest's testing module works. Test.createTestingModule({ imports: [AppModule] }).compile() builds the providers before
any HTTP platform exists; the module waits for the one createNestApplication() supplies, and still refuses an
unsupported platform before the application listens. A testing module initialized with no application at all is
refused at init(), as a platform of none.
The module owns no resource, so there is nothing to shut down. Your Prisma provider closes its own client in its
onModuleDestroy, with app.enableShutdownHooks() for signals.
Calling it by hand
Against the shape aventara init writes (the api mount path /api, a Prisma User model):
# 1. The contract. Its protocol.hash is what every resource request sends as Aventara-Contract-Hash.
HASH=$(curl -s http://localhost:3000/api/_contract | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).protocol.hash')
# 2. find.many on User, with both identity headers.
curl -s -X POST http://localhost:3000/api/_resources/User/find/many \
-H 'Content-Type: application/json' \
-H 'Aventara-Protocol-Version: 1' \
-H "Aventara-Contract-Hash: $HASH" \
-d '{}'
# {"data":[],"code":"A1000","cause":null}
Leave out an identity header and the answer is 400 A2000, naming the header that is missing. The hash changes
whenever the schema or the configuration does: fetch it again, and regenerate the client.
Resource keys are the model names, as written. model User is User everywhere: on the wire
(/_resources/User/find/many) and in the generated client (avClient.User.find.many({})). Nothing is renamed,
lower-cased or pluralized.
Results are read-only. A list result is a readonly array, so type it readonly User[]:
const users: readonly User[] = await avClient.User.find.many({});.
What runs on a protocol route
The protocol's routes are platform routes, registered from the module's configure(). There is one per
protocol.surface() entry (every advertised operation, GET <api>/_contract, and POST <api>/_transactions only when the ClientContract advertises interactive), plus the absence handler for the
rest of <api>/_*. Nest's RouterExplorer does not list platform routes, so the module logs one line of its
own when it mounts them, through Nest's Logger (context AventaraModule; logger: false silences it like any other):
[Nest] LOG [AventaraModule] Aventara mounted 10 operations at /api (+ GET /api/_contract, POST /api/_transactions, POST /api/_all)
An internal failure (A3000–A3004, answered as a 500) is logged as one error line through Nest's Logger
(context Aventara): the code, the operation, the origin and the request id, and the thrown value's class name. It
never logs the message, which can hold SQL or a connection string. The raw error still goes to your diagnostics sink, when you
configure one. The aventara.config.ts that aventara new and aventara init write has one: it logs the raw error with its
stack through the same Logger, at error level, right after this line.
[Nest] ERROR [Aventara] A3001 on Post.create.one (client origin, request 3f1c…): PrismaClientValidationError. The raw error goes to the framework's diagnostics sink.
[Nest] ERROR [Aventara] A3001 (request 3f1c…): raw error
PrismaClientValidationError: …
For A3004 (one of your pipes returned arguments the server contract refuses), the line also names the pipe, the
operation, the offending field and its V-code. The caller only ever sees the generic message.
The line is added when the module builds the framework from config; a framework you build yourself and pass as
framework keeps exactly the diagnostics sink you gave it.
The framework's validation is outermost:
| Express | Fastify | |
|---|---|---|
Your app.use(...) middleware, app.enableCors(), helmet | runs | runs |
| Nest guards, interceptors, pipes (global or not) | never | never |
MiddlewareConsumer middleware | never | runs, because Fastify runs Nest middleware before routing; add .exclude("api/_*path") (your api path) to keep it off |
Your controller at <api>/_contract (or any _ path) | never reached | the application does not start |
Your controller at <api>/health (outside _) | reached | reached |
Authorization therefore belongs in Aventara pipelines (guards). A guard reads the request's headers as
ctx.$transport.headers (lower-cased names, repeats joined), and whatever your middleware found as ctx.$state,
through the module's state option (next section).
Request state
state seeds each protocol request's ctx.$state, the one object every stage of that request reads and writes. It is
called once per request, with the platform's own request, after the protocol has accepted it (a stale client or an
unknown route never calls it):
declare module "@aventara/core" {
interface AvState {
user?: { id: string; roles: string[] };
}
}
AventaraModule.forRoot({
config,
state: (req: Request) => ({ user: req.user }), // what your auth middleware set
});
// In the configuration: a guard reads it.
const signedIn: AvGuard = (ctx) => ctx.$state.user !== undefined;
forRootAsync'suseFactoryreturnsstatebesideframeworkorconfig.- On Express,
reqis Express's request. On Fastify it is Fastify'srequest;app.usemiddleware writes to its Node request, so readrequest.raw.user. Fastify answers a protocol route from itsonRequesthook, so only what runs before it (app.usemiddleware, an earlieronRequesthook) has run. - Use
app.use(...)for the middleware that finds the user (a Passportauthenticatecall, a session or JWT check). Nest guards never run on protocol routes, andMiddlewareConsumermiddleware does not on Express (the table above). - A Framework built with
access(an access provider, see@aventara/core) seeds the state itself: the module calls the provider'sstatewith{ headers, request }, the platform request's headers and the request. Pass nostatebeside it; the module refuses the pair while Nest builds the application, and as a type error on aconfigthat statesaccess. It works the same throughconfigandframework:AventaraModule.forRoot({ config: { ...config, access } }). - Throw
new AvError("A4001", "…")fromstateto refuse the request (403); any other throw is500 A3000, with the error in yourdiagnosticssink. - A plan sent to
/_transactionsgets one state for all its steps and transaction stages. A ctx call a stage makes inherits it. Compute reads it but cannot write it. - Server code that calls the framework in process seeds it per call:
framework.application.Post.create.one(args, { state: { user } }).
AvConfig.api alone decides where the protocol is mounted. app.setGlobalPrefix(...) does not move it; set
api to where you want it, and point the generated client at the same URL.
Requests
- Bodies on protocol routes are read raw, never through Nest's body parser, up to
limits.maxRequestBytes. The protocol's decoder answers an oversized body (413 A2010), another content type (415 A2011) and malformed JSON (400 A2000). Your own routes keep Nest's parsedreq.body. NoNestFactory.createoption is needed. - For
Content-Encoding,gzip,deflateandbrare inflated, and the limit counts decoded bytes. Any other coding answers415 A2011; a body that does not decode in the coding it names answers400 A2000. These two answers carry a newly mintedAventara-Request-Id, not the client's. - The request id is the client's
Aventara-Request-Idas it arrived, or one core mints. A middleware that rewritesreq.headerscannot change it, andX-Request-Idis never copied into it, so setAventara-Request-Idat your proxy. - A query string is not part of the protocol path; it is ignored.
The MCP endpoint
With mcp in the configuration, the module also mounts the MCP server of @aventara/mcp at that path, on Express and
Fastify alike, and logs Aventara mounted the MCP endpoint at /mcp (…). Install it beside this package, at
@aventara/core's exact version:
npm install --save-exact @aventara/mcp@<your @aventara/core version>
It is an optional peer dependency: an application without mcp does not need it, and one with mcp and without the
package refuses to start, naming it and the command, with the version filled in:
AventaraModule: the configuration has `mcp`, and the package that serves it, @aventara/mcp, is not installed. Install it at @aventara/core's exact version (npm install --save-exact @aventara/[email protected]), or remove `mcp` from the configuration.
As for the protocol's routes, the module only mounts: the endpoint's body is read
raw, the request's URL comes from the platform's protocol and host (set trust proxy on Express or trustProxy on
Fastify behind a proxy), and every answer is @aventara/mcp's. The state option seeds each tool call's ctx.$state.
See @aventara/mcp's README for the tools, the contract resource and authorization.
Platform differences
- Fastify cannot mount some Resource keys or
apipaths as literal routes. A Resource key that needs*or a reserved character (: + # $ & , / ; = ? @) in its route path, or anapipath containing*, is refused at startup with a message naming it. Rename it, or host on Express, which mounts every key. - Express routing is case-insensitive by default, so it also answers the protocol under, say,
/API/_contract; Fastify's is not. That is Express's owncase sensitive routingsetting, off by default, and yours to change. The MCP endpoint is the exception: on both platforms it answers its own path only, exactly as written (/mcp, not/MCPor/mcp/). - Express answers
500 A3000to a protocol route requested with a percent-encoding a generated client never sends (for example%6Eotesfornotes); Fastify and the in-process protocol execute it. This is a known divergence. - On Express the response carries Express's
X-Powered-Byheader;app.disable("x-powered-by")removes it.
License
PolyForm Shield 1.0.0 with an additional permission. It is free to use, including in commercial applications, but you
may not use it to build a product that competes with Aventara itself. See LICENSE and
LICENSE-ADDITIONAL-PERMISSION.md.