Skip to main content

Route Groups

Route groups let you register the same models under multiple URL prefixes, each with its own middleware stack and authentication behavior. This enables hybrid routing where different parts of your application serve the same data with different access rules.

Route Registration​

Routes are registered automatically. Define your route groups in the RhinoConfig you pass to RhinoModule.forRoot(), then call applyRhinoRouting() in main.ts:

src/main.ts
import { NestFactory } from '@nestjs/core';
import { applyRhinoRouting } from '@rhino-dev/rhino-nestjs';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
applyRhinoRouting(app, { prefix: 'api' });
await app.listen(3000);
}
bootstrap();

applyRhinoRouting() reads your configuration and registers all CRUD routes, auth routes, invitation routes, and nested operation routes for every group automatically.

Configuration​

Define route groups in src/rhino.config.ts. Middleware entries are NestJS middleware classes (Type<NestMiddleware>), not string names:

src/rhino.config.ts
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';

// Inside the RhinoConfig object passed to RhinoModule.forRoot()
models: {
posts: { model: 'post' },
comments: { model: 'comment' },
categories: { model: 'category' },
},

routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
admin: {
prefix: 'admin',
tenant: false,
models: '*',
},
public: {
prefix: 'public',
skipAuth: true,
models: ['categories'],
},
},

multiTenant: {
enabled: true,
organizationIdentifierColumn: 'slug',
},

Structure​

A route group (RouteGroupConfig) accepts:

PropertyTypeDefaultDescription
prefixstring—URL prefix for all routes in this group (e.g., ':organization', 'admin', '')
domainstring—Optional host constraint (see Domain Constraints)
middlewareType<NestMiddleware>[][]Middleware applied to all routes in this group
models'*' | string[]—'*' for all registered models, or an array of specific model slugs
skipAuthbooleanfalseSkip the JWT guard for this group (used by the reserved public group)
authbooleanfalseRegister a group-tagged auth route set (see Group membership & auth)
hooksType<AuthLifecycleHooks> | AuthLifecycleHooks—Per-group lifecycle hooks (see Group membership & auth)
tenantbooleaninferredWhether the group is org-scoped. When omitted, a group is a tenant group iff multi-tenancy is enabled; set tenant: false for org-less groups (e.g. admin, driver) when multi-tenancy is on. tenant: false also lifts the resource-scope resolver's tenant requirement for the group — see Tenant Boundary

Reserved Group Names​

Two group names have special behavior:

  • tenant -- Rhino detects this name and:

    • Registers invitation CRUD routes under the tenant prefix
    • Registers the nested operations endpoint under the tenant prefix
    • The middleware (ResolveOrganizationMiddleware) sets req.organization, enabling automatic organization scoping
  • public -- Rhino detects this name and:

    • Implies skipAuth: true, so the JWT guard is skipped for all routes in this group

Any other group name (e.g., 'driver', 'admin', 'default') is treated as a standard authenticated group.

Route Naming​

All routes are named with the pattern rhino.{groupKey}.{modelSlug}.{action}:

rhino.tenant.posts.index
rhino.tenant.posts.store
rhino.admin.posts.show
rhino.public.categories.index

Registration Order​

Route groups with literal prefixes (e.g., admin, public) are registered before groups with parameterized prefixes (containing :, e.g., :organization). This prevents parameterized routes from capturing requests meant for literal prefixes.

Domain Constraints​

A route group can be constrained to a specific host with the optional domain key, so two groups can share the same prefix while living on different hosts.

src/rhino.config.ts
routeGroups: {
// Only matches requests to admin.example.com
admin: {
prefix: '',
domain: 'admin.example.com',
models: '*',
},
},
domain valueBehavior
omittedMatches any host (default, fully backward compatible)
'admin.example.com'Group's routes match only that exact host; other hosts get a 404
'{organization}.example.com'Parameterized; the captured {organization} feeds org resolution like a path-prefix tenant param

domain and prefix are independent and combine. A parameterized domain binds the captured subdomain exactly like the path-prefix :organization, so subdomain multi-tenancy works with no extra wiring (see Multi-Tenancy → Subdomain Mode).

Group membership & auth​

