@via-profit/ability 3.6.4 → 3.7.1

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.
package/dist/index.cjs ADDED
@@ -0,0 +1,3435 @@
1
+ 'use strict';
2
+
3
+ function brand$4(code) {
4
+ return code;
5
+ }
6
+ const AbilityCompare = {
7
+ or: brand$4('or'),
8
+ and: brand$4('and'),
9
+ };
10
+
11
+ class AbilityError extends Error {
12
+ constructor(message, options) {
13
+ super(message, options);
14
+ this.name = 'AbilityError';
15
+ if (Error.captureStackTrace) {
16
+ Error.captureStackTrace(this, this.constructor);
17
+ }
18
+ }
19
+ }
20
+ class AbilityParserError extends Error {
21
+ constructor(message, options) {
22
+ super(message, options);
23
+ this.name = 'AbilityParserError';
24
+ if (Error.captureStackTrace) {
25
+ Error.captureStackTrace(this, this.constructor);
26
+ }
27
+ }
28
+ }
29
+
30
+ function brand$3(code) {
31
+ return code;
32
+ }
33
+ const AbilityCondition = {
34
+ equals: brand$3('='),
35
+ defined: brand$3('defined'),
36
+ not_defined: brand$3('not_defined'),
37
+ not_equals: brand$3('<>'),
38
+ greater_than: brand$3('>'),
39
+ less_than: brand$3('<'),
40
+ less_or_equal: brand$3('<='),
41
+ greater_or_equal: brand$3('>='),
42
+ in: brand$3('in'),
43
+ not_in: brand$3('not in'),
44
+ contains: brand$3('contains'),
45
+ not_contains: brand$3('not contains'),
46
+ length_greater_than: brand$3('length greater than'),
47
+ length_less_than: brand$3('length less than'),
48
+ length_equals: brand$3('length equals'),
49
+ always: brand$3('always'),
50
+ never: brand$3('never'),
51
+ };
52
+ function fromLiteral(literal) {
53
+ const map = {
54
+ equals: AbilityCondition.equals,
55
+ not_equals: AbilityCondition.not_equals,
56
+ greater_than: AbilityCondition.greater_than,
57
+ less_than: AbilityCondition.less_than,
58
+ less_or_equal: AbilityCondition.less_or_equal,
59
+ greater_or_equal: AbilityCondition.greater_or_equal,
60
+ in: AbilityCondition.in,
61
+ not_in: AbilityCondition.not_in,
62
+ contains: AbilityCondition.contains,
63
+ not_contains: AbilityCondition.not_contains,
64
+ length_greater_than: AbilityCondition.length_greater_than,
65
+ length_less_than: AbilityCondition.length_less_than,
66
+ length_equals: AbilityCondition.length_equals,
67
+ always: AbilityCondition.always,
68
+ never: AbilityCondition.never,
69
+ defined: AbilityCondition.defined,
70
+ not_defined: AbilityCondition.not_defined,
71
+ };
72
+ const value = map[literal];
73
+ if (!value) {
74
+ const expected = Object.keys(map).join(', ');
75
+ throw new AbilityParserError(`Literal "${literal}" does not found in AbilityCondition. Expected one of: ${expected}`);
76
+ }
77
+ return value;
78
+ }
79
+ function toLiteral(cond) {
80
+ switch (cond) {
81
+ case AbilityCondition.equals:
82
+ return 'equals';
83
+ case AbilityCondition.not_equals:
84
+ return 'not_equals';
85
+ case AbilityCondition.greater_than:
86
+ return 'greater_than';
87
+ case AbilityCondition.less_than:
88
+ return 'less_than';
89
+ case AbilityCondition.less_or_equal:
90
+ return 'less_or_equal';
91
+ case AbilityCondition.greater_or_equal:
92
+ return 'greater_or_equal';
93
+ case AbilityCondition.in:
94
+ return 'in';
95
+ case AbilityCondition.not_in:
96
+ return 'not_in';
97
+ case AbilityCondition.contains:
98
+ return 'contains';
99
+ case AbilityCondition.not_contains:
100
+ return 'not_contains';
101
+ case AbilityCondition.length_greater_than:
102
+ return 'length_greater_than';
103
+ case AbilityCondition.length_less_than:
104
+ return 'length_less_than';
105
+ case AbilityCondition.length_equals:
106
+ return 'length_equals';
107
+ case AbilityCondition.always:
108
+ return 'always';
109
+ case AbilityCondition.never:
110
+ return 'never';
111
+ case AbilityCondition.defined:
112
+ return 'defined';
113
+ case AbilityCondition.not_defined:
114
+ return 'not_defined';
115
+ default:
116
+ return 'never';
117
+ }
118
+ }
119
+ function isConditionEqual(a, b) {
120
+ return a !== null && b !== null && a === b;
121
+ }
122
+ function isConditionNotEqual(a, b) {
123
+ return !isConditionEqual(a, b);
124
+ }
125
+
126
+ function brand$2(code) {
127
+ return code;
128
+ }
129
+ const AbilityMatch = {
130
+ pending: brand$2('pending'),
131
+ match: brand$2('match'),
132
+ mismatch: brand$2('mismatch'),
133
+ exceptMismatch: brand$2('except-mismatch'),
134
+ disabled: brand$2('disabled'),
135
+ };
136
+
137
+ class AbilityExplain {
138
+ type;
139
+ children;
140
+ name;
141
+ match;
142
+ debugInfo;
143
+ constructor(config, children = []) {
144
+ this.type = config.type;
145
+ this.children = children;
146
+ this.name = config.name;
147
+ this.match = config.match;
148
+ this.debugInfo = config.debugInfo;
149
+ }
150
+ toString(indentPrefix = '', isLast = true) {
151
+ const isMatch = this.match === AbilityMatch.match;
152
+ const isMismatch = this.match === AbilityMatch.mismatch;
153
+ const isPending = this.match === AbilityMatch.pending;
154
+ // const isDisabled = this.match === AbilityMatch.disabled;
155
+ const mark = isMatch
156
+ ? `<match ✓>`
157
+ : isMismatch
158
+ ? `<mismatch ✗>`
159
+ : isPending
160
+ ? `<pending …>`
161
+ : `<disabled ⊘>`;
162
+ let label;
163
+ switch (this.type) {
164
+ case 'policy':
165
+ label = `POLICY`;
166
+ break;
167
+ case 'ruleSet':
168
+ label = `RULESET`;
169
+ break;
170
+ default:
171
+ label = `RULE`;
172
+ }
173
+ const branch = indentPrefix.length === 0
174
+ ? ''
175
+ : isLast
176
+ ? `└─ `
177
+ : `├─ `;
178
+ let out = `${indentPrefix}${branch}${label} ${this.name} — ${mark}`;
179
+ if (this.debugInfo)
180
+ out += ` (${this.debugInfo})`;
181
+ const nextIndent = indentPrefix + (isLast ? ' ' : `│ `);
182
+ this.children.forEach((child, idx) => {
183
+ out += '\n' + child.toString(nextIndent, idx === this.children.length - 1);
184
+ });
185
+ return out;
186
+ }
187
+ }
188
+ class AbilityExplainRule extends AbilityExplain {
189
+ constructor(rule) {
190
+ super({
191
+ type: 'rule',
192
+ match: rule.state,
193
+ name: rule.name,
194
+ debugInfo: `${rule.subject} ${rule.condition} ${JSON.stringify(rule.resource)}`,
195
+ });
196
+ }
197
+ }
198
+ class AbilityExplainRuleSet extends AbilityExplain {
199
+ constructor(ruleSet) {
200
+ const children = ruleSet.rules.map(rule => new AbilityExplainRule(rule));
201
+ super({
202
+ type: 'ruleSet',
203
+ match: ruleSet.state,
204
+ name: ruleSet.name,
205
+ }, children);
206
+ }
207
+ }
208
+ class AbilityExplainPolicy extends AbilityExplain {
209
+ constructor(policy) {
210
+ const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
211
+ super({
212
+ type: 'policy',
213
+ name: policy.priority > -1 ? `@priority ${policy.priority} ${policy.name}` : policy.name,
214
+ match: policy.matchState,
215
+ }, children);
216
+ }
217
+ }
218
+
219
+ class AbilityResult {
220
+ effect;
221
+ strategy;
222
+ constructor(effect, strategy) {
223
+ this.effect = effect;
224
+ this.strategy = strategy;
225
+ }
226
+ /**
227
+ * Returns a list of explanations for each policy involved in the ability evaluation.
228
+ * Each item describes how a specific policy contributed to the final permission result.
229
+ *
230
+ * Useful for debugging, logging, or building UI tools that visualize permission logic.
231
+ */
232
+ explain() {
233
+ const resMarker = this.strategy.isDenied() ? '== DENIED==' : '== ALLOWED ==';
234
+ const policiesExplain = this.strategy.policies
235
+ .map(policy => {
236
+ return new AbilityExplainPolicy(policy).toString();
237
+ })
238
+ .join('\n');
239
+ return `${resMarker}\n${policiesExplain}\n`;
240
+ }
241
+ decisive() {
242
+ return this.strategy.decisivePolicy();
243
+ }
244
+ explainDecisive() {
245
+ const policy = this.decisive();
246
+ if (!policy) {
247
+ return null;
248
+ }
249
+ return new AbilityExplainPolicy(policy).toString();
250
+ }
251
+ isAllowed = () => {
252
+ return this.strategy.isAllowed();
253
+ };
254
+ isDenied = () => {
255
+ return this.strategy.isDenied();
256
+ };
257
+ }
258
+
259
+ function brand$1(code) {
260
+ return code;
261
+ }
262
+ const AbilityPolicyEffect = {
263
+ deny: brand$1('deny'),
264
+ permit: brand$1('permit'),
265
+ };
266
+
267
+ class AbilityResolver {
268
+ onDeny;
269
+ onAllow;
270
+ StrategyClass;
271
+ policyEntries;
272
+ constructor(
273
+ /**
274
+ * `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.
275
+ */
276
+ policyOrListOfPolicies, strategy, options = {}) {
277
+ const policies = this.toArray(policyOrListOfPolicies);
278
+ this.onDeny = options.onDeny;
279
+ this.onAllow = options.onAllow;
280
+ const filtered = options.tags
281
+ ? policies.filter(p => p.tags.some(tag => options.tags.includes(tag)))
282
+ : policies;
283
+ const sorted = [...filtered].sort((a, b) => b.priority - a.priority);
284
+ this.policyEntries = sorted.map(policy => ({
285
+ policy,
286
+ normalizedPermission: AbilityResolver.normalizePermission(policy.permission),
287
+ segments: AbilityResolver.normalizePermission(policy.permission).split('.'),
288
+ }));
289
+ this.StrategyClass = strategy;
290
+ }
291
+ /**
292
+ * Resolve policy for the resource and permission key
293
+ *
294
+ * @param permission - Permission key
295
+ * @param resource - Resource
296
+ * @param environment
297
+ */
298
+ resolve(permission, resource, environment) {
299
+ const inputNormalized = AbilityResolver.normalizePermission(String(permission));
300
+ const inputSegments = inputNormalized.split('.');
301
+ const filteredPolicies = this.policyEntries
302
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
303
+ .map(entry => entry.policy);
304
+ // 2. check the policies
305
+ for (const policy of filteredPolicies) {
306
+ if (policy.disabled) {
307
+ continue;
308
+ }
309
+ const policyMatchState = policy.check(resource, environment);
310
+ if (policyMatchState === AbilityMatch.pending) {
311
+ 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.`);
312
+ }
313
+ }
314
+ // 3. Use strategy
315
+ const strategy = new this.StrategyClass(filteredPolicies);
316
+ const effect = strategy.evaluate();
317
+ const result = new AbilityResult(effect, strategy);
318
+ if (effect === AbilityPolicyEffect.deny && this.onDeny) {
319
+ this.onDeny(result);
320
+ }
321
+ if (effect === AbilityPolicyEffect.permit && this.onAllow) {
322
+ this.onAllow(result);
323
+ }
324
+ return result;
325
+ }
326
+ enforce(permission, resource, environment, options) {
327
+ const result = this.resolve(permission, resource, environment);
328
+ if (result.isDenied()) {
329
+ options?.onDeny && options?.onDeny(result);
330
+ throw new AbilityError(`Permission denied`);
331
+ }
332
+ }
333
+ /**
334
+ * @deprecated - will be removed
335
+ *
336
+ * Check if the permission key is contained in another permission key
337
+ * @param permissionA - The first permission to check
338
+ * @param permissionB - The second permission to check
339
+ */
340
+ static isInPermissionContain(permissionA, permissionB) {
341
+ const A = permissionA.split('.');
342
+ const B = permissionB.split('.');
343
+ const [longer, shorter] = A.length >= B.length ? [A, B] : [B, A];
344
+ return shorter.every((chunk, i) => {
345
+ return chunk === '*' || longer[i] === '*' || chunk === longer[i];
346
+ });
347
+ }
348
+ toArray(value) {
349
+ return [...(Array.isArray(value) ? value : [value])];
350
+ }
351
+ static normalizePermission(permission) {
352
+ return permission
353
+ .trim()
354
+ .replace(/^permission\./, '') // remove prefix
355
+ .replace(/\.+/g, '.') // collapse multiple dots
356
+ .toLowerCase(); // optional: make case-insensitive
357
+ }
358
+ static matchPermissions(policySegments, inputSegments) {
359
+ let i = 0;
360
+ for (; i < policySegments.length; i++) {
361
+ const pSeg = policySegments[i];
362
+ const iSeg = inputSegments[i];
363
+ // '*' — глобальный wildcard: матчим всё, что дальше
364
+ if (pSeg === '*') {
365
+ return true;
366
+ }
367
+ // input закончился раньше — mismatch
368
+ if (iSeg === undefined) {
369
+ return false;
370
+ }
371
+ // обычное сравнение
372
+ if (pSeg !== iSeg) {
373
+ return false;
374
+ }
375
+ }
376
+ // Если политика закончилась, но input длиннее — match только если последний сегмент был '*'
377
+ return i === inputSegments.length;
378
+ }
379
+ }
380
+
381
+ class AbilityTypeGenerator {
382
+ policies;
383
+ policyEntries;
384
+ constructor(policies) {
385
+ this.policies = policies;
386
+ this.policyEntries = policies.map(policy => ({
387
+ policy,
388
+ normalizedPermission: AbilityResolver.normalizePermission(policy.permission),
389
+ segments: AbilityResolver.normalizePermission(policy.permission).split('.'),
390
+ }));
391
+ }
392
+ /**
393
+ * Generates TypeScript type definitions based on the provided policies.
394
+ * @returns A generated type definitions.
395
+ */
396
+ generateTypeDefs() {
397
+ // Structure to store types: { [action]: { [subjectPath]: type } }
398
+ const resorceStructure = {};
399
+ const environmentStructure = {};
400
+ // tags
401
+ const allTags = new Set();
402
+ // Iterate through all policies
403
+ this.policies.forEach(policy => {
404
+ policy.tags.forEach(tag => allTags.add(tag));
405
+ const action = policy.permission;
406
+ // Initialize object for action if it doesn't exist
407
+ if (!resorceStructure[action]) {
408
+ resorceStructure[action] = {};
409
+ }
410
+ // Iterate through all ruleSets in the policy
411
+ policy.ruleSet.forEach(ruleSet => {
412
+ // Iterate through all rules in the ruleSet
413
+ ruleSet.rules.forEach(rule => {
414
+ const subjectPath = rule.subject;
415
+ const ruleType = this.determineTypeFromRule(rule);
416
+ if (!ruleType) {
417
+ return;
418
+ }
419
+ // -----------------------------
420
+ // ENVIRONMENT HANDLING (subject)
421
+ // -----------------------------
422
+ if (subjectPath.startsWith('env.')) {
423
+ const envPath = subjectPath.replace(/^env\./, '');
424
+ if (!environmentStructure[action]) {
425
+ environmentStructure[action] = {};
426
+ }
427
+ environmentStructure[action][envPath] = ruleType;
428
+ }
429
+ else {
430
+ const existingType = resorceStructure[action][subjectPath];
431
+ if (existingType && existingType !== ruleType) {
432
+ resorceStructure[action][subjectPath] = `${existingType} | ${ruleType}`;
433
+ }
434
+ else {
435
+ resorceStructure[action][subjectPath] = ruleType;
436
+ }
437
+ }
438
+ // -----------------------------
439
+ // RESOURCE PATH HANDLING (right side)
440
+ // -----------------------------
441
+ if (typeof rule.resource === 'string' && this.isPath(rule.resource)) {
442
+ const resourcePath = rule.resource;
443
+ // env.* справа
444
+ if (resourcePath.startsWith('env.')) {
445
+ const envPath = resourcePath.replace(/^env\./, '');
446
+ if (!environmentStructure[action]) {
447
+ environmentStructure[action] = {};
448
+ }
449
+ const existingEnvType = environmentStructure[action][envPath];
450
+ const targetType = ruleType;
451
+ if (existingEnvType && existingEnvType !== targetType) {
452
+ environmentStructure[action][envPath] = `${existingEnvType} | ${targetType}`;
453
+ }
454
+ else {
455
+ environmentStructure[action][envPath] = targetType;
456
+ }
457
+ }
458
+ else {
459
+ // обычный ресурс справа
460
+ if (!resorceStructure[action]) {
461
+ resorceStructure[action] = {};
462
+ }
463
+ const existingResType = resorceStructure[action][resourcePath];
464
+ const targetType = ruleType; // или 'unknown'
465
+ if (existingResType && existingResType !== targetType) {
466
+ resorceStructure[action][resourcePath] = `${existingResType} | ${targetType}`;
467
+ }
468
+ else {
469
+ resorceStructure[action][resourcePath] = targetType;
470
+ }
471
+ }
472
+ }
473
+ });
474
+ });
475
+ });
476
+ const filteredStructure = {};
477
+ Object.entries(resorceStructure).forEach(([action, fields]) => {
478
+ if (!action.endsWith('.*')) {
479
+ filteredStructure[action] = fields;
480
+ }
481
+ });
482
+ // Transform flat structure into nested structure for easier use
483
+ const nestedStructure = this.buildNestedStructure(filteredStructure);
484
+ const nestedEnvironment = this.buildNestedStructure(environmentStructure);
485
+ return this.formatTypeDefinitions(nestedStructure, nestedEnvironment, allTags);
486
+ }
487
+ isPath(value) {
488
+ if (typeof value !== 'string') {
489
+ return false;
490
+ }
491
+ if (value.startsWith('"') || value.startsWith("'")) {
492
+ return false;
493
+ }
494
+ return value.includes('.');
495
+ }
496
+ /**
497
+ * Determines TypeScript type based on the rule
498
+ * @param rule - The rule to analyze
499
+ * @returns TypeScript type as string
500
+ */
501
+ determineTypeFromRule(rule) {
502
+ if (rule.condition === AbilityCondition.never || rule.condition === AbilityCondition.always) {
503
+ return null;
504
+ }
505
+ if (rule.condition === AbilityCondition.contains ||
506
+ rule.condition === AbilityCondition.not_contains) {
507
+ return this.getArrayType(rule.resource);
508
+ }
509
+ if (rule.condition === AbilityCondition.length_equals ||
510
+ rule.condition === AbilityCondition.length_greater_than ||
511
+ rule.condition === AbilityCondition.length_less_than) {
512
+ return 'string | readonly unknown[]';
513
+ }
514
+ // Numeric comparisons - always number
515
+ if (rule.condition === AbilityCondition.greater_than ||
516
+ rule.condition === AbilityCondition.greater_or_equal ||
517
+ rule.condition === AbilityCondition.less_than ||
518
+ rule.condition === AbilityCondition.less_or_equal) {
519
+ return 'number';
520
+ }
521
+ // Array operations
522
+ if (rule.condition === AbilityCondition.in || rule.condition === AbilityCondition.not_in) {
523
+ return this.getInArrayType(rule.resource);
524
+ }
525
+ // Equality/Inequality operations
526
+ if (rule.condition === AbilityCondition.equals ||
527
+ rule.condition === AbilityCondition.not_equals) {
528
+ return this.getPrimitiveType(rule.resource);
529
+ }
530
+ return 'any';
531
+ }
532
+ /**
533
+ * Gets TypeScript type for array values
534
+ * @param resource - The resource value to analyze
535
+ * @returns TypeScript array type as string
536
+ */
537
+ getArrayType(resource) {
538
+ const elementType = this.getInArrayType(resource);
539
+ return `readonly ${elementType}[]`;
540
+ }
541
+ getInArrayType(resource) {
542
+ if (Array.isArray(resource)) {
543
+ if (resource.length === 0) {
544
+ return 'unknown';
545
+ }
546
+ // Determine types of array elements
547
+ const elementTypes = new Set(resource.map(item => this.getPrimitiveType(item)));
548
+ return elementTypes.size === 1
549
+ ? Array.from(elementTypes)[0]
550
+ : `(${Array.from(elementTypes).join(' | ')})`;
551
+ }
552
+ // If resource is not an array but condition is in/not_in,
553
+ // it expects an array of such elements
554
+ return this.getPrimitiveType(resource);
555
+ }
556
+ /**
557
+ * Gets primitive TypeScript type for a value
558
+ * @param value - The value to analyze
559
+ * @returns TypeScript primitive type as string
560
+ */
561
+ getPrimitiveType(value) {
562
+ if (value === null) {
563
+ return 'null | unknown';
564
+ }
565
+ if (value === undefined) {
566
+ return 'undefined';
567
+ }
568
+ if (typeof value === 'string' && this.isPath(value)) {
569
+ // This is not a string literal, but a path to another field.
570
+ return 'unknown';
571
+ }
572
+ switch (typeof value) {
573
+ case 'string':
574
+ return 'string';
575
+ case 'number':
576
+ return 'number';
577
+ case 'boolean':
578
+ return 'boolean';
579
+ case 'object':
580
+ if (Array.isArray(value)) {
581
+ return 'array'; // special marker, handled separately
582
+ }
583
+ return 'object';
584
+ default:
585
+ return 'any';
586
+ }
587
+ }
588
+ /**
589
+ * Builds nested structure from flat paths
590
+ * Example: 'user.profile.name' -> { user: { profile: { name: 'string' } } }
591
+ * @param flatStructure - Flat structure with dot notation paths
592
+ * @returns Nested object structure
593
+ */
594
+ buildNestedStructure(flatStructure) {
595
+ const result = {};
596
+ Object.entries(flatStructure).forEach(([action, paths]) => {
597
+ result[action] = {};
598
+ Object.entries(paths).forEach(([path, type]) => {
599
+ const parts = path.split('.');
600
+ let current = result[action];
601
+ // Iterate through all parts except the last one
602
+ for (let i = 0; i < parts.length - 1; i++) {
603
+ const part = parts[i];
604
+ const currentValue = current[part];
605
+ if (!currentValue || typeof currentValue !== 'object') {
606
+ const newObj = {};
607
+ current[part] = newObj;
608
+ current = newObj;
609
+ }
610
+ else {
611
+ current = currentValue;
612
+ }
613
+ }
614
+ // Set type for the last part
615
+ const lastPart = parts[parts.length - 1];
616
+ current[lastPart] = type;
617
+ });
618
+ });
619
+ return result;
620
+ }
621
+ /**
622
+ * Formats type structure into a string
623
+ * @param structure - Nested type structure
624
+ * @param environment
625
+ * @param allTags
626
+ * @returns Formatted TypeScript type definition string
627
+ */
628
+ formatTypeDefinitions(structure, environment, allTags) {
629
+ let output = '// Automatically generated by via-profit/ability\n';
630
+ output += '// Do not edit manually\n';
631
+ output += 'export type Resources = {\n';
632
+ const sortedActions = Object.keys(structure).sort();
633
+ sortedActions.forEach(permission => {
634
+ const actionObj = structure[permission];
635
+ const isEmpty = Object.keys(actionObj).length === 0;
636
+ const inputNormalized = AbilityResolver.normalizePermission(permission);
637
+ const inputSegments = inputNormalized.split('.');
638
+ const filteredPolicies = this.policyEntries
639
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
640
+ .map(entry => entry.policy);
641
+ // Effects
642
+ const effects = [...new Set(filteredPolicies.map(p => p.effect))].sort();
643
+ // Policies list
644
+ const items = filteredPolicies
645
+ .sort((a, b) => a.id.localeCompare(b.id))
646
+ .map(p => {
647
+ const effect = p.effect.padEnd(6, ' '); // permit / deny / audit
648
+ const displayName = p.name === p.id ? 'Unnamed policy' : p.name;
649
+ return ` * - ${effect} ${p.id} "${displayName}"`;
650
+ })
651
+ .join('\n');
652
+ //
653
+ output += `
654
+ /**
655
+ * Permission: ${permission}
656
+ * Effects: ${effects.join(', ')}
657
+ * Policies:
658
+ ${items}
659
+ */
660
+ `;
661
+ if (isEmpty) {
662
+ // empty object → undefined
663
+ output += ` ['${permission}']: undefined;\n`;
664
+ }
665
+ else {
666
+ // not empty object
667
+ output += ` ['${permission}']: {\n`;
668
+ output += this.formatNestedObject(actionObj, 4);
669
+ output += ' } | null | undefined;\n';
670
+ }
671
+ });
672
+ output += '}\n';
673
+ // tags
674
+ const tagsUnion = allTags.size > 0
675
+ ? Array.from(allTags)
676
+ .sort()
677
+ .map(tag => `'${tag}'`)
678
+ .join(' | ')
679
+ : 'never';
680
+ output += `\n\nexport type PolicyTags = ${tagsUnion};\n`;
681
+ // environments
682
+ output += '\n\nexport type Environment = {\n';
683
+ Object.entries(environment).forEach(([permission, envObj]) => {
684
+ const isEmpty = Object.keys(envObj).length === 0;
685
+ const inputNormalized = AbilityResolver.normalizePermission(permission);
686
+ const inputSegments = inputNormalized.split('.');
687
+ const filteredPolicies = this.policyEntries
688
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
689
+ .map(entry => entry.policy);
690
+ const effects = [...new Set(filteredPolicies.map(p => p.effect))].sort();
691
+ const items = filteredPolicies
692
+ .sort((a, b) => a.id.localeCompare(b.id))
693
+ .map(p => {
694
+ const effect = p.effect.padEnd(6, ' ');
695
+ const displayName = p.name === p.id ? 'Unnamed policy' : p.name;
696
+ return ` * - ${effect} ${p.id} "${displayName}"`;
697
+ })
698
+ .join('\n');
699
+ output += `
700
+ /**
701
+ * Permission: ${permission}
702
+ * Effects: ${effects.join(', ')}
703
+ * Policies:
704
+ ${items}
705
+ */
706
+ `;
707
+ if (isEmpty) {
708
+ output += ` ['${permission}']: undefined;\n`;
709
+ }
710
+ else {
711
+ output += ` ['${permission}']: {\n`;
712
+ output += this.formatNestedObject(envObj, 4);
713
+ output += ' } | null | undefined;\n';
714
+ }
715
+ });
716
+ output += '}\n';
717
+ // complex
718
+ return output;
719
+ }
720
+ /**
721
+ * Recursively formats nested object
722
+ * @param obj - Object to format
723
+ * @param indent - Current indentation level
724
+ * @returns Formatted string
725
+ */
726
+ formatNestedObject(obj, indent) {
727
+ const spaces = ' '.repeat(indent);
728
+ let output = '';
729
+ // Sort keys for stable output
730
+ const sortedKeys = Object.keys(obj).sort();
731
+ sortedKeys.forEach(key => {
732
+ const value = obj[key];
733
+ if (typeof value === 'object' && value !== null) {
734
+ // Nested object
735
+ output += `${spaces}readonly ${key}: {\n`;
736
+ output += this.formatNestedObject(value, indent + 2);
737
+ output += `${spaces}} | null | undefined;\n`;
738
+ }
739
+ else {
740
+ // Primitive type
741
+ const va = [String(value)];
742
+ let v = String(value);
743
+ if (!v.match(/unknown/)) {
744
+ if (!v.match(/null/)) {
745
+ va.push('null');
746
+ }
747
+ if (!v.match(/undefined/)) {
748
+ va.push('undefined');
749
+ }
750
+ }
751
+ output += `${spaces}readonly ${key}: ${va.join(' | ')} \n`;
752
+ }
753
+ });
754
+ return output;
755
+ }
756
+ }
757
+
758
+ class AbilityHash {
759
+ static sha1(message) {
760
+ const msgBytes = AbilityHash.stringToBytes(message);
761
+ const msgBitLength = msgBytes.length * 8;
762
+ const withOne = new Uint8Array(msgBytes.length + 1);
763
+ withOne.set(msgBytes, 0);
764
+ withOne[msgBytes.length] = 0x80;
765
+ let zeroBytes = (56 - (withOne.length % 64) + 64) % 64;
766
+ const padded = new Uint8Array(withOne.length + zeroBytes + 8);
767
+ padded.set(withOne, 0);
768
+ const bitLenHigh = Math.floor(msgBitLength / 0x100000000);
769
+ const bitLenLow = msgBitLength >>> 0;
770
+ padded[padded.length - 8] = (bitLenHigh >>> 24) & 0xff;
771
+ padded[padded.length - 7] = (bitLenHigh >>> 16) & 0xff;
772
+ padded[padded.length - 6] = (bitLenHigh >>> 8) & 0xff;
773
+ padded[padded.length - 5] = bitLenHigh & 0xff;
774
+ padded[padded.length - 4] = (bitLenLow >>> 24) & 0xff;
775
+ padded[padded.length - 3] = (bitLenLow >>> 16) & 0xff;
776
+ padded[padded.length - 2] = (bitLenLow >>> 8) & 0xff;
777
+ padded[padded.length - 1] = bitLenLow & 0xff;
778
+ let h0 = 0x67452301;
779
+ let h1 = 0xefcdab89;
780
+ let h2 = 0x98badcfe;
781
+ let h3 = 0x10325476;
782
+ let h4 = 0xc3d2e1f0;
783
+ const w = new Array(80);
784
+ for (let i = 0; i < padded.length; i += 64) {
785
+ for (let j = 0; j < 16; j++) {
786
+ const idx = i + j * 4;
787
+ w[j] =
788
+ (padded[idx] << 24) | (padded[idx + 1] << 16) | (padded[idx + 2] << 8) | padded[idx + 3];
789
+ }
790
+ for (let j = 16; j < 80; j++) {
791
+ w[j] = AbilityHash.leftRotate(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
792
+ }
793
+ let a = h0;
794
+ let b = h1;
795
+ let c = h2;
796
+ let d = h3;
797
+ let e = h4;
798
+ for (let j = 0; j < 80; j++) {
799
+ let f;
800
+ let k;
801
+ if (j < 20) {
802
+ f = (b & c) | (~b & d);
803
+ k = 0x5a827999;
804
+ }
805
+ else {
806
+ if (j < 40) {
807
+ f = b ^ c ^ d;
808
+ k = 0x6ed9eba1;
809
+ }
810
+ else {
811
+ if (j < 60) {
812
+ f = (b & c) | (b & d) | (c & d);
813
+ k = 0x8f1bbcdc;
814
+ }
815
+ else {
816
+ f = b ^ c ^ d;
817
+ k = 0xca62c1d6;
818
+ }
819
+ }
820
+ }
821
+ const temp = (AbilityHash.leftRotate(a, 5) + f + e + k + (w[j] | 0)) | 0;
822
+ e = d;
823
+ d = c;
824
+ c = AbilityHash.leftRotate(b, 30);
825
+ b = a;
826
+ a = temp;
827
+ }
828
+ h0 = (h0 + a) | 0;
829
+ h1 = (h1 + b) | 0;
830
+ h2 = (h2 + c) | 0;
831
+ h3 = (h3 + d) | 0;
832
+ h4 = (h4 + e) | 0;
833
+ }
834
+ return [
835
+ AbilityHash.toHex32(h0),
836
+ AbilityHash.toHex32(h1),
837
+ AbilityHash.toHex32(h2),
838
+ AbilityHash.toHex32(h3),
839
+ AbilityHash.toHex32(h4),
840
+ ].join('');
841
+ }
842
+ static leftRotate(value, bits) {
843
+ return ((value << bits) | (value >>> (32 - bits))) >>> 0;
844
+ }
845
+ static toHex32(num) {
846
+ return (num >>> 0).toString(16).padStart(8, '0');
847
+ }
848
+ static stringToBytes(str) {
849
+ if (typeof TextEncoder !== 'undefined') {
850
+ const encoder = new TextEncoder();
851
+ return encoder.encode(str);
852
+ }
853
+ else {
854
+ const buf = Buffer.from(str, 'utf8');
855
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
856
+ }
857
+ }
858
+ }
859
+
860
+ class AbilityPolicy {
861
+ matchState = AbilityMatch.pending;
862
+ /**
863
+ * List of rules
864
+ */
865
+ ruleSet = [];
866
+ /**
867
+ * Policy effect
868
+ */
869
+ effect;
870
+ /**
871
+ * Rules compare method.\
872
+ * For the «and» method the rule will be permitted if all\
873
+ * rules will be returns «permit» status and for the «or» - if\
874
+ * one of the rules returns as «permit»
875
+ */
876
+ compareMethod = AbilityCompare.and;
877
+ /**
878
+ * Policy ID
879
+ */
880
+ id;
881
+ /**
882
+ * Policy name
883
+ */
884
+ name;
885
+ description;
886
+ /**
887
+ * Running the `enforce` or `resolve` method
888
+ * will select only those from all passed policies that fall under the specified permission key.
889
+ */
890
+ permission;
891
+ priority = -1;
892
+ disabled;
893
+ tags;
894
+ constructor(params) {
895
+ const { name, description, id, permission, effect, compareMethod = AbilityCompare.and, priority, disabled, tags, } = params;
896
+ this.permission = permission;
897
+ this.description = description;
898
+ this.effect = effect;
899
+ this.compareMethod = compareMethod;
900
+ this.priority = typeof priority === 'number' ? priority : -1;
901
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
902
+ this.tags = (tags || []);
903
+ this.id = id || `p_${this.hash().slice(0, 10)}`;
904
+ this.name = name || this.id;
905
+ }
906
+ /**
907
+ * Add rule set to the policy
908
+ * @param ruleSet - The rule set to add
909
+ */
910
+ addRuleSet(ruleSet) {
911
+ this.ruleSet.push(ruleSet);
912
+ return this;
913
+ }
914
+ /**
915
+ * Add rule set to the policy
916
+ * @param ruleSets - The array of rule set to add
917
+ */
918
+ addRuleSets(ruleSets) {
919
+ for (const ruleSet of ruleSets) {
920
+ this.ruleSet.push(ruleSet);
921
+ }
922
+ return this;
923
+ }
924
+ /**
925
+ * Extract all rules of all ruleSets of this policy
926
+ */
927
+ extractRules() {
928
+ const rules = [];
929
+ for (const ruleSet of this.ruleSet) {
930
+ for (const rule of ruleSet.rules) {
931
+ rules.push(rule);
932
+ }
933
+ }
934
+ return rules;
935
+ }
936
+ /**
937
+ * Check if the policy is matched
938
+ * @param resource - The resource to check
939
+ * @param environment - The user environment object
940
+ */
941
+ check(resource, environment) {
942
+ this.matchState = AbilityMatch.mismatch;
943
+ if (this.disabled) {
944
+ this.matchState = AbilityMatch.disabled;
945
+ return this.matchState;
946
+ }
947
+ if (!this.ruleSet.length) {
948
+ return this.matchState;
949
+ }
950
+ const normalGroups = this.ruleSet.filter(g => !g.isExcept);
951
+ const exceptGroups = this.ruleSet.filter(g => g.isExcept);
952
+ const normalStates = [];
953
+ for (const group of normalGroups) {
954
+ if (group.disabled) {
955
+ continue;
956
+ }
957
+ const state = group.check(resource, environment);
958
+ normalStates.push(state);
959
+ if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
960
+ this.matchState = AbilityMatch.mismatch;
961
+ return this.matchState;
962
+ }
963
+ if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
964
+ this.matchState = AbilityMatch.match;
965
+ // break to check except-rule sets
966
+ break;
967
+ }
968
+ }
969
+ // 3. Simple rule sets
970
+ let normalMatch = false;
971
+ if (AbilityCompare.and === this.compareMethod) {
972
+ normalMatch = normalStates.every(s => AbilityMatch.match === s);
973
+ }
974
+ else {
975
+ normalMatch = normalStates.some(s => AbilityMatch.match === s);
976
+ }
977
+ if (!normalMatch) {
978
+ this.matchState = AbilityMatch.mismatch;
979
+ return this.matchState;
980
+ }
981
+ // 4. except-rule sets
982
+ for (const group of exceptGroups) {
983
+ if (group.disabled) {
984
+ continue;
985
+ }
986
+ const state = group.check(resource, environment);
987
+ if (AbilityMatch.match === state) {
988
+ this.matchState = AbilityMatch.exceptMismatch;
989
+ return this.matchState;
990
+ }
991
+ }
992
+ // 5. match
993
+ this.matchState = AbilityMatch.match;
994
+ return this.matchState;
995
+ }
996
+ explain() {
997
+ if (this.matchState === AbilityMatch.pending) {
998
+ throw new AbilityError('First, run the check method, then explain');
999
+ }
1000
+ return new AbilityExplainPolicy(this);
1001
+ }
1002
+ copyWith(props) {
1003
+ const policy = new AbilityPolicy({
1004
+ id: props.id ?? this.id,
1005
+ name: props.name ?? this.name,
1006
+ description: props.description ?? this.description,
1007
+ priority: typeof props.priority !== 'undefined' ? props.priority : this.priority,
1008
+ permission: props.permission ?? this.permission,
1009
+ effect: props.effect ?? this.effect,
1010
+ compareMethod: props.compareMethod ?? this.compareMethod,
1011
+ });
1012
+ const nextRuleSet = props.ruleSet ?? this.ruleSet;
1013
+ for (const rule of nextRuleSet) {
1014
+ policy.addRuleSet(rule);
1015
+ }
1016
+ return policy;
1017
+ }
1018
+ hash() {
1019
+ const parts = [
1020
+ `permission:${this.permission}`,
1021
+ `effect:${this.effect}`,
1022
+ `compareMethod:${this.compareMethod}`,
1023
+ `priority:${this.priority}`,
1024
+ `disabled:${this.disabled}`,
1025
+ ];
1026
+ if (this.tags && this.tags.length > 0) {
1027
+ parts.push(`tags:${[...this.tags].sort().join(',')}`);
1028
+ }
1029
+ if (this.ruleSet && this.ruleSet.length > 0) {
1030
+ const ruleHashes = this.ruleSet.map(r => r.hash());
1031
+ parts.push(`rules:${ruleHashes.sort().join('|')}`);
1032
+ }
1033
+ const str = parts.join(';');
1034
+ return AbilityHash.sha1(str);
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Represents a rule that defines a condition to be checked against a subject and resource.
1040
+ */
1041
+ class AbilityRule {
1042
+ /**
1043
+ * Subject key path like a 'user.name'
1044
+ */
1045
+ subject;
1046
+ /**
1047
+ * Resource key path like a 'user.name' or value
1048
+ */
1049
+ resource;
1050
+ condition;
1051
+ name;
1052
+ description;
1053
+ id;
1054
+ state = AbilityMatch.pending;
1055
+ disabled;
1056
+ /**
1057
+ * Creates an instance of AbilityRule.
1058
+ * @param {string} params.id - The unique identifier of the rule.
1059
+ * @param {string} params.name - The name of the rule.
1060
+ * @param {AbilityCondition} params.condition - The condition to evaluate.
1061
+ * @param {string} params.subject - The subject of the rule.
1062
+ * @param {string} params.resource - The resource to compare against.
1063
+ * @param {boolean} params.disabled - Disabling flag.
1064
+ * @param params
1065
+ */
1066
+ constructor(params) {
1067
+ const { id, name, subject, resource, condition, disabled, description } = params;
1068
+ this.description = description;
1069
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
1070
+ this.subject = subject;
1071
+ this.resource = resource;
1072
+ this.condition = condition;
1073
+ this.state = this.disabled ? AbilityMatch.disabled : this.state;
1074
+ this.id = id || `r_${this.hash().slice(0, 10)}`;
1075
+ this.name = name || this.id;
1076
+ }
1077
+ static isPrimitive(v) {
1078
+ return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
1079
+ }
1080
+ static isNumber(v) {
1081
+ return typeof v === 'number';
1082
+ }
1083
+ static isString(v) {
1084
+ return typeof v === 'string';
1085
+ }
1086
+ static valueLen = (v) => this.isString(v) || Array.isArray(v) ? v.length : null;
1087
+ static operatorHandlers = {
1088
+ [toLiteral(AbilityCondition.always)]: () => true,
1089
+ [toLiteral(AbilityCondition.defined)]: (a) => typeof a !== 'undefined',
1090
+ [toLiteral(AbilityCondition.not_defined)]: (a) => typeof a === 'undefined',
1091
+ [toLiteral(AbilityCondition.never)]: () => false,
1092
+ [toLiteral(AbilityCondition.equals)]: (a, b) => a === b,
1093
+ [toLiteral(AbilityCondition.not_equals)]: (a, b) => a !== b,
1094
+ [toLiteral(AbilityCondition.contains)]: (a, b) => {
1095
+ if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
1096
+ return a.includes(b);
1097
+ }
1098
+ if (Array.isArray(a) && Array.isArray(b)) {
1099
+ return a.some(v => b.includes(v));
1100
+ }
1101
+ return false;
1102
+ },
1103
+ [toLiteral(AbilityCondition.not_contains)]: (a, b) => {
1104
+ if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
1105
+ return !a.includes(b);
1106
+ }
1107
+ if (Array.isArray(a) && Array.isArray(b)) {
1108
+ return !a.some(v => b.includes(v));
1109
+ }
1110
+ return false;
1111
+ },
1112
+ [toLiteral(AbilityCondition.in)]: (a, b) => {
1113
+ if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
1114
+ return b.includes(a);
1115
+ }
1116
+ if (Array.isArray(a) && Array.isArray(b)) {
1117
+ return a.some(v => b.includes(v));
1118
+ }
1119
+ return false;
1120
+ },
1121
+ [toLiteral(AbilityCondition.not_in)]: (a, b) => {
1122
+ if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
1123
+ return !b.includes(a);
1124
+ }
1125
+ if (Array.isArray(a) && Array.isArray(b)) {
1126
+ return !a.some(v => b.includes(v));
1127
+ }
1128
+ return false;
1129
+ },
1130
+ [toLiteral(AbilityCondition.greater_than)]: (a, b) => {
1131
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a > b : false;
1132
+ },
1133
+ [toLiteral(AbilityCondition.less_than)]: (a, b) => {
1134
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a < b : false;
1135
+ },
1136
+ [toLiteral(AbilityCondition.greater_or_equal)]: (a, b) => {
1137
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a >= b : false;
1138
+ },
1139
+ [toLiteral(AbilityCondition.less_or_equal)]: (a, b) => {
1140
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a <= b : false;
1141
+ },
1142
+ [toLiteral(AbilityCondition.length_greater_than)]: (a, b) => {
1143
+ const alen = AbilityRule.valueLen(a);
1144
+ if (alen === null) {
1145
+ return false;
1146
+ }
1147
+ if (AbilityRule.isNumber(b)) {
1148
+ return alen > b;
1149
+ }
1150
+ const bLen = AbilityRule.valueLen(b);
1151
+ if (bLen !== null) {
1152
+ return alen > bLen;
1153
+ }
1154
+ return false;
1155
+ },
1156
+ [toLiteral(AbilityCondition.length_less_than)]: (a, b) => {
1157
+ const alen = AbilityRule.valueLen(a);
1158
+ if (alen === null) {
1159
+ return false;
1160
+ }
1161
+ if (AbilityRule.isNumber(b)) {
1162
+ return alen < b;
1163
+ }
1164
+ const bLen = AbilityRule.valueLen(b);
1165
+ if (bLen !== null) {
1166
+ return alen < bLen;
1167
+ }
1168
+ return false;
1169
+ },
1170
+ [toLiteral(AbilityCondition.length_equals)]: (a, b) => {
1171
+ const alen = AbilityRule.valueLen(a);
1172
+ if (alen === null) {
1173
+ return false;
1174
+ }
1175
+ if (AbilityRule.isNumber(b)) {
1176
+ return alen === b;
1177
+ }
1178
+ const bLen = AbilityRule.valueLen(b);
1179
+ if (bLen !== null) {
1180
+ return alen === bLen;
1181
+ }
1182
+ return false;
1183
+ },
1184
+ };
1185
+ /**
1186
+ * Check if the rule is matched
1187
+ * @param resource - The resource to check
1188
+ * @param environment
1189
+ */
1190
+ check(resource, environment) {
1191
+ if (this.disabled) {
1192
+ this.state = AbilityMatch.disabled;
1193
+ return this.state;
1194
+ }
1195
+ const [subjectValue, resourceValue] = this.extractValues(resource, environment);
1196
+ const handler = AbilityRule.operatorHandlers[toLiteral(this.condition)];
1197
+ const result = handler(subjectValue, resourceValue);
1198
+ this.state = result ? AbilityMatch.match : AbilityMatch.mismatch;
1199
+ return this.state;
1200
+ }
1201
+ /**
1202
+ * Extract values from the resourceData
1203
+ * @param resourceData - The resourceData to extract values from
1204
+ * @param environment - Environment data
1205
+ */
1206
+ extractValues(resourceData, environment) {
1207
+ let subjectValue;
1208
+ let resourceValue;
1209
+ if ((resourceData === null || typeof resourceData === 'undefined') &&
1210
+ (environment === null || typeof environment === 'undefined')) {
1211
+ return [NaN, NaN];
1212
+ }
1213
+ // left side resolve
1214
+ if (this.subject.includes('.')) {
1215
+ // if is environment
1216
+ if (this.subject.startsWith('env.') && typeof environment !== 'undefined') {
1217
+ subjectValue = this.getDotNotationValue(environment, this.subject.replace(/^env\./, ''));
1218
+ // if is resource
1219
+ }
1220
+ else {
1221
+ subjectValue = this.getDotNotationValue(resourceData, this.subject);
1222
+ }
1223
+ }
1224
+ else {
1225
+ subjectValue = this.subject;
1226
+ }
1227
+ // right side resolve
1228
+ if (typeof this.resource === 'string' && this.resource.includes('.')) {
1229
+ // if is environment
1230
+ if (this.resource.startsWith('env.') && typeof environment !== 'undefined') {
1231
+ resourceValue = this.getDotNotationValue(environment, this.resource.replace(/^env\./, ''));
1232
+ }
1233
+ else {
1234
+ // if is resource
1235
+ resourceValue = this.getDotNotationValue(resourceData, this.resource);
1236
+ }
1237
+ }
1238
+ else {
1239
+ resourceValue = this.resource;
1240
+ }
1241
+ return [subjectValue, resourceValue];
1242
+ }
1243
+ static _pathCache = new Map();
1244
+ static _parsePath(desc) {
1245
+ const cached = AbilityRule._pathCache.get(desc);
1246
+ if (cached)
1247
+ return cached;
1248
+ const parts = desc.split('.');
1249
+ const segments = [];
1250
+ for (const part of parts) {
1251
+ const bracketIdx = part.indexOf('[');
1252
+ if (bracketIdx !== -1) {
1253
+ // формат: "prop[index]" (индекс может быть только числом)
1254
+ const prop = part.slice(0, bracketIdx);
1255
+ const indexStr = part.slice(bracketIdx + 1, -1);
1256
+ const index = Number(indexStr);
1257
+ segments.push({ prop, index });
1258
+ }
1259
+ else {
1260
+ segments.push(part);
1261
+ }
1262
+ }
1263
+ AbilityRule._pathCache.set(desc, segments);
1264
+ return segments;
1265
+ }
1266
+ /**
1267
+ * Get the value of the object by dot notation
1268
+ * @param resource - The object to get the value from
1269
+ * @param desc - The dot notation string
1270
+ */
1271
+ getDotNotationValue(resource, desc) {
1272
+ if (resource == null) {
1273
+ return undefined;
1274
+ }
1275
+ const segments = AbilityRule._parsePath(desc);
1276
+ let current = resource;
1277
+ for (const seg of segments) {
1278
+ if (current == null) {
1279
+ return undefined;
1280
+ }
1281
+ if (typeof seg === 'string') {
1282
+ current = current[seg];
1283
+ }
1284
+ else {
1285
+ const arr = current[seg.prop];
1286
+ current = Array.isArray(arr) ? arr[seg.index] : undefined;
1287
+ }
1288
+ }
1289
+ return current;
1290
+ }
1291
+ toString() {
1292
+ return `AbilityRule: ${this.name} condition: ${toLiteral(this.condition)} subject: "${this.subject?.toString()}" resource: "${this.resource?.toString()}"`;
1293
+ }
1294
+ copyWith(props) {
1295
+ return new AbilityRule({
1296
+ id: props.id ?? this.id,
1297
+ name: props.name ?? this.name,
1298
+ description: props.description ?? this.description,
1299
+ subject: props.subject ?? this.subject,
1300
+ resource: props.resource ?? this.resource,
1301
+ condition: props.condition ?? this.condition,
1302
+ });
1303
+ }
1304
+ hash() {
1305
+ const parts = [];
1306
+ parts.push(`subject:${this.subject}`);
1307
+ parts.push(`resource:${JSON.stringify(this.resource)}`);
1308
+ parts.push(`condition:${this.condition}`);
1309
+ parts.push(`disabled:${this.disabled}`);
1310
+ return AbilityHash.sha1(parts.join(';'));
1311
+ }
1312
+ static equals(subject, resource) {
1313
+ return new AbilityRule({
1314
+ condition: AbilityCondition.equals,
1315
+ subject,
1316
+ resource,
1317
+ });
1318
+ }
1319
+ static notEquals(subject, resource) {
1320
+ return new AbilityRule({
1321
+ condition: AbilityCondition.not_equals,
1322
+ subject,
1323
+ resource,
1324
+ });
1325
+ }
1326
+ static contains(subject, resource) {
1327
+ return new AbilityRule({
1328
+ condition: AbilityCondition.contains,
1329
+ subject,
1330
+ resource,
1331
+ });
1332
+ }
1333
+ static notContains(subject, resource) {
1334
+ return new AbilityRule({
1335
+ condition: AbilityCondition.not_contains,
1336
+ subject,
1337
+ resource,
1338
+ });
1339
+ }
1340
+ static notIn(subject, resource) {
1341
+ return new AbilityRule({
1342
+ condition: AbilityCondition.not_in,
1343
+ subject,
1344
+ resource,
1345
+ });
1346
+ }
1347
+ static in(subject, resource) {
1348
+ return new AbilityRule({
1349
+ condition: AbilityCondition.in,
1350
+ subject,
1351
+ resource,
1352
+ });
1353
+ }
1354
+ static notEqual(subject, resource) {
1355
+ return new AbilityRule({
1356
+ condition: AbilityCondition.not_equals,
1357
+ subject,
1358
+ resource,
1359
+ });
1360
+ }
1361
+ static lessThan(subject, resource) {
1362
+ return new AbilityRule({
1363
+ condition: AbilityCondition.less_than,
1364
+ subject,
1365
+ resource,
1366
+ });
1367
+ }
1368
+ static lessOrEqual(subject, resource) {
1369
+ return new AbilityRule({
1370
+ condition: AbilityCondition.less_or_equal,
1371
+ subject,
1372
+ resource,
1373
+ });
1374
+ }
1375
+ static moreThan(subject, resource) {
1376
+ return new AbilityRule({
1377
+ condition: AbilityCondition.greater_than,
1378
+ subject,
1379
+ resource,
1380
+ });
1381
+ }
1382
+ static moreOrEqual(subject, resource) {
1383
+ return new AbilityRule({
1384
+ condition: AbilityCondition.greater_or_equal,
1385
+ subject,
1386
+ resource,
1387
+ });
1388
+ }
1389
+ }
1390
+
1391
+ class AbilityRuleSet {
1392
+ state = AbilityMatch.pending;
1393
+ /**
1394
+ * List of rules
1395
+ */
1396
+ rules = [];
1397
+ /**
1398
+ * Rules compare method.\
1399
+ * For the «and» method the rule will be permitted if all\
1400
+ * rules will be returns «permit» status and for the «or» - if\
1401
+ * one of the rules returns as «permit»
1402
+ */
1403
+ compareMethod = AbilityCompare.and;
1404
+ /**
1405
+ * Group name
1406
+ */
1407
+ name;
1408
+ description;
1409
+ /**
1410
+ * Group ID
1411
+ */
1412
+ id;
1413
+ isExcept = false;
1414
+ disabled;
1415
+ constructor(params) {
1416
+ const { name, id, compareMethod, isExcept, disabled, description } = params;
1417
+ this.description = description;
1418
+ this.compareMethod = compareMethod;
1419
+ this.isExcept = isExcept;
1420
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
1421
+ this.state = this.disabled ? AbilityMatch.disabled : this.state;
1422
+ this.id = id || `g_${this.hash().slice(0, 10)}`;
1423
+ this.name = name || this.id;
1424
+ }
1425
+ addRule(rule) {
1426
+ this.rules.push(rule);
1427
+ return this;
1428
+ }
1429
+ addRules(rules) {
1430
+ rules.forEach(rule => this.addRule(rule));
1431
+ return this;
1432
+ }
1433
+ check(resources, environment) {
1434
+ this.state = AbilityMatch.mismatch;
1435
+ if (this.disabled) {
1436
+ this.state = AbilityMatch.disabled;
1437
+ return this.state;
1438
+ }
1439
+ if (!this.rules.length) {
1440
+ return this.state;
1441
+ }
1442
+ const ruleCheckStates = [];
1443
+ for (const rule of this.rules) {
1444
+ if (rule.disabled) {
1445
+ continue;
1446
+ }
1447
+ const state = rule.check(resources, environment);
1448
+ ruleCheckStates.push(state);
1449
+ if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
1450
+ return this.state; // mismatch
1451
+ }
1452
+ if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
1453
+ this.state = AbilityMatch.match;
1454
+ return this.state;
1455
+ }
1456
+ }
1457
+ if (AbilityCompare.and === this.compareMethod) {
1458
+ if (ruleCheckStates.every(s => AbilityMatch.match === s)) {
1459
+ this.state = AbilityMatch.match;
1460
+ }
1461
+ }
1462
+ if (AbilityCompare.or === this.compareMethod) {
1463
+ if (ruleCheckStates.some(s => AbilityMatch.match === s)) {
1464
+ this.state = AbilityMatch.match;
1465
+ }
1466
+ }
1467
+ return this.state;
1468
+ }
1469
+ toString() {
1470
+ return `AbilityRuleSet: ${this.name} compareMethod: ${this.compareMethod}, rules: ${this.rules.map(rule => rule.toString()).join('\n')}`;
1471
+ }
1472
+ copyWith(props) {
1473
+ const next = new AbilityRuleSet({
1474
+ id: props.id ?? this.id,
1475
+ name: props.name ?? this.name,
1476
+ description: props.description ?? this.description,
1477
+ compareMethod: props.compareMethod ?? this.compareMethod,
1478
+ });
1479
+ const nextRules = props.rules ?? this.rules;
1480
+ for (const rule of nextRules) {
1481
+ next.addRule(rule);
1482
+ }
1483
+ return next;
1484
+ }
1485
+ hash() {
1486
+ const ruleHashes = this.rules.map(r => r.hash()).sort();
1487
+ const parts = [
1488
+ `compareMethod:${this.compareMethod}`,
1489
+ `isExcept:${this.isExcept}`,
1490
+ `disabled:${this.disabled}`,
1491
+ `rules:${ruleHashes.join('|')}`,
1492
+ ];
1493
+ return AbilityHash.sha1(parts.join(';'));
1494
+ }
1495
+ static and(rules) {
1496
+ return new AbilityRuleSet({
1497
+ compareMethod: AbilityCompare.and,
1498
+ }).addRules(rules);
1499
+ }
1500
+ static or(rules) {
1501
+ return new AbilityRuleSet({
1502
+ compareMethod: AbilityCompare.or,
1503
+ }).addRules(rules);
1504
+ }
1505
+ }
1506
+
1507
+ class AbilityJSONParser {
1508
+ /**
1509
+ * Parses an array of policy configurations into an array of AbilityPolicy instances.
1510
+ * @param configs - Array of policy configurations
1511
+ * @returns Array of AbilityPolicy instances
1512
+ */
1513
+ static parse(configs) {
1514
+ return configs.map(config => AbilityJSONParser.parsePolicy(config));
1515
+ }
1516
+ static parsePolicy(config) {
1517
+ const { id, name, ruleSet, compareMethod, permission, effect, priority, disabled, tags } = config;
1518
+ // Create the empty policy
1519
+ const policy = new AbilityPolicy({
1520
+ name,
1521
+ id,
1522
+ permission: permission,
1523
+ priority: priority,
1524
+ effect: effect,
1525
+ disabled,
1526
+ tags,
1527
+ });
1528
+ policy.compareMethod = compareMethod;
1529
+ ruleSet.forEach(ruleSetConfig => {
1530
+ policy.addRuleSet(AbilityJSONParser.parseRuleSet(ruleSetConfig));
1531
+ });
1532
+ return policy;
1533
+ }
1534
+ static parseRule(config) {
1535
+ const { id, name, subject, resource, condition, disabled } = config;
1536
+ return new AbilityRule({
1537
+ id,
1538
+ name,
1539
+ subject,
1540
+ resource,
1541
+ disabled,
1542
+ condition,
1543
+ });
1544
+ }
1545
+ /**
1546
+ * Parse the config JSON format to Group class instance
1547
+ */
1548
+ static parseRuleSet(config) {
1549
+ const { id, name, rules, compareMethod, disabled } = config;
1550
+ const ruleSet = new AbilityRuleSet({
1551
+ disabled,
1552
+ compareMethod: compareMethod,
1553
+ name,
1554
+ id,
1555
+ });
1556
+ // Adding rules if exists
1557
+ if (rules && rules.length > 0) {
1558
+ const abilityRules = rules.map(ruleConfig => AbilityJSONParser.parseRule(ruleConfig));
1559
+ ruleSet.addRules(abilityRules);
1560
+ }
1561
+ return ruleSet;
1562
+ }
1563
+ static ruleToJSON(rule) {
1564
+ return {
1565
+ id: rule.id,
1566
+ name: rule.name,
1567
+ disabled: rule.disabled,
1568
+ subject: rule.subject,
1569
+ resource: rule.resource,
1570
+ condition: rule.condition,
1571
+ };
1572
+ }
1573
+ static ruleSetToJSON(ruleSet) {
1574
+ return {
1575
+ id: ruleSet.id.toString(),
1576
+ name: ruleSet.name.toString(),
1577
+ disabled: ruleSet.disabled,
1578
+ compareMethod: ruleSet.compareMethod,
1579
+ rules: ruleSet.rules.map(rule => AbilityJSONParser.ruleToJSON(rule)),
1580
+ };
1581
+ }
1582
+ static policyToJSON(policy) {
1583
+ return {
1584
+ id: policy.id.toString(),
1585
+ name: policy.name.toString(),
1586
+ disabled: policy.disabled,
1587
+ priority: policy.priority,
1588
+ permission: policy.permission,
1589
+ effect: policy.effect,
1590
+ compareMethod: policy.compareMethod,
1591
+ tags: policy.tags,
1592
+ ruleSet: policy.ruleSet.map(ruleSet => AbilityJSONParser.ruleSetToJSON(ruleSet)),
1593
+ };
1594
+ }
1595
+ static toJSON(policies) {
1596
+ return policies.map(policy => AbilityJSONParser.policyToJSON(policy));
1597
+ }
1598
+ }
1599
+
1600
+ function brand(code) {
1601
+ return code;
1602
+ }
1603
+ const TokenTypes = {
1604
+ EFFECT: brand('EFFECT'),
1605
+ IF: brand('IF'),
1606
+ PERMISSION: brand('PERMISSION'),
1607
+ IDENTIFIER: brand('IDENTIFIER'),
1608
+ COLON: brand('COLON'),
1609
+ COMMA: brand('COMMA'),
1610
+ DOT: brand('DOT'),
1611
+ LBRACKET: brand('LBRACKET'),
1612
+ RBRACKET: brand('RBRACKET'),
1613
+ ALL: brand('ALL'),
1614
+ ANY: brand('ANY'),
1615
+ OF: brand('OF'),
1616
+ EOF: brand('EOF'),
1617
+ COMMENT: brand('COMMENT'),
1618
+ EQ: brand('EQ'),
1619
+ CONTAINS: brand('CONTAINS'),
1620
+ IN: brand('IN'),
1621
+ NOT_IN: brand('NOT_IN'),
1622
+ NOT_CONTAINS: brand('NOT_CONTAINS'),
1623
+ GT: brand('GT'),
1624
+ GTE: brand('GTE'),
1625
+ LT: brand('LT'),
1626
+ LTE: brand('LTE'),
1627
+ NULL: brand('NULL'),
1628
+ EQ_NULL: brand('EQ_NULL'),
1629
+ NOT_EQ_NULL: brand('NOT_EQ_NULL'),
1630
+ DEFINED: brand('DEFINED'),
1631
+ NOT_EQ: brand('NOT_EQ'),
1632
+ LEN_GT: brand('LEN_GT'),
1633
+ LEN_LT: brand('LEN_LT'),
1634
+ LEN_EQ: brand('LEN_EQ'),
1635
+ ALWAYS: brand('ALWAYS'),
1636
+ NEVER: brand('NEVER'),
1637
+ EXCEPT: brand('EXCEPT'),
1638
+ ANNOTATION: brand('ANNOTATION'),
1639
+ STRING: brand('STRING'),
1640
+ NUMBER: brand('NUMBER'),
1641
+ BOOLEAN: brand('BOOLEAN'),
1642
+ SYMBOL: brand('SYMBOL'),
1643
+ KEYWORD: brand('KEYWORD'),
1644
+ ALIAS: brand('ALIAS'),
1645
+ UNKNOWN: brand('UNKNOWN'),
1646
+ };
1647
+ class AbilityDSLToken {
1648
+ type;
1649
+ value;
1650
+ line;
1651
+ column;
1652
+ constructor(type, value, line, column) {
1653
+ this.type = type;
1654
+ this.value = value;
1655
+ this.line = line;
1656
+ this.column = column;
1657
+ }
1658
+ toString() {
1659
+ return `AbilityDSLToken([${this.type}] "${this.value}" at ${this.line}:${this.column})`;
1660
+ }
1661
+ }
1662
+
1663
+ class AbilityDSLLexer {
1664
+ input;
1665
+ pos = 0;
1666
+ tokens = [];
1667
+ line = 1;
1668
+ column = 1;
1669
+ keywords = new Set([
1670
+ 'if',
1671
+ 'all',
1672
+ 'any',
1673
+ 'of',
1674
+ 'permit',
1675
+ 'allow',
1676
+ 'deny',
1677
+ 'forbidden',
1678
+ 'true',
1679
+ 'false',
1680
+ 'null',
1681
+ 'defined',
1682
+ 'contains',
1683
+ 'includes',
1684
+ 'length',
1685
+ 'len',
1686
+ 'has',
1687
+ 'in',
1688
+ 'gt',
1689
+ 'lt',
1690
+ 'gte',
1691
+ 'lte',
1692
+ 'equals',
1693
+ 'greater',
1694
+ 'less',
1695
+ 'not',
1696
+ 'is',
1697
+ 'or',
1698
+ 'than',
1699
+ 'always',
1700
+ 'never',
1701
+ 'except',
1702
+ 'alias',
1703
+ ]);
1704
+ constructor(input) {
1705
+ this.input = input;
1706
+ }
1707
+ tokenize() {
1708
+ while (!this.isAtEnd()) {
1709
+ this.skipWhitespace();
1710
+ if (this.isAtEnd())
1711
+ break;
1712
+ const char = this.peek();
1713
+ if (char === '@') {
1714
+ this.tokens.push(this.readAnnotation());
1715
+ continue;
1716
+ }
1717
+ if (char === '#') {
1718
+ this.tokens.push(this.readComment());
1719
+ continue;
1720
+ }
1721
+ if (char === '"' || char === "'") {
1722
+ this.tokens.push(this.readString());
1723
+ continue;
1724
+ }
1725
+ if (this.isDigit(char)) {
1726
+ this.tokens.push(this.readNumber());
1727
+ continue;
1728
+ }
1729
+ if (this.isSymbol(char)) {
1730
+ this.tokens.push(this.readSymbol());
1731
+ continue;
1732
+ }
1733
+ if (this.isAlpha(char)) {
1734
+ this.tokens.push(this.readWord());
1735
+ continue;
1736
+ }
1737
+ throw new Error(`Unexpected character '${char}' at ${this.line}:${this.column}`);
1738
+ }
1739
+ this.tokens.push(new AbilityDSLToken(TokenTypes.EOF, '', this.line, this.column));
1740
+ return this.tokens;
1741
+ }
1742
+ readComment() {
1743
+ const startLine = this.line;
1744
+ const startColumn = this.column;
1745
+ this.advance(); // skip '#'
1746
+ let value = '';
1747
+ while (!this.isAtEnd() && !this.isNewline()) {
1748
+ value += this.advance();
1749
+ }
1750
+ return new AbilityDSLToken(TokenTypes.COMMENT, value.trim(), startLine, startColumn);
1751
+ }
1752
+ // private readAlias(): AbilityDSLToken {
1753
+ // const startLine = this.line;
1754
+ // const startColumn = this.column;
1755
+ //
1756
+ // this.advance(); // skip "alias" keyword
1757
+ //
1758
+ // // Read colon
1759
+ // this.readSymbol();
1760
+ //
1761
+ // let value = '';
1762
+ // while (!this.isAtEnd() && !this.isNewline()) {
1763
+ // value += this.advance();
1764
+ // }
1765
+ // return new AbilityDSLToken(TokenTypes.ALIAS, value.trim(), startLine, startColumn);
1766
+ // }
1767
+ readAnnotation() {
1768
+ const startLine = this.line;
1769
+ const startColumn = this.column;
1770
+ let raw = '';
1771
+ // читаем всю строку после @
1772
+ while (!this.isAtEnd() && !this.isNewline()) {
1773
+ raw += this.advance();
1774
+ }
1775
+ raw = raw.trim();
1776
+ // parse literals
1777
+ let result = '';
1778
+ let i = 0;
1779
+ let inString = false;
1780
+ let quote = null;
1781
+ let escaped = false;
1782
+ while (i < raw.length) {
1783
+ const ch = raw[i];
1784
+ if (inString) {
1785
+ if (escaped) {
1786
+ result += ch;
1787
+ escaped = false;
1788
+ }
1789
+ else if (ch === '\\') {
1790
+ escaped = true;
1791
+ }
1792
+ else if (ch === quote) {
1793
+ inString = false;
1794
+ quote = null;
1795
+ }
1796
+ else {
1797
+ result += ch;
1798
+ }
1799
+ i++;
1800
+ continue;
1801
+ }
1802
+ // start of string
1803
+ if (ch === '"' || ch === "'") {
1804
+ inString = true;
1805
+ quote = ch;
1806
+ i++;
1807
+ continue;
1808
+ }
1809
+ result += ch;
1810
+ i++;
1811
+ }
1812
+ return new AbilityDSLToken(TokenTypes.ANNOTATION, result.trim(), startLine, startColumn);
1813
+ }
1814
+ readString() {
1815
+ const startLine = this.line;
1816
+ const startColumn = this.column;
1817
+ const quote = this.advance();
1818
+ let value = '';
1819
+ let escaped = false;
1820
+ while (!this.isAtEnd()) {
1821
+ const char = this.advance();
1822
+ if (escaped) {
1823
+ value += char;
1824
+ escaped = false;
1825
+ continue;
1826
+ }
1827
+ if (char === '\\') {
1828
+ escaped = true;
1829
+ continue;
1830
+ }
1831
+ if (char === quote) {
1832
+ return new AbilityDSLToken(TokenTypes.STRING, value, startLine, startColumn);
1833
+ }
1834
+ value += char;
1835
+ }
1836
+ throw new Error(`Unterminated string at ${startLine}:${startColumn}`);
1837
+ }
1838
+ readNumber() {
1839
+ const startLine = this.line;
1840
+ const startColumn = this.column;
1841
+ const start = this.pos;
1842
+ while (!this.isAtEnd() && this.isDigit(this.peek())) {
1843
+ this.advance();
1844
+ }
1845
+ const value = this.input.slice(start, this.pos);
1846
+ return new AbilityDSLToken(TokenTypes.NUMBER, value, startLine, startColumn);
1847
+ }
1848
+ readSymbol() {
1849
+ const startLine = this.line;
1850
+ const startColumn = this.column;
1851
+ const char = this.advance();
1852
+ switch (char) {
1853
+ case '.':
1854
+ return new AbilityDSLToken(TokenTypes.DOT, char, startLine, startColumn);
1855
+ case ':':
1856
+ return new AbilityDSLToken(TokenTypes.COLON, char, startLine, startColumn);
1857
+ case ',':
1858
+ return new AbilityDSLToken(TokenTypes.COMMA, char, startLine, startColumn);
1859
+ case '[':
1860
+ return new AbilityDSLToken(TokenTypes.LBRACKET, char, startLine, startColumn);
1861
+ case ']':
1862
+ return new AbilityDSLToken(TokenTypes.RBRACKET, char, startLine, startColumn);
1863
+ case '>':
1864
+ if (this.peek() === '=') {
1865
+ this.advance();
1866
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '>=', startLine, startColumn);
1867
+ }
1868
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '>', startLine, startColumn);
1869
+ case '<':
1870
+ if (this.peek() === '=') {
1871
+ this.advance();
1872
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<=', startLine, startColumn);
1873
+ }
1874
+ if (this.peek() === '>') {
1875
+ this.advance();
1876
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<>', startLine, startColumn);
1877
+ }
1878
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<', startLine, startColumn);
1879
+ case '=':
1880
+ if (this.peek() === '=') {
1881
+ this.advance();
1882
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '==', startLine, startColumn);
1883
+ }
1884
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '=', startLine, startColumn);
1885
+ case '!':
1886
+ if (this.peek() === '=') {
1887
+ this.advance();
1888
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '!=', startLine, startColumn);
1889
+ }
1890
+ throw new Error(`Unexpected symbol '!' at ${this.line}:${this.column}`);
1891
+ default:
1892
+ throw new Error(`Unknown symbol '${char}' at ${this.line}:${this.column}`);
1893
+ }
1894
+ }
1895
+ readWord() {
1896
+ const startLine = this.line;
1897
+ const startColumn = this.column;
1898
+ const start = this.pos;
1899
+ // First segment
1900
+ while (!this.isAtEnd() && /[a-zA-Z0-9_*]/.test(this.peek())) {
1901
+ this.advance();
1902
+ }
1903
+ // dots segments
1904
+ while (!this.isAtEnd() && this.peek() === '.') {
1905
+ this.advance(); // dot
1906
+ if (!/[a-zA-Z_*]/.test(this.peek())) {
1907
+ break;
1908
+ }
1909
+ while (!this.isAtEnd() && /[a-zA-Z0-9_*]/.test(this.peek())) {
1910
+ this.advance();
1911
+ }
1912
+ }
1913
+ const word = this.input.slice(start, this.pos);
1914
+ if (word === 'always') {
1915
+ return new AbilityDSLToken(TokenTypes.ALWAYS, word, startLine, startColumn);
1916
+ }
1917
+ if (word === 'never') {
1918
+ return new AbilityDSLToken(TokenTypes.NEVER, word, startLine, startColumn);
1919
+ }
1920
+ // (identifier or permission)
1921
+ if (word.includes('.')) {
1922
+ const last = this.tokens[this.tokens.length - 1];
1923
+ if (last?.type === TokenTypes.EFFECT) {
1924
+ if (word.startsWith('permission.')) {
1925
+ return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
1926
+ }
1927
+ }
1928
+ return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
1929
+ }
1930
+ if (this.keywords.has(word)) {
1931
+ if (word === 'permit' || word === 'allow') {
1932
+ return new AbilityDSLToken(TokenTypes.EFFECT, 'permit', startLine, startColumn);
1933
+ }
1934
+ if (word === 'deny' || word === 'forbidden') {
1935
+ return new AbilityDSLToken(TokenTypes.EFFECT, 'deny', startLine, startColumn);
1936
+ }
1937
+ if (word === 'all') {
1938
+ return new AbilityDSLToken(TokenTypes.ALL, word, startLine, startColumn);
1939
+ }
1940
+ if (word === 'any') {
1941
+ return new AbilityDSLToken(TokenTypes.ANY, word, startLine, startColumn);
1942
+ }
1943
+ if (word === 'of') {
1944
+ return new AbilityDSLToken(TokenTypes.OF, word, startLine, startColumn);
1945
+ }
1946
+ if (word === 'if') {
1947
+ return new AbilityDSLToken(TokenTypes.IF, word, startLine, startColumn);
1948
+ }
1949
+ if (word === 'true' || word === 'false') {
1950
+ return new AbilityDSLToken(TokenTypes.BOOLEAN, word, startLine, startColumn);
1951
+ }
1952
+ if (word === 'null') {
1953
+ return new AbilityDSLToken(TokenTypes.NULL, word, startLine, startColumn);
1954
+ }
1955
+ if (word === 'defined') {
1956
+ return new AbilityDSLToken(TokenTypes.DEFINED, word, startLine, startColumn);
1957
+ }
1958
+ if (word === 'except') {
1959
+ return new AbilityDSLToken(TokenTypes.EXCEPT, word, startLine, startColumn);
1960
+ }
1961
+ if (word === 'alias') {
1962
+ return new AbilityDSLToken(TokenTypes.ALIAS, word, startLine, startColumn);
1963
+ }
1964
+ // Остальные ключевые слова (contains, in, equals, greater, less, not, is, or, than, equal)
1965
+ return new AbilityDSLToken(TokenTypes.KEYWORD, word, startLine, startColumn);
1966
+ }
1967
+ // Если после EFFECT и нет точки — действие (например, "create")
1968
+ const lastToken = this.tokens[this.tokens.length - 1];
1969
+ if (lastToken?.type === TokenTypes.EFFECT) {
1970
+ return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
1971
+ }
1972
+ // Обычный идентификатор
1973
+ return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
1974
+ }
1975
+ skipWhitespace() {
1976
+ while (!this.isAtEnd() && /\s/.test(this.peek())) {
1977
+ this.advance();
1978
+ }
1979
+ }
1980
+ isDigit(char) {
1981
+ return char >= '0' && char <= '9';
1982
+ }
1983
+ isAlpha(char) {
1984
+ return /[a-zA-Z_]/.test(char);
1985
+ }
1986
+ isSymbol(char) {
1987
+ return ['.', ':', ',', '[', ']', '>', '<', '=', '!'].includes(char);
1988
+ }
1989
+ isNewline() {
1990
+ return this.peek() === '\n';
1991
+ }
1992
+ peek() {
1993
+ return this.input[this.pos];
1994
+ }
1995
+ advance() {
1996
+ const ch = this.input[this.pos++];
1997
+ if (ch === '\n') {
1998
+ this.line++;
1999
+ this.column = 1;
2000
+ }
2001
+ else {
2002
+ this.column++;
2003
+ }
2004
+ return ch;
2005
+ }
2006
+ isAtEnd() {
2007
+ return this.pos >= this.input.length;
2008
+ }
2009
+ }
2010
+
2011
+ class AbilityDSLSyntaxError extends Error {
2012
+ line;
2013
+ column;
2014
+ context;
2015
+ details;
2016
+ _formattedMessage;
2017
+ _originalStack;
2018
+ constructor(line, column, context, // строка DSL + ^ + соседние строки
2019
+ details) {
2020
+ super(details.split('\n')[0]); // message = только первая строка
2021
+ this.line = line;
2022
+ this.column = column;
2023
+ this.context = context;
2024
+ this.details = details;
2025
+ this.name = 'AbilityDSLSyntaxError';
2026
+ if (Error.captureStackTrace) {
2027
+ Error.captureStackTrace(this, AbilityDSLSyntaxError);
2028
+ }
2029
+ this._originalStack = this.stack;
2030
+ this._formattedMessage = this.formatMessage();
2031
+ Object.defineProperty(this, 'stack', {
2032
+ get: () => this._formattedMessage,
2033
+ configurable: true,
2034
+ });
2035
+ }
2036
+ static supportsColor() {
2037
+ return typeof process !== 'undefined' && process.stdout?.isTTY;
2038
+ }
2039
+ formatMessage() {
2040
+ const useColor = AbilityDSLSyntaxError.supportsColor();
2041
+ const BOLD = useColor ? '\x1b[1m' : '';
2042
+ const RED = useColor ? '\x1b[31m' : '';
2043
+ const ORANGE = useColor ? '\x1b[33;1m' : '';
2044
+ const GRAY = useColor ? '\x1b[90m' : '';
2045
+ const RESET = useColor ? '\x1b[0m' : '';
2046
+ const lines = this.context.split('\n');
2047
+ // Find line with ^
2048
+ const pointerIndex = lines.findIndex(l => l.includes('^') || l.includes('~'));
2049
+ const commentIndex = lines.findIndex(l => l.trim().includes('#'));
2050
+ const formattedLines = lines.map((line, idx) => {
2051
+ if (idx === pointerIndex - 1) {
2052
+ // Error line
2053
+ return `${BOLD}${ORANGE}${line}${RESET}`;
2054
+ }
2055
+ if (idx === pointerIndex) {
2056
+ // Error with ~~~~~
2057
+ return `${RED}${line}${RESET}`;
2058
+ }
2059
+ // Comments # ...
2060
+ if (idx === commentIndex) {
2061
+ return `${GRAY}${line}${RESET}`;
2062
+ }
2063
+ return line;
2064
+ });
2065
+ const contextBlock = formattedLines.join('\n');
2066
+ return `${BOLD}${RED}${this.name}: ${this.details}${RESET}\n\n` + contextBlock;
2067
+ }
2068
+ toString() {
2069
+ return this._formattedMessage;
2070
+ }
2071
+ }
2072
+
2073
+ class AbilityDSLTokenStream {
2074
+ tokens;
2075
+ pos = 0;
2076
+ dsl;
2077
+ marks = [];
2078
+ lastToken = null;
2079
+ next() {
2080
+ const token = this.tokens[this.pos++];
2081
+ this.lastToken = token;
2082
+ return token;
2083
+ }
2084
+ prev() {
2085
+ if (this.pos === 0) {
2086
+ return null;
2087
+ }
2088
+ const token = this.tokens[this.pos--];
2089
+ this.lastToken = token;
2090
+ return token;
2091
+ }
2092
+ lookPrev() {
2093
+ return this.lastToken;
2094
+ }
2095
+ constructor(tokens, dsl) {
2096
+ this.tokens = tokens;
2097
+ this.dsl = dsl;
2098
+ }
2099
+ peek() {
2100
+ return this.tokens[this.pos];
2101
+ }
2102
+ eof() {
2103
+ return this.peek().type === TokenTypes.EOF;
2104
+ }
2105
+ check(type) {
2106
+ if (this.eof()) {
2107
+ return false;
2108
+ }
2109
+ const p = this.peek().type;
2110
+ return p === type;
2111
+ }
2112
+ match(type) {
2113
+ if (this.check(type)) {
2114
+ return this.next();
2115
+ }
2116
+ return null;
2117
+ }
2118
+ expect(type, message) {
2119
+ const token = this.peek();
2120
+ if (token && token.type === type) {
2121
+ return this.next();
2122
+ }
2123
+ this.syntaxError(message, token, [type]);
2124
+ }
2125
+ expectOneOf(types, message) {
2126
+ const token = this.peek();
2127
+ for (const t of types) {
2128
+ if (token && token.type === t) {
2129
+ return this.next();
2130
+ }
2131
+ }
2132
+ this.syntaxError(message, token, types);
2133
+ }
2134
+ mark() {
2135
+ this.marks.push(this.pos);
2136
+ }
2137
+ reset() {
2138
+ const pos = this.marks.pop();
2139
+ if (pos !== undefined) {
2140
+ this.pos = pos;
2141
+ }
2142
+ }
2143
+ commit() {
2144
+ this.marks.pop();
2145
+ }
2146
+ syntaxError(details, token, expected) {
2147
+ const lines = this.dsl.split(/\r?\n/);
2148
+ const lineIdx = token.line - 1;
2149
+ const lineBefore = lineIdx > 0 ? lines[lineIdx - 1] : '';
2150
+ const current = lines[lineIdx];
2151
+ const linesAfter = lineIdx + 1 < lines.length ? lines[lineIdx + 1] : '';
2152
+ const wave = ' '.repeat(Math.max(0, token.column - 1)) + '~'.repeat(token.value.length);
2153
+ const lineNumWidth = String(token.line + 1).length;
2154
+ const num = (n) => String(n).padStart(lineNumWidth, ' ');
2155
+ let context = '';
2156
+ if (lineBefore.trim() !== '') {
2157
+ context += `${num(token.line - 1)} | ${lineBefore}\n`;
2158
+ }
2159
+ context += `${num(token.line)} | ${current}\n`;
2160
+ context += `${' '.repeat(lineNumWidth)} | ${wave}\n`;
2161
+ if (linesAfter.trim() !== '') {
2162
+ context += `${num(token.line + 1)} | ${linesAfter}`;
2163
+ }
2164
+ let finalDetails = details;
2165
+ if (expected && expected?.length > 0) {
2166
+ const actual = token.value;
2167
+ const suggestion = this.suggest(actual, expected);
2168
+ const detailsMsg = `${details}\nDetails: Unexpected value token \`${actual}\``;
2169
+ finalDetails = suggestion ? `${detailsMsg} Did you mean \`${suggestion}\`?` : detailsMsg;
2170
+ }
2171
+ throw new AbilityDSLSyntaxError(token.line, token.column, context + '\n', finalDetails);
2172
+ }
2173
+ suggest(actual, expectedTypes) {
2174
+ const candidates = [];
2175
+ for (const type of expectedTypes) {
2176
+ candidates.push(type);
2177
+ }
2178
+ const uniqueCandidates = [...new Set(candidates)];
2179
+ let best = null;
2180
+ let bestDist = 3;
2181
+ for (const candidate of uniqueCandidates) {
2182
+ const d = this.levenshteinDistance(actual.toLowerCase(), candidate.toLowerCase());
2183
+ if (d < bestDist) {
2184
+ bestDist = d;
2185
+ best = candidate;
2186
+ }
2187
+ }
2188
+ return best;
2189
+ }
2190
+ levenshteinDistance(a, b) {
2191
+ const matrix = Array.from({ length: b.length + 1 }, () => Array.from({ length: a.length + 1 }, () => 0));
2192
+ for (let i = 0; i <= a.length; i++)
2193
+ matrix[0][i] = i;
2194
+ for (let j = 0; j <= b.length; j++)
2195
+ matrix[j][0] = j;
2196
+ for (let j = 1; j <= b.length; j++) {
2197
+ for (let i = 1; i <= a.length; i++) {
2198
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2199
+ matrix[j][i] = Math.min(matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + cost);
2200
+ }
2201
+ }
2202
+ return matrix[b.length][a.length];
2203
+ }
2204
+ }
2205
+
2206
+ class AbilityDSLAnnotations {
2207
+ store = {
2208
+ id: undefined,
2209
+ name: undefined,
2210
+ priority: undefined,
2211
+ description: undefined,
2212
+ disabled: undefined,
2213
+ tags: undefined,
2214
+ };
2215
+ get(key) {
2216
+ return this.store[key] ?? null;
2217
+ }
2218
+ set(key, value, token) {
2219
+ if (value === null) {
2220
+ this.store[key] = undefined;
2221
+ }
2222
+ else {
2223
+ this.store[key] = {
2224
+ key,
2225
+ value,
2226
+ token,
2227
+ };
2228
+ }
2229
+ return this;
2230
+ }
2231
+ clear() {
2232
+ for (const key of Object.keys(this.store)) {
2233
+ this.store[key] = undefined;
2234
+ }
2235
+ }
2236
+ clone() {
2237
+ const cloned = new AbilityDSLAnnotations();
2238
+ for (const key of Object.keys(this.store)) {
2239
+ const entry = this.store[key];
2240
+ cloned.store[key] = entry
2241
+ ? { ...entry }
2242
+ : undefined;
2243
+ }
2244
+ return cloned;
2245
+ }
2246
+ // convenience getters
2247
+ get id() { return this.get('id'); }
2248
+ get name() { return this.get('name'); }
2249
+ get description() { return this.get('description'); }
2250
+ get priority() { return this.get('priority'); }
2251
+ get disabled() { return this.get('disabled'); }
2252
+ get tags() { return this.get('tags'); }
2253
+ // convenience setters
2254
+ setID(v, t) { return this.set('id', v, t); }
2255
+ setName(v, t) { return this.set('name', v, t); }
2256
+ setDescription(v, t) { return this.set('description', v, t); }
2257
+ setPriority(v, t) { return this.set('priority', v, t); }
2258
+ setDisabled(v, t) { return this.set('disabled', v, t); }
2259
+ setTags(v, t) { return this.set('tags', v, t); }
2260
+ }
2261
+
2262
+ const AnnotationAllowed = {
2263
+ policy: new Set(["id", "name", "description", "priority", "disabled", "tags"]),
2264
+ ruleSet: new Set(["id", "name", "description", "disabled"]),
2265
+ rule: new Set(["id", "name", "disabled"]),
2266
+ alias: new Set(["name", "disabled"]),
2267
+ };
2268
+
2269
+ class AbilityDSLAliases {
2270
+ store = new Map();
2271
+ get(alias) {
2272
+ return this.store.get(alias) || null;
2273
+ }
2274
+ set(alias, rule) {
2275
+ this.store.set(alias, rule);
2276
+ return this;
2277
+ }
2278
+ has(alias) {
2279
+ return this.store.has(alias);
2280
+ }
2281
+ }
2282
+
2283
+ /**
2284
+ * Parser for the Ability DSL.
2285
+ *
2286
+ * Converts a DSL string into one or more AbilityPolicy instances.
2287
+ * The grammar follows the structure:
2288
+ *
2289
+ * <effect> <permission> if <group> [ <group> ... ]
2290
+ *
2291
+ * where <group> is either "all of:" or "any of:", followed by a colon,
2292
+ * and then a list of rules (one per line).
2293
+ *
2294
+ * Each rule is: <identifier> <operator> <value>
2295
+ *
2296
+ * Operators can be simple (equals, contains, in) or
2297
+ * composed (is null, is not null, greater than, less than or equal, etc.).
2298
+ */
2299
+ class AbilityDSLParser {
2300
+ dsl;
2301
+ stream;
2302
+ annBuffer = new AbilityDSLAnnotations();
2303
+ aliasBuffer = new AbilityDSLAliases();
2304
+ constructor(dsl) {
2305
+ this.dsl = dsl;
2306
+ }
2307
+ /**
2308
+ * Main entry point: tokenize the input and parse all policies.
2309
+ * @returns Array of AbilityPolicy instances.
2310
+ */
2311
+ parse() {
2312
+ this.annBuffer.clear();
2313
+ // 1. Лексер → токены
2314
+ const tokens = new AbilityDSLLexer(this.dsl).tokenize();
2315
+ // 2. Создаём TokenStream
2316
+ this.stream = new AbilityDSLTokenStream(tokens, this.dsl);
2317
+ const policies = [];
2318
+ while (!this.stream.eof()) {
2319
+ this.consumeLeadingComments();
2320
+ this.consumeLeadingAnnotations();
2321
+ this.consumeLeadingAliases();
2322
+ if (!this.isStartOfPolicy()) {
2323
+ const token = this.stream.peek();
2324
+ this.stream.syntaxError(`Expected policy, got ${token.type}.`, token, [TokenTypes.EFFECT]);
2325
+ }
2326
+ policies.push(this.parsePolicy());
2327
+ }
2328
+ return policies;
2329
+ }
2330
+ // -------------------------------------------------------------------------
2331
+ // #region Policy parsing
2332
+ // -------------------------------------------------------------------------
2333
+ /**
2334
+ * Parses a single policy from the current token position.
2335
+ *
2336
+ * Grammar:
2337
+ * policy = EFFECT PERMISSION IF (ALL | ANY) COLON ruleSets
2338
+ */
2339
+ parsePolicy() {
2340
+ this.consumeLeadingComments();
2341
+ this.consumeLeadingAnnotations();
2342
+ this.consumeLeadingAliases();
2343
+ const annotations = this.takeAnnotations('policy');
2344
+ // Effect: "permit" or "deny"
2345
+ const effectToken = this.stream.expect(TokenTypes.EFFECT, 'Expected effect');
2346
+ const effect = effectToken.value;
2347
+ // Permission: e.g. "order.update"
2348
+ const permissionToken = this.stream.expect(TokenTypes.PERMISSION, 'Expected permission');
2349
+ const permission = permissionToken.value;
2350
+ if (!permission.startsWith('permission.')) {
2351
+ return this.stream.syntaxError(`Unexpected token. The permission key, must be starts with prefix \`permission.\`, but got \`${permission}\`.\nDid you mean \`permission.${permission}\`?`, permissionToken);
2352
+ }
2353
+ // "if" keyword
2354
+ this.stream.expect(TokenTypes.IF, 'Expected "if"');
2355
+ // Group selector: "all" or "any" – determines how the top‑level rule sets are combined.
2356
+ const compareToken = this.stream.expectOneOf([TokenTypes.ALL, TokenTypes.ANY], 'Expected "all" or "any"');
2357
+ const compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
2358
+ // Colon after the group keyword
2359
+ this.stream.expect(TokenTypes.COLON, 'Expected ":"');
2360
+ // Parse the list of rule sets (each "all of:" or "any of:" block)
2361
+ const ruleSets = this.parseRuleSets(compareMethod);
2362
+ // Construct the policy instance.
2363
+ return new AbilityPolicy({
2364
+ id: annotations.id?.value || null,
2365
+ name: annotations.name?.value || null,
2366
+ description: annotations.description?.value || null,
2367
+ priority: annotations.priority?.value || null,
2368
+ permission: permission.replace(/^permission\./, ''),
2369
+ effect: effect === 'permit' ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny,
2370
+ disabled: annotations.disabled?.value ?? undefined,
2371
+ tags: annotations.tags?.value ?? undefined,
2372
+ compareMethod,
2373
+ }).addRuleSets(ruleSets);
2374
+ }
2375
+ // -------------------------------------------------------------------------
2376
+ // #region Rule set parsing (groups of rules)
2377
+ // -------------------------------------------------------------------------
2378
+ /**
2379
+ * Parses a sequence of rule sets (groups) until a new policy starts or EOF.
2380
+ */
2381
+ parseRuleSets(policyCompareMethod) {
2382
+ const sets = [];
2383
+ this.consumeLeadingComments();
2384
+ this.consumeLeadingAnnotations();
2385
+ while (!this.stream.eof() && !this.isStartOfPolicy()) {
2386
+ // maybe except ruleSet
2387
+ if (this.isStartOfExcept()) {
2388
+ sets.push(this.parseExceptGroup(policyCompareMethod));
2389
+ continue;
2390
+ }
2391
+ // maybe ruleSet
2392
+ if (this.isStartOfGroup()) {
2393
+ sets.push(this.parseGroup());
2394
+ continue;
2395
+ }
2396
+ // implicit ruleSet
2397
+ // if (!this.isStartOfRule()) {
2398
+ // this.consumeLeadingComments();
2399
+ // this.consumeLeadingAnnotations();
2400
+ // }
2401
+ // is implicit group
2402
+ // const annotation = this.takeAnnotations('ruleSet');
2403
+ const group = new AbilityRuleSet({
2404
+ // id: annotation.id?.value || null,
2405
+ compareMethod: policyCompareMethod,
2406
+ // name: annotation.name?.value ?? null,
2407
+ // description: annotation.description?.value || null,
2408
+ // disabled: annotation.disabled?.value ?? undefined,
2409
+ });
2410
+ // Read rules of implicit-группы
2411
+ while (!this.stream.eof()) {
2412
+ this.consumeLeadingComments();
2413
+ this.consumeLeadingAnnotations();
2414
+ if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
2415
+ break;
2416
+ }
2417
+ if (this.stream.check(TokenTypes.IDENTIFIER) ||
2418
+ this.stream.check(TokenTypes.ALWAYS) ||
2419
+ this.stream.check(TokenTypes.NEVER)) {
2420
+ group.addRule(this.parseRule());
2421
+ }
2422
+ else {
2423
+ this.stream.syntaxError(`Unexpected token in implicit group: ${this.stream.peek().type}`, this.stream.peek());
2424
+ }
2425
+ }
2426
+ sets.push(group);
2427
+ }
2428
+ return sets;
2429
+ }
2430
+ /**
2431
+ * Parses a single group, e.g. "all of:" or "any of:", and returns a RuleSet.
2432
+ */
2433
+ parseGroup() {
2434
+ this.consumeLeadingComments();
2435
+ this.consumeLeadingAnnotations();
2436
+ const annotations = this.takeAnnotations('ruleSet');
2437
+ const compareToken = this.stream.expectOneOf([TokenTypes.ALL, TokenTypes.ANY, TokenTypes.ALWAYS, TokenTypes.NEVER], 'Expected "all" or "any" or "always" or "never"');
2438
+ const compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
2439
+ if (this.stream.check(TokenTypes.OF)) {
2440
+ this.stream.next();
2441
+ }
2442
+ this.stream.expect(TokenTypes.COLON, 'Expected ":"');
2443
+ const group = new AbilityRuleSet({
2444
+ id: annotations.id?.value || null,
2445
+ compareMethod,
2446
+ name: annotations.name?.value || null,
2447
+ description: annotations.description?.value || null,
2448
+ disabled: annotations.disabled?.value ?? undefined,
2449
+ });
2450
+ while (!this.stream.eof()) {
2451
+ this.consumeLeadingComments();
2452
+ this.consumeLeadingAnnotations();
2453
+ if (this.isStartOfExcept()) {
2454
+ break;
2455
+ }
2456
+ if (this.isStartOfGroup() || this.isStartOfPolicy()) {
2457
+ break;
2458
+ }
2459
+ if (this.stream.check(TokenTypes.IDENTIFIER)) {
2460
+ group.addRule(this.parseRule());
2461
+ }
2462
+ else {
2463
+ this.stream.syntaxError(`Unexpected token in group: ${this.stream.peek().type}`, this.stream.peek());
2464
+ }
2465
+ }
2466
+ return group;
2467
+ }
2468
+ // -------------------------------------------------------------------------
2469
+ // #region Except RuleSet parsing
2470
+ // -------------------------------------------------------------------------
2471
+ parseExceptGroup(policyCompareMethod) {
2472
+ this.consumeLeadingComments();
2473
+ this.consumeLeadingAnnotations();
2474
+ const annotations = this.takeAnnotations('ruleSet');
2475
+ // consume "except"
2476
+ this.stream.expect(TokenTypes.EXCEPT, 'Expected "except"');
2477
+ let compareMethod = policyCompareMethod;
2478
+ // optional: "all" / "any"
2479
+ if (this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY)) {
2480
+ const compareToken = this.stream.next();
2481
+ compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
2482
+ if (this.stream.check(TokenTypes.OF)) {
2483
+ this.stream.next();
2484
+ }
2485
+ this.stream.expect(TokenTypes.COLON, 'Expected ":" after except group');
2486
+ }
2487
+ else {
2488
+ // implicit except group — no "all/any of:"
2489
+ // but still must end with colon
2490
+ this.stream.expect(TokenTypes.COLON, 'Expected ":" after "except"');
2491
+ }
2492
+ const group = new AbilityRuleSet({
2493
+ id: annotations.id?.value || null,
2494
+ compareMethod,
2495
+ name: annotations.name?.value || null,
2496
+ description: annotations.description?.value || null,
2497
+ isExcept: true,
2498
+ disabled: annotations.disabled?.value ?? undefined,
2499
+ });
2500
+ // read rules
2501
+ while (!this.stream.eof()) {
2502
+ this.consumeLeadingComments();
2503
+ this.consumeLeadingAnnotations();
2504
+ if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
2505
+ break;
2506
+ }
2507
+ if (this.stream.check(TokenTypes.IDENTIFIER)) {
2508
+ group.addRule(this.parseRule());
2509
+ }
2510
+ else {
2511
+ this.stream.syntaxError(`Unexpected token in except group: ${this.stream.peek().type}`, this.stream.peek());
2512
+ }
2513
+ }
2514
+ return group;
2515
+ }
2516
+ // -------------------------------------------------------------------------
2517
+ // #region Rule parsing
2518
+ // -------------------------------------------------------------------------
2519
+ /**
2520
+ * Parses a single rule: subject operator value
2521
+ */
2522
+ parseRule() {
2523
+ this.consumeLeadingComments();
2524
+ this.consumeLeadingAnnotations();
2525
+ const annotations = this.takeAnnotations('rule');
2526
+ const isNeverAlways = this.stream.check(TokenTypes.ALWAYS) || this.stream.check(TokenTypes.NEVER);
2527
+ if (!isNeverAlways && !this.stream.check(TokenTypes.IDENTIFIER)) {
2528
+ this.stream.syntaxError(`Expected identifier, but got ${this.stream.peek().type}`, this.stream.peek());
2529
+ }
2530
+ // subject
2531
+ const subject = isNeverAlways
2532
+ ? ''
2533
+ : this.stream.expect(TokenTypes.IDENTIFIER, 'Expected field').value;
2534
+ // check alias
2535
+ if (this.aliasBuffer.has(subject)) {
2536
+ return this.aliasBuffer.get(subject);
2537
+ }
2538
+ // operator
2539
+ const { condition, operator } = this.parseConditionOperator();
2540
+ // value
2541
+ let resource = null;
2542
+ let valueToken = null;
2543
+ const operatorConsumesValue = operator !== TokenTypes.EQ_NULL &&
2544
+ operator !== TokenTypes.NOT_EQ_NULL &&
2545
+ operator !== TokenTypes.NULL &&
2546
+ operator !== TokenTypes.DEFINED &&
2547
+ operator !== TokenTypes.ALWAYS &&
2548
+ operator !== TokenTypes.NEVER;
2549
+ if (operatorConsumesValue) {
2550
+ this.stream.mark();
2551
+ resource = this.parseValue();
2552
+ valueToken = this.stream.lookPrev();
2553
+ this.stream.commit();
2554
+ }
2555
+ this.consumeLeadingComments();
2556
+ this.consumeLeadingAnnotations();
2557
+ this.consumeLeadingAliases();
2558
+ // validation: identifier without dot → error
2559
+ if (typeof resource === 'string' &&
2560
+ valueToken &&
2561
+ valueToken.type === TokenTypes.IDENTIFIER &&
2562
+ !valueToken.value.includes('.')) {
2563
+ this.stream.syntaxError(`Expected comparison operator or value, got \`${resource}\``, valueToken, [TokenTypes.KEYWORD]);
2564
+ }
2565
+ return new AbilityRule({
2566
+ id: annotations.id?.value || null,
2567
+ subject,
2568
+ resource,
2569
+ condition,
2570
+ name: annotations.name?.value || null,
2571
+ description: annotations.description?.value || null,
2572
+ disabled: annotations.disabled?.value ?? undefined,
2573
+ });
2574
+ }
2575
+ // -------------------------------------------------------------------------
2576
+ // #region Operator parsing
2577
+ // -------------------------------------------------------------------------
2578
+ /**
2579
+ * Parses the comparison operator part of a rule.
2580
+ * Returns both the resulting AbilityCondition and the token type that was consumed.
2581
+ */
2582
+ parseConditionOperator() {
2583
+ // "always"
2584
+ this.stream.mark();
2585
+ if (this.matchWord('always')) {
2586
+ this.stream.commit();
2587
+ return { condition: AbilityCondition.always, operator: TokenTypes.ALWAYS };
2588
+ }
2589
+ this.stream.reset();
2590
+ // "never"
2591
+ this.stream.mark();
2592
+ if (this.matchWord('never')) {
2593
+ this.stream.commit();
2594
+ return { condition: AbilityCondition.never, operator: TokenTypes.NEVER };
2595
+ }
2596
+ this.stream.reset();
2597
+ // "length equals"
2598
+ this.stream.mark();
2599
+ if ((this.matchWord('length') || this.matchWord('len')) && this.matchWord('equals')) {
2600
+ this.stream.commit();
2601
+ return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
2602
+ }
2603
+ this.stream.reset();
2604
+ // "length ="
2605
+ this.stream.mark();
2606
+ if ((this.matchWord('length') || this.matchWord('len')) && this.matchSymbol('=')) {
2607
+ this.stream.commit();
2608
+ return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
2609
+ }
2610
+ this.stream.reset();
2611
+ // "length greater than"
2612
+ this.stream.mark();
2613
+ if ((this.matchWord('length') || this.matchWord('len')) &&
2614
+ this.matchWord('greater') &&
2615
+ this.matchWord('than')) {
2616
+ this.stream.commit();
2617
+ return { condition: AbilityCondition.length_greater_than, operator: TokenTypes.LEN_GT };
2618
+ }
2619
+ this.stream.reset();
2620
+ // "length >"
2621
+ this.stream.mark();
2622
+ if ((this.matchWord('length') || this.matchWord('len')) && this.matchSymbol('>')) {
2623
+ this.stream.commit();
2624
+ return { condition: AbilityCondition.length_greater_than, operator: TokenTypes.LEN_GT };
2625
+ }
2626
+ this.stream.reset();
2627
+ // "length less than"
2628
+ this.stream.mark();
2629
+ if ((this.matchWord('length') || this.matchWord('len')) &&
2630
+ this.matchWord('less') &&
2631
+ this.matchWord('than')) {
2632
+ this.stream.commit();
2633
+ return { condition: AbilityCondition.length_less_than, operator: TokenTypes.LEN_LT };
2634
+ }
2635
+ this.stream.reset();
2636
+ // "length <"
2637
+ this.stream.mark();
2638
+ if ((this.matchWord('length') || this.matchWord('len')) && this.matchSymbol('<')) {
2639
+ this.stream.commit();
2640
+ return { condition: AbilityCondition.length_less_than, operator: TokenTypes.LEN_LT };
2641
+ }
2642
+ this.stream.reset();
2643
+ // "greater than or equal"
2644
+ this.stream.mark();
2645
+ if (this.matchWord('greater') &&
2646
+ this.matchWord('than') &&
2647
+ this.matchWord('or') &&
2648
+ this.matchWord('equal')) {
2649
+ this.stream.commit();
2650
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
2651
+ }
2652
+ this.stream.reset();
2653
+ // greater than
2654
+ this.stream.mark();
2655
+ if (this.matchWord('greater') && this.matchWord('than')) {
2656
+ this.stream.commit();
2657
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
2658
+ }
2659
+ this.stream.reset();
2660
+ // less than or equal
2661
+ this.stream.mark();
2662
+ if (this.matchWord('less') &&
2663
+ this.matchWord('than') &&
2664
+ this.matchWord('or') &&
2665
+ this.matchWord('equal')) {
2666
+ this.stream.commit();
2667
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
2668
+ }
2669
+ this.stream.reset();
2670
+ // less than
2671
+ if (this.matchWord('less') && this.matchWord('than')) {
2672
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
2673
+ }
2674
+ this.stream.reset();
2675
+ // "not contains"
2676
+ this.stream.mark();
2677
+ if (this.matchWord('not') && this.matchWord('contains')) {
2678
+ this.stream.commit();
2679
+ return {
2680
+ condition: AbilityCondition.not_contains,
2681
+ operator: TokenTypes.NOT_CONTAINS,
2682
+ };
2683
+ }
2684
+ this.stream.reset();
2685
+ // "not includes"
2686
+ this.stream.mark();
2687
+ if (this.matchWord('not') && this.matchWord('includes')) {
2688
+ this.stream.commit();
2689
+ return {
2690
+ condition: AbilityCondition.not_contains,
2691
+ operator: TokenTypes.NOT_CONTAINS,
2692
+ };
2693
+ }
2694
+ this.stream.reset();
2695
+ // "not includes"
2696
+ this.stream.mark();
2697
+ if (this.matchWord('not') && this.matchWord('has')) {
2698
+ this.stream.commit();
2699
+ return {
2700
+ condition: AbilityCondition.not_contains,
2701
+ operator: TokenTypes.NOT_CONTAINS,
2702
+ };
2703
+ }
2704
+ this.stream.reset();
2705
+ // "is equals"
2706
+ this.stream.mark();
2707
+ if (this.matchWord('is') && this.matchWord('equals')) {
2708
+ this.stream.commit();
2709
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
2710
+ }
2711
+ this.stream.reset();
2712
+ // not equal
2713
+ this.stream.mark();
2714
+ if (this.matchWord('not') && this.matchWord('equals')) {
2715
+ this.stream.commit();
2716
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
2717
+ }
2718
+ this.stream.reset();
2719
+ // is not equals
2720
+ this.stream.mark();
2721
+ if (this.matchWord('is') && this.matchWord('not') && this.matchWord('equals')) {
2722
+ this.stream.commit();
2723
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
2724
+ }
2725
+ this.stream.reset();
2726
+ // is in
2727
+ this.stream.mark();
2728
+ if (this.matchWord('is') && this.matchWord('in')) {
2729
+ this.stream.commit();
2730
+ return { condition: AbilityCondition.in, operator: TokenTypes.IN };
2731
+ }
2732
+ this.stream.reset();
2733
+ // not in
2734
+ this.stream.mark();
2735
+ if (this.matchWord('not') && this.matchWord('in')) {
2736
+ this.stream.commit();
2737
+ return { condition: AbilityCondition.not_in, operator: TokenTypes.NOT_IN };
2738
+ }
2739
+ this.stream.reset();
2740
+ // is not null
2741
+ this.stream.mark();
2742
+ if (this.matchWord('is') && this.matchWord('not')) {
2743
+ if (this.stream.check(TokenTypes.NULL)) {
2744
+ this.stream.next();
2745
+ this.stream.commit();
2746
+ return {
2747
+ condition: AbilityCondition.not_equals,
2748
+ operator: TokenTypes.NOT_EQ_NULL,
2749
+ };
2750
+ }
2751
+ }
2752
+ this.stream.reset();
2753
+ // is null
2754
+ this.stream.mark();
2755
+ if (this.matchWord('is') && this.matchWord('null')) {
2756
+ if (this.stream.check(TokenTypes.NULL)) {
2757
+ this.stream.commit();
2758
+ this.stream.next();
2759
+ return {
2760
+ condition: AbilityCondition.equals,
2761
+ operator: TokenTypes.EQ_NULL,
2762
+ };
2763
+ }
2764
+ }
2765
+ this.stream.reset();
2766
+ // is defined
2767
+ this.stream.mark();
2768
+ if (this.matchWord('is') && this.matchWord('defined')) {
2769
+ this.stream.commit();
2770
+ return { condition: AbilityCondition.defined, operator: TokenTypes.DEFINED };
2771
+ }
2772
+ this.stream.reset();
2773
+ // is not defined
2774
+ this.stream.mark();
2775
+ if (this.matchWord('is') && this.matchWord('not') && this.matchWord('defined')) {
2776
+ this.stream.commit();
2777
+ return { condition: AbilityCondition.not_defined, operator: TokenTypes.DEFINED };
2778
+ }
2779
+ this.stream.reset();
2780
+ // Single token (symbol or keyword)
2781
+ const token = this.stream.peek();
2782
+ if (token.type !== TokenTypes.SYMBOL &&
2783
+ token.type !== TokenTypes.KEYWORD &&
2784
+ token.type !== TokenTypes.NULL) {
2785
+ this.stream.syntaxError(`Expected comparison operator, got \`${token.value}\``, token, [
2786
+ TokenTypes.SYMBOL,
2787
+ TokenTypes.KEYWORD,
2788
+ TokenTypes.NULL,
2789
+ ]);
2790
+ }
2791
+ this.stream.next();
2792
+ switch (token.type) {
2793
+ case TokenTypes.SYMBOL:
2794
+ if (token.value === '=' || token.value === '==')
2795
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
2796
+ if (token.value === '!=' || token.value === '<>')
2797
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
2798
+ if (token.value === '>')
2799
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
2800
+ if (token.value === '<')
2801
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
2802
+ if (token.value === '>=')
2803
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
2804
+ if (token.value === '<=')
2805
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
2806
+ break;
2807
+ case TokenTypes.KEYWORD:
2808
+ if (token.value === 'contains' || token.value === 'includes' || token.value === 'has')
2809
+ return { condition: AbilityCondition.contains, operator: TokenTypes.CONTAINS };
2810
+ if (token.value === 'in')
2811
+ return { condition: AbilityCondition.in, operator: TokenTypes.IN };
2812
+ if (token.value === 'equals')
2813
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
2814
+ if (token.value === 'gte') {
2815
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
2816
+ }
2817
+ if (token.value === 'greater' || token.value === 'gt') {
2818
+ // If we come here, it means "greater" without "than" – treat as '>'
2819
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
2820
+ }
2821
+ if (token.value === 'less' || token.value === 'lt') {
2822
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
2823
+ }
2824
+ if (token.value === 'lte') {
2825
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
2826
+ }
2827
+ if (token.value === 'is') {
2828
+ // "is" alone -> equals
2829
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
2830
+ }
2831
+ break;
2832
+ }
2833
+ return this.stream.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
2834
+ TokenTypes.SYMBOL,
2835
+ TokenTypes.KEYWORD,
2836
+ ]);
2837
+ }
2838
+ /**
2839
+ * Helper to match and consume a specific word token (KEYWORD or IDENTIFIER).
2840
+ * @param word The exact string to look for.
2841
+ * @returns True if the next token has that value.
2842
+ */
2843
+ matchWord(word) {
2844
+ if (this.stream.eof()) {
2845
+ return false;
2846
+ }
2847
+ const token = this.stream.peek();
2848
+ if ((token.type === TokenTypes.KEYWORD ||
2849
+ token.type === TokenTypes.IDENTIFIER ||
2850
+ token.type === TokenTypes.ALWAYS ||
2851
+ token.type === TokenTypes.NEVER ||
2852
+ token.type === TokenTypes.DEFINED) &&
2853
+ token.value === word) {
2854
+ this.stream.next();
2855
+ return true;
2856
+ }
2857
+ return false;
2858
+ }
2859
+ matchSymbol(symbol) {
2860
+ if (this.stream.eof())
2861
+ return false;
2862
+ const token = this.stream.peek();
2863
+ if (token.type === TokenTypes.SYMBOL && token.value === symbol) {
2864
+ this.stream.next();
2865
+ return true;
2866
+ }
2867
+ return false;
2868
+ }
2869
+ // -------------------------------------------------------------------------
2870
+ // #region Value parsing (literals, arrays, identifiers)
2871
+ // -------------------------------------------------------------------------
2872
+ /**
2873
+ * Parses a resource value. Can be a string literal, number, boolean,
2874
+ * null, a path (identifier), or an array.
2875
+ */
2876
+ parseValue() {
2877
+ // Arrays start with a left bracket
2878
+ if (this.stream.check(TokenTypes.LBRACKET)) {
2879
+ this.stream.next();
2880
+ return this.parseArray();
2881
+ }
2882
+ // Ensure we are not about to read a structural token as a value.
2883
+ const token = this.stream.peek();
2884
+ if (token.type === TokenTypes.ALL ||
2885
+ token.type === TokenTypes.ANY ||
2886
+ token.type === TokenTypes.EFFECT) {
2887
+ this.stream.syntaxError(`Unexpected ${token.type} in value position`, token);
2888
+ }
2889
+ this.stream.next();
2890
+ // if (token.type === TokenTypes.IDENTIFIER) {
2891
+ // this.stream.next();
2892
+ //
2893
+ // return this.parseValue();
2894
+ // }
2895
+ // CHECK THIS SWITCH COMPARE
2896
+ switch (token.type) {
2897
+ case TokenTypes.STRING:
2898
+ return token.value;
2899
+ case TokenTypes.NUMBER:
2900
+ return Number(token.value);
2901
+ case TokenTypes.BOOLEAN:
2902
+ return token.value === 'true';
2903
+ case TokenTypes.NULL:
2904
+ return null;
2905
+ case TokenTypes.DEFINED:
2906
+ return typeof token.value !== 'undefined';
2907
+ case TokenTypes.IDENTIFIER:
2908
+ return null;
2909
+ default: {
2910
+ this.stream.syntaxError(`Unexpected value token "${token.value}"`, token, [
2911
+ TokenTypes.KEYWORD,
2912
+ ]);
2913
+ }
2914
+ }
2915
+ }
2916
+ /**
2917
+ * Parses an array literal: [ <value>, <value>, ... ]
2918
+ * The opening bracket has already been consumed.
2919
+ */
2920
+ parseArray() {
2921
+ const arr = [];
2922
+ // Handle empty array
2923
+ if (this.stream.check(TokenTypes.RBRACKET)) {
2924
+ this.stream.next();
2925
+ return arr;
2926
+ }
2927
+ while (!this.stream.eof() && !this.stream.check(TokenTypes.RBRACKET)) {
2928
+ const value = this.parseValue();
2929
+ // Flatten nested arrays if they appear (though grammar doesn't currently allow nesting).
2930
+ if (Array.isArray(value)) {
2931
+ arr.push(...value);
2932
+ }
2933
+ else if (typeof value === 'string' ||
2934
+ typeof value === 'number' ||
2935
+ typeof value === 'boolean') {
2936
+ arr.push(value);
2937
+ }
2938
+ else if (value === null) {
2939
+ // Null is allowed in arrays? Currently, we throw.
2940
+ this.stream.syntaxError('Unexpected null in array', this.stream.peek());
2941
+ }
2942
+ // Optional comma between elements
2943
+ if (this.stream.check(TokenTypes.COMMA)) {
2944
+ this.stream.next();
2945
+ }
2946
+ }
2947
+ this.stream.expect(TokenTypes.RBRACKET, 'Expected "]"');
2948
+ return arr;
2949
+ }
2950
+ // -------------------------------------------------------------------------
2951
+ // #region comments
2952
+ // -------------------------------------------------------------------------
2953
+ consumeLeadingComments() {
2954
+ while (this.stream.check(TokenTypes.COMMENT)) {
2955
+ this.stream.next();
2956
+ // this.processCommentToken(token);
2957
+ }
2958
+ }
2959
+ // private _consumeLeadingAnnotations() {
2960
+ // while (this.stream.check(TokenTypes.ANNOTATION)) {
2961
+ // const token = this.stream.next();
2962
+ // this.processAnnotationToken(token);
2963
+ // }
2964
+ // }
2965
+ consumeLeadingAliases() {
2966
+ while (this.stream.check(TokenTypes.ALIAS)) {
2967
+ this.stream.next(); // consume "alias"
2968
+ const nameToken = this.stream.expect(TokenTypes.IDENTIFIER, `Expected alias name`);
2969
+ const aliasKey = nameToken.value;
2970
+ this.stream.expect(TokenTypes.COLON, `Expected colon after an alias`);
2971
+ const annotations = this.takeAnnotations('alias');
2972
+ while (!this.stream.eof() && !this.isStartOfAlias() && !this.isStartOfPolicy()) {
2973
+ const rule = this.parseRule();
2974
+ rule.name = annotations.get('name')?.value || aliasKey;
2975
+ rule.description = annotations.get('description')?.value;
2976
+ if (annotations.get('disabled')?.value === true) {
2977
+ rule.disabled = true;
2978
+ }
2979
+ this.aliasBuffer.set(aliasKey, rule);
2980
+ }
2981
+ }
2982
+ }
2983
+ consumeLeadingAnnotations() {
2984
+ while (this.stream.check(TokenTypes.ANNOTATION)) {
2985
+ const token = this.stream.next();
2986
+ const text = token.value.trim();
2987
+ if (text.startsWith('@id ')) {
2988
+ this.annBuffer.setID(text.slice(4).trim(), token);
2989
+ }
2990
+ if (text.startsWith('@name ')) {
2991
+ this.annBuffer.setName(text.slice(6).trim(), token);
2992
+ }
2993
+ if (text.startsWith('@description ')) {
2994
+ this.annBuffer.setDescription(text.slice(13).trim(), token);
2995
+ }
2996
+ if (text.startsWith('@priority ')) {
2997
+ this.annBuffer.setPriority(parseInt(text.slice(10).trim(), 10), token);
2998
+ }
2999
+ if (text.startsWith('@disabled')) {
3000
+ const value = text.slice(9).trim();
3001
+ this.annBuffer.setDisabled(value.length === 0 ? true : text.slice(9).trim() === 'true', token);
3002
+ }
3003
+ if (text.startsWith('@tags ')) {
3004
+ const value = text
3005
+ .slice(6)
3006
+ .trim()
3007
+ .split(',')
3008
+ .map(tag => tag.trim());
3009
+ this.annBuffer.setTags(value, token);
3010
+ }
3011
+ }
3012
+ }
3013
+ takeAnnotations(owner) {
3014
+ const ann = this.annBuffer.clone();
3015
+ this.annBuffer.clear();
3016
+ const allowed = AnnotationAllowed[owner];
3017
+ for (const key of Object.keys(ann['store'])) {
3018
+ const entry = ann.get(key);
3019
+ if (!entry)
3020
+ continue;
3021
+ if (!allowed.has(key)) {
3022
+ this.stream.syntaxError(`Annotation @${key} is not allowed on ${owner}. Allowed: ${[...allowed]
3023
+ .map(a => '@' + a)
3024
+ .join(', ')}`, entry.token ?? this.stream.peek());
3025
+ }
3026
+ }
3027
+ return ann;
3028
+ }
3029
+ // -------------------------------------------------------------------------
3030
+ // #region Helpers
3031
+ // -------------------------------------------------------------------------
3032
+ isStartOfPolicy() {
3033
+ return this.stream.check(TokenTypes.EFFECT);
3034
+ }
3035
+ isStartOfGroup() {
3036
+ return this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY);
3037
+ }
3038
+ isStartOfRule() {
3039
+ return (this.stream.check(TokenTypes.IDENTIFIER) ||
3040
+ this.stream.check(TokenTypes.ALWAYS) ||
3041
+ this.stream.check(TokenTypes.NEVER));
3042
+ }
3043
+ isStartOfExcept() {
3044
+ return this.stream.check(TokenTypes.EXCEPT);
3045
+ }
3046
+ isStartOfAlias() {
3047
+ return this.stream.check(TokenTypes.ALIAS);
3048
+ }
3049
+ }
3050
+
3051
+ function ability(strings, ...expr) {
3052
+ const dsl = strings.reduce((acc, s, i) => acc + s + (expr[i] ?? ''), '');
3053
+ return new AbilityDSLParser(dsl).parse();
3054
+ }
3055
+
3056
+ class AbilityStrategy {
3057
+ policies;
3058
+ matched;
3059
+ constructor(policies) {
3060
+ this.policies = policies;
3061
+ this.matched = policies.filter(p => p.matchState === AbilityMatch.match);
3062
+ }
3063
+ matchedPolicies() {
3064
+ return this.matched;
3065
+ }
3066
+ hasMatched() {
3067
+ return this.matched.length > 0;
3068
+ }
3069
+ firstMatched() {
3070
+ return this.matchedPolicies()[0] ?? null;
3071
+ }
3072
+ lastMatched() {
3073
+ const list = this.matchedPolicies();
3074
+ return list.length > 0 ? list[list.length - 1] : null;
3075
+ }
3076
+ firstDenied() {
3077
+ return this.getDenyPolicies()[0] ?? null;
3078
+ }
3079
+ firstPermitted() {
3080
+ return this.getPermitPolicies()[0] ?? null;
3081
+ }
3082
+ getPermitPolicies() {
3083
+ return this.matched.filter(p => p.effect === AbilityPolicyEffect.permit);
3084
+ }
3085
+ getDenyPolicies() {
3086
+ return this.matched.filter(p => p.effect === AbilityPolicyEffect.deny);
3087
+ }
3088
+ hasPermit() {
3089
+ return this.getPermitPolicies().length > 0;
3090
+ }
3091
+ hasDeny() {
3092
+ return this.getDenyPolicies().length > 0;
3093
+ }
3094
+ isAllowed() {
3095
+ return this.evaluate() === AbilityPolicyEffect.permit;
3096
+ }
3097
+ isDenied() {
3098
+ return this.evaluate() === AbilityPolicyEffect.deny;
3099
+ }
3100
+ }
3101
+
3102
+ /**
3103
+ * AllMustPermitStrategy
3104
+ *
3105
+ * This strategy requires *every applicable policy* to return "permit".
3106
+ * If at least one policy returns "deny" or "not applicable", the final result is "deny".
3107
+ *
3108
+ * Use this strategy when:
3109
+ * - You want strict, conservative access control.
3110
+ * - All rules must explicitly allow the action.
3111
+ *
3112
+ * Example:
3113
+ * Policies:
3114
+ * P1 → permit
3115
+ * P2 → permit
3116
+ * P3 → deny
3117
+ * Result: deny (because not all policies permitted)
3118
+ */
3119
+ class AllMustPermitStrategy extends AbilityStrategy {
3120
+ _decisive = null;
3121
+ evaluate() {
3122
+ // 1. Нет совпавших политик → deny, но решающей политики нет
3123
+ if (!this.hasMatched()) {
3124
+ this._decisive = null;
3125
+ return AbilityPolicyEffect.deny;
3126
+ }
3127
+ // 2. Если есть deny — она решающая
3128
+ const deny = this.firstDenied();
3129
+ if (deny) {
3130
+ this._decisive = deny;
3131
+ return AbilityPolicyEffect.deny;
3132
+ }
3133
+ this._decisive = this.firstPermitted();
3134
+ return AbilityPolicyEffect.permit;
3135
+ }
3136
+ decisivePolicy() {
3137
+ return this._decisive;
3138
+ }
3139
+ }
3140
+
3141
+ /**
3142
+ * AnyPermitStrategy
3143
+ *
3144
+ * This strategy returns "permit" as soon as *any* applicable policy permits the action.
3145
+ * If no policy permits, the result is "deny".
3146
+ *
3147
+ * Use this strategy when:
3148
+ * - You want optimistic access control.
3149
+ * - A single positive rule should be enough to grant access.
3150
+ *
3151
+ * Example:
3152
+ * Policies:
3153
+ * P1 → deny
3154
+ * P2 → permit
3155
+ * P3 → deny
3156
+ * Result: permit (because at least one policy permitted)
3157
+ */
3158
+ class AnyPermitStrategy extends AbilityStrategy {
3159
+ _decisive = null;
3160
+ evaluate() {
3161
+ // 1. Если есть permit — он решающий
3162
+ const permit = this.firstPermitted();
3163
+ if (permit) {
3164
+ this._decisive = permit;
3165
+ return AbilityPolicyEffect.permit;
3166
+ }
3167
+ // 2. Нет permit → deny по умолчанию
3168
+ this._decisive = null;
3169
+ return AbilityPolicyEffect.deny;
3170
+ }
3171
+ decisivePolicy() {
3172
+ return this._decisive;
3173
+ }
3174
+ }
3175
+
3176
+ /**
3177
+ * DenyOverridesStrategy
3178
+ *
3179
+ * This strategy gives absolute priority to "deny".
3180
+ * If any applicable policy returns "deny", the final result is "deny".
3181
+ * Otherwise, if at least one policy permits, the result is "permit".
3182
+ *
3183
+ * Use this strategy when:
3184
+ * - Security is critical.
3185
+ * - A single denial must block access.
3186
+ *
3187
+ * Example:
3188
+ * Policies:
3189
+ * P1 → permit
3190
+ * P2 → deny
3191
+ * P3 → permit
3192
+ * Result: deny (because deny overrides everything)
3193
+ */
3194
+ class DenyOverridesStrategy extends AbilityStrategy {
3195
+ _decisive = null;
3196
+ evaluate() {
3197
+ // 1. Если есть deny — он решающий
3198
+ const deny = this.firstDenied();
3199
+ if (deny) {
3200
+ this._decisive = deny;
3201
+ return AbilityPolicyEffect.deny;
3202
+ }
3203
+ // 2. Если есть permit — он решающий
3204
+ const permit = this.firstPermitted();
3205
+ if (permit) {
3206
+ this._decisive = permit;
3207
+ return AbilityPolicyEffect.permit;
3208
+ }
3209
+ // 3. Нет ни permit, ни deny → deny по умолчанию
3210
+ this._decisive = null;
3211
+ return AbilityPolicyEffect.deny;
3212
+ }
3213
+ decisivePolicy() {
3214
+ return this._decisive;
3215
+ }
3216
+ }
3217
+
3218
+ /**
3219
+ * FirstMatchStrategy
3220
+ *
3221
+ * This strategy evaluates policies in order and returns the result of the *first applicable* policy.
3222
+ * Remaining policies are ignored.
3223
+ *
3224
+ * Use this strategy when:
3225
+ * - Policy order matters.
3226
+ * - You want predictable, sequential rule evaluation.
3227
+ *
3228
+ * Example:
3229
+ * Policies:
3230
+ * P1 → not applicable
3231
+ * P2 → permit
3232
+ * P3 → deny
3233
+ * Result: permit (P2 is the first applicable)
3234
+ */
3235
+ class FirstMatchStrategy extends AbilityStrategy {
3236
+ _decisive = null;
3237
+ evaluate() {
3238
+ const first = this.firstMatched();
3239
+ // Если нет совпавших политик → deny по умолчанию
3240
+ if (!first) {
3241
+ this._decisive = null;
3242
+ return AbilityPolicyEffect.deny;
3243
+ }
3244
+ // Первая совпавшая политика — решающая
3245
+ this._decisive = first;
3246
+ return first.effect;
3247
+ }
3248
+ decisivePolicy() {
3249
+ return this._decisive;
3250
+ }
3251
+ }
3252
+
3253
+ /**
3254
+ * OnlyOneApplicableStrategy
3255
+ *
3256
+ * This strategy requires that *exactly one* policy is applicable.
3257
+ * If zero or more than one policy applies, the result is "deny".
3258
+ *
3259
+ * Use this strategy when:
3260
+ * - Policies must be mutually exclusive.
3261
+ * - You want to detect ambiguous or conflicting rules.
3262
+ *
3263
+ * Example:
3264
+ * Policies:
3265
+ * P1 → applicable
3266
+ * P2 → applicable
3267
+ * Result: deny (more than one applicable policy)
3268
+ */
3269
+ class OnlyOneApplicableStrategy extends AbilityStrategy {
3270
+ _decisive = null;
3271
+ evaluate() {
3272
+ const matched = this.matchedPolicies();
3273
+ // 1. Ровно одна совпавшая политика → она решающая
3274
+ if (matched.length === 1) {
3275
+ this._decisive = matched[0];
3276
+ return matched[0].effect;
3277
+ }
3278
+ // 2. Иначе deny, решающей политики нет
3279
+ this._decisive = null;
3280
+ return AbilityPolicyEffect.deny;
3281
+ }
3282
+ decisivePolicy() {
3283
+ return this._decisive;
3284
+ }
3285
+ }
3286
+
3287
+ /**
3288
+ * PermitOverridesStrategy
3289
+ *
3290
+ * This strategy gives priority to "permit".
3291
+ * If any applicable policy permits, the final result is "permit".
3292
+ * Deny is returned only if no policy permits.
3293
+ *
3294
+ * Use this strategy when:
3295
+ * - You want permissive behavior.
3296
+ * - A single positive rule should override denials.
3297
+ *
3298
+ * Example:
3299
+ * Policies:
3300
+ * P1 → deny
3301
+ * P2 → permit
3302
+ * P3 → deny
3303
+ * Result: permit (permit overrides deny)
3304
+ */
3305
+ class PermitOverridesStrategy extends AbilityStrategy {
3306
+ _decisive = null;
3307
+ evaluate() {
3308
+ // 1. Если есть permit — он выигрывает
3309
+ const permit = this.matchedPolicies().find(p => p.effect === AbilityPolicyEffect.permit);
3310
+ if (permit) {
3311
+ this._decisive = permit;
3312
+ return AbilityPolicyEffect.permit;
3313
+ }
3314
+ // 2. Если permit нет — ищем deny
3315
+ const deny = this.matchedPolicies().find(p => p.effect === AbilityPolicyEffect.deny);
3316
+ if (deny) {
3317
+ this._decisive = deny;
3318
+ return AbilityPolicyEffect.deny;
3319
+ }
3320
+ // 3. Нет ни permit, ни deny → deny по умолчанию
3321
+ this._decisive = null;
3322
+ return AbilityPolicyEffect.deny;
3323
+ }
3324
+ decisivePolicy() {
3325
+ return this._decisive;
3326
+ }
3327
+ }
3328
+
3329
+ /**
3330
+ * SequentialLastMatchStrategy
3331
+ *
3332
+ * This strategy evaluates all applicable policies in order and returns the result of the *last* applicable one.
3333
+ *
3334
+ * Use this strategy when:
3335
+ * - Later policies should override earlier ones.
3336
+ * - You want a "last rule wins" behavior.
3337
+ *
3338
+ * Example:
3339
+ * Policies:
3340
+ * P1 → permit
3341
+ * P2 → deny
3342
+ * P3 → permit
3343
+ * Result: permit (P3 is the last applicable)
3344
+ */
3345
+ class SequentialLastMatchStrategy extends AbilityStrategy {
3346
+ _decisive = null;
3347
+ evaluate() {
3348
+ const last = this.lastMatched();
3349
+ // Нет совпавших политик → deny по умолчанию
3350
+ if (!last) {
3351
+ this._decisive = null;
3352
+ return AbilityPolicyEffect.deny;
3353
+ }
3354
+ // Последняя совпавшая политика — решающая
3355
+ this._decisive = last;
3356
+ return last.effect;
3357
+ }
3358
+ decisivePolicy() {
3359
+ return this._decisive;
3360
+ }
3361
+ }
3362
+
3363
+ /**
3364
+ * PriorityStrategy
3365
+ *
3366
+ * This strategy evaluates policies based on their numeric priority.
3367
+ * The policy with the highest priority (lowest number or highest number depending on implementation)
3368
+ * determines the final result.
3369
+ *
3370
+ * Use this strategy when:
3371
+ * - Policies have explicit priority levels.
3372
+ * - You want deterministic resolution based on ranking.
3373
+ *
3374
+ * Example:
3375
+ * Policies:
3376
+ * P1 (priority 10) → deny
3377
+ * P2 (priority 1) → permit
3378
+ * Result: permit (P2 has higher priority)
3379
+ */
3380
+ class PriorityStrategy extends AbilityStrategy {
3381
+ _decisive = null;
3382
+ evaluate() {
3383
+ const matched = this.matchedPolicies();
3384
+ // 1. Нет совпавших политик → deny, решающей политики нет
3385
+ if (matched.length === 0) {
3386
+ this._decisive = null;
3387
+ return AbilityPolicyEffect.deny;
3388
+ }
3389
+ // 2. Сортируем по приоритету (больший приоритет — выше)
3390
+ const sorted = [...matched].sort((a, b) => b.priority - a.priority);
3391
+ // 3. Самая приоритетная политика — решающая
3392
+ const top = sorted[0];
3393
+ this._decisive = top;
3394
+ return top.effect;
3395
+ }
3396
+ decisivePolicy() {
3397
+ return this._decisive;
3398
+ }
3399
+ }
3400
+
3401
+ exports.AbilityCompare = AbilityCompare;
3402
+ exports.AbilityCondition = AbilityCondition;
3403
+ exports.AbilityDSLLexer = AbilityDSLLexer;
3404
+ exports.AbilityDSLParser = AbilityDSLParser;
3405
+ exports.AbilityDSLToken = AbilityDSLToken;
3406
+ exports.AbilityError = AbilityError;
3407
+ exports.AbilityExplain = AbilityExplain;
3408
+ exports.AbilityExplainPolicy = AbilityExplainPolicy;
3409
+ exports.AbilityExplainRule = AbilityExplainRule;
3410
+ exports.AbilityExplainRuleSet = AbilityExplainRuleSet;
3411
+ exports.AbilityJSONParser = AbilityJSONParser;
3412
+ exports.AbilityMatch = AbilityMatch;
3413
+ exports.AbilityParserError = AbilityParserError;
3414
+ exports.AbilityPolicy = AbilityPolicy;
3415
+ exports.AbilityPolicyEffect = AbilityPolicyEffect;
3416
+ exports.AbilityResolver = AbilityResolver;
3417
+ exports.AbilityResult = AbilityResult;
3418
+ exports.AbilityRule = AbilityRule;
3419
+ exports.AbilityRuleSet = AbilityRuleSet;
3420
+ exports.AbilityStrategy = AbilityStrategy;
3421
+ exports.AbilityTypeGenerator = AbilityTypeGenerator;
3422
+ exports.AllMustPermitStrategy = AllMustPermitStrategy;
3423
+ exports.AnyPermitStrategy = AnyPermitStrategy;
3424
+ exports.DenyOverridesStrategy = DenyOverridesStrategy;
3425
+ exports.FirstMatchStrategy = FirstMatchStrategy;
3426
+ exports.OnlyOneApplicableStrategy = OnlyOneApplicableStrategy;
3427
+ exports.PermitOverridesStrategy = PermitOverridesStrategy;
3428
+ exports.PriorityStrategy = PriorityStrategy;
3429
+ exports.SequentialLastMatchStrategy = SequentialLastMatchStrategy;
3430
+ exports.TokenTypes = TokenTypes;
3431
+ exports.ability = ability;
3432
+ exports.fromLiteral = fromLiteral;
3433
+ exports.isConditionEqual = isConditionEqual;
3434
+ exports.isConditionNotEqual = isConditionNotEqual;
3435
+ exports.toLiteral = toLiteral;