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-permission

v1.2.0

Published

Permission types, enums, and access checking for FeedMe services.

Readme

@feedmepos/hrm-permission

Permission types, enums, and access checking for FeedMe services.

New to the permission system, or changing one? Read docs/permission-lifecycle.md first — it covers the portal vs POS lanes, where permissions are stored, how they reach each consumer, and what actually happens when you add, rename or delete one. This README is the API reference for the portal lane.

Installation

pnpm add @feedmepos/hrm-permission

Usage

Permission Enums

All subjects are in PermissionSubjectBusinessNamespace, exposed via Permission.Subject.Business.

import {
	Permission,
	PermissionAction,
	PermissionSubjectBusinessNamespace,
} from '@feedmepos/hrm-permission';

// Actions
PermissionAction.manage; // full control
PermissionAction.read;
PermissionAction.create;
PermissionAction.update;
PermissionAction.delete;

// Subjects — use either form, they are the same value
Permission.Subject.Business.hrm_teamMember; // 'business::hrm::teamMember'
PermissionSubjectBusinessNamespace.hrm_teamMember; // same

// Common subjects by module
Permission.Subject.Business.menu_item; // 'business::menu::item'
Permission.Subject.Business.menu_catalog; // 'business::menu::catalog'
Permission.Subject.Business.menu_menuManagement; // 'business::menu::menuManagement'
// ... (see full menu list in the collapsible below)
Permission.Subject.Business.restaurant; // 'business::restaurant'

Permission.Subject.Business.crm_promotion; // 'business::crm::promotion'
Permission.Subject.Business.crm_voucher; // 'business::crm::voucher'
Permission.Subject.Business.crm_membership; // 'business::crm::membership'

Permission.Subject.Business.payment_payoutAccount; // 'business::payment::payoutAccount'
Permission.Subject.Business.payment_paymentOnboarding; // 'business::payment::paymentOnboarding'
Permission.Subject.Business.payment_transactions; // 'business::payment::transactions'
Permission.Subject.Business.payment_settlements; // 'business::payment::settlements'

Permission.Subject.Business.inventory_stockBalance; // 'business::inventory::stockBalance'
Permission.Subject.Business.inventory_ingredient; // 'business::inventory::ingredient'
Permission.Subject.Business.inventory_recipe; // 'business::inventory::recipe'
// ... (see full list in the collapsible below)

Permission.Subject.Business.hrm_employee; // 'business::hrm::employee'
Permission.Subject.Business.hrm_teamMember; // 'business::hrm::teamMember'
Permission.Subject.Business.hrm_auditLog; // 'business::hrm::auditLog'

Permission.Subject.Business.report_createReport; // 'business::report::createReport'
Permission.Subject.Business.report_accessOverview; // 'business::report::accessOverview'
// General
Permission.Subject.Business.profile;
Permission.Subject.Business.restaurant;

// Menu
Permission.Subject.Business.menu_item; // 'business::menu::item'
Permission.Subject.Business.menu_catalog; // 'business::menu::catalog'
Permission.Subject.Business.menu_category; // 'business::menu::category'
Permission.Subject.Business.menu_subCategory; // 'business::menu::subCategory'
Permission.Subject.Business.menu_group; // 'business::menu::group'
Permission.Subject.Business.menu_takeaway; // 'business::menu::takeaway'
Permission.Subject.Business.menu_scheduler; // 'business::menu::scheduler'
Permission.Subject.Business.menu_variant; // 'business::menu::variant'
Permission.Subject.Business.menu_cookingGuide; // 'business::menu::cookingGuide'
Permission.Subject.Business.menu_printRoute; // 'business::menu::printRoute'
Permission.Subject.Business.menu_servingSequence; // 'business::menu::servingSequence'
Permission.Subject.Business.menu_unit; // 'business::menu::unit'
Permission.Subject.Business.menu_ingredient; // 'business::menu::ingredient'
Permission.Subject.Business.menu_recipe; // 'business::menu::recipe'
Permission.Subject.Business.menu_settings; // 'business::menu::settings'
Permission.Subject.Business.menu_publish; // 'business::menu::publish'
Permission.Subject.Business.menu_menuManagement; // 'business::menu::menuManagement'
Permission.Subject.Business.menu_importExport; // 'business::menu::importExport'

