@republicroad/zen-udf 0.2.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 (40) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +80 -0
  3. package/docs/naming.md +40 -0
  4. package/package.json +24 -0
  5. package/src/context-hardening.test.ts +93 -0
  6. package/src/contrib/crypto.test.ts +60 -0
  7. package/src/contrib/crypto.ts +74 -0
  8. package/src/contrib/custom-list-query.ts +51 -0
  9. package/src/contrib/debug.ts +42 -0
  10. package/src/contrib/debugui.test.ts +10 -0
  11. package/src/contrib/debugui.ts +27 -0
  12. package/src/contrib/http-guard.test.ts +116 -0
  13. package/src/contrib/http.test.ts +230 -0
  14. package/src/contrib/http.ts +283 -0
  15. package/src/contrib/ip-location.ts +82 -0
  16. package/src/contrib/legacy-graph-acceptance.test.ts +67 -0
  17. package/src/contrib/rate-store-conformance.ts +68 -0
  18. package/src/contrib/rate-store.test.ts +30 -0
  19. package/src/contrib/rate-window.ts +195 -0
  20. package/src/contrib/rebuilt-functions.test.ts +60 -0
  21. package/src/contrib/roster.test.ts +27 -0
  22. package/src/contrib/roster.ts +38 -0
  23. package/src/decision-cache.test.ts +122 -0
  24. package/src/decision-cache.ts +104 -0
  25. package/src/decision-runtime.test.ts +154 -0
  26. package/src/engine-cache-semantics.test.ts +87 -0
  27. package/src/engine.ts +474 -0
  28. package/src/exec-context.test.ts +52 -0
  29. package/src/exec-context.ts +32 -0
  30. package/src/execution-spec.test.ts +173 -0
  31. package/src/index.ts +43 -0
  32. package/src/limiter.ts +70 -0
  33. package/src/reference.ts +34 -0
  34. package/src/register.test.ts +60 -0
  35. package/src/register.ts +515 -0
  36. package/src/roster.test.ts +96 -0
  37. package/src/roster.ts +129 -0
  38. package/src/sanitize.test.ts +65 -0
  39. package/src/udf-pack.test.ts +107 -0
  40. package/src/udf-trace.test.ts +137 -0
