The Server SDK ships NestJS bindings under @shiprev/server-sdk-node/nest: a global module that provides one started client, two guards and a decorator. This page shows how a NestJS service is put together with them. What a ruling means, and which route needs which operation, is covered in Protect routes.
Install
npm install @shiprev/server-sdk-node@betaThe bindings work with @nestjs/common and @nestjs/core 9, 10 or 11, and need reflect-metadata, which a NestJS service already has. The examples read configuration with @nestjs/config; any provider that gives you the two values works.
Register the module
Import ShipRevModule once, in your root module. It is global: every module of the service can inject the client and use the guards without importing it again.
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ShipRevModule } from '@shiprev/server-sdk-node/nest';
import { BulkExportsController } from './bulk-exports.controller';
import { EntitlementsService } from './entitlements.service';
import { ReportsController } from './reports.controller';
import { ShipRevLifecycle } from './shiprev.lifecycle';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ShipRevModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
base_url: config.getOrThrow<string>('SHIPREV_BASE_URL'),
api_key: config.getOrThrow<string>('SHIPREV_API_KEY'),
}),
}),
],
controllers: [ReportsController, BulkExportsController],
providers: [ShipRevLifecycle, EntitlementsService],
})
export class AppModule {}useFactory returns the same options as createClient(), so batching, the backlog and the presets are set here too; they are listed in the Server SDK reference. Where the values are known at import time, ShipRevModule.forRoot({ base_url, api_key }) does the same without a factory.
The module creates the client and calls start() without waiting for it, so your service boots even while your application's region is unreachable. Until the client has reached the region once, every token is refused with not_ready and rulings have no snapshot to rule from; expose the client's state in a health endpoint (below) and let your orchestrator wait for LIVE if that matters to you.
Stop the client on shutdown
stop() sends the usage still buffered. Call it from a provider that implements OnApplicationShutdown, and enable shutdown hooks so NestJS runs it on SIGTERM and SIGINT:
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { ShipRevClient } from '@shiprev/server-sdk-node';
import { InjectShipRevClient } from '@shiprev/server-sdk-node/nest';
/** Stops the client on shutdown, which sends the usage still buffered. */
@Injectable()
export class ShipRevLifecycle implements OnApplicationShutdown {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
async onApplicationShutdown(): Promise<void> {
await this.shiprev.stop();
}
}Guard a controller
Apply both guards to the controller, in this order, and name the capability and the operation on every route:
import { Controller, Get, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { type Ruling, type TokenClaims } from '@shiprev/server-sdk-node';
import { RequireCapability, ShipRevAuthGuard, ShipRevCapabilityGuard } from '@shiprev/server-sdk-node/nest';
import { CAPABILITIES } from './capabilities';
import { EntitlementsService } from './entitlements.service';
/** What the two guards leave on the request once they let it through. */
interface GuardedRequest {
authClaims: TokenClaims;
ruling: Ruling;
}
@Controller('api/reports')
@UseGuards(ShipRevAuthGuard, ShipRevCapabilityGuard)
export class ReportsController {
constructor(private readonly entitlements: EntitlementsService) {}
@Get()
@RequireCapability(CAPABILITIES.reports, 'read', 0)
list(@Req() request: GuardedRequest) {
return { organization_id: request.authClaims.organization_id, reports: [] };
}
@Post('export')
@HttpCode(200)
@RequireCapability(CAPABILITIES.reportExports, 'write', 1)
async export(@Req() request: GuardedRequest) {
await this.entitlements.report(request.authClaims.organization_id, CAPABILITIES.reportExports, 1);
return { exported: true, remaining: request.ruling.remaining };
}
}ShipRevAuthGuard puts the verified claims on the request as authClaims, and also as user for code that already reads request.user. ShipRevCapabilityGuard rules with adjudicateAsync() and puts the ruling on the request as ruling. What each guard answers, and why the organization is taken only from the claims, is in Protect routes.
ShipRevCapabilityGuard lets through a route that has no @RequireCapability() at all, after the token check only. A requirement on the controller would cover such routes, but with whatever operation it names: a POST added later under a controller-level 'read' would be ruled as a read. So name the requirement on each route, and check in a test that none is missing (below).
Keep capability keys in constants. Decorators are evaluated when the controller's file is imported, before any configuration module has run, so a key cannot come from ConfigService:
export const CAPABILITIES = {
reports: 'reports',
reportExports: 'report_exports',
} as const;When a 403 is not enough
The capability guard answers every refusal with 403 and a message such as Capability report_exports not allowed: quota_exhausted. When your frontend needs to tell refusals apart, for example to offer a larger plan on quota_exhausted, or the quantity comes from the request, rule in the handler with a provider of your own:
import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { type AdjudicateOptions, type Ruling, ShipRevClient } from '@shiprev/server-sdk-node';
import { InjectShipRevClient } from '@shiprev/server-sdk-node/nest';
import { randomUUID } from 'node:crypto';
/** Rulings and usage for handlers that need more than the guard's 403. */
@Injectable()
export class EntitlementsService {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
/** Rules, and throws an answer your frontend can act on: { reason } in a 403, or a 503 while offline. */
async require(options: AdjudicateOptions): Promise<Ruling> {
const ruling = await this.shiprev.adjudicateAsync(options);
if (ruling.allowed) return ruling;
if (ruling.reason === 'disconnected_write' || ruling.reason === 'cold_start_denied') {
throw new ServiceUnavailableException({ reason: ruling.reason });
}
throw new ForbiddenException({ reason: ruling.reason, capability_key: options.capability_key });
}
/** Reports what a request consumed, for the organization that was ruled on. */
async report(organizationId: string, capabilityKey: string, quantity: number): Promise<void> {
await this.shiprev.reportUsage({
usage_event_id: randomUUID(),
organization_id: organizationId,
capability_key: capabilityKey,
quantity,
occurred_at: new Date().toISOString(),
});
}
}Put such routes in a controller that has the token guard only, so that each handler is visibly responsible for its own ruling:
import { Body, Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { type TokenClaims } from '@shiprev/server-sdk-node';
import { ShipRevAuthGuard } from '@shiprev/server-sdk-node/nest';
import { CAPABILITIES } from './capabilities';
import { EntitlementsService } from './entitlements.service';
/** Token check by the guard; every handler rules for itself with EntitlementsService.require(). */
@Controller('api/bulk-exports')
@UseGuards(ShipRevAuthGuard)
export class BulkExportsController {
constructor(private readonly entitlements: EntitlementsService) {}
@Post()
@HttpCode(200)
async create(@Req() request: { authClaims: TokenClaims }, @Body() body: { rows: number }) {
const organizationId = request.authClaims.organization_id;
await this.entitlements.require({
organization_id: organizationId,
capability_key: CAPABILITIES.reportExports,
quantity: body.rows,
operation: 'write',
});
await this.entitlements.report(organizationId, CAPABILITIES.reportExports, body.rows);
return { exported: body.rows };
}
}Register it in your module's controllers as well. A refusal then reaches the caller as 403 with the body { "reason": "quota_exhausted", "capability_key": "report_exports" }, and a write while the region is unreachable as 503. What each reason means is in Rulings.
Report health
Inject the client wherever you need it. A health endpoint that shows its state tells you, in production, whether the process is connected:
import { Controller, Get } from '@nestjs/common';
import { ShipRevClient } from '@shiprev/server-sdk-node';
import { InjectShipRevClient } from '@shiprev/server-sdk-node/nest';
@Controller('health')
export class HealthController {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
@Get()
health() {
const { state, snapshot_revision, last_sync_at } = this.shiprev.state();
return { shiprev: state, snapshot_revision, last_sync_at }; // 'LIVE' when all is well
}
}Leave it without guards, and register it in your module's controllers.
Test that every route names its capability
A route without @RequireCapability() passes the capability guard unchecked, and nothing fails at start-up. Catch it in a unit test, for every controller guarded by ShipRevCapabilityGuard:
import { RequestMethod } from '@nestjs/common';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { type CapabilityRequirement, SHIPREV_CAPABILITY_METADATA } from '@shiprev/server-sdk-node/nest';
/**
* The routes of a controller guarded by ShipRevCapabilityGuard that do not name their own capability,
* or that change state under a read. Use it in a test: expect(uncoveredRoutes(ReportsController)).toEqual([]).
*/
export function uncoveredRoutes(controller: { prototype: object }): string[] {
const prototype = controller.prototype as Record<string, unknown>;
return Object.getOwnPropertyNames(prototype).flatMap((name) => {
const handler = prototype[name];
if (name === 'constructor' || typeof handler !== 'function') return [];
if (Reflect.getMetadata(PATH_METADATA, handler) === undefined) return []; // not a route
const method = Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod;
const requirement = Reflect.getMetadata(SHIPREV_CAPABILITY_METADATA, handler) as CapabilityRequirement | undefined;
if (!requirement) return [`${name}: no @RequireCapability() on the route`];
if (method !== RequestMethod.GET && requirement.operation !== 'write') {
return [`${name}: ${RequestMethod[method]} under a 'read' requirement`];
}
return [];
});
}import 'reflect-metadata';
import { uncoveredRoutes } from './capability-coverage';
import { ReportsController } from './reports.controller';
it('names a capability on every route, and a write on every route that is not a GET', () => {
expect(uncoveredRoutes(ReportsController)).toEqual([]);
});Run against a controller that relies on a controller-level requirement, it lists each route that does not carry its own, for example list: no @RequireCapability() on the route.
Check it
With the service running against your application and a test end user's access token:
| Request | Expected |
|---|---|
GET /health | 200, shiprev LIVE |
GET /api/reports without Authorization | 401, Missing Authorization header |
GET /api/reports as an end user whose plan grants reports | 200 |
GET /api/reports as an end user whose plan does not | 403, Capability reports not allowed: capability_not_found |
POST /api/reports/export beyond the allowance | 403, Capability report_exports not allowed: quota_exhausted |
POST /api/bulk-exports with more rows than are left | 403, { "reason": "quota_exhausted", … } |
Stopping the service with Ctrl-C runs stop() and sends the buffered usage before the process exits.
Without NestJS
Nothing here depends on NestJS except the guards and the injection. In Express, Fastify or any other Node.js framework, create the client yourself with createClient(), await its start() at boot and stop() on shutdown, and call a function that verifies the token and rules at the start of each protected handler; Protect routes has one.