odgn-rights 0.2.0 → 0.5.0

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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * ElysiaJS integration for odgn-rights
3
+ *
4
+ * @module odgn-rights/integrations/elysia
5
+ *
6
+ * This module provides middleware and utilities for integrating
7
+ * odgn-rights with ElysiaJS applications.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { Elysia } from 'elysia';
12
+ * import { elysiaRights } from 'odgn-rights/integrations/elysia';
13
+ *
14
+ * const app = new Elysia()
15
+ * .use(elysiaRights({
16
+ * registry: subjectRegistry,
17
+ * getSubject: ({ headers }) => headers.get('x-user-id')
18
+ * }))
19
+ * .get('/users', () => 'list users')
20
+ * .listen(3000);
21
+ * ```
22
+ */
23
+ /**
24
+ * HTTP method to Flags mapping
25
+ */
26
+ const DEFAULT_METHOD_FLAG_MAP = {
27
+ DELETE: 4, // DELETE
28
+ GET: 1, // READ
29
+ HEAD: 1,
30
+ OPTIONS: 1,
31
+ PATCH: 2,
32
+ POST: 8, // CREATE
33
+ PUT: 2 // WRITE
34
+ };
35
+ /**
36
+ * Create an Elysia plugin for rights-based authorization.
37
+ *
38
+ * @example Using with SubjectRegistry
39
+ * ```typescript
40
+ * import { Elysia } from 'elysia';
41
+ * import { elysiaRights } from 'odgn-rights/integrations/elysia';
42
+ *
43
+ * const app = new Elysia()
44
+ * .use(elysiaRights({
45
+ * registry: subjectRegistry,
46
+ * getSubject: ({ headers }) => headers.get('x-user-id')
47
+ * }))
48
+ * .get('/users', () => 'list users')
49
+ * .listen(3000);
50
+ * ```
51
+ *
52
+ * @example Using with direct Subject
53
+ * ```typescript
54
+ * const app = new Elysia()
55
+ * .derive(({ headers }) => ({
56
+ * user: getUserFromToken(headers.get('authorization'))
57
+ * }))
58
+ * .use(elysiaRights({
59
+ * getSubject: ({ user }) => user?.subject
60
+ * }))
61
+ * .get('/users', () => 'list users')
62
+ * .listen(3000);
63
+ * ```
64
+ */
65
+ export const elysiaRights = (options) => {
66
+ // Dynamic import to avoid requiring elysia as a hard dependency
67
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
68
+ const { Elysia } = require('elysia');
69
+ const { flagMapper, getContext, getSubject, onNoSubject, onUnauthorized, pathMapper, registry } = options;
70
+ return new Elysia({ name: 'odgn-rights' }).onBeforeHandle({ as: 'scoped' }, async ({ path, request, set, ...rest }) => {
71
+ const method = request.method;
72
+ // Get the subject
73
+ const subjectOrId = await getSubject({
74
+ headers: request.headers,
75
+ path,
76
+ request,
77
+ ...rest
78
+ });
79
+ let subject;
80
+ if (subjectOrId === null || subjectOrId === undefined) {
81
+ if (onNoSubject) {
82
+ return onNoSubject({ path, request });
83
+ }
84
+ set.status = 401;
85
+ return { error: 'Unauthorized', message: 'No subject found' };
86
+ }
87
+ if (typeof subjectOrId === 'string') {
88
+ if (!registry) {
89
+ throw new Error('elysiaRights: registry is required when getSubject returns a string identifier');
90
+ }
91
+ subject = registry.get(subjectOrId);
92
+ if (!subject) {
93
+ if (onNoSubject) {
94
+ return onNoSubject({ path, request });
95
+ }
96
+ set.status = 401;
97
+ return { error: 'Unauthorized', message: 'Subject not found' };
98
+ }
99
+ }
100
+ else {
101
+ subject = subjectOrId;
102
+ }
103
+ // Map the path
104
+ const rightsPath = pathMapper
105
+ ? pathMapper({ method, path, request })
106
+ : path;
107
+ // Get the required flags
108
+ const requiredFlags = flagMapper
109
+ ? flagMapper({ method, path, request })
110
+ : (DEFAULT_METHOD_FLAG_MAP[method] ?? 1); // Default to READ
111
+ // Get condition context if provided
112
+ const context = getContext
113
+ ? await getContext({
114
+ headers: request.headers,
115
+ path,
116
+ request,
117
+ subject,
118
+ ...rest
119
+ })
120
+ : undefined;
121
+ // Check permission
122
+ const allowed = subject.has(rightsPath, requiredFlags, context);
123
+ if (!allowed) {
124
+ if (onUnauthorized) {
125
+ return onUnauthorized({
126
+ path: rightsPath,
127
+ request,
128
+ requiredFlags,
129
+ subject
130
+ });
131
+ }
132
+ set.status = 403;
133
+ return { error: 'Forbidden', message: 'Access denied' };
134
+ }
135
+ // Permission granted, continue to handler
136
+ });
137
+ };
138
+ /**
139
+ * Create an Elysia plugin for rights-based authorization using a Rights instance directly.
140
+ * Use this when you don't need subject/role-based access control.
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * import { Elysia } from 'elysia';
145
+ * import { elysiaRightsStandalone } from 'odgn-rights/integrations/elysia';
146
+ * import { Rights, Flags } from 'odgn-rights';
147
+ *
148
+ * const rights = new Rights();
149
+ * rights.allow('/api/**', Flags.READ);
150
+ * rights.allow('/api/admin/**', Flags.ALL);
151
+ *
152
+ * const app = new Elysia()
153
+ * .use(elysiaRightsStandalone({ rights }))
154
+ * .get('/api/users', () => 'list users')
155
+ * .listen(3000);
156
+ * ```
157
+ */
158
+ export const elysiaRightsStandalone = (options) => {
159
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
160
+ const { Elysia } = require('elysia');
161
+ const { flagMapper, getContext, onUnauthorized, pathMapper, rights } = options;
162
+ return new Elysia({ name: 'odgn-rights-standalone' }).onBeforeHandle({ as: 'scoped' }, async ({ path, request, set, ...rest }) => {
163
+ const method = request.method;
164
+ // Map the path
165
+ const rightsPath = pathMapper
166
+ ? pathMapper({ method, path, request })
167
+ : path;
168
+ // Get the required flags
169
+ const requiredFlags = flagMapper
170
+ ? flagMapper({ method, path, request })
171
+ : (DEFAULT_METHOD_FLAG_MAP[method] ?? 1);
172
+ // Get condition context if provided
173
+ const context = getContext
174
+ ? await getContext({
175
+ headers: request.headers,
176
+ path,
177
+ request,
178
+ subject: undefined,
179
+ ...rest
180
+ })
181
+ : undefined;
182
+ // Check permission
183
+ const allowed = rights.has(rightsPath, requiredFlags, context);
184
+ if (!allowed) {
185
+ if (onUnauthorized) {
186
+ return onUnauthorized({
187
+ path: rightsPath,
188
+ request,
189
+ requiredFlags,
190
+ subject: undefined
191
+ });
192
+ }
193
+ set.status = 403;
194
+ return { error: 'Forbidden', message: 'Access denied' };
195
+ }
196
+ });
197
+ };
198
+ /**
199
+ * Create a guard configuration for use with Elysia's .guard() method.
200
+ * This allows more fine-grained control over which routes are protected.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * import { Elysia } from 'elysia';
205
+ * import { createRightsGuard } from 'odgn-rights/integrations/elysia';
206
+ *
207
+ * const guard = createRightsGuard({
208
+ * registry: subjectRegistry,
209
+ * getSubject: ({ headers }) => headers.get('x-user-id')
210
+ * });
211
+ *
212
+ * const app = new Elysia()
213
+ * .get('/public', () => 'anyone can see this')
214
+ * .guard(guard, (app) =>
215
+ * app
216
+ * .get('/protected', () => 'only authorized users')
217
+ * .post('/protected', () => 'create something')
218
+ * )
219
+ * .listen(3000);
220
+ * ```
221
+ */
222
+ export const createRightsGuard = (options) => {
223
+ const { flagMapper, getContext, getSubject, onNoSubject, onUnauthorized, pathMapper, registry } = options;
224
+ return {
225
+ async beforeHandle({ path, request, set, ...rest }) {
226
+ const method = request.method;
227
+ const subjectOrId = await getSubject({
228
+ headers: request.headers,
229
+ path,
230
+ request,
231
+ ...rest
232
+ });
233
+ let subject;
234
+ if (subjectOrId === null || subjectOrId === undefined) {
235
+ if (onNoSubject) {
236
+ return onNoSubject({ path, request });
237
+ }
238
+ set.status = 401;
239
+ return { error: 'Unauthorized', message: 'No subject found' };
240
+ }
241
+ if (typeof subjectOrId === 'string') {
242
+ if (!registry) {
243
+ throw new Error('createRightsGuard: registry is required when getSubject returns a string identifier');
244
+ }
245
+ subject = registry.get(subjectOrId);
246
+ if (!subject) {
247
+ if (onNoSubject) {
248
+ return onNoSubject({ path, request });
249
+ }
250
+ set.status = 401;
251
+ return { error: 'Unauthorized', message: 'Subject not found' };
252
+ }
253
+ }
254
+ else {
255
+ subject = subjectOrId;
256
+ }
257
+ const rightsPath = pathMapper
258
+ ? pathMapper({ method, path, request })
259
+ : path;
260
+ const requiredFlags = flagMapper
261
+ ? flagMapper({ method, path, request })
262
+ : (DEFAULT_METHOD_FLAG_MAP[method] ?? 1);
263
+ const context = getContext
264
+ ? await getContext({
265
+ headers: request.headers,
266
+ path,
267
+ request,
268
+ subject,
269
+ ...rest
270
+ })
271
+ : undefined;
272
+ const allowed = subject.has(rightsPath, requiredFlags, context);
273
+ if (!allowed) {
274
+ if (onUnauthorized) {
275
+ return onUnauthorized({
276
+ path: rightsPath,
277
+ request,
278
+ requiredFlags,
279
+ subject
280
+ });
281
+ }
282
+ set.status = 403;
283
+ return { error: 'Forbidden', message: 'Access denied' };
284
+ }
285
+ }
286
+ };
287
+ };
288
+ /**
289
+ * Create a macro for declarative per-route authorization.
290
+ * This allows you to specify rights requirements directly on route definitions.
291
+ *
292
+ * @example
293
+ * ```typescript
294
+ * import { Elysia } from 'elysia';
295
+ * import { createRightsMacro } from 'odgn-rights/integrations/elysia';
296
+ * import { Flags } from 'odgn-rights';
297
+ *
298
+ * const rightsMacro = createRightsMacro({
299
+ * registry: subjectRegistry,
300
+ * getSubject: ({ headers }) => headers.get('x-user-id')
301
+ * });
302
+ *
303
+ * const app = new Elysia()
304
+ * .use(rightsMacro)
305
+ * .get('/public', () => 'anyone')
306
+ * .get('/users', () => 'list users', {
307
+ * rights: { path: '/users', flags: Flags.READ }
308
+ * })
309
+ * .delete('/users/:id', () => 'delete user', {
310
+ * rights: { path: '/users/*', flags: Flags.DELETE }
311
+ * })
312
+ * .listen(3000);
313
+ * ```
314
+ */
315
+ export const createRightsMacro = (options) => {
316
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
317
+ const { Elysia } = require('elysia');
318
+ const { getContext, getSubject, onNoSubject, onUnauthorized, registry } = options;
319
+ return new Elysia({ name: 'odgn-rights-macro' }).macro({
320
+ rights: (rightsConfig) => ({
321
+ async resolve({ path, request, set, status, ...rest }) {
322
+ const subjectOrId = await getSubject({
323
+ headers: request.headers,
324
+ path,
325
+ request,
326
+ ...rest
327
+ });
328
+ let subject;
329
+ if (subjectOrId === null || subjectOrId === undefined) {
330
+ if (onNoSubject) {
331
+ return onNoSubject({ path, request });
332
+ }
333
+ return status(401);
334
+ }
335
+ if (typeof subjectOrId === 'string') {
336
+ if (!registry) {
337
+ throw new Error('createRightsMacro: registry is required when getSubject returns a string identifier');
338
+ }
339
+ subject = registry.get(subjectOrId);
340
+ if (!subject) {
341
+ if (onNoSubject) {
342
+ return onNoSubject({ path, request });
343
+ }
344
+ return status(401);
345
+ }
346
+ }
347
+ else {
348
+ subject = subjectOrId;
349
+ }
350
+ const context = getContext
351
+ ? await getContext({
352
+ headers: request.headers,
353
+ path,
354
+ request,
355
+ subject,
356
+ ...rest
357
+ })
358
+ : undefined;
359
+ const allowed = subject.has(rightsConfig.path, rightsConfig.flags, context);
360
+ if (!allowed) {
361
+ if (onUnauthorized) {
362
+ return onUnauthorized({
363
+ path: rightsConfig.path,
364
+ request,
365
+ requiredFlags: rightsConfig.flags,
366
+ subject
367
+ });
368
+ }
369
+ return status(403);
370
+ }
371
+ return { subject };
372
+ }
373
+ })
374
+ });
375
+ };
package/dist/right.d.ts CHANGED
@@ -6,6 +6,7 @@ export type RightInit = {
6
6
  condition?: Condition;
7
7
  deny?: Flags[];
8
8
  description?: string;
9
+ priority?: number;
9
10
  tags?: string[];
10
11
  validFrom?: Date;
11
12
  validUntil?: Date;
@@ -20,7 +21,9 @@ export declare class Right {
20
21
  readonly validUntil?: Date;
21
22
  private readonly _tags;
22
23
  private readonly _specificity;
24
+ private readonly _priority;
23
25
  private readonly _re?;
26
+ private _dbId?;
24
27
  constructor(path: string, init?: RightInit);
25
28
  allow(flag: Flags): this;
26
29
  deny(flag: Flags): this;
@@ -35,18 +38,22 @@ export declare class Right {
35
38
  isExpired(now?: Date): boolean;
36
39
  get allowMaskValue(): number;
37
40
  get denyMaskValue(): number;
41
+ get dbId(): number | undefined;
42
+ _setDbId(id: number): void;
38
43
  toString(): string;
39
44
  toJSON(): {
40
45
  allow: string;
41
46
  deny?: string;
42
47
  description?: string;
43
48
  path: string;
49
+ priority?: number;
44
50
  tags?: string[];
45
51
  validFrom?: string;
46
52
  validUntil?: string;
47
53
  };
48
54
  matches(targetPath: string): boolean;
49
55
  specificity(): number;
56
+ get priority(): number;
50
57
  private calculateSpecificity;
51
58
  private static globToRegExp;
52
59
  static parse(input: string): Right;
package/dist/right.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { ALL_BITS, Flags, hasBit } from './constants';
2
- import { lettersFromMask, normalizePath } from './utils';
2
+ import { lettersFromMask, normalizePath, parsePath } from './helpers';
3
3
  export class Right {
4
4
  constructor(path, init) {
5
5
  this.allowMask = 0;
6
6
  this.denyMask = 0;
7
- this.path = normalizePath(path);
7
+ const parsed = parsePath(path);
8
+ this.path = parsed.path;
8
9
  this.description = init?.description;
9
10
  this.condition = init?.condition;
10
11
  this.validFrom = init?.validFrom;
11
12
  this.validUntil = init?.validUntil;
12
13
  this._tags = new Set(init?.tags);
14
+ this._priority = init?.priority ?? 0;
13
15
  if (this.validFrom && this.validUntil && this.validFrom > this.validUntil) {
14
16
  throw new Error('validFrom must be before validUntil');
15
17
  }
@@ -17,11 +19,23 @@ export class Right {
17
19
  if (this.path.includes('*') || this.path.includes('?')) {
18
20
  this._re = Right.globToRegExp(this.path);
19
21
  }
20
- if (init?.allow) {
21
- init.allow.forEach(f => this.allow(f));
22
+ // When path is negated (!path), swap allow and deny semantics
23
+ // allow becomes deny, deny becomes allow
24
+ if (parsed.negated) {
25
+ if (init?.allow) {
26
+ init.allow.forEach(f => this.deny(f));
27
+ }
28
+ if (init?.deny) {
29
+ init.deny.forEach(f => this.allow(f));
30
+ }
22
31
  }
23
- if (init?.deny) {
24
- init.deny.forEach(f => this.deny(f));
32
+ else {
33
+ if (init?.allow) {
34
+ init.allow.forEach(f => this.allow(f));
35
+ }
36
+ if (init?.deny) {
37
+ init.deny.forEach(f => this.deny(f));
38
+ }
25
39
  }
26
40
  }
27
41
  allow(flag) {
@@ -102,6 +116,12 @@ export class Right {
102
116
  get denyMaskValue() {
103
117
  return this.denyMask;
104
118
  }
119
+ get dbId() {
120
+ return this._dbId;
121
+ }
122
+ _setDbId(id) {
123
+ this._dbId = id;
124
+ }
105
125
  toString() {
106
126
  const denyLetters = lettersFromMask(this.denyMask);
107
127
  const allowLetters = lettersFromMask(this.allowMask);
@@ -114,6 +134,9 @@ export class Right {
114
134
  }
115
135
  const left = parts.join('');
116
136
  let res = `${left}:${this.path}`;
137
+ if (this._priority !== 0) {
138
+ res += `^${this._priority}`;
139
+ }
117
140
  if (this._tags.size > 0) {
118
141
  res += `#${this.tags.join(',')}`;
119
142
  }
@@ -137,6 +160,9 @@ export class Right {
137
160
  if (this.description) {
138
161
  out.description = this.description;
139
162
  }
163
+ if (this._priority !== 0) {
164
+ out.priority = this._priority;
165
+ }
140
166
  if (this._tags.size > 0) {
141
167
  out.tags = this.tags;
142
168
  }
@@ -164,6 +190,9 @@ export class Right {
164
190
  specificity() {
165
191
  return this._specificity;
166
192
  }
193
+ get priority() {
194
+ return this._priority;
195
+ }
167
196
  calculateSpecificity() {
168
197
  const parts = this.path.split('/').filter(p => p.length > 0);
169
198
  let literalCount = 0;
@@ -213,6 +242,7 @@ export class Right {
213
242
  let pathStr = '';
214
243
  let tagsStr = '';
215
244
  let timeStr = '';
245
+ let priorityStr = '';
216
246
  let pathEndIdx = s.length;
217
247
  if (atIdx !== -1) {
218
248
  timeStr = s.slice(atIdx + 1);
@@ -222,6 +252,13 @@ export class Right {
222
252
  tagsStr = s.slice(hashIdx + 1, pathEndIdx);
223
253
  pathEndIdx = hashIdx;
224
254
  }
255
+ // Find ^ for priority (between path and #/@)
256
+ const pathStartIdx = colonIdx === -1 ? 0 : colonIdx + 1;
257
+ const caretIdx = s.indexOf('^', pathStartIdx);
258
+ if (caretIdx !== -1 && caretIdx < pathEndIdx) {
259
+ priorityStr = s.slice(caretIdx + 1, pathEndIdx);
260
+ pathEndIdx = caretIdx;
261
+ }
225
262
  if (colonIdx === -1) {
226
263
  pathStr = s.slice(0, pathEndIdx);
227
264
  }
@@ -250,11 +287,20 @@ export class Right {
250
287
  if (tagsStr) {
251
288
  init.tags = tagsStr.split(',').map(t => t.trim());
252
289
  }
253
- const r = new Right(pathStr, init);
290
+ if (priorityStr) {
291
+ const p = Number.parseInt(priorityStr, 10);
292
+ if (!Number.isNaN(p)) {
293
+ init.priority = p;
294
+ }
295
+ }
296
+ // Check if path is negated (starts with !)
297
+ const parsed = parsePath(pathStr);
298
+ const r = new Right(parsed.path, init);
254
299
  if (!flagsStr) {
255
300
  return r;
256
301
  }
257
302
  // parse groups like '-abc+xyz' or '+xyz-abc'
303
+ // When path is negated, swap the meaning of + and -
258
304
  let i = 0;
259
305
  while (i < flagsStr.length) {
260
306
  const sign = flagsStr[i];
@@ -270,7 +316,16 @@ export class Right {
270
316
  letters += flagsStr[i];
271
317
  i++;
272
318
  }
273
- const apply = sign === '+' ? (f) => r.allow(f) : (f) => r.deny(f);
319
+ // When negated: + becomes deny, - becomes allow
320
+ let apply;
321
+ if (parsed.negated) {
322
+ apply =
323
+ sign === '+' ? (f) => r.deny(f) : (f) => r.allow(f);
324
+ }
325
+ else {
326
+ apply =
327
+ sign === '+' ? (f) => r.allow(f) : (f) => r.deny(f);
328
+ }
274
329
  if (letters === '*') {
275
330
  apply(Flags.ALL);
276
331
  continue;
package/dist/rights.d.ts CHANGED
@@ -5,6 +5,7 @@ export type RightJSON = {
5
5
  deny?: string;
6
6
  description?: string;
7
7
  path: string;
8
+ priority?: number;
8
9
  tags?: string[];
9
10
  validFrom?: string;
10
11
  validUntil?: string;
@@ -24,6 +25,20 @@ export declare class Rights {
24
25
  allRights(): Right[];
25
26
  allow(path: string, ...flags: Flags[]): this;
26
27
  deny(path: string, flag: Flags): this;
28
+ /**
29
+ * Exclude (deny) specific flags for a path pattern.
30
+ * This is equivalent to calling deny() for each flag but accepts multiple flags.
31
+ * Provides clearer semantics for exclusion patterns.
32
+ *
33
+ * @example
34
+ * rights.allow('/api/**', Flags.READ);
35
+ * rights.exclude('/api/internal/**', Flags.READ); // Deny read on internal paths
36
+ *
37
+ * @example
38
+ * rights.allow('/files/**', Flags.ALL);
39
+ * rights.exclude('/files/system/**', Flags.DELETE, Flags.WRITE); // Protect system files
40
+ */
41
+ exclude(path: string, ...flags: Flags[]): this;
27
42
  private matchOrdered;
28
43
  has(path: string, flag: Flags, context?: ConditionContext): boolean;
29
44
  explain(path: string, flag: Flags, context?: ConditionContext): {
@@ -42,6 +57,10 @@ export declare class Rights {
42
57
  delete(path: string, context?: ConditionContext): boolean;
43
58
  create(path: string, context?: ConditionContext): boolean;
44
59
  execute(path: string, context?: ConditionContext): boolean;
60
+ checkMany(requests: Array<{
61
+ flags: Flags;
62
+ path: string;
63
+ }>, context?: ConditionContext): boolean[];
45
64
  toString(): string;
46
65
  toJSON(): RightJSON[];
47
66
  static fromJSON(arr: RightJSON[]): Rights;
package/dist/rights.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ALL_BITS, Flags, hasBit } from './constants';
2
+ import { normalizePath } from './helpers';
2
3
  import { Right } from './right';
3
- import { normalizePath } from './utils';
4
4
  export class Rights {
5
5
  constructor() {
6
6
  this.list = [];
@@ -94,6 +94,40 @@ export class Rights {
94
94
  this.notify();
95
95
  return this;
96
96
  }
97
+ /**
98
+ * Exclude (deny) specific flags for a path pattern.
99
+ * This is equivalent to calling deny() for each flag but accepts multiple flags.
100
+ * Provides clearer semantics for exclusion patterns.
101
+ *
102
+ * @example
103
+ * rights.allow('/api/**', Flags.READ);
104
+ * rights.exclude('/api/internal/**', Flags.READ); // Deny read on internal paths
105
+ *
106
+ * @example
107
+ * rights.allow('/files/**', Flags.ALL);
108
+ * rights.exclude('/files/system/**', Flags.DELETE, Flags.WRITE); // Protect system files
109
+ */
110
+ exclude(path, ...flags) {
111
+ // Strip leading ! if present (user might write exclude('!/path'))
112
+ const cleanPath = path.startsWith('!') ? path.slice(1) : path;
113
+ const p = normalizePath(cleanPath);
114
+ let r = this.list.find(x => x.path === p);
115
+ if (!r) {
116
+ r = new Right(p);
117
+ this.list.push(r);
118
+ }
119
+ else {
120
+ // Invalidate cache if we update an existing right
121
+ this.matchCache.clear();
122
+ }
123
+ // Support spreading an array: exclude(path, [Flags.READ, Flags.WRITE] as any)
124
+ const flat = [].concat(...flags);
125
+ for (const f of flat) {
126
+ r.deny(f);
127
+ }
128
+ this.notify();
129
+ return this;
130
+ }
97
131
  matchOrdered(path) {
98
132
  const cached = this.matchCache.get(path);
99
133
  if (cached) {
@@ -101,7 +135,15 @@ export class Rights {
101
135
  }
102
136
  const result = this.list
103
137
  .filter(r => r.matches(path))
104
- .sort((a, b) => b.specificity() - a.specificity());
138
+ .sort((a, b) => {
139
+ // Priority first (higher wins)
140
+ const pDiff = b.priority - a.priority;
141
+ if (pDiff !== 0) {
142
+ return pDiff;
143
+ }
144
+ // Then specificity
145
+ return b.specificity() - a.specificity();
146
+ });
105
147
  this.matchCache.set(path, result);
106
148
  return result;
107
149
  }
@@ -177,6 +219,9 @@ export class Rights {
177
219
  execute(path, context) {
178
220
  return this.has(path, Flags.EXECUTE, context);
179
221
  }
222
+ checkMany(requests, context) {
223
+ return requests.map(req => this.has(req.path, req.flags, context));
224
+ }
180
225
  toString() {
181
226
  return this.list.map(r => r.toString()).join(', ');
182
227
  }
@@ -189,6 +234,7 @@ export class Rights {
189
234
  const p = normalizePath(item.path);
190
235
  const init = {
191
236
  description: item.description,
237
+ priority: item.priority,
192
238
  tags: item.tags
193
239
  };
194
240
  if (item.validFrom) {