package/src/roster.ts ADDED
@@ -0,0 +1,129 @@
1
+ export interface Roster {
2
+ name: string;
3
+ description?: string;
4
+ items: string[];
5
+ }
6
+
7
+ /**
8
+ * 名单作用域(U5 租户化):tenantId 必填;actor 缺省 = 租户共享,指定 actor = 该用户私有。
9
+ * 可见性:自有 > 租户共享 > 不可见他人;无 actor(管理员)遍历本租户全域。跨租户永不可见。
10
+ */
11
+ export interface RosterScope {
12
+ tenantId: string;
13
+ actor?: string;
14
+ }
15
+
16
+ interface TenantStore {
17
+ /** 租户共享名单 */
18
+ shared: Map<string, Roster>;
19
+ /** actor → name → roster(用户私有名单) */
20
+ private: Map<string, Map<string, Roster>>;
21
+ }
22
+
23
+ const tenants = new Map<string, TenantStore>();
24
+
25
+ const tenantOf = (tenantId: string): TenantStore => {
26
+ let store = tenants.get(tenantId);
27
+ if (!store) {
28
+ store = { shared: new Map(), private: new Map() };
29
+ tenants.set(tenantId, store);
30
+ }
31
+ return store;
32
+ };
33
+
34
+ interface ResolvedEntry {
35
+ roster: Roster;
36
+ /** 私有名单命中时的属主 actor;共享名单无属主 */
37
+ ownerActor?: string;
38
+ }
39
+
40
+ const resolveRosterEntry = (name: string, scope: RosterScope): ResolvedEntry | null => {
41
+ const tenant = tenants.get(scope.tenantId);
42
+ if (!tenant) return null;
43
+ if (scope.actor) {
44
+ const own = tenant.private.get(scope.actor)?.get(name);
45
+ if (own) return { roster: own, ownerActor: scope.actor };
46
+ const shared = tenant.shared.get(name);
47
+ if (shared) return { roster: shared };
48
+ return null;
49
+ }
50
+ // 管理员(无 actor):租户共享优先,其次遍历本租户私有域
51
+ const shared = tenant.shared.get(name);
52
+ if (shared) return { roster: shared };
53
+ for (const [actor, map] of tenant.private) {
54
+ const hit = map.get(name);
55
+ if (hit) return { roster: hit, ownerActor: actor };
56
+ }
57
+ return null;
58
+ };
59
+
60
+ const assertScope = (scope: RosterScope): void => {
61
+ if (!scope?.tenantId) {
62
+ throw new Error('[roster] scope.tenantId is required');
63
+ }
64
+ };
65
+
66
+ export const registerRoster = (roster: Roster, scope: RosterScope): void => {
67
+ assertScope(scope);
68
+ const tenant = tenantOf(scope.tenantId);
69
+ const stored: Roster = { ...roster };
70
+ if (scope.actor) {
71
+ let own = tenant.private.get(scope.actor);
72
+ if (!own) {
73
+ own = new Map();
74
+ tenant.private.set(scope.actor, own);
75
+ }
76
+ own.set(stored.name, stored);
77
+ } else {
78
+ tenant.shared.set(stored.name, stored);
79
+ }
80
+ };
81
+
82
+ /** 解析 actor 可访问的名单:有 actor 时自有优先、租户共享次之、他人私有不可见;无 actor(管理员)共享优先、遍历本租户私有域 */
83
+ export const getRoster = (name: string, scope: RosterScope): Roster | undefined => {
84
+ assertScope(scope);
85
+ return resolveRosterEntry(name, scope)?.roster;
86
+ };
87
+
88
+ export const listRosters = (query: string | undefined, scope: RosterScope): Roster[] => {
89
+ assertScope(scope);
90
+ const tenant = tenants.get(scope.tenantId);
91
+ if (!tenant) return [];
92
+ const collected: Roster[] = [];
93
+ if (scope.actor) {
94
+ collected.push(...(tenant.private.get(scope.actor)?.values() ?? []));
95
+ collected.push(...tenant.shared.values());
96
+ } else {
97
+ collected.push(...tenant.shared.values());
98
+ for (const map of tenant.private.values()) collected.push(...map.values());
99
+ }
100
+ const q = query?.trim().toLowerCase() ?? '';
101
+ const visible = q ? collected.filter((roster) => roster.name.toLowerCase().includes(q)) : collected;
102
+ return visible.map((roster) => ({ ...roster }));
103
+ };
104
+
105
+ /** 删除名单;私有仅 owner 或本租户管理员可删,共享本租户任意调用方可删;返回是否存在且有权(便于 API 层区分 404) */
106
+ export const deleteRoster = (name: string, scope: RosterScope): boolean => {
107
+ assertScope(scope);
108
+ const entry = resolveRosterEntry(name, scope);
109
+ if (!entry) return false;
110
+ if (entry.ownerActor !== undefined && scope.actor !== undefined && scope.actor !== entry.ownerActor) {
111
+ return false;
112
+ }
113
+ const tenant = tenants.get(scope.tenantId)!;
114
+ if (entry.ownerActor !== undefined) {
115
+ return tenant.private.get(entry.ownerActor)?.delete(name) ?? false;
116
+ }
117
+ return tenant.shared.delete(name);
118
+ };
119
+
120
+ export const queryRoster = (
121
+ name: string,
122
+ value: unknown,
123
+ scope: RosterScope,
124
+ ): { hit: boolean; roster: string; value: unknown } => {
125
+ assertScope(scope);
126
+ const roster = resolveRosterEntry(name, scope)?.roster;
127
+ const hit = roster ? roster.items.some((item) => String(item) === String(value)) : false;
128
+ return { hit, roster: roster?.name ?? name, value };
129
+ };
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { DecisionRuntime } from './engine.ts';
4
+ import { runWithExecContext } from './exec-context.ts';
5
+ import { UdfRegistry } from './register.ts';
6
+
7
+ const graph = {
8
+ id: 'g-sanitize',
9
+ nodes: [
10
+ { id: 'in', type: 'inputNode', name: 'Request' },
11
+ {
12
+ id: 'c1',
13
+ type: 'customNode',
14
+ name: 'custom',
15
+ content: { kind: 'UDF', config: { expressions: [{ id: 'e1', key: 'out', value: 'boom_udf' }] } },
16
+ },
17
+ { id: 'out', type: 'outputNode', name: 'Response' },
18
+ ],
19
+ edges: [
20
+ { id: 'ed1', sourceId: 'in', targetId: 'c1', type: 'edge' },
21
+ { id: 'ed2', sourceId: 'c1', targetId: 'out', type: 'edge' },
22
+ ],
23
+ };
24
+
25
+ const evalOut = async (runtime: DecisionRuntime): Promise<string | undefined> => {
26
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}));
27
+ return (result.result as { out?: { error?: string } }).out?.error;
28
+ };
29
+
30
+ describe('V4 表达式错误脱敏(§6.7)', () => {
31
+ test('内置规则:绝对路径占位 + 敏感环境变量值替换', async () => {
32
+ process.env.ZUDF_TEST_SECRET = 'supersecret42';
33
+ const registry = new UdfRegistry();
34
+ registry.registerFunction(function boom_udf() {
35
+ throw new Error('read failed at C:\\Users\\op\\secret.txt with token supersecret42');
36
+ }, 'target');
37
+ const runtime = new DecisionRuntime({ registry });
38
+ await runWithExecContext({ tenantId: 't-1' }, () => {
39
+ runtime.createDecisionWithCacheKey('k', graph);
40
+ return Promise.resolve();
41
+ });
42
+
43
+ const error = await evalOut(runtime);
44
+ expect(error).toBeDefined();
45
+ expect(error).not.toContain('C:\\Users\\op\\secret.txt');
46
+ expect(error).not.toContain('supersecret42');
47
+ expect(error).toContain('[path]');
48
+ expect(error).toContain('[ZUDF_TEST_SECRET]');
49
+ delete process.env.ZUDF_TEST_SECRET;
50
+ });
51
+
52
+ test('可注入脱敏器替换内置规则', async () => {
53
+ const registry = new UdfRegistry();
54
+ registry.registerFunction(function boom_udf() {
55
+ throw new Error('plain message');
56
+ }, 'target');
57
+ const runtime = new DecisionRuntime({ registry, sanitizer: () => '[redacted]' });
58
+ await runWithExecContext({ tenantId: 't-1' }, () => {
59
+ runtime.createDecisionWithCacheKey('k', graph);
60
+ return Promise.resolve();
61
+ });
62
+
63
+ expect(await evalOut(runtime)).toBe('[redacted]');
64
+ });
65
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { DecisionRuntime } from './engine.ts';
4
+ import { runWithExecContext } from './exec-context.ts';
5
+ import { type UdfPack, createUdfRegistry, validatePack } from './register.ts';
6
+
7
+ const fraudPack: UdfPack = {
8
+ namespace: 'fraud',
9
+ tools: [
10
+ {
11
+ name: 'device_fingerprint_query',
12
+ description: '设备指纹查询(探针)',
13
+ parametersSchema: {
14
+ properties: { deviceId: { type: 'string', title: 'DeviceId' } },
15
+ required: ['deviceId'],
16
+ title: 'device_fingerprint_query',
17
+ type: 'object',
18
+ },
19
+ returnsSchema: { type: 'object', title: 'result', properties: {} },
20
+ fn: function deviceFingerprintQueryUdf(kwargs: Record<string, unknown>) {
21
+ return { deviceId: kwargs?.deviceId ?? null, marker: 'fraud-pack' };
22
+ },
23
+ },
24
+ ],
25
+ };
26
+
27
+ describe('UdfPack 契约(U6)', () => {
28
+ test('validatePack:合法包通过', () => {
29
+ expect(validatePack(fraudPack)).toEqual([]);
30
+ });
31
+
32
+ test('validatePack:缺 namespace / 空 tools / 缺 fn / 重名 / 缺 properties 逐项报错', () => {
33
+ const errors = validatePack({
34
+ namespace: '',
35
+ tools: [
36
+ { name: '', fn: () => null },
37
+ { name: 't1', fn: 'not-a-fn' as unknown as () => null },
38
+ {
39
+ name: 't2',
40
+ fn: () => null,
41
+ parametersSchema: { title: 'no-properties', type: 'object' } as never,
42
+ },
43
+ { name: 't2', fn: () => null },
44
+ ],
45
+ });
46
+ expect(errors.length).toBeGreaterThanOrEqual(5);
47
+ expect(errors.join('\n')).toContain('namespace is required');
48
+ expect(errors.join('\n')).toContain("tool 't1' requires a function fn");
49
+ expect(errors.join('\n')).toContain("duplicate tool name 't2'");
50
+ });
51
+
52
+ test('createUdfRegistry:pack 注册 → schema 下发 → 调用 round-trip', async () => {
53
+ const registry = createUdfRegistry({ packs: [fraudPack] });
54
+
55
+ const schema = registry.udfFunctionSchema('device_fingerprint_query');
56
+ expect(schema?.namespace).toBe('fraud');
57
+ expect(schema?.parametersSchema?.properties?.['deviceId']).toBeDefined();
58
+
59
+ const namespaces = registry.udfFunctionSchemaNamespaces();
60
+ expect(namespaces).toHaveLength(1);
61
+ expect(namespaces[0]).toMatchObject({ name: 'fraud', title: 'fraud', type: 'namespace' });
62
+ expect(namespaces[0].tools[0]).toMatchObject({ name: 'device_fingerprint_query', kind: 'fraud' });
63
+
64
+ const bound = registry.funcBindParams('device_fingerprint_query', ['dev-1']);
65
+ const result = (await registry.call('device_fingerprint_query', bound)) as { marker: string };
66
+ expect(result.marker).toBe('fraud-pack');
67
+ });
68
+
69
+ test('createUdfRegistry:非法 pack 整体失败且不产生半注册状态', () => {
70
+ const badPack: UdfPack = { namespace: 'bad', tools: [{ name: 't', fn: undefined as never }] };
71
+ expect(() => createUdfRegistry({ packs: [badPack] })).toThrow(/invalid UdfPack 'bad'/);
72
+ });
73
+
74
+ test('pack 注入的注册表 + DecisionRuntime 端到端执行', async () => {
75
+ const registry = createUdfRegistry({ packs: [fraudPack] });
76
+ const runtime = new DecisionRuntime({ registry });
77
+ const graph = {
78
+ id: 'g-pack',
79
+ nodes: [
80
+ { id: 'in', type: 'inputNode', name: 'Request' },
81
+ {
82
+ id: 'c1',
83
+ type: 'customNode',
84
+ name: 'custom',
85
+ content: {
86
+ kind: 'UDF',
87
+ config: { expressions: [{ id: 'e1', key: 'fp', value: 'device_fingerprint_query;;deviceId' }] },
88
+ },
89
+ },
90
+ { id: 'out', type: 'outputNode', name: 'Response' },
91
+ ],
92
+ edges: [
93
+ { id: 'ed1', sourceId: 'in', targetId: 'c1', type: 'edge' },
94
+ { id: 'ed2', sourceId: 'c1', targetId: 'out', type: 'edge' },
95
+ ],
96
+ };
97
+
98
+ await runWithExecContext({ tenantId: 't-1' }, () => {
99
+ runtime.createDecisionWithCacheKey('fraud-model', graph, 'v1');
100
+ return Promise.resolve();
101
+ });
102
+ const result = await runWithExecContext({ tenantId: 't-1' }, () =>
103
+ runtime.evaluateAsync('fraud-model', { deviceId: 'dev-9' }, undefined, 'v1'),
104
+ );
105
+ expect((result.result as { fp: { marker: string } }).fp.marker).toBe('fraud-pack');
106
+ });
107
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { DecisionRuntime } from './engine.ts';
4
+ import { runWithExecContext } from './exec-context.ts';
5
+ import { UdfRegistry } from './register.ts';
6
+
7
+ /** customNode 图:target_udf 返回值写入 out 字段 */
8
+ const graph = (id: string) => ({
9
+ id,
10
+ nodes: [
11
+ { id: 'in', type: 'inputNode', name: 'Request' },
12
+ {
13
+ id: 'c1',
14
+ type: 'customNode',
15
+ name: 'custom',
16
+ content: { kind: 'UDF', config: { expressions: [{ id: 'e1', key: 'out', value: 'target_udf' }] } },
17
+ },
18
+ { id: 'out', type: 'outputNode', name: 'Response' },
19
+ ],
20
+ edges: [
21
+ { id: 'ed1', sourceId: 'in', targetId: 'c1', type: 'edge' },
22
+ { id: 'ed2', sourceId: 'c1', targetId: 'out', type: 'edge' },
23
+ ],
24
+ });
25
+
26
+ const makeRegistry = (fn: (kwargs: Record<string, unknown>) => unknown): UdfRegistry => {
27
+ const registry = new UdfRegistry();
28
+ registry.registerFunction(
29
+ fn,
30
+ 'target',
31
+ {
32
+ description: 'target udf',
33
+ returnsSchema: {
34
+ type: 'object',
35
+ title: 'TargetResult',
36
+ properties: { ok: { type: 'boolean', title: 'Ok' } },
37
+ required: ['ok'],
38
+ },
39
+ },
40
+ 'target_udf',
41
+ );
42
+ return registry;
43
+ };
44
+
45
+ /** 断言 c1 节点 traceData.udf 存在并返回首条 */
46
+ const firstUdfTrace = (result: { trace?: unknown }): Record<string, unknown> | undefined => {
47
+ const trace = result.trace as Record<string, { traceData?: { udf?: Array<Record<string, unknown>> } }> | undefined;
48
+ return trace?.['c1']?.traceData?.udf?.[0];
49
+ };
50
+
51
+ describe('V2 返回值契约(resultValidation)', () => {
52
+ test('warn(缺省):违例结果原样下发,traceData 记 INVALID_RESULT', async () => {
53
+ const registry = makeRegistry(() => 'not-an-object');
54
+ const runtime = new DecisionRuntime({ registry }); // 缺省 warn
55
+ await runWithExecContext({ tenantId: 't-1' }, () => {
56
+ runtime.createDecisionWithCacheKey('k', graph('g-warn'));
57
+ return Promise.resolve();
58
+ });
59
+
60
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}, { trace: true }));
61
+ expect((result.result as { out?: unknown }).out).toBe('not-an-object'); // 行为不变
62
+ const trace = firstUdfTrace(result);
63
+ expect(trace?.code).toBe('INVALID_RESULT');
64
+ expect(JSON.stringify(trace?.issues)).toContain('result type expected object, got string');
65
+ });
66
+
67
+ test('enforce:违例结果替换为 INVALID_RESULT 结构化错误', async () => {
68
+ const registry = makeRegistry(() => 'not-an-object');
69
+ const runtime = new DecisionRuntime({ registry, resultValidation: 'enforce' });
70
+ await runWithExecContext({ tenantId: 't-1' }, () => {
71
+ runtime.createDecisionWithCacheKey('k', graph('g-enforce'));
72
+ return Promise.resolve();
73
+ });
74
+
75
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}, { trace: true }));
76
+ const out = (result.result as { out?: { error?: { code?: string } } }).out;
77
+ expect(out?.error?.code).toBe('INVALID_RESULT');
78
+ expect(firstUdfTrace(result)?.code).toBe('INVALID_RESULT');
79
+ });
80
+
81
+ test('off:不校验不记违例', async () => {
82
+ const registry = makeRegistry(() => 'not-an-object');
83
+ const runtime = new DecisionRuntime({ registry, resultValidation: 'off' });
84
+ await runWithExecContext({ tenantId: 't-1' }, () => {
85
+ runtime.createDecisionWithCacheKey('k', graph('g-off'));
86
+ return Promise.resolve();
87
+ });
88
+
89
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}, { trace: true }));
90
+ expect((result.result as { out?: unknown }).out).toBe('not-an-object');
91
+ expect(firstUdfTrace(result)?.code).toBeUndefined();
92
+ });
93
+
94
+ test('合规返回值不产生违例记录', async () => {
95
+ const registry = makeRegistry(() => ({ ok: true }));
96
+ const runtime = new DecisionRuntime({ registry });
97
+ await runWithExecContext({ tenantId: 't-1' }, () => {
98
+ runtime.createDecisionWithCacheKey('k', graph('g-ok'));
99
+ return Promise.resolve();
100
+ });
101
+
102
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}, { trace: true }));
103
+ expect(firstUdfTrace(result)?.code).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('V3 UDF 级 traceData', () => {
108
+ test('正常调用记录 key/name/micros', async () => {
109
+ const registry = makeRegistry(() => ({ ok: true }));
110
+ const runtime = new DecisionRuntime({ registry });
111
+ await runWithExecContext({ tenantId: 't-1' }, () => {
112
+ runtime.createDecisionWithCacheKey('k', graph('g-trace'));
113
+ return Promise.resolve();
114
+ });
115
+
116
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}, { trace: true }));
117
+ const trace = firstUdfTrace(result);
118
+ expect(trace?.key).toBe('out');
119
+ expect(trace?.name).toBe('target_udf');
120
+ expect(trace?.micros).toBeGreaterThanOrEqual(0);
121
+ });
122
+
123
+ test('无返回值声明的 UDF 不产生违例(returnsSchema 缺省豁免)', async () => {
124
+ const registry = new UdfRegistry();
125
+ registry.registerFunction(function target_udf() {
126
+ return 12345;
127
+ }, 'target');
128
+ const runtime = new DecisionRuntime({ registry });
129
+ await runWithExecContext({ tenantId: 't-1' }, () => {
130
+ runtime.createDecisionWithCacheKey('k', graph('g-noschema'));
131
+ return Promise.resolve();
132
+ });
133
+
134
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', {}, { trace: true }));
135
+ expect(firstUdfTrace(result)?.code).toBeUndefined();
136
+ });
137
+ });