// CRM
Permission.Subject.Business.crm_promotion; // 'business::crm::promotion'
Permission.Subject.Business.crm_voucher; // 'business::crm::voucher'
Permission.Subject.Business.crm_membership; // 'business::crm::membership'
Permission.Subject.Business.crm_analytic; // 'business::crm::analytic'
Permission.Subject.Business.crm_tier; // 'business::crm::tier'
Permission.Subject.Business.crm_title; // 'business::crm::title'
Permission.Subject.Business.crm_broadcast; // 'business::crm::broadcast'
Permission.Subject.Business.crm_point; // 'business::crm::point'
Permission.Subject.Business.crm_credit; // 'business::crm::credit'
Permission.Subject.Business.crm_experience; // 'business::crm::experience'
Permission.Subject.Business.crm_game; // 'business::crm::game'
Permission.Subject.Business.crm_mission; // 'business::crm::mission'
Permission.Subject.Business.crm_loyaltyMember; // 'business::crm::loyaltyMember'
Permission.Subject.Business.crm_loyaltySegment; // 'business::crm::loyaltySegment'
Permission.Subject.Business.crm_loyaltyCard; // 'business::crm::loyaltyCard'
Permission.Subject.Business.crm_referral; // 'business::crm::referral'
Permission.Subject.Business.crm_store; // 'business::crm::store'
Permission.Subject.Business.crm_transaction; // 'business::crm::transaction'
Permission.Subject.Business.crm_setting; // 'business::crm::setting'
Permission.Subject.Business.crm_bin; // 'business::crm::bin'
Permission.Subject.Business.crm_marketingMaterial; // 'business::crm::marketingMaterial'

// Payment
Permission.Subject.Business.payment_payoutAccount; // 'business::payment::payoutAccount'
Permission.Subject.Business.payment_paymentOnboarding; // 'business::payment::paymentOnboarding'
Permission.Subject.Business.payment_transactions; // 'business::payment::transactions'
Permission.Subject.Business.payment_settlements; // 'business::payment::settlements'

// Inventory
Permission.Subject.Business.inventory_stock; // 'business::inventory::stock'
Permission.Subject.Business.inventory_stockBalance; // 'business::inventory::stockBalance'
Permission.Subject.Business.inventory_stockAdjustment; // 'business::inventory::stockAdjustment'
Permission.Subject.Business.inventory_stockAdjustmentReason; // 'business::inventory::stockAdjustmentReason'
Permission.Subject.Business.inventory_unitCostHistory; // 'business::inventory::unitCostHistory'
Permission.Subject.Business.inventory_wastageTemplate; // 'business::inventory::wastageTemplate'
Permission.Subject.Business.inventory_closingHistory; // 'business::inventory::closingHistory'
Permission.Subject.Business.inventory_closingTemplate; // 'business::inventory::closingTemplate'
Permission.Subject.Business.inventory_closingDraft; // 'business::inventory::closingDraft'
Permission.Subject.Business.inventory_ingredient; // 'business::inventory::ingredient'
Permission.Subject.Business.inventory_ingredientGroup; // 'business::inventory::ingredientGroup'
Permission.Subject.Business.inventory_recipe; // 'business::inventory::recipe'
Permission.Subject.Business.inventory_unit; // 'business::inventory::unit'
Permission.Subject.Business.inventory_purchaseTransfer; // 'business::inventory::purchaseTransfer'
Permission.Subject.Business.inventory_orderDraftApproval; // 'business::inventory::orderDraftApproval'
Permission.Subject.Business.inventory_transferOut; // 'business::inventory::transferOut'
Permission.Subject.Business.inventory_surcharge; // 'business::inventory::surcharge'
Permission.Subject.Business.inventory_orderTemplate; // 'business::inventory::orderTemplate'
Permission.Subject.Business.inventory_supplier; // 'business::inventory::supplier'
Permission.Subject.Business.inventory_warehouse; // 'business::inventory::warehouse'
Permission.Subject.Business.inventory_publish; // 'business::inventory::publish'
Permission.Subject.Business.inventory_import; // 'business::inventory::import'
Permission.Subject.Business.inventory_integration; // 'business::inventory::integration'

// HRM
Permission.Subject.Business.hrm_employee; // 'business::hrm::employee'
Permission.Subject.Business.hrm_teamMember; // 'business::hrm::teamMember'
Permission.Subject.Business.hrm_auditLog; // 'business::hrm::auditLog'
Permission.Subject.Business.hrm_approvalCode; // 'business::hrm::approvalCode'

// Report
Permission.Subject.Business.report_createReport; // 'business::report::createReport'
Permission.Subject.Business.report_accessOsDashboard; // 'business::report::accessOsDashboard'
Permission.Subject.Business.report_accessOverview; // 'business::report::accessOverview'
Permission.Subject.Business.report_accessInsight; // 'business::report::accessInsight'
Permission.Subject.Business.report_accessSetting; // 'business::report::accessSetting'
Permission.Subject.Business.report_accessIntegration; // 'business::report::accessIntegration'
Permission.Subject.Business.report_reports_allDefaultReports; // 'business::report::allDefaultReports'
Permission.Subject.Business.report_reports_allCustomReports; // 'business::report::allCustomReports'

// Media
Permission.Subject.Business.media_assets; // 'business::media::assets'

checkAccess

import { checkAccess } from '@feedmepos/hrm-permission';
import type { RawRule } from '@casl/ability';

// userPermissions comes from your permission service / store
const userPermissions: RawRule[] = /* ... */;

Basic check (AND — all must pass)

const result = checkAccess(
	[
		{ action: PermissionAction.manage, subject: Permission.Subject.Business.hrm_teamMember },
		{ action: PermissionAction.read, subject: Permission.Subject.Business.hrm_employee },
	],
	userPermissions
);

