<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged apidesign</title>
<link>https://coderlegion.com/tag/apidesign</link>
<description>Powered by Question2Answer</description>
<item>
<title>Stop Inventing Custom API Errors: A Guide to RFC 7807 Problem Details</title>
<link>https://coderlegion.com/32579/stop-inventing-custom-api-errors-a-guide-to-rfc-7807-problem-details</link>
<description>&lt;p&gt;RFC 7807 Problem Details is an official technical specification that defines a standard JSON format for describing errors returned by HTTP web APIs. Instead of having every software engineer create their own custom fields like &lt;code&gt;message&lt;/code&gt;, &lt;code&gt;err_code&lt;/code&gt;, or &lt;code&gt;reason&lt;/code&gt;, this standard establishes predictable key names such as &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;detail&lt;/code&gt;, and &lt;code&gt;instance&lt;/code&gt;. This gives both computer programs and human developers a uniform way to understand exactly what went wrong during an API request.&lt;/p&gt;
&lt;h3 id=&quot;the-everyday-analogy-car-diagnostic-reports&quot;&gt;The Everyday Analogy: Car Diagnostic Reports&lt;/h3&gt;
&lt;p&gt;Imagine taking your car to a mechanic when the check engine light comes on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; The mechanic hands you a piece of paper that simply says: &quot;Car is broken. Cost: $300.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt; The mechanic hands you a standardized vehicle diagnostic report that lists the exact sensor code (e.g., P0300), a short title (&quot;Random Cylinder Misfire&quot;), the severity level, specific details (&quot;Cylinder 3 failed 14 times in the past 10 miles&quot;), and a reference link to the manufacturer's repair guide.&lt;/p&gt;
&lt;p&gt;Option A leaves you guessing and forces you to ask ten follow-up questions. Option B gives you instant, structured clarity. RFC 7807 acts as that standardized diagnostic report for your web applications.&lt;/p&gt;
&lt;h3 id=&quot;why-rfc-7807-matters-for-engineers&quot;&gt;Why RFC 7807 Matters for Engineers&lt;/h3&gt;
&lt;p&gt;In modern software engineering, web applications are built by connecting multiple services together. Front-end user interfaces (like React apps) communicate with back-end servers running Express.js, which in turn communicate with databases or external payment systems.&lt;/p&gt;
&lt;p&gt;When something fails—such as an invalid credit card or an out-of-stock product—the back-end server must notify the front-end application. Without a standard like RFC 7807, team A might structure errors as &lt;code&gt;{ &quot;error&quot;: &quot;Invalid payload&quot; }&lt;/code&gt;, while team B uses &lt;code&gt;{ &quot;statusCode&quot;: 400, &quot;description&quot;: &quot;Bad input&quot; }&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This inconsistency causes real operational pain:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Fragile UI Code&lt;/strong&gt;: Front-end developers must write endless conditional &lt;code&gt;if/else&lt;/code&gt; checks to parse different error formats from different endpoints.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wasted Debugging Hours&lt;/strong&gt;: Software engineers spend extra time reading through server logs trying to figure out what a vague error message actually means.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Poor User Experience&lt;/strong&gt;: Applications fail silently or show generic &quot;An unknown error occurred&quot; popups because the front-end couldn't parse the unexpected error structure.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By enforcing RFC 7807, teams agree on a single contract. Front-end apps can use one central error-handling mechanism to extract the exact field that failed and display a meaningful message to the user.&lt;/p&gt;
&lt;h3 id=&quot;code-example-express-js-error-handler&quot;&gt;Code Example: Express.js Error Handler&lt;/h3&gt;
&lt;p&gt;Here is how you can implement an RFC 7807 compliant error handler in a Node.js and Express server. The custom HTTP content type for this standard is &lt;code&gt;application/problem+json&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const express = require('express');
const app = express();

app.use(express.json());

// Route that triggers a validation error
app.post('/api/users', (req, res, next) =&amp;gt; {
  const { email } = req.body;
  if (!email) {
    const error = new Error('Email field is required.');
    error.status = 400;
    error.type = 'https://api.example.com/errors/missing-field';
    error.title = 'Invalid Request Payload';
    error.instance = '/api/users';
    return next(error);
  }
  res.status(201).json({ message: 'User created' });
});

// Central RFC 7807 Error Handling Middleware
app.use((err, req, res, next) =&amp;gt; {
  const status = err.status || 500;
  
  const problemDetails = {
    type: err.type || 'https://api.example.com/errors/internal-error',
    title: err.title || 'Internal Server Error',
    status: status,
    detail: err.message || 'An unexpected error occurred on the server.',
    instance: err.instance || req.originalUrl
  };

  // Set the standard RFC 7807 header
  res.setHeader('Content-Type', 'application/problem+json');
  res.status(status).json(problemDetails);
});

app.listen(3000, () =&amp;gt; console.log('Server running on port 3000'));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;key-takeaway&quot;&gt;Key Takeaway&lt;/h3&gt;
&lt;p&gt;RFC 7807 transforms unpredictable API errors into predictable, structured data that both machines and humans can process seamlessly. Adopting this standard eliminates entire classes of error-handling bugs, simplifies frontend integration, and establishes clear debugging contracts across your entire software ecosystem.&lt;/p&gt;
&lt;hr&gt;
&lt;p align=&quot;center&quot;&gt;
  &lt;a href=&quot;https://www.buymeacoffee.com/saurav_tb_pandey&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer nofollow&quot;&gt;
    &lt;img src=&quot;https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=for-the-badge&amp;amp;logo=buy-me-a-coffee&amp;amp;logoColor=black&quot; alt=&quot;Buy Me A Coffee&quot;&gt;
