npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@feedmepos/hrm-actionguard

v1.1.0

Published

NestJS Action Guard for HRM permission system

Downloads

1,593

Readme

@feedmepos/hrm-actionguard

NestJS Guard + Decorator for declarative permission control, built on CASL.

Sends a gRPC CheckPermission call to hrm-backend for permission verification and audit logging. By default, the package connects to the internal hr-backend:5001 over insecure gRPC; set PERMISSION_CHECK_TARGET=hr-backend to route through the remote SSL endpoint instead, or PERMISSION_CHECK_TARGET=internal to explicitly opt into the default insecure path.

Installation

pnpm add @feedmepos/hrm-actionguard

Usage

Module Setup

import { ActionGuardModule } from '@feedmepos/hrm-actionguard/nestjs';

@Module({
	imports: [
		ActionGuardModule.forRoot({
			// grpcUrl defaults to:
			// - PERMISSION_CHECK_GRPC_URL env, if set
			// - otherwise PERMISSION_CHECK_TARGET=hr-backend: 'hr-backend-grpc.feedmeapidev.com:443'
			// - otherwise PERMISSION_CHECK_TARGET=internal (or unset): 'hr-backend:5001'
			//
			// grpcMode defaults to:
			// - PERMISSION_CHECK_GRPC_MODE env, if set ('ssl' | 'insecure')
			// - otherwise PERMISSION_CHECK_TARGET=hr-backend: 'ssl'
			// - otherwise PERMISSION_CHECK_TARGET=internal (or unset): 'insecure'
			//
			// token defaults to HRM_INTERNAL_TOKEN env → PERMISSION_CHECK_TOKEN_DEFAULT
		}),
	],
})
export class AppModule {}

That's it. Inside the cluster (and any environment where hr-backend:5001 is reachable) the default is correct. Set PERMISSION_CHECK_TARGET=hr-backend when the caller has no internal hr-backend service and needs to use the shared dev endpoint.

Environment variables

| Variable | Purpose | Default | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | HRM_INTERNAL_TOKEN | Authenticates gRPC calls to hrm-backend. Auto-read when not passed explicitly. | PERMISSION_CHECK_TOKEN_DEFAULT ('Greetings_from_hrm') | | PERMISSION_CHECK_TARGET | Selects the preset endpoint: hr-backend (remote SSL) or internal (insecure hr-backend:5001). Unset = internal. | unset | | PERMISSION_CHECK_GRPC_URL | Manual override for the gRPC server address. Wins over the target default. | derived from PERMISSION_CHECK_TARGET | | PERMISSION_CHECK_GRPC_MODE | Manual override for the gRPC channel credentials mode. Must be ssl or insecure. | derived from PERMISSION_CHECK_TARGET |

Target defaults:

| PERMISSION_CHECK_TARGET | URL | gRPC mode | | ------------------------- | -------------------------------------- | ---------- | | (unset) | hr-backend:5001 | insecure | | internal | hr-backend:5001 | insecure | | hr-backend | hr-backend-grpc.feedmeapidev.com:443 | ssl |

Any other non-empty value throws on startup so typos fail loudly. An empty string is treated the same as unset.

Precedence is:

  1. ActionGuardModule.forRoot({ grpcUrl, grpcMode })
  2. PERMISSION_CHECK_GRPC_URL / PERMISSION_CHECK_GRPC_MODE
  3. PERMISSION_CHECK_TARGET default

For local HRM development where you run hrm-backend on your own machine, set PERMISSION_CHECK_GRPC_URL=localhost:5001. The default mode is insecure, so no PERMISSION_CHECK_GRPC_MODE override is needed — provided PERMISSION_CHECK_TARGET is unset or internal. If PERMISSION_CHECK_TARGET=hr-backend is also set, the resolved mode stays ssl, so also set PERMISSION_CHECK_GRPC_MODE=insecure.

Overriding defaults

// Synchronous override
ActionGuardModule.forRoot({
	token: 'override-token',
	grpcUrl: 'custom-host:5001',
	grpcMode: 'insecure',
});

Async Configuration