if (result.granted) {
	// allowed
} else {
	console.log('blocked by', result.decisivePermission);
}

coverSubject — OR fallback

Grants access when the user can perform the action on either subject or coverSubject. Useful for "group covers individual" patterns (e.g. a catch-all report permission covering a specific dynamic report subject).

const result = checkAccess(
	[
		{
			action: PermissionAction.read,
			subject: 'business::report::myDynamicReport',
			coverSubject: Permission.Subject.Business.report_reports_allDefaultReports,
		},
	],
	userPermissions
);

By default, coverSubject uses OR semantics. For report access checks where a report-specific rule should override an all-report default, pass coverSubjectMode: 'directOverride'. In that mode, coverSubject is consulted only when the user has no non-inverted direct rule for the requested subject and action.

const result = checkAccess(
	{
		action: PermissionAction.read,
		subject: 'business::report::reports::closeup',
		coverSubject: Permission.Subject.Business.report_reports_allDefaultReports,
		coverSubjectMode: 'directOverride',
	},
	userPermissions,
	reportAccessWindowBuilder.buildContext?.({ nowAtUtc, reportDate })
);

buildContext is an optional convenience supplied by a domain builder. A consumer may construct the context itself instead, as long as the final object satisfies the builder's required contextSchema fields.

Condition-aware check (single permission + context)

Use this when a rule carries a $in / $eq condition (e.g. restaurant-scoped permissions, or an authored condition-builder restriction like report access windows). Only accepts a single permission because context fields are subject-specific. context is optional — see below for what happens when it's omitted. Works identically on the frontend (against a cached permissions array, no network call) and the backend.

const result = checkAccess(
	{ action: PermissionAction.manage, subject: Permission.Subject.Business.restaurant },
	userPermissions,
	{ restaurantId: 'abc' } // evaluated against rule conditions, e.g. { restaurantId: { $in: ['abc', 'def'] } }
);

// Typical use: filter a list to items the user can access
const accessible = restaurants.filter(
	(r) =>
		checkAccess(
			{ action: PermissionAction.manage, subject: Permission.Subject.Business.restaurant },
			userPermissions,
			{ restaurantId: r.id }
		).granted
);

Omitting context (or passing undefined): whether that's tolerated or denied is derived from the rules, not declared anywhere. If every applicable direct or cover rule that grants this permission carries a condition on a registered condition-builder's ownedFields, a real restriction exists and can't be verified — denied (missing_or_invalid_context). If any applicable rule is unconditioned (e.g. the builder's unrestricted preset) or conditioned on fields no builder owns, context is irrelevant and the type-level result stands (not_enforced). A builder's ownedFields only count toward this derivation for actions its appliesToActions covers (absent = all actions) — a rule conditioned solely on a builder's fields is unconditioned, and thus not_enforced, for an action that builder does not govern. See "Advanced: condition builders" below for the full algorithm.

Return value

interface AccessCheckResult {
	granted: boolean;
	/** The permission requirement that was decisive. */
	decisivePermission: CaslPermission;
	/** The matched CASL rule, or null if nothing matched. Parse `.reason` as JSON for audit source info. */
	decisiveRule: RawRule | null;
}

// The single-permission overload actually returns this superset:
interface ContextAccessCheckResult extends AccessCheckResult {
	/** How condition evaluation participated in the decision. */
	conditionEvaluation: 'matched' | 'missing_or_invalid_context' | 'not_enforced';
}

Adding a New Permission

Tip: You can ask Copilot to do this for you using the add-new-permission skill. Example prompts:

@workspace /add-new-permission add hrm_payroll under the HRM category with manage action
@workspace /add-new-permission add crm_loyaltyCard under CRM category, manage action, behind feature flag crm_loyalty_card
@workspace /add-new-permission add report_exportCsv under Report category with manage action

Adding a portal permission (one admins can toggle in the UI) requires two file changes.

Step 1 — Add the subject enum value

File: packages/permission/src/common/types.ts

export enum PermissionSubjectBusinessNamespace {
	// ... existing entries ...

	// HRM module — follow the module_feature naming pattern
	hrm_payroll = 'business::hrm::payroll',
}

Naming rules:

  • Prefix with the module: crm_, inventory_, hrm_, report_
  • camelCase feature name
  • Value pattern: business::<module>::<feature>

Step 2 — Add it to FullPortalPermissions

File: packages/permission/src/common/full-portal-permissions.ts

export const FullPortalPermissions = {
	// ... existing entries ...

	payroll: {
		label: 'Payroll Management', // shown in the UI permission editor and audit logs
		subject: PermissionSubjectBusinessNamespace.hrm_payroll,
		actions: [PermissionAction.manage],
		category: PermissionCategory.hrm, // General | Inventory | HRM | CRM | Report
		// showByFeatureFlag: 'payroll-beta',                // optional: hides checkbox until flag is on
	},
};

