@feedmepos/hrm-actionguard
v1.3.0
Published
NestJS Action Guard for HRM permission system
Keywords
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-actionguardUsage
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:
ActionGuardModule.forRoot({ grpcUrl, grpcMode })PERMISSION_CHECK_GRPC_URL/PERMISSION_CHECK_GRPC_MODEPERMISSION_CHECK_TARGETdefault
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. |
| coverSubjectMode? | 'or' | 'directOverride' | Optional cover fallback strategy. Defaults to or. Use directOverride for report access defaults where a direct per-report rule should override the all-report cover rule. |
| skipValidation? | boolean | Skip permission check (allow through regardless — logs a warning) |
| skipAudit? | boolean | Skip writing the audit log row. Defaults to false (row written). The permission check always runs regardless — set true only to suppress logging for high-frequency, low-value reads (e.g. list/refresh GETs). Mutations and security-sensitive operations should keep the default. |
| eventKey? | AuditEventKey | ((ctx: OperationContext) => AuditEventKey) | Stable, domain-owned request-intent key in <entity>.<verb> form, for example inventory-item.adjust-stock. Domains may introduce keys without changing ActionGuard. Pass a resolver function for endpoints that handle multiple distinct events and need request data to determine which one occurred. |
| eventParams? | EventParams | ((ctx: OperationContext) => EventParams) | Flat string/number values stored with the event. Use label as the preferred human-readable entity anchor; domain-specific values are available to matching portal translations. |
| entityId? | string | ((ctx: OperationContext) => string) | Target record identifier, promoted for entity timeline queries |
| operationLabel? | string | ((ctx: OperationContext) => string) | Deprecated human-readable fallback for unmigrated external consumers |
| 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). |
| context? | Record<string, unknown> | ((ctx: OperationContext) => Record<string, unknown>) | Evaluation context for a condition-matched subject — computes the scalar fields the subject's ConditionBuilder.contextSchema declares (e.g. reportAgeDays for report access-window rules). See @feedmepos/hrm-permission → "condition builders". Errors are caught — nothing is sent on throw; the backend then decides fail-open vs fail-closed based on whether a real restriction is configured (see checkAccess). |
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,
})
// Dynamic report subject with all-report fallback only when no direct report rule exists
@Action({
level: Permission.Level.business,
subject: ({ params }) => `business::report::reports::${params.permissionKey}`,
coverSubject: Permission.Subject.Business.report_reports_allDefaultReports,
coverSubjectMode: 'directOverride',
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. Resolve traceId: active OTel span's traceId when the process runs
instrumented, else a random 32-hex-char id in the same shape
(correlates audit events of one request). Which path was taken is logged
once per process — grep an environment's logs for "[ActionGuard] trace ids"
to see whether its ids are real span ids
7. gRPC CheckPermission → hrm-backend (8 MB send limit)
(constructAbility + checkAccess + audit access event)
8. Retry on transient errors (with exponential backoff)
9. Reconstruct CASL Ability from gRPC response
10. Inject request.user.ability = { [level]: Ability }
11. Stash audit context (traceId, actor, subject…) for the completion
interceptor (granted, non-skipAudit requests only)
12. Return granted/denied
After the handler settles (mutations only — POST/PUT/PATCH/DELETE):
13. Completion interceptor composes status (success, or failed + error
message), duration, and any staged entity records
14. gRPC RecordOperation → hrm-backend writes the operation event
(fire-and-forget: audit failure never affects the response)Operation Labels
Deprecated.
operationLabelis a display fallback kept for consumers that have not migrated yet. New code should useeventKey,eventParams, andentityId(see the@Action(metadata)table above) — they give the audit portal a stable, structured identity to render instead of a free-text string. TheOperationContextshape documented below is still current:eventKey,eventParams, andentityIdresolver functions receive the same context object.
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.usermay beundefinedon 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,
eventKey: 'team-member.list',
},
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 |
| coverSubjectMode? | 'or' | 'directOverride' | Cover fallback strategy. Defaults to or; use directOverride when a direct report rule should override the cover rule. |
| skipValidation? | boolean | Skip permission check (allow through — logs outcome as skipped in audit log) |
| skipAudit? | boolean | Skip writing the audit log row. Defaults to false. Permission check always runs — set true to suppress logging for high-frequency, low-value reads. |
| eventKey? | AuditEventKey | ((ctx: OperationContext) => AuditEventKey) | Stable, domain-owned request-intent key in <entity>.<verb> form, for example inventory-item.adjust-stock. Domains may introduce keys without changing ActionGuard. Pass a resolver function for endpoints that handle multiple distinct events and need request data to determine which one occurred. |
| eventParams? | EventParams | ((ctx: OperationContext) => EventParams) | Flat string/number values stored with the event. Use label as the preferred human-readable entity anchor; domain-specific values are available to matching portal translations. |
| entityId? | string | ((ctx: OperationContext) => string) | Target record identifier, promoted for entity timeline queries |
| operationLabel? | string | ((ctx: OperationContext) => string) | Deprecated human-readable fallback for unmigrated consumers — prefer eventKey/eventParams/entityId |
Dynamic subjects and event metadata
// 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,
coverSubjectMode: 'directOverride',
eventKey: 'custom-report.view',
entityId: ({ params }) => 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', // deprecated legacy fallback — prefer eventKey/eventParams/entityId (see above)
},
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',
eventKey: '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,
audit,
PERMISSION_CHECK_PROTO_PATH,
PERMISSION_CHECK_PACKAGE_NAME,
} from '@feedmepos/hrm-actionguard/nestjs';
import type { ActionMetadata, OperationRecordPayload, ChangeNode } from '@feedmepos/hrm-actionguard/nestjs';
// Nuxt / Nitro integration
import {
withActionGuard,
audit,
initActionGuardClient,
getActionGuardClient,
closeActionGuardClient,
} from '@feedmepos/hrm-actionguard/nuxt';
import type { ActionGuardUser, ActionGuardConfig } from '@feedmepos/hrm-actionguard/nuxt';audit.record({ eventKey, entityId, params, before, after, changeNodes }) stages an entity snapshot
inside a guarded mutation handler (any call depth, synchronous, no I/O); it is sent
with the request's operation event when the handler settles. Credential-shaped keys
(password, token, secret, …) are redacted automatically; before, after, and
changeNodes are each gzipped individually (same "gzip:" convention as the request
body) once their JSON exceeds 1 MB.
Convention: fetch before you mutate, record what you fetched.
changeNodes is an optional array of display-ready change summaries (ChangeNode[]), where
each node represents a human-readable diff row (e.g. "Role: Manager → Staff"). Producers
resolve IDs to display names at write time; the portal renders them as a dumb table,
falling back to raw before/after JSON diff when changeNodes is absent.
Whole-Request Size Budget
A single RecordOperation gRPC call carries every record staged during the mutation
(one per audit.record() call), so fields that are individually under the per-field
cap above can still sum past the gRPC channel's whole-message send limit (8 MB). Before
sending, recordOperation() sums the byte length of every string field already built
and, if the total exceeds that budget (minus headroom for protobuf framing), repeatedly
replaces the single largest before/after/changeNodes field across all records with
the same oversized-field placeholder used for individually oversized fields — never
eventKey/entityId/params/status, which are required for correlation. This trades
a few large snapshots for still delivering the operation event, instead of losing the
whole request to a RESOURCE_EXHAUSTED gRPC error.
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 preserveActionGuard.name.@Inject()decorators — esbuild does not supportemitDecoratorMetadata, sodesign:paramtypesis not emitted. Constructor params must use explicit@Inject().zlibexternalized —zlibis a Node.js built-in used for gzip compression. It must be listed inbuild.rollupOptions.externalso Vite does not attempt to bundle it.
Related Packages
- @feedmepos/hrm-permission — Permission service and CASL integration
- @casl/ability — Authorization library
