Skip to content

Every gate in app code is a flag. A link, a page, a button, an endpoint: if some people get it and others do not, the code asks a flag, and the flag’s rule says why: a privilege (privileges contains "USER_WRITE"), a plan feature (bridge:billing.entitlement.analytics eq true) or a rollout. App code never reads a role, a privilege list, the plan or a plan feature to decide what someone may see or do.

Two things are not gates and are not flags: numbers are plan limits (<QuotaGate> in the browser, @RequireQuota on the backend), and permission on one specific record stays in app code (“only the author edits their post”).

Before calling the work done, run npx @nebulr-group/bridge-cli check gates in the project. It lists every direct role, privilege, plan or plan-feature check with the flag to use instead. Fix each one.

In NestJS, an endpoint is @RequireFeatureFlag('…') on the handler (or a route rule with featureFlag).

Use the @CurrentUser() decorator to access the authenticated user:

import { Controller, Get } from '@nestjs/common';
import { CurrentUser, BridgeUser } from '@nebulr-group/bridge-nestjs';

@Controller('users')
export class UsersController {
  @Get('me')
  getProfile(@CurrentUser() user: BridgeUser) {
    return {
      id: user.id,
      email: user.email,
      username: user.username,
      fullName: user.fullName,
      tenantId: user.tenantId,
      appId: user.appId,
      role: user.role,
      privileges: user.privileges,
      onboarded: user.onboarded,
    };
  }
}

The BridgeUser interface:

interface BridgeUser {
  id: string;                    // User ID (sub claim)
  email: string;                 // User's email
  emailVerified: boolean;
  username: string;              // preferred_username claim
  fullName: string;              // Display name
  givenName?: string;
  familyName?: string;
  locale?: string;
  onboarded?: boolean;
  tenantId: string;              // Tenant/workspace ID
  appId?: string;                // App ID from the token (aid claim)
  scope?: string;                // OAuth scopes granted to the token
  role?: string;                 // User's role within the tenant
  multiTenantAccess?: boolean;
  privileges?: string[];         // Privilege strings (e.g. ['AUTHENTICATED', 'USER_READ'])
}

Use the @CurrentTenant() decorator to access details about the user’s workspace (called a tenant in the API):

import { Controller, Get } from '@nestjs/common';
import { CurrentUser, CurrentTenant, BridgeUser, BridgeTenant } from '@nebulr-group/bridge-nestjs';

@Controller('workspace')
export class WorkspaceController {
  @Get()
  getWorkspace(
    @CurrentUser() user: BridgeUser,
    @CurrentTenant() tenant: BridgeTenant,
  ) {
    return {
      user: { id: user.id, email: user.email, role: user.role },
      tenant: {
        id: tenant.id,
        name: tenant.name,
        locale: tenant.locale,
        logo: tenant.logo,
        onboarded: tenant.onboarded,
      },
    };
  }
}

The BridgeTenant interface:

interface BridgeTenant {
  id: string;
  name: string;
  locale?: string;
  logo?: string;
  onboarded?: boolean;
}

Enable the global guard in your module configuration:

BridgeModule.forRoot({
  appId: 'YOUR_APP_ID',
  guard: {
    global: true,
    defaultAccess: 'protected',
    rules: [
      { path: '/health', privilege: 'ANONYMOUS' },
      { path: '/webhooks/*', privilege: 'ANONYMOUS' },
    ],
  },
})

With the global guard enabled, use @Public() to mark exceptions:

import { Public } from '@nebulr-group/bridge-nestjs';

@Controller()
export class AppController {
  @Get('health')
  @Public()
  healthCheck() {
    return { status: 'ok' };
  }
}

Apply the guard to specific controllers:

import { Controller, Get, UseGuards } from '@nestjs/common';
import { BridgeAuthGuard, CurrentUser, BridgeUser } from '@nebulr-group/bridge-nestjs';

@Controller('items')
@UseGuards(BridgeAuthGuard)
export class ItemsController {
  @Get()
  findAll(@CurrentUser() user: BridgeUser) {
    return { message: 'Protected', user: user.email };
  }
}

Apply the guard to specific routes:

@Controller('items')
export class ItemsController {
  @Get()
  findAll() {
    return { message: 'Public endpoint' };
  }

  @Get('private')
  @UseGuards(BridgeAuthGuard)
  findPrivate(@CurrentUser() user: BridgeUser) {
    return { message: 'Protected endpoint', user: user.email };
  }
}

The Bridge NestJS plugin supports two authentication paths:

  1. User JWT: sent via the Authorization: Bearer <token> header. This is the standard path for browser-based users.
  2. API token: sent via the x-api-key header as a JWT. This is for server-to-server communication and programmatic access.

