@vertesia/studio-utils 1.3.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 (52) hide show
  1. package/LICENSE +201 -0
  2. package/lib/index.d.ts +6 -0
  3. package/lib/index.d.ts.map +1 -0
  4. package/lib/index.js +6 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/prompts/extract-vars.d.ts +19 -0
  7. package/lib/prompts/extract-vars.d.ts.map +1 -0
  8. package/lib/prompts/extract-vars.js +111 -0
  9. package/lib/prompts/extract-vars.js.map +1 -0
  10. package/lib/prompts/mock-data.d.ts +17 -0
  11. package/lib/prompts/mock-data.d.ts.map +1 -0
  12. package/lib/prompts/mock-data.js +52 -0
  13. package/lib/prompts/mock-data.js.map +1 -0
  14. package/lib/prompts/render.d.ts +52 -0
  15. package/lib/prompts/render.d.ts.map +1 -0
  16. package/lib/prompts/render.js +166 -0
  17. package/lib/prompts/render.js.map +1 -0
  18. package/lib/prompts/validate.d.ts +46 -0
  19. package/lib/prompts/validate.d.ts.map +1 -0
  20. package/lib/prompts/validate.js +167 -0
  21. package/lib/prompts/validate.js.map +1 -0
  22. package/lib/roles/classes.d.ts +59 -0
  23. package/lib/roles/classes.d.ts.map +1 -0
  24. package/lib/roles/classes.js +60 -0
  25. package/lib/roles/classes.js.map +1 -0
  26. package/lib/roles/content.d.ts +13 -0
  27. package/lib/roles/content.d.ts.map +1 -0
  28. package/lib/roles/content.js +39 -0
  29. package/lib/roles/content.js.map +1 -0
  30. package/lib/roles/index.d.ts +37 -0
  31. package/lib/roles/index.d.ts.map +1 -0
  32. package/lib/roles/index.js +87 -0
  33. package/lib/roles/index.js.map +1 -0
  34. package/lib/roles/system.d.ts +3 -0
  35. package/lib/roles/system.d.ts.map +1 -0
  36. package/lib/roles/system.js +187 -0
  37. package/lib/roles/system.js.map +1 -0
  38. package/lib/vertesia-studio-utils.js +2 -0
  39. package/lib/vertesia-studio-utils.js.map +1 -0
  40. package/package.json +50 -0
  41. package/src/index.ts +20 -0
  42. package/src/prompts/extract-vars.ts +110 -0
  43. package/src/prompts/mock-data.ts +63 -0
  44. package/src/prompts/render.test.ts +109 -0
  45. package/src/prompts/render.ts +192 -0
  46. package/src/prompts/validate.test.ts +274 -0
  47. package/src/prompts/validate.ts +216 -0
  48. package/src/roles/classes.ts +78 -0
  49. package/src/roles/content.ts +46 -0
  50. package/src/roles/index.test.ts +206 -0
  51. package/src/roles/index.ts +96 -0
  52. package/src/roles/system.ts +204 -0
