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