By default a route group only chooses a URL/host context and a permission source — it is not an access boundary, and auth (/api/auth/*) is group-blind. Three opt-in, fully backward-compatible features turn the group into a first-class boundary. With all flags off, behavior is byte-for-byte what it is today.

Group membership​

A migration adds a nullable route_group column to user_roles (and makes organization_id nullable, since non-tenant groups like admin/driver have no org). A membership row is now keyed by (user, route_group, organization, role).

route_group valueMeaning
NULL (absent)Wildcard — member of every group (the back-compat default)
'driver'Membership scoped to the driver group only

Enforcement is gated by the master flag on auth:

src/rhino.config.ts
RhinoModule.forRoot({
auth: { enforceGroupMembership: false }, // default OFF
// ...
})
  • Off (default): no membership check; the permission source is the existing org-presence heuristic (see Permission Resolution by Group).
  • On: after authentication, the user must hold a user_roles row whose route_group matches the request's group (a NULL/absent row is a wildcard match) and, for tenant groups, the resolved organization. No match → 403. Permissions then resolve from that matching membership row (per (group, org)), not the heuristic. The default/empty-prefix group is itself a first-class membership dimension, so enforcement applies to it uniformly.

Membership is the coarse gate (may you enter the group at all); permissions remain the fine check (what may you do). The public group skips both.

No backfill required

A NULL route_group is a wildcard, so enabling enforcement never locks out existing rows. Scope rows to concrete groups only when you want to restrict them.

Group-aware auth​

Set auth: true on a group to register the full auth route set — login, logout, password/recover, password/reset, register — under that group's prefix/domain, tagged with the group's name (Decision 9.A). The matched route makes the group unambiguous, so the controller knows which group is signing in.

src/rhino.config.ts
routeGroups: {
driver: {
prefix: 'driver',
auth: true, // adds POST /api/driver/auth/login, …/logout, …/register, …/password/*
tenant: false,
models: ['trips'],
},
},
  • The legacy unprefixed /api/auth/* set always remains and maps to the default/no-group case, preserving today's behavior for apps that don't opt in.
  • A group with auth and a domain gets …/auth/login on that host; with a prefix it gets {prefix}/auth/login. Both carry the group's name.
  • public is never auth-enabled.
An empty-prefix, no-domain auth group is the default auth

A group with auth: true, an empty prefix, and no domain is indistinguishable from the legacy /api/auth/* set, so Rhino resolves the default auth paths to that group (keeping skipAuth semantics) and adopts its hooks — it does not register a second, colliding route, even when a host-claiming empty-prefix domain group also exists. If two or more auth-enabled groups have an empty prefix and no domain, they are genuinely indistinguishable and Rhino throws a boot-time RouteGroupConflictError. Give each a distinct prefix or domain.

Lifecycle hooks​

A group may declare an optional hooks provider implementing AuthLifecycleHooks (a class resolved via Nest DI, or a plain object). Each method runs after its auth action succeeds, receiving an AuthHookContext ({ user, routeGroup, organization?, token?, request }):

src/auth/driver-auth.hooks.ts
import { Injectable } from '@nestjs/common';
import { AuthLifecycleHooks, AuthHookContext, RhinoAuthRejected } from '@rhino-dev/rhino-nestjs';

@Injectable()
export class DriverAuthHooks implements AuthLifecycleHooks {
async afterLogin(ctx: AuthHookContext): Promise<void> {
if (!ctx.user.driver?.active) {
// Revokes the just-issued token and returns 403.
throw new RhinoAuthRejected(403, 'Driver account is suspended.');
}
}
}
src/rhino.config.ts
routeGroups: {
driver: {
prefix: 'driver',
auth: true,
hooks: DriverAuthHooks, // provided via Nest DI
tenant: false,
models: ['trips'],
},
},
EventFires afterCan reject?
afterLoginsuccessful login (token issued)yes — revokes the token, returns the status
afterRegisterinvitation-accept registrationyes — revokes the token, returns the status
afterLogoutlogoutyes (token is already gone; returns the status)
afterPasswordResetpassword reset completedyes
afterPasswordRecoverrecovery email requestedruns, but the rejection is swallowed (see note)

A hook rejects by throwing RhinoAuthRejected(status = 403, message) (or any HttpException). For token-issuing actions (login, register) the controller revokes the just-issued token and returns the given status; for the others it returns the status with no side effects. Default status is 403.

afterPasswordRecover rejections are swallowed

The recover action still runs the hook (so side effects like auditing or throttling happen), but swallows any rejection — it always returns the same uniform "recovery email sent" response whether or not the email exists. Surfacing the hook's status would turn recover into an email-enumeration oracle. Reject semantics are preserved for afterLogin/afterRegister/afterLogout/afterPasswordReset.

Token revocation requires a RevokedToken model

On rejection of afterLogin/afterRegister the controller always drops the token from the response, and attempts to denylist it via a RevokedToken Prisma model (token, createdAt). With no such model the revocation is logged and skipped — provision RevokedToken and use short-TTL JWTs (auth.jwtExpiresIn) if you rely on revocation.

Invitations carry the group​

When a group is an access boundary, invitations record which group the invitee joins:

  • Invite creation stores a route_group (with an org only for tenant groups — non-tenant group invites store a NULL org).
  • Accept populates the user_roles membership with that route_group (+ org + role), then fires the group's afterRegister hook.
  • You cannot invite into the public group. A forged/unknown routeGroup (not configured, not public) is rejected with 422 regardless of enforcement.
  • When enforcement is on, a coarse membership gate runs first — the inviter must themselves be a member of the target group (403 on denial; a NULL row is a wildcard) — then the normal permission check.

Examples​

Simple Non-Tenant App​

src/rhino.config.ts
routeGroups: {
default: {
prefix: '',
models: '*',
},
},

Routes: GET /api/posts, POST /api/posts, etc. All routes require authentication.

Simple Multi-Tenant App​

src/rhino.config.ts
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';

routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
},
multiTenant: {
enabled: true,
organizationIdentifierColumn: 'slug',
},

Routes: GET /api/:organization/posts, etc. All routes require auth + organization resolution.

Hybrid Platform​

src/rhino.config.ts
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';

models: {
trips: { model: 'trip' },
trucks: { model: 'truck' },
materials: { model: 'material' },
},

routeGroups: {
// Customer dashboard -- org-scoped
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
// Driver app -- authenticated, not org-scoped
driver: {
prefix: 'driver',
tenant: false,
models: ['trips', 'trucks'],
},
// Admin panel -- authenticated, global access
admin: {
prefix: 'admin',
tenant: false,
models: '*',
},
// Public API -- no auth
public: {
prefix: 'public',
skipAuth: true,
models: ['materials'],
},
},

Generated routes:

GroupURL PatternAuthOrg Scoped
tenant/api/:organization/tripsYesYes
tenant/api/:organization/materialsYesYes
driver/api/driver/tripsYesNo
driver/api/driver/trucksYesNo
admin/api/admin/tripsYesNo
admin/api/admin/materialsYesNo
public/api/public/materialsNoNo

Organization Scoping Behavior​

Organization scoping is implicit, based on the middleware stack:

  • Organization present (set by ResolveOrganizationMiddleware): scoping is applied automatically.
  • Organization absent (no middleware sets it): scoping is skipped, query returns all records.

This means:

  • tenant group routes get org scoping automatically (their middleware sets req.organization).
  • Non-tenant group routes skip org scoping naturally (no middleware sets an org).
  • No configuration flag needed -- the behavior is implicit based on the middleware stack.

Tenant Boundary​

CRUD scoping is implicit, as above — but the resource-scope resolver used by your own controllers cannot guess. ResourceScopeService fails closed: an org-scoped model queried with no ctx.organization throws 403 TENANT_CONTEXT_REQUIRED rather than returning every tenant's rows. In a group that has no organization to resolve, that throw is wrong — so say so:

src/rhino.config.ts
routeGroups: {
tenant: { prefix: ':organization', middleware: [ResolveOrganizationMiddleware], models: '*' },
admin: { prefix: 'admin', tenant: false, models: [] }, // spans every organization
},

In a tenant: false group the resolver applies no organization filter and does not throw. Every other group is unchanged and still fails closed, including the tenant groups in the same app.

The resolver reads the group from ctx.routeGroup. RouteGroupMiddleware already puts it on every request as req.__routeGroup, so a custom controller just passes it through:

src/admin/admin-dashboard.controller.ts
@Controller('admin')
export class AdminDashboardController {
constructor(private readonly scope: ResourceScopeService) {}

@Get('dashboard')
async summary(@Req() req: any) {
const ctx = { user: req.user, routeGroup: req.__routeGroup };
return { tasks_total: await this.scope.count('tasks', ctx) }; // every organization
}
}

Declaring the group also makes its prefix a reserved segment for createTenantRouteRewrite, so /api/admin/dashboard is never mistaken for an organization slug and rewritten (or rejected as an unknown tenant).

A context with no routeGroup — a queued job, a script, a controller that forgets to pass it — keeps failing closed. Code with no request names the group itself ({ routeGroup: 'admin' }) or passes ctx.organization explicitly. Naming a group that is not declared tenant: false still fails closed. See Multi-Tenancy — Naming the group where there is no request.

See Multi-Tenancy — Route Groups Without a Tenant Boundary.

Custom Scoping for Non-Tenant Groups​

For non-tenant groups (e.g., driver, admin), custom data filtering is implemented with a RhinoScope class attached to the model registration via scopes. The scope's apply(where, ctx) augments the Prisma where clause; ctx includes the current user and resolved route group:

src/scopes/TripScope.ts
import type { RhinoScope } from '@rhino-dev/rhino-nestjs';

export class TripScope implements RhinoScope {
apply(where: Record<string, any>, ctx: { user?: any; routeGroup?: string | null }) {
if (ctx.routeGroup === 'driver' && ctx.user?.driverId) {
return { ...where, driverId: ctx.user.driverId };
}
return where;
}
}
src/rhino.config.ts
import { TripScope } from './scopes/TripScope';

models: {
trips: { model: 'trip', scopes: [TripScope] },
},

Permission Resolution​

Two permission sources are used, determined by the route group context:

Route GroupPermission SourceDescription
'tenant'layered, org-scoped(role ∪ granted) − denied per org (see below)
Any otherusers.permissionsUser-level, checked directly on the user model

For a tenant group the permissions are resolved through the layered model: effective = (role ∪ granted) − denied, where role is the shared OrgRolePermission for (org, role), and granted/denied are the per-user deltas (grantedPermissions / deniedPermissions) on userRoles. The legacy userRoles.permissions is still honored as an allow layer.

Deny always wins — a permission in deniedPermissions is denied even under a role *. The decision is deterministic based on the presence of an organization in the request context. See Layered Permissions for the full model, the required Prisma schema, and the npx rhino permissions-migrate command.