@@ -0,0 +1,216 @@
1
+ import type { JSONObject } from '@llumiverse/common';
2
+ import { type JSONSchema, TemplateType } from '@vertesia/common';
3
+ import { getFreeVariables, renderJsTemplate } from '@vertesia/jst';
4
+ import { extractHandlebarsVariables } from './extract-vars.js';
5
+ import { generateMockData } from './mock-data.js';
6
+ import { executeHandlebars } from './render.js';
7
+
8
+ export type PromptValidationIssueType =
9
+ | 'undeclared_template_variable'
10
+ | 'unused_schema_variable'
11
+ | 'handlebars_render_error'
12
+ | 'jst_unsafe_construct'
13
+ | 'jst_render_error';
14
+
15
+ export type PromptValidationIssueSeverity = 'error' | 'warning';
16
+
17
+ export interface PromptValidationIssue {
18
+ /** Discriminator for issue kind */
19
+ type: PromptValidationIssueType;
20
+ /** Errors should block prompt creation; warnings are informational. */
21
+ severity: PromptValidationIssueSeverity;
22
+ /** The variable name when the issue is variable-related. */
23
+ variable?: string;
24
+ /** Human-readable message; safe to surface directly to an LLM tool error. */
25
+ message: string;
26
+ }
27
+
28
+ export interface PromptValidationResult {
29
+ /** Flat list of issues found. `severity` discriminates errors (blocking) from warnings. */
30
+ issues: PromptValidationIssue[];
31
+ /** Count of `severity: 'error'` entries in `issues`. Zero ⇔ validation passed. */
32
+ error_count: number;
33
+ /** Count of `severity: 'warning'` entries in `issues`. Non-blocking informational findings. */
34
+ warning_count: number;
35
+ }
36
+
37
+ export interface PromptValidationInput {
38
+ /** The prompt's template source. */
39
+ content: string;
40
+ /** Template language. `handlebars` and `jst` are validated; `text` passes through. */
41
+ contentType: TemplateType;
42
+ /** JSON Schema declaring the variables the template expects. */
43
+ inputSchema?: JSONSchema;
44
+ }
45
+
46
+ // JST's renderJsTemplate auto-adds `_` (helpers object) and runtime injects `Set` and `Array`
47
+ // — treat them as globals so they don't appear as free vars in user templates.
48
+ // Globals always available to JST templates regardless of schema:
49
+ // - `_`, `Array`, `Set`: runtime-injected by `renderJsTemplate` (jst library)
50
+ // - `_model`: runtime-injected by the studio-server executor as `{ ..._model: run.modelId }`
51
+ // (see ExecutionRequest.ts:313 and executor/rendering/template.ts:13)
52
+ // Keeping these in sync with `renderTemplate` in ./render.ts so a JST template that runs in
53
+ // production also passes the validator.
54
+ const JST_AUTO_GLOBALS = ['_', 'Array', 'Set', '_model'];
55
+
56
+ function countSeverities(issues: PromptValidationIssue[]): { error_count: number; warning_count: number } {
57
+ let error_count = 0;
58
+ let warning_count = 0;
59
+ for (const issue of issues) {
60
+ if (issue.severity === 'error') {
61
+ error_count++;
62
+ } else if (issue.severity === 'warning') {
63
+ warning_count++;
64
+ }
65
+ }
66
+ return { error_count, warning_count };
67
+ }
68
+
69
+ function validateHandlebarsPrompt(content: string, inputSchema?: JSONSchema): PromptValidationIssue[] {
70
+ const issues: PromptValidationIssue[] = [];
71
+ const usedVars = extractHandlebarsVariables(content);
72
+ const declaredVars = new Set<string>(inputSchema?.properties ? Object.keys(inputSchema.properties) : []);
73
+
74
+ for (const used of usedVars) {
75
+ if (!declaredVars.has(used)) {
76
+ issues.push({
77
+ type: 'undeclared_template_variable',
78
+ severity: 'error',
79
+ variable: used,
80
+ message: `Template references variable '{{${used}}}' but it is not declared in input_schema.properties. Add '${used}' to the schema with an appropriate type.`,
81
+ });
82
+ }
83
+ }
84
+
85
+ for (const declared of declaredVars) {
86
+ if (!usedVars.has(declared)) {
87
+ issues.push({
88
+ type: 'unused_schema_variable',
89
+ severity: 'warning',
90
+ variable: declared,
91
+ message: `Schema declares property '${declared}' but the template never references it. Remove it from input_schema or use it via {{${declared}}}.`,
92
+ });
93
+ }
94
+ }
95
+
96
+ // Render-time smoke test — always runs so syntax errors and failing helper calls are
97
+ // surfaced even when undeclared-variable errors are already in the list. Handlebars renders
98
+ // missing vars as empty strings (non-strict by default), so the render check does NOT echo
99
+ // the var errors — anything it reports is a distinct template problem worth showing.
100
+ const renderSchema = inputSchema ?? ({} as JSONSchema);
101
+ const mockData = generateMockData(renderSchema);
102
+ const mockObject: JSONObject =
103
+ typeof mockData === 'object' && mockData !== null && !Array.isArray(mockData) ? (mockData as JSONObject) : {};
104
+ const renderResult = executeHandlebars(content, renderSchema, mockObject);
105
+ if (!renderResult.success) {
106
+ issues.push({
107
+ type: 'handlebars_render_error',
108
+ severity: 'error',
109
+ message: `Handlebars rendering failed: ${renderResult.error}`,
110
+ });
111
+ }
112
+
113
+ return issues;
114
+ }
115
+
116
+ function validateJstPrompt(content: string, inputSchema?: JSONSchema): PromptValidationIssue[] {
117
+ const issues: PromptValidationIssue[] = [];
118
+ const declaredVars = new Set<string>(inputSchema?.properties ? Object.keys(inputSchema.properties) : []);
119
+
120
+ let referenced: Set<string>;
121
+ try {
122
+ const result = getFreeVariables(content, {
123
+ globals: JST_AUTO_GLOBALS,
124
+ acorn: { allowReturnOutsideFunction: true, locations: true },
125
+ });
126
+ referenced = result.vars;
127
+ for (const err of result.errors) {
128
+ issues.push({
129
+ type: 'jst_unsafe_construct',
130
+ severity: 'error',
131
+ message: `JST validation error at ${err.location}: ${err.message}`,
132
+ });
133
+ }
134
+ } catch (parseError) {
135
+ // Acorn parse failure — surface as render error since the template can't be compiled.
136
+ issues.push({
137
+ type: 'jst_render_error',
138
+ severity: 'error',
139
+ message: `JST parse failed: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
140
+ });
141
+ return issues;
142
+ }
143
+
144
+ for (const used of referenced) {
145
+ if (!declaredVars.has(used)) {
146
+ issues.push({
147
+ type: 'undeclared_template_variable',
148
+ severity: 'error',
149
+ variable: used,
150
+ message: `Template references variable '${used}' but it is not declared in input_schema.properties. Add '${used}' to the schema with an appropriate type.`,
151
+ });
152
+ }
153
+ }
154
+
155
+ for (const declared of declaredVars) {
156
+ if (!referenced.has(declared)) {
157
+ issues.push({
158
+ type: 'unused_schema_variable',
159
+ severity: 'warning',
160
+ variable: declared,
161
+ message: `Schema declares property '${declared}' but the template never references it. Remove it from input_schema or use it in the template.`,
162
+ });
163
+ }
164
+ }
165
+
166
+ // Render-time smoke test — only if there are no blocking errors so far, otherwise
167
+ // the failure mode would just echo what we already reported.
168
+ const blockingSoFar = issues.some((i) => i.severity === 'error');
169
+ if (!blockingSoFar) {
170
+ const renderSchema = inputSchema ?? ({} as JSONSchema);
171
+ const mockData = generateMockData(renderSchema);
172
+ const mockObject: JSONObject =
173
+ typeof mockData === 'object' && mockData !== null && !Array.isArray(mockData)
174
+ ? (mockData as JSONObject)
175
+ : {};
176
+ try {
177
+ renderJsTemplate(content, [...declaredVars], mockObject);
178
+ } catch (renderError) {
179
+ issues.push({
180
+ type: 'jst_render_error',
181
+ severity: 'error',
182
+ message: `JST rendering failed: ${renderError instanceof Error ? renderError.message : String(renderError)}`,
183
+ });
184
+ }
185
+ }
186
+
187
+ return issues;
188
+ }
189
+
190
+ /**
191
+ * Validate a single prompt template against its declared input schema.
192
+ *
193
+ * For `handlebars` and `jst` templates, the following checks are performed:
194
+ * 1. Every variable referenced in the template must be declared as a top-level property
195
+ * in `inputSchema.properties` (else → `undeclared_template_variable` error).
196
+ * 2. Every property declared in `inputSchema.properties` should be referenced by the template
197
+ * (else → `unused_schema_variable` warning — non-blocking).
198
+ * 3. The template must render successfully against schema-derived mock data
199
+ * (else → `handlebars_render_error` / `jst_render_error` error).
200
+ * 4. For JST only: unsafe constructs (`with`, `for`, `while`, `import`, class, `this`,
201
+ * dynamic property lookup, blacklisted props) → `jst_unsafe_construct` error.
202
+ *
203
+ * `text` content type passes through with no issues.
204
+ */
205
+ export function validatePrompt(input: PromptValidationInput): PromptValidationResult {
206
+ let issues: PromptValidationIssue[];
207
+ if (input.contentType === TemplateType.handlebars) {
208
+ issues = validateHandlebarsPrompt(input.content, input.inputSchema);
209
+ } else if (input.contentType === TemplateType.jst) {
210
+ issues = validateJstPrompt(input.content, input.inputSchema);
211
+ } else {
212
+ issues = [];
213
+ }
214
+ const { error_count, warning_count } = countSeverities(issues);
215
+ return { issues, error_count, warning_count };
216
+ }
@@ -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
+ }