Skip to content

Route Flags

A route flag is a named, inheritable fact about a route. It carries no behaviour of its own — it records something any consumer can read: this endpoint is public, this one is exempt from CSRF, this one is unmetered.

ts
import { defineRouteFlag } from '@forinda/kickjs'

export const Public = defineRouteFlag('auth.public')
ts
@Public // on the controller — every route below inherits it
@Controller()
export class WebhooksController {
  @Get('/health')
  health(ctx: RequestContext) {} // public

  @Public.off // the method wins
  @Post('/admin')
  admin(ctx: RequestContext) {} // not public
}

Why not just a decorator per concern?

Because the same fact keeps getting restated. Before flags, "this endpoint is open" was said three different ways: auth used a contributor, CSRF used ignorePaths, rate limiting used skipPaths. Two of those are exact pathname strings that cannot express /users/:id and keep parsing after an apiPrefix change that voids them.

A flag is declared once, on the route, and every consumer reads the same thing.

Reading flags

Anything holding a RequestContext — handlers, @Middleware(), guards, contributors — gets them from ctx.route:

ts
const requireAuth = (ctx: RequestContext, next: () => void) => {
  if (ctx.route?.flags.has('auth.public')) return next()
  if (!ctx.headers.authorization) throw new HttpException(401, 'Unauthorized')
  next()
}

ctx.route also carries method, path, controller and handlerName. It is undefined in global middleware, which runs before a route is matched.

Flags with values

A flag can carry more than presence:

ts
const RateLimit = defineRouteFlag<{ rpm: number }>('rate.limit')

@RateLimit({ rpm: 10 })
@Post('/login')
login(ctx: RequestContext) {}
ts
ctx.route?.flags.get('rate.limit') // { rpm: 10 }

A false flag is absent, not false

Removal is .off, not a falsy value

@Public.off removes the flag its class set. A resolved flag is either absent or present — presence is the whole question has() answers, so there is no "present but turned off" state to reason about.

Removal is spelled as its own member rather than @Public(false) for two reasons. It keeps false usable as a real value: a flag declared boolean can store it, and flags.get() can return it. And it removes the trap where @Public(false) looked like "not public" while a presence check still answered true — which would read a protected route as public.

DeclarationResolved
@Public on the class, nothing on the methodauth.public → true
@Public on the class, @Public.off on itabsent
@RateLimit({ rpm: 10 })rate.limit → { rpm: 10 }
@Enabled(false) (flag declared boolean)feature.enabled → false

@Public.off on a route that never inherited the flag is a no-op — usually a sign the author expected an inheritance that isn't there.

Consumers

Context contributors

skipWhen / onlyWhen on a contributor registration:

ts
const LoadAuthUser = defineHttpContextDecorator({
  key: 'user',
  skipWhen: 'auth.public',
  resolve: (ctx) => verify(ctx.headers.authorization),
})

This is what makes exemption composable. Without it, the only way to opt out of a contributor is to register a permissive twin under the same key — which requires owning that key, so you cannot exempt a contributor a plugin shipped. A flag lives on the route, so you can.

onlyWhen is the inverse: run only where the flag is present.

Guards and middleware

csrfGuard() and rateLimitGuard() take exemptWhen:

ts
@Middleware(csrfGuard({ exemptWhen: 'csrf.exempt' }))
@Middleware(rateLimitGuard({ max: 60, exemptWhen: 'auth.public' }))
@Controller()
export class ApiController {}

Both are ctx-style, so they run inside the matched route.

Middleware that runs before routing

rateLimit() is mounted app-wide and runs before a route is matched, so it has no ctx.route. It reads flags from a policy table instead: every mounted route registers its method, path and flags at boot, and the limiter looks up the incoming request.

ts
bootstrap({ middlewares: [rateLimit({ max: 60, exemptWhen: 'auth.public' })] })

A request matching no route matches no flags and stays limited — which is exactly why this one keeps running pre-match instead of becoming a guard.

Your own pre-match middleware can read the same table:

ts
import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'

export function auditUnflagged() {
  let policy: RoutePolicyTable | undefined
  const handler = (req, _res, next) => {
    const flags = policy?.lookup(req.method, req.url ?? '/')
    if (!flags?.has('audit.skip')) log(req.url)
    next()
  }
  return bindRoutePolicy(handler, (table) => {
    policy = table
  })
}

The Application hands the table to any middleware declaring that slot, once routes are mounted. It is per-application rather than a global, so two apps in one process never see each other's routes.

The connect-style csrf() has no table equivalent — a token check on an unmatched route is meaningless, so use csrfGuard() where you want flags.

Readers: OpenAPI and DevTools

Two consumers read flags without running per request.

OpenAPI. Name the flag your project uses for public endpoints and the spec reads the same declaration the runtime does, instead of a second annotation that can drift from it:

ts
SwaggerAdapter({ bearerAuth: true, publicFlag: 'auth.public' })
SwaggerAdapter({ bearerAuth: true, publicFlag: ['auth.public', 'health.probe'] })

