A protected route does two things before any work: it verifies who is calling, then rules on whether the caller's buyer organization may use the capability. Both run inside your process. This page shows how to do it with the NestJS bindings of the Server SDK and without them, and how to keep new routes from slipping through.
It assumes a started client; see Backend and the Server SDK.
The rule both ways follow
- Read the session token from
Authorization: Bearer <token>and verify it withverifyToken(). Any failure is a401. - Take
organization_idfrom the verified claims, and from nowhere else. - Rule on the capability for that organization, then act on
allowed. - After the work, report the usage with the same
organization_id.
Step 2 is the one that matters most. A header, a query or route parameter and the request body are all under the caller's control. An organization taken from them would let any signed-in end user rule on, and report usage against, whichever organization they named.
With NestJS
@shiprev/server-sdk-node/nest provides two guards and a decorator:
| Part | What it does |
|---|---|
ShipRevAuthGuard | Verifies Authorization: Bearer <token> with verifyToken() and puts the claims on the request as authClaims. A missing, malformed, expired or refused token is a 401 |
ShipRevCapabilityGuard | Rules with adjudicateAsync() on what @RequireCapability() names, for the organization in the verified claims only, and puts the ruling on the request as ruling. A refusal is a 403; a token without an organization is a 403 before any ruling |
@RequireCapability(key, operation, quantity) | Names the capability of a route. operation defaults to 'read' and quantity to 1 |
Apply both guards at controller level, in this order, and name the capability on each route:
import { Controller, Get, 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';
interface GuardedRequest {
authClaims: TokenClaims;
ruling: Ruling;
}
@Controller('api/reports')
@UseGuards(ShipRevAuthGuard, ShipRevCapabilityGuard)
@RequireCapability('reports', 'read', 0) // the default for every route of this controller
export class ReportsController {
@Get()
list(@Req() request: GuardedRequest) {
return listReports(request.authClaims.organization_id);
}
@Post('export')
@RequireCapability('report_exports', 'write', 1) // replaces the controller's requirement on this route
async export(@Req() request: GuardedRequest) {
// ... do the work, then report it
}
}@RequireCapability() can stand on the controller as well as on a route. The guard looks at the route first: a requirement on the route replaces the controller's, it does not add to it.
A route without a requirement is identity-checked only
The capability guard lets through any route that has no @RequireCapability(), on the route or on its controller. Such a route is reachable by every signed-in end user of your application whatever their plan. A requirement on the controller closes that gap for routes added later; name 'write' explicitly on every route that consumes quota, changes state or costs money.
What the caller receives
Both guards throw the standard NestJS exceptions, so the answer has the shape your exception filters already produce:
| Case | Status | Message |
|---|---|---|
No or malformed Authorization header | 401 | Missing Authorization header or Invalid Bearer token format |
| Token invalid, expired or refused | 401 | Token verification failed: followed by the error's message |
| No organization in the token | 403 | Capability <key> not allowed: missing_organization |
| A write while the region is unreachable | 403 | Write operation denied while client is disconnected |
| Any other refusal | 403 | Capability <key> not allowed: followed by the reason |
When the guard is not enough
The guard answers every refusal with 403 and decides before your handler runs. Rule in the handler yourself when you need to:
- answer
503rather than403while the region is unreachable; - give a different answer for
quota_exhausted, such as a link to a larger plan; - decide the quantity from the request, for example the number of rows to export.
import { Body, Controller, ForbiddenException, Post, Req, ServiceUnavailableException, UseGuards } from '@nestjs/common';
import { ShipRevClient, type TokenClaims } from '@shiprev/server-sdk-node';
import { InjectShipRevClient, ShipRevAuthGuard } from '@shiprev/server-sdk-node/nest';
@Controller('api/exports')
@UseGuards(ShipRevAuthGuard) // the token is still verified; the ruling is made below
export class ExportsController {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
@Post()
async create(@Req() request: { authClaims: TokenClaims }, @Body() body: { rows: number }) {
const ruling = await this.shiprev.adjudicateAsync({
organization_id: request.authClaims.organization_id,
capability_key: 'report_exports',
quantity: body.rows,
operation: 'write',
});
if (!ruling.allowed) {
if (ruling.reason === 'disconnected_write') throw new ServiceUnavailableException();
throw new ForbiddenException({ reason: ruling.reason, upgrade: ruling.reason === 'quota_exhausted' });
}
// ... do the work, then report it
}
}Rulings lists what each reason means.
Without NestJS
Do the same steps in your own code. The function below works in any Node.js framework; call it at the start of each protected handler.
import { type AdjudicateOptions, TokenRejectedError, type TokenClaims } from '@shiprev/server-sdk-node';
import { shiprev } from './shiprev';
type Requirement = Pick<AdjudicateOptions, 'capability_key' | 'quantity' | 'operation'>;
export async function protect(
authorization: string | undefined,
requirement: Requirement,
): Promise<{ ok: true; claims: TokenClaims } | { ok: false; status: number; reason: string }> {
const token = authorization?.replace(/^Bearer /, '');
if (!token) return { ok: false, status: 401, reason: 'missing_token' };
let claims: TokenClaims;
try {
claims = await shiprev.verifyToken(token);
} catch (error) {
return { ok: false, status: 401, reason: error instanceof TokenRejectedError ? error.reason : 'invalid_token' };
}
const ruling = await shiprev.adjudicateAsync({ ...requirement, organization_id: claims.organization_id });
if (!ruling.allowed) {
const offline = ruling.reason === 'disconnected_write' || ruling.reason === 'cold_start_denied';
return { ok: false, status: offline ? 503 : 403, reason: ruling.reason ?? 'denied' };
}
return { ok: true, claims };
}const result = await protect(request.headers.authorization, {
capability_key: 'report_exports',
quantity: 1,
operation: 'write',
});
if (!result.ok) return reply(result.status, { reason: result.reason });
// ... do the work, then report it with result.claims.organization_idEvery failure of verifyToken() is a 401: a malformed, badly signed or expired token throws a plain Error, and a correctly signed token that is not a session token of your application throws TokenRejectedError with a reason. The values are in TokenRejectedError.reason.
Run a write only if it is allowed
guardWrite() rules on a write with adjudicate() and runs your function only if it is allowed; otherwise it throws DisconnectedWriteError, whose message names the reason:
await shiprev.guardWrite(
{ organization_id: claims.organization_id, capability_key: 'report_exports', quantity: 1 },
() => runExport(),
);It uses the synchronous ruling, so it never asks the region; see adjudicate() or adjudicateAsync().
Report what the route consumed
After the work, report it with the organization from the same verified claims, so the usage counts against the organization that was ruled on:
import { randomUUID } from 'node:crypto';
await shiprev.reportUsage({
usage_event_id: randomUUID(),
organization_id: claims.organization_id,
capability_key: 'report_exports',
quantity: 1,
occurred_at: new Date().toISOString(),
});Batching, retries and refused events are covered in Backend and the Server SDK and the Server SDK reference.
Test that every route is covered
A route added without a requirement is the most likely gap. Cover it in your tests: for each route that consumes quota, call it with the session token of a test customer whose plan does not grant the capability, and expect a 403.
Checklist
- Every protected route verifies the token before anything else.
-
organization_idcomes only from the verified claims, in rulings and in usage reports. - Every route that consumes quota, changes state or costs money rules with
operation: 'write'. - With NestJS, the controller carries a default
@RequireCapability(), or every route has its own. - Your tests call each such route as a customer without the capability and expect a refusal.