Wiring your own auth
KavoContext.app is the application's request-scoped context — the authenticated caller, the tenant, a request id, whatever your app needs to carry. Kavo carries it and nothing more: core never reads, judges or shapes the value. Your own code does, whether that's a policy, a custom operation handler, or a replacement OperationHandler.
Type it
KavoAppContext ships empty. Declare its shape once, and every context.app read is typed with no cast:
declare module "@kavo/core" {
interface KavoAppContext {
userId?: string;
roles?: string[];
permissions?: string[];
tenantId?: string;
}
}One shape per process — @Kavo is a decorator and can't infer a per-entity type, so the augmented interface is what types context.app everywhere. Until you declare a field, reading it is a compile error: an unwired ownership check should not silently type-check.
Two rules for what goes in it:
- Declare every field optional unless your extractor is guaranteed to populate it. Kavo types
context.appas fully populated no matter what the request carried, so a requireduserId: stringisundefinedat run time on any request with noappextractor — an anonymous REST call, and every GraphQL/MCP call. A required field turns that into a silent bug (context.app.userId === entity.ownerIdcompiles clean and comparesundefined); an optional one forces you to handle the missing case. - Put plain, shallow data in it — the fields your policies and custom operation handlers read, not
request.userpassed straight through. When the result cache is on,context.appis walked into the cache key on every cacheable read: a class instance with getter-backed fields (a Passport user, a TypeORM entity, a class-transformer object) canonicalizes identically for every caller and collapses them onto one cache bucket, so one caller's response is served to another. Build a plain object:{ userId: request.user?.id, roles: request.user?.roles }, notrequest.user.
Populate it
Getting app in place is two separate jobs, and only the second is Kavo's. Authenticating the caller is yours: a guard, a middleware, @nestjs/passport, whatever already runs ahead of the route handler and leaves the caller on the request. Kavo adds no auth dependency and mounts no guard of its own. The app option is the other half: building the context object from that request.
KavoModule.forRootAsync({
useFactory: () => ({
infrastructure: createInfrastructure(dataSource),
// Pull the plain fields you need off `request.user` — don't pass the
// guard's user object through as-is (see "Type it" above).
app: (request): KavoAppContext => {
const user = request.user as { id?: string; roles?: string[] } | undefined;
return { userId: user?.id, roles: user?.roles };
},
}),
});
// Read from wherever your guard actually leaves things:
KavoModule.forRoot({
infrastructure: createInfrastructure(dataSource),
app: (request): KavoAppContext => {
const session = request["session"] as Session | undefined;
return { userId: session?.account };
},
});The extractor runs once per request, inside the generated route handler, and what it returns is that request's context.app. Nothing is memoized between requests, so one caller's context can never be served to the next. Keep it synchronous and cheap: read a property some guard already set, rather than verifying a token or querying a table. Throwing from it fails the request with a 500 problem-details document instead of quietly producing an empty context.
Leave
appunset andcontext.appstays{}. Nothing is populated by assumption: an ownership predicate that quietly starts answering differently is worse than one you can see is unwired.It reaches standard and custom operations alike. One generated handler builds the request for every route, so a replacement handler on
POST /books/:id/claimsees the samecontext.appa plainGET /books/1does.It reaches the generated REST routes and nothing else. The GraphQL and MCP surfaces (
graphql/mcpabove, and controllers extendingBaseKavoGraphQLController/BaseKavoMcpController) call the service directly, socontext.appis{}there no matter what this option says. A policy or custom handler that readscontext.appsees an empty context overPOST /graphql.Programmatic callers pass their own:
crud.findOne(id, query, { app }). The module option is HTTP wiring, not a global; a background job has no request to extract from.When the result cache is on, the "plain, shallow data" rule above is load-bearing: the cache key is built from
context.app. A cyclic value throws aRangeErroron the read; a framework object silently collapses callers onto one bucket.A method Kavo does not generate passes its own too. An
@Override'd method or a fully custom route reaches the engine itself, so nothing fillsoptionsfor it.boundKavoAppContext(this, request)runs the extractor the module configured, so the method does not restate where the caller lives:ts@Override() async findOne( id: EntityId, query: WireQuery, preconditions: RequestPreconditions | null, request: KavoAppContextRequest, ) { const app = boundKavoAppContext(this, request); return boundKavoService<Book>(this).findOne(id, query, { app, preconditions: preconditions ?? undefined, }); }The request is the trailing parameter of the fixed layout Kavo wires for you; declare it only if you want it.
Row-scoping is still out of scope: Kavo will not filter findMany's match set to the caller on its own. Refusing an operation on the caller's behalf is not — @Kavo(Entity, { operations: { updateOne: { policy: hasPermission('post:update') } } }) denies a request before it reaches its handler, where hasPermission is a one-line Policy<Entity> reading context.app. policy is a single function — ({ context, entity, resource, operation, params }) => boolean | Promise<boolean> — so composition (role bypasses, ownership checks, anything else) is ordinary &&/||/! inside it, not a combinator DSL to learn, and it can also default at entity or global scope (EntityConfig.policy, createKavo({ policy })) rather than repeating itself per operation. A guard still decides who may call the route at all; policy decides who may perform this operation once they're in, and a replacement handler still decides what they get back. See Policy for the full shape and how the stage behaves.