Skip to content
OPENREF0.1.0

OPENREF

One install, one line, and your NestJS application has an API reference.

npm i @openref/nest
OpenRefModule.setup('/docs', app, { document });

Open /docs. Without configuring anything else, the page already carries:

A reference with search, and a page per named schema
Try it, on every operation
SSE endpoints, marked from the document
No CDN and no outgoing request of any kind
Every asset served by your own application, under a name carrying its own digest
Output a strict CSP accepts: no inline style, no inline script, and a nonce on what needs one. Setting the header is yours to do, because this module never writes one
No telemetry, no version check, and no install time call home
Descriptions rendered as markdown and then sanitized, rather than escaped

document is the object @nestjs/swagger already builds for you. Nothing else changes.

Three more lines, and it stops being a rendering of a file

The eight items above are everything a document can say on its own. These are what your application knows. Nothing is registered unasked, so each fact costs you the collector that reads it:

OpenRefModule.forRoot({
  runtime: {
    collectors: [guardsCollector(), scopesCollector({ metadataKey: SCOPES_KEY }), sourceCollector()],
    sourceLink: 'https://github.com/org/repo/blob/{ref}/{file}#L{line}',
  },
});

That block, exactly as printed, adds two things:

Guards and the scopes a route requires
A link to the line the handler is written on

The other two cost more than a line each, and here is what each one costs:

To also get Add Which costs
Error contracts, in three groups that are never one list errorsCollector({ catalogs, global }) one more collector, and the catalogue is yours to declare: nothing derives an endpoint's errors from an exception filter
Rate limits throttlerCollector() from @openref/collector-throttler a second package to install, so that @openref/nest never puts a rate limiting library in the closure of an application that does not rate limit anything

Until you add them the reference says so rather than staying blank. With the collectors above and no error catalogue, the rate limit row reads not described / no 429 response, which is the parity scale reporting that the comparison did not run, not a route with no limit.

packages/nest/test/integration/first-minute.spec.ts boots the exact code printed on this page and asserts which items appear, so this table and that block cannot drift apart.

The page you are reading is the product

This site was rendered by the same code the install above brings in. It made no network request while you loaded it, its markup carries no inline style and no inline script so a policy with no unsafe-inline accepts it, and every asset came from its own directory under a name carrying the digest of its own bytes. Whatever the rest of this page claims, that part you can check in your browser's network tab right now. Sending the header is the host's job, here as anywhere: this project never writes one for you.

What your application knows that the document does not

This controller is real. It is in examples/nest-minimal/src/orders.controller.ts in the repository, and pnpm demo serves it:

@ApiTags('orders')
@UseGuards(ScopesGuard)
@Controller('orders')
export class OrdersController {
  @Get()
  @Scopes('orders:read')
  @Throttle({ default: { limit: 30, ttl: 60_000 } })
  @UseGuards(ThrottlerGuard)
  ...
}

This is what the reference draws for it, read off the served page:

GET /orders

Authentication     ≠  ScopesGuard, ThrottlerGuard
Scopes             ?  orders:read
Rate limit         =  30 / minute (default)
Response codes     ?  This handler declares no errors; 429; 401, 403; 500
Validation         ?  TrimPipe; CurrencyPipe
Timeout            ?  5000 ms
Unread parameters  ≠  4 of 10 seen read
Source             ?  OrdersController.list()

Each row pairs what the specification declares with what the application does. The glyph between them is the verdict: = where the rule looked and stayed quiet, ≠ where a finding is recorded, ? where the comparison did not run.

None of that is in the OpenAPI document, and none of it is guessed. Every value carries the level it was read at, declared, derived or inferred, and the name of the collector that produced it, so you can tell a promise somebody wrote from an observation of the running application.

Two of those rows are real findings on real code. Authentication reads ≠ because a guard stands in front of the route while the document asserts no security. Unread parameters reads ≠ because the operation declares ten inputs and the handler binds four, so five filters and a header the document promises are read by nothing.

Where to go next

  • Coming from a plain @nestjs/swagger setup? Read the next section. It is the whole audience of this project, and the change is one line.
  • Want to see it before installing it? pnpm demo from a clone boots the application above.
  • Want the reference for what mounting actually gives you? Every route in the navigation on this page is a route OpenRefModule.setup mounts. Open one.

Coming from @nestjs/swagger

OPENREF does not replace @nestjs/swagger. It keeps building your OpenAPI document; OPENREF renders it and adds what the running application knows.

Here is a normal setup today:

const app = await NestFactory.create(AppModule);

const document = SwaggerModule.createDocument(
  app,
  new DocumentBuilder().setTitle('Orders').setVersion('1.0.0').build(),
);

SwaggerModule.setup('docs', app, document);

await app.listen(3000);

Here is the same setup with OPENREF:

const app = await NestFactory.create(AppModule);

const document = SwaggerModule.createDocument(
  app,
  new DocumentBuilder().setTitle('Orders').setVersion('1.0.0').build(),
);

OpenRefModule.setup('/docs', app, { document });

await app.listen(3000);

One line changed. createDocument stays exactly where it was, and so does every @ApiProperty, @ApiResponse and @ApiTags you have already written.

What you gain on the first render