Available categories: PermissionCategory.general, .inventory, .hrm, .crm, .payment, .report, .reports, .customReports, .menu, .media

The permission will now appear as a checkbox in the HRM portal permission editor under the correct category section. Two build steps remain.

Step 3 — Rebuild the package

cd packages/permission && pnpm build

Step 4 — Regenerate Go enums

cd packages/permission && pnpm build:go

Go services consume subjects via the generated packages/permission/go module (portal/pos/casl subpackages). The "Permission Go Enums Check" CI workflow fails the PR if the regenerated output is stale, so commit the diff in the same PR — see packages/permission/go/README.md for the full Go-side picture.


Some features silently require access to other resources to work correctly (e.g. "Team Member Management" needs to read the POS role list to populate a dropdown). Rather than requiring admins to grant both, you define a system permission set that auto-injects the dependency whenever the parent permission is granted.

When to use this

  • Your feature silently requires read access to another resource → use permissions[]
  • You split an existing subject into multiple new granular subjects and existing users should silently get them → use permissionSets[] (backward-compat chaining)
  • If neither applies, skip this — most portal permissions do not need a system set

Subject namespaces in permissions[]

permissions[] accepts both resource subjects (hrm::posRole) and business/module subjects (business::payment::payoutAccount). Both are injected as leaf-level CASL rules and are never shown in the portal UI. Use PermissionSubjectBusinessNamespace directly when the subject already exists there — no separate resource enum needed. Create a resource enum only for subjects with no corresponding portal entry.

permissions[] — inject a read-only dependency

// permission-manifest/hrm/hrm-system-sets.ts
import { HrmResource } from './types';

[`set_${PermissionSubjectBusinessNamespace.hrm_teamMember}`]: {
  key: 'sys:team_member_access',   // stable — never change after deploy, stored in audit logs
  name: 'Team Member Access',      // shown in audit log breadcrumbs
  permissions: [
    {
      label: 'Pos Role',           // subject name only, reused as audit log display label
      subject: HrmResource.hrm_posRole,
      actions: [PermissionAction.read],
    },
  ],
},

Recommended rule: prefer read-only actions in permissions[] — avoid manage where possible. Injecting manage causes two problems: (1) UI bleed — the editor filters out system-injected entries by checking for non-manage actions, so a manage entry renders as an auto-checked checkbox; (2) over-grantmanage is a CASL wildcard covering all actions, silently granting destructive write access the admin never explicitly authorised. If manage access is needed as a side-effect, prefer permissionSets[] chaining instead. Some existing system sets (e.g. hrm-system-sets.ts) inject manage via permissions[] for legacy reasons — new sets should avoid this pattern.

permissionSets[] — chain to another portal permission (backward-compat)

Use when an existing parent permission should silently unlock new granular sibling subjects. Each chained subject gets a synthetic manage rule and its own system set is recursively expanded.

[`set_${PermissionSubjectBusinessNamespace.hrm_payroll}`]: {
  key: 'sys:payroll_access',
  name: 'Payroll Access',
  permissionSets: [
    PermissionSubjectBusinessNamespace.hrm_payroll_approval, // users with payroll:manage get this too
  ],
},

File organisation

When a domain has many system sets, extract them:

permission-manifest/
  hrm/
    types.ts           ← HrmResource enum
    hrm-system-sets.ts
    index.ts
  inventory/
    types.ts           ← InventoryResource enum
    inventory-system-sets.ts
    index.ts

Spread them into SYSTEM_PERMISSION_SETS in system-permission-sets.ts:

export const SYSTEM_PERMISSION_SETS = {
	...INVENTORY_SYSTEM_SETS,
	...HRM_SYSTEM_SETS,
};

Updated checklist (with system set)

| Step | File | Portal permission | Resource dependency | | ------------------------------- | ------------------------------- | :---------------: | :-----------------: | | Add enum value | types.ts | ✅ | ✅ | | Add to FullPortalPermissions | full-portal-permissions.ts | ✅ | ❌ | | Add to SYSTEM_PERMISSION_SETS | permission-manifest/<domain>/ | ❌ | ✅ | | Add @Action to controller | your controller | ✅ | ✅ | | Rebuild package | — | ✅ | ✅ |

A condition builder lets a permission carry a condition that is matched against a per-request context at evaluation time — e.g. "this staff member may read a report only for the current day", or "only restaurants A/B". The admin configures the condition in the portal; the endpoint supplies the context; HRM matches them with CASL.

The two halves: condition vs context

| | What | Who produces it | When | | ------------- | --------------------------------------------------------------------- | ---------------------------------------------- | -------------- | | condition | stored mongo-query on the rule, e.g. { reportAgeDays: { $lte: 7 } } | admin via the portal form → toCondition | authoring time | | context | computed scalars, e.g. { reportAgeDays: 6 } | the endpoint's @Action({ context }) resolver | every request |

HRM never computes "now" or interprets the condition — the team pre-computes any relative value (e.g. "days ago") into a constant-comparison scalar in the context. CASL matches the context against the stored condition: 6 <= 7 → granted.