&lt;p&gt;  &lt;/p&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Enjoyed this breakdown? Support my open-source work on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.buymeacoffee.com/saurav_tb_pandey&quot;&gt;Buy Me a Coffee&lt;/a&gt; or explore &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/react-hook-lab&quot;&gt;react-hook-lab&lt;/a&gt;!&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/10/how-rfc-7807-problem-details-transforms.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32579/stop-inventing-custom-api-errors-a-guide-to-rfc-7807-problem-details</guid>
<pubDate>Mon, 26 Oct 2026 17:41:23 +0000</pubDate>
</item>
<item>
<title>Route flags, context decorators and typed assets in KickJS: declare it once, use it everywhere</title>
<link>https://coderlegion.com/28152/route-flags-context-decorators-and-typed-assets-in-kickjs-declare-it-once-use-everywhere</link>
<description>&lt;p&gt;Every API accumulates the same kind of glue. &quot;This endpoint is public&quot; gets written three times — once for auth, once for CSRF, once for rate limiting — and two of those are path strings that break the day someone renames a route. &quot;Who is the current user&quot; becomes &lt;code&gt;req.user as any&lt;/code&gt;. &quot;Where is the welcome email template&quot; becomes &lt;code&gt;__dirname&lt;/code&gt; arithmetic that works in dev and fails in the Docker image.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;KickJS&lt;/a&gt; has three features that remove that glue, and they are far more useful &lt;strong&gt;together&lt;/strong&gt; than apart:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;What it answers &lt;/th&gt;&lt;th&gt;Mental model&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Route flags&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;What is true about this route?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A fact, written once on the route, read by anything. No behaviour.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Context decorators&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;What do we know about this request?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A typed value computed per request, before the handler runs.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Asset manager&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;em&gt;Where is that file?&lt;/em&gt; &lt;/td&gt;&lt;td&gt;A typed path to a file shipped with your code, correct in dev and in the build.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This guide explains each one, then builds a small feature set that uses all three at once: public and private routes, a signed-in user, plan-gated endpoints, per-route rate limits, and locale-aware emails and reports rendered from typed templates.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Versions: &lt;code&gt;@forinda/kickjs&lt;/code&gt; 8.4+, &lt;code&gt;@forinda/kickjs-cli&lt;/code&gt; 8.2+, &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; 8.0+. All three features are in the core package — nothing extra to install.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-route-flags-facts-about-a-route&quot;&gt;1. Route flags: facts about a route&lt;/h3&gt;
&lt;p&gt;A route flag is a &lt;strong&gt;named, inheritable fact&lt;/strong&gt; about a route. It does nothing by itself; it records something any consumer can read.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/flags.ts
import { defineRouteFlag } from '@forinda/kickjs'

export const Public = defineRouteFlag('auth.public')
export const RateLimit = defineRouteFlag&amp;lt;{ rpm: number }&amp;gt;('rate.limit')
export const Feature = defineRouteFlag&amp;lt;string&amp;gt;('billing.feature')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put flags on a controller, a method, or a module mount. The most specific declaration wins — &lt;strong&gt;method &amp;gt; class &amp;gt; mount&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public // every route in this controller is public…
@Controller()
export class DocsController {
  @Get('/')
  index(ctx: RequestContext) {} // public