ActionGuardModule.forRootAsync({
	imports: [ConfigModule],
	inject: [ConfigService],
	useFactory: (config: ConfigService) => ({
		token: config.get('HRM_INTERNAL_TOKEN'),
		// Optional explicit overrides:
		grpcUrl: config.get('PERMISSION_CHECK_GRPC_URL'),
		grpcMode: config.get<'ssl' | 'insecure'>('PERMISSION_CHECK_GRPC_MODE'),
		timeoutMs: 8000,
		maxRetries: 3,
	}),
});

Declare Permissions at Controller / Method Level

import { ActionGuard, Action } from '@feedmepos/hrm-actionguard/nestjs';

// Controller level — all methods inherit
@Action({
	level: 1, // 0=FeedMe, 1=Business, 2=Restaurant
	subject: 'permission',
	action: 'manage',
})
@UseGuards(AuthGuard('firebase'), ActionGuard)
@Controller('portal/businesses/:businessId/users')
export class UserController {
	@Get()
	async getUsers() {}
}

Access Ability in Controller

After the guard passes, ability is injected into request.user.ability:

@Get()
async getData(@Req() req: Request) {
  const ability = req.user?.ability[1]; // business level
  const canDelete = ability?.can('delete', { subjectType: 'role' });
}

API

@Action(metadata)

| Param | Type | Description | | ----------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | level | 0 \| 1 \| 2 | Permission level: 0=FeedMe, 1=Business, 2=Restaurant | | subject | string \| ((ctx: OperationContext) => string) | Permission subject, e.g. Permission.Subject.Business.hrm_teamMember. Pass a function for dynamic subjects resolved from request context. | | action | string | CASL action: "manage", "read", "create", "update", "delete" | | field? | string | Optional field-level permission | | coverSubject? | string \| ((ctx: OperationContext) => string) | Optional OR fallback subject — access granted if user can perform action on either subject or coverSubject. Accepts a resolver function same as subject. | | skipValidation? | boolean | Skip permission check (allow through regardless — logs a warning) | | operationLabel? | string \| ((ctx: OperationContext) => string) | Human-readable label surfaced in audit log metadata | | requestBody? | Record<string, unknown> \| ((ctx: OperationContext) => Record<string, unknown>) | Override for the request body forwarded to hr-backend for audit logging. Defaults to request.body. Use a function to send only the fields relevant to auditing. Errors are caught — nothing is sent on throw (fail-safe). |

Dynamic Subjects

subject and coverSubject accept a resolver function (ctx: OperationContext) => string for cases where the subject depends on request context:

// Static subject
@Action({
  level: Permission.Level.business,
  subject: Permission.Subject.Business.hrm_teamMember,
  action: Permission.Action.read,
})

// Dynamic subject resolved from route params
@Action({
  level: Permission.Level.business,
  subject: ({ params }) => `business::report::reports::${params.permissionKey}`,
  action: Permission.Action.read,
})

// Dynamic coverSubject as fallback
@Action({
  level: Permission.Level.business,
  subject: ({ params }) => `business::report::reports::${params.permissionKey}`,
  coverSubject: 'business::report::accessInsight',
  action: Permission.Action.read,
})

The resolver receives the same OperationContext as operationLabel. See the Operation Labels section for the full context shape.

ActionGuardModule.forRoot(config?) / forRootAsync(config)

| Config | Type | Default | Description | | --------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | token? | string | HRM_INTERNAL_TOKEN env → PERMISSION_CHECK_TOKEN_DEFAULT | HRM internal token for gRPC auth | | grpcUrl? | string | PERMISSION_CHECK_GRPC_URL env → target default URL | gRPC server address | | grpcMode? | 'ssl' \| 'insecure' | PERMISSION_CHECK_GRPC_MODE env → ssl when PERMISSION_CHECK_TARGET=hr-backend, insecure when internal or unset | gRPC channel credentials mode | | timeoutMs? | number | 5000 | Per-call gRPC deadline in milliseconds | | maxRetries? | number | 2 | Max retry attempts for transient gRPC errors (DEADLINE_EXCEEDED, etc) | | retryDelayMs? | number | 500 | Initial retry delay in ms (doubles each attempt — exponential backoff) |

Retry Behavior