What a domain team provides

Four things — all of it is "content"; HRM owns the frame (registry, portal host, evaluation):

1. The builder (common/condition-builder/<domain>/<name>-builder.ts) — the whole builder in ONE file via defineConditionBuilder: context schema, the pure form ⇄ condition translation, a declarative form config (rendered by the portal's generic renderer — no per-builder Vue file), ownedFields, and optionally a co-located buildContext convenience factory. Consumers may use that factory or build the same schema-valid context themselves. labelKeys follow the conditionBuilder.<builderGroup>.<field> convention — a builder ships its own translations as <domain>/locales/*.json (one file per locale, content nested under its own top-level key, e.g. { "reportAccessWindow": { ... } }) and registers them with one import + one spread in common/condition-builder/locales.ts, which the portal consumes as a single message source alongside its own view-owned locale files:

import { z } from 'zod';
import type { ConditionFormConfig } from '../form-config';
import { defineConditionBuilder } from '../form-config';

export const REPORT_ACCESS_WINDOW_CONTEXT_SCHEMA = z.object({
	reportDate: z.string(),
	reportAgeDays: z.number().int().min(0),
	reportAgeMonths: z.number().int().min(0),
	reportAgeYears: z.number().int().min(0),
});

export type ReportAccessWindowForm =
	| { mode: 'unrestricted' }
	| { mode: 'since'; sinceAtUtc: string }
	| { mode: 'last'; amount: number; unit: 'days' | 'months' | 'years' };

const reportAccessWindowForm: ConditionFormConfig = {
	defaults: { mode: 'unrestricted' },
	fields: [
		{
			kind: 'radio',
			key: 'mode',
			labelKey: 'conditionBuilder.reportAccessWindow.mode',
			options: {
				static: [
					{ value: 'unrestricted', labelKey: 'conditionBuilder.reportAccessWindow.unrestricted' },
					{ value: 'since', labelKey: 'conditionBuilder.reportAccessWindow.since' },
					{ value: 'last', labelKey: 'conditionBuilder.reportAccessWindow.last' },
				],
			},
		},
		{
			kind: 'date',
			key: 'sinceAtUtc',
			labelKey: 'conditionBuilder.reportAccessWindow.sinceAtUtc',
			visibleWhen: { field: 'mode', eq: 'since' },
		},
		{
			kind: 'number',
			key: 'amount',
			labelKey: 'conditionBuilder.reportAccessWindow.amount',
			min: 1,
			default: 7,
			visibleWhen: { field: 'mode', eq: 'last' },
		},
		{
			kind: 'select',
			key: 'unit',
			labelKey: 'conditionBuilder.reportAccessWindow.unit',
			default: 'days',
			visibleWhen: { field: 'mode', eq: 'last' },
			options: {
				static: [
					{ value: 'days', labelKey: 'conditionBuilder.reportAccessWindow.days' },
					{ value: 'months', labelKey: 'conditionBuilder.reportAccessWindow.months' },
					{ value: 'years', labelKey: 'conditionBuilder.reportAccessWindow.years' },
				],
			},
		},
	],
};

export const reportAccessWindowBuilder = defineConditionBuilder<ReportAccessWindowForm>({
	builderKey: 'report-access-window', // stable id — never change after deploy
	version: 1,
	contextSchema: REPORT_ACCESS_WINDOW_CONTEXT_SCHEMA, // ownedFields derived from this
	form: reportAccessWindowForm,

	// toCondition/fromCondition are GENERATED from these templates: when the form
	// matches `when`, `condition` is stored with `{ $form: '<field>' }` substituted
	// by the form value — and the inverse (stored condition → form) is derived by
	// matching back against the same templates, so the two directions cannot drift.
	// Unknown stored shapes fall back to the form defaults — never throw.
	templates: [
		{ when: { mode: 'unrestricted' }, condition: {} }, // empty = no restriction
		{
			when: { mode: 'since' },
			condition: { reportDate: { $gte: { $form: 'sinceAtUtc' } } },
		},
		{
			when: { mode: 'last', unit: 'days' },
			condition: { reportAgeDays: { $lte: { $form: 'amount' } } },
		},
		{
			when: { mode: 'last', unit: 'months' },
			condition: { reportAgeMonths: { $lte: { $form: 'amount' } } },
		},
		{
			when: { mode: 'last', unit: 'years' },
			condition: { reportAgeYears: { $lte: { $form: 'amount' } } },
		},
	],
});

Templates cover the common case: "preset → fixed condition shape with directly substituted form values". Only when a condition needs COMPUTED operands (arithmetic on form values, merged fields) should a builder hand-write the toCondition(form) / fromCondition(condition) pair instead — defineConditionBuilder accepts either style. Hand-written fromCondition MUST tolerate null/unknown shapes (older rules) and never throw.

Optional hooks — a builder may also implement either of these on the same object passed to defineConditionBuilder (they pass through untouched, including through the templates shorthand):

appliesToActions?(actions: string[]): boolean;
summarizeForm?(form: TForm, opts: { timeZone: string }): ConditionSummary | undefined;
  • appliesToActions — whether this builder governs a rule carrying these actions. Absent = applies always. Gates both the authoring UI (E.g. reportAccessWindowBuilder only offers authoring for read/manage rules) and missing-context enforcement derivation (see Evaluation below) — a check for an action outside this set is never failed closed by this builder's ownedFields.
  • summarizeForm — a { labelKey, params } summary of a form value for display (e.g. an "inherited condition" hint next to an authoring control). The host resolves labelKey via i18n; timeZone is an IANA zone the host resolves from business context and passes in — the builder never looks it up itself.

2. Attachment (common/condition-builder/attachments.ts) — ONE central declarative map decides which subjects render (and enforce) which builders. The builder itself is a pure "condition entity" and knows nothing about subjects; importing the builder objects here is also what loads them — a builder absent from this map does not exist anywhere. This is authoring/storage only — it does not declare an enforcement mode; whether a condition is actually enforced is derived per-check by checkAccess from the data (see Evaluation below):

// attachments.ts — add your builder to the relevant subject entries
const REPORT_COMMON = [reportAccessWindowBuilder]; // shared list — grow it, every entry grows

export const CONDITION_BUILDER_ATTACHMENTS: Record<string, ConditionBuilder[]> = {
	'prefix:business::report::reports::': REPORT_COMMON,
	// exact entry = the WHOLE truth for that subject (opts out of / extends the prefix):
	'business::report::reports::dailySales': [...REPORT_COMMON, dailySpecialBuilder],
};

Keys accept exact subjects and prefix:<subjectPrefix> entries (dynamic subjects like per-report permissions). Resolution is most-specific-wins — exact > longest prefix — and the winning entry fully replaces the others (entries never merge; compose shared lists with spread). A subject may carry SEVERAL builders: array order = portal render order, and builders in one entry must own disjoint context fields (enforced with a throw at load).

3. The form — usually NO Vue file: the declarative form config above is rendered by the portal's generic ConditionFormRenderer (fields, visibleWhen, static/provider options) in both permission editors automatically. Only for UI that exceeds the declarative vocabulary, set form: { component: '<key>' } and register the component in condition-builders/component-map.ts under that key (escape hatch).

4. The @Action context resolver on the endpoint (in the team's own service) — computes the scalars the schema declares:

@Action({
	level: Permission.Level.business,
	subject: ({ params }) => `business::report::reports::${params.reportKey}`,
	action: Permission.Action.read,
	context: ({ query }) => ({
		reportDate: new Date(query.reportDate).toISOString(),
		reportAgeDays: diffDays(new Date(), new Date(query.reportDate)),
		reportAgeMonths: diffMonths(new Date(), new Date(query.reportDate)),
		reportAgeYears: diffYears(new Date(), new Date(query.reportDate)),
	}),
})
@Get('/:reportKey')
async getReport() {}

Some subjects are also evaluated locally by a non-HRM consumer instead of round-tripping through the backend — e.g. POS's Go CASL port checks report access-window access on-device. That consumer may use its own same-language context factory, pinned against a shared parity fixture so it can't drift from the required schema. See reportAccessWindowBuilder.buildContext and its Go port at permission/lib/conditionbuilder/report_access_window.go (parity fixtures: packages/shared/condition-builder/fixtures/report-access-window.json) for the reference pattern.

Evaluation

There is no declared enforcement mode — checkAccess derives it from the data on every check:

  1. Run the type-level check (ignoring conditions). If denied, stop — context can never rescue a type-level deny.
  2. If granted, look at every non-inverted rule that applies to this subject+action. A builder's ownedFields only count for this step if its appliesToActions covers the action being checked (absent = always counts) — e.g. a rule mixing create and read conditioned only on the report access-window builder's fields is unconditioned, for a create check, since that builder doesn't govern create. If any applicable rule has no condition, or a condition that doesn't touch an in-scope ownedFields key (e.g. the rule is unrestricted, or the condition belongs to an unrelated/legacy mechanism) — context is irrelevant. Grant, not_enforced.
  3. If every applicable rule carries a condition on an ownedFields key, a real restriction is configured and needs evaluating: missing or schema-invalid context → deny (missing_or_invalid_context, fail-closed); valid context → match it against the condition (matched).

Practical effect: while every rule for a subject stays unrestricted (the builder's default), nothing requires a context resolver — authoring and storage work with zero endpoint changes. The moment an admin configures a real restriction, every endpoint guarding that subject needs a context resolver or requests will be denied. So ship resolvers on all endpoints for a subject before anyone configures anything other than unrestricted for it — not after.

Scope boundary: this is derived only from non-inverted (restricted-allow) rules. A conditioned deny rule on an ownedFields key is not a supported pattern here (production revoke logic never emits one — see permission-set.ts's invertBase/getEffectivePermissions, which strip the condition or delete the subject outright for a full revoke instead of relying on CASL to negate a condition on an inverted rule).

Condition shape: flat fields only

Sibling fields in one condition are AND-ed. Conditions must be FLAT — every key a top-level owned field. Builders must not emit $or/$and:

  • Missing-context enforcement derivation (see Evaluation above) only inspects top-level condition keys — an owned field nested under $or reads as unconditioned for that check, a silent fail-open.
  • CASL v4+ removed logical operators from the default matcher. Stored conditions are data, not code — they outlive whatever wrote them, so a CASL upgrade would need a production data migration to re-shape every already-saved $or condition, not just a code change.

OR-shaped policies, in order of preference:

  1. Compute the OR in buildContext as a derived scalar field. E.g. the policy "current month always, last month until the 7th, older never": derive a single field graceDaysSinceMonthEnd (current/future month → 0; last month → today's day-of-month; older → a large sentinel) and condition on { graceDaysSinceMonthEnd: { $lte: N } } — one flat, parameterized field expresses the whole rule.
  2. Multiple same-subject rules — CASL unions positive grants across rules (guarded by __tests__/casl-rule-union.test.ts). Today this is authorable only via separate permission sets, since the builder/portal path emits one condition per rule.

contextSchema should declare every field a builder's condition references — declared fields are validated (fail-closed on bad values); undeclared context fields pass through to matching unvalidated (so legacy scoping keys like restaurantId: { $in: [...] } keep evaluating).

Required test

Every builder must include a round-trip test — it's what catches a wrong fromCondition:

expect(builder.fromCondition(builder.toCondition(form))).toEqual(form);

Checklist

| Step | File | | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | | Builder (schema + form config + templates/to-fromCondition + optional buildContext) | common/condition-builder/<domain>/<name>-builder.ts | | Export from the domain barrel | common/condition-builder/<domain>/index.ts | | Translations (one JSON per locale) + register in the aggregator | common/condition-builder/<domain>/locales/*.json, common/condition-builder/locales.ts | | Attach (subject → builders, ONE central map) | common/condition-builder/attachments.ts | | (only for bespoke UI) component + component-map entry | apps/hrm-portal/.../condition-builders/ | | @Action({ context }) resolver | your controller | | (only if a subject is also evaluated locally, e.g. POS) context factory + parity fixture | e.g. permission/lib/conditionbuilder/, packages/shared/condition-builder/fixtures/ | | Round-trip test | __tests__/ | | Rebuild package | cd packages/permission && pnpm build |

import '@feedmepos/hrm-permission/style.css';

PermissionWrapper

Renders children only when route permissions are satisfied.

<template>
	<PermissionWrapper>
		<div>Protected content</div>
	</PermissionWrapper>
</template>

<script setup lang="ts">
import { PermissionWrapper } from '@feedmepos/hrm-permission/components';
</script>

withPermission HOC

Wraps a component so it renders only when the route's validationManifest permissions are satisfied. Typically combined with a lazy-loaded component:

import { withPermission } from '@feedmepos/hrm-permission/components';
import { withLoading } from '@/components/loading';
import { Permission } from '@feedmepos/hrm-permission';
import type { RouteMeta } from 'vue-router';

// Wrap lazy-loaded views
const hrMain = withPermission(withLoading(() => import('@/views/hr/Main.vue')));
const teamMain = withPermission(withLoading(() => import('@/views/team/Main.vue')));

// Helper to build the required route meta
const canManage = (subject: string): RouteMeta =>
	({
		validationManifest: {
			requiredCaslPermissions: [{ action: Permission.Action.manage, subject }],
		},
	}) as unknown as RouteMeta;

// Routes
const routes = [
	{
		path: '/',
		component: hrMain,
		meta: canManage(Permission.Subject.Business.hrm_employee),
		children: [
			{
				path: 'employee',
				component: withLoading(() => import('@/views/hr/employee/EmployeeList.vue')),
			},
			{ path: 'role', component: withLoading(() => import('@/views/hr/role/RoleList.vue')) },
		],
	},
	{
		path: '/team',
		component: teamMain,
		meta: canManage(Permission.Subject.Business.hrm_teamMember),
	},
];

The validationManifest on the route meta is read by withPermission to check the user's CASL ability before rendering. If the check fails, the component is not mounted.

Standalone

import { PermissionService } from '@feedmepos/hrm-permission/nestjs';

const service = new PermissionService({
	mongoUrl: process.env.MONGODB_URL,
	dbName: process.env.MONGODB_NAME || 'companyDB',
});

await service.initialize();

const ability = await service.constructAbility({
	userId: 'user123',
	level: 1, // Permission.Level.business
	role: 'admin',
	businessId: 'biz123',
});

await service.close();

NestJS module

// app.module.ts
import { PermissionModule } from '@feedmepos/hrm-permission/nestjs';

@Module({
	imports: [
		PermissionModule.forRoot({
			mongoUrl: process.env.MONGODB_URL,
			dbName: process.env.MONGODB_NAME,
		}),
	],
})
export class AppModule {}

// your.service.ts
import { PermissionService } from '@feedmepos/hrm-permission/nestjs';

@Injectable()
export class MyService {
	constructor(private readonly permissionService: PermissionService) {}
}

Environment variables

| Variable | Required | Description | | -------------- | -------- | ------------------------------------ | | MONGODB_URL | ✅ | MongoDB connection string | | MONGODB_NAME | — | Database name (default: companyDB) |

Audit logging is handled automatically by hrm-backend — every ActionGuard permission check writes a ClickHouse entry via gRPC. Consumer services do not call any audit-log function directly.

Two-event model. A single request produces up to two records, correlated by the request's traceId:

  1. Access check (AuditLogEntry) — written on every guarded request, granted or denied. Carries eventKey, entityId, and params authored on @Action (the request-intent key, in <entity>.<verb> form, e.g. inventory-item.adjust-stock).
  2. Operation event(s) (AuditLogOperation) — written after a mutation handler settles (POST/PUT/PATCH/DELETE), one per audit.record() call. Carries the effect key, status (success / failed), durationMs, optional before/after snapshots, and optional changeNodes (display-ready human-readable diff rows resolved at write time — the portal renders them as a table, falling back to raw JSON diff when absent).

eventKey is a stable, domain-owned key — domains may introduce keys without changing ActionGuard. See @feedmepos/hrm-actionguard for how @Action and audit.record() populate these.

If you need to build a log entry manually (e.g. inside hrm-backend itself), use buildPermissionLog:

import { buildPermissionLog, type AuditContext } from '@feedmepos/hrm-permission/audit-log';
import { checkAccess } from '@feedmepos/hrm-permission/utils';

const result = checkAccess(
	[{ action: PermissionAction.read, subject: Permission.Subject.Business.hrm_employee }],
	userPermissions
);

const logEntry = buildPermissionLog(result, {
	userId: 'user123',
	requestPath: '/api/employees',
	requestMethod: 'GET',
	requestBody: '{"key":"value"}', // serialized string — plain JSON or "gzip:<base64>" for large bodies
	businessId: 'biz456',
	country: 'MY',
} satisfies AuditContext);

AuditContext.requestBody is a string — either plain JSON (JSON.stringify(body)) for bodies ≤ 1 MB, or "gzip:<base64>" for larger bodies (produced by serializeRequestBody in @feedmepos/hrm-actionguard). Legacy records stored in ClickHouse may have requestBody as a Record<string, unknown> object — AuditLogMetadata.requestBody is typed as string | Record<string, unknown> to handle both.

Flat → namespaced subjects

PermissionSubjectBusiness is @deprecated. Use PermissionSubjectBusinessNamespace instead. Legacy subjects are automatically remapped at runtime.

| Old (deprecated) | New | | --------------------------- | -------------------------------------- | | business::promotion | business::crm::promotion | | business::voucher | business::crm::voucher | | business::membership | business::crm::membership | | business::stock | business::inventory::stock | | business::ingredient | business::inventory::ingredient | | business::recipe | business::inventory::recipe | | business::unit | business::inventory::unit | | business::supplier | business::inventory::supplier | | business::warehouse | business::inventory::warehouse | | business::publish | business::inventory::publish | | business::integration | business::inventory::integration | | business::orderDraft | business::inventory::orderDraft | | business::wastageTemplate | business::inventory::wastageTemplate | | business::closingTemplate | business::inventory::closingTemplate | | business::orderTemplate | business::inventory::orderTemplate | | business::unitCostHistory | business::inventory::unitCostHistory | | business::permission | business::hrm::teamMember | | business::role | business::hrm::employee::role |

hasAccesscheckAccess

// Before
const canAccess: boolean = hasAccess(requiredPermissions, userPermissions);

// After
const result = checkAccess(requiredPermissions, userPermissions);
const canAccess = result.granted;

| Entry point | What's in it | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | @feedmepos/hrm-permission | Enums, types, constants, checkAccess | | @feedmepos/hrm-permission/utils | checkAccess, validate, validateRoute, mergePermissions, mapLegacyPermission, getCoverSubjectFor, isCoverSubject | | @feedmepos/hrm-permission/components | PermissionWrapper, withPermission | | @feedmepos/hrm-permission/nestjs | PermissionModule, PermissionService + re-exports common | | @feedmepos/hrm-permission/service | PermissionService (standalone, no NestJS) | | @feedmepos/hrm-permission/audit-log | buildPermissionLog, AuditContext type | | @feedmepos/hrm-permission/condition-builder | Condition builders (reportAccessWindowBuilder, ...), registry, conditionBuilderI18nMessages | | @feedmepos/hrm-permission/style.css | Component styles |

Peer dependencies

  • @casl/ability
  • @feedmepos/core
  • @feedmepos/zod-repo
  • mongodb
  • vue ^3.5.0 (for components)
  • vue-router ^4.2.0 (for route validation)
pnpm install

pnpm dev        # run demo app with hot reload
pnpm build      # build library
pnpm type-check