The guard checks both headers. If an API token is present, it is verified by introspection: the guard POSTs the token to the Bridge, which checks both the signature and the backing record (not revoked, not expired) and returns the token’s claims. Those verified claims are attached to req.bridgeApiToken. If only a Bearer token is present, the request follows the standard user JWT path, verified locally against Bridge’s JWKS endpoint.

API tokens only. @RequirePrivilege() is an API token’s scope, for machine callers (x-api-key). It is not a gate on a person: a user JWT is not checked against it. Gate people with @RequireFeatureFlag() and a flag rule on a privilege.

When an API token is verified, req.bridgeApiToken is set with these claims:

interface ApiTokenClaims {
  sub: string;              // Token subject identifier
  appId: string;            // App ID the token was issued for
  tenantId: string | null;  // Tenant ID (null for app-level tokens)
  type: 'api';              // Always 'api' for API tokens
  privileges: string[];     // Privilege strings (e.g. ['USER_READ', 'TENANT_WRITE'])
}

Use @RequirePrivilege() to enforce that an API token has a specific privilege:

import { Controller, Get } from '@nestjs/common';
import { RequirePrivilege } from '@nebulr-group/bridge-nestjs';

@Controller('users')
export class UsersController {
  @Get()
  @RequirePrivilege('USER_READ')
  listUsers() {
    // API tokens must have USER_READ privilege
    // API tokens only: a user JWT is not checked against it
  }

  @Post()
  @RequirePrivilege('USER_WRITE')
  createUser() {
    // API tokens must have USER_WRITE privilege
  }
}

Use @AcceptAuth() to restrict which authentication type an endpoint accepts:

import { AcceptAuth } from '@nebulr-group/bridge-nestjs';

// Only user JWTs accepted; API tokens get 401
@AcceptAuth('jwt')

// Only API tokens accepted; user JWTs get 401
@AcceptAuth('api_token')

// Both accepted (default when decorator is omitted)
@AcceptAuth('both')

The AuthType is 'jwt' | 'api_token' | 'both'.

Endpoints that accept both user JWTs and API tokens (default behavior):

import { Controller, Get, Req } from '@nestjs/common';
import { RequirePrivilege, CurrentUser, BridgeUser } from '@nebulr-group/bridge-nestjs';
import { Request } from 'express';

@Controller('users')
export class UsersController {
  @Get()
  @RequirePrivilege('USER_READ')
  listUsers(@CurrentUser() user: BridgeUser, @Req() req: Request) {
    if (req.bridgeApiToken) {
      // Authenticated via API token
      console.log('API token tenant:', req.bridgeApiToken.tenantId);
      console.log('API token privileges:', req.bridgeApiToken.privileges);
      return this.usersService.findAll();
    }

    // Authenticated via user JWT
    return this.usersService.findByTenant(user.tenantId);
  }
}

Endpoints that only accept API tokens:

@Controller('integrations')
export class IntegrationsController {
  @Post('sync')
  @AcceptAuth('api_token')
  @RequirePrivilege('TENANT_WRITE')
  syncData(@Req() req: Request) {
    const { tenantId, privileges } = req.bridgeApiToken!;
    return this.syncService.run(tenantId);
  }
}

Endpoints that only accept user JWTs (reject API tokens):

@Controller('account')
export class AccountController {
  @Get('profile')
  @AcceptAuth('jwt')
  getProfile(@CurrentUser() user: BridgeUser) {
    // Only accessible with a user JWT
    return { email: user.email, role: user.role };
  }
}

Who may call an endpoint is a flag whose rule names a privilege, e.g. privileges contains "USER_WRITE" (in the default setup ADMIN and OWNER hold it). Read the app’s real roles and privileges first (list_roles / bridge role list); prefer a privilege rule over a role rule (user.role eq "ADMIN" only when you mean the role itself). contains is exact membership.

import { Controller, Get, UseGuards } from '@nestjs/common';
import { BridgeAuthGuard, RequireFeatureFlag, CurrentUser, BridgeUser } from '@nebulr-group/bridge-nestjs';

@Controller('admin')
@UseGuards(BridgeAuthGuard)
@RequireFeatureFlag('admin-area') // rule: privileges contains "USER_WRITE"
export class AdminController {
  @Get('dashboard')
  getDashboard(@CurrentUser() user: BridgeUser) {
    return { message: 'Admin dashboard', admin: user.email };
  }

  @Get('settings')
  @RequireFeatureFlag('admin-settings') // rule: privileges contains "TENANT_WRITE"
  getSettings(@CurrentUser() user: BridgeUser) {
    return { settings: 'sensitive data' };
  }
}

Route-level decorators override controller-level decorators. The guard uses getAllAndOverride from NestJS Reflector, so the most specific decorator wins.