The name is configuration because the framework names no flags — see Naming. For anything richer than a name (a flag's value, two flags combined), securityResolver plus getRouteFlags covers it:

ts
SwaggerAdapter({
  securityResolver: ({ controllerClass, handlerName }) =>
    getRouteFlags(controllerClass, handlerName).has('auth.public') ? null : undefined,
})

DevTools. GET /_debug/routes reports each route's resolved flags, and the dashboard's Routes tab shows them in a Flags column — so "why does this endpoint not require auth" is answerable from the route list rather than by reading the controller.

getRouteFlags(controllerClass, handlerName) is the out-of-request resolver behind both: the same method-over-class-over-mount result ctx.route.flags carries, for consumers that see a controller and a method name rather than a live request. Mount flags are recorded against the controller when it mounts, so a spec cannot report a route public that the runtime protects.

Matching: name, list, or predicate

Every skipWhen / onlyWhen / exemptWhen accepts the same three forms:

ts
'auth.public' // carries this flag
'!auth.public' // does NOT carry it
;['auth.public', 'health.probe'] // carries any of these
;['!auth.public', '!health.probe'] // carries none of these
;({ flags, route }) => flags.has('a') && flags.has('b') // anything else

A positive list is any-of — it reads as "these are all reasons to skip". A negated list is its complement, none-of, so flipping every entry inverts the meaning the way a reader expects.

A list is single-polarity

['auth.public', '!metered'] is a compile error. Under any-of it would mean "public present or metered absent", which almost everyone reads as "and" — so rather than pick a reading, the type forbids it and a predicate says it unambiguously. Mixing them at runtime (from untyped config) throws where the consumer is constructed, not on the first request that hits it.

Negation matters most on exemptWhen, which has no onlyWhen counterpart — skipWhen: '!x' is just onlyWhen: 'x' written differently.

All-of, value checks and path checks go through a predicate:

ts
exemptWhen: ({ flags }) => (flags.get('rate.limit') as { rpm: number } | undefined)?.rpm === 0
exemptWhen: ({ route }) => route?.path.startsWith('/internal') ?? false

Keep predicates cheap — they run per request, per consumer.

Type safety: declare your flags

Flag names are plain strings by default, which means a typo is a flag that silently never matches. Declare them once and every use narrows — the same ContextMeta mechanism context decorators use:

kick typegen writes this for you. It scans every defineRouteFlag('name') call and emits the registry to .kickjs/types/kick__route-flags.d.ts — so declaring a flag is the only step:

ts
// src/flags.ts — this is all you write
export const Public = defineRouteFlag('auth.public')
export const Limit = defineRouteFlag<{ rpm: number }>('rate.limit')
ts
// .kickjs/types/kick__route-flags.d.ts — generated, on every `kick dev` save
declare module '@forinda/kickjs' {
  interface KickRouteFlags {
    'auth.public': true
    'rate.limit': { rpm: number }
  }
}

A bare flag registers as true; one declared with an explicit value type registers that type. You can also hand-write the augmentation if you prefer — the generated file is a normal declaration merge — but there is rarely a reason to.

Three things switch on at once:

ts
defineRouteFlag('auth.pubic') // tsc: Did you mean '"auth.public"'?

const Limit = defineRouteFlag('rate.limit') // RouteFlag<{ rpm: number }> — no generic needed
ctx.route?.flags.get('rate.limit')?.rpm // typed, not `unknown`

rateLimitGuard({ exemptWhen: 'auth.pubic' }) // tsc error, in every consumer

skipWhen, onlyWhen and exemptWhen all take the narrowed name, so a misspelling fails at the call site rather than at 3am.

It stays optional

KickRouteFlags is empty until you augment it, and everything falls back to plain string while it is — a project that never declares a flag keeps compiling. The narrowing switches on with the first declaration, and you can adopt it one flag at a time.

The framework declares nothing in this registry. auth.public above is a name you chose — see Naming.

Two constraints on what a flag can be:

  • A name cannot start with !. That prefix means "does not carry this flag" in a test, so a flag literally named !x would be indistinguishable from the negation of x. defineRouteFlag rejects it.
  • Removal is @Flag.off, not @Flag(false). false is an ordinary value, so a flag declared boolean stores and reads it back normally — @Enabled(false) means the feature is off, and flags.get('feature.enabled') returns false. Only .off removes.

Where flags can be declared

Method, class, and module mount — resolved method > class > mount, the top three levels of the context contributor precedence chain. Adapter and global sites are planned.

The mount site is where a module flags routes on a controller it does not own, which no decorator can do:

ts
routes: () => ({ path: '/webhooks', controller: WebhooksController, flags: ['auth.public'] })

A list stores each flag as true. Use the record form for flags that carry a value:

ts
flags: { 'auth.public': true, 'rate.limit': { rpm: 10 } }

Both narrow against KickRouteFlags, and a name starting with ! is rejected at boot — a declaration has no negative form; remove the flag instead.

Since it is the lowest level, a class or method declaration of the same flag wins and @Flag.off on a method still removes it.

The built-in health probes

GET /health/live and GET /health/ready are an ordinary module inside the middleware chain, so app-wide auth applies to them. The controller is the framework's and the flag names are yours, so the flag goes on the mount:

ts
bootstrap({ health: { flags: ['auth.public'] } })

That is the whole wiring — every consumer already reading auth.public now exempts the probes, including the OpenAPI spec. Exempting /health/live and /health/ready by pathname does the same thing until the paths move.

Naming

The framework ships defineRouteFlag and names no flags. auth.public is a string you choose — nothing in the core branches on it, which is deliberate: a framework-blessed @Public would bake in an auth opinion, and that is what got the old auth package deprecated in favour of BYO recipes.

Pick a namespace per concern (auth.*, csrf.*, billing.*) so a reader can tell who consumes a flag.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.