@via-profit/ability 3.6.0 → 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +24 -14
  2. package/dist/index.js +492 -229
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -134,6 +134,7 @@ declare class AbilityRule<Resources extends object = object, Environment extends
134
134
  resource: AbilityRuleConfig['resource'];
135
135
  condition: AbilityConditionType;
136
136
  }>): AbilityRule<Resources, Environment>;
137
+ hash(): string;
137
138
  static equals<Resources extends object = object, Environment extends object = object>(subject: string, resource: AbilityRuleConfig['resource']): AbilityRule<Resources, Environment>;
138
139
  static notEquals<Resources extends object = object, Environment extends object = object>(subject: string, resource: AbilityRuleConfig['resource']): AbilityRule<Resources, Environment>;
139
140
  static contains<Resources extends object = object, Environment extends object = object>(subject: string, resource: AbilityRuleConfig['resource']): AbilityRule<Resources, Environment>;
@@ -199,6 +200,7 @@ declare class AbilityRuleSet<R extends ResourceObject = Record<string, unknown>,
199
200
  compareMethod: AbilityCompareType;
200
201
  rules: AbilityRule<R, E>[];
201
202
  }>): AbilityRuleSet<R, E>;
203
+ hash(): string;
202
204
  static and(rules: AbilityRule[]): AbilityRuleSet<Record<string, unknown>, Record<string, unknown>>;
203
205
  static or(rules: AbilityRule[]): AbilityRuleSet<Record<string, unknown>, Record<string, unknown>>;
204
206
  }
@@ -216,14 +218,16 @@ type AbilityExplainConfig = {
216
218
  readonly type: AbilityExplainType;
217
219
  readonly name: string;
218
220
  readonly match: AbilityMatchType;
221
+ readonly debugInfo?: boolean;
219
222
  };
220
223
  declare class AbilityExplain {
221
224
  readonly type: AbilityExplainType;
222
225
  readonly children: AbilityExplain[];
223
226
  readonly name: string;
224
227
  readonly match: AbilityMatchType;
228
+ readonly debugInfo?: boolean;
225
229
  constructor(config: AbilityExplainConfig, children?: AbilityExplain[]);
226
- toString(indent?: number): string;
230
+ toString(indentPrefix?: string, isLast?: boolean): string;
227
231
  }
228
232
  declare class AbilityExplainRule extends AbilityExplain {
229
233
  constructor(rule: AbilityRule);
@@ -276,15 +280,15 @@ declare class AbilityPolicy<R extends ResourceObject = Record<string, unknown>,
276
280
  * one of the rules returns as «permit»
277
281
  */
278
282
  compareMethod: AbilityCompareType;
283
+ /**
284
+ * Policy ID
285
+ */
286
+ id: string;
279
287
  /**
280
288
  * Policy name
281
289
  */
282
290
  name: string;
283
291
  description?: string | null;
284
- /**
285
- * Policy ID
286
- */
287
- id: string;
288
292
  /**
289
293
  * Running the `enforce` or `resolve` method
290
294
  * will select only those from all passed policies that fall under the specified permission key.
@@ -325,6 +329,7 @@ declare class AbilityPolicy<R extends ResourceObject = Record<string, unknown>,
325
329
  compareMethod: AbilityCompareType;
326
330
  ruleSet: AbilityRuleSet<R, E>[];
327
331
  }>): AbilityPolicy<R, E>;
332
+ hash(): string;
328
333
  }
329
334
 
330
335
  type Primitive = string | number | boolean | null | undefined;
@@ -336,6 +341,7 @@ type EnvironmentObject = Record<string, unknown>;
336
341
  type ResourcesMap = Record<string, ResourceObject>;
337
342
  declare class AbilityTypeGenerator {
338
343
  readonly policies: readonly AbilityPolicy[];
344
+ private readonly policyEntries;
339
345
  constructor(policies: readonly AbilityPolicy[]);
340
346
  /**
341
347
  * Generates TypeScript type definitions based on the provided policies.
@@ -411,8 +417,8 @@ declare class AbilityResult<R extends ResourceObject = Record<string, unknown>,
411
417
  * Useful for debugging, logging, or building UI tools that visualize permission logic.
412
418
  */
413
419
  explain(): readonly AbilityExplain[];
414
- isAllowed(): boolean;
415
- isDenied(): boolean;
420
+ isAllowed: () => boolean;
421
+ isDenied: () => boolean;
416
422
  }
417
423
 