The change above touches the renderer and nothing else, so what you gain from it is what a renderer can give you:

You had You now also have
A page that fetched its assets from a CDN Assets served by your own application, digest named, with no outgoing request
A page that needed unsafe-inline Markup with neither an inline style nor an inline script, so a policy without it accepts the page. You still send the header; this module never writes one
A console that sent from the page A console that can send through your own origin, when you turn the proxy on

Everything below this line is the part a renderer cannot do, and none of it arrives until you register the collector that reads it.

What you lose

Nothing that @nestjs/swagger produced. But be honest about two things:

  • Swagger UI's "Authorize" dialog is not the same dialog. OPENREF has its own console, with its own credential handling described in the security section. If your team has muscle memory for Swagger UI, that muscle memory does not transfer.
  • OPENREF renders OpenAPI 3.0 and later, and AsyncAPI 3 and later. Swagger 2.0 documents and AsyncAPI 2.x documents are not accepted on the input side, and that is a stated non-goal rather than a missing feature.

Running both at once

You can. They mount at different routes and neither knows about the other:

SwaggerModule.setup('swagger', app, document);
OpenRefModule.setup('/docs', app, { document });

This is the recommended way to try it. Point half your team at /docs, keep /swagger where it was, and remove the second line when nobody opens it any more.

The collectors that make it worth the move

Everything above is a nicer renderer, and a nicer renderer is not a reason to change a dependency. This is, and it is a block in your root module rather than a line:

OpenRefModule.forRoot({
  runtime: {
    collectors: [guardsCollector(), scopesCollector({ metadataKey: SCOPES_KEY }), sourceCollector()],
    sourceLink: 'https://github.com/org/repo/blob/{ref}/{file}#L{line}',
  },
});

That block, exactly as printed, adds two things:

Guards and the scopes a route requires
A link to the line the handler is written on

The other facts a @nestjs/swagger setup never had cost more than a line each. Error contracts, in three groups that are never one list, need errorsCollector({ catalogs, global }) and a catalogue you declare, because nothing derives an endpoint's errors from an exception filter. Rate limits need throttlerCollector() from @openref/collector-throttler, a second package, so that @openref/nest never puts a rate limiting library in the closure of an application that does not rate limit anything.

With those in place the reference stops being a rendering of a file and starts being a report about your application. A guard that protects a route the document says is public becomes a finding with a rule code, not something a reader has to notice.

Install

npm i @openref/nest

That is the only package most applications install. The renderer, the request console and the search index are bundled inside it. Five more packages exist and you install them only when you need what they hold:

Package Install it when
@openref/theme you want the default theme's stylesheet on a static build
@openref/theme-telltale you want the second reference theme instead of the default
@openref/vue you are writing your own theme
@openref/core you are building your own tool on the normalized model
openref you want the command line: build, diff, doctor, lint, preview

The minimal form

OpenRefModule.setup('/docs', app, { document });

One route, one document, defaults for everything else. This is the form in the first section and it is the form most applications keep.

The full form

Everything is optional and every default is stated:

OpenRefModule.forRoot({
  documents: [
    { id: 'public', route: '/docs', document, proxy: { enabled: true, timeoutMs: 30_000 } },
    { id: 'events', route: '/docs/events', kind: 'events' },
    { id: 'admin', route: '/docs/admin', document, guard: AdminDocsGuard, visibility: 'internal' },
  ],
  theme: { definition, bundle },
  runtime: {
    collectors: [],
    sourceLink: 'https://github.com/org/repo/blob/{ref}/{file}#L{line}',
    guardSecuritySchemes: { JwtAuthGuard: 'bearer' },
    health: true,
  },
  agent: { llmsTxt: true, mcp: false },
});

forRootAsync({ useFactory, inject }) exists and takes the same options.

A short list at the root, and no more. Anything that belongs to one mount rather than to all of them lives on the entry: the proxy, the render cache, the bridge, the guard and the visibility are per document, because two references mounted by one application are two different things to publish. theme and agent are on both, as the default and as the override.

Why there are two entry points rather than one

SwaggerModule.createDocument(app, ...) needs the application, so the document does not exist until NestFactory.create has returned, which is strictly after any module's imports array is read. A forRoot that demanded documents up front would be unusable for the flow your application already has.

So the two add up rather than compete. forRoot contributes the container, which is the only place DiscoveryService can be injected, and that is the only route to the controller classes every runtime fact hangs off. setup then supplies the document and picks up the runtime pass forRoot registered.

The ordinary shape is therefore both:

@Module({
  imports: [OpenRefModule.forRoot({ runtime: { collectors: [guardsCollector()] } })],
})
export class AppModule {}
const app = await NestFactory.create(AppModule);
const document = SwaggerModule.createDocument(app, config);
OpenRefModule.setup('/docs', app, { document });

What is refused rather than ignored

Three names from the specification's own sketch of this form are not built: runner, cache and devWatch at the root. Passing any of them throws at boot, naming the option and where its capability actually lives, rather than being accepted and doing nothing. documents[].include, which would build a document from a subset of modules, is the same story in the other direction: it is printed in the specification because the drift rules reason about a document assembled that way, and it is not implemented.

A documented option that silently does nothing is worse than an option that is not there, which is why one of those two is a refusal you can see and the other is written down here.

