@vertesia/studio-utils 1.4.0-dev.20260615.051508Z → 1.4.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.
Files changed (45) hide show
  1. package/lib/conditions/index.d.ts +2 -0
  2. package/lib/conditions/index.d.ts.map +1 -0
  3. package/lib/conditions/index.js +2 -0
  4. package/lib/conditions/index.js.map +1 -0
  5. package/lib/conditions/match.d.ts +54 -0
  6. package/lib/conditions/match.d.ts.map +1 -0
  7. package/lib/conditions/match.js +156 -0
  8. package/lib/conditions/match.js.map +1 -0
  9. package/lib/index.d.ts +1 -0
  10. package/lib/index.d.ts.map +1 -1
  11. package/lib/index.js +1 -0
  12. package/lib/index.js.map +1 -1
  13. package/lib/logger.d.ts +6 -0
  14. package/lib/logger.d.ts.map +1 -0
  15. package/lib/logger.js +15 -0
  16. package/lib/logger.js.map +1 -0
  17. package/lib/roles/classes.d.ts +59 -0
  18. package/lib/roles/classes.d.ts.map +1 -0
  19. package/lib/roles/classes.js +60 -0
  20. package/lib/roles/classes.js.map +1 -0
  21. package/lib/roles/content.d.ts +13 -0
  22. package/lib/roles/content.d.ts.map +1 -0
  23. package/lib/roles/content.js +39 -0
  24. package/lib/roles/content.js.map +1 -0
  25. package/lib/roles/index.d.ts +37 -0
  26. package/lib/roles/index.d.ts.map +1 -0
  27. package/lib/roles/index.js +87 -0
  28. package/lib/roles/index.js.map +1 -0
  29. package/lib/roles/system.d.ts +3 -0
  30. package/lib/roles/system.d.ts.map +1 -0
  31. package/lib/roles/system.js +187 -0
  32. package/lib/roles/system.js.map +1 -0
  33. package/lib/vertesia-studio-utils.js +1 -1
  34. package/lib/vertesia-studio-utils.js.map +1 -1
  35. package/package.json +26 -9
  36. package/src/conditions/index.ts +1 -0
  37. package/src/conditions/match.test.ts +75 -0
  38. package/src/conditions/match.ts +146 -0
  39. package/src/index.ts +1 -0
  40. package/src/logger.ts +25 -0
  41. package/src/roles/classes.ts +78 -0
  42. package/src/roles/content.ts +46 -0
  43. package/src/roles/index.test.ts +206 -0
  44. package/src/roles/index.ts +96 -0
  45. package/src/roles/system.ts +204 -0