418
424
  interface AbilityResolverOptions<TTags extends string> {
@@ -420,6 +426,7 @@ interface AbilityResolverOptions<TTags extends string> {
420
426
  }
421
427
  type ExtractResources<P> = P extends AbilityPolicy<infer R, any, any> ? R : never;
422
428
  type ExtractEnvironment<P> = P extends AbilityPolicy<any, infer E, any> ? E : never;
429
+ type ExtractPermission<R> = R extends AbilityResolver<infer P, any, any> ? keyof ExtractResources<P> & string : never;
423
430
  type ExtractResourceByPermission<P, Perm extends string> = P extends AbilityPolicy<infer R, any, any> ? (Perm extends keyof R ? R[Perm] : never) : never;
424
431
  type ExtractEnvironmentByPermission<P, Perm extends string> = P extends AbilityPolicy<any, infer E, any> ? (Perm extends keyof E ? E[Perm] : never) : never;
425
432
  declare class AbilityResolver<P extends AbilityPolicy<any, any, any>, S extends AbilityStrategy<P extends AbilityPolicy<infer R, infer E, any> ? R : never, P extends AbilityPolicy<any, infer E, any> ? E : never>, TTags extends string = P extends AbilityPolicy<any, any, infer T> ? T : never> {
@@ -440,14 +447,16 @@ declare class AbilityResolver<P extends AbilityPolicy<any, any, any>, S extends
440
447
  resolve<Permission extends keyof ExtractResources<P> & string>(permission: Permission, resource: ExtractResourceByPermission<P, Permission>, environment?: ExtractEnvironmentByPermission<P, Permission>): AbilityResult<ExtractResourceByPermission<P, Permission>, ExtractEnvironment<P>>;
441
448
  enforce<Permission extends keyof ExtractResources<P> & string>(permission: Permission, resource: ExtractResourceByPermission<P, Permission>, environment?: ExtractEnvironmentByPermission<P, Permission>): void | never;
442
449
  /**
450
+ * @deprecated - will be removed
451
+ *
443
452
  * Check if the permission key is contained in another permission key
444
453
  * @param permissionA - The first permission to check
445
454
  * @param permissionB - The second permission to check
446
455
  */
447
456
  static isInPermissionContain(permissionA: string, permissionB: string): boolean;
448
457
  private toArray;
449
- private normalizePermission;
450
- private static matchPermissions;
458
+ static normalizePermission(permission: string): string;
459
+ static matchPermissions(policySegments: string[], inputSegments: string[]): boolean;
451
460
  }
452
461
 
453
462
  declare class AbilityJSONParser {
@@ -456,13 +465,13 @@ declare class AbilityJSONParser {
456
465
  * @param configs - Array of policy configurations
457
466
  * @returns Array of AbilityPolicy instances
458
467
  */
459
- static parse<Resource extends ResourceObject>(configs: readonly AbilityPolicyConfig[]): AbilityPolicy<Resource>[];
460
- static parsePolicy<Resource extends ResourceObject = Record<string, unknown>>(config: AbilityPolicyConfig): AbilityPolicy<Resource>;
461
- static parseRule<Resources extends object>(config: AbilityRuleConfig): AbilityRule<Resources>;
468
+ static parse<R extends ResourceObject, E extends EnvironmentObject, T extends string = string>(configs: readonly AbilityPolicyConfig[]): AbilityPolicy<R, E, T>[];
469
+ static parsePolicy<R extends ResourceObject, E extends EnvironmentObject, T extends string = string>(config: AbilityPolicyConfig): AbilityPolicy<R, E, T>;
470
+ static parseRule<R extends ResourceObject, E extends EnvironmentObject>(config: AbilityRuleConfig): AbilityRule<R, E>;
462
471
  /**
463
472
  * Parse the config JSON format to Group class instance
464
473
  */
465
- static parseRuleSet<Resource extends ResourceObject = Record<string, unknown>>(config: AbilityRuleSetConfig): AbilityRuleSet<Resource>;
474
+ static parseRuleSet<R extends ResourceObject, E extends EnvironmentObject>(config: AbilityRuleSetConfig): AbilityRuleSet<R, E>;
466
475
  static ruleToJSON(rule: AbilityRule): AbilityRuleConfig;
467
476
  static ruleSetToJSON(ruleSet: AbilityRuleSet): AbilityRuleSetConfig;
468
477
  static policyToJSON(policy: AbilityPolicy): AbilityPolicyConfig;
@@ -544,6 +553,7 @@ declare class AbilityDSLParser<R extends ResourceObject = Record<string, unknown
544
553
  private takeAnnotations;
545
554
  private isStartOfPolicy;
546
555
  private isStartOfGroup;
556
+ private isStartOfRule;
547
557
  private isStartOfExcept;
548
558
  private isStartOfAlias;
549
559
  }
@@ -800,4 +810,4 @@ declare class PriorityStrategy<R extends ResourceObject, E extends EnvironmentOb
800
810
  }
801
811
 
802
812
  export { AbilityCompare, AbilityCondition, AbilityDSLLexer, AbilityDSLParser, AbilityDSLToken, AbilityError, AbilityExplain, AbilityExplainPolicy, AbilityExplainRule, AbilityExplainRuleSet, AbilityJSONParser, AbilityMatch, AbilityParserError, AbilityPolicy, AbilityPolicyEffect, AbilityResolver, AbilityResult, AbilityRule, AbilityRuleSet, AbilityStrategy, AbilityTypeGenerator, AllMustPermitStrategy, AnyPermitStrategy, DenyOverridesStrategy, FirstMatchStrategy, OnlyOneApplicableStrategy, PermitOverridesStrategy, PriorityStrategy, SequentialLastMatchStrategy, TokenTypes, ability, fromLiteral, isConditionEqual, isConditionNotEqual, toLiteral };
803
- export type { AbilityCompareType, AbilityConditionCode, AbilityConditionLiteral, AbilityConditionType, AbilityExplainConfig, AbilityExplainType, AbilityMatchType, AbilityPolicyConfig, AbilityPolicyConstructorProps, AbilityPolicyEffectType, AbilityResolverOptions, AbilityRuleConfig, AbilityRuleConstructorProps, AbilityRuleSetConfig, AbilityRuleSetConstructorProps, EnvironmentObject, NestedDict, Primitive, ResourceObject, ResourcesMap, TokenType, TokenTypeCode };
813
+ export type { AbilityCompareType, AbilityConditionCode, AbilityConditionLiteral, AbilityConditionType, AbilityExplainConfig, AbilityExplainType, AbilityMatchType, AbilityPolicyConfig, AbilityPolicyConstructorProps, AbilityPolicyEffectType, AbilityResolverOptions, AbilityRuleConfig, AbilityRuleConstructorProps, AbilityRuleSetConfig, AbilityRuleSetConstructorProps, EnvironmentObject, ExtractEnvironment, ExtractEnvironmentByPermission, ExtractPermission, ExtractResourceByPermission, ExtractResources, NestedDict, Primitive, ResourceObject, ResourcesMap, TokenType, TokenTypeCode };
package/dist/index.js CHANGED
@@ -126,10 +126,220 @@ const AbilityMatch = {
126
126
  disabled: brand$2('disabled'),
127
127
  };
128
128
 
129
+ const colors = {
130
+ reset: '\x1b[0m',
131
+ green: '\x1b[32m',
132
+ red: '\x1b[31m',
133
+ blue: '\x1b[34m',
134
+ yellow: '\x1b[33m',
135
+ white: '\x1b[37m',
136
+ gray: '\x1b[90m',
137
+ };
138
+ class AbilityExplain {
139
+ type;
140
+ children;
141
+ name;
142
+ match;
143
+ debugInfo;
144
+ constructor(config, children = []) {
145
+ this.type = config.type;
146
+ this.children = children;
147
+ this.name = config.name;
148
+ this.match = config.match;
149
+ this.debugInfo = config.debugInfo;
150
+ }
151
+ toString(indentPrefix = '', isLast = true) {
152
+ const isMatch = this.match === AbilityMatch.match;
153
+ const mark = isMatch ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
154
+ const label = this.type === 'policy'
155
+ ? `${colors.blue}POLICY${colors.reset}`
156
+ : this.type === 'ruleSet'
157
+ ? `${colors.yellow}RULESET${colors.reset}`
158
+ : `${colors.white}RULE${colors.reset}`;
159
+ const branch = indentPrefix.length === 0
160
+ ? ''
161
+ : isLast
162
+ ? `${colors.gray}└─${colors.reset} `
163
+ : `${colors.gray}├─${colors.reset} `;
164
+ let out = `${indentPrefix}${branch}${label} ${this.name} — ${mark}`;
165
+ if (this.debugInfo) {
166
+ out += ` ${colors.gray}(${this.debugInfo})${colors.reset}`;
167
+ }
168
+ const nextIndent = indentPrefix + (isLast ? ' ' : `${colors.gray}│ ${colors.reset}`);
169
+ this.children.forEach((child, index) => {
170
+ const last = index === this.children.length - 1;
171
+ out += '\n' + child.toString(nextIndent, last);
172
+ });
173
+ return out;
174
+ }
175
+ }
176
+ class AbilityExplainRule extends AbilityExplain {
177
+ constructor(rule) {
178
+ super({
179
+ type: 'rule',
180
+ match: rule.state,
181
+ name: rule.name,
182
+ });
183
+ }
184
+ }
185
+ class AbilityExplainRuleSet extends AbilityExplain {
186
+ constructor(ruleSet) {
187
+ const children = ruleSet.rules.map(rule => new AbilityExplainRule(rule));
188
+ super({
189
+ type: 'ruleSet',
190
+ match: ruleSet.state,
191
+ name: ruleSet.name,
192
+ }, children);
193
+ }
194
+ }
195
+ class AbilityExplainPolicy extends AbilityExplain {
196
+ constructor(policy) {
197
+ const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
198
+ super({
199
+ type: 'policy',
200
+ name: policy.priority > -1 ? `@priority ${policy.priority} ${policy.name}` : policy.name,
201
+ match: policy.matchState,
202
+ }, children);
203
+ }
204
+ }
205
+
206
+ class AbilityResult {
207
+ effect;
208
+ strategy;
209
+ constructor(effect, strategy) {
210
+ this.effect = effect;
211
+ this.strategy = strategy;
212
+ }
213
+ /**
214
+ * Returns a list of explanations for each policy involved in the ability evaluation.
215
+ * Each item describes how a specific policy contributed to the final permission result.
216
+ *
217
+ * Useful for debugging, logging, or building UI tools that visualize permission logic.
218
+ */
219
+ explain() {
220
+ return this.strategy.policies.map(policy => {
221
+ return new AbilityExplainPolicy(policy);
222
+ });
223
+ }
224
+ isAllowed = () => {
225
+ return this.strategy.isAllowed();
226
+ };
227
+ isDenied = () => {
228
+ return this.strategy.isDenied();
229
+ };
230
+ }
231
+
232
+ class AbilityResolver {
233
+ StrategyClass;
234
+ policyEntries;
235
+ constructor(
236
+ /**
237
+ * `Important!` The incorrect Resources type was intentionally passed to AbilityPolicy so that TypeScript could suggest the name of the permission and the structure of its resource in the parse method.
238
+ */
239
+ policyOrListOfPolicies, strategy, options = {}) {
240
+ const policies = this.toArray(policyOrListOfPolicies);
241
+ const filtered = options.tags
242
+ ? policies.filter(p => p.tags.some(tag => options.tags.includes(tag)))
243
+ : policies;
244
+ const sorted = [...filtered].sort((a, b) => b.priority - a.priority);
245
+ this.policyEntries = sorted.map(policy => ({
246
+ policy,
247
+ normalizedPermission: AbilityResolver.normalizePermission(policy.permission),
248
+ segments: AbilityResolver.normalizePermission(policy.permission).split('.'),
249
+ }));
250
+ this.StrategyClass = strategy;
251
+ }
252
+ /**
253
+ * Resolve policy for the resource and permission key
254
+ *
255
+ * @param permission - Permission key
256
+ * @param resource - Resource
257
+ * @param environment
258
+ */
259
+ resolve(permission, resource, environment) {
260
+ const inputNormalized = AbilityResolver.normalizePermission(String(permission));
261
+ const inputSegments = inputNormalized.split('.');
262
+ const filteredPolicies = this.policyEntries
263
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
264
+ .map(entry => entry.policy);
265
+ // 2. check the policies
266
+ for (const policy of filteredPolicies) {
267
+ if (policy.disabled) {
268
+ continue;
269
+ }
270
+ const policyMatchState = policy.check(resource, environment);
271
+ if (policyMatchState === AbilityMatch.pending) {
272
+ throw new AbilityError(`The policy "${policy.name}" is still in a pending state. Make sure to call "check" to evaluate the policy before resolving permissions.`);
273
+ }
274
+ }
275
+ // 3. Use strategy
276
+ const strategy = new this.StrategyClass(filteredPolicies);
277
+ const effect = strategy.evaluate();
278
+ return new AbilityResult(effect, strategy);
279
+ }
280
+ enforce(permission, resource, environment) {
281
+ const result = this.resolve(permission, resource, environment);
282
+ if (result.isDenied()) {
283
+ throw new AbilityError(`Permission denied`);
284
+ }
285
+ }
286
+ /**
287
+ * @deprecated - will be removed
288
+ *
289
+ * Check if the permission key is contained in another permission key
290
+ * @param permissionA - The first permission to check
291
+ * @param permissionB - The second permission to check
292
+ */
293
+ static isInPermissionContain(permissionA, permissionB) {
294
+ const A = permissionA.split('.');
295
+ const B = permissionB.split('.');
296
+ const [longer, shorter] = A.length >= B.length ? [A, B] : [B, A];
297
+ return shorter.every((chunk, i) => {
298
+ return chunk === '*' || longer[i] === '*' || chunk === longer[i];
299
+ });
300
+ }
301
+ toArray(value) {
302
+ return [...(Array.isArray(value) ? value : [value])];
303
+ }
304
+ static normalizePermission(permission) {
305
+ return permission
306
+ .trim()
307
+ .replace(/^permission\./, '') // remove prefix
308
+ .replace(/\.+/g, '.') // collapse multiple dots
309
+ .toLowerCase(); // optional: make case-insensitive
310
+ }
311
+ static matchPermissions(policySegments, inputSegments) {
312
+ const maxLen = Math.max(policySegments.length, inputSegments.length);
313
+ for (let i = 0; i < maxLen; i++) {
314
+ const pSeg = policySegments[i];
315
+ const iSeg = inputSegments[i];
316
+ if (pSeg === undefined) {
317
+ return false;
318
+ }
319
+ if (pSeg === '*') {
320
+ continue; // '*'
321
+ }
322
+ if (iSeg === undefined) {
323
+ return false;
324
+ }
325
+ if (pSeg !== iSeg) {
326
+ return false;
327
+ }
328
+ }
329
+ return true;
330
+ }
331
+ }
332
+
129
333
  class AbilityTypeGenerator {
130
334
  policies;
335
+ policyEntries;
131
336
  constructor(policies) {
132
337
  this.policies = policies;
338
+ this.policyEntries = policies.map(policy => ({
339
+ policy,
340
+ normalizedPermission: AbilityResolver.normalizePermission(policy.permission),
341
+ segments: AbilityResolver.normalizePermission(policy.permission).split('.'),
342
+ }));
133
343
  }
134
344
  /**
135
345
  * Generates TypeScript type definitions based on the provided policies.
@@ -277,21 +487,6 @@ class AbilityTypeGenerator {
277
487
  * @returns TypeScript array type as string
278
488
  */
279
489
  getArrayType(resource) {
280
- // if (Array.isArray(resource)) {
281
- // if (resource.length === 0) {
282
- // return 'readonly unknown[]';
283
- // }
284
- // // Determine types of array elements
285
- // const elementTypes = new Set(resource.map(item => this.getPrimitiveType(item)));
286
- // const elementType =
287
- // elementTypes.size === 1
288
- // ? Array.from(elementTypes)[0]
289
- // : `(${Array.from(elementTypes).join(' | ')})`;
290
- // return `readonly ${elementType}[]`;
291
- // }
292
- // // If resource is not an array but condition is in/not_in,
293
- // // it expects an array of such elements
294
- // return `readonly ${this.getPrimitiveType(resource)}[]`;
295
490
  const elementType = this.getInArrayType(resource);
296
491
  return `readonly ${elementType}[]`;
297
492
  }
@@ -387,18 +582,43 @@ class AbilityTypeGenerator {
387
582
  output += '// Do not edit manually\n';
388
583
  output += 'export type Resources = {\n';
389
584
  const sortedActions = Object.keys(structure).sort();
390
- sortedActions.forEach(action => {
391
- const actionObj = structure[action];
585
+ sortedActions.forEach(permission => {
586
+ const actionObj = structure[permission];
392
587
  const isEmpty = Object.keys(actionObj).length === 0;
588
+ const inputNormalized = AbilityResolver.normalizePermission(permission);
589
+ const inputSegments = inputNormalized.split('.');
590
+ const filteredPolicies = this.policyEntries
591
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
592
+ .map(entry => entry.policy);
593
+ // Effects
594
+ const effects = [...new Set(filteredPolicies.map(p => p.effect))].sort();
595
+ // Policies list
596
+ const items = filteredPolicies
597
+ .sort((a, b) => a.id.localeCompare(b.id))
598
+ .map(p => {
599
+ const effect = p.effect.padEnd(6, ' '); // permit / deny / audit
600
+ const displayName = p.name === p.id ? 'Unnamed policy' : p.name;
601
+ return ` * - ${effect} ${p.id} "${displayName}"`;
602
+ })
603
+ .join('\n');
604
+ //
605
+ output += `
606
+ /**
607
+ * Permission: ${permission}
608
+ * Effects: ${effects.join(', ')}
609
+ * Policies:
610
+ ${items}
611
+ */
612
+ `;
393
613
  if (isEmpty) {
394
- // Пустой объект → undefined
395
- output += ` ['${action}']: undefined;\n`;
614
+ // empty object → undefined
615
+ output += ` ['${permission}']: undefined;\n`;
396
616
  }
397
617
  else {
398
- // Непустой объект → как раньше
399
- output += ` ['${action}']: {\n`;
618
+ // not empty object
619
+ output += ` ['${permission}']: {\n`;
400
620
  output += this.formatNestedObject(actionObj, 4);
401
- output += ' };\n';
621
+ output += ' } | null | undefined;\n';
402
622
  }
403
623
  });
404
624
  output += '}\n';
@@ -412,15 +632,37 @@ class AbilityTypeGenerator {
412
632
  output += `\n\nexport type PolicyTags = ${tagsUnion};\n`;
413
633
  // environments
414
634
  output += '\n\nexport type Environment = {\n';
415
- Object.entries(environment).forEach(([action, envObj]) => {
635
+ Object.entries(environment).forEach(([permission, envObj]) => {
416
636
  const isEmpty = Object.keys(envObj).length === 0;
637
+ const inputNormalized = AbilityResolver.normalizePermission(permission);
638
+ const inputSegments = inputNormalized.split('.');
639
+ const filteredPolicies = this.policyEntries
640
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
641
+ .map(entry => entry.policy);
642
+ const effects = [...new Set(filteredPolicies.map(p => p.effect))].sort();
643
+ const items = filteredPolicies
644
+ .sort((a, b) => a.id.localeCompare(b.id))
645
+ .map(p => {
646
+ const effect = p.effect.padEnd(6, ' ');
647
+ const displayName = p.name === p.id ? 'Unnamed policy' : p.name;
648
+ return ` * - ${effect} ${p.id} "${displayName}"`;
649
+ })
650
+ .join('\n');
651
+ output += `
652
+ /**
653
+ * Permission: ${permission}
654
+ * Effects: ${effects.join(', ')}
655
+ * Policies:
656
+ ${items}
657
+ */
658
+ `;
417
659
  if (isEmpty) {
418
- output += ` ['${action}']: undefined;\n`;
660
+ output += ` ['${permission}']: undefined;\n`;
419
661
  }
420
662
  else {
421
- output += ` ['${action}']: {\n`;
663
+ output += ` ['${permission}']: {\n`;
422
664
  output += this.formatNestedObject(envObj, 4);
423
- output += ' };\n';
665
+ output += ' } | null | undefined;\n';
424
666
  }
425
667
  });
426
668
  output += '}\n';
@@ -444,69 +686,126 @@ class AbilityTypeGenerator {
444
686
  // Nested object
445
687
  output += `${spaces}readonly ${key}: {\n`;
446
688
  output += this.formatNestedObject(value, indent + 2);
447
- output += `${spaces}};\n`;
689
+ output += `${spaces}} | null | undefined;\n`;
448
690
  }
449
691
  else {
450
692
  // Primitive type
451
- output += `${spaces}readonly ${key}: ${value};\n`;
693
+ const va = [String(value)];
694
+ let v = String(value);
695
+ if (!v.match(/unknown/)) {
696
+ if (!v.match(/null/)) {
697
+ va.push('null');
698
+ }
699
+ if (!v.match(/undefined/)) {
700
+ va.push('undefined');
701
+ }
702
+ }
703
+ output += `${spaces}readonly ${key}: ${va.join(' | ')} \n`;
452
704
  }
453
705
  });
454
706
  return output;
455
707
  }
456
708
  }
457
709
 
458
- class AbilityExplain {
459
- type;
460
- children;
461
- name;
462
- match;
463
- constructor(config, children = []) {
464
- this.type = config.type;
465
- this.children = children;
466
- this.name = config.name;
467
- this.match = config.match;
468
- }
469
- toString(indent = 0) {
470
- const pad = ' '.repeat(indent);
471
- const mark = this.match === AbilityMatch.match ? '✓' : '✗';
472
- let out = '';
473
- if (this.type === 'policy') {
474
- out += '\n';
710
+ class AbilityHash {
711
+ static sha1(message) {
712
+ const msgBytes = AbilityHash.stringToBytes(message);
713
+ const msgBitLength = msgBytes.length * 8;
714
+ const withOne = new Uint8Array(msgBytes.length + 1);
715
+ withOne.set(msgBytes, 0);
716
+ withOne[msgBytes.length] = 0x80;
717
+ let zeroBytes = (56 - (withOne.length % 64) + 64) % 64;
718
+ const padded = new Uint8Array(withOne.length + zeroBytes + 8);
719
+ padded.set(withOne, 0);
720
+ const bitLenHigh = Math.floor(msgBitLength / 0x100000000);
721
+ const bitLenLow = msgBitLength >>> 0;
722
+ padded[padded.length - 8] = (bitLenHigh >>> 24) & 0xff;
723
+ padded[padded.length - 7] = (bitLenHigh >>> 16) & 0xff;
724
+ padded[padded.length - 6] = (bitLenHigh >>> 8) & 0xff;
725
+ padded[padded.length - 5] = bitLenHigh & 0xff;
726
+ padded[padded.length - 4] = (bitLenLow >>> 24) & 0xff;
727
+ padded[padded.length - 3] = (bitLenLow >>> 16) & 0xff;
728
+ padded[padded.length - 2] = (bitLenLow >>> 8) & 0xff;
729
+ padded[padded.length - 1] = bitLenLow & 0xff;
730
+ let h0 = 0x67452301;
731
+ let h1 = 0xefcdab89;
732
+ let h2 = 0x98badcfe;
733
+ let h3 = 0x10325476;
734
+ let h4 = 0xc3d2e1f0;
735
+ const w = new Array(80);
736
+ for (let i = 0; i < padded.length; i += 64) {
737
+ for (let j = 0; j < 16; j++) {
738
+ const idx = i + j * 4;
739
+ w[j] =
740
+ (padded[idx] << 24) | (padded[idx + 1] << 16) | (padded[idx + 2] << 8) | padded[idx + 3];
741
+ }
742
+ for (let j = 16; j < 80; j++) {
743
+ w[j] = AbilityHash.leftRotate(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
744
+ }
745
+ let a = h0;
746
+ let b = h1;
747
+ let c = h2;
748
+ let d = h3;
749
+ let e = h4;
750
+ for (let j = 0; j < 80; j++) {
751
+ let f;
752
+ let k;
753
+ if (j < 20) {
754
+ f = (b & c) | (~b & d);
755
+ k = 0x5a827999;
756
+ }
757
+ else {
758
+ if (j < 40) {
759
+ f = b ^ c ^ d;
760
+ k = 0x6ed9eba1;
761
+ }
762
+ else {
763
+ if (j < 60) {
764
+ f = (b & c) | (b & d) | (c & d);
765
+ k = 0x8f1bbcdc;
766
+ }
767
+ else {
768
+ f = b ^ c ^ d;
769
+ k = 0xca62c1d6;
770
+ }
771
+ }
772
+ }
773
+ const temp = (AbilityHash.leftRotate(a, 5) + f + e + k + (w[j] | 0)) | 0;
774
+ e = d;
775
+ d = c;
776
+ c = AbilityHash.leftRotate(b, 30);
777
+ b = a;
778
+ a = temp;
779
+ }
780
+ h0 = (h0 + a) | 0;
781
+ h1 = (h1 + b) | 0;
782
+ h2 = (h2 + c) | 0;
783
+ h3 = (h3 + d) | 0;
784
+ h4 = (h4 + e) | 0;
785
+ }
786
+ return [
787
+ AbilityHash.toHex32(h0),
788
+ AbilityHash.toHex32(h1),
789
+ AbilityHash.toHex32(h2),
790
+ AbilityHash.toHex32(h3),
791
+ AbilityHash.toHex32(h4),
792
+ ].join('');
793
+ }
794
+ static leftRotate(value, bits) {
795
+ return ((value << bits) | (value >>> (32 - bits))) >>> 0;
796
+ }
797
+ static toHex32(num) {
798
+ return (num >>> 0).toString(16).padStart(8, '0');
799
+ }
800
+ static stringToBytes(str) {
801
+ if (typeof TextEncoder !== 'undefined') {
802
+ const encoder = new TextEncoder();
803
+ return encoder.encode(str);
804
+ }
805
+ else {
806
+ const buf = Buffer.from(str, 'utf8');
807
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
475
808
  }
476
- out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match}`;
477
- this.children.forEach(child => {
478
- out += '\n' + child.toString(indent + 1);
479
- });
480
- return out;
481
- }
482
- }
483
- class AbilityExplainRule extends AbilityExplain {
484
- constructor(rule) {
485
- super({
486
- type: 'rule',
487
- match: rule.state,
488
- name: rule.name,
489
- });
490
- }
491
- }
492
- class AbilityExplainRuleSet extends AbilityExplain {
493
- constructor(ruleSet) {
494
- const children = ruleSet.rules.map(rule => new AbilityExplainRule(rule));
495
- super({
496
- type: 'ruleSet',
497
- match: ruleSet.state,
498
- name: ruleSet.name,
499
- }, children);
500
- }
501
- }
502
- class AbilityExplainPolicy extends AbilityExplain {
503
- constructor(policy) {
504
- const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
505
- super({
506
- type: 'policy',
507
- name: policy.priority > -1 ? `@priority ${policy.priority} ${policy.name}` : policy.name,
508
- match: policy.matchState,
509
- }, children);
510
809
  }
511
810
  }
512
811
 
@@ -527,15 +826,15 @@ class AbilityPolicy {
527
826
  * one of the rules returns as «permit»
528
827
  */
529
828
  compareMethod = AbilityCompare.and;
829
+ /**
830
+ * Policy ID
831
+ */
832
+ id;
530
833
  /**
531
834
  * Policy name
532
835
  */
533
836
  name;
534
837
  description;
535
- /**
536
- * Policy ID
537
- */
538
- id;
539
838
  /**
540
839
  * Running the `enforce` or `resolve` method
541
840
  * will select only those from all passed policies that fall under the specified permission key.
@@ -546,8 +845,6 @@ class AbilityPolicy {
546
845
  tags;
547
846
  constructor(params) {
548
847
  const { name, description, id, permission, effect, compareMethod = AbilityCompare.and, priority, disabled, tags, } = params;
549
- this.id = id || `policy:${effect}:${permission}`;
550
- this.name = name || this.id;
551
848
  this.permission = permission;
552
849
  this.description = description;
553
850
  this.effect = effect;
@@ -555,6 +852,8 @@ class AbilityPolicy {
555
852
  this.priority = typeof priority === 'number' ? priority : -1;
556
853
  this.disabled = typeof disabled === 'boolean' ? disabled : false;
557
854
  this.tags = (tags || []);
855
+ this.id = id || `p_${this.hash().slice(0, 10)}`;
856
+ this.name = name || this.id;
558
857
  }
559
858
  /**
560
859
  * Add rule set to the policy
@@ -668,6 +967,24 @@ class AbilityPolicy {
668
967
  }
669
968
  return policy;
670
969
  }
970
+ hash() {
971
+ const parts = [
972
+ `permission:${this.permission}`,
973
+ `effect:${this.effect}`,
974
+ `compareMethod:${this.compareMethod}`,
975
+ `priority:${this.priority}`,
976
+ `disabled:${this.disabled}`,
977
+ ];
978
+ if (this.tags && this.tags.length > 0) {
979
+ parts.push(`tags:${[...this.tags].sort().join(',')}`);
980
+ }
981
+ if (this.ruleSet && this.ruleSet.length > 0) {
982
+ const ruleHashes = this.ruleSet.map(r => r.hash());
983
+ parts.push(`rules:${ruleHashes.sort().join('|')}`);
984
+ }
985
+ const str = parts.join(';');
986
+ return AbilityHash.sha1(str);
987
+ }
671
988
  }
672
989
 
673
990
  function brand$1(code) {
@@ -678,127 +995,6 @@ const AbilityPolicyEffect = {
678
995
  permit: brand$1('permit'),
679
996
  };
680
997
 
681
- class AbilityResult {
682
- effect;
683
- strategy;
684
- constructor(effect, strategy) {
685
- this.effect = effect;
686
- this.strategy = strategy;
687
- }
688
- /**
689
- * Returns a list of explanations for each policy involved in the ability evaluation.
690
- * Each item describes how a specific policy contributed to the final permission result.
691
- *
692
- * Useful for debugging, logging, or building UI tools that visualize permission logic.
693
- */
694
- explain() {
695
- return this.strategy.policies.map(policy => {
696
- return new AbilityExplainPolicy(policy);
697
- });
698
- }
699
- isAllowed() {
700
- return this.strategy.isAllowed();
701
- }
702
- isDenied() {
703
- return this.strategy.isDenied();
704
- }
705
- }
706
-
707
- class AbilityResolver {
708
- StrategyClass;
709
- policyEntries;
710
- constructor(
711
- /**
712
- * `Important!` The incorrect Resources type was intentionally passed to AbilityPolicy so that TypeScript could suggest the name of the permission and the structure of its resource in the parse method.
713
- */
714
- policyOrListOfPolicies, strategy, options = {}) {
715
- const policies = this.toArray(policyOrListOfPolicies);
716
- const filtered = options.tags
717
- ? policies.filter(p => p.tags.some(tag => options.tags.includes(tag)))
718
- : policies;
719
- const sorted = [...filtered].sort((a, b) => b.priority - a.priority);
720
- this.policyEntries = sorted.map(policy => ({
721
- policy,
722
- normalizedPermission: this.normalizePermission(policy.permission),
723
- segments: this.normalizePermission(policy.permission).split('.'),
724
- }));
725
- this.StrategyClass = strategy;
726
- }
727
- /**
728
- * Resolve policy for the resource and permission key
729
- *
730
- * @param permission - Permission key
731
- * @param resource - Resource
732
- * @param environment
733
- */
734
- resolve(permission, resource, environment) {
735
- const inputNormalized = this.normalizePermission(String(permission));
736
- const inputSegments = inputNormalized.split('.');
737
- const filteredPolicies = this.policyEntries
738
- .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
739
- .map(entry => entry.policy);
740
- // 2. check the policies
741
- for (const policy of filteredPolicies) {
742
- if (policy.disabled) {
743
- continue;
744
- }
745
- const policyMatchState = policy.check(resource, environment);
746
- if (policyMatchState === AbilityMatch.pending) {
747
- throw new AbilityError(`The policy "${policy.name}" is still in a pending state. Make sure to call "check" to evaluate the policy before resolving permissions.`);
748
- }
749
- }
750
- // 3. Use strategy
751
- const strategy = new this.StrategyClass(filteredPolicies);
752
- const effect = strategy.evaluate();
753
- return new AbilityResult(effect, strategy);
754
- }
755
- enforce(permission, resource, environment) {
756
- const result = this.resolve(permission, resource, environment);
757
- if (result.isDenied()) {
758
- throw new AbilityError(`Permission denied`);
759
- }
760
- }
761
- /**
762
- * Check if the permission key is contained in another permission key
763
- * @param permissionA - The first permission to check
764
- * @param permissionB - The second permission to check
765
- */
766
- static isInPermissionContain(permissionA, permissionB) {
767
- const A = permissionA.split('.');
768
- const B = permissionB.split('.');
769
- const [longer, shorter] = A.length >= B.length ? [A, B] : [B, A];
770
- return shorter.every((chunk, i) => {
771
- return chunk === '*' || longer[i] === '*' || chunk === longer[i];
772
- });
773
- }
774
- toArray(value) {
775
- return [...(Array.isArray(value) ? value : [value])];
776
- }
777
- normalizePermission(permission) {
778
- return permission
779
- .trim()
780
- .replace(/^permission\./, '') // remove prefix
781
- .replace(/\.+/g, '.') // collapse multiple dots
782
- .toLowerCase(); // optional: make case-insensitive
783
- }
784
- static matchPermissions(policySegments, inputSegments) {
785
- const maxLen = Math.max(policySegments.length, inputSegments.length);
786
- for (let i = 0; i < maxLen; i++) {
787
- const pSeg = policySegments[i];
788
- const iSeg = inputSegments[i];
789
- if (pSeg === undefined)
790
- return false; // policy короче – не матчит
791
- if (pSeg === '*')
792
- continue; // '*' матчит любой сегмент
793
- if (iSeg === undefined)
794
- return false; // входной permission короче
795
- if (pSeg !== iSeg)
796
- return false;
797
- }
798
- return true;
799
- }
800
- }
801
-
802
998
  /**
803
999
  * Represents a rule that defines a condition to be checked against a subject and resource.
804
1000
  */
@@ -829,14 +1025,14 @@ class AbilityRule {
829
1025
  */
830
1026
  constructor(params) {
831
1027
  const { id, name, subject, resource, condition, disabled, description } = params;
832
- this.name = name || `rule:${JSON.stringify(subject)}:${condition}:${JSON.stringify(resource)}`;
833
- this.id = id || this.name;
834
1028
  this.description = description;
835
1029
  this.disabled = typeof disabled === 'boolean' ? disabled : false;
836
1030
  this.subject = subject;
837
1031
  this.resource = resource;
838
1032
  this.condition = condition;
839
1033
  this.state = this.disabled ? AbilityMatch.disabled : this.state;
1034
+ this.id = id || `r_${this.hash().slice(0, 10)}`;
1035
+ this.name = name || this.id;
840
1036
  }
841
1037
  static isPrimitive(v) {
842
1038
  return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
@@ -1063,6 +1259,14 @@ class AbilityRule {
1063
1259
  condition: props.condition ?? this.condition,
1064
1260
  });
1065
1261
  }
1262
+ hash() {
1263
+ const parts = [];
1264
+ parts.push(`subject:${this.subject}`);
1265
+ parts.push(`resource:${JSON.stringify(this.resource)}`);
1266
+ parts.push(`condition:${this.condition}`);
1267
+ parts.push(`disabled:${this.disabled}`);
1268
+ return AbilityHash.sha1(parts.join(';'));
1269
+ }
1066
1270
  static equals(subject, resource) {
1067
1271
  return new AbilityRule({
1068
1272
  condition: AbilityCondition.equals,
@@ -1168,13 +1372,13 @@ class AbilityRuleSet {
1168
1372
  disabled;
1169
1373
  constructor(params) {
1170
1374
  const { name, id, compareMethod, isExcept, disabled, description } = params;
1171
- this.name = name || `ruleset:${compareMethod}`;
1172
- this.id = id || this.name;
1173
1375
  this.description = description;
1174
1376
  this.compareMethod = compareMethod;
1175
1377
  this.isExcept = isExcept;
1176
1378
  this.disabled = typeof disabled === 'boolean' ? disabled : false;
1177
1379
  this.state = this.disabled ? AbilityMatch.disabled : this.state;
1380
+ this.id = id || `g_${this.hash().slice(0, 10)}`;
1381
+ this.name = name || this.id;
1178
1382
  }
1179
1383
  addRule(rule) {
1180
1384
  this.rules.push(rule);
@@ -1236,6 +1440,16 @@ class AbilityRuleSet {
1236
1440
  }
1237
1441
  return next;
1238
1442
  }
1443
+ hash() {
1444
+ const ruleHashes = this.rules.map(r => r.hash()).sort();
1445
+ const parts = [
1446
+ `compareMethod:${this.compareMethod}`,
1447
+ `isExcept:${this.isExcept}`,
1448
+ `disabled:${this.disabled}`,
1449
+ `rules:${ruleHashes.join('|')}`,
1450
+ ];
1451
+ return AbilityHash.sha1(parts.join(';'));
1452
+ }
1239
1453
  static and(rules) {
1240
1454
  return new AbilityRuleSet({
1241
1455
  compareMethod: AbilityCompare.and,
@@ -1308,32 +1522,32 @@ class AbilityJSONParser {
1308
1522
  return {
1309
1523
  id: rule.id,
1310
1524
  name: rule.name,
1525
+ disabled: rule.disabled,
1311
1526
  subject: rule.subject,
1312
1527
  resource: rule.resource,
1313
1528
  condition: rule.condition,
1314
- disabled: rule.disabled,
1315
1529
  };
1316
1530
  }
1317
1531
  static ruleSetToJSON(ruleSet) {
1318
1532
  return {
1319
1533
  id: ruleSet.id.toString(),
1320
1534
  name: ruleSet.name.toString(),
1535
+ disabled: ruleSet.disabled,
1321
1536
  compareMethod: ruleSet.compareMethod,
1322
1537
  rules: ruleSet.rules.map(rule => AbilityJSONParser.ruleToJSON(rule)),
1323
- disabled: ruleSet.disabled,
1324
1538
  };
1325
1539
  }
1326
1540
  static policyToJSON(policy) {
1327
1541
  return {
1328
1542
  id: policy.id.toString(),
1329
1543
  name: policy.name.toString(),
1330
- compareMethod: policy.compareMethod,
1331
- ruleSet: policy.ruleSet.map(ruleSet => AbilityJSONParser.ruleSetToJSON(ruleSet)),
1544
+ disabled: policy.disabled,
1545
+ priority: policy.priority,
1332
1546
  permission: policy.permission,
1333
1547
  effect: policy.effect,
1334
- priority: policy.priority,
1335
- disabled: policy.disabled,
1548
+ compareMethod: policy.compareMethod,
1336
1549
  tags: policy.tags,
1550
+ ruleSet: policy.ruleSet.map(ruleSet => AbilityJSONParser.ruleSetToJSON(ruleSet)),
1337
1551
  };
1338
1552
  }
1339
1553
  static toJSON(policies) {
@@ -1508,11 +1722,49 @@ class AbilityDSLLexer {
1508
1722
  readAnnotation() {
1509
1723
  const startLine = this.line;
1510
1724
  const startColumn = this.column;
1511
- let value = '';
1725
+ let raw = '';
1726
+ // читаем всю строку после @
1512
1727
  while (!this.isAtEnd() && !this.isNewline()) {
1513
- value += this.advance();
1728
+ raw += this.advance();
1729
+ }
1730
+ raw = raw.trim();
1731
+ // parse literals
1732
+ let result = '';
1733
+ let i = 0;
1734
+ let inString = false;
1735
+ let quote = null;
1736
+ let escaped = false;
1737
+ while (i < raw.length) {
1738
+ const ch = raw[i];
1739
+ if (inString) {
1740
+ if (escaped) {
1741
+ result += ch;
1742
+ escaped = false;
1743
+ }
1744
+ else if (ch === '\\') {
1745
+ escaped = true;
1746
+ }
1747
+ else if (ch === quote) {
1748
+ inString = false;
1749
+ quote = null;
1750
+ }
1751
+ else {
1752
+ result += ch;
1753
+ }
1754
+ i++;
1755
+ continue;
1756
+ }
1757
+ // start of string
1758
+ if (ch === '"' || ch === "'") {
1759
+ inString = true;
1760
+ quote = ch;
1761
+ i++;
1762
+ continue;
1763
+ }
1764
+ result += ch;
1765
+ i++;
1514
1766
  }
1515
- return new AbilityDSLToken(TokenTypes.ANNOTATION, value.trim(), startLine, startColumn);
1767
+ return new AbilityDSLToken(TokenTypes.ANNOTATION, result.trim(), startLine, startColumn);
1516
1768
  }
1517
1769
  readString() {
1518
1770
  const startLine = this.line;
@@ -2083,28 +2335,34 @@ class AbilityDSLParser {
2083
2335
  */
2084
2336
  parseRuleSets(policyCompareMethod) {
2085
2337
  const sets = [];
2338
+ this.consumeLeadingComments();
2339
+ this.consumeLeadingAnnotations();
2086
2340
  while (!this.stream.eof() && !this.isStartOfPolicy()) {
2087
- this.consumeLeadingComments();
2088
- this.consumeLeadingAnnotations();
2089
- // Если начинается новая except группа — парсим её
2341
+ // maybe except ruleSet
2090
2342
  if (this.isStartOfExcept()) {
2091
2343
  sets.push(this.parseExceptGroup(policyCompareMethod));
2092
2344
  continue;
2093
2345
  }
2094
- // Если начинается новая группа — парсим её
2346
+ // maybe ruleSet
2095
2347
  if (this.isStartOfGroup()) {
2096
2348
  sets.push(this.parseGroup());
2097
2349
  continue;
2098
2350
  }
2099
- const annotation = this.takeAnnotations('ruleSet');
2351
+ // implicit ruleSet
2352
+ // if (!this.isStartOfRule()) {
2353
+ // this.consumeLeadingComments();
2354
+ // this.consumeLeadingAnnotations();
2355
+ // }
2356
+ // is implicit group
2357
+ // const annotation = this.takeAnnotations('ruleSet');
2100
2358
  const group = new AbilityRuleSet({
2101
- id: annotation.id?.value || null,
2359
+ // id: annotation.id?.value || null,
2102
2360
  compareMethod: policyCompareMethod,
2103
- name: annotation.name?.value ?? null,
2104
- description: annotation.description?.value || null,
2105
- disabled: annotation.disabled?.value ?? undefined,
2361
+ // name: annotation.name?.value ?? null,
2362
+ // description: annotation.description?.value || null,
2363
+ // disabled: annotation.disabled?.value ?? undefined,
2106
2364
  });
2107
- // Читаем правила implicit-группы
2365
+ // Read rules of implicit-группы
2108
2366
  while (!this.stream.eof()) {
2109
2367
  this.consumeLeadingComments();
2110
2368
  this.consumeLeadingAnnotations();
@@ -2705,6 +2963,11 @@ class AbilityDSLParser {
2705
2963
  isStartOfGroup() {
2706
2964
  return this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY);
2707
2965
  }
2966
+ isStartOfRule() {
2967
+ return (this.stream.check(TokenTypes.IDENTIFIER) ||
2968
+ this.stream.check(TokenTypes.ALWAYS) ||
2969
+ this.stream.check(TokenTypes.NEVER));
2970
+ }
2708
2971
  isStartOfExcept() {
2709
2972
  return this.stream.check(TokenTypes.EXCEPT);
2710
2973
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@via-profit/ability",
3
3
  "support": "https://via-profit.ru",
4
- "version": "3.6.0",
4
+ "version": "3.6.2",
5
5
  "description": "Via-Profit Ability service",
6
6
  "keywords": [
7
7
  "ability",