Entitlements

Protect routes

Verify the caller's session token, then rule on the capability for their buyer organization, on every route, with the NestJS guards or in your own code.

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

  1. Read the session token from Authorization: Bearer <token> and verify it with verifyToken(). Any failure is a 401.
  2. Take organization_id from the verified claims, and from nowhere else.
  3. Rule on the capability for that organization, then act on allowed.
  4. 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:

PartWhat it does
ShipRevAuthGuardVerifies Authorization: Bearer <token> with verifyToken() and puts the claims on the request as authClaims. A missing, malformed, expired or refused token is a 401
ShipRevCapabilityGuardRules 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:

reports.controller.ts
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:

CaseStatusMessage
No or malformed Authorization header401Missing Authorization header or Invalid Bearer token format
Token invalid, expired or refused401Token verification failed: followed by the error's message
No organization in the token403Capability <key> not allowed: missing_organization
A write while the region is unreachable403Write operation denied while client is disconnected
Any other refusal403Capability <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 503 rather than 403 while 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.
exports.controller.ts
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.

protect.ts
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 };
}
export-handler.ts
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_id

Every 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:

ts
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:

ts
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_id comes 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.

Where to go from here

© 2026 ShipRev