nestjs-notifications
Recipes

Authorization of the mounted routes

Decide who reaches the inbox, SSE stream, preference center and delivery callbacks with NestJS's own @nestjs/authentication and @nestjs/authorization — by passing decorators or by mounting the controller yourself.

The library ships these HTTP controllers:

ControllerWho mounts itRoutes
Inbox — DatabaseChannelModule.forRoot() / forFeature()the module, by defaultGET/POST/DELETE /notifications…
Inbox — createNotificationsController()yousame
SSE stream — createNotificationsStreamController()youGET /notifications/stream
Preference center — createPreferenceCenterController()youGET/PUT/DELETE /preferences…
Twilio status — createTwilioStatusController()youPOST /webhooks/twilio/status
SES/SNS events — createSesNotificationController()youPOST /webhooks/ses

The library has no access-control options of its own. You decide who reaches these routes with NestJS's first-party @nestjs/authentication and @nestjs/authorization, in one of two ways.

1. decorators

Every module and factory above takes decorators?: ClassDecorator[], class decorators applied in order to the controller it builds:

import { AuthenticationGuard } from '@nestjs/authentication';
import { Can } from '@nestjs/authorization';
import { UseGuards } from '@nestjs/common';

DatabaseChannelModule.forRoot({
  decorators: [UseGuards(AuthenticationGuard), Can(InboxPolicy, 'read')],
});

createNotificationsStreamController({
  resolveRoute: (req) => String(req.user.id),
  decorators: [UseGuards(AuthenticationGuard)],
});

Each registration builds a fresh controller class, so decorators never leak from one registration to another.

2. Mount the controller yourself

The factories return a controller class; put it in your own module's controllers, next to your other routes and whatever protects them. For the inbox, turn the module's auto-mount off with routes: false, or you get two inboxes:

const NotificationsController = createNotificationsController({
  resolveRef: (req) => ({ type: 'User', id: req.user.id }),
  decorators: [UseGuards(AuthenticationGuard)],
});

@Module({
  imports: [DatabaseChannelModule.forFeature({ routes: false })],
  controllers: [NotificationsController],
})
export class InboxModule {}

routes: false is the same option name the other Aviary libraries use to stop a module from mounting anything.

@nestjs/authorization's deny-by-default guard

AuthorizationModule.forRoot() registers a global guard that refuses any route declaring no @Can(). Every controller this library builds is marked open to it (the metadata Can.Anyone() writes), so adopting @nestjs/authorization doesn't lock them. The mark only opens routes with no requirement: a @Can() you pass in decorators is still enforced (401 for a guest, 403 for a user the policy denies).

Production defaults

Nothing refuses to boot here. Each route already checks something on every request:

  • Inbox, SSE stream, preference center — every route that reads or changes data is scoped to the recipient your resolveRef / resolveRoute returns, so a caller only ever reaches their own inbox, stream or preferences. The auto-mounted inbox's default resolver reads req.user.id and answers 401 when there is none. Your own resolvers should throw (e.g. UnauthorizedException) when the request carries no identity. The one unscoped route, GET /preferences/categories, returns your configured category definitions, not anyone's data.
  • Delivery-tracking callbacks are called by the provider, which has no user, so they stay outside any "must be decorated" rule. They authenticate the provider instead, and answer 403 when that fails:
    • the Twilio controller verifies X-Twilio-Signature on every request;
    • the SES controller verifies the SNS message signature (certificate fetched only from an SNS host), requires the TopicArn to be one of your topicArns, and refuses stale timestamps. See Delivery tracking.

Migrating from guards

guards was removed from DatabaseChannelModule's controller options and from every factory:

// before
createNotificationsController({ resolveRef, guards: [AuthGuard] });
DatabaseChannelModule.forRoot({ controller: { guards: [AuthGuard] } });
DatabaseChannelModule.forRoot({ controller: false });

// after
createNotificationsController({ resolveRef, decorators: [UseGuards(AuthGuard)] });
DatabaseChannelModule.forRoot({ decorators: [UseGuards(AuthGuard)] });
DatabaseChannelModule.forRoot({ routes: false });

The old options are refused, not ignored: passing guards to any factory or in controller, or a boolean controller, throws at registration with a pointer to the replacement, so a JavaScript caller (no type checker) never ends up with an inbox that mounts again, or mounts without its guards.

On this page