@@ -0,0 +1,146 @@
1
+ import type { PropertyConditions } from '@vertesia/common';
2
+ import { getStudioUtilsLogger } from '../logger.js';
3
+
4
+ /**
5
+ * Resolve a property path against a context object.
6
+ *
7
+ * Supports:
8
+ * - `clearance` → obj.clearance
9
+ * - `email` → obj.email
10
+ * - `properties.department` → obj.properties.department (one nested level under `properties`)
11
+ *
12
+ * Returns `undefined` if any segment is missing or non-traversable.
13
+ */
14
+ export function resolvePath(obj: Record<string, unknown>, path: string): unknown {
15
+ if (path.startsWith('properties.')) {
16
+ const properties = obj.properties;
17
+ return properties && typeof properties === 'object'
18
+ ? (properties as Record<string, unknown>)[path.slice(11)]
19
+ : undefined;
20
+ }
21
+ return obj[path];
22
+ }
23
+
24
+ /**
25
+ * Match a wildcard pattern against a string value.
26
+ *
27
+ * Supports `*` as wildcard:
28
+ * - `"*@domain.com"` — ends with
29
+ * - `"bogdan.*"` — starts with
30
+ * - `"bogdan+*@vertesia.com"` — contains
31
+ * - `"*"` — match all
32
+ *
33
+ * Case-insensitive. Non-string `value` always returns `false`.
34
+ */
35
+ export function matchLike(value: unknown, pattern: string): boolean {
36
+ if (typeof value !== 'string') return false;
37
+ // Convert wildcard pattern to regex: escape regex chars except *, then replace * with .*
38
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
39
+ return new RegExp(`^${escaped}$`, 'i').test(value);
40
+ }
41
+
42
+ function compareOrdered(value: unknown, expected: unknown, op: '$gt' | '$gte' | '$lt' | '$lte'): boolean {
43
+ if (typeof value !== 'number' && typeof value !== 'string') return false;
44
+ if (typeof value !== typeof expected) return false;
45
+ const left = value;
46
+ const right = expected as typeof left;
47
+ switch (op) {
48
+ case '$gt':
49
+ return left > right;
50
+ case '$gte':
51
+ return left >= right;
52
+ case '$lt':
53
+ return left < right;
54
+ case '$lte':
55
+ return left <= right;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Evaluate a PropertyConditions object against a set of properties (JS-side, in-memory).
61
+ *
62
+ * Supports the MongoDB query syntax subset shared across token-server (principal property
63
+ * matching at JWT-mint time) and zeno-server (write-path ABAC permission checks against an
64
+ * in-memory document):
65
+ *
66
+ * - Direct value match (`{ field: literal }`)
67
+ * - `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`
68
+ * - `$in`, `$nin` (against array `expected`)
69
+ * - `$exists` (boolean)
70
+ * - `$empty` (boolean) — true for undefined/null/empty-string/empty-array
71
+ * - `$like` (wildcard string, see {@link matchLike})
72
+ *
73
+ * All field-level conditions are AND'd (every field must match). Within a single field, all
74
+ * operators are AND'd.
75
+ *
76
+ * Mirrors the Mongo-side filter built by `conditionsToMongoFilter` (zeno-server utils.ts) and
77
+ * the ES-side filter built by `conditionsToEsQuery`. Use this when conditions need to be
78
+ * evaluated against a single hydrated document instead of pushed down as a query.
79
+ *
80
+ * Unknown operators emit a warning via the studio-utils logger and return false — surfacing
81
+ * misconfiguration loudly without breaking the surrounding token mint or permission check.
82
+ *
83
+ * `$principal.X` substitutions in conditions are expected to have already been resolved at
84
+ * JWT-mint time by `resolveConditions` in token-server; values reaching this function are
85
+ * concrete.
86
+ */
87
+ export function matchConditions(conditions: PropertyConditions, properties: Record<string, unknown>): boolean {
88
+ for (const [key, condition] of Object.entries(conditions)) {
89
+ const value = resolvePath(properties, key);
90
+
91
+ if (condition === null || condition === undefined || typeof condition !== 'object') {
92
+ // Direct value match
93
+ if (value !== condition) return false;
94
+ } else {
95
+ // Operator object
96
+ const ops = condition as Record<string, unknown>;
97
+ for (const [op, expected] of Object.entries(ops)) {
98
+ switch (op) {
99
+ case '$eq':
100
+ if (value !== expected) return false;
101
+ break;
102
+ case '$ne':
103
+ if (value === expected) return false;
104
+ break;
105
+ case '$gt':
106
+ if (!compareOrdered(value, expected, '$gt')) return false;
107
+ break;
108
+ case '$gte':
109
+ if (!compareOrdered(value, expected, '$gte')) return false;
110
+ break;
111
+ case '$lt':
112
+ if (!compareOrdered(value, expected, '$lt')) return false;
113
+ break;
114
+ case '$lte':
115
+ if (!compareOrdered(value, expected, '$lte')) return false;
116
+ break;
117
+ case '$in':
118
+ if (!Array.isArray(expected) || !expected.includes(value)) return false;
119
+ break;
120
+ case '$nin':
121
+ if (Array.isArray(expected) && expected.includes(value)) return false;
122
+ break;
123
+ case '$exists':
124
+ if (typeof expected !== 'boolean' || (value !== undefined) !== expected) return false;
125
+ break;
126
+ case '$empty': {
127
+ const isEmpty =
128
+ value === undefined ||
129
+ value === null ||
130
+ value === '' ||
131
+ (Array.isArray(value) && value.length === 0);
132
+ if (typeof expected !== 'boolean' || isEmpty !== expected) return false;
133
+ break;
134
+ }
135
+ case '$like':
136
+ if (typeof expected !== 'string' || !matchLike(value, expected)) return false;
137
+ break;
138
+ default:
139
+ getStudioUtilsLogger().warn({ op, key }, 'Unknown operator in PropertyConditions');
140
+ return false;
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return true;
146
+ }
package/src/index.ts CHANGED
@@ -17,3 +17,4 @@ export {
17
17
  type PromptValidationResult,
18
18
  validatePrompt,
19
19
  } from './prompts/validate.js';
20
+ export * from './roles/index.js';
package/src/logger.ts ADDED
@@ -0,0 +1,25 @@
1
+ export interface StudioUtilsLogger {
2
+ warn(details: Record<string, unknown>, message: string): void;
3
+ }
4
+
5
+ // studio-utils deliberately excludes DOM/Node lib globals so browser and Node
6
+ // consumers stay symmetric. Declare the minimal console surface for the fallback logger.
7
+ declare const console: { warn(...args: unknown[]): void };
8
+
9
+ const consoleLogger: StudioUtilsLogger = {
10
+ warn: (details, message) => {
11
+ console.warn(message, details);
12
+ },
13
+ };
14
+
15
+ let logger: StudioUtilsLogger = consoleLogger;
16
+
17
+ export function getStudioUtilsLogger(): StudioUtilsLogger {
18
+ return logger;
19
+ }
20
+
21
+ export function installStudioUtilsLogger(customLogger: StudioUtilsLogger): StudioUtilsLogger {
22
+ const previousLogger = logger;
23
+ logger = customLogger;
24
+ return previousLogger;
25
+ }
@@ -0,0 +1,78 @@
1
+ import type { AbacScope, Permission, RoleDomain } from '@vertesia/common';
2
+
3
+ /**
4
+ * Class hierarchy and registry-bound interface for the role system. These
5
+ * are LOGIC — they have runtime behavior (constructors, instance methods,
6
+ * subclass dispatch via `instanceof`). They live in `@vertesia/studio-utils`
7
+ * (not common) per the package-layering contract: only types stay in common.
8
+ */
9
+
10
+ /**
11
+ * A role: a named bundle of permissions that ACEs reference by name.
12
+ *
13
+ * Generic over the permission type so subclasses can tighten it:
14
+ * - **System roles** declare `extends Role<Permission>` — construction time
15
+ * type-checks against the central `Permission` enum.
16
+ * - **ABAC roles** (`AbacRole`) declare `extends Role<string>` — permissions
17
+ * are bare verbs (`'read'`, `'write'`, `'delete'`, future domain-specific
18
+ * verbs) consumed by the JWT generator to form `{scope}:{verb}` keys in
19
+ * `content_security`.
20
+ *
21
+ * The registry stores `Role` (defaulting to `Role<string>`) — the loose type
22
+ * is the lowest common denominator. Tight typing is only enforced at
23
+ * declaration sites of the role subclasses.
24
+ */
25
+ export class Role<PermissionType extends string = string> {
26
+ permissions: Set<PermissionType>;
27
+ constructor(
28
+ public name: string,
29
+ permissions: PermissionType[],
30
+ public domain: RoleDomain,
31
+ ) {
32
+ this.permissions = new Set(permissions);
33
+ }
34
+
35
+ hasPermission(permission: PermissionType) {
36
+ return this.permissions.has(permission);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Base class for built-in system roles. Hardcodes `domain: 'system'` and
42
+ * specializes `Role<Permission>` so subclasses get compile-time type-checking
43
+ * against the central `Permission` enum at construction.
44
+ */
45
+ export class SystemRole extends Role<Permission> {
46
+ constructor(name: string, permissions: Permission[]) {
47
+ super(name, permissions, 'system');
48
+ }
49
+ }
50
+
51
+ /**
52
+ * A role usable in ContentSet ACEs. Adds `applicableScopes` — the kinds of
53
+ * objects the role can be applied to at the ABAC scope level. Inherits
54
+ * `Role<string>` because ABAC verbs aren't constrained to the central
55
+ * `Permission` enum.
56
+ *
57
+ * The IAM UI filters via `listAbacRolesForScope` which checks `instanceof AbacRole`.
58
+ */
59
+ export class AbacRole extends Role<string> {
60
+ constructor(
61
+ name: string,
62
+ permissions: string[],
63
+ domain: RoleDomain,
64
+ public applicableScopes: readonly AbacScope[],
65
+ ) {
66
+ super(name, permissions, domain);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A logical bucket of roles owned by a single domain. The registry iterates
72
+ * partitions in registration order — first match wins. The `system` partition
73
+ * is registered first so domain partitions cannot shadow built-in system roles.
74
+ */
75
+ export interface RolePartition {
76
+ domain: RoleDomain;
77
+ roles: Map<string, Role>;
78
+ }
@@ -0,0 +1,46 @@
1
+ import type { AbacScope, RoleDomain } from '@vertesia/common';
2
+ import { AbacRole, type Role, type RolePartition } from './classes.js';
3
+
4
+ const ContentRoleDomain: RoleDomain = 'content';
5
+
6
+ const APPLICABLE_SCOPES: readonly AbacScope[] = ['document', 'collection'];
7
+
8
+ /**
9
+ * Names of roles owned by the `content` domain. Apply to ContentSet ACEs
10
+ * scoped to either `document` or `collection` — the semantics of "read
11
+ * content" are the same for both kinds.
12
+ */
13
+ export enum ContentRoleNames {
14
+ content_reader = 'content_reader',
15
+ content_writer = 'content_writer',
16
+ content_manager = 'content_manager',
17
+ }
18
+
19
+ class ContentReaderRole extends AbacRole {
20
+ constructor() {
21
+ super(ContentRoleNames.content_reader, ['read'], ContentRoleDomain, APPLICABLE_SCOPES);
22
+ }
23
+ }
24
+
25
+ class ContentWriterRole extends AbacRole {
26
+ constructor() {
27
+ super(ContentRoleNames.content_writer, ['read', 'write'], ContentRoleDomain, APPLICABLE_SCOPES);
28
+ }
29
+ }
30
+
31
+ class ContentManagerRole extends AbacRole {
32
+ constructor() {
33
+ super(ContentRoleNames.content_manager, ['read', 'write', 'delete'], ContentRoleDomain, APPLICABLE_SCOPES);
34
+ }
35
+ }
36
+
37
+ const contentRoles: Record<ContentRoleNames, Role> = {
38
+ [ContentRoleNames.content_reader]: new ContentReaderRole(),
39
+ [ContentRoleNames.content_writer]: new ContentWriterRole(),
40
+ [ContentRoleNames.content_manager]: new ContentManagerRole(),
41
+ };
42
+
43
+ export const contentPartition: RolePartition = {
44
+ domain: ContentRoleDomain,
45
+ roles: new Map(Object.entries(contentRoles)),
46
+ };
@@ -0,0 +1,206 @@
1
+ import { Permission, SystemRoles } from '@vertesia/common';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ContentRoleNames } from './content.js';
4
+ import {
5
+ AbacRole,
6
+ getAllRoleNames,
7
+ getPermissionsForRoles,
8
+ getRoleByName,
9
+ listAbacRolesForScope,
10
+ listRoles,
11
+ listRolesByDomain,
12
+ listSystemRoles,
13
+ Role,
14
+ RoleList,
15
+ SystemRole,
16
+ } from './index.js';
17
+
18
+ describe('getRoleByName', () => {
19
+ it('returns a system role by name', () => {
20
+ const role = getRoleByName(SystemRoles.owner);
21
+ expect(role).toBeInstanceOf(SystemRole);
22
+ expect(role.name).toBe('owner');
23
+ expect(role.domain).toBe('system');
24
+ });
25
+
26
+ it('returns an ABAC role by name', () => {
27
+ const role = getRoleByName(ContentRoleNames.content_reader);
28
+ expect(role).toBeInstanceOf(AbacRole);
29
+ expect(role.name).toBe('content_reader');
30
+ expect(role.domain).toBe('content');
31
+ });
32
+
33
+ it('throws on unknown role', () => {
34
+ expect(() => getRoleByName('not_a_real_role')).toThrow(/Role not_a_real_role not found/);
35
+ });
36
+
37
+ it('queries partitions in registration order — system first', () => {
38
+ // System partition is registered before content. Even if a future partition
39
+ // declared a role named 'owner', the system one would still win.
40
+ const role = getRoleByName(SystemRoles.owner);
41
+ expect(role.domain).toBe('system');
42
+ });
43
+ });
44
+
45
+ describe('listRoles', () => {
46
+ it('returns every role across all partitions', () => {
47
+ const roles = listRoles();
48
+ // 16 system roles + 3 content roles
49
+ expect(roles).toHaveLength(19);
50
+ });
51
+
52
+ it('lists system roles before content roles (partition registration order)', () => {
53
+ const roles = listRoles();
54
+ const systemCount = roles.filter((r) => r.domain === 'system').length;
55
+ const contentCount = roles.filter((r) => r.domain === 'content').length;
56
+ expect(systemCount).toBe(16);
57
+ expect(contentCount).toBe(3);
58
+
59
+ // First 16 are system, next 3 are content
60
+ for (let i = 0; i < 16; i++) expect(roles[i].domain).toBe('system');
61
+ for (let i = 16; i < 19; i++) expect(roles[i].domain).toBe('content');
62
+ });
63
+ });
64
+
65
+ describe('listRolesByDomain', () => {
66
+ it('returns only system roles for "system"', () => {
67
+ const roles = listRolesByDomain('system');
68
+ expect(roles).toHaveLength(16);
69
+ expect(roles.every((r) => r.domain === 'system')).toBe(true);
70
+ });
71
+
72
+ it('returns only content roles for "content"', () => {
73
+ const roles = listRolesByDomain('content');
74
+ expect(roles).toHaveLength(3);
75
+ expect(roles.every((r) => r.domain === 'content')).toBe(true);
76
+ });
77
+
78
+ it('returns empty for an unregistered domain', () => {
79
+ expect(listRolesByDomain('tasks')).toEqual([]);
80
+ });
81
+ });
82
+
83
+ describe('listSystemRoles', () => {
84
+ it('returns SystemRole instances', () => {
85
+ const roles = listSystemRoles();
86
+ expect(roles).toHaveLength(16);
87
+ expect(roles.every((r) => r instanceof SystemRole)).toBe(true);
88
+ });
89
+
90
+ it('excludes ABAC roles', () => {
91
+ const roles = listSystemRoles();
92
+ expect(roles.some((r) => r instanceof AbacRole)).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('listAbacRolesForScope', () => {
97
+ it('returns content roles for "document" scope', () => {
98
+ const roles = listAbacRolesForScope('document');
99
+ expect(roles).toHaveLength(3);
100
+ expect(roles.map((r) => r.name).sort()).toEqual(['content_manager', 'content_reader', 'content_writer']);
101
+ });
102
+
103
+ it('returns content roles for "collection" scope (same roles, applicableScopes covers both)', () => {
104
+ const roles = listAbacRolesForScope('collection');
105
+ expect(roles).toHaveLength(3);
106
+ expect(roles.every((r) => r.applicableScopes.includes('collection'))).toBe(true);
107
+ });
108
+
109
+ it('returns empty for "task" scope (no task partition registered)', () => {
110
+ expect(listAbacRolesForScope('task')).toEqual([]);
111
+ });
112
+
113
+ it('returns AbacRole instances only — no system roles bleed through', () => {
114
+ const roles = listAbacRolesForScope('document');
115
+ expect(roles.every((r) => r instanceof AbacRole)).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('getAllRoleNames', () => {
120
+ it('returns names of every registered role', () => {
121
+ const names = getAllRoleNames();
122
+ expect(names).toHaveLength(19);
123
+ expect(names).toContain('owner');
124
+ expect(names).toContain('content_reader');
125
+ });
126
+
127
+ it('produces a flat list suited for mongoose enum constraints', () => {
128
+ const names = getAllRoleNames();
129
+ expect(names.every((n) => typeof n === 'string')).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe('getPermissionsForRoles', () => {
134
+ it('merges permissions across multiple roles, deduped', () => {
135
+ // content_reader: ['read']
136
+ // content_writer: ['read', 'write']
137
+ const merged = getPermissionsForRoles([ContentRoleNames.content_reader, ContentRoleNames.content_writer]);
138
+ expect(merged.sort()).toEqual(['read', 'write']);
139
+ });
140
+
141
+ it('returns system Permission values for system roles', () => {
142
+ const merged = getPermissionsForRoles([SystemRoles.reader]);
143
+ // reader role has: int_read, run_read, content_read + account_member (via OrgMemberRole)
144
+ expect(merged).toContain(Permission.int_read);
145
+ expect(merged).toContain(Permission.content_read);
146
+ expect(merged).toContain(Permission.account_member);
147
+ });
148
+ });
149
+
150
+ describe('Role instances', () => {
151
+ it('SystemRole hasPermission for granted Permission', () => {
152
+ const owner = getRoleByName(SystemRoles.owner);
153
+ expect(owner.hasPermission(Permission.content_read)).toBe(true);
154
+ expect(owner.hasPermission(Permission.manage_billing)).toBe(true);
155
+ });
156
+
157
+ it('SystemRole hasPermission false for arbitrary string', () => {
158
+ const reader = getRoleByName(SystemRoles.reader);
159
+ expect(reader.hasPermission('not_a_real_perm')).toBe(false);
160
+ });
161
+
162
+ it('AbacRole carries applicableScopes', () => {
163
+ const reader = getRoleByName(ContentRoleNames.content_reader) as AbacRole;
164
+ expect(reader.applicableScopes).toEqual(['document', 'collection']);
165
+ });
166
+
167
+ it('AbacRole permissions are bare verbs, not Permission enum values', () => {
168
+ const manager = getRoleByName(ContentRoleNames.content_manager);
169
+ expect(Array.from(manager.permissions).sort()).toEqual(['delete', 'read', 'write']);
170
+ });
171
+ });
172
+
173
+ describe('SystemRole vs AbacRole discrimination', () => {
174
+ it('SystemRole instanceof Role', () => {
175
+ const owner = getRoleByName(SystemRoles.owner);
176
+ expect(owner).toBeInstanceOf(Role);
177
+ expect(owner).toBeInstanceOf(SystemRole);
178
+ expect(owner).not.toBeInstanceOf(AbacRole);
179
+ });
180
+
181
+ it('AbacRole instanceof Role', () => {
182
+ const reader = getRoleByName(ContentRoleNames.content_reader);
183
+ expect(reader).toBeInstanceOf(Role);
184
+ expect(reader).toBeInstanceOf(AbacRole);
185
+ expect(reader).not.toBeInstanceOf(SystemRole);
186
+ });
187
+ });
188
+
189
+ describe('RoleList', () => {
190
+ it('fromRoleNames composes a list checkable for permissions', () => {
191
+ const list = RoleList.fromRoleNames([SystemRoles.reader, SystemRoles.executor]);
192
+ expect(list.hasPermission(Permission.content_read)).toBe(true); // from reader
193
+ expect(list.hasPermission(Permission.int_execute)).toBe(true); // from executor
194
+ expect(list.hasPermission(Permission.manage_billing)).toBe(false); // neither has it
195
+ });
196
+
197
+ it('fromRoleName composes a single-role list', () => {
198
+ const list = RoleList.fromRoleName(SystemRoles.billing);
199
+ expect(list.hasPermission(Permission.manage_billing)).toBe(true);
200
+ expect(list.hasPermission(Permission.content_write)).toBe(false);
201
+ });
202
+
203
+ it('throws on unknown role name', () => {
204
+ expect(() => RoleList.fromRoleNames(['not_real'])).toThrow(/Role not_real not found/);
205
+ });
206
+ });
@@ -0,0 +1,96 @@
1
+ import type { AbacScope, RoleDomain } from '@vertesia/common';
2
+ import { AbacRole, type Role, type RolePartition, SystemRole } from './classes.js';
3
+ import { contentPartition } from './content.js';
4
+ import { systemPartition } from './system.js';
5
+
6
+ export { AbacRole, Role, type RolePartition, SystemRole } from './classes.js';
7
+ export { ContentRoleNames } from './content.js';
8
+
9
+ /**
10
+ * The ordered partition registry. Partitions are queried in this order — first
11
+ * match wins. The `system` partition is registered first so domain-specific
12
+ * partitions (added later) cannot shadow built-in system roles.
13
+ */
14
+ const partitions: RolePartition[] = [systemPartition, contentPartition];
15
+
16
+ /** Look up a role by its name across all registered partitions. */
17
+ export function getRoleByName(name: string): Role {
18
+ for (const partition of partitions) {
19
+ const role = partition.roles.get(name);
20
+ if (role) return role;
21
+ }
22
+ throw new Error(`Role ${name} not found`);
23
+ }
24
+
25
+ /** List every role across all partitions, in partition registration order. */
26
+ export function listRoles(): Role[] {
27
+ const result: Role[] = [];
28
+ for (const partition of partitions) {
29
+ for (const role of partition.roles.values()) {
30
+ result.push(role);
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+
36
+ /** Roles owned by a specific domain (e.g. `'system'`, `'content'`). */
37
+ export function listRolesByDomain(domain: RoleDomain): Role[] {
38
+ const partition = partitions.find((p) => p.domain === domain);
39
+ return partition ? Array.from(partition.roles.values()) : [];
40
+ }
41
+
42
+ /**
43
+ * ABAC roles applicable to a given ContentSet scope (e.g. `'document'`,
44
+ * `'collection'`). System roles are excluded — they don't carry scope semantics.
45
+ */
46
+ export function listAbacRolesForScope(scope: AbacScope): AbacRole[] {
47
+ return listRoles().filter((r): r is AbacRole => r instanceof AbacRole && r.applicableScopes.includes(scope));
48
+ }
49
+
50
+ /** Shortcut for the system partition: returns only `SystemRole` instances. */
51
+ export function listSystemRoles(): SystemRole[] {
52
+ return listRoles().filter((r): r is SystemRole => r instanceof SystemRole);
53
+ }
54
+
55
+ /** Names of every registered role across all partitions — suited for mongoose schema enum constraints. */
56
+ export function getAllRoleNames(): string[] {
57
+ return listRoles().map((r) => r.name);
58
+ }
59
+
60
+ /**
61
+ * Merge the permissions granted by a set of roles into a single array.
62
+ * Intended for the system-role gating path. For ABAC roles, the bare-verb
63
+ * permissions returned here aren't directly meaningful — use the JWT
64
+ * `content_security` pathway instead.
65
+ */
66
+ export function getPermissionsForRoles(roleNames: Iterable<string>): string[] {
67
+ const permissions = new Set<string>();
68
+ for (const roleName of roleNames) {
69
+ const role = getRoleByName(roleName);
70
+ for (const permission of role.permissions) {
71
+ permissions.add(permission);
72
+ }
73
+ }
74
+ return Array.from(permissions);
75
+ }
76
+
77
+ /**
78
+ * A list of roles with a unified `hasPermission` check across them.
79
+ */
80
+ export class RoleList {
81
+ private constructor(public readonly roles: Role[]) {}
82
+
83
+ static fromRoleNames(roleNames: string[]): RoleList {
84
+ const roles = roleNames.map((r) => getRoleByName(r));
85
+ return new RoleList(roles);
86
+ }
87
+
88
+ static fromRoleName(roleName: string): RoleList {
89
+ const roles = [getRoleByName(roleName)];
90
+ return new RoleList(roles);
91
+ }
92
+
93
+ hasPermission(perm: string): boolean {
94
+ return this.roles.some((role) => role.hasPermission(perm));
95
+ }
96
+ }