  @Public.off // …except this one
  @Get('/drafts')
  drafts(ctx: RequestContext) {} // not public
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read them anywhere you hold a &lt;code&gt;RequestContext&lt;/code&gt; — handlers, &lt;code&gt;@Middleware()&lt;/code&gt;, guards, contributors:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ctx.route?.flags.has('auth.public') // true / false
ctx.route?.flags.get('rate.limit') // { rpm: 10 } or undefined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two rules make flags predictable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Removal is &lt;code&gt;.off&lt;/code&gt;, never a falsy value.&lt;/strong&gt; A flag is either present or absent. &lt;code&gt;@Public.off&lt;/code&gt; removes it; there is no &quot;present but false&quot; state that a presence check could misread as &quot;public&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;false&lt;/code&gt; is a real value.&lt;/strong&gt; A flag declared with a boolean type can store &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;flags.get()&lt;/code&gt; returns it. Only &lt;code&gt;.off&lt;/code&gt; removes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;ctx.route&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; in global middleware, which runs before a route is matched (section 8 shows how pre-routing middleware reads flags anyway).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-context-decorators-typed-values-about-a-request&quot;&gt;2. Context decorators: typed values about a request&lt;/h3&gt;
&lt;p&gt;A context decorator (also called a &lt;em&gt;contributor&lt;/em&gt;) computes a value from the request and stores it on the context &lt;strong&gt;before the handler runs&lt;/strong&gt;. It replaces middleware whose only job was &quot;compute X and stash it on the request&quot;.&lt;/p&gt;
&lt;p&gt;Scaffold one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kick g contributor locale --dry-run
kick g contributor locale -o src/contributors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fill it in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/locale.contributor.ts
import { defineHttpContextDecorator } from '@forinda/kickjs'

// 1. Declare the key's type once, for the whole app.
declare module '@forinda/kickjs' {
  interface ContextMeta {
    locale: { language: string; region: string | null }
  }
}

// 2. Define how it's computed.
export const ResolveLocale = defineHttpContextDecorator({
  key: 'locale',
  resolve: (ctx) =&amp;gt; {
    const header = (ctx.req.headers['accept-language'] as string | undefined) ?? 'en'
    const [language, region] = header.split(',')[0].trim().split('-')
    return { language: language.toLowerCase(), region: region ?? null }
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@ResolveLocale
@Get('/home')
home(ctx: RequestContext) {
  const locale = ctx.require('locale') // { language: string; region: string | null }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What you get over hand-written middleware:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Concern &lt;/th&gt;&lt;th&gt;Middleware &lt;/th&gt;&lt;th&gt;Context decorator&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Type of the value &lt;/td&gt;&lt;td&gt;&lt;code&gt;(req as any).locale&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ctx.require('locale')&lt;/code&gt;, typed from &lt;code&gt;ContextMeta&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ordering &lt;/td&gt;&lt;td&gt;Array position in &lt;code&gt;bootstrap()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;dependsOn: ['user']&lt;/code&gt;, sorted at boot; cycles and missing producers &lt;strong&gt;fail startup&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Services &lt;/td&gt;&lt;td&gt;&lt;code&gt;Container.getInstance()&lt;/code&gt; inside the body &lt;/td&gt;&lt;td&gt;&lt;code&gt;deps: { repo: REPO_TOKEN }&lt;/code&gt;, resolved and typed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Per-route opt-out &lt;/td&gt;&lt;td&gt;Fork the stack or check paths &lt;/td&gt;&lt;td&gt;&lt;code&gt;skipWhen&lt;/code&gt; / &lt;code&gt;onlyWhen&lt;/code&gt; with route flags&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Errors &lt;/td&gt;&lt;td&gt;Throw → 500 &lt;/td&gt;&lt;td&gt;&lt;code&gt;optional: true&lt;/code&gt;, &lt;code&gt;onError&lt;/code&gt; fallback, or &lt;code&gt;HttpException&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Testing &lt;/td&gt;&lt;td&gt;Whole HTTP stack &lt;/td&gt;&lt;td&gt;&lt;code&gt;runContributor()&lt;/code&gt; against a fake context&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Registration sites, highest precedence first: &lt;strong&gt;method &amp;gt; class &amp;gt; module &lt;code&gt;contributors()&lt;/code&gt; &amp;gt; adapter/plugin &lt;code&gt;contributors()&lt;/code&gt; &amp;gt; &lt;code&gt;bootstrap({ contributors })&lt;/code&gt;&lt;/strong&gt;. A narrower registration of the same key silently replaces a broader one.&lt;/p&gt;
&lt;p&gt;Reading values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ctx.require('key')&lt;/code&gt; — for values the route &lt;strong&gt;must&lt;/strong&gt; have. Throws &lt;code&gt;MissingContextValueError&lt;/code&gt; naming the key and route if the contributor didn't run. Never write &lt;code&gt;ctx.get('key')!&lt;/code&gt;: it compiles even after someone deletes the decorator.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ctx.get('key')&lt;/code&gt; — for optional extras. Returns &lt;code&gt;T | undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getRequestValue('key')&lt;/code&gt; — from a &lt;strong&gt;service&lt;/strong&gt; with no &lt;code&gt;ctx&lt;/code&gt; in scope. Returns &lt;code&gt;undefined&lt;/code&gt; outside a request.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;Always &lt;strong&gt;return&lt;/strong&gt; the value from &lt;code&gt;resolve()&lt;/code&gt;. Assigning &lt;code&gt;ctx.locale = …&lt;/code&gt; writes to one internal context object that the handler never sees.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-asset-manager-typed-paths-to-your-files&quot;&gt;3. The asset manager: typed paths to your files&lt;/h3&gt;
&lt;p&gt;Templates, report layouts, JSON schemas and fixtures live next to your code in development and get copied somewhere else in the build. Without help, every call site does this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const path =
  process.env.NODE_ENV === 'production'
    ? join(__dirname, '../templates/mails/welcome.ejs')
    : join(__dirname, '../../src/templates/mails/welcome.ejs')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typos compile, every call site branches on the environment, and &lt;code&gt;__dirname&lt;/code&gt; breaks under ESM and bundling.&lt;/p&gt;
&lt;h4 id=&quot;configure&quot;&gt;Configure&lt;/h4&gt;
&lt;p&gt;Tell the CLI which directories hold assets, in &lt;code&gt;kick.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { defineConfig } from '@forinda/kickjs-cli'

export default defineConfig({
  assetMap: {
    mails: { src: 'src/templates/mails', glob: '**/*.ejs' },
    reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },
    schemas: { src: 'src/schemas', glob: '**/*.json' },
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each entry takes &lt;code&gt;src&lt;/code&gt; (required), &lt;code&gt;dest&lt;/code&gt; (default &lt;code&gt;dist/&amp;lt;name&amp;gt;/&lt;/code&gt;), &lt;code&gt;glob&lt;/code&gt; (default &lt;code&gt;**/*&lt;/code&gt;) and &lt;code&gt;keys&lt;/code&gt; (&lt;code&gt;'auto'&lt;/code&gt;, &lt;code&gt;'strip'&lt;/code&gt; or &lt;code&gt;'with-extension'&lt;/code&gt; — how file names become property names).&lt;/p&gt;
&lt;h4 id=&quot;generate-types&quot;&gt;Generate types&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kick typegen   # kick dev runs this automatically on every change
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a file tree like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/templates/mails/en/welcome.ejs
src/templates/mails/fr/welcome.ejs
src/templates/reports/monthly.ejs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;you get a typed surface:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { assets } from '@forinda/kickjs'

assets.mails.en.welcome() // absolute path to the right file, dev or build
assets.reports.monthly()
assets.mails.en.welcom() // compile error
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;four-ways-to-read-an-asset&quot;&gt;Four ways to read an asset&lt;/h4&gt;
&lt;p&gt;All four go through the same cached resolver:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { assets, useAssets, Asset, resolveAsset } from '@forinda/kickjs'

assets.reports.monthly() // 1. ambient proxy — the default

class ReportService {
  private assets = useAssets() // 2. as a field — easy to mock in tests
}

@Service()
class InvoiceService {
  @Asset('reports/invoice') // 3. declarative field, resolved on each access
  private invoiceTemplate!: string
}

resolveAsset('mails', 'fr/welcome') // 4. dynamic — names known only at runtime; throws UnknownAssetError on a miss
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;in-the-build&quot;&gt;In the build&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;kick build&lt;/code&gt; copies every &lt;code&gt;assetMap&lt;/code&gt; entry into the output directory, writes a manifest (&lt;code&gt;.kickjs-assets.json&lt;/code&gt;) and runs typegen. At runtime the resolver uses that manifest when it exists, and walks your &lt;code&gt;src&lt;/code&gt; directories when it doesn't — so the same &lt;code&gt;assets.mails.en.welcome()&lt;/code&gt; call works in &lt;code&gt;kick dev&lt;/code&gt; and in production. After adding a template in dev, &lt;code&gt;kick build:assets&lt;/code&gt; refreshes the manifest without a full rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-how-they-fit-together&quot;&gt;4. How they fit together&lt;/h3&gt;
&lt;p&gt;The three features form a natural pipeline for any request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;route matched
   │
   ├─ route flags resolved (method &amp;gt; class &amp;gt; mount)          &quot;what is true about this route&quot;
   │
   ├─ contributors run, in dependsOn order                   &quot;what we know about this request&quot;
   │     • skipWhen / onlyWhen read the flags
   │     • resolve() can read ctx.route.flags values
   │     • guards read flags via exemptWhen
   │
   └─ handler / services
         • ctx.require(...) / getRequestValue(...)
         • assets.* picks the file, often chosen by a contributor value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The division of labour that keeps codebases clean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flags describe&lt;/strong&gt; — &quot;public&quot;, &quot;needs the reports feature&quot;, &quot;limit 10 rpm&quot;. Declarative, inheritable, visible in the route list.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contributors decide&lt;/strong&gt; — load the user, load the plan, check the feature, compute the locale. Typed, ordered, testable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Assets deliver&lt;/strong&gt; — the template or schema the decision points at, with a path that is always right.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recipes below build that pipeline piece by piece.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-recipe-one-public-flag-every-consumer&quot;&gt;5. Recipe: one &quot;public&quot; flag, every consumer&lt;/h3&gt;
&lt;p&gt;Declare the fact once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/flags.ts
export const Public = defineRouteFlag('auth.public')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put it on routes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Public
@Controller()
export class AuthController {
  @Post('/login') login(ctx: RequestContext) {}
  @Post('/register') register(ctx: RequestContext) {}

  @Public.off // the only private route here
  @Get('/me')
  me(ctx: RequestContext) {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;strong&gt;every&lt;/strong&gt; consumer reads that one declaration instead of its own path list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Auth: the user contributor steps aside on public routes (section 6).
skipWhen: 'auth.public'

// CSRF and rate limiting, as route guards:
@Middleware(csrfGuard({ exemptWhen: 'auth.public' }))
@Middleware(rateLimitGuard({ max: 60, exemptWhen: 'auth.public' }))

// OpenAPI: public routes show no lock icon.
SwaggerAdapter({ bearerAuth: true, publicFlag: 'auth.public' })

// The built-in health probes, which you don't own:
bootstrap({ health: { flags: ['auth.public'] } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rename a route, change the API prefix, add &lt;code&gt;/users/:id&lt;/code&gt; — nothing breaks, because nothing matches on path strings. And &lt;strong&gt;DevTools&lt;/strong&gt; shows each route's resolved flags in its Routes tab, so &quot;why is this endpoint open?&quot; is answered from the route list.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The framework names no flags. &lt;code&gt;auth.public&lt;/code&gt; is a name you chose — pick a namespace per concern (&lt;code&gt;auth.*&lt;/code&gt;, &lt;code&gt;billing.*&lt;/code&gt;, &lt;code&gt;rate.*&lt;/code&gt;) so readers can tell who consumes a flag.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-recipe-the-signed-in-user-skipped-on-public-routes&quot;&gt;6. Recipe: the signed-in user, skipped on public routes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/load-user.contributor.ts
import { defineHttpContextDecorator, HttpException } from '@forinda/kickjs'
import { SESSIONS } from '@/auth/sessions'

declare module '@forinda/kickjs' {
  interface ContextMeta {
    user: { id: string; accountId: string; roles: string[] }
  }
}

export const LoadUser = defineHttpContextDecorator({
  key: 'user',
  skipWhen: 'auth.public', // the flag from section 5
  beforeValidation: true, // answer 401 before 422
  deps: { sessions: SESSIONS },
  resolve: async (ctx, { sessions }) =&amp;gt; {
    const user = await sessions.fromToken(ctx.headers.authorization)
    if (!user) throw HttpException.unauthorized()
    return user
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register it &lt;strong&gt;once&lt;/strong&gt;, app-wide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ modules, contributors: [LoadUser.registration] })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every route now requires a user, except routes flagged public. Three details matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;skipWhen&lt;/code&gt; makes exemption work across ownership.&lt;/strong&gt; Without flags, the only way to opt a route out of a contributor is to register a permissive twin under the same key — which you can't do for a contributor a plugin shipped. A flag lives on the route, so you can exempt anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;beforeValidation: true&lt;/code&gt;&lt;/strong&gt; moves the contributor ahead of body validation. Otherwise an anonymous request with a bad body gets a &lt;code&gt;422&lt;/code&gt; listing your schema. The order becomes &lt;code&gt;beforeValidation contributors → validation → @Middleware() → other contributors → handler&lt;/code&gt;. Read headers and cookies here, never the body — it isn't validated yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A skipped contributor sets no key.&lt;/strong&gt; On routes that can be public, read &lt;code&gt;ctx.get('user')&lt;/code&gt;; use &lt;code&gt;ctx.require('user')&lt;/code&gt; only where the route is guaranteed private.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Services that need the user don't take it as a parameter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Service()
export class AuditService {
  record(action: string) {
    const user = getRequestValue('user') // typed, undefined on public routes
    this.log.insert({ action, userId: user?.id ?? null })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-recipe-plan-gated-routes-with-a-flag-value-and-dependson&quot;&gt;7. Recipe: plan-gated routes with a flag value and dependsOn&lt;/h3&gt;
&lt;p&gt;Goal: some routes require a paid feature. The route &lt;strong&gt;states&lt;/strong&gt; which feature; contributors &lt;strong&gt;enforce&lt;/strong&gt; it.&lt;/p&gt;
&lt;p&gt;The flag carries a value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const Feature = defineRouteFlag&amp;lt;string&amp;gt;('billing.feature')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Load the account's plan after the user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/load-plan.contributor.ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    plan: { name: 'free' | 'pro' | 'enterprise'; features: string[] }
  }
}

export const LoadPlan = defineHttpContextDecorator({
  key: 'plan',
  dependsOn: ['user'], // runs after LoadUser; boot fails if nothing produces 'user'
  skipWhen: 'auth.public',
  deps: { billing: BILLING },
  resolve: async (ctx, { billing }) =&amp;gt; billing.planFor(ctx.require('user').accountId),
  onError: () =&amp;gt; ({ name: 'free', features: [] }), // billing down → treat as free, don't 500
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enforce the feature &lt;strong&gt;only where a route asks for one&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/contributors/require-feature.contributor.ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    feature: string
  }
}

export const RequireFeature = defineHttpContextDecorator({
  key: 'feature',
  onlyWhen: 'billing.feature', // routes without the flag skip this entirely
  dependsOn: ['plan'],
  resolve: (ctx) =&amp;gt; {
    const needed = ctx.route?.flags.get('billing.feature') // the flag's value, e.g. 'reports'
    const plan = ctx.require('plan')
    if (!needed || !plan.features.includes(needed)) {
      ctx.problem.forbidden({
        type: 'https://example.com/probs/upgrade-required',
        detail: `This needs the ${needed} feature, which is not on the ${plan.name} plan`,
      })
    }
    return needed
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the chain app-wide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({
  modules,
  contributors: [LoadUser.registration, LoadPlan.registration, RequireFeature.registration],
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now gating a route is one line, and it reads like documentation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Controller()
export class ReportsController {
  @Feature('reports')
  @Get('/monthly')
  monthly(ctx: RequestContext) {} // pro and above

  @Get('/summary')
  summary(ctx: RequestContext) {} // everyone signed in
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this shape works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Order is declared, not arranged.&lt;/strong&gt; &lt;code&gt;dependsOn&lt;/code&gt; gives &lt;code&gt;user → plan → feature&lt;/code&gt;. Forget &lt;code&gt;LoadUser&lt;/code&gt; and the app refuses to start with &lt;code&gt;MissingContributorError&lt;/code&gt; instead of failing on the first request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost is paid only where needed.&lt;/strong&gt; &lt;code&gt;onlyWhen&lt;/code&gt; means routes without the flag never evaluate the check; a skipped contributor costs a map lookup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Errors keep their shape.&lt;/strong&gt; &lt;code&gt;ctx.problem.forbidden(...)&lt;/code&gt; answers RFC 9457 &lt;code&gt;application/problem+json&lt;/code&gt; from inside a contributor, so clients can branch on &lt;code&gt;type&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fallback is explicit.&lt;/strong&gt; &lt;code&gt;onError&lt;/code&gt; returns a value; the runner stores it under the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-recipe-per-route-rate-limits-from-one-declaration&quot;&gt;8. Recipe: per-route rate limits from one declaration&lt;/h3&gt;
&lt;p&gt;A flag with a value, read by several consumers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const RateLimit = defineRouteFlag&amp;lt;{ rpm: number }&amp;gt;('rate.limit')

@RateLimit({ rpm: 10 })
@Post('/login')
login(ctx: RequestContext) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Inside the route&lt;/strong&gt; — a guard can exempt unmetered routes with a predicate that reads the value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Middleware(
  rateLimitGuard({
    max: 60,
    exemptWhen: ({ flags }) =&amp;gt; flags.get('rate.limit')?.rpm === 0,
  }),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;A contributor&lt;/strong&gt; can derive the limiter key and window from the same flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextMeta {
    rateLimit: { key: string; rpm: number }
  }
}

export const RateLimitKey = defineHttpContextDecorator({
  key: 'rateLimit',
  onlyWhen: 'rate.limit',
  resolve: (ctx) =&amp;gt; ({
    key: `${ctx.get('user')?.id ?? ctx.req.ip}:${ctx.route?.path}`,
    rpm: ctx.route?.flags.get('rate.limit')?.rpm ?? 60,
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Before routing&lt;/strong&gt; — app-wide middleware has no &lt;code&gt;ctx.route&lt;/code&gt;, so it reads a policy table the app builds at boot from every mounted route's method, path and flags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ middlewares: [rateLimit({ max: 60, exemptWhen: 'auth.public' })] })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your own pre-routing middleware can read the same table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { bindRoutePolicy, type RoutePolicyTable } from '@forinda/kickjs'

export function accessLog() {
  let policy: RoutePolicyTable | undefined
  const handler = (req, _res, next) =&amp;gt; {
    const flags = policy?.lookup(req.method, req.url ?? '/')
    if (!flags?.has('audit.skip')) log.info({ method: req.method, url: req.url })
    next()
  }
  return bindRoutePolicy(handler, (table) =&amp;gt; {
    policy = table
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A request that matches no route matches no flags, and stays limited and logged — which is exactly what you want for scanners probing random paths.&lt;/p&gt;
&lt;h4 id=&quot;the-three-shapes-a-flag-test-can-take&quot;&gt;The three shapes a flag test can take&lt;/h4&gt;
&lt;p&gt;Every &lt;code&gt;skipWhen&lt;/code&gt;, &lt;code&gt;onlyWhen&lt;/code&gt; and &lt;code&gt;exemptWhen&lt;/code&gt; accepts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;'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 }) =&amp;gt; flags.has('a') &amp;amp;&amp;amp; flags.has('b') // anything else
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A list is single-polarity: &lt;code&gt;['auth.public', '!metered']&lt;/code&gt; is a compile error, because &quot;any-of&quot; would read it as &quot;public &lt;strong&gt;or&lt;/strong&gt; not metered&quot; while most people read &quot;and&quot;. Use a predicate for mixed conditions, and keep predicates cheap — they run per request.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-recipe-flag-or-parameter-choosing-the-right-tool&quot;&gt;9. Recipe: flag or parameter? Choosing the right tool&lt;/h3&gt;
&lt;p&gt;Contributors can also take &lt;strong&gt;per-call parameters&lt;/strong&gt;, which overlaps with flags. The rule of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use a &lt;strong&gt;flag&lt;/strong&gt; when the fact is read by &lt;strong&gt;several consumers&lt;/strong&gt;, or by tooling (OpenAPI, DevTools), or must be set on routes you don't own.&lt;/li&gt;
&lt;li&gt;Use a &lt;strong&gt;parameter&lt;/strong&gt; when it only configures &lt;strong&gt;one contributor's behaviour&lt;/strong&gt; at that call site.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A permission check is a good parameter — only one contributor cares, and the action must be named at every use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;declare module '@forinda/kickjs' {
  interface ContextMeta {
    permission: string
  }
}

export const RequirePermission = defineHttpContextDecorator.withParams&amp;lt;{ action: string }&amp;gt;()({
  key: 'permission',
  dependsOn: ['user'],
  // no paramDefaults.action — every route must name its own action
  resolve: (ctx, _deps, { action }) =&amp;gt; {
    if (!ctx.require('user').roles.includes(action)) throw HttpException.forbidden()
    return action
  },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@RequirePermission({ action: 'reports:export' }) // ✓
@Get('/export')
export(ctx: RequestContext) {}

@RequirePermission // ✗ compile error: `action` is required
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Required parameters have no default on purpose: a placeholder default that one route forgets to override silently gates on the placeholder. For module, adapter or bootstrap registration, use &lt;code&gt;RequirePermission.with({ action: '…' }).registration&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Compare with &lt;code&gt;billing.feature&lt;/code&gt; from section 7: that one is a &lt;strong&gt;flag&lt;/strong&gt; because the billing contributor, the OpenAPI spec (to document &quot;requires Pro&quot;), and DevTools all benefit from reading it.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Need &lt;/th&gt;&lt;th&gt;Reach for&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;This route is public / metered / exempt&quot; &lt;/td&gt;&lt;td&gt;Flag&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Requires feature X&quot; (and docs should say so) &lt;/td&gt;&lt;td&gt;Flag with a value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Check permission &lt;code&gt;reports:export&lt;/code&gt;&quot; &lt;/td&gt;&lt;td&gt;Parameterised contributor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Load the tenant from a header here, a subdomain there&quot; &lt;/td&gt;&lt;td&gt;Parameterised contributor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mark routes on a controller from another package &lt;/td&gt;&lt;td&gt;Flag on the module mount&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;10-recipe-locale-aware-emails-from-typed-templates&quot;&gt;10. Recipe: locale-aware emails from typed templates&lt;/h3&gt;
&lt;p&gt;Now bring in assets. Keep templates per language:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;src/templates/mails/en/welcome.ejs
src/templates/mails/en/password-reset.ejs
src/templates/mails/fr/welcome.ejs
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// kick.config.ts
assetMap: {
  mails: { src: 'src/templates/mails', glob: '**/*.ejs' },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After &lt;code&gt;kick typegen&lt;/code&gt;, &lt;code&gt;assets.mails.en.welcome()&lt;/code&gt; and &lt;code&gt;assets.mails['en']['password-reset']()&lt;/code&gt; are typed.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;locale contributor&lt;/strong&gt; (section 2) decides the language per request. The &lt;strong&gt;mail service&lt;/strong&gt; picks the matching template, falling back to English when a translation is missing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/mail/mail.service.ts
import { Service, assets, getRequestValue, resolveAsset, UnknownAssetError } from '@forinda/kickjs'
import ejs from 'ejs'

type MailName = 'welcome' | 'password-reset'

@Service()
export class MailService {
  async render(name: MailName, data: Record&amp;lt;string, unknown&amp;gt;) {
    const language = getRequestValue('locale')?.language ?? 'en'
    return ejs.renderFile(this.templateFor(name, language), data)
  }

  private templateFor(name: MailName, language: string): string {
    try {
      return resolveAsset('mails', `${language}/${name}`) // dynamic: language is runtime data
    } catch (error) {
      if (error instanceof UnknownAssetError) return assets.mails.en[name]() // typed fallback
      throw error
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look at who does what:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The contributor&lt;/strong&gt; turns a header into a typed &lt;code&gt;locale&lt;/code&gt; once per request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The service&lt;/strong&gt; reads it with &lt;code&gt;getRequestValue&lt;/code&gt; — no &lt;code&gt;ctx&lt;/code&gt; threaded through three layers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The asset manager&lt;/strong&gt; turns &lt;code&gt;fr/welcome&lt;/code&gt; into a real path in dev and in the build, and the typed &lt;code&gt;assets.mails.en[name]()&lt;/code&gt; fallback means English templates are checked at compile time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outside a request (a queue worker, a cron job) &lt;code&gt;getRequestValue&lt;/code&gt; returns &lt;code&gt;undefined&lt;/code&gt;, and the service falls back to English instead of throwing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;11-recipe-reports-rendered-per-route&quot;&gt;11. Recipe: reports rendered per route&lt;/h3&gt;
&lt;p&gt;Report templates are a good fit for the &lt;code&gt;@Asset&lt;/code&gt; field decorator — fixed files, injected into a service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// kick.config.ts
assetMap: {
  reports: { src: 'src/templates/reports', glob: '**/*.ejs' },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Service()
export class ReportService {
  @Asset('reports/monthly')
  private monthlyTemplate!: string

  @Asset('reports/invoice')
  private invoiceTemplate!: string

  monthly(data: MonthlyReport) {
    return ejs.renderFile(this.monthlyTemplate, data)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combined with section 7, the controller carries all the policy as declarations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@Controller()
export class ReportsController {
  @Autowired() private readonly reports!: ReportService

  @Feature('reports') // flag: needs the reports feature
  @RateLimit({ rpm: 5 }) // flag: expensive, limit it
  @ResolveLocale // contributor: formats numbers and dates for the reader
  @Get('/monthly')
  async monthly(ctx: RequestContext) {
    const html = await this.reports.monthly({
      user: ctx.require('user'),
      plan: ctx.require('plan'),
      locale: ctx.require('locale'),
    })
    return ctx.html(html)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reading the decorators top to bottom tells you everything about the endpoint without opening a single middleware file: who can call it, how often, and what context it renders with.&lt;/p&gt;
&lt;p&gt;JSON schemas and fixtures work the same way — &lt;code&gt;assets.schemas.webhook()&lt;/code&gt; gives a validator the right file in every environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;12-recipe-flagging-controllers-you-don-t-own&quot;&gt;12. Recipe: flagging controllers you don't own&lt;/h3&gt;
&lt;p&gt;Decorators only work on code you can edit. A module can flag routes on &lt;strong&gt;any&lt;/strong&gt; controller it mounts — including one from a package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/modules/webhooks/webhooks.module.ts
import { defineModule } from '@forinda/kickjs'
import { StripeWebhookController } from 'some-payments-package'

export const WebhooksModule = defineModule({
  name: 'WebhooksModule',
  build: () =&amp;gt; ({
    routes: () =&amp;gt; ({
      path: '/webhooks',
      controller: StripeWebhookController,
      flags: { 'auth.public': true, 'rate.limit': { rpm: 600 } },
    }),
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mount flags are the lowest precedence, so a class or method declaration of the same flag still wins. A list form (&lt;code&gt;flags: ['auth.public']&lt;/code&gt;) stores each flag as &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The same idea covers the framework's own health probes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bootstrap({ health: { flags: ['auth.public'] } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every consumer already reading &lt;code&gt;auth.public&lt;/code&gt; — &lt;code&gt;LoadUser&lt;/code&gt;, the guards, the OpenAPI spec — now exempts &lt;code&gt;/health/live&lt;/code&gt; and &lt;code&gt;/health/ready&lt;/code&gt;, with no path strings.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;13-type-safety-what-the-compiler-catches&quot;&gt;13. Type safety: what the compiler catches&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;kick typegen&lt;/code&gt; (automatic in &lt;code&gt;kick dev&lt;/code&gt;) generates three registries into &lt;code&gt;.kickjs/types/&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;You write &lt;/th&gt;&lt;th&gt;Typegen generates &lt;/th&gt;&lt;th&gt;The compiler then catches&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;defineRouteFlag('rate.limit')&lt;/code&gt; with a value type &lt;/td&gt;&lt;td&gt;&lt;code&gt;KickRouteFlags&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;skipWhen: 'auth.pubic'&lt;/code&gt; → &lt;em&gt;Did you mean 'auth.public'?&lt;/em&gt;; &lt;code&gt;flags.get('rate.limit')?.rpm&lt;/code&gt; typed, not &lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;declare module … ContextMeta&lt;/code&gt; &lt;/td&gt;&lt;td&gt;— (you write it) &lt;/td&gt;&lt;td&gt;&lt;code&gt;ctx.require('locale')&lt;/code&gt; typed; &lt;code&gt;dependsOn: ['usr']&lt;/code&gt; is an error&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;assetMap&lt;/code&gt; in &lt;code&gt;kick.config.ts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;KickAssets&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets.mails.en.welcom()&lt;/code&gt; is an error&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And one more that is easy to miss: &lt;strong&gt;a deleted decorator becomes a compile error.&lt;/strong&gt; When a handler is typed with a generated route type, typegen narrows &lt;code&gt;ctx.require()&lt;/code&gt; to the keys that route provably has:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;@LoadTenant
@RequirePermission({ action: 'audit:read' }) // ← delete this line…
@Get('/audit')
audit(ctx: Ctx&amp;lt;KickRoutes.AuditController['audit']&amp;gt;) {
  return { perm: ctx.require('permission') } // …and this no longer compiles
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typegen narrows only when it can prove the whole contributor set. Adapter or plugin &lt;code&gt;contributors()&lt;/code&gt; can't be read statically, so projects using them skip the narrowing — &lt;code&gt;ctx.require()&lt;/code&gt; still throws at runtime, which is why integration tests stay worth having.&lt;/p&gt;
&lt;p&gt;All three registries are opt-in: until you declare your first flag, &lt;code&gt;ContextMeta&lt;/code&gt; key or &lt;code&gt;assetMap&lt;/code&gt; entry, everything accepts plain strings and keeps compiling.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;14-testing-all-three&quot;&gt;14. Testing all three&lt;/h3&gt;
&lt;h4 id=&quot;contributors-in-isolation&quot;&gt;Contributors, in isolation&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;runContributor&lt;/code&gt; from &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; calls a contributor's &lt;code&gt;resolve()&lt;/code&gt; against a fake context — no container, no HTTP. Hand it mocked &lt;code&gt;deps&lt;/code&gt; and pre-seed upstream keys with &lt;code&gt;initial&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { describe, expect, it, vi } from 'vitest'
import { runContributor } from '@forinda/kickjs-testing'
import { LoadPlan } from './load-plan.contributor'

describe('LoadPlan', () =&amp;gt; {
  it('loads the plan for the signed-in account', async () =&amp;gt; {
    const billing = { planFor: vi.fn(async () =&amp;gt; ({ name: 'pro', features: ['reports'] })) }

    const { value } = await runContributor(LoadPlan, {
      deps: { billing },
      initial: { user: { id: 'u1', accountId: 'acc-9', roles: [] } }, // what LoadUser would have set
    })

    expect(billing.planFor).toHaveBeenCalledWith('acc-9')
    expect(value).toEqual({ name: 'pro', features: ['reports'] })
  })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;runContributor&lt;/code&gt; calls &lt;code&gt;resolve()&lt;/code&gt; directly, so &lt;code&gt;optional&lt;/code&gt; and &lt;code&gt;onError&lt;/code&gt; paths need the pipeline: build one with &lt;code&gt;buildPipeline([...])&lt;/code&gt; and run it with &lt;code&gt;runContributors(...)&lt;/code&gt; from &lt;code&gt;@forinda/kickjs&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;flags-without-a-request&quot;&gt;Flags, without a request&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;getRouteFlags(controllerClass, handlerName)&lt;/code&gt; returns the same resolved flags a live request sees — method over class over mount:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { getRouteFlags } from '@forinda/kickjs'

it('keeps /me private while the rest of AuthController is public', () =&amp;gt; {
  expect(getRouteFlags(AuthController, 'login').has('auth.public')).toBe(true)
  expect(getRouteFlags(AuthController, 'me').has('auth.public')).toBe(false)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A test like this is cheap insurance on the one flag whose removal is a security bug.&lt;/p&gt;
&lt;h4 id=&quot;the-whole-pipeline&quot;&gt;The whole pipeline&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;createTestApp&lt;/code&gt; from &lt;code&gt;@forinda/kickjs-testing&lt;/code&gt; boots the app with real contributors, flags and ordering; drive it with Supertest and assert the outcomes: &lt;code&gt;401&lt;/code&gt; without a token, &lt;code&gt;200&lt;/code&gt; on a public route, &lt;code&gt;403&lt;/code&gt; with the upgrade problem type on a gated route for a free plan. &lt;code&gt;createTestApp&lt;/code&gt; also sets up the request scope, so services calling &lt;code&gt;getRequestValue&lt;/code&gt; work in these tests.&lt;/p&gt;
&lt;h4 id=&quot;assets-with-fixtures&quot;&gt;Assets, with fixtures&lt;/h4&gt;
&lt;p&gt;Point the resolver at a fixture manifest and clear its cache:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { join } from 'node:path'
import { clearAssetCache } from '@forinda/kickjs'

beforeEach(() =&amp;gt; {
  process.env.KICK_ASSETS_ROOT = join(__dirname, 'fixtures/assets') // contains .kickjs-assets.json
  clearAssetCache()
})

afterEach(() =&amp;gt; {
  delete process.env.KICK_ASSETS_ROOT
  clearAssetCache()
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Asset&lt;/code&gt; fields resolve on every access, so existing service instances pick up the fixtures without being recreated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;15-pitfalls&quot;&gt;15. Pitfalls&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Symptom &lt;/th&gt;&lt;th&gt;Cause &lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A &quot;private&quot; route is treated as public &lt;/td&gt;&lt;td&gt;&lt;code&gt;@Public(false)&lt;/code&gt; style thinking &lt;/td&gt;&lt;td&gt;Remove with &lt;code&gt;@Public.off&lt;/code&gt;; flags have no false state&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@Public.off&lt;/code&gt; does nothing &lt;/td&gt;&lt;td&gt;The route never inherited the flag &lt;/td&gt;&lt;td&gt;Check where you expected it to come from&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Handler sees &lt;code&gt;undefined&lt;/code&gt; for a contributor value &lt;/td&gt;&lt;td&gt;Assigned &lt;code&gt;ctx.x = …&lt;/code&gt; in &lt;code&gt;resolve&lt;/code&gt;/&lt;code&gt;onError&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Return&lt;/strong&gt; the value; the runner stores it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ctx.require('user')&lt;/code&gt; throws on some routes &lt;/td&gt;&lt;td&gt;The contributor was skipped by &lt;code&gt;skipWhen&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Use &lt;code&gt;ctx.get('user')&lt;/code&gt; on routes that can be public&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Anonymous callers see validation errors &lt;/td&gt;&lt;td&gt;Auth contributor runs after validation &lt;/td&gt;&lt;td&gt;&lt;code&gt;beforeValidation: true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MissingContributorError&lt;/code&gt; at boot &lt;/td&gt;&lt;td&gt;A &lt;code&gt;dependsOn&lt;/code&gt; key has no producer for that route &lt;/td&gt;&lt;td&gt;Register the producer at the same or broader site&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;beforeValidation&lt;/code&gt; contributor fails boot &lt;/td&gt;&lt;td&gt;It depends on a normal contributor &lt;/td&gt;&lt;td&gt;Early contributors may only depend on early ones&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Flag test typo matches nothing &lt;/td&gt;&lt;td&gt;Flags not declared yet &lt;/td&gt;&lt;td&gt;Declare with &lt;code&gt;defineRouteFlag&lt;/code&gt;; typegen narrows every name&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;['a', '!b']&lt;/code&gt; won't compile &lt;/td&gt;&lt;td&gt;Lists are single-polarity &lt;/td&gt;&lt;td&gt;Use a predicate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Asset works in dev, missing in production &lt;/td&gt;&lt;td&gt;File not matched by &lt;code&gt;glob&lt;/code&gt;, or custom &lt;code&gt;build.outDir&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Widen the glob; set &lt;code&gt;build.outDir&lt;/code&gt; in &lt;code&gt;kick.config.ts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two files, one asset key &lt;/td&gt;&lt;td&gt;Same basename (&lt;code&gt;index.html&lt;/code&gt; + &lt;code&gt;index.pug&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;Default &lt;code&gt;keys: 'auto'&lt;/code&gt; keeps extensions for that group&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New template not found in dev &lt;/td&gt;&lt;td&gt;Manifest is stale &lt;/td&gt;&lt;td&gt;&lt;code&gt;kick build:assets&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;welcome-email.ejs&lt;/code&gt; needs bracket access &lt;/td&gt;&lt;td&gt;Non-identifier file name &lt;/td&gt;&lt;td&gt;&lt;code&gt;assets.mails['welcome-email']()&lt;/code&gt;, or rename the file&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-cli-commands-used&quot;&gt;The CLI commands used&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Command &lt;/th&gt;&lt;th&gt;What it does here&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick g contributor &amp;lt;name&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Scaffolds a context decorator with its &lt;code&gt;ContextMeta&lt;/code&gt; stub (&lt;code&gt;--type bare&lt;/code&gt; for transport-agnostic, &lt;code&gt;--params &quot;action:string&quot;&lt;/code&gt; for the parameterised form, &lt;code&gt;-m &amp;lt;module&amp;gt;&lt;/code&gt; to place it in a module).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick typegen&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Generates &lt;code&gt;KickRouteFlags&lt;/code&gt;, route types for &lt;code&gt;ctx.require()&lt;/code&gt; narrowing, and &lt;code&gt;KickAssets&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick dev&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Runs the app with hot reload and typegen on every change.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick build&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Builds, copies &lt;code&gt;assetMap&lt;/code&gt; files, writes the asset manifest and runs typegen.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;kick build:assets&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Refreshes the asset manifest without a JS rebuild.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--dry-run&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Previews any generator's files first.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Route flags&lt;/strong&gt; are facts on routes: declared once, inherited method &amp;gt; class &amp;gt; mount, removed with &lt;code&gt;.off&lt;/code&gt;, and read by contributors, guards, pre-routing middleware, OpenAPI and DevTools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context decorators&lt;/strong&gt; are typed per-request values: ordered with &lt;code&gt;dependsOn&lt;/code&gt;, wired to services with &lt;code&gt;deps&lt;/code&gt;, skipped per route with &lt;code&gt;skipWhen&lt;/code&gt; / &lt;code&gt;onlyWhen&lt;/code&gt;, run early with &lt;code&gt;beforeValidation&lt;/code&gt;, parameterised with &lt;code&gt;.withParams&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The asset manager&lt;/strong&gt; gives typed paths to shipped files: &lt;code&gt;assetMap&lt;/code&gt; in config, &lt;code&gt;assets.*&lt;/code&gt; in code, correct in dev and in the build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Together:&lt;/strong&gt; flags describe the route, contributors decide about the request, assets deliver the file the decision points at — and typegen checks all three.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;KickJS on GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/forinda/kick-js&quot;&gt;github.com/forinda/kick-js&lt;/a&gt; — source, issues and discussions. A ⭐ helps others find it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guides:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/route-flags.html&quot;&gt;Route flags&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/context-decorators.html&quot;&gt;Context decorators&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/guide/asset-manager.html&quot;&gt;Asset manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kickjs.app/&quot;&gt;kickjs.app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28152/route-flags-context-decorators-and-typed-assets-in-kickjs-declare-it-once-use-everywhere</guid>
<pubDate>Tue, 22 Sep 2026 11:40:21 +0000</pubDate>
</item>
</channel>
</rss>