Transient gRPC errors are retried automatically with exponential backoff. The following status codes trigger retries:

  • DEADLINE_EXCEEDED (4)
  • UNAVAILABLE (14)
  • RESOURCE_EXHAUSTED (8)
  • ABORTED (10)

Non-retryable errors (e.g. PERMISSION_DENIED, NOT_FOUND, INVALID_ARGUMENT) fail immediately.

With defaults (maxRetries: 2, retryDelayMs: 500), a failing call will attempt up to 3 times total with delays of 500ms and 1000ms between attempts.

Execution Flow

AuthGuard → ActionGuard
  1. Read @Action metadata from controller/method
  2. Resolve subject/coverSubject functions (if any)
  3. Resolve operationLabel function (if any)
  4. Resolve requestBody: use @Action override when set, else request.body
  5. Serialize requestBody:
       ≤ 1 MB → plain JSON string
       > 1 MB → gzip-compressed, base64-encoded with "gzip:" prefix
  6. gRPC CheckPermission → hrm-backend (8 MB send limit)
     (constructAbility + checkAccess + auditLog)
  7. Retry on transient errors (with exponential backoff)
  8. Reconstruct CASL Ability from gRPC response
  9. Inject request.user.ability = { [level]: Ability }
 10. Return granted/denied

Operation Labels

The optional operationLabel field provides a human-readable description of the operation. The resolved string is stored in metadata.operationLabel in the ClickHouse audit log.

Static Label

@Action({
  level: Permission.Level.business,
  subject: Permission.Subject.Business.inventory,
  action: Permission.Action.update,
  operationLabel: 'Update inventory stock',
})

Dynamic Label

Pass a function to build the label from request context. The function receives an OperationContext:

| Field | Type | Description | | -------- | ------------------------------------------------- | ----------------------------------------------------- | | params | Record<string, string> | Route params, e.g. { businessId, restaurantId } | | body | Record<string, unknown> \| undefined | Parsed request body | | query | Record<string, string \| string[]> \| undefined | Query string params | | path | string | Full request path, e.g. "/api/businesses/123/users" | | method | string | HTTP method, e.g. "GET", "PUT" | | user | { uid: string; role?: string } | Authenticated user |

@Action({
  level: Permission.Level.restaurant,
  subject: Permission.Subject.Restaurant.inventory_stock,
  action: Permission.Action.update,
  operationLabel: ({ params }) => `Update stock for outlet ${params.restaurantId}`,
})

@Action({
  level: Permission.Level.business,
  subject: Permission.Subject.Business.hrm_employee,
  action: Permission.Action.update,
  operationLabel: ({ body, params }) =>
    `Update employee ${body?.name ?? params.employeeId}`,
})

Note: The label function runs before the user check. ctx.user may be undefined on unauthenticated requests — always guard with optional chaining.

Request Body Forwarding

By default, the full request.body is forwarded to hr-backend and stored in the ClickHouse audit log. Use requestBody in @Action to override which fields are sent.

Gzip Compression

The request body is serialized before being sent over gRPC:

  • ≤ 1 MB: sent as a plain JSON string.
  • > 1 MB: gzip-compressed and base64-encoded with a "gzip:" prefix — e.g. "gzip:<base64>".

The hrm-portal audit log detail view detects the prefix and decompresses transparently. Legacy audit records that stored requestBody as a raw object are also handled.

Overriding the Body

// Send a static subset
@Action({
  level: Permission.Level.business,
  subject: Permission.Subject.Business.hrm_employee,
  action: Permission.Action.update,
  requestBody: { action: 'bulk-import' },
})

// Send only the fields relevant for auditing
@Action({
  level: Permission.Level.restaurant,
  subject: Permission.Subject.Restaurant.inventory_stock,
  action: Permission.Action.update,
  requestBody: ({ body }) => ({ name: body?.name, count: body?.items?.length }),
})

If the function throws, nothing is sent — this prevents raw body leakage when you intended to strip fields.


Nuxt / Nitro Integration

@feedmepos/hrm-actionguard/nuxt provides the same permission control for Nuxt backends (Nitro/H3). The gRPC call, ability reconstruction, audit logging, and env var defaults are identical to the NestJS integration — only the wiring differs.

