@vertesia/studio-utils 1.6.0-dev.20260923.085325Z → 1.6.0-dev.20261004.085740Z
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/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -1
- package/lib/index.js.map +1 -1
- package/lib/prompts/extract-vars.d.ts +44 -12
- package/lib/prompts/extract-vars.d.ts.map +1 -1
- package/lib/prompts/extract-vars.js +165 -78
- package/lib/prompts/extract-vars.js.map +1 -1
- package/lib/prompts/render.d.ts +7 -9
- package/lib/prompts/render.d.ts.map +1 -1
- package/lib/prompts/render.js +12 -15
- package/lib/prompts/render.js.map +1 -1
- package/lib/prompts/validate.d.ts +8 -2
- package/lib/prompts/validate.d.ts.map +1 -1
- package/lib/prompts/validate.js +87 -51
- package/lib/prompts/validate.js.map +1 -1
- package/lib/roles/agent-runs.d.ts +8 -0
- package/lib/roles/agent-runs.d.ts.map +1 -0
- package/lib/roles/agent-runs.js +36 -0
- package/lib/roles/agent-runs.js.map +1 -0
- package/lib/roles/classes.d.ts +15 -1
- package/lib/roles/classes.d.ts.map +1 -1
- package/lib/roles/classes.js +10 -1
- package/lib/roles/classes.js.map +1 -1
- package/lib/roles/content.d.ts.map +1 -1
- package/lib/roles/content.js +14 -4
- package/lib/roles/content.js.map +1 -1
- package/lib/roles/index.d.ts +4 -3
- package/lib/roles/index.d.ts.map +1 -1
- package/lib/roles/index.js +7 -5
- package/lib/roles/index.js.map +1 -1
- package/lib/vertesia-studio-utils.js +2 -1
- package/lib/vertesia-studio-utils.js.map +1 -1
- package/package.json +11 -10
- package/src/index.ts +20 -1
- package/src/prompts/extract-vars.ts +212 -71
- package/src/prompts/render.ts +24 -15
- package/src/prompts/validate.ts +113 -57
- package/src/roles/agent-runs.ts +41 -0
- package/src/roles/classes.ts +7 -0
- package/src/roles/content.ts +21 -5
- package/src/roles/index.ts +7 -5
- package/src/apps/index.test.ts +0 -166
- package/src/conditions/match.test.ts +0 -134
- package/src/prompts/render.test.ts +0 -110
- package/src/prompts/validate.test.ts +0 -309
- package/src/roles/index.test.ts +0 -221
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type AbacScope, Permission, type RoleDomain } from '@vertesia/common';
|
|
2
|
+
import { AbacRole, type Role, type RolePartition } from './classes.js';
|
|
3
|
+
|
|
4
|
+
const AgentRunRoleDomain: RoleDomain = 'agent_runs';
|
|
5
|
+
|
|
6
|
+
const APPLICABLE_SCOPES: readonly AbacScope[] = ['agent_run'];
|
|
7
|
+
|
|
8
|
+
/** Names of roles that grant conditional access to agent and process runs. */
|
|
9
|
+
export enum AgentRunRoleNames {
|
|
10
|
+
agent_run_reader = 'agent_runs:reader',
|
|
11
|
+
agent_run_operator = 'agent_runs:operator',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class AgentRunReaderRole extends AbacRole {
|
|
15
|
+
constructor() {
|
|
16
|
+
// Delegating run reads requires the RBAC the run read endpoints check (`workflow:read`), not
|
|
17
|
+
// `agent_run:read`, which is the auditor-only "see every private run" capability.
|
|
18
|
+
super(AgentRunRoleNames.agent_run_reader, ['read'], AgentRunRoleDomain, APPLICABLE_SCOPES, [
|
|
19
|
+
Permission.workflow_read,
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class AgentRunOperatorRole extends AbacRole {
|
|
25
|
+
constructor() {
|
|
26
|
+
super(AgentRunRoleNames.agent_run_operator, ['read', 'control'], AgentRunRoleDomain, APPLICABLE_SCOPES, [
|
|
27
|
+
Permission.workflow_read,
|
|
28
|
+
Permission.workflow_run,
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const agentRunRoles: Record<AgentRunRoleNames, Role> = {
|
|
34
|
+
[AgentRunRoleNames.agent_run_reader]: new AgentRunReaderRole(),
|
|
35
|
+
[AgentRunRoleNames.agent_run_operator]: new AgentRunOperatorRole(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const agentRunPartition: RolePartition = {
|
|
39
|
+
domain: AgentRunRoleDomain,
|
|
40
|
+
roles: new Map(Object.entries(agentRunRoles)),
|
|
41
|
+
};
|
package/src/roles/classes.ts
CHANGED
|
@@ -62,6 +62,13 @@ export class AbacRole extends Role<string> {
|
|
|
62
62
|
permissions: string[],
|
|
63
63
|
domain: RoleDomain,
|
|
64
64
|
public applicableScopes: readonly AbacScope[],
|
|
65
|
+
/**
|
|
66
|
+
* The central `Permission`s a caller must hold to delegate this role through an ACE.
|
|
67
|
+
* Declared explicitly because ABAC verbs (`read`, `control`, ...) are scope vocabulary,
|
|
68
|
+
* not `Permission` values — deriving them from `${domain}:${verb}` only happens to work
|
|
69
|
+
* for the `content` domain and yields unknown keys for every other one.
|
|
70
|
+
*/
|
|
71
|
+
public delegationPermissions: readonly Permission[],
|
|
65
72
|
) {
|
|
66
73
|
super(name, permissions, domain);
|
|
67
74
|
}
|
package/src/roles/content.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AbacScope, Permission, type RoleDomain } from '@vertesia/common';
|
|
2
2
|
import { AbacRole, type Role, type RolePartition } from './classes.js';
|
|
3
3
|
|
|
4
4
|
const ContentRoleDomain: RoleDomain = 'content';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Scopes where content can be written/managed. `shared_content` is deliberately absent — shared content
|
|
7
|
+
// is read-only, so writer/manager never apply to it.
|
|
8
|
+
const WRITABLE_CONTENT_SCOPES: readonly AbacScope[] = ['document', 'collection'];
|
|
9
|
+
|
|
10
|
+
// content:reader additionally serves the read-only `shared_content` scope: cross-project sharing reuses
|
|
11
|
+
// the content read permission — `shared_content` is a SCOPE over content (like `collection`), not its own
|
|
12
|
+
// domain. Only the reader lists it, so the vocabulary can't grant write/manage on shared content.
|
|
13
|
+
const READER_SCOPES: readonly AbacScope[] = [...WRITABLE_CONTENT_SCOPES, 'shared_content'];
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Names of roles owned by the `content` domain. Apply to ResourceSet ACEs
|
|
@@ -18,19 +25,28 @@ export enum ContentRoleNames {
|
|
|
18
25
|
|
|
19
26
|
class ContentReaderRole extends AbacRole {
|
|
20
27
|
constructor() {
|
|
21
|
-
super(ContentRoleNames.content_reader, ['read'], ContentRoleDomain,
|
|
28
|
+
super(ContentRoleNames.content_reader, ['read'], ContentRoleDomain, READER_SCOPES, [Permission.content_read]);
|
|
22
29
|
}
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
class ContentWriterRole extends AbacRole {
|
|
26
33
|
constructor() {
|
|
27
|
-
super(ContentRoleNames.content_writer, ['read', 'write'], ContentRoleDomain,
|
|
34
|
+
super(ContentRoleNames.content_writer, ['read', 'write'], ContentRoleDomain, WRITABLE_CONTENT_SCOPES, [
|
|
35
|
+
Permission.content_read,
|
|
36
|
+
Permission.content_write,
|
|
37
|
+
]);
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
class ContentManagerRole extends AbacRole {
|
|
32
42
|
constructor() {
|
|
33
|
-
super(
|
|
43
|
+
super(
|
|
44
|
+
ContentRoleNames.content_manager,
|
|
45
|
+
['read', 'write', 'delete'],
|
|
46
|
+
ContentRoleDomain,
|
|
47
|
+
WRITABLE_CONTENT_SCOPES,
|
|
48
|
+
[Permission.content_read, Permission.content_write, Permission.content_delete],
|
|
49
|
+
);
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
|
package/src/roles/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { AbacScope, RoleDomain } from '@vertesia/common';
|
|
2
|
+
import { agentRunPartition } from './agent-runs.js';
|
|
2
3
|
import { AbacRole, type Role, type RolePartition, SystemRole } from './classes.js';
|
|
3
4
|
import { contentPartition } from './content.js';
|
|
4
5
|
import { systemPartition } from './system.js';
|
|
5
6
|
|
|
7
|
+
export { AgentRunRoleNames } from './agent-runs.js';
|
|
6
8
|
export { AbacRole, Role, type RolePartition, SystemRole } from './classes.js';
|
|
7
9
|
export { ContentRoleNames } from './content.js';
|
|
8
10
|
|
|
@@ -11,7 +13,7 @@ export { ContentRoleNames } from './content.js';
|
|
|
11
13
|
* match wins. The `system` partition is registered first so domain-specific
|
|
12
14
|
* partitions (added later) cannot shadow built-in system roles.
|
|
13
15
|
*/
|
|
14
|
-
const partitions: RolePartition[] = [systemPartition, contentPartition];
|
|
16
|
+
const partitions: RolePartition[] = [systemPartition, contentPartition, agentRunPartition];
|
|
15
17
|
|
|
16
18
|
/** Look up a role by its name across all registered partitions. */
|
|
17
19
|
export function getRoleByName(name: string): Role {
|
|
@@ -75,16 +77,16 @@ export function getPermissionsForRoles(roleNames: Iterable<string>): string[] {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
/**
|
|
78
|
-
* Return the platform
|
|
80
|
+
* Return the platform `Permission` keys a caller must hold to delegate a role.
|
|
79
81
|
*
|
|
80
82
|
* System roles already contain complete `domain:verb` Permission values. ABAC roles store bare
|
|
81
|
-
* verbs for
|
|
82
|
-
*
|
|
83
|
+
* scope verbs (`read`, `control`, ...) for token generation, which are not Permission values, so
|
|
84
|
+
* they declare the Permissions that gate their delegation separately (`AbacRole.delegationPermissions`).
|
|
83
85
|
*/
|
|
84
86
|
export function getDelegablePermissionsForRole(roleName: string): string[] {
|
|
85
87
|
const role = getRoleByName(roleName);
|
|
86
88
|
if (role instanceof AbacRole) {
|
|
87
|
-
return [...role.
|
|
89
|
+
return [...role.delegationPermissions];
|
|
88
90
|
}
|
|
89
91
|
return [...role.permissions];
|
|
90
92
|
}
|
package/src/apps/index.test.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { installStudioUtilsLogger, type StudioUtilsLogger } from '../logger.js';
|
|
3
|
-
import {
|
|
4
|
-
parseGatewayAppEndpoint,
|
|
5
|
-
type ResolveAppEndpointOptions,
|
|
6
|
-
resolveAppApiBase,
|
|
7
|
-
resolveAppEndpoint,
|
|
8
|
-
resolveAppResource,
|
|
9
|
-
} from './index.js';
|
|
10
|
-
|
|
11
|
-
const APPS_MOUNT = 'https://gw.example.com/tenants/1589ba_149d0b/apps/my-app';
|
|
12
|
-
const LEGACY_PACKAGE_MOUNT = 'https://gw.example.com/tenants/1589ba_149d0b/package/my-app';
|
|
13
|
-
const PACKAGE_MOUNT = 'https://gw.example.com/tenants/1589ba_149d0b/apps/my-app/api/package';
|
|
14
|
-
const VERSION_PACKAGE_MOUNT =
|
|
15
|
-
'https://gw.example.com/tenants/1589ba_149d0b/apps/my-app/versions/20260101T000000Z/api/package';
|
|
16
|
-
|
|
17
|
-
function resolve(
|
|
18
|
-
endpoint: string,
|
|
19
|
-
options: ResolveAppEndpointOptions = {},
|
|
20
|
-
endpoint_overrides?: Record<string, string>,
|
|
21
|
-
): string | undefined {
|
|
22
|
-
return resolveAppEndpoint({ endpoint, endpoint_overrides }, options);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('app endpoint resolution', () => {
|
|
26
|
-
it('parses gateway app endpoint shapes', () => {
|
|
27
|
-
expect(parseGatewayAppEndpoint(APPS_MOUNT)).toEqual({
|
|
28
|
-
origin: 'https://gw.example.com',
|
|
29
|
-
tenant: '1589ba_149d0b',
|
|
30
|
-
appId: 'my-app',
|
|
31
|
-
});
|
|
32
|
-
expect(parseGatewayAppEndpoint(LEGACY_PACKAGE_MOUNT)).toEqual({
|
|
33
|
-
origin: 'https://gw.example.com',
|
|
34
|
-
tenant: '1589ba_149d0b',
|
|
35
|
-
appId: 'my-app',
|
|
36
|
-
});
|
|
37
|
-
expect(parseGatewayAppEndpoint(VERSION_PACKAGE_MOUNT)).toEqual({
|
|
38
|
-
origin: 'https://gw.example.com',
|
|
39
|
-
tenant: '1589ba_149d0b',
|
|
40
|
-
appId: 'my-app',
|
|
41
|
-
version: '20260101T000000Z',
|
|
42
|
-
});
|
|
43
|
-
expect(parseGatewayAppEndpoint('https://third-party.example.com/api/package')).toBeUndefined();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('normalizes gateway app endpoints to canonical package URLs', () => {
|
|
47
|
-
expect(resolve(APPS_MOUNT)).toBe(PACKAGE_MOUNT);
|
|
48
|
-
expect(resolve(LEGACY_PACKAGE_MOUNT)).toBe(PACKAGE_MOUNT);
|
|
49
|
-
expect(resolve(PACKAGE_MOUNT)).toBe(PACKAGE_MOUNT);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('normalizes gateway app endpoints to version-pinned package URLs', () => {
|
|
53
|
-
expect(resolve(APPS_MOUNT, { version: '20260101T000000Z' })).toBe(VERSION_PACKAGE_MOUNT);
|
|
54
|
-
expect(resolve(LEGACY_PACKAGE_MOUNT, { version: '20260101T000000Z' })).toBe(VERSION_PACKAGE_MOUNT);
|
|
55
|
-
expect(resolve(PACKAGE_MOUNT, { version: '20260101T000000Z' })).toBe(VERSION_PACKAGE_MOUNT);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('ignores endpoint overrides for gateway apps', () => {
|
|
59
|
-
const endpoint_overrides = {
|
|
60
|
-
'20260101T000000Z': 'https://plugin.example.com/releases/20260101T000000Z/package',
|
|
61
|
-
dev: 'https://plugin.example.com/dev/package',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
expect(resolve(APPS_MOUNT, { version: '20260101T000000Z', envName: 'dev' }, endpoint_overrides)).toBe(
|
|
65
|
-
VERSION_PACKAGE_MOUNT,
|
|
66
|
-
);
|
|
67
|
-
expect(resolve(APPS_MOUNT, { envName: 'dev' }, endpoint_overrides)).toBe(PACKAGE_MOUNT);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('uses endpoint overrides in version, requested override, environment, endpoint order', () => {
|
|
71
|
-
const endpoint_overrides = {
|
|
72
|
-
'1.2.3': 'https://plugin.example.com/releases/1.2.3/package',
|
|
73
|
-
dev: 'https://plugin.example.com/dev/package',
|
|
74
|
-
desktop: 'https://plugin.example.com/desktop/package',
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
expect(
|
|
78
|
-
resolve('https://plugin.example.com/api/package', { version: '1.2.3', envName: 'dev' }, endpoint_overrides),
|
|
79
|
-
).toBe('https://plugin.example.com/releases/1.2.3/package');
|
|
80
|
-
expect(
|
|
81
|
-
resolve(
|
|
82
|
-
'https://plugin.example.com/api/package',
|
|
83
|
-
{ requestedOverride: 'desktop', envName: 'dev' },
|
|
84
|
-
endpoint_overrides,
|
|
85
|
-
),
|
|
86
|
-
).toBe('https://plugin.example.com/desktop/package');
|
|
87
|
-
expect(resolve('https://plugin.example.com/api/package', { envName: 'dev' }, endpoint_overrides)).toBe(
|
|
88
|
-
'https://plugin.example.com/dev/package',
|
|
89
|
-
);
|
|
90
|
-
expect(resolve('https://plugin.example.com/api/package', {}, endpoint_overrides)).toBe(
|
|
91
|
-
'https://plugin.example.com/api/package',
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('does not use endpoint overrides without a base endpoint', () => {
|
|
96
|
-
expect(
|
|
97
|
-
resolveAppEndpoint(
|
|
98
|
-
{
|
|
99
|
-
endpoint: undefined,
|
|
100
|
-
endpoint_overrides: {
|
|
101
|
-
dev: 'https://plugin.example.com/dev/package',
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
{ envName: 'dev' },
|
|
105
|
-
),
|
|
106
|
-
).toBeUndefined();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('substitutes endpoint variables before gateway normalization', () => {
|
|
110
|
-
expect(
|
|
111
|
-
resolve('{{gateway}}/tenants/1589ba_149d0b/package/my-app', {
|
|
112
|
-
vars: { gateway: 'https://gw.example.com/' },
|
|
113
|
-
}),
|
|
114
|
-
).toBe(PACKAGE_MOUNT);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('leaves regular package endpoints unchanged', () => {
|
|
118
|
-
expect(resolve('https://third-party.example.com/api/package')).toBe(
|
|
119
|
-
'https://third-party.example.com/api/package',
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('leaves non-standard package endpoints unchanged and warns', () => {
|
|
124
|
-
const logger: StudioUtilsLogger = {
|
|
125
|
-
warn: vi.fn(),
|
|
126
|
-
};
|
|
127
|
-
const previousLogger = installStudioUtilsLogger(logger);
|
|
128
|
-
|
|
129
|
-
expect(resolve('https://third-party.example.com/exotic/pkg')).toBe(
|
|
130
|
-
'https://third-party.example.com/exotic/pkg',
|
|
131
|
-
);
|
|
132
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
133
|
-
{ endpoint: 'https://third-party.example.com/exotic/pkg' },
|
|
134
|
-
'Non-standard app package endpoint; returning it unchanged during app endpoint resolution',
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
installStudioUtilsLogger(previousLogger);
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe('app API base resolution', () => {
|
|
142
|
-
it('derives the API base by removing the final package URL segment', () => {
|
|
143
|
-
expect(resolveAppApiBase('https://third-party.example.com/api/package')).toBe(
|
|
144
|
-
'https://third-party.example.com/api',
|
|
145
|
-
);
|
|
146
|
-
expect(resolveAppApiBase('https://third-party.example.com/exotic/pkg')).toBe(
|
|
147
|
-
'https://third-party.example.com/exotic',
|
|
148
|
-
);
|
|
149
|
-
expect(resolveAppApiBase(PACKAGE_MOUNT)).toBe('https://gw.example.com/tenants/1589ba_149d0b/apps/my-app/api');
|
|
150
|
-
expect(resolveAppApiBase(VERSION_PACKAGE_MOUNT)).toBe(
|
|
151
|
-
'https://gw.example.com/tenants/1589ba_149d0b/apps/my-app/versions/20260101T000000Z/api',
|
|
152
|
-
);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('resolves API-relative resource paths from package endpoints', () => {
|
|
156
|
-
expect(resolveAppResource('https://third-party.example.com/api/package', 'tools/main')).toBe(
|
|
157
|
-
'https://third-party.example.com/api/tools/main',
|
|
158
|
-
);
|
|
159
|
-
expect(resolveAppResource(PACKAGE_MOUNT, '/interactions/main/hello')).toBe(
|
|
160
|
-
'https://gw.example.com/tenants/1589ba_149d0b/apps/my-app/api/interactions/main/hello',
|
|
161
|
-
);
|
|
162
|
-
expect(resolveAppResource(VERSION_PACKAGE_MOUNT, 'templates/invoice')).toBe(
|
|
163
|
-
'https://gw.example.com/tenants/1589ba_149d0b/apps/my-app/versions/20260101T000000Z/api/templates/invoice',
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { installStudioUtilsLogger, type StudioUtilsLogger } from '../logger.js';
|
|
3
|
-
import { matchConditions, matchLike, resolveConditions, resolvePath } from './index.js';
|
|
4
|
-
|
|
5
|
-
describe('resolvePath', () => {
|
|
6
|
-
it('resolves top-level fields and one-level properties fields', () => {
|
|
7
|
-
const value = {
|
|
8
|
-
clearance: 3,
|
|
9
|
-
properties: {
|
|
10
|
-
department: 'engineering',
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
expect(resolvePath(value, 'clearance')).toBe(3);
|
|
15
|
-
expect(resolvePath(value, 'properties.department')).toBe('engineering');
|
|
16
|
-
expect(resolvePath(value, 'properties.missing')).toBeUndefined();
|
|
17
|
-
expect(resolvePath({ clearance: 3 }, 'properties.department')).toBeUndefined();
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe('matchLike', () => {
|
|
22
|
-
it('matches wildcard string patterns case-insensitively', () => {
|
|
23
|
-
expect(matchLike('bogdan+test@vertesiahq.com', '*+test*@VERTESIAHQ.COM')).toBe(true);
|
|
24
|
-
expect(matchLike('bogdan@gmail.com', '*@vertesiahq.com')).toBe(false);
|
|
25
|
-
expect(matchLike(42, '*')).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('matchConditions', () => {
|
|
30
|
-
it('matches direct values and operator objects', () => {
|
|
31
|
-
const properties = {
|
|
32
|
-
active: true,
|
|
33
|
-
clearance: 5,
|
|
34
|
-
compartments: ['engineering'],
|
|
35
|
-
email: 'bogdan@vertesiahq.com',
|
|
36
|
-
properties: {
|
|
37
|
-
department: 'engineering',
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
expect(
|
|
42
|
-
matchConditions(
|
|
43
|
-
{
|
|
44
|
-
active: true,
|
|
45
|
-
clearance: { $gte: 3, $lte: 5 },
|
|
46
|
-
compartments: { $empty: false },
|
|
47
|
-
email: { $like: '*@vertesiahq.com' },
|
|
48
|
-
'properties.department': { $in: ['engineering', 'product'] },
|
|
49
|
-
},
|
|
50
|
-
properties,
|
|
51
|
-
),
|
|
52
|
-
).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('requires ordered comparisons to use matching primitive types', () => {
|
|
56
|
-
expect(matchConditions({ clearance: { $gt: 3 } }, { clearance: 5 })).toBe(true);
|
|
57
|
-
expect(matchConditions({ clearance: { $gt: 3 } }, { clearance: '5' })).toBe(false);
|
|
58
|
-
expect(matchConditions({ code: { $gt: 'a' } }, { code: 'b' })).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('matches set operators against scalar and array-valued object fields', () => {
|
|
62
|
-
expect(
|
|
63
|
-
matchConditions({ compartments: { $in: ['finance', 'legal'] } }, { compartments: ['hr', 'finance'] }),
|
|
64
|
-
).toBe(true);
|
|
65
|
-
expect(matchConditions({ compartments: { $in: ['finance'] } }, { compartments: ['hr'] })).toBe(false);
|
|
66
|
-
expect(matchConditions({ compartments: { $nin: ['finance'] } }, { compartments: ['hr'] })).toBe(true);
|
|
67
|
-
expect(matchConditions({ compartments: { $nin: ['finance'] } }, { compartments: ['hr', 'finance'] })).toBe(
|
|
68
|
-
false,
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('uses Mongo-compatible equality for scalar conditions on array-valued fields', () => {
|
|
73
|
-
expect(matchConditions({ tags: 'finance' }, { tags: ['audit', 'finance'] })).toBe(true);
|
|
74
|
-
expect(matchConditions({ tags: { $eq: 'finance' } }, { tags: ['audit', 'finance'] })).toBe(true);
|
|
75
|
-
expect(matchConditions({ tags: { $ne: 'finance' } }, { tags: ['audit', 'finance'] })).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('treats unknown operators as non-matches', () => {
|
|
79
|
-
const logger: StudioUtilsLogger = {
|
|
80
|
-
warn: vi.fn(),
|
|
81
|
-
};
|
|
82
|
-
const previousLogger = installStudioUtilsLogger(logger);
|
|
83
|
-
|
|
84
|
-
expect(matchConditions({ clearance: { $unknown: 3 } }, { clearance: 5 })).toBe(false);
|
|
85
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
86
|
-
{ op: '$unknown', key: 'clearance' },
|
|
87
|
-
'Unknown operator in PropertyConditions',
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
installStudioUtilsLogger(previousLogger);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('resolveConditions', () => {
|
|
95
|
-
it('resolves scalar and array principal references', () => {
|
|
96
|
-
expect(
|
|
97
|
-
resolveConditions(
|
|
98
|
-
{
|
|
99
|
-
sensitivity: { $lte: '$principal.clearance' },
|
|
100
|
-
compartments: { $in: '$principal.compartments' },
|
|
101
|
-
'properties.department': '$principal.properties.department',
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
clearance: 3,
|
|
105
|
-
compartments: ['finance'],
|
|
106
|
-
properties: { department: 'finance' },
|
|
107
|
-
},
|
|
108
|
-
),
|
|
109
|
-
).toEqual({
|
|
110
|
-
sensitivity: { $lte: 3 },
|
|
111
|
-
compartments: { $in: ['finance'] },
|
|
112
|
-
'properties.department': 'finance',
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('uses deterministic defaults for missing principal fields', () => {
|
|
117
|
-
expect(
|
|
118
|
-
resolveConditions(
|
|
119
|
-
{
|
|
120
|
-
sensitivity: { $lte: '$principal.clearance' },
|
|
121
|
-
compartments: { $in: '$principal.compartments' },
|
|
122
|
-
active: { $exists: '$principal.active' },
|
|
123
|
-
department: '$principal.properties.department',
|
|
124
|
-
},
|
|
125
|
-
{},
|
|
126
|
-
),
|
|
127
|
-
).toEqual({
|
|
128
|
-
sensitivity: { $lte: 0 },
|
|
129
|
-
compartments: { $in: [] },
|
|
130
|
-
active: { $exists: false },
|
|
131
|
-
department: '',
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
});
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { PromptRole } from '@llumiverse/common';
|
|
2
|
-
import {
|
|
3
|
-
type PromptSegmentDef,
|
|
4
|
-
PromptSegmentDefType,
|
|
5
|
-
PromptStatus,
|
|
6
|
-
type PromptTemplate,
|
|
7
|
-
TemplateType,
|
|
8
|
-
} from '@vertesia/common';
|
|
9
|
-
import { describe, expect, it } from 'vitest';
|
|
10
|
-
|
|
11
|
-
import { renderSegments, renderTemplate } from './render.js';
|
|
12
|
-
|
|
13
|
-
describe('renderTemplate', () => {
|
|
14
|
-
it('returns text content verbatim instead of evaluating it as JST', () => {
|
|
15
|
-
const content = 'You are a test assistant for skill validation. Be concise.';
|
|
16
|
-
expect(renderTemplate(content, TemplateType.text, {}, {})).toEqual(content);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('renders handlebars templates', () => {
|
|
20
|
-
expect(renderTemplate('Hello {{name}}', TemplateType.handlebars, {}, { name: 'Ada' })).toEqual('Hello Ada');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('renders JST templates', () => {
|
|
24
|
-
expect(
|
|
25
|
-
renderTemplate('return `Hello ${name}`', TemplateType.jst, { properties: { name: {} } }, { name: 'Ada' }),
|
|
26
|
-
).toEqual('Hello Ada');
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('renderSegments', () => {
|
|
31
|
-
it('renders a text system segment followed by a handlebars user segment', () => {
|
|
32
|
-
const segments: PromptSegmentDef<PromptTemplate>[] = [
|
|
33
|
-
{
|
|
34
|
-
type: PromptSegmentDefType.template,
|
|
35
|
-
template: createPromptTemplate({
|
|
36
|
-
id: 'sys-1',
|
|
37
|
-
role: PromptRole.system,
|
|
38
|
-
content: 'You are a test assistant for skill validation. Be concise.',
|
|
39
|
-
content_type: TemplateType.text,
|
|
40
|
-
}),
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
type: PromptSegmentDefType.template,
|
|
44
|
-
template: createPromptTemplate({
|
|
45
|
-
id: 'user-1',
|
|
46
|
-
role: PromptRole.user,
|
|
47
|
-
content: 'Answer this question: {{question}}',
|
|
48
|
-
content_type: TemplateType.handlebars,
|
|
49
|
-
}),
|
|
50
|
-
},
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
expect(renderSegments(segments, { question: 'What is 2+2?' })).toEqual([
|
|
54
|
-
{
|
|
55
|
-
title: '@system',
|
|
56
|
-
content: 'You are a test assistant for skill validation. Be concise.',
|
|
57
|
-
segmentId: 'sys-1',
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
title: '@user',
|
|
61
|
-
content: 'Answer this question: What is 2+2?',
|
|
62
|
-
segmentId: 'user-1',
|
|
63
|
-
},
|
|
64
|
-
]);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('isolates a failing segment so the remaining segments still render', () => {
|
|
68
|
-
const segments: PromptSegmentDef<PromptTemplate>[] = [
|
|
69
|
-
{
|
|
70
|
-
type: PromptSegmentDefType.template,
|
|
71
|
-
template: createPromptTemplate({
|
|
72
|
-
id: 'bad-1',
|
|
73
|
-
content: 'return missingGlobal',
|
|
74
|
-
content_type: TemplateType.jst,
|
|
75
|
-
}),
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
type: PromptSegmentDefType.template,
|
|
79
|
-
template: createPromptTemplate({
|
|
80
|
-
id: 'good-1',
|
|
81
|
-
content: 'Hello {{name}}',
|
|
82
|
-
content_type: TemplateType.handlebars,
|
|
83
|
-
}),
|
|
84
|
-
},
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
const result = renderSegments(segments, { name: 'Ada' });
|
|
88
|
-
expect(result[0].error).toBeInstanceOf(Error);
|
|
89
|
-
expect(result[1]).toEqual({ title: '@user', content: 'Hello Ada', segmentId: 'good-1' });
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
function createPromptTemplate(overrides: Partial<PromptTemplate>): PromptTemplate {
|
|
94
|
-
return {
|
|
95
|
-
id: 'prompt-1',
|
|
96
|
-
name: 'Greeting',
|
|
97
|
-
role: PromptRole.user,
|
|
98
|
-
status: PromptStatus.draft,
|
|
99
|
-
version: 1,
|
|
100
|
-
edit_revision: 1,
|
|
101
|
-
content: '',
|
|
102
|
-
content_type: TemplateType.jst,
|
|
103
|
-
project: 'project-1',
|
|
104
|
-
created_by: 'user-1',
|
|
105
|
-
updated_by: 'user-1',
|
|
106
|
-
created_at: '2026-01-01T00:00:00.000Z',
|
|
107
|
-
updated_at: '2026-01-01T00:00:00.000Z',
|
|
108
|
-
...overrides,
|
|
109
|
-
};
|
|
110
|
-
}
|