What mounting gives you

Every address below is registered by the single call above. Reader pages live on bare segments, machine answers on segments beginning with an underscore, and one address never answers in two ways depending on a request header:

Address What it answers
<route> the reference, server rendered
<route>/{nodeId} one operation or one channel
<route>/schema/{schemaId} one named schema
<route>/bench/{nodeId} the request console for one operation
<route>/health the Documentation Health report as a page
<route>/shapes/{schemaId} the shapes showcase for one schema
<route>/states the states showcase
<route>/service/{serviceId} one federated service card
<route>/openapi.json, <route>/openapi.yaml the specification, canonical key order
<route>/asyncapi.json, <route>/asyncapi.yaml the same for an events document
<route>/_assets/* the client bundle, the theme and its fonts, digest named
<route>/_search-index the serialized search index
<route>/_navigation/{hash} the navigation payload for one document hash
<route>/_proxy the same origin proxy the console sends through
<route>/_bridge the broker bridge, when one is configured
<route>/_oauth/callback the return address of an authorization server
<route>/_health whether this mount is alive, whether it describes anything, and what it was built from
<route>/_federation a live snapshot of remote states
<route>/llms.txt, <route>/llms-full.txt the reference as text for a language model
<route>/mcp a read only JSON-RPC endpoint, off by default

A surface that is switched off answers by saying so and naming the option that switches it on, rather than answering 404 as if it never existed. That difference is deliberate: "turned off" and "not a thing" are different facts and a reader acts on them differently.

Decorators

Nine decorators, and you need none of them to start. Each one exists because the fact it

carries cannot be read from a running application, and inventing it would be a guess presented as a fact.

@ApiScopes('orders:write')
@ApiErrors(NotFoundError, PermissionDeniedError)
@ApiStream({ itemType: ProgressDto, kind: 'sse', terminator: '[DONE]' })
@ApiSample({ lang: 'typescript', label: 'SDK', source: '...' })
@ApiAudience('internal')
@ApiExample({ name: 'Success', request: {}, response: {} })
@ApiChannel({ address: 'orders.created', protocol: 'amqp', direction: 'send' })
@ApiMessage({ payload: OrderCreatedDto, headers: TraceHeadersDto })
@ApiPublishes('payment.created')
Decorator What it declares
@ApiScopes the scopes this route requires, at declared confidence
@ApiErrors the error contracts this endpoint promises, as classes
@ApiStream that this route streams, and the type of one item
@ApiSample a code sample you wrote, for a language or an SDK
@ApiAudience public, partner or internal
@ApiExample a named request and response pair
@ApiChannel the message channel a handler serves
@ApiMessage the payload and headers of that channel's message
@ApiPublishes the events this handler emits, by address

Why @ApiStream has to exist

@Controller('orders')
export class OrdersController {
  @Sse('watch')
  @ApiStream({ itemType: OrderEventDto, kind: 'sse' })
  watch(): Observable<MessageEvent<OrderEventDto>> {
    return orderEvents;
  }
}

@Sse alone is enough for the reference to know the route streams: the framework writes its own metadata key and that is read. What cannot be read is OrderEventDto. TypeScript generics do not survive compilation, so Observable<MessageEvent<OrderEventDto>> is Observable at runtime and nothing more. There is no reflection level at which that type comes back.

So the priority is four reads and no guesses:

  1. @ApiStream({ itemType }), which is declared and authoritative
  2. itemSchema from OpenAPI 3.2 in the document, also declared
  3. a compile time AST plugin, which is inferred and best effort
  4. nothing, which produces a doctor warning under the rule stream-unspecified

Level 4 emits no field at all. An empty schema or any in its place would be a guess dressed as a fact.

@ApiErrors and the three groups

An endpoint's error contracts are never one flat list, because three different things are being said:

@Controller('orders')
export class OrdersController {
  @Get(':id')
  @ApiErrors(OrderNotFoundError)
  findOne(@Param('id') id: string): OrderDto {
    return orders.byId(id);
  }
}
  • What the endpoint promises. @ApiErrors above: a 404 with a body shape.
  • What follows from what is standing in front of it. A guard implies 401 and 403. A rate limiter implies 429. Nobody wrote those and they are true anyway.
  • What the application can answer with anywhere. The 500 your global filter renders, which the host declares once.

Merging those into one list of status codes destroys the difference, and the difference is the product. Note also what is not on that list: the full set of errors an endpoint can throw is not derivable from your exception filters. A filter says "if X happens, render it this way", never "this endpoint can produce X".

Generic response wrappers

@Controller('cats')
export class CatsController {
  @Get()
  @ApiOkResponse(paginated(CatDto))
  list(): unknown {
    return cats.page();
  }
}

The synthetic schema is named deterministically, PaginatedCatDto and EnvelopeOrderDto, never PaginatedResponseDto_1. The pair is cached, so components.schemas never holds two copies. A name collision is a build error naming both sources, not a silent win for whichever was registered last, because a client SDK generated from a document with two PaginatedCatDto is not debuggable.

The body is merged into your document before normalization, so the specification you serve at /docs/openapi.json and the model the page renders describe one document. A schema added only to the model would be a schema missing from the file an SDK generator downloads.

Collectors

A collector reads one kind of fact off your running application. You register the ones whose facts you want, and none of them run unless you do:

OpenRefModule.forRoot({
  runtime: {
    collectors: [
      sourceCollector(),
      guardsCollector(),
      declarationsCollector(),
      streamCollector(),
      scopesCollector({ metadataKey: SCOPES_KEY }),
      errorsCollector({ catalogs: [ORDER_ERRORS] }),
      pipesCollector(),
      timeoutCollector({ metadataKey: TIMEOUT_KEY }),
      headersCollector({ metadataKey: REQUIRED_HEADERS_KEY }),
      handlerScanCollector(),
      httpCodeCollector(),
    ],
    sourceLink: 'https://github.com/org/repo/blob/{ref}/{file}#L{line}',
  },
});
Collector Reads
sourceCollector where the handler is written, from V8 and the source map
guardsCollector the guard class names in front of the route
scopesCollector scopes, from a metadata key you name
rolesCollector roles, from a metadata key you name
pipesCollector the pipes bound to the route, with their scope
timeoutCollector a timeout, from a metadata key you name
headersCollector required headers, from a metadata key you name
httpCodeCollector the success status @HttpCode sets
streamCollector that a route streams, and its item type when declared
declarationsCollector what this package's own decorators declared
errorsCollector error contracts, from catalogs you supply
handlerScanCollector which declared parameters the handler actually binds

throttlerCollector lives in its own package, @openref/collector-throttler, so that installing @openref/nest never puts a rate limiting library in the dependency closure of an application that does not rate limit anything. The same is true of @openref/collector-casl, @openref/collector-access-control, @openref/collector-redisx-rate-limit, which reads @nestjs-redisx/rate-limit, @openref/collector-redisx-idempotency, which reads @nestjs-redisx/idempotency, @openref/collector-redisx-cache, which reads @nestjs-redisx/cache, @openref/collector-redisx-locks, which reads @nestjs-redisx/locks, and @openref/collector-redisx-circuit-breaker, which reads @nestjs-redisx/circuit-breaker.

The last three report a handler policy: what a route declares about caching its own response, about what happens when two callers arrive at once, and about what it does when the thing behind it is down. Each is a separate package for the reason the first sentence gives, so an application that caches nothing does not carry the lock module to learn that it locks nothing either.

The rate limit and idempotency collectors report statuses as well, into the route's runtime derived error contracts, so a route that answers something the document does not mention shows up as drift. A @RateLimit route answers 429 whenever the limit is spent, and answers 503 as well where the module declares errorPolicy: 'fail-closed', which is the only place that option can be read; where it cannot be read the 503 is left off and openref doctor says so rather than assuming it. An @Idempotent route answers 409, and 422 as well where the plugin compares request fingerprints.

Register at most one collector per fact. Two that report the same fact at the same confidence are resolved by registration order, first wins, and the doctor report names the pair and the value it dropped so the choice is never silent.

What a collector cannot read, it says

A rate limit written on a route is a fact. A rate limit applied by a guard your application registered for everything is not: what that guard decides is in its own code, which no collector ever reads. So a route with no limit of its own and a globally registered guard over it does not come back empty. It comes back with a line in openref doctor naming the guard, and the module wide budget if one is configured, and saying that nothing observed connects the two. An unlimited route and a route whose limit is unreadable must not look the same, and this is where they stop looking the same.

Every fact carries where it came from

const runtime = {
  scopes: { value: ['orders:read'], confidence: 'declared', collector: 'scopesCollector' },
};
Three levels, and no fourth:
  • declared: you wrote it explicitly, with a decorator
  • derived: read from metadata under a key that was explicitly configured
  • inferred: a compile time AST plugin's best effort

A bare value with no provenance is not accepted anywhere in the model. That is what lets the page tell you whether a scope is a promise somebody typed or an observation of the application.

The three things that are impossible, and are never faked

  1. Reading what a guard decides. ScopesGuard is a class name. What it checks is code, and code is not readable as data. Only metadata under a key you configured is readable.
  2. Deriving an endpoint's full error list from exception filters. See the previous section.
  3. Recovering a generic parameter through reflection. It is not in the compiled output.

When a fact cannot be obtained, the reference emits a doctor warning naming what it could not read. It never substitutes a guess. That is the difference between a route that needs no scopes and a route whose scopes are unreadable, and a reader cannot tell those apart from a blank.

Naming a metadata key

There is no default key and there never will be one, because guessing your application's key would mean reporting somebody else's metadata as your route's facts:

export const SCOPES_KEY = 'orders:scopes';
export const Scopes = (...scopes: string[]) => SetMetadata(SCOPES_KEY, scopes);
OpenRefModule.forRoot({ runtime: { collectors: [scopesCollector({ metadataKey: SCOPES_KEY })] } });

Writing your own

The contract is public and frozen, and both members are in the block below:

export interface IRuntimeCollector {
  readonly name: string;
  collect(context: CollectorContext): IRNodeRuntime | undefined;
}

context hands you the normalized node, the controller class, the handler, the class the handler was declared on, Nest's Reflector and ModuleRef, the global guards and pipes, and fact(value, confidence), which is how a value becomes a fact with provenance. Returning undefined means this collector has nothing to say about this node.

This one turns an authorization library's ability rules into the scopes a route requires, which is the shape most hand written collectors have:

export const ABILITY_COLLECTOR_NAME = 'abilityCollector';

interface AbilityRule {
  readonly action: string;
  readonly subject: string;
}

function isAbilityRule(value: unknown): value is AbilityRule {
  if (typeof value !== 'object' || value === null) return false;
  const rule = value as { action?: unknown; subject?: unknown };
  return typeof rule.action === 'string' && typeof rule.subject === 'string';
}

export function abilityCollector(options: { readonly metadataKey: string }): IRuntimeCollector {
  return {
    name: ABILITY_COLLECTOR_NAME,

    collect(context: CollectorContext) {
      const declared: unknown = context.reflector.get(options.metadataKey, context.handler);
      if (!Array.isArray(declared)) return undefined;

      const scopes = declared.filter(isAbilityRule).map((rule) => `${rule.subject}:${rule.action}`);
      if (scopes.length === 0) return undefined;

      return { scopes: context.fact(scopes, 'derived') };
    },
  };
}

derived and not declared, because the rules were read from a metadata key rather than written as a statement about this route. Getting that level wrong is the one way a collector can lie.

Note what is not annotated: collect returns IRNodeRuntime | undefined. Writing the annotation is fine, and @openref/nest re-exports the type so it costs no second package; leaving it off is fine too, because the literal is checked against IRuntimeCollector, which checks the same thing.

Collectors are fail open. A collector that throws, or one whose optional package is not installed, is skipped and reported; it never takes the reference down with it. That is the opposite of the normalizer's policy, which is fail closed, because a broken specification that renders as if it were fine is a lie and a missing optional fact is not.

examples/runtime-intelligence in the repository is a complete application built around a collector written this way.

Themes

Three levels, and you can stop at any of them.
Level What it gives you What it costs
L0, tokens CSS custom properties. Colours, spacing, radii, fonts nothing, no build step
L1, slots your own Vue component in a named position a browser bundle built with your theme
L2, a full theme your own layout; the core ships no style at all a package

A fourth level, arbitrary HTML marked up with data-oref-* attributes, was planned and then withdrawn on 2026-08-14. It amounted to writing a template language, and the case it existed for is covered by an L2 theme in the Web Component's light DOM mode, at a fraction of the cost. It is recorded here as withdrawn rather than left in a roadmap.

L0: tokens

OpenRefModule.setup('/docs', app, {
  document,
  theme: {
    definition: {
      name: 'acme',
      tokens: {
        '--oref-color-accent-link': '#0088ff',
        '--oref-color-accent-bg': '#0088ff',
        '--oref-radius-md': '2px',
      },
    },
  },
});

No bundle, no build step, no package. The tokens are written into a <style> element carrying the page's nonce, so they work under a strict policy. Token names must match --oref-<group>-<name>; a theme name must be lowercase with hyphens.

Every colour, length, radius and font in the shipped themes is a token. The core ships no visual opinion of its own, and a hardcoded value in a theme's stylesheet is a lint error rather than a matter of taste.

L1: one slot, your component

Twenty one positions are registered, and the list is public API:
AppShell        NavTree          CommandPalette   DocumentOverview  SchemaPage
OperationHeader RuntimePanel     ProvenanceTag    DriftCard         ParamTable
ResponseList    CodeSample       SchemaTree       ShapeForm         AuthPanel
ServerSelect    SendButton       ResponseView     StreamLog         HealthScore
StateNotice
export default defineTheme({
  name: 'acme',
  components: { StateNotice },
  tokens: { '--oref-color-accent-link': '#3b6ef5' },
  assets: { css: ['./acme.css'] },
});

The moment a theme declares a component it also needs a browser bundle built with it, and passing one is not optional:

OpenRefModule.setup('/docs', app, {
  document,
  theme: { definition: acme, bundle: '@acme/openref-theme/entry' },
});

A definition with component overrides and no bundle is refused at setup with a message saying so. The reason is a failure mode rather than a rule: the server would render your component and the default bundle would hydrate over it, producing a page that is correct in every test and silently wrong in a browser.

Eight of those positions are server resolved. They carry no client state and must keep their

root element type, because the server's markup and the client's expectation of it have to agree.

L2: your own layout

An L2 theme is a package. It brings its own AppShell, its own stylesheet, its own fonts and the whole token set in both colour schemes, and the core contributes no style at all. @openref/theme-telltale is one, shipped as a reference: it is written against @openref/vue alone and imports nothing from the renderer.

@openref/theme-kit scaffolds one and checks it against the contract.

The rule underneath all three

No inline styles. Anywhere.

<!-- this is how a dynamic value is carried -->
<div class="oref-badge" :class="statusClass">

<!-- this is refused, and a CI check scans built output for it -->
<div :style="{ color: statusColor }">

A CSP nonce can authorize a <style> element. It can never authorize a style="..." attribute, because the attribute has nowhere to carry the nonce. Emitting markup a host can serve under style-src 'self' 'nonce-...' with no unsafe-inline is the point, so a dynamic value goes through a CSS custom property set on a class, never through an inline style.

Both DOM modes

The Web Component ships in two builds. Shadow DOM isolates styles, which is what you want when you are embedding the reference in a page you do not control. Light DOM (shadow: false) lets the host page's CSS reach in, which is what you want when the reference is meant to look like the portal around it. Neither is a workaround for the other, and every theme level is tested in both.

The command line

npm i -D @openref/cli
Six commands. `build` and `doctor` are the two you will use.
openref build   [--spec|--config|--from-nest] [--out] [--base] [--target]
openref preview [--spec] [--watch]
openref doctor  [--from-nest] [--fail-on=drift|warn|error] [--json]
openref lint    <spec>
openref diff    <old> <new>
openref pr      [--spec] [--base] [--out] [--preview-base] [--preview-url] [--fail-on-breaking]

build: the reference as a directory of files

openref build --spec openapi.yaml --out dist-docs --base https://docs.example.com

One directory per page with its own index.html, plus sitemap.xml, llms.txt, the search index, the navigation payload and digest named assets. No server, no runtime, no JavaScript required to read a page. Two builds of the same document write the same bytes.

The source is exactly one of three:

  • --spec <path>, an OpenAPI or AsyncAPI document on disk, JSON or YAML
  • --config <path>, a JSON file naming spec and the other options
  • --from-nest <path>, a compiled NestJS entry point, booted headlessly and closed again, so the static build carries the runtime facts a served reference has

--base takes a path such as /docs or an absolute URL. Only an absolute base can produce sitemap.xml, the canonical link and og:url, because those are defined as absolute URLs and a sitemap of paths is not a sitemap.

build --target: the console still works on a static host

A static page cannot send a cross origin request to your API without the API allowing it. So the build can generate the proxy configuration for the host it is going to:

openref build --spec openapi.yaml --out dist-docs --target netlify
Target What is generated
nitro, nginx, caddy, netlify, vercel, cloudflare-pages, s3-cloudfront a rewrite rule to the servers the document declares
github-pages, gitlab-pages, s3 nothing, because they cannot rewrite; pages carry a warning saying the console sends directly
auto reads the platform's environment variables, falls back to none with a warning

Absent means no proxy configuration is generated at all. A proxy is a standing gateway and never appears unasked.

doctor: what the application and the document disagree about

openref doctor --from-nest dist/main.js

It boots your application, collects the runtime facts, compares them to the document and prints the findings with rule codes. --fail-on drift|warn|error is what makes it a CI gate; without it the command always exits 0 and only reports.

--json prints a versioned machine readable report. --fix writes the findings the report classifies as silence back into your source as new decorators. It only adds, never alters, and refuses to run on a dirty working tree.

diff: breaking and non-breaking, told apart

openref diff v1.2.0 HEAD --spec openapi.yaml

Either side is a file, a <ref>:<path>, or a bare git ref. The classification is by direction rather than by presence: a constraint that got tighter and the same constraint loosened are two different findings, and the line names the keyword and both values.

lint: the document alone

openref lint openapi.yaml

Structural problems, with no application involved.

preview and pr

preview --spec <path> --watch serves the reference and re-reads the document when it changes.

pr produces the comment a pull request gets: what changed, whether anything breaking is in it, and a link to the preview build. It is what the GitHub Action runs.

In CI

- run: npx @openref/cli lint openapi.yaml
- run: npx @openref/cli diff origin/main HEAD --spec openapi.yaml
- run: npx @openref/cli doctor --from-nest dist/main.js --fail-on error
- run: npx @openref/cli build --spec openapi.yaml --out dist-docs --base ${{ env.DOCS_URL }}

Federation

One reference over several services, without any of them knowing about the others.

OpenRefModule.forRoot({
  federation: {
    id: 'platform',
    route: '/docs',
    remotes: [
      { id: 'orders', url: 'http://orders.internal/docs/openapi.json' },
      { id: 'billing', url: 'http://billing.internal/docs/openapi.json', prefix: '/billing' },
    ],
  },
});

The gateway fetches each service's own specification, merges them into one document, and serves one reference with one search index and one navigation. A remote is { id, url, prefix? } and the url is http or https only. A document this same forRoot mounts itself joins the merge through services: [{ id }], naming a documents entry by id rather than fetching itself over the network.

Merging is lossless, and that is enforced rather than intended

Two services can both have a User schema, both have POST /orders, both have an operationId called create. The merge renames rather than drops:

onConflict What happens
namespace, the default every service's names move under its own prefix
first-wins the lowest service id keeps the plain name, the rest move
fail no document is produced at all

first-wins wins the name, not the right to exist. A reference that quietly omitted an endpoint a service really serves would be a lie about the API, and that is the one outcome the merge will not produce. Losslessness is proved by inverting the merge: every merged node is put back into its own service's names using nothing but the rename report, and compared with the source by hash.

Ordering does not matter

Services are processed in sorted id order and never in configured order. Six orderings of three services produce one document hash and one report, byte for byte. A merge that read the configured order could not give you that, and a reference whose output depends on the order of a configuration array is a reference you cannot cache or diff.

Deduplication is by what a schema points at, not by its body

User can be byte identical in two services while the Address it refers to is not. One hash of the body would show every reader of the second service the first service's model under the second service's name. So the signature is computed over the reference closure, refined round by round until the number of distinct signatures stops growing, which terminates on cycles rather than recursing forever.

A service that is down does not take the reference down

Each remote has a status: pending, stale, fresh, degraded or failed. A fetch that fails falls back to the last good copy from the cache, and the page says which services are stale rather than pretending everything is fresh. In strict mode a failed remote is a 503 with a readable body, and the cache never softens it.

<route>/_federation answers with the live snapshot, and the navigation's service dots read from it.

Runtime facts belong to local services only

A collector reads the application it runs inside. A specification fetched over HTTP from another service carries no runtime pass and cannot acquire one, so a federated gateway reports runtime facts for the services it hosts itself and reports their absence for the rest, rather than showing a blank that reads as "this route needs no scopes".

examples/federation in the repository is a working two application demo. pnpm demo:federation boots it.

Events

HTTP endpoints and message channels in one reference, from one application.

OpenRefModule.forRoot({
  documents: [
    {
      id: 'events',
      route: '/docs/events',
      kind: 'events',
      title: 'Orders events',
      servers: [
        { protocol: 'kafka', host: 'kafka.example.com:9092' },
        { protocol: 'amqp', host: 'rabbit.example.com:5672' },
      ],
    },
  ],
  runtime: { collectors: [guardsCollector(), declarationsCollector()] },
});

An events document carries no document member, because there is nothing to hand it. It is synthesized from the application: the channels are discovered from your handlers, and the AsyncAPI 3 document is written from what was found. That is why an events entry lives in forRoot and not in setup.

What is discovered, and what you have to declare

Discovered from the framework's own metadata:

@Injectable()
export class OrdersProjector {
  @MessagePattern('orders.get', Transport.KAFKA)
  get(): OrderDto {
    return orders.latest();
  }

  @EventPattern('orders.created', Transport.KAFKA)
  created(): void {
    orders.refresh();
  }
}

Declared, because nothing about it is readable:

@Injectable()
export class RefundsProjector {
  @ApiChannel({ address: 'billing.refunded', protocol: 'amqp', summary: 'A refund went out' })
  @ApiMessage({ payload: RefundDto })
  refunded(): void {
    refunds.refresh();
  }
}

@ApiMessage({ payload: RefundDto }) contributes the class name, which resolves against the schemas you pass on the entry:

OpenRefModule.forRoot({
  documents: [
    { id: 'events', route: '/docs/events', kind: 'events', schemas: openApiSchemas },
  ],
});

That is usually the components.schemas your HTTP document already built, so a DTO is described once and both sides point at the same schema. A payload name nothing answers reaches doctor rather than being invented.

One graph, not two documents side by side

@Controller('orders')
export class OrdersController {
  @Post()
  @ApiPublishes('orders.created')
  create(@Body() body: CreateOrderDto): OrderDto {
    return orders.create(body);
  }
}

@ApiPublishes records that this HTTP endpoint emits that event, so the reference can draw the edge: this endpoint publishes this channel, and these handlers receive it. Channels an application only sends to and never receives are drawn as ends outside the estate, and in a federation a name no document in the federation declares is labelled as exactly that, rather than left looking like a broken link.

On the input side

AsyncAPI 3.0 and 3.1 are accepted. AsyncAPI 2.x is not, and that is a stated non-goal.

Multi Format Schema is supported. Avro and Protobuf payloads are carried with their dialect marked rather than converted to JSON Schema, because converting them would silently change what the contract says. Traits merge by the specification's own rule.

A document that declares both openapi and asyncapi at its root is refused naming both members, rather than being read as one of them and quietly losing the other half.

Security posture

Six properties, each one checkable rather than promised.

Zero external requests

A rendered page fetches nothing from any origin but the one that served it. No CDN, no font host, no analytics, no error reporter, no "check for updates". Fonts, the client bundle, the theme's stylesheets and the search index are served by your application, under names carrying the digest of their own bytes.

This is proved in a real browser with the network intercepted, and proved twice: once by watching a planted external stylesheet be seen, and once by watching the real page ask for nothing. A check that cannot see a request it should see is a check that reports zero for the wrong reason.

Output a strict CSP accepts, and a header you have to set

This module never writes a Content-Security-Policy header. That is deliberate and it is recorded in the specification: a library that set a policy on your responses would be overwriting whatever your application already sends. So the guarantee is about the output, and the header is yours.

What is guaranteed: the served markup carries no inline style attribute, no executable inline script, and takes a nonce on the two elements that need one. A CI check scans built output for inline style= attributes, inline scripts and dynamic code evaluation, and a browser under the policy below counts violations and requires zero.

What you have to do: send the header. The host sets the policy; the reference makes its output compatible with one and writes no Content-Security-Policy header of its own. This is the policy the output is built for, and buildContentSecurityPolicy returns exactly it for the nonce and the origins you hand it, so a host does not have to transcribe it. A committed case holds the block below against what that function returns, so the two cannot drift apart again. It is exported from @openref/nest, which is the package a Nest host installs. A Nuxt host transcribes the block below for now, and the reason is written down rather than glossed: @openref/nuxt is not published, so there is no package a Nuxt application can install that exports the builder. The policy is this:

default-src 'none';
script-src 'self' 'nonce-<per response>';
style-src 'self' 'nonce-<per response>';
font-src 'self';
img-src 'self' data:;
connect-src 'self' <your authorization server origin>;
base-uri 'none';
form-action 'none';
frame-ancestors 'none'

connect-src is the one directive with two origins in it, and the second one is yours to supply. The try-it console sends to your own application, which 'self' covers. Signing in does not: exchanging an authorization code for a token is a browser fetch from the page to the authorization server, a third origin, and under a bare connect-src 'self' the browser refuses that request before it is made and reports it nowhere but the developer console. A reference served under connect-src 'self' alone cannot sign in at all. So the builder takes those origins rather than defaulting them, and the block above is what it returns when you pass one:

import { buildContentSecurityPolicy } from '@openref/nest';

const policyFor = (nonce: string): string =>
  buildContentSecurityPolicy(nonce, ['https://login.example.com']);

Pass nothing and drop the second token if no security scheme in your document declares an authorizationCode flow. Pass the authorization server's origin, and only that origin, if one does. Both halves run in a real browser: with the origin named the exchange completes and the console says it signed in, and with the bare form the browser blocks the exchange on connect-src and it does not.

Note what is not in it: unsafe-inline. That is the distinction the whole design turns on. A nonce can authorize a <style> element and can never authorize a style="..." attribute, so a renderer that emits inline style attributes forces unsafe-inline into your policy and no amount of nonce plumbing changes it. For a regulated deployment that is an admission condition rather than a detail, which is why it is the output rather than the header that is guaranteed: a guarantee about a header you did not send would be worth nothing.

The console sends through your own origin

Pressing Send does not send from the page to an arbitrary host. Turn the proxy on and the request goes to <route>/_proxy on your own application, which forwards it:

OpenRefModule.setup('/docs', app, {
  document,
  proxy: { enabled: true, timeoutMs: 30_000, maxResponseBytes: 10 * 1024 * 1024 },
});

Off by default, and off means the page sends directly, which is the honest default for a capability that turns your application into a forwarder. Switched on, the proxy forwards only to hosts the document's own servers declare, and everything else is refused. It is fail closed by policy: an address it cannot resolve to an allowed host is a refusal, never a best effort. That covers the obvious SSRF shapes, including redirects that leave the allowlist and addresses that decode into something else after parsing.

The body ceiling is checked before the body is read, so a request that is going to be refused cannot spend the ceiling first.

Descriptions are sanitized, not escaped

A description in a specification is markdown and may contain HTML, and specifications are frequently not written by you. The renderer renders the markdown and then sanitizes the result, in that order, because a markdown renderer passes raw HTML through by design and its output is therefore untrusted no matter how trusted the input looked.

Escaping instead of sanitizing would have been simpler and would have broken every document that legitimately writes a <table> in a description.

No telemetry, of any kind

No usage reporting, no version check, no install time call home. Two packages in the wider dependency graph run analytics on install; both are refused a postinstall script by name in the workspace configuration, with the reason written next to them. Refusing a postinstall does not remove a package, it removes the call home.

Closing the reference

OpenRefModule.setup('/docs', app, {
  document,
  visibility: 'internal',
  guard: AdminDocsGuard,
});

visibility says who the reference is for and guard is what enforces it. They travel together, and a list of guards is a conjunction, exactly as @UseGuards reads. An empty list is refused, because it reads as "there is a guard" and means "there is no guard".

An operation marked @ApiAudience('internal') is withheld from the agent surfaces as well as from the page, in one place both surfaces call, so the JSON-RPC endpoint cannot answer with what the page withheld.

Examples

Seven directories in the repository, each one small enough to read in a sitting. The

ones that listen are booted by a committed test, which fetches a page from each of them.

Directory What it is for
examples/nest-minimal the first minute: one controller, one line of setup, a page you can send requests from
examples/runtime-intelligence a hand written collector, and what a fact with provenance looks like
examples/custom-theme an L0 theme: tokens only, no build step, no package
examples/federation three services, one reference over all of them
examples/events message channels discovered from handlers, rendered as AsyncAPI
examples/static-build the static build, and the proxy configuration per hosting platform
examples/nuxt-reference the Nuxt module, for a site that is not a NestJS application
pnpm demo             # examples/nest-minimal, on http://127.0.0.1:3000/docs
pnpm demo:federation  # examples/federation

Each directory has a README saying what to open and what to look at.

This site

The site you are reading is built by the product it documents. There is no second renderer:

pnpm docs:build

runs openref build on a document whose operations are the routes OpenRefModule.setup mounts and whose description is the guide above. The addresses in the navigation are reconciled in both directions against the route table the module really registers, so a route that appears in the product and not here is a failing test rather than an out of date page.

Two things it cannot do, said plainly rather than worked around:
  • The guide has no in-page anchors. Headings inside a description carry no id, because heading ids are generated from heading text and that would make the same document render differently depending on its prose. So this page is scrolled, not linked into.
  • The guide is one page. The product renders operations, channels and schemas, each with its own address. It has no page kind for prose, and inventing one for this site would have been a change to a frozen contract made for the convenience of the documentation rather than for a reader of the product.

Servers

  • /