Architecture

Frontend request
  → 01.auth.ts          (sets event.context.user from Firebase JWT)
  → withActionGuard     (gRPC CheckPermission → hrm-backend)
       ├─ 403 if denied
       └─ event.context.user.ability = rebuilt CASL abilities
  → handler             (runs only when granted)

Setup

1. Initialize the client once at startup

Create a Nitro server plugin. It runs once when the server boots, same as ActionGuardModule.forRoot() in NestJS.

// server/plugins/actionguard.ts
import { initActionGuardClient } from '@feedmepos/hrm-actionguard/nuxt';

export default defineNitroPlugin(() => {
	// Zero config — reads the same env vars as the NestJS module:
	// PERMISSION_CHECK_TARGET, PERMISSION_CHECK_GRPC_URL, HRM_INTERNAL_TOKEN, etc.
	initActionGuardClient();
});

To override specific settings:

initActionGuardClient({
	grpcUrl: 'localhost:5001',
	grpcMode: 'insecure',
	token: 'my-token',
	timeoutMs: 8000,
});

2. Protect API routes with withActionGuard

Replace defineEventHandler with withActionGuard. It requires event.context.user to already be set by a prior auth middleware.

// server/api/businesses/[businessId]/members.get.ts
import { withActionGuard } from '@feedmepos/hrm-actionguard/nuxt';
import { Permission } from '@feedmepos/hrm-permission';

export default withActionGuard(
	{
		level: Permission.Level.business,
		action: Permission.Action.read,
		subject: Permission.Subject.Business.hrm_teamMember,
		operationLabel: 'List team members',
	},
	async (event) => {
		const businessId = event.context.params.businessId;
		return Services.member.list(businessId);
	}
);

Action Metadata

withActionGuard accepts the same Action shape as the NestJS @Action() decorator.

| Field | Type | Description | | ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | level | 0 \| 1 \| 2 | Permission level: 0=FeedMe, 1=Business, 2=Restaurant | | subject | string \| ((ctx: OperationContext) => string) | Permission subject. Pass a function for dynamic subjects resolved from request context. | | action | string | CASL action: "manage", "read", "create", "update", "delete" | | field? | string | Optional field-level permission | | coverSubject? | string \| ((ctx: OperationContext) => string) | OR fallback subject — access granted if user can perform action on either subject or coverSubject | | skipValidation? | boolean | Skip permission check (allow through — logs outcome as skipped in audit log) | | operationLabel? | string \| ((ctx: OperationContext) => string) | Human-readable label surfaced in audit log metadata |

Dynamic subjects and labels

// server/api/businesses/[businessId]/reports/[reportKey].get.ts
import { withActionGuard } from '@feedmepos/hrm-actionguard/nuxt';
import { Permission } from '@feedmepos/hrm-permission';

export default withActionGuard(
  {
    level: Permission.Level.business,
    action: Permission.Action.read,
    // Each custom report has its own subject: business::report::reports::<reportKey>
    subject: ({ params }) => `business::report::reports::${params.reportKey}`,
    // Fallback: grant access if user has access to all custom reports
    coverSubject: Permission.Subject.Business.report_reports_allCustomReports,
    operationLabel: ({ params }) => `View custom report ${params.reportKey}`,
  },
  async (event) => { ... }
)

Accessing Ability in the Handler

After withActionGuard passes, event.context.user.ability contains reconstructed CASL Ability objects keyed by level, identical to req.user.ability in NestJS.

import { withActionGuard } from '@feedmepos/hrm-actionguard/nuxt';
import type { ActionGuardUser } from '@feedmepos/hrm-actionguard/nuxt';
import { Permission } from '@feedmepos/hrm-permission';

export default withActionGuard(
  {
    level: Permission.Level.business,
    action: Permission.Action.read,
    subject: Permission.Subject.Business.hrm_teamMember,
    operationLabel: 'List team members',
  },
  async (event) => {
    const user = event.context.user as ActionGuardUser;
    const ability = user.ability?.[Permission.Level.business];

    // Fine-grained check beyond what withActionGuard already enforced
    const canDelete = ability?.can(Permission.Action.delete, Permission.Subject.Business.hrm_teamMember);

    // Parsed body is cached on context — no need to call readBody() again
    const body = event.context.parsedBody;
    ...
  }
);

Frontend Route Guard (Vue Router)

For mf-billing or any Vue Router based frontend, add a validationManifest to route meta and check it in router.beforeEach. This is a UX guard only — the real security boundary is withActionGuard on the server.

1. Add permission meta to routes

// router/merchant.ts
{
  path: '/businesses/:businessId/members',
  name: 'Team Members',
  meta: {
    validationManifest: {
      level: 1,
      action: 'read',
      subject: 'business::hrm::teamMember',
    }
  },
  component: () => import('@/views/members/index.vue')
}

2. Check in router.beforeEach

// router/index.ts
router.beforeEach(async (to) => {
	const manifest = to.meta?.validationManifest;
	if (!manifest) return;

	try {
		await $fetch('/api/authz/can', {
			method: 'POST',
			body: {
				...manifest,
				routeParams: to.params,
				routePath: to.path,
			},
		});
	} catch (e: any) {
		if (e?.response?.status === 403) return navigateTo('/403');
	}
});

3. Expose a lightweight /api/authz/can endpoint on the backend

// server/api/authz/can.post.ts
import { getActionGuardClient } from '@feedmepos/hrm-actionguard/nuxt';
import type { ActionGuardUser } from '@feedmepos/hrm-actionguard/nuxt';

export default defineEventHandler(async (event) => {
	const user = event.context.user as ActionGuardUser;
	if (!user?.uid) throw createError({ statusCode: 401 });

	const body = await readBody(event);
	const result = await getActionGuardClient().checkPermission({
		userId: user.uid,
		role: user.role ?? '',
		businessId: (body.routeParams?.businessId as string) ?? '',
		restaurantId: (body.routeParams?.restaurantId as string) ?? '',
		level: body.level,
		action: body.action,
		subject: body.subject,
		requestPath: body.routePath ?? '',
		requestMethod: 'GET',
		country: process.env.FEEDME_COUNTRY ?? 'unknown',
		operationLabel: 'Frontend route guard check',
	});

	if (!result.granted) throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
	return { granted: true };
});

Environment Variables

Same env vars as the NestJS module — no extra configuration required for internal cluster deployments.

| Variable | Default | Description | | ---------------------------- | ---------------------- | ---------------------------------------------------------------------- | | HRM_INTERNAL_TOKEN | 'Greetings_from_hrm' | Bearer token for gRPC auth | | PERMISSION_CHECK_TARGET | (unset = internal) | hr-backend (SSL, remote) or internal (insecure, hr-backend:5001) | | PERMISSION_CHECK_GRPC_URL | derived from target | Manual URL override | | PERMISSION_CHECK_GRPC_MODE | derived from target | Manual mode override: ssl | insecure |


Exports

// Types only
import type { Action } from '@feedmepos/hrm-actionguard';

// NestJS Guard, Decorator & Module
import {
	ActionGuard,
	Action,
	ActionGuardModule,
	PERMISSION_CHECK_PROTO_PATH,
	PERMISSION_CHECK_PACKAGE_NAME,
} from '@feedmepos/hrm-actionguard/nestjs';

import type { ActionMetadata } from '@feedmepos/hrm-actionguard/nestjs';

// Nuxt / Nitro integration
import {
	withActionGuard,
	initActionGuardClient,
	getActionGuardClient,
	closeActionGuardClient,
} from '@feedmepos/hrm-actionguard/nuxt';

import type { ActionGuardUser, ActionGuardConfig } from '@feedmepos/hrm-actionguard/nuxt';

Build Notes

This package is built with Vite (esbuild). Three critical configurations:

  • minify: false — esbuild minifies class names to empty strings, causing NestJS to silently skip the guard. Minification must be disabled to preserve ActionGuard.name.
  • @Inject() decorators — esbuild does not support emitDecoratorMetadata, so design:paramtypes is not emitted. Constructor params must use explicit @Inject().
  • zlib externalizedzlib is a Node.js built-in used for gzip compression. It must be listed in build.rollupOptions.external so Vite does not attempt to